This commit is contained in:
2026-04-24 22:25:13 +08:00
parent 75681751c2
commit 67062a8af3
43 changed files with 1857 additions and 268 deletions

View File

@@ -0,0 +1,233 @@
use module_ai::{
AiTaskCreateInput, AiTaskKind, AiTaskStageBlueprint, AiTaskStageKind, AiTaskStageStartInput,
AiTextChunkAppendInput,
};
use serde_json::json;
use spacetime_client::{SpacetimeClient, SpacetimeClientError};
use std::sync::{Arc, Mutex};
use tracing::warn;
#[derive(Clone, Debug)]
pub(crate) struct AiGenerationDraftContext {
pub task_id: String,
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: String,
pub template_key: String,
pub operation_id: String,
}
impl AiGenerationDraftContext {
pub fn new(
template_key: &str,
owner_user_id: &str,
session_id: &str,
operation_id: &str,
request_label: &str,
) -> Self {
let normalized_template = normalize_identifier_segment(template_key);
let normalized_session = normalize_identifier_segment(session_id);
let normalized_operation = normalize_identifier_segment(operation_id);
Self {
// 生成过程草稿使用稳定 task_id保证同一模板会话操作重试时能继续定位已有内容。
task_id: format!(
"aitask_draft_{normalized_template}_{normalized_session}_{normalized_operation}"
),
owner_user_id: owner_user_id.trim().to_string(),
request_label: request_label.trim().to_string(),
source_module: normalized_template,
source_entity_id: session_id.trim().to_string(),
template_key: template_key.trim().to_string(),
operation_id: operation_id.trim().to_string(),
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct AiGenerationDraftSink {
context: AiGenerationDraftContext,
client: SpacetimeClient,
next_sequence: Arc<Mutex<u32>>,
persisted_text: Arc<Mutex<String>>,
}
impl AiGenerationDraftSink {
pub fn new(context: AiGenerationDraftContext, client: SpacetimeClient) -> Self {
Self {
context,
client,
next_sequence: Arc::new(Mutex::new(1)),
persisted_text: Arc::new(Mutex::new(String::new())),
}
}
pub fn persist_visible_text_async(&self, visible_text: &str) {
let (sequence, delta_text) = {
let mut persisted_text = self
.persisted_text
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let delta_text = visible_text
.strip_prefix(persisted_text.as_str())
.unwrap_or(visible_text)
.to_string();
*persisted_text = visible_text.to_string();
if delta_text.trim().is_empty() {
return;
}
let mut next_sequence = self
.next_sequence
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let sequence = *next_sequence;
*next_sequence = next_sequence.saturating_add(1);
(sequence, delta_text)
};
let context = self.context.clone();
let client = self.client.clone();
tokio::spawn(async move {
if let Err(error) = client
.append_ai_text_chunk(AiTextChunkAppendInput {
task_id: context.task_id.clone(),
stage_kind: AiTaskStageKind::RequestModel,
sequence,
delta_text,
created_at_micros: current_utc_micros(),
})
.await
{
warn!(
task_id = %context.task_id,
sequence,
error = %error,
"AI 生成草稿后台增量落库失败,主生成流程继续执行"
);
}
});
}
}
#[derive(Debug)]
pub(crate) struct AiGenerationDraftWriter {
context: AiGenerationDraftContext,
next_sequence: u32,
persisted_text: String,
}
impl AiGenerationDraftWriter {
pub fn new(context: AiGenerationDraftContext) -> Self {
Self {
context,
next_sequence: 1,
persisted_text: String::new(),
}
}
pub async fn ensure_started(
&mut self,
client: &SpacetimeClient,
) -> Result<(), SpacetimeClientError> {
let now_micros = current_utc_micros();
match client
.create_ai_task(AiTaskCreateInput {
task_id: self.context.task_id.clone(),
task_kind: AiTaskKind::CustomWorldGeneration,
owner_user_id: self.context.owner_user_id.clone(),
request_label: self.context.request_label.clone(),
source_module: self.context.source_module.clone(),
source_entity_id: Some(self.context.source_entity_id.clone()),
request_payload_json: Some(
json!({
"templateKey": self.context.template_key,
"operationId": self.context.operation_id,
})
.to_string(),
),
stages: vec![AiTaskStageBlueprint {
stage_kind: AiTaskStageKind::RequestModel,
label: "请求模型".to_string(),
detail: "模板生成过程中持续写入模型已生成文本。".to_string(),
order: 1,
}],
created_at_micros: now_micros,
})
.await
{
Ok(_) => {}
Err(error) if is_duplicate_ai_task_error(&error) => {}
Err(error) => return Err(error),
}
client
.start_ai_task_stage(AiTaskStageStartInput {
task_id: self.context.task_id.clone(),
stage_kind: AiTaskStageKind::RequestModel,
started_at_micros: now_micros,
})
.await
}
pub async fn persist_visible_text(&mut self, client: &SpacetimeClient, visible_text: &str) {
let delta_text = match visible_text.strip_prefix(self.persisted_text.as_str()) {
Some(delta) => delta,
None => visible_text,
};
if delta_text.trim().is_empty() {
self.persisted_text = visible_text.to_string();
return;
}
let sequence = self.next_sequence;
self.next_sequence = self.next_sequence.saturating_add(1);
self.persisted_text = visible_text.to_string();
if let Err(error) = client
.append_ai_text_chunk(AiTextChunkAppendInput {
task_id: self.context.task_id.clone(),
stage_kind: AiTaskStageKind::RequestModel,
sequence,
delta_text: delta_text.to_string(),
created_at_micros: current_utc_micros(),
})
.await
{
warn!(
task_id = %self.context.task_id,
sequence,
error = %error,
"AI 生成草稿增量落库失败,主生成流程继续执行"
);
}
}
}
fn normalize_identifier_segment(value: &str) -> String {
let normalized = value
.trim()
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
character
} else {
'_'
}
})
.collect::<String>();
if normalized.is_empty() {
"unknown".to_string()
} else {
normalized
}
}
fn is_duplicate_ai_task_error(error: &SpacetimeClientError) -> bool {
error.to_string().contains("ai_task.task_id 已存在")
}
fn current_utc_micros() -> i64 {
time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64 / 1_000
}

View File

@@ -48,13 +48,13 @@ use crate::{
custom_world::{
create_custom_world_agent_session, delete_custom_world_agent_session,
delete_custom_world_library_profile, execute_custom_world_agent_action,
get_custom_world_agent_card_detail,
get_custom_world_agent_operation, get_custom_world_agent_session,
get_custom_world_gallery_detail, get_custom_world_gallery_detail_by_code,
get_custom_world_library, get_custom_world_library_detail, get_custom_world_works,
list_custom_world_gallery, publish_custom_world_library_profile,
put_custom_world_library_profile, stream_custom_world_agent_message,
submit_custom_world_agent_message, unpublish_custom_world_library_profile,
get_custom_world_agent_card_detail, get_custom_world_agent_operation,
get_custom_world_agent_session, get_custom_world_gallery_detail,
get_custom_world_gallery_detail_by_code, get_custom_world_library,
get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery,
publish_custom_world_library_profile, put_custom_world_library_profile,
stream_custom_world_agent_message, submit_custom_world_agent_message,
unpublish_custom_world_library_profile,
},
custom_world_ai::{
generate_custom_world_cover_image, generate_custom_world_entity,

View File

@@ -42,8 +42,14 @@ use crate::big_fish_agent_turn::{
run_big_fish_agent_turn,
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
ai_generation_drafts::{
AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter,
},
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
pub async fn create_big_fish_session(
@@ -203,14 +209,44 @@ pub async fn submit_big_fish_message(
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new(
"big_fish",
owner_user_id.as_str(),
session_id.as_str(),
payload.client_message_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(
"big_fish",
owner_user_id.as_str(),
session_id.as_str(),
payload.client_message_id.as_str(),
"大鱼吃小鱼模板生成草稿",
),
state.spacetime_client().clone(),
);
let turn_result = run_big_fish_agent_turn(
BigFishAgentTurnRequest {
llm_client: state.llm_client(),
session: &submitted_session,
},
|_| {},
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(),
@@ -285,6 +321,26 @@ pub async fn stream_big_fish_message(
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new(
"big_fish",
owner_user_id.as_str(),
session_id.as_str(),
payload.client_message_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(
"big_fish",
owner_user_id.as_str(),
session_id.as_str(),
payload.client_message_id.as_str(),
"大鱼吃小鱼模板生成草稿",
),
state.spacetime_client().clone(),
);
let mut streamed_reply_text = String::new();
let turn_result = run_big_fish_agent_turn(
BigFishAgentTurnRequest {
@@ -292,10 +348,16 @@ pub async fn stream_big_fish_message(
session: &submitted_session,
},
|text| {
draft_sink.persist_visible_text_async(text);
streamed_reply_text = text.to_string();
},
)
.await;
if !streamed_reply_text.is_empty() {
draft_writer
.persist_visible_text(state.spacetime_client(), streamed_reply_text.as_str())
.await;
}
let reply_text = match &turn_result {
Ok(result) => result.assistant_reply_text.clone(),
Err(error) => error.to_string(),

View File

@@ -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());
}
}

View File

@@ -1,5 +1,5 @@
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use serde_json::{Map as JsonMap, Value as JsonValue, json};
use serde_json::{Map as JsonMap, Value as JsonValue};
use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest;
use spacetime_client::CustomWorldAgentSessionRecord;

View File

@@ -1,4 +1,4 @@
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use serde_json::{Map as JsonMap, Value as JsonValue, json};
use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest;
use spacetime_client::CustomWorldAgentSessionRecord;
@@ -738,7 +738,7 @@ fn build_custom_world_landmark_seed_batch_prompt(
) -> String {
[
"请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(),
"后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架默认生图描述。".to_string(),
"后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架、地点默认生图描述和逐幕背景描述".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 0),
@@ -751,6 +751,7 @@ fn build_custom_world_landmark_seed_batch_prompt(
" \"name\": \"场景名称\",".to_string(),
" \"description\": \"场景极简描述\",".to_string(),
" \"visualDescription\": \"默认场景生图描述\",".to_string(),
" \"actBackgroundPromptTexts\": [\"第一幕背景画面描述\", \"第二幕背景画面描述\", \"第三幕背景画面描述\"],".to_string(),
" \"dangerLevel\": \"low|medium|high|extreme\"".to_string(),
" }".to_string(),
" ]".to_string(),
@@ -760,8 +761,10 @@ fn build_custom_world_landmark_seed_batch_prompt(
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
"- 每个地点只保留name、description、visualDescription、dangerLevel。".to_string(),
"- 每个地点只保留name、description、visualDescription、actBackgroundPromptTexts、dangerLevel。".to_string(),
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须是大模型根据当前地点、主线阶段和可出场角色直接写出的画面描述,控制在 40 到 90 个汉字内。".to_string(),
"- actBackgroundPromptTexts 禁止使用“某某第1幕背景玩家会在……”这类标题、摘要、规则句拼接格式必须像可直接交给生图模型的自然画面描述。".to_string(),
"- description 控制在 12 到 24 个汉字内。".to_string(),
"- dangerLevel 只能是 low、medium、high、extreme 之一。".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
@@ -780,8 +783,8 @@ fn build_custom_world_landmark_seed_batch_json_repair_prompt(
"顶层必须只包含一个 landmarks 数组。".to_string(),
format!("必须保留恰好 {expected_count} 个地点对象。"),
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}", forbidden_names.join("")) },
"每个地点只包含name、description、visualDescription、dangerLevel。".to_string(),
"如果缺少字段字符串补空字符串dangerLevel 补 medium。".to_string(),
"每个地点只包含name、description、visualDescription、actBackgroundPromptTexts、dangerLevel。".to_string(),
"如果缺少字段:字符串补空字符串,actBackgroundPromptTexts 补空数组,dangerLevel 补 medium。".to_string(),
"不要输出 sceneNpcNames、connectedLandmarks、items 或任何其他字段。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
@@ -945,6 +948,7 @@ fn build_custom_world_role_batch_json_repair_prompt(
response_text.trim().to_string(),
].join("\n")
}
#[cfg(test)]
fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String {
let anchor_content = to_pretty_json(&session.anchor_content);
let creator_intent = to_pretty_json(&session.creator_intent);
@@ -1063,11 +1067,80 @@ fn build_foundation_draft_profile_from_framework(
JsonValue::Array(playable_detailed),
);
object.insert("storyNpcs".to_string(), JsonValue::Array(story_detailed));
let scene_chapter_blueprints = build_scene_chapter_blueprints_from_landmarks(&landmarks);
object.insert("landmarks".to_string(), JsonValue::Array(landmarks));
object.insert("chapters".to_string(), JsonValue::Array(Vec::new()));
object.insert(
"sceneChapterBlueprints".to_string(),
JsonValue::Array(scene_chapter_blueprints),
);
normalize_foundation_draft_profile(JsonValue::Object(object), session)
}
fn build_scene_chapter_blueprints_from_landmarks(landmarks: &[JsonValue]) -> Vec<JsonValue> {
// 幕背景描述必须来自关键场景生成步骤,不能在草稿合成阶段再用规则句拼接。
landmarks
.iter()
.enumerate()
.map(|(chapter_index, landmark)| {
let scene_name = json_text(landmark, "name")
.unwrap_or_else(|| format!("关键场景{}", chapter_index + 1));
let scene_id = json_text(landmark, "id")
.unwrap_or_else(|| format!("saved-landmark-{}", chapter_index + 1));
let summary = json_text(landmark, "description").unwrap_or_default();
let act_prompts =
json_string_array(landmark, "actBackgroundPromptTexts").unwrap_or_default();
let scene_npc_names = json_string_array(landmark, "sceneNpcNames").unwrap_or_default();
json!({
"id": scene_id.clone(),
"sceneId": scene_id.clone(),
"title": scene_name,
"summary": summary,
"linkedLandmarkIds": [scene_id.clone()],
"acts": (0..3)
.map(|act_index| build_scene_act_blueprint_from_landmark(
&scene_id,
&summary,
&act_prompts,
&scene_npc_names,
act_index,
))
.collect::<Vec<_>>(),
})
})
.collect()
}
fn build_scene_act_blueprint_from_landmark(
scene_id: &str,
scene_summary: &str,
act_prompts: &[String],
scene_npc_names: &[String],
act_index: usize,
) -> JsonValue {
let act_title = if act_index == 0 {
"第1幕".to_string()
} else {
format!("{}", act_index + 1)
};
let prompt = act_prompts
.get(act_index)
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("");
// 缺失时保留空值,让后续生图前校验暴露底稿质量问题。
json!({
"id": format!("{}-act-{}", scene_id, act_index + 1),
"sceneId": scene_id,
"title": act_title,
"summary": scene_summary,
"backgroundPromptText": prompt,
"encounterNpcIds": scene_npc_names,
})
}
fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
if !framework.is_object() {
*framework = json!({});
@@ -1469,12 +1542,6 @@ fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue {
fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue {
let mut object = act.as_object().cloned().unwrap_or_default();
let fallback_act = build_fallback_scene_act_with_index(index);
let fallback_prompt = fallback_act
.get("backgroundPromptText")
.and_then(JsonValue::as_str)
.unwrap_or("当前幕场景背景,突出可探索空间、站位地面和局势氛围。")
.to_string();
let title = object
.get("title")
.and_then(JsonValue::as_str)
@@ -1497,7 +1564,7 @@ fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue {
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("{title}{summary}{fallback_prompt}"));
.unwrap_or_default();
object.insert(
"backgroundPromptText".to_string(),
JsonValue::String(background_prompt),
@@ -1523,7 +1590,7 @@ fn build_fallback_scene_act_with_index(index: usize) -> JsonValue {
"id": format!("scene-act-{}", index + 1),
"title": if index == 0 { "开场场景幕".to_string() } else { format!("{}", index + 1) },
"summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
"backgroundPromptText": "第一幕场景背景,突出玩家初入现场时的空间轮廓、可站立地面、远近景层次和第一波威胁氛围。",
"backgroundPromptText": "",
})
}
@@ -1642,10 +1709,12 @@ fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error>
serde_json::from_str::<JsonValue>(trimmed)
}
#[cfg(test)]
fn to_pretty_json(value: &JsonValue) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string())
}
#[cfg(test)]
fn is_non_null_json(value: &JsonValue) -> bool {
!matches!(value, JsonValue::Null)
}
@@ -1665,6 +1734,54 @@ mod tests {
use super::*;
#[test]
fn scene_chapter_blueprints_use_landmark_act_background_prompts() {
let landmarks = vec![json!({
"name": "雾港码头",
"description": "旧船骨露出黑潮。",
"actBackgroundPromptTexts": [
"潮湿木栈桥在青灰雾里延伸,近处有可站立的破旧甲板,远处旧船骨与灯塔剪影压低天空。",
"封锁绳与巡海灯横切码头,中景堆满浸水货箱,远景黑潮拍打沉船残骸。",
"退潮后的泥滩露出父亲留下的海图匣,雾中灯火错位闪烁,岸边留出对峙站位。"
],
"sceneNpcNames": ["灯童丁"]
})];
let blueprints = build_scene_chapter_blueprints_from_landmarks(&landmarks);
let acts = blueprints[0]
.get("acts")
.and_then(JsonValue::as_array)
.expect("acts should exist");
assert_eq!(acts.len(), 3);
assert_eq!(
acts[0].get("backgroundPromptText"),
Some(&json!(
"潮湿木栈桥在青灰雾里延伸,近处有可站立的破旧甲板,远处旧船骨与灯塔剪影压低天空。"
))
);
assert!(
!acts[0]
.get("backgroundPromptText")
.and_then(JsonValue::as_str)
.unwrap_or_default()
.contains("第1幕背景")
);
}
#[test]
fn normalize_scene_act_keeps_missing_background_prompt_empty() {
let act = normalize_scene_act_blueprint(
json!({
"title": "第1幕",
"summary": "玩家进入雾港码头。"
}),
0,
);
assert_eq!(act.get("backgroundPromptText"), Some(&json!("")));
}
#[test]
fn foundation_prompt_uses_real_seed_text() {
let session = build_test_session();
@@ -1740,7 +1857,7 @@ mod tests {
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
),
llm_response(
r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#,
r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","visualDescription":"深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。","actionDescription":"抬手下令封锁,动作缓慢却压迫感强。","sceneVisualDescription":"他常出现在议会石厅高处,旧档柜阴影切过半张脸。","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#,
),
llm_response(
r#"{"storyNpcs":[{"name":"雾商丙","title":"雾港商人","role":"中间人","description":"贩卖航线的人","initialAffinity":5,"relationshipHooks":["伪造海图"],"tags":["商人"]},{"name":"灯童丁","title":"灯塔学徒","role":"目击者","description":"听见夜钟的人","initialAffinity":30,"relationshipHooks":["夜钟"],"tags":["学徒"]}]}"#,
@@ -1795,6 +1912,24 @@ mod tests {
assert!(request_text.contains("叙事档案"));
assert!(request_text.contains("养成档案"));
assert!(!request_text.contains("seedText\\uff1acustom-world-agent-session-1"));
assert_eq!(
draft_profile
.get("playableNpcs")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("visualDescription"))
.and_then(JsonValue::as_str),
Some("灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。")
);
assert_eq!(
draft_profile
.get("storyNpcs")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("visualDescription"))
.and_then(JsonValue::as_str),
Some("深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。")
);
assert_eq!(draft_profile.get("name"), Some(&json!("雾港归航")));
assert!(
draft_profile

View File

@@ -1,4 +1,5 @@
mod admin;
mod ai_generation_drafts;
mod ai_tasks;
mod api_response;
mod app;

View File

@@ -52,6 +52,7 @@ use spacetime_client::{
use std::convert::Infallible;
use crate::{
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
@@ -288,6 +289,16 @@ pub async fn stream_puzzle_agent_message(
let session_id_for_stream = session_id.clone();
let owner_user_id_for_stream = owner_user_id.clone();
let stream = async_stream::stream! {
let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new(
"puzzle",
owner_user_id_for_stream.as_str(),
session_id_for_stream.as_str(),
payload.client_message_id.as_str(),
"拼图模板生成草稿",
));
if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await {
tracing::warn!(error = %error, "拼图模板生成草稿任务启动失败,主生成流程继续执行");
}
let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let turn_result = {
let run_turn = run_puzzle_agent_turn(
@@ -306,6 +317,7 @@ pub async fn stream_puzzle_agent_message(
result = &mut run_turn => break result,
maybe_text = reply_rx.recv() => {
if let Some(text) = maybe_text {
draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await;
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),
@@ -317,6 +329,7 @@ pub async fn stream_puzzle_agent_message(
};
while let Some(text) = reply_rx.recv().await {
draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await;
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),

View File

@@ -484,4 +484,39 @@ impl SpacetimeClient {
})
.await
}
pub async fn upsert_custom_world_agent_operation_progress(
&self,
input: CustomWorldAgentOperationProgressRecordInput,
) -> Result<CustomWorldAgentOperationRecord, SpacetimeClientError> {
let procedure_input = CustomWorldAgentOperationProgressInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
operation_id: input.operation_id,
operation_type: parse_rpg_agent_operation_type_record(input.operation_type.as_str())?,
operation_status: parse_rpg_agent_operation_status_record(
input.operation_status.as_str(),
)?,
phase_label: input.phase_label,
phase_detail: input.phase_detail,
operation_progress: input.operation_progress,
error_message: input.error_message,
updated_at_micros: input.updated_at_micros,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.upsert_custom_world_agent_operation_progress_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_custom_world_agent_operation_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
}

View File

@@ -16,8 +16,9 @@ pub use mapper::{
BigFishWorkSummaryRecord, CustomWorldAgentActionExecuteRecord,
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord,
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationRecord,
CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput,
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
CustomWorldAgentSessionRecord,
CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord,

View File

@@ -2726,6 +2726,31 @@ pub(crate) fn format_rpg_agent_operation_type(
}
}
pub(crate) fn parse_rpg_agent_operation_type_record(
value: &str,
) -> Result<crate::module_bindings::RpgAgentOperationType, SpacetimeClientError> {
match value.trim() {
"process_message" => Ok(crate::module_bindings::RpgAgentOperationType::ProcessMessage),
"draft_foundation" => Ok(crate::module_bindings::RpgAgentOperationType::DraftFoundation),
"update_draft_card" => Ok(crate::module_bindings::RpgAgentOperationType::UpdateDraftCard),
"sync_result_profile" => Ok(crate::module_bindings::RpgAgentOperationType::SyncResultProfile),
"generate_characters" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateCharacters),
"generate_landmarks" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateLandmarks),
"generate_role_assets" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets),
"sync_role_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncRoleAssets),
"generate_scene_assets" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets),
"sync_scene_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncSceneAssets),
"expand_long_tail" => Ok(crate::module_bindings::RpgAgentOperationType::ExpandLongTail),
"publish_world" => Ok(crate::module_bindings::RpgAgentOperationType::PublishWorld),
"revert_checkpoint" => Ok(crate::module_bindings::RpgAgentOperationType::RevertCheckpoint),
"delete_characters" => Ok(crate::module_bindings::RpgAgentOperationType::DeleteCharacters),
"delete_landmarks" => Ok(crate::module_bindings::RpgAgentOperationType::DeleteLandmarks),
other => Err(SpacetimeClientError::Runtime(format!(
"未知 rpg agent operation type: {other}"
))),
}
}
pub(crate) fn format_rpg_agent_operation_status(
value: crate::module_bindings::RpgAgentOperationStatus,
) -> &'static str {
@@ -2806,22 +2831,6 @@ impl TryFrom<&str> for BigFishAssetKind {
}
}
pub(crate) fn map_big_fish_creation_stage(
value: module_big_fish::BigFishCreationStage,
) -> BigFishCreationStage {
match value {
module_big_fish::BigFishCreationStage::CollectingAnchors => {
BigFishCreationStage::CollectingAnchors
}
module_big_fish::BigFishCreationStage::DraftReady => BigFishCreationStage::DraftReady,
module_big_fish::BigFishCreationStage::AssetRefining => BigFishCreationStage::AssetRefining,
module_big_fish::BigFishCreationStage::ReadyToPublish => {
BigFishCreationStage::ReadyToPublish
}
module_big_fish::BigFishCreationStage::Published => BigFishCreationStage::Published,
}
}
pub(crate) fn parse_big_fish_creation_stage(
value: &str,
) -> Result<BigFishCreationStage, SpacetimeClientError> {
@@ -3466,6 +3475,21 @@ pub struct CustomWorldAgentOperationRecord {
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldAgentOperationProgressRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
// SpacetimeDB 模块侧使用枚举存储操作类型,这里保留字符串给 API 层做轻量传参。
pub operation_type: String,
pub operation_status: String,
pub phase_label: String,
pub phase_detail: String,
pub operation_progress: u32,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CustomWorldDraftCardRecord {
pub card_id: String,

View File

@@ -116,6 +116,7 @@ pub mod custom_world_agent_message_submit_input_type;
pub mod custom_world_agent_operation_type;
pub mod custom_world_agent_operation_get_input_type;
pub mod custom_world_agent_operation_procedure_result_type;
pub mod custom_world_agent_operation_progress_input_type;
pub mod custom_world_agent_operation_snapshot_type;
pub mod custom_world_agent_session_type;
pub mod custom_world_agent_session_create_input_type;
@@ -475,6 +476,7 @@ pub mod unpublish_custom_world_profile_and_return_procedure;
pub mod update_puzzle_work_procedure;
pub mod upsert_auth_store_snapshot_procedure;
pub mod upsert_chapter_progression_and_return_procedure;
pub mod upsert_custom_world_agent_operation_progress_procedure;
pub mod upsert_custom_world_profile_and_return_procedure;
pub mod upsert_npc_state_and_return_procedure;
pub mod upsert_platform_browse_history_and_return_procedure;
@@ -586,6 +588,7 @@ pub use custom_world_agent_message_submit_input_type::CustomWorldAgentMessageSub
pub use custom_world_agent_operation_type::CustomWorldAgentOperation;
pub use custom_world_agent_operation_get_input_type::CustomWorldAgentOperationGetInput;
pub use custom_world_agent_operation_procedure_result_type::CustomWorldAgentOperationProcedureResult;
pub use custom_world_agent_operation_progress_input_type::CustomWorldAgentOperationProgressInput;
pub use custom_world_agent_operation_snapshot_type::CustomWorldAgentOperationSnapshot;
pub use custom_world_agent_session_type::CustomWorldAgentSession;
pub use custom_world_agent_session_create_input_type::CustomWorldAgentSessionCreateInput;
@@ -942,6 +945,7 @@ pub use unpublish_custom_world_profile_and_return_procedure::unpublish_custom_wo
pub use update_puzzle_work_procedure::update_puzzle_work;
pub use upsert_auth_store_snapshot_procedure::upsert_auth_store_snapshot;
pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return;
pub use upsert_custom_world_agent_operation_progress_procedure::upsert_custom_world_agent_operation_progress;
pub use upsert_custom_world_profile_and_return_procedure::upsert_custom_world_profile_and_return;
pub use upsert_npc_state_and_return_procedure::upsert_npc_state_and_return;
pub use upsert_platform_browse_history_and_return_procedure::upsert_platform_browse_history_and_return;

View File

@@ -1572,7 +1572,34 @@ fn delete_custom_world_agent_session_tx(
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
if session.stage == RpgAgentStage::Published {
return Err("已发布 RPG 作品请通过 profile 删除".to_string());
let published_profile = ctx
.db
.custom_world_profile()
.iter()
.find(|row| {
row.owner_user_id == input.owner_user_id
&& row.source_agent_session_id.as_deref() == Some(input.session_id.as_str())
&& row.deleted_at.is_none()
})
.ok_or_else(|| "已发布 RPG 作品缺少关联 profile无法删除".to_string())?;
// 作品卡可能只携带源 Agent sessionId。这里把“按 session 删除已发布作品”收敛为
// profile 软删除,避免前端误入草稿删除接口时把业务分支放大成上游 502。
delete_custom_world_profile_record(
ctx,
CustomWorldProfileDeleteInput {
profile_id: published_profile.profile_id,
owner_user_id: input.owner_user_id.clone(),
deleted_at_micros: ctx.timestamp.to_micros_since_unix_epoch(),
},
)?;
return list_custom_world_work_snapshots(
ctx,
CustomWorldWorksListInput {
owner_user_id: input.owner_user_id,
},
);
}
// 删除纯 Agent 草稿时同步清理消息、操作与草稿卡,避免作品列表消失后残留孤儿数据。