1
This commit is contained in:
@@ -18,6 +18,7 @@ use module_assets::{
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
};
|
||||
use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
|
||||
use platform_llm::{LlmMessage, LlmTextRequest};
|
||||
use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
@@ -76,6 +77,7 @@ use crate::{
|
||||
},
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
llm_model_routing::CREATION_TEMPLATE_LLM_MODEL,
|
||||
platform_errors::map_oss_error,
|
||||
prompt::puzzle::{
|
||||
draft::{
|
||||
@@ -83,6 +85,10 @@ use crate::{
|
||||
resolve_puzzle_draft_cover_prompt, resolve_puzzle_level_image_prompt,
|
||||
},
|
||||
image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
|
||||
level_name::{
|
||||
PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT, build_puzzle_first_level_name_user_prompt,
|
||||
},
|
||||
tags::{PUZZLE_TAG_GENERATION_SYSTEM_PROMPT, build_puzzle_tag_generation_user_prompt},
|
||||
},
|
||||
puzzle_agent_turn::{
|
||||
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
|
||||
@@ -527,15 +533,15 @@ pub async fn execute_puzzle_agent_action(
|
||||
});
|
||||
(
|
||||
"compile_puzzle_draft",
|
||||
"完整拼图草稿",
|
||||
"已编译草稿、生成拼图图片并应用为正式图。",
|
||||
"首关拼图草稿",
|
||||
"已编译首关草稿、生成首关画面并写入正式草稿。",
|
||||
session,
|
||||
)
|
||||
}
|
||||
"save_puzzle_form_draft" => {
|
||||
let seed_text = build_puzzle_form_seed_text_from_parts(
|
||||
payload.work_title.as_deref(),
|
||||
payload.work_description.as_deref(),
|
||||
None,
|
||||
None,
|
||||
payload
|
||||
.picture_description
|
||||
.as_deref()
|
||||
@@ -705,6 +711,66 @@ pub async fn execute_puzzle_agent_action(
|
||||
session,
|
||||
)
|
||||
}
|
||||
"generate_puzzle_tags" => {
|
||||
let work_title = payload
|
||||
.work_title
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
puzzle_bad_request(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"作品名称不能为空",
|
||||
)
|
||||
})?;
|
||||
let work_description = payload
|
||||
.work_description
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
puzzle_bad_request(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"作品描述不能为空",
|
||||
)
|
||||
})?;
|
||||
let levels_json = normalize_puzzle_levels_json_for_module(
|
||||
payload.levels_json.as_deref(),
|
||||
)
|
||||
.map_err(|message| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let generated_tags =
|
||||
generate_puzzle_work_tags(&state, work_title, work_description).await;
|
||||
let session = save_generated_puzzle_tags_to_session(
|
||||
&state,
|
||||
&session_id,
|
||||
&owner_user_id,
|
||||
&payload,
|
||||
generated_tags,
|
||||
levels_json,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||
});
|
||||
(
|
||||
"generate_puzzle_tags",
|
||||
"作品标签生成",
|
||||
"已生成 6 个作品标签。",
|
||||
session,
|
||||
)
|
||||
}
|
||||
"select_puzzle_image" => {
|
||||
let candidate_id = payload
|
||||
.candidate_id
|
||||
@@ -2058,12 +2124,12 @@ fn build_puzzle_welcome_text(seed_text: &str) -> String {
|
||||
|
||||
fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String {
|
||||
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||||
title: payload
|
||||
.work_title
|
||||
title: None,
|
||||
work_description: None,
|
||||
picture_description: payload
|
||||
.picture_description
|
||||
.as_deref()
|
||||
.or(payload.seed_text.as_deref()),
|
||||
work_description: payload.work_description.as_deref(),
|
||||
picture_description: payload.picture_description.as_deref(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2088,8 +2154,8 @@ async fn save_puzzle_form_payload_before_compile(
|
||||
now: i64,
|
||||
) -> Result<String, Response> {
|
||||
let seed_text = build_puzzle_form_seed_text_from_parts(
|
||||
payload.work_title.as_deref(),
|
||||
payload.work_description.as_deref(),
|
||||
None,
|
||||
None,
|
||||
payload
|
||||
.picture_description
|
||||
.as_deref()
|
||||
@@ -2486,6 +2552,176 @@ fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
|
||||
)
|
||||
}
|
||||
|
||||
async fn generate_puzzle_first_level_name(state: &AppState, picture_description: &str) -> String {
|
||||
if let Some(llm_client) = state.llm_client() {
|
||||
let user_prompt = build_puzzle_first_level_name_user_prompt(picture_description);
|
||||
let response = llm_client
|
||||
.request_text(
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT),
|
||||
LlmMessage::user(user_prompt),
|
||||
])
|
||||
.with_model(CREATION_TEMPLATE_LLM_MODEL)
|
||||
.with_responses_api(),
|
||||
)
|
||||
.await;
|
||||
match response {
|
||||
Ok(response) => {
|
||||
if let Some(level_name) =
|
||||
parse_puzzle_first_level_name_from_text(response.content.as_str())
|
||||
{
|
||||
return level_name;
|
||||
}
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
picture_chars = picture_description.chars().count(),
|
||||
"拼图首关名模型返回非法,降级使用关键词名"
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
picture_chars = picture_description.chars().count(),
|
||||
error = %error,
|
||||
"拼图首关名生成失败,降级使用关键词名"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
build_fallback_puzzle_first_level_name(picture_description)
|
||||
}
|
||||
|
||||
fn parse_puzzle_first_level_name_from_text(text: &str) -> Option<String> {
|
||||
let trimmed = text.trim();
|
||||
let json_text = if let Some(start) = trimmed.find('{')
|
||||
&& let Some(end) = trimmed.rfind('}')
|
||||
&& end > start
|
||||
{
|
||||
&trimmed[start..=end]
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
let parsed = serde_json::from_str::<Value>(json_text).ok();
|
||||
let raw_name = parsed
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("levelName").and_then(Value::as_str))
|
||||
.or_else(|| {
|
||||
parsed
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("level_name").and_then(Value::as_str))
|
||||
})
|
||||
.unwrap_or(trimmed);
|
||||
normalize_puzzle_first_level_name(raw_name)
|
||||
}
|
||||
|
||||
fn normalize_puzzle_first_level_name(value: &str) -> Option<String> {
|
||||
let normalized = value
|
||||
.trim()
|
||||
.trim_matches(|ch: char| {
|
||||
ch.is_ascii_punctuation()
|
||||
|| matches!(
|
||||
ch,
|
||||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||||
)
|
||||
})
|
||||
.trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ')'))
|
||||
.chars()
|
||||
.filter(|ch| {
|
||||
!matches!(
|
||||
ch,
|
||||
'#' | '"'
|
||||
| '\''
|
||||
| '`'
|
||||
| ' '
|
||||
| '\t'
|
||||
| '\r'
|
||||
| '\n'
|
||||
| ','
|
||||
| '。'
|
||||
| '、'
|
||||
| ';'
|
||||
| ':'
|
||||
| '!'
|
||||
| '?'
|
||||
| '“'
|
||||
| '”'
|
||||
| '《'
|
||||
| '》'
|
||||
)
|
||||
})
|
||||
.take(12)
|
||||
.collect::<String>();
|
||||
let normalized = strip_puzzle_level_name_generic_words(normalized);
|
||||
if normalized.chars().count() >= 2
|
||||
&& !matches!(
|
||||
normalized.as_str(),
|
||||
"第一关" | "画面" | "拼图" | "作品" | "关卡"
|
||||
)
|
||||
{
|
||||
Some(normalized)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_puzzle_level_name_generic_words(mut value: String) -> String {
|
||||
for prefix in ["第一关", "关卡名", "关卡"] {
|
||||
value = value.trim_start_matches(prefix).to_string();
|
||||
}
|
||||
for suffix in ["第一关", "关卡名", "关卡", "画面", "拼图", "作品"] {
|
||||
value = value.trim_end_matches(suffix).to_string();
|
||||
}
|
||||
value.chars().take(8).collect()
|
||||
}
|
||||
|
||||
fn build_fallback_puzzle_first_level_name(picture_description: &str) -> String {
|
||||
let source = picture_description.trim();
|
||||
if source.contains("猫") && (source.contains("雨夜") || source.contains('雨')) {
|
||||
return "雨夜猫街".to_string();
|
||||
}
|
||||
if source.contains("猫") && source.contains('灯') {
|
||||
return "暖灯猫街".to_string();
|
||||
}
|
||||
for (keyword, level_name) in [
|
||||
("雨夜", "雨夜灯街"),
|
||||
("猫", "暖灯猫街"),
|
||||
("狗", "花园小狗"),
|
||||
("神庙", "神庙遗光"),
|
||||
("遗迹", "遗迹谜光"),
|
||||
("森林", "森林秘境"),
|
||||
("城市", "霓虹城市"),
|
||||
("机械", "机械迷城"),
|
||||
("蒸汽", "蒸汽街区"),
|
||||
("海", "海岸微光"),
|
||||
("花", "花园晨光"),
|
||||
("雪", "雪境小径"),
|
||||
("龙", "龙影高塔"),
|
||||
("灯", "暖灯街角"),
|
||||
("塔", "塔顶星光"),
|
||||
] {
|
||||
if source.contains(keyword) {
|
||||
return level_name.to_string();
|
||||
}
|
||||
}
|
||||
"奇境初见".to_string()
|
||||
}
|
||||
|
||||
fn build_puzzle_levels_with_primary_name(
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
) -> Vec<PuzzleDraftLevelRecord> {
|
||||
let mut levels = draft.levels.clone();
|
||||
if let Some(index) = levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == target_level.level_id)
|
||||
.or_else(|| (!levels.is_empty()).then_some(0))
|
||||
{
|
||||
levels[index].level_name = target_level.level_name.clone();
|
||||
}
|
||||
levels
|
||||
}
|
||||
|
||||
async fn compile_puzzle_draft_with_initial_cover(
|
||||
state: &AppState,
|
||||
session_id: String,
|
||||
@@ -2506,7 +2742,14 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
"message": "拼图结果页草稿尚未生成",
|
||||
}))
|
||||
})?;
|
||||
let target_level = select_puzzle_level_for_api(&draft, None)?;
|
||||
let mut target_level = select_puzzle_level_for_api(&draft, None)?;
|
||||
let fallback_level_name = target_level.level_name.clone();
|
||||
let generated_level_name =
|
||||
generate_puzzle_first_level_name(state, &target_level.picture_description).await;
|
||||
target_level.level_name = generated_level_name.clone();
|
||||
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
|
||||
&build_puzzle_levels_with_primary_name(&draft, &target_level),
|
||||
)?);
|
||||
let image_prompt = resolve_puzzle_draft_cover_prompt(
|
||||
prompt_text,
|
||||
&target_level.picture_description,
|
||||
@@ -2554,7 +2797,7 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
session_id: compiled_session.session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
level_id: Some(target_level.level_id.clone()),
|
||||
levels_json: None,
|
||||
levels_json: levels_json_with_generated_name,
|
||||
candidates_json,
|
||||
saved_at_micros: current_utc_micros(),
|
||||
})
|
||||
@@ -2572,7 +2815,13 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
"拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
|
||||
);
|
||||
let session = apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
compiled_session.clone(),
|
||||
apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
compiled_session.clone(),
|
||||
target_level.level_id.as_str(),
|
||||
generated_level_name.as_str(),
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
),
|
||||
target_level.level_id.as_str(),
|
||||
candidates.clone(),
|
||||
now,
|
||||
@@ -2655,6 +2904,39 @@ fn apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
session
|
||||
}
|
||||
|
||||
fn apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
target_level_id: &str,
|
||||
level_name: &str,
|
||||
previous_level_name: &str,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
let normalized_name = level_name.trim();
|
||||
if normalized_name.is_empty() {
|
||||
return session;
|
||||
}
|
||||
let Some(target_index) = draft
|
||||
.levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == target_level_id)
|
||||
.or_else(|| (!draft.levels.is_empty()).then_some(0))
|
||||
else {
|
||||
return session;
|
||||
};
|
||||
draft.levels[target_index].level_name = normalized_name.to_string();
|
||||
let should_default_work_title =
|
||||
draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim();
|
||||
if target_index == 0 && should_default_work_title {
|
||||
draft.work_title = normalized_name.to_string();
|
||||
}
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftRecord) {
|
||||
let Some(primary_level) = draft.levels.first() else {
|
||||
return;
|
||||
@@ -2677,6 +2959,305 @@ fn replace_puzzle_session_draft_snapshot(
|
||||
session
|
||||
}
|
||||
|
||||
async fn generate_puzzle_work_tags(
|
||||
state: &AppState,
|
||||
work_title: &str,
|
||||
work_description: &str,
|
||||
) -> Vec<String> {
|
||||
if let Some(llm_client) = state.llm_client() {
|
||||
let user_prompt = build_puzzle_tag_generation_user_prompt(work_title, work_description);
|
||||
let response = llm_client
|
||||
.request_text(
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system(PUZZLE_TAG_GENERATION_SYSTEM_PROMPT),
|
||||
LlmMessage::user(user_prompt),
|
||||
])
|
||||
.with_model(CREATION_TEMPLATE_LLM_MODEL)
|
||||
.with_responses_api(),
|
||||
)
|
||||
.await;
|
||||
match response {
|
||||
Ok(response) => {
|
||||
let tags = normalize_puzzle_tag_candidates(parse_puzzle_tags_from_text(
|
||||
response.content.as_str(),
|
||||
));
|
||||
if tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||
return tags;
|
||||
}
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
work_title,
|
||||
"拼图 AI 标签数量不足,降级使用关键词补齐"
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
work_title,
|
||||
error = %error,
|
||||
"拼图 AI 标签生成失败,降级使用关键词标签"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalize_puzzle_tag_candidates(build_fallback_puzzle_tags(work_title, work_description))
|
||||
}
|
||||
|
||||
fn parse_puzzle_tags_from_text(text: &str) -> Vec<String> {
|
||||
let trimmed = text.trim();
|
||||
let json_text = if let Some(start) = trimmed.find('{')
|
||||
&& let Some(end) = trimmed.rfind('}')
|
||||
&& end > start
|
||||
{
|
||||
&trimmed[start..=end]
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
let Ok(value) = serde_json::from_str::<Value>(json_text) else {
|
||||
return normalize_puzzle_tag_candidates(trimmed.split([',', ',', '、', '\n']));
|
||||
};
|
||||
let Some(tags) = value.get("tags").and_then(Value::as_array) else {
|
||||
return Vec::new();
|
||||
};
|
||||
normalize_puzzle_tag_candidates(tags.iter().filter_map(Value::as_str))
|
||||
}
|
||||
|
||||
fn normalize_puzzle_tag_candidates<S>(candidates: impl IntoIterator<Item = S>) -> Vec<String>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut tags = Vec::new();
|
||||
for candidate in candidates {
|
||||
let normalized = normalize_puzzle_tag(candidate.as_ref());
|
||||
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
|
||||
continue;
|
||||
}
|
||||
tags.push(normalized);
|
||||
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for fallback in ["拼图", "插画", "清晰构图", "奇幻", "场景", "氛围"] {
|
||||
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||
break;
|
||||
}
|
||||
if !tags.iter().any(|tag| tag == fallback) {
|
||||
tags.push(fallback.to_string());
|
||||
}
|
||||
}
|
||||
tags
|
||||
}
|
||||
|
||||
fn normalize_puzzle_tag(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.trim_matches(|ch: char| {
|
||||
ch.is_ascii_punctuation()
|
||||
|| matches!(
|
||||
ch,
|
||||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||||
)
|
||||
})
|
||||
.trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ')'))
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`'))
|
||||
.take(6)
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
fn build_fallback_puzzle_tags(work_title: &str, work_description: &str) -> Vec<&'static str> {
|
||||
let source = format!("{work_title} {work_description}");
|
||||
let mut tags = Vec::new();
|
||||
for (keyword, tag) in [
|
||||
("猫", "猫咪"),
|
||||
("狗", "小狗"),
|
||||
("神庙", "神庙遗迹"),
|
||||
("遗迹", "神庙遗迹"),
|
||||
("森林", "童话森林"),
|
||||
("雨", "雨夜"),
|
||||
("夜", "夜景"),
|
||||
("城市", "城市奇景"),
|
||||
("蒸汽", "蒸汽城市"),
|
||||
("机械", "机械幻想"),
|
||||
("海", "海岸"),
|
||||
("花", "花园"),
|
||||
("雪", "雪景"),
|
||||
("龙", "幻想生物"),
|
||||
("灯", "暖灯"),
|
||||
("塔", "高塔"),
|
||||
] {
|
||||
if source.contains(keyword) && !tags.contains(&tag) {
|
||||
tags.push(tag);
|
||||
}
|
||||
}
|
||||
tags.extend(["拼图", "插画", "清晰构图", "奇幻", "场景", "氛围"]);
|
||||
tags
|
||||
}
|
||||
|
||||
async fn save_generated_puzzle_tags_to_session(
|
||||
state: &AppState,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
payload: &ExecutePuzzleAgentActionRequest,
|
||||
generated_tags: Vec<String>,
|
||||
levels_json: Option<String>,
|
||||
now: i64,
|
||||
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string())
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
let draft = session.draft.clone().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图结果页草稿尚未生成",
|
||||
}))
|
||||
})?;
|
||||
let mut levels = if let Some(levels_json) = levels_json.as_deref() {
|
||||
parse_puzzle_level_records_from_module_json(levels_json)?
|
||||
} else {
|
||||
draft.levels.clone()
|
||||
};
|
||||
if levels.is_empty() {
|
||||
levels = draft.levels.clone();
|
||||
}
|
||||
let first_level = levels.first().cloned().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图草稿缺少可编辑关卡",
|
||||
}))
|
||||
})?;
|
||||
let work_title = payload
|
||||
.work_title
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(draft.work_title.as_str())
|
||||
.to_string();
|
||||
let work_description = payload
|
||||
.work_description
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(draft.work_description.as_str())
|
||||
.to_string();
|
||||
let levels_json = Some(serialize_puzzle_level_records_for_module(&levels)?);
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(session_id);
|
||||
state
|
||||
.spacetime_client()
|
||||
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
|
||||
profile_id,
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
work_title: work_title.clone(),
|
||||
work_description: work_description.clone(),
|
||||
level_name: first_level.level_name.clone(),
|
||||
summary: work_description.clone(),
|
||||
theme_tags: generated_tags.clone(),
|
||||
cover_image_src: first_level.cover_image_src.clone(),
|
||||
cover_asset_id: first_level.cover_asset_id.clone(),
|
||||
levels_json,
|
||||
updated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
|
||||
Ok(apply_generated_puzzle_tags_to_session_snapshot(
|
||||
session,
|
||||
generated_tags,
|
||||
work_title,
|
||||
work_description,
|
||||
levels,
|
||||
now,
|
||||
))
|
||||
}
|
||||
|
||||
fn apply_generated_puzzle_tags_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
generated_tags: Vec<String>,
|
||||
work_title: String,
|
||||
work_description: String,
|
||||
levels: Vec<PuzzleDraftLevelRecord>,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
draft.work_title = work_title;
|
||||
draft.work_description = work_description.clone();
|
||||
draft.summary = work_description;
|
||||
draft.theme_tags = generated_tags;
|
||||
draft.levels = levels;
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
session.progress_percent = session.progress_percent.max(96);
|
||||
session.stage = if is_puzzle_session_snapshot_publish_ready(draft) {
|
||||
"ready_to_publish".to_string()
|
||||
} else {
|
||||
"image_refining".to_string()
|
||||
};
|
||||
session.last_assistant_reply = Some("作品标签已生成。".to_string());
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraftRecord) -> bool {
|
||||
!draft.work_title.trim().is_empty()
|
||||
&& !draft.work_description.trim().is_empty()
|
||||
&& draft.theme_tags.len() >= module_puzzle::PUZZLE_MIN_TAG_COUNT
|
||||
&& draft.theme_tags.len() <= module_puzzle::PUZZLE_MAX_TAG_COUNT
|
||||
&& !draft.levels.is_empty()
|
||||
&& draft.levels.iter().all(|level| {
|
||||
!level.level_name.trim().is_empty()
|
||||
&& level
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn serialize_puzzle_level_records_for_module(
|
||||
levels: &[PuzzleDraftLevelRecord],
|
||||
) -> Result<String, AppError> {
|
||||
let payload = levels
|
||||
.iter()
|
||||
.map(|level| {
|
||||
json!({
|
||||
"level_id": level.level_id,
|
||||
"level_name": level.level_name,
|
||||
"picture_description": level.picture_description,
|
||||
"candidates": level
|
||||
.candidates
|
||||
.iter()
|
||||
.map(|candidate| {
|
||||
json!({
|
||||
"candidate_id": candidate.candidate_id,
|
||||
"image_src": candidate.image_src,
|
||||
"asset_id": candidate.asset_id,
|
||||
"prompt": candidate.prompt,
|
||||
"actual_prompt": candidate.actual_prompt,
|
||||
"source_type": candidate.source_type,
|
||||
"selected": candidate.selected,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
"selected_candidate_id": level.selected_candidate_id,
|
||||
"cover_image_src": level.cover_image_src,
|
||||
"cover_asset_id": level.cover_asset_id,
|
||||
"generation_status": level.generation_status,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::to_string(&payload).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图关卡列表序列化失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_spacetimedb_connectivity_app_error(error: &AppError) -> bool {
|
||||
matches!(
|
||||
error.status_code(),
|
||||
@@ -3069,6 +3650,84 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() {
|
||||
assert_eq!(
|
||||
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街"}"#),
|
||||
Some("雨夜猫街".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_puzzle_first_level_name_from_text("1. 《暖灯猫街》"),
|
||||
Some("暖灯猫街".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#),
|
||||
Some("雨夜猫街".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_first_level_name_fallback_uses_picture_keywords() {
|
||||
assert_eq!(
|
||||
build_fallback_puzzle_first_level_name("一只猫在雨夜灯牌下回头。"),
|
||||
"雨夜猫街"
|
||||
);
|
||||
assert_eq!(
|
||||
build_fallback_puzzle_first_level_name("看不出关键词的抽象色块。"),
|
||||
"奇境初见"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_first_level_name_snapshot_defaults_work_title() {
|
||||
let levels_json = serde_json::to_string(&vec![json!({
|
||||
"level_id": "puzzle-level-1",
|
||||
"level_name": "猫画面",
|
||||
"picture_description": "一只猫在雨夜灯牌下回头。",
|
||||
"candidates": [],
|
||||
"selected_candidate_id": null,
|
||||
"cover_image_src": null,
|
||||
"cover_asset_id": null,
|
||||
"generation_status": "idle",
|
||||
})])
|
||||
.expect("levels json");
|
||||
let payload = ExecutePuzzleAgentActionRequest {
|
||||
action: "generate_puzzle_images".to_string(),
|
||||
prompt_text: None,
|
||||
reference_image_src: None,
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
candidate_count: Some(1),
|
||||
candidate_id: None,
|
||||
level_id: Some("puzzle-level-1".to_string()),
|
||||
work_title: Some("猫画面".to_string()),
|
||||
work_description: None,
|
||||
picture_description: None,
|
||||
level_name: None,
|
||||
summary: None,
|
||||
theme_tags: Some(vec![]),
|
||||
levels_json: Some(levels_json.clone()),
|
||||
};
|
||||
let session = build_puzzle_session_snapshot_from_action_payload(
|
||||
"puzzle-session-1",
|
||||
&payload,
|
||||
Some(levels_json.as_str()),
|
||||
1_713_686_401_234_567,
|
||||
)
|
||||
.expect("fallback session");
|
||||
|
||||
let renamed = apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
session,
|
||||
"puzzle-level-1",
|
||||
"雨夜猫街",
|
||||
"猫画面",
|
||||
1_713_686_401_234_568,
|
||||
);
|
||||
let draft = renamed.draft.expect("draft");
|
||||
assert_eq!(draft.level_name, "雨夜猫街");
|
||||
assert_eq!(draft.work_title, "雨夜猫街");
|
||||
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
|
||||
let invalid_operation =
|
||||
|
||||
Reference in New Issue
Block a user