1
This commit is contained in:
@@ -28,9 +28,10 @@ use shared_contracts::runtime::{
|
||||
use shared_kernel::build_prefixed_uuid_id;
|
||||
use spacetime_client::{
|
||||
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord,
|
||||
CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput,
|
||||
CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
|
||||
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
|
||||
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput,
|
||||
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
|
||||
CustomWorldAgentSessionRecord,
|
||||
CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord,
|
||||
CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
|
||||
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
|
||||
@@ -38,9 +39,13 @@ use spacetime_client::{
|
||||
CustomWorldWorkSummaryRecord, SpacetimeClientError,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
use tokio::task::JoinSet;
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
ai_generation_drafts::{
|
||||
AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter,
|
||||
},
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
character_visual_assets::generate_character_primary_visual_for_profile,
|
||||
@@ -59,6 +64,8 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3;
|
||||
|
||||
pub async fn get_custom_world_library(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -508,7 +515,7 @@ pub async fn get_custom_world_works(
|
||||
|
||||
pub async fn delete_custom_world_agent_session(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(session_id): AxumPath<String>,
|
||||
Path(session_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
@@ -516,10 +523,7 @@ pub async fn delete_custom_world_agent_session(
|
||||
|
||||
let items = state
|
||||
.spacetime_client()
|
||||
.delete_custom_world_agent_session(
|
||||
session_id,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
)
|
||||
.delete_custom_world_agent_session(session_id, authenticated.claims().user_id().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
||||
@@ -636,6 +640,26 @@ pub async fn submit_custom_world_agent_message(
|
||||
.map_err(|error| {
|
||||
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
||||
})?;
|
||||
let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new(
|
||||
"custom_world",
|
||||
owner_user_id.as_str(),
|
||||
session_id.as_str(),
|
||||
operation_id.as_str(),
|
||||
"自定义世界模板生成草稿",
|
||||
));
|
||||
if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await {
|
||||
tracing::warn!(error = %error, "自定义世界模板生成草稿任务启动失败,主生成流程继续执行");
|
||||
}
|
||||
let draft_sink = AiGenerationDraftSink::new(
|
||||
AiGenerationDraftContext::new(
|
||||
"custom_world",
|
||||
owner_user_id.as_str(),
|
||||
session_id.as_str(),
|
||||
operation_id.as_str(),
|
||||
"自定义世界模板生成草稿",
|
||||
),
|
||||
state.spacetime_client().clone(),
|
||||
);
|
||||
let turn_result = run_custom_world_agent_turn(
|
||||
CustomWorldAgentTurnRequest {
|
||||
llm_client: state.llm_client(),
|
||||
@@ -643,9 +667,19 @@ pub async fn submit_custom_world_agent_message(
|
||||
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
|
||||
focus_card_id: payload.focus_card_id.clone(),
|
||||
},
|
||||
|_| {},
|
||||
move |text| {
|
||||
draft_sink.persist_visible_text_async(text);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if let Ok(result) = &turn_result {
|
||||
draft_writer
|
||||
.persist_visible_text(
|
||||
state.spacetime_client(),
|
||||
result.assistant_reply_text.as_str(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let finalize_input = match turn_result {
|
||||
Ok(turn_result) => build_finalize_record_input(
|
||||
session_id.clone(),
|
||||
@@ -764,6 +798,26 @@ pub async fn stream_custom_world_agent_message(
|
||||
let owner_user_id_for_stream = owner_user_id.clone();
|
||||
let operation_id = operation.operation_id.clone();
|
||||
let stream = async_stream::stream! {
|
||||
let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new(
|
||||
"custom_world",
|
||||
owner_user_id_for_stream.as_str(),
|
||||
session_id_for_stream.as_str(),
|
||||
operation_id.as_str(),
|
||||
"自定义世界模板生成草稿",
|
||||
));
|
||||
if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await {
|
||||
tracing::warn!(error = %error, "自定义世界模板生成草稿任务启动失败,主生成流程继续执行");
|
||||
}
|
||||
let draft_sink = AiGenerationDraftSink::new(
|
||||
AiGenerationDraftContext::new(
|
||||
"custom_world",
|
||||
owner_user_id_for_stream.as_str(),
|
||||
session_id_for_stream.as_str(),
|
||||
operation_id.as_str(),
|
||||
"自定义世界模板生成草稿",
|
||||
),
|
||||
state.spacetime_client().clone(),
|
||||
);
|
||||
// 聊天回复必须等本轮模型解析、进度与会话快照全部落库后,
|
||||
// 再随最终 session 一次性返回,避免玩家先看到回复而进度仍停在旧状态。
|
||||
let turn_result = run_custom_world_agent_turn(
|
||||
@@ -773,9 +827,16 @@ pub async fn stream_custom_world_agent_message(
|
||||
quick_fill_requested,
|
||||
focus_card_id,
|
||||
},
|
||||
|_| {},
|
||||
move |text| {
|
||||
draft_sink.persist_visible_text_async(text);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if let Ok(result) = &turn_result {
|
||||
draft_writer
|
||||
.persist_visible_text(state.spacetime_client(), result.assistant_reply_text.as_str())
|
||||
.await;
|
||||
}
|
||||
|
||||
let finalize_input = match turn_result {
|
||||
Ok(turn_result) => build_finalize_record_input(
|
||||
@@ -1121,7 +1182,7 @@ fn spawn_custom_world_draft_foundation_job(
|
||||
"底稿生成失败",
|
||||
message.clone().as_str(),
|
||||
100,
|
||||
Some(message),
|
||||
Some(message.clone()),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
@@ -1142,7 +1203,7 @@ fn spawn_custom_world_draft_foundation_job(
|
||||
"底稿素材生成失败",
|
||||
message.as_str(),
|
||||
100,
|
||||
Some(message),
|
||||
Some(message.clone()),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
@@ -1158,56 +1219,73 @@ fn spawn_custom_world_draft_foundation_job(
|
||||
"底稿素材生成失败",
|
||||
message.as_str(),
|
||||
100,
|
||||
Some(message),
|
||||
Some(message.clone()),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(message) = generate_draft_foundation_role_visuals(
|
||||
&state,
|
||||
&session,
|
||||
&owner_user_id,
|
||||
&operation_id,
|
||||
&mut draft_profile_value,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = upsert_custom_world_draft_foundation_progress(
|
||||
&state,
|
||||
&session.session_id,
|
||||
&owner_user_id,
|
||||
&operation_id,
|
||||
"failed",
|
||||
"生成角色主形象失败",
|
||||
message.as_str(),
|
||||
100,
|
||||
Some(message),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let role_visual_profile_input = draft_profile_value.clone();
|
||||
let act_background_profile_input = draft_profile_value.clone();
|
||||
// 角色主形象与幕背景图互不依赖,必须并行发起,避免底稿阶段串行等待两类图片。
|
||||
let (role_visual_result, act_background_result) = tokio::join!(
|
||||
async {
|
||||
let mut profile = role_visual_profile_input;
|
||||
generate_draft_foundation_role_visuals(
|
||||
&state,
|
||||
&session,
|
||||
&owner_user_id,
|
||||
&operation_id,
|
||||
&mut profile,
|
||||
)
|
||||
.await
|
||||
.map(|_| profile)
|
||||
},
|
||||
async {
|
||||
let mut profile = act_background_profile_input;
|
||||
generate_draft_foundation_act_backgrounds(
|
||||
&state,
|
||||
&session,
|
||||
&owner_user_id,
|
||||
&operation_id,
|
||||
&mut profile,
|
||||
)
|
||||
.await
|
||||
.map(|_| profile)
|
||||
}
|
||||
);
|
||||
|
||||
if let Err(message) = generate_draft_foundation_act_backgrounds(
|
||||
&state,
|
||||
&session,
|
||||
&owner_user_id,
|
||||
&operation_id,
|
||||
&mut draft_profile_value,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = upsert_custom_world_draft_foundation_progress(
|
||||
let mut draft_profile_with_assets = draft_profile_value.clone();
|
||||
let mut asset_generation_errors = Vec::new();
|
||||
match role_visual_result {
|
||||
Ok(profile) => draft_profile_with_assets = profile,
|
||||
Err(message) => asset_generation_errors.push(("生成角色主形象失败", message)),
|
||||
}
|
||||
match act_background_result {
|
||||
Ok(profile) => merge_generated_act_backgrounds(&mut draft_profile_with_assets, &profile),
|
||||
Err(message) => asset_generation_errors.push(("生成幕背景图失败", message)),
|
||||
}
|
||||
draft_profile_value = draft_profile_with_assets;
|
||||
|
||||
if !asset_generation_errors.is_empty() {
|
||||
let message = asset_generation_errors
|
||||
.iter()
|
||||
.map(|(_, message)| message.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
let phase_label = asset_generation_errors
|
||||
.first()
|
||||
.map(|(label, _)| *label)
|
||||
.unwrap_or("素材生成失败");
|
||||
persist_partial_draft_foundation_after_asset_failure(
|
||||
&state,
|
||||
&session.session_id,
|
||||
&session,
|
||||
&owner_user_id,
|
||||
&operation_id,
|
||||
"failed",
|
||||
"生成幕背景图失败",
|
||||
&draft_profile_value,
|
||||
phase_label,
|
||||
message.as_str(),
|
||||
100,
|
||||
Some(message),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
@@ -1226,7 +1304,7 @@ fn spawn_custom_world_draft_foundation_job(
|
||||
"底稿素材写回失败",
|
||||
message.as_str(),
|
||||
100,
|
||||
Some(message),
|
||||
Some(message.clone()),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
@@ -1320,8 +1398,8 @@ async fn generate_draft_foundation_role_visuals(
|
||||
}
|
||||
}
|
||||
}
|
||||
let total = role_refs.len().max(1);
|
||||
for (completed, (key, index)) in role_refs.into_iter().enumerate() {
|
||||
let mut role_generation_refs = Vec::new();
|
||||
for (key, index) in role_refs {
|
||||
let role = profile_object
|
||||
.get(key.as_str())
|
||||
.and_then(Value::as_array)
|
||||
@@ -1331,31 +1409,82 @@ async fn generate_draft_foundation_role_visuals(
|
||||
let name =
|
||||
json_text_from_value(&role, "name").unwrap_or_else(|| format!("角色{}", index + 1));
|
||||
let role_id = json_text_from_value(&role, "id").unwrap_or_else(|| format!("{key}-{index}"));
|
||||
let visual_prompt = json_text_from_value(&role, "visualDescription")
|
||||
.or_else(|| json_text_from_value(&role, "description"))
|
||||
.unwrap_or_else(|| name.clone());
|
||||
upsert_custom_world_draft_foundation_progress(
|
||||
state,
|
||||
&session.session_id,
|
||||
owner_user_id,
|
||||
operation_id,
|
||||
"running",
|
||||
"生成角色主形象",
|
||||
format!("正在生成角色主形象 {}/{}:{}。", completed + 1, total, name).as_str(),
|
||||
97 + ((completed as u32).min(1)),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let generated = generate_character_primary_visual_for_profile(
|
||||
state,
|
||||
owner_user_id,
|
||||
role_id.as_str(),
|
||||
visual_prompt.as_str(),
|
||||
Some(name.as_str()),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.message().to_string())?;
|
||||
let visual_prompt = json_text_from_value(&role, "visualDescription").ok_or_else(|| {
|
||||
format!("角色「{name}」缺少 visualDescription,不能在角色形象设定文本生成前直接生图。")
|
||||
})?;
|
||||
role_generation_refs.push(RoleVisualGenerationRef {
|
||||
key,
|
||||
index,
|
||||
role_id,
|
||||
name,
|
||||
prompt: visual_prompt,
|
||||
});
|
||||
}
|
||||
|
||||
upsert_custom_world_draft_foundation_progress(
|
||||
state,
|
||||
&session.session_id,
|
||||
owner_user_id,
|
||||
operation_id,
|
||||
"running",
|
||||
"并行生成角色主形象",
|
||||
format!("正在同时生成 {} 张角色主形象。", role_generation_refs.len()).as_str(),
|
||||
97,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let mut generation_tasks = JoinSet::new();
|
||||
for role_ref in role_generation_refs {
|
||||
let task_state = (*state).clone();
|
||||
let task_owner_user_id = owner_user_id.to_string();
|
||||
generation_tasks.spawn(async move {
|
||||
let mut last_error = None;
|
||||
for attempt in 1..=DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
|
||||
match generate_character_primary_visual_for_profile(
|
||||
&task_state,
|
||||
task_owner_user_id.as_str(),
|
||||
role_ref.role_id.as_str(),
|
||||
role_ref.prompt.as_str(),
|
||||
Some(role_ref.name.as_str()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(generated) => {
|
||||
return Ok::<_, String>((role_ref.key, role_ref.index, generated));
|
||||
}
|
||||
Err(error) => {
|
||||
last_error = Some(error.message().to_string());
|
||||
if attempt < DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
300 * u64::from(attempt),
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"角色「{}」主形象连续生成 {} 次失败:{}",
|
||||
role_ref.name,
|
||||
DRAFT_ASSET_GENERATION_MAX_ATTEMPTS,
|
||||
last_error.unwrap_or_else(|| "未知错误".to_string())
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
while let Some(result) = generation_tasks.join_next().await {
|
||||
let task_result = result.map_err(|error| error.to_string())?;
|
||||
let (key, index, generated) = match task_result {
|
||||
Ok(value) => value,
|
||||
Err(message) => {
|
||||
errors.push(message);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(role_object) = profile_object
|
||||
.get_mut(key.as_str())
|
||||
.and_then(Value::as_array_mut)
|
||||
@@ -1369,6 +1498,9 @@ async fn generate_draft_foundation_role_visuals(
|
||||
);
|
||||
}
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Err(errors.join(";"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1383,46 +1515,87 @@ async fn generate_draft_foundation_act_backgrounds(
|
||||
json_text_from_value(draft_profile, "name").unwrap_or_else(|| "未命名世界".to_string());
|
||||
let profile_id = json_text_from_value(draft_profile, "id");
|
||||
let act_refs = collect_scene_act_refs(draft_profile);
|
||||
let total = act_refs.len().max(1);
|
||||
for (completed, act_ref) in act_refs.into_iter().enumerate() {
|
||||
upsert_custom_world_draft_foundation_progress(
|
||||
state,
|
||||
&session.session_id,
|
||||
owner_user_id,
|
||||
operation_id,
|
||||
"running",
|
||||
"生成幕背景图",
|
||||
format!(
|
||||
"正在生成幕背景图 {}/{}:{}。",
|
||||
completed + 1,
|
||||
total,
|
||||
act_ref.title
|
||||
)
|
||||
.as_str(),
|
||||
98,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let generated = generate_custom_world_scene_image_for_profile(
|
||||
state,
|
||||
owner_user_id,
|
||||
profile_id.as_deref(),
|
||||
world_name.as_str(),
|
||||
act_ref.scene_id.as_str(),
|
||||
act_ref.title.as_str(),
|
||||
act_ref.summary.as_str(),
|
||||
act_ref.prompt.as_str(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.message().to_string())?;
|
||||
validate_scene_act_background_prompts(&act_refs)?;
|
||||
upsert_custom_world_draft_foundation_progress(
|
||||
state,
|
||||
&session.session_id,
|
||||
owner_user_id,
|
||||
operation_id,
|
||||
"running",
|
||||
"并行生成幕背景图",
|
||||
format!("正在同时生成 {} 张幕背景图。", act_refs.len()).as_str(),
|
||||
98,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let mut generation_tasks = JoinSet::new();
|
||||
for act_ref in act_refs {
|
||||
let task_state = (*state).clone();
|
||||
let task_owner_user_id = owner_user_id.to_string();
|
||||
let task_profile_id = profile_id.clone();
|
||||
let task_world_name = world_name.clone();
|
||||
generation_tasks.spawn(async move {
|
||||
let mut last_error = None;
|
||||
for attempt in 1..=DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
|
||||
match generate_custom_world_scene_image_for_profile(
|
||||
&task_state,
|
||||
task_owner_user_id.as_str(),
|
||||
task_profile_id.as_deref(),
|
||||
task_world_name.as_str(),
|
||||
act_ref.scene_id.as_str(),
|
||||
act_ref.title.as_str(),
|
||||
act_ref.summary.as_str(),
|
||||
act_ref.prompt.as_str(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(generated) => {
|
||||
return Ok::<_, String>((
|
||||
act_ref.chapter_index,
|
||||
act_ref.act_index,
|
||||
generated,
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
last_error = Some(error.message().to_string());
|
||||
if attempt < DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
300 * u64::from(attempt),
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"幕「{}」背景图连续生成 {} 次失败:{}",
|
||||
act_ref.title,
|
||||
DRAFT_ASSET_GENERATION_MAX_ATTEMPTS,
|
||||
last_error.unwrap_or_else(|| "未知错误".to_string())
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
while let Some(result) = generation_tasks.join_next().await {
|
||||
let task_result = result.map_err(|error| error.to_string())?;
|
||||
let (chapter_index, act_index, generated) = match task_result {
|
||||
Ok(value) => value,
|
||||
Err(message) => {
|
||||
errors.push(message);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(act_object) = draft_profile
|
||||
.get_mut("sceneChapterBlueprints")
|
||||
.and_then(Value::as_array_mut)
|
||||
.and_then(|chapters| chapters.get_mut(act_ref.chapter_index))
|
||||
.and_then(|chapters| chapters.get_mut(chapter_index))
|
||||
.and_then(|chapter| chapter.get_mut("acts"))
|
||||
.and_then(Value::as_array_mut)
|
||||
.and_then(|acts| acts.get_mut(act_ref.act_index))
|
||||
.and_then(|acts| acts.get_mut(act_index))
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
act_object.insert(
|
||||
@@ -1443,9 +1616,20 @@ async fn generate_draft_foundation_act_backgrounds(
|
||||
);
|
||||
}
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Err(errors.join(";"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct RoleVisualGenerationRef {
|
||||
key: String,
|
||||
index: usize,
|
||||
role_id: String,
|
||||
name: String,
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
struct SceneActGenerationRef {
|
||||
chapter_index: usize,
|
||||
act_index: usize,
|
||||
@@ -1480,14 +1664,93 @@ fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
|
||||
title: json_text_from_value(act, "title")
|
||||
.unwrap_or_else(|| format!("第{}幕", act_index + 1)),
|
||||
summary: json_text_from_value(act, "summary").unwrap_or_default(),
|
||||
prompt: json_text_from_value(act, "backgroundPromptText")
|
||||
.or_else(|| json_text_from_value(act, "summary"))
|
||||
.unwrap_or_else(|| "场景幕背景图,突出探索空间与局势氛围。".to_string()),
|
||||
prompt: json_first_text_from_value(
|
||||
act,
|
||||
&[
|
||||
"backgroundPromptText",
|
||||
"scenePromptText",
|
||||
"visualPromptText",
|
||||
"promptText",
|
||||
"imagePromptText",
|
||||
"backgroundPrompt",
|
||||
"visualPrompt",
|
||||
],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn validate_scene_act_background_prompts(act_refs: &[SceneActGenerationRef]) -> Result<(), String> {
|
||||
if let Some(act_ref) = act_refs.iter().find(|act_ref| act_ref.prompt.is_empty()) {
|
||||
return Err(format!(
|
||||
"第{}章第{}幕「{}」缺少 backgroundPromptText,不能在幕背景图描述文本生成前直接生图。",
|
||||
act_ref.chapter_index + 1,
|
||||
act_ref.act_index + 1,
|
||||
act_ref.title
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn json_first_text_from_value(value: &Value, keys: &[&str]) -> Option<String> {
|
||||
keys.iter().find_map(|key| json_text_from_value(value, key))
|
||||
}
|
||||
|
||||
fn merge_generated_act_backgrounds(target_profile: &mut Value, background_profile: &Value) {
|
||||
let Some(target_chapters) = target_profile
|
||||
.get_mut("sceneChapterBlueprints")
|
||||
.and_then(Value::as_array_mut)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(background_chapters) = background_profile
|
||||
.get("sceneChapterBlueprints")
|
||||
.and_then(Value::as_array)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (chapter_index, background_chapter) in background_chapters.iter().enumerate() {
|
||||
let Some(target_acts) = target_chapters
|
||||
.get_mut(chapter_index)
|
||||
.and_then(|chapter| chapter.get_mut("acts"))
|
||||
.and_then(Value::as_array_mut)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(background_acts) = background_chapter.get("acts").and_then(Value::as_array) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for (act_index, background_act) in background_acts.iter().enumerate() {
|
||||
let Some(target_act_object) = target_acts
|
||||
.get_mut(act_index)
|
||||
.and_then(Value::as_object_mut)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(background_act_object) = background_act.as_object() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// 只合并图片生成产物字段,避免并行分支把其他草稿内容互相覆盖。
|
||||
for key in [
|
||||
"backgroundImageSrc",
|
||||
"backgroundAssetId",
|
||||
"generatedScenePrompt",
|
||||
"generatedSceneModel",
|
||||
] {
|
||||
if let Some(value) = background_act_object.get(key) {
|
||||
target_act_object.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_text_from_value(value: &Value, key: &str) -> Option<String> {
|
||||
value
|
||||
.get(key)
|
||||
@@ -1497,6 +1760,68 @@ fn json_text_from_value(value: &Value, key: &str) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
async fn persist_partial_draft_foundation_after_asset_failure(
|
||||
state: &AppState,
|
||||
session: &CustomWorldAgentSessionRecord,
|
||||
owner_user_id: &str,
|
||||
operation_id: &str,
|
||||
draft_profile: &Value,
|
||||
phase_label: &str,
|
||||
error_message: &str,
|
||||
) {
|
||||
let draft_profile_json = match serde_json::to_string(draft_profile) {
|
||||
Ok(value) => Some(value),
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "素材失败后的部分底稿序列化失败");
|
||||
None
|
||||
}
|
||||
};
|
||||
let finalize_result = state
|
||||
.spacetime_client()
|
||||
.finalize_custom_world_agent_message(CustomWorldAgentMessageFinalizeRecordInput {
|
||||
session_id: session.session_id.clone(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
operation_id: operation_id.to_string(),
|
||||
assistant_message_id: None,
|
||||
assistant_reply_text: None,
|
||||
phase_label: phase_label.to_string(),
|
||||
phase_detail: format!("已保存成功生成的素材,失败项超过 {DRAFT_ASSET_GENERATION_MAX_ATTEMPTS} 次重试:{error_message}"),
|
||||
operation_status: "failed".to_string(),
|
||||
operation_progress: 100,
|
||||
stage: session.stage.clone(),
|
||||
progress_percent: session.progress_percent,
|
||||
focus_card_id: session.focus_card_id.clone(),
|
||||
anchor_content_json: session.anchor_content.to_string(),
|
||||
creator_intent_json: Some(session.creator_intent.to_string()),
|
||||
creator_intent_readiness_json: session.creator_intent_readiness.to_string(),
|
||||
anchor_pack_json: Some(session.anchor_pack.to_string()),
|
||||
draft_profile_json,
|
||||
pending_clarifications_json: Value::Array(session.pending_clarifications.clone()).to_string(),
|
||||
suggested_actions_json: Value::Array(session.suggested_actions.clone()).to_string(),
|
||||
recommended_replies_json: json!(session.recommended_replies).to_string(),
|
||||
quality_findings_json: Value::Array(session.quality_findings.clone()).to_string(),
|
||||
asset_coverage_json: session.asset_coverage.to_string(),
|
||||
error_message: Some(error_message.to_string()),
|
||||
updated_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await;
|
||||
if let Err(error) = finalize_result {
|
||||
tracing::warn!(error = %error, "素材失败后的部分底稿持久化失败");
|
||||
let _ = upsert_custom_world_draft_foundation_progress(
|
||||
state,
|
||||
&session.session_id,
|
||||
owner_user_id,
|
||||
operation_id,
|
||||
"failed",
|
||||
phase_label,
|
||||
error_message,
|
||||
100,
|
||||
Some(error_message.to_string()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn upsert_custom_world_draft_foundation_progress(
|
||||
state: &AppState,
|
||||
session_id: &str,
|
||||
@@ -1755,6 +2080,24 @@ fn has_custom_world_scene_act(profile: Option<&Map<String, Value>>) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn ensure_non_empty(
|
||||
request_context: &RequestContext,
|
||||
value: &str,
|
||||
field_name: &str,
|
||||
) -> Result<(), Response> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(custom_world_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-agent",
|
||||
"message": format!("{field_name} is required"),
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_custom_world_publish_gate_response(
|
||||
gate: CustomWorldPublishGateRecord,
|
||||
) -> CustomWorldPublishGateResponse {
|
||||
@@ -2090,3 +2433,32 @@ fn current_utc_micros() -> i64 {
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn collect_scene_act_refs_accepts_scene_prompt_text_alias() {
|
||||
let draft_profile = json!({
|
||||
"sceneChapterBlueprints": [
|
||||
{
|
||||
"sceneId": "scene-office",
|
||||
"acts": [
|
||||
{
|
||||
"title": "深夜工位",
|
||||
"summary": "团队在凌晨三点继续赶版本。",
|
||||
"scenePromptText": "现代创业公司办公室,凌晨灯光,紧张忙碌"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let act_refs = collect_scene_act_refs(&draft_profile);
|
||||
|
||||
assert_eq!(act_refs.len(), 1);
|
||||
assert_eq!(act_refs[0].prompt, "现代创业公司办公室,凌晨灯光,紧张忙碌");
|
||||
assert!(validate_scene_act_background_prompts(&act_refs).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user