Auto-open draft result after foundation completes

This commit is contained in:
2026-04-25 10:52:39 +08:00
parent 35c2bce6f1
commit 03acbc5cb1
31 changed files with 36472 additions and 232 deletions

View File

@@ -1,3 +1,5 @@
use std::collections::BTreeMap;
use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
@@ -31,14 +33,14 @@ use spacetime_client::{
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput,
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
CustomWorldAgentSessionRecord,
CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord,
CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord,
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
CustomWorldWorkSummaryRecord, SpacetimeClientError,
};
use std::{collections::BTreeSet, convert::Infallible, sync::Arc};
use std::{collections::BTreeSet, convert::Infallible, sync::Arc, time::Instant};
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tracing::info;
@@ -66,7 +68,6 @@ use crate::{
};
const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3;
const DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS: usize = 2;
pub async fn get_custom_world_library(
State(state): State<AppState>,
@@ -1229,7 +1230,7 @@ fn spawn_custom_world_draft_foundation_job(
};
let image_generation_limiter = Arc::new(Semaphore::new(
DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS,
state.config.draft_asset_generation_max_concurrent_requests,
));
let role_visual_profile_input = draft_profile_value.clone();
let act_background_profile_input = draft_profile_value.clone();
@@ -1270,7 +1271,9 @@ fn spawn_custom_world_draft_foundation_job(
Err(message) => asset_generation_errors.push(("生成角色主形象失败", message)),
}
match act_background_result {
Ok(profile) => merge_generated_act_backgrounds(&mut draft_profile_with_assets, &profile),
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;
@@ -1465,13 +1468,12 @@ async fn generate_draft_foundation_role_visuals(
)
.await
};
match generation_result
{
match generation_result {
Ok(generated) => {
return Ok::<_, String>((role_ref.key, role_ref.index, generated));
}
Err(error) => {
last_error = Some(error.message().to_string());
last_error = Some(error.body_text());
if attempt < DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
tokio::time::sleep(std::time::Duration::from_millis(
300 * u64::from(attempt),
@@ -1531,8 +1533,16 @@ async fn generate_draft_foundation_act_backgrounds(
let world_name =
json_text_from_value(draft_profile, "name").unwrap_or_else(|| "未命名世界".to_string());
let profile_id = json_text_from_value(draft_profile, "id");
let scene_image_profile_input = draft_profile.clone();
let act_refs = collect_scene_act_refs(draft_profile);
validate_scene_act_background_prompts(&act_refs)?;
tracing::info!(
operation_id,
session_id = %session.session_id,
act_count = act_refs.len(),
max_concurrent_requests = state.config.draft_asset_generation_max_concurrent_requests,
"开始并行生成草稿幕背景图"
);
upsert_custom_world_draft_foundation_progress(
state,
&session.session_id,
@@ -1553,38 +1563,79 @@ async fn generate_draft_foundation_act_backgrounds(
let task_owner_user_id = owner_user_id.to_string();
let task_profile_id = profile_id.clone();
let task_world_name = world_name.clone();
let task_profile = scene_image_profile_input.clone();
let task_limiter = image_generation_limiter.clone();
let task_operation_id = operation_id.to_string();
let task_session_id = session.session_id.clone();
generation_tasks.spawn(async move {
let mut last_error = None;
for attempt in 1..=DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
let attempt_started_at = Instant::now();
tracing::info!(
operation_id = %task_operation_id,
session_id = %task_session_id,
chapter_index = act_ref.chapter_index,
act_index = act_ref.act_index,
scene_id = %act_ref.scene_id,
scene_name = %act_ref.scene_name,
attempt,
"开始生成单幕背景图"
);
let generation_result = {
let _permit = task_limiter
.acquire()
.await
.map_err(|error| format!("图片生成并发控制失效:{error}"))?;
let _permit = task_limiter.acquire().await.map_err(|error| {
(
act_ref.chapter_index,
act_ref.act_index,
format!("图片生成并发控制失效:{error}"),
)
})?;
generate_custom_world_scene_image_for_profile(
&task_state,
task_owner_user_id.as_str(),
&task_profile,
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.scene_name.as_str(),
act_ref.scene_description.as_str(),
act_ref.prompt.as_str(),
)
.await
};
match generation_result
{
match generation_result {
Ok(generated) => {
return Ok::<_, String>((
tracing::info!(
operation_id = %task_operation_id,
session_id = %task_session_id,
chapter_index = act_ref.chapter_index,
act_index = act_ref.act_index,
scene_id = %act_ref.scene_id,
scene_name = %act_ref.scene_name,
attempt,
elapsed_ms = attempt_started_at.elapsed().as_millis(),
"单幕背景图生成成功"
);
return Ok::<_, (usize, usize, String)>((
act_ref.chapter_index,
act_ref.act_index,
generated,
));
}
Err(error) => {
last_error = Some(error.message().to_string());
let error_message = error.body_text();
tracing::warn!(
operation_id = %task_operation_id,
session_id = %task_session_id,
chapter_index = act_ref.chapter_index,
act_index = act_ref.act_index,
scene_id = %act_ref.scene_id,
scene_name = %act_ref.scene_name,
attempt,
elapsed_ms = attempt_started_at.elapsed().as_millis(),
error_message = %error_message,
"单幕背景图生成失败"
);
last_error = Some(error_message);
if attempt < DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
tokio::time::sleep(std::time::Duration::from_millis(
300 * u64::from(attempt),
@@ -1595,23 +1646,34 @@ async fn generate_draft_foundation_act_backgrounds(
}
}
Err(format!(
"{}章第{}幕「{}」背景图连续生成 {} 次失败:{}",
act_ref.chapter_index + 1,
act_ref.act_index + 1,
act_ref.title,
DRAFT_ASSET_GENERATION_MAX_ATTEMPTS,
last_error.unwrap_or_else(|| "未知错误".to_string())
Err((
act_ref.chapter_index,
act_ref.act_index,
format!(
"{}章第{}幕「{}」背景图连续生成 {} 次失败:{}",
act_ref.chapter_index + 1,
act_ref.act_index + 1,
act_ref.scene_name,
DRAFT_ASSET_GENERATION_MAX_ATTEMPTS,
last_error.unwrap_or_else(|| "未知错误".to_string())
),
))
});
}
let mut errors = Vec::new();
let mut generated_count = 0usize;
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) => {
Err((chapter_index, act_index, message)) => {
mark_scene_act_background_generation_error(
draft_profile,
chapter_index,
act_index,
&message,
);
errors.push(message);
continue;
}
@@ -1641,14 +1703,48 @@ async fn generate_draft_foundation_act_backgrounds(
"generatedSceneModel".to_string(),
Value::String(generated.model),
);
generated_count += 1;
}
}
if !errors.is_empty() {
if generated_count > 0 {
// 自动草稿生成和手动生成用的是同一套生图与资产入库能力;这里不能因为批量中的个别幕失败,
// 把已经写入 profile 分支的 backgroundImageSrc 一起丢掉,否则前端就看不到已经生成好的图。
tracing::warn!(
generated_count,
failed_count = errors.len(),
error_message = %join_unique_error_messages(errors),
"部分幕背景图生成失败,已保留成功生成的幕图"
);
return Ok(());
}
return Err(join_unique_error_messages(errors));
}
Ok(())
}
fn mark_scene_act_background_generation_error(
draft_profile: &mut Value,
chapter_index: usize,
act_index: usize,
message: &str,
) {
if let Some(act_object) = draft_profile
.get_mut("sceneChapterBlueprints")
.and_then(Value::as_array_mut)
.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_index))
.and_then(Value::as_object_mut)
{
act_object.insert(
"backgroundGenerationError".to_string(),
Value::String(message.trim().to_string()),
);
}
}
fn join_unique_error_messages(messages: Vec<String>) -> String {
// 并行图片任务可能从同一个上游故障返回完全相同的业务错误;用户侧只需要看到去重后的失败项。
messages
@@ -1673,12 +1769,13 @@ struct SceneActGenerationRef {
chapter_index: usize,
act_index: usize,
scene_id: String,
title: String,
summary: String,
scene_name: String,
scene_description: String,
prompt: String,
}
fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
let scene_context_by_id = collect_scene_context_by_id(draft_profile);
draft_profile
.get("sceneChapterBlueprints")
.and_then(Value::as_array)
@@ -1689,21 +1786,31 @@ fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
let chapter_scene_id = json_text_from_value(chapter, "sceneId")
.or_else(|| json_text_from_value(chapter, "id"))
.unwrap_or_else(|| format!("chapter-{chapter_index}"));
let chapter_scene_name = json_first_text_from_value(
chapter,
&["sceneName", "landmarkName", "name", "title"],
)
.unwrap_or_else(|| chapter_scene_id.clone());
let chapter_scene_context = scene_context_by_id
.get(&chapter_scene_id)
.cloned()
.unwrap_or_else(|| SceneImageContext {
id: chapter_scene_id.clone(),
name: chapter_scene_name.clone(),
description: json_text_from_value(chapter, "description")
.or_else(|| json_text_from_value(chapter, "summary"))
.unwrap_or_default(),
danger_level: json_text_from_value(chapter, "dangerLevel").unwrap_or_default(),
});
let scene_contexts = scene_context_by_id.clone();
chapter
.get("acts")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
.map(move |(act_index, act)| SceneActGenerationRef {
chapter_index,
act_index,
scene_id: json_text_from_value(act, "sceneId")
.unwrap_or_else(|| chapter_scene_id.clone()),
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_first_text_from_value(
.map(move |(act_index, act)| {
let prompt = json_first_text_from_value(
act,
&[
"backgroundPromptText",
@@ -1715,19 +1822,90 @@ fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
"visualPrompt",
],
)
.unwrap_or_default(),
.unwrap_or_default();
let scene_name = json_first_text_from_value(
act,
&["sceneName", "landmarkName", "locationName"],
)
.unwrap_or_else(|| chapter_scene_context.name.clone());
let act_scene_id = json_text_from_value(act, "sceneId")
.unwrap_or_else(|| chapter_scene_context.id.clone());
let scene_context =
scene_contexts
.get(&act_scene_id)
.cloned()
.unwrap_or_else(|| SceneImageContext {
id: act_scene_id.clone(),
name: scene_name,
description: chapter_scene_context.description.clone(),
danger_level: chapter_scene_context.danger_level.clone(),
});
SceneActGenerationRef {
chapter_index,
act_index,
scene_id: act_scene_id,
scene_name: scene_context.name,
scene_description: scene_context.description,
prompt: prompt.clone(),
}
})
})
.collect()
}
#[derive(Clone, Debug)]
struct SceneImageContext {
id: String,
name: String,
description: String,
danger_level: String,
}
fn collect_scene_context_by_id(draft_profile: &Value) -> BTreeMap<String, SceneImageContext> {
let mut contexts = BTreeMap::new();
if let Some(camp) = draft_profile.get("camp").and_then(Value::as_object) {
if let Some(context) = scene_context_from_object(camp, "camp") {
contexts.insert(context.id.clone(), context);
}
}
if let Some(landmarks) = draft_profile.get("landmarks").and_then(Value::as_array) {
for landmark in landmarks.iter().filter_map(Value::as_object) {
if let Some(context) = scene_context_from_object(landmark, "landmark") {
contexts.insert(context.id.clone(), context);
}
}
}
contexts
}
fn scene_context_from_object(
object: &Map<String, Value>,
fallback_id: &str,
) -> Option<SceneImageContext> {
let id = read_string_field(object, "id")
.or_else(|| read_string_field(object, "sceneId"))
.unwrap_or_else(|| fallback_id.to_string());
let name = read_string_field(object, "name")
.or_else(|| read_string_field(object, "sceneName"))
.unwrap_or_else(|| id.clone());
Some(SceneImageContext {
id,
name,
description: read_string_field(object, "description")
.or_else(|| read_string_field(object, "visualDescription"))
.unwrap_or_default(),
danger_level: read_string_field(object, "dangerLevel").unwrap_or_default(),
})
}
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
act_ref.scene_name
));
}
@@ -2480,13 +2658,28 @@ mod tests {
#[test]
fn collect_scene_act_refs_accepts_scene_prompt_text_alias() {
let draft_profile = json!({
"name": "雾港纪元",
"tone": "潮湿、悬疑、低照度",
"landmarks": [
{
"id": "scene-office",
"name": "旧港办公室",
"description": "旧港边缘的玻璃办公室,窗外能看到潮湿码头。",
"dangerLevel": "low"
}
],
"sceneChapterBlueprints": [
{
"sceneId": "scene-office",
"sceneName": "旧港办公室",
"acts": [
{
"title": "深夜工位",
"summary": "团队在凌晨三点继续赶版本。",
"actGoal": "找到丢失的部署钥匙",
"transitionHook": "电梯门在无人操作时打开",
"primaryRoleName": "林澈",
"supportRoleNames": ["阿岚"],
"scenePromptText": "现代创业公司办公室,凌晨灯光,紧张忙碌"
}
]
@@ -2498,6 +2691,12 @@ mod tests {
assert_eq!(act_refs.len(), 1);
assert_eq!(act_refs[0].prompt, "现代创业公司办公室,凌晨灯光,紧张忙碌");
assert_eq!(act_refs[0].scene_id, "scene-office");
assert_eq!(act_refs[0].scene_name, "旧港办公室");
assert_eq!(
act_refs[0].scene_description,
"旧港边缘的玻璃办公室,窗外能看到潮湿码头。"
);
assert!(validate_scene_act_background_prompts(&act_refs).is_ok());
}
}