Files
Genarrative/server-rs/crates/api-server/src/custom_world.rs
2026-04-27 14:23:19 +08:00

2986 lines
112 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::BTreeMap;
use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
http::StatusCode,
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use module_custom_world::{
CustomWorldThemeMode, empty_agent_anchor_content_json, empty_agent_asset_coverage_json,
empty_agent_creator_intent_readiness_json, empty_json_array, empty_json_object,
};
use serde_json::{Map, Value, json};
use shared_contracts::runtime::{
CreateCustomWorldAgentSessionRequest, CustomWorldAgentCardDetailResponse,
CustomWorldAgentCheckpointResponse, CustomWorldAgentMessageResponse,
CustomWorldAgentOperationResponse, CustomWorldAgentSessionResponse,
CustomWorldAgentSessionSnapshotResponse, CustomWorldDraftCardDetailResponse,
CustomWorldDraftCardDetailSectionResponse, CustomWorldDraftCardSummaryResponse,
CustomWorldGalleryCardResponse, CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse,
CustomWorldLibraryEntryResponse, CustomWorldLibraryMutationResponse,
CustomWorldLibraryResponse, CustomWorldProfileUpsertRequest, CustomWorldPublishGateResponse,
CustomWorldResultPreviewBlockerResponse, CustomWorldSupportedActionResponse,
CustomWorldWorkSummaryResponse, CustomWorldWorksResponse, ExecuteCustomWorldAgentActionRequest,
SendCustomWorldAgentMessageRequest,
};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord,
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput,
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord,
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
CustomWorldWorkSummaryRecord, SpacetimeClientError,
};
use std::{collections::BTreeSet, convert::Infallible, sync::Arc, time::Instant};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tokio::sync::Semaphore;
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,
custom_world_agent_entities::generate_custom_world_agent_entities,
custom_world_agent_turn::{
CustomWorldAgentTurnRequest, build_failed_finalize_record_input,
build_finalize_record_input, run_custom_world_agent_turn,
},
custom_world_ai::generate_custom_world_scene_image_for_profile,
custom_world_foundation_draft::{
DraftFoundationPayloadError, build_draft_foundation_action_payload_json,
generate_custom_world_foundation_draft,
},
http_error::AppError,
prompt::scene_background::{
SceneActBackgroundPromptParams, build_scene_act_background_image_prompt,
},
request_context::RequestContext,
state::AppState,
};
const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3;
const DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_START: u32 = 12;
const DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_DONE: u32 = 97;
const DRAFT_FOUNDATION_PROGRESS_ASSET_START: u32 = 97;
const DRAFT_FOUNDATION_PROGRESS_CARD_START: u32 = 98;
const DRAFT_FOUNDATION_PROGRESS_WRITEBACK_START: u32 = 99;
const DRAFT_ROLE_ASSET_TEXT_FIELDS: [&str; 3] = [
"visualDescription",
"actionDescription",
"sceneVisualDescription",
];
fn timestamp_micros_to_rfc3339(value: i64) -> String {
match OffsetDateTime::from_unix_timestamp_nanos(i128::from(value) * 1_000) {
Ok(timestamp) => timestamp
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()),
Err(_) => "1970-01-01T00:00:00Z".to_string(),
}
}
fn fallback_draft_foundation_failure_progress(phase_label: &str) -> u32 {
if phase_label.contains("写入") {
return DRAFT_FOUNDATION_PROGRESS_WRITEBACK_START;
}
if phase_label.contains("草稿卡") {
return DRAFT_FOUNDATION_PROGRESS_CARD_START;
}
if phase_label.contains("素材")
|| phase_label.contains("角色主形象")
|| phase_label.contains("幕背景图")
{
return DRAFT_FOUNDATION_PROGRESS_ASSET_START;
}
if phase_label.contains("底稿") {
return DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_DONE;
}
DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_START
}
fn reusable_draft_profile_for_asset_generation(
session: &CustomWorldAgentSessionRecord,
) -> Option<Value> {
let object = session
.draft_profile
.as_object()
.filter(|object| !object.is_empty())?;
let profile = Value::Object(object.clone());
match missing_role_asset_text_report(&profile) {
Some(report) => {
// 中文注释:旧失败会话可能保存了缺少角色形象文本的半成品底稿;
// 这种底稿不能直接续跑到生图阶段,必须回到文本底稿链路重新生成。
tracing::warn!(
session_id = %session.session_id,
missing_report = %report,
"已保存 RPG 底稿缺少角色形象设定文本,跳过复用并重新生成底稿"
);
None
}
None => Some(profile),
}
}
fn missing_role_asset_text_report(draft_profile: &Value) -> Option<String> {
let profile_object = draft_profile.as_object()?;
let mut missing_items = Vec::new();
for key in ["playableNpcs", "storyNpcs"] {
if let Some(roles) = profile_object.get(key).and_then(Value::as_array) {
for (index, role) in roles.iter().enumerate() {
let name = json_text_from_value(role, "name")
.unwrap_or_else(|| format!("{}-{}", key, index + 1));
let missing_fields = DRAFT_ROLE_ASSET_TEXT_FIELDS
.into_iter()
.filter(|field| json_text_from_value(role, field).is_none())
.collect::<Vec<_>>();
if !missing_fields.is_empty() {
missing_items.push(format!("角色「{name}」缺少 {}", missing_fields.join("/")));
}
}
}
}
if missing_items.is_empty() {
None
} else {
Some(missing_items.join(""))
}
}
pub async fn get_custom_world_library(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let entries = state
.spacetime_client()
.list_custom_world_profiles(owner_user_id)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldLibraryResponse {
entries: entries
.into_iter()
.map(map_custom_world_library_entry_response)
.collect(),
},
))
}
pub async fn get_custom_world_library_detail(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(profile_id): Path<String>,
) -> Result<Json<Value>, Response> {
if profile_id.trim().is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-library",
"message": "profileId is required",
})),
));
}
let detail = state
.spacetime_client()
.get_custom_world_library_detail(authenticated.claims().user_id().to_string(), profile_id)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldGalleryDetailResponse {
entry: map_custom_world_library_entry_response(detail.entry),
},
))
}
pub async fn put_custom_world_library_profile(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(profile_id): Path<String>,
payload: Result<Json<CustomWorldProfileUpsertRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-library",
"message": error.body_text(),
})),
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
if profile_id.trim().is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-library",
"message": "profileId is required",
})),
));
}
let metadata = extract_custom_world_metadata(&payload.profile).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-library",
"message": error,
})),
)
})?;
let author_display_name = resolve_author_display_name(&state, &authenticated);
let author_public_user_code =
resolve_author_public_user_code(&state, &authenticated, &request_context)?;
let mutation = state
.spacetime_client()
.upsert_custom_world_profile(CustomWorldProfileUpsertRecordInput {
profile_id: profile_id.clone(),
owner_user_id: owner_user_id.clone(),
public_work_code: None,
author_public_user_code: Some(author_public_user_code),
source_agent_session_id: payload.source_agent_session_id.clone(),
world_name: metadata.world_name,
subtitle: metadata.subtitle,
summary_text: metadata.summary_text,
theme_mode: metadata.theme_mode,
cover_image_src: metadata.cover_image_src,
profile_payload_json: serde_json::to_string(&payload.profile).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-library",
"message": format!("profile JSON 序列化失败:{error}"),
})),
)
})?,
playable_npc_count: metadata.playable_npc_count,
landmark_count: metadata.landmark_count,
author_display_name,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldLibraryMutationResponse {
entry: map_custom_world_library_entry_response(mutation.entry.clone()),
entries: vec![map_custom_world_library_entry_response(mutation.entry)],
},
))
}
pub async fn delete_custom_world_library_profile(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(profile_id): Path<String>,
) -> Result<Json<Value>, Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
if profile_id.trim().is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-library",
"message": "profileId is required",
})),
));
}
let entries = state
.spacetime_client()
.delete_custom_world_profile(profile_id, owner_user_id, current_utc_micros())
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldLibraryResponse {
entries: entries
.into_iter()
.map(map_custom_world_library_entry_response)
.collect(),
},
))
}
pub async fn publish_custom_world_library_profile(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(profile_id): Path<String>,
) -> Result<Json<Value>, Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
if profile_id.trim().is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-library",
"message": "profileId is required",
})),
));
}
let mutation = state
.spacetime_client()
.publish_custom_world_profile(
profile_id,
owner_user_id,
None,
resolve_author_public_user_code(&state, &authenticated, &request_context)?,
resolve_author_display_name(&state, &authenticated),
current_utc_micros(),
)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldLibraryMutationResponse {
entry: map_custom_world_library_entry_response(mutation.entry.clone()),
entries: vec![map_custom_world_library_entry_response(mutation.entry)],
},
))
}
pub async fn unpublish_custom_world_library_profile(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(profile_id): Path<String>,
) -> Result<Json<Value>, Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
if profile_id.trim().is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-library",
"message": "profileId is required",
})),
));
}
let mutation = state
.spacetime_client()
.unpublish_custom_world_profile(
profile_id,
owner_user_id,
resolve_author_display_name(&state, &authenticated),
current_utc_micros(),
)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldLibraryMutationResponse {
entry: map_custom_world_library_entry_response(mutation.entry.clone()),
entries: vec![map_custom_world_library_entry_response(mutation.entry)],
},
))
}
pub async fn list_custom_world_gallery(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.list_custom_world_gallery_entries()
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldGalleryResponse {
entries: entries
.into_iter()
.map(map_custom_world_gallery_card_response)
.collect(),
},
))
}
pub async fn get_custom_world_gallery_detail(
State(state): State<AppState>,
Path((owner_user_id, profile_id)): Path<(String, String)>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-gallery",
"message": "ownerUserId and profileId are required",
})),
));
}
let detail = state
.spacetime_client()
.get_custom_world_gallery_detail(owner_user_id, profile_id)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldGalleryDetailResponse {
entry: map_custom_world_library_entry_response(detail.entry),
},
))
}
pub async fn get_custom_world_gallery_detail_by_code(
State(state): State<AppState>,
Path(code): Path<String>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
if code.trim().is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-gallery",
"message": "code is required",
})),
));
}
let detail = state
.spacetime_client()
.get_custom_world_gallery_detail_by_code(code)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldGalleryDetailResponse {
entry: map_custom_world_library_entry_response(detail.entry),
},
))
}
pub async fn create_custom_world_agent_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CreateCustomWorldAgentSessionRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": error.body_text(),
})),
)
})?;
let seed_text = payload.seed_text.unwrap_or_default().trim().to_string();
let welcome_message_text = build_custom_world_agent_welcome_text(&seed_text);
let session = state
.spacetime_client()
.create_custom_world_agent_session(CustomWorldAgentSessionCreateRecordInput {
session_id: build_prefixed_uuid_id("custom-world-agent-session-"),
owner_user_id: authenticated.claims().user_id().to_string(),
seed_text,
welcome_message_id: build_prefixed_uuid_id("message-"),
welcome_message_text,
anchor_content_json: empty_agent_anchor_content_json(),
creator_intent_json: Some(empty_json_object()),
creator_intent_readiness_json: empty_agent_creator_intent_readiness_json(),
anchor_pack_json: Some(empty_json_object()),
lock_state_json: Some(empty_json_object()),
draft_profile_json: Some(empty_json_object()),
pending_clarifications_json: empty_json_array(),
suggested_actions_json: empty_json_array(),
recommended_replies_json: empty_json_array(),
quality_findings_json: empty_json_array(),
asset_coverage_json: empty_agent_asset_coverage_json(),
checkpoints_json: empty_json_array(),
created_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldAgentSessionResponse {
session: map_custom_world_agent_session_response(session),
},
))
}
pub async fn get_custom_world_agent_session(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
if session_id.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": "sessionId is required",
})),
));
}
let session = state
.spacetime_client()
.get_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))
})?;
log_custom_world_publish_gate_diagnostics("get_session", &session);
Ok(json_success_body(
Some(&request_context),
map_custom_world_agent_session_response(session),
))
}
pub async fn get_custom_world_works(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_custom_world_works(authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldWorksResponse {
items: items
.into_iter()
.map(map_custom_world_work_summary_response)
.collect(),
},
))
}
pub async fn delete_custom_world_agent_session(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &session_id, "sessionId")?;
let items = state
.spacetime_client()
.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))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldWorksResponse {
items: items
.into_iter()
.map(map_custom_world_work_summary_response)
.collect(),
},
))
}
pub async fn get_custom_world_agent_card_detail(
State(state): State<AppState>,
Path((session_id, card_id)): Path<(String, String)>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
if session_id.trim().is_empty() || card_id.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": "sessionId and cardId are required",
})),
));
}
let card = state
.spacetime_client()
.get_custom_world_agent_card_detail(
session_id,
authenticated.claims().user_id().to_string(),
card_id,
)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldAgentCardDetailResponse {
card: map_custom_world_draft_card_detail_response(card),
},
))
}
pub async fn submit_custom_world_agent_message(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<SendCustomWorldAgentMessageRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": error.body_text(),
})),
)
})?;
if session_id.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": "sessionId is required",
})),
));
}
let client_message_id = payload.client_message_id.trim().to_string();
let message_text = payload.text.trim().to_string();
if client_message_id.is_empty() || message_text.is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "clientMessageId and text are required",
})),
));
}
let owner_user_id = authenticated.claims().user_id().to_string();
let operation_id = build_prefixed_uuid_id("operation-");
let submitted_at_micros = current_utc_micros();
let operation = state
.spacetime_client()
.submit_custom_world_agent_message(CustomWorldAgentMessageSubmitRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
user_message_id: client_message_id,
user_message_text: message_text,
operation_id: operation_id.clone(),
submitted_at_micros,
})
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
let session = state
.spacetime_client()
.get_custom_world_agent_session(session_id.clone(), owner_user_id.clone())
.await
.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(),
session: &session,
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
focus_card_id: payload.focus_card_id.clone(),
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
},
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(),
owner_user_id.clone(),
operation_id.clone(),
format!("assistant-{}", operation.operation_id),
turn_result,
current_utc_micros(),
),
Err(error) => build_failed_finalize_record_input(
session_id.clone(),
owner_user_id.clone(),
operation_id.clone(),
&session,
error.to_string(),
current_utc_micros(),
),
};
let finalized_operation = state
.spacetime_client()
.finalize_custom_world_agent_message(finalize_input)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
if finalized_operation.status == "failed" {
let message = finalized_operation
.error_message
.clone()
.unwrap_or_else(|| "消息处理失败".to_string());
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "custom-world-agent",
"message": message,
"operationId": finalized_operation.operation_id,
})),
));
}
Ok(json_success_body(
Some(&request_context),
json!({
"operation": map_custom_world_agent_operation_response(finalized_operation),
}),
))
}
pub async fn stream_custom_world_agent_message(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<SendCustomWorldAgentMessageRequest>, JsonRejection>,
) -> Result<Response, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": error.body_text(),
})),
)
})?;
if session_id.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": "sessionId is required",
})),
));
}
let client_message_id = payload.client_message_id.trim().to_string();
let message_text = payload.text.trim().to_string();
if client_message_id.is_empty() || message_text.is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "clientMessageId and text are required",
})),
));
}
let owner_user_id = authenticated.claims().user_id().to_string();
let operation = state
.spacetime_client()
.submit_custom_world_agent_message(CustomWorldAgentMessageSubmitRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
user_message_id: client_message_id,
user_message_text: message_text,
operation_id: build_prefixed_uuid_id("operation-"),
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
let session = state
.spacetime_client()
.get_custom_world_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
let quick_fill_requested = payload.quick_fill_requested.unwrap_or(false);
let focus_card_id = payload.focus_card_id.clone();
let state = state.clone();
let session_id_for_stream = session_id.clone();
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 (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
// Agent turn 仍负责完整 JSON 解析和最终写回;这里把 replyText 增量桥接成前端可见的 SSE 分片。
let turn_result = {
let run_turn = run_custom_world_agent_turn(
CustomWorldAgentTurnRequest {
llm_client: state.llm_client(),
session: &session,
quick_fill_requested,
focus_card_id,
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
},
move |text| {
let _ = reply_tx.send(text.to_string());
},
);
tokio::pin!(run_turn);
loop {
// 不等待最终 session 落库即可先推送回复进度session/done 仍在 finalize 成功后发送。
tokio::select! {
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>(custom_world_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),
));
}
}
}
}
};
while let Some(text) = reply_rx.recv().await {
draft_writer
.persist_visible_text(state.spacetime_client(), text.as_str())
.await;
yield Ok::<Event, Infallible>(custom_world_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),
));
}
let finalize_input = match turn_result {
Ok(turn_result) => build_finalize_record_input(
session_id_for_stream.clone(),
owner_user_id_for_stream.clone(),
operation_id.clone(),
format!("assistant-{operation_id}"),
turn_result,
current_utc_micros(),
),
Err(error) => build_failed_finalize_record_input(
session_id_for_stream.clone(),
owner_user_id_for_stream.clone(),
operation_id.clone(),
&session,
error.to_string(),
current_utc_micros(),
),
};
let finalize_result = state
.spacetime_client()
.finalize_custom_world_agent_message(finalize_input)
.await;
let _finalized_operation = match finalize_result {
Ok(operation) => operation,
Err(error) => {
yield Ok::<Event, Infallible>(custom_world_sse_json_event_or_error(
"error",
json!({ "message": error.to_string() }),
));
return;
}
};
if _finalized_operation.status == "failed" {
yield Ok::<Event, Infallible>(custom_world_sse_json_event_or_error(
"error",
json!({
"message": _finalized_operation
.error_message
.clone()
.unwrap_or_else(|| "消息处理失败".to_string())
}),
));
return;
}
let final_session = match state
.spacetime_client()
.get_custom_world_agent_session(
session_id_for_stream,
owner_user_id_for_stream,
)
.await
{
Ok(session) => session,
Err(error) => {
yield Ok::<Event, Infallible>(custom_world_sse_json_event_or_error(
"error",
json!({ "message": error.to_string() }),
));
return;
}
};
let session_response = map_custom_world_agent_session_response(final_session);
yield Ok::<Event, Infallible>(custom_world_sse_json_event_or_error(
"session",
json!({ "session": session_response }),
));
yield Ok::<Event, Infallible>(custom_world_sse_json_event_or_error(
"done",
json!({ "ok": true }),
));
};
Ok(Sse::new(stream).into_response())
}
pub async fn get_custom_world_agent_operation(
State(state): State<AppState>,
Path((session_id, operation_id)): Path<(String, String)>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
if session_id.trim().is_empty() || operation_id.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": "sessionId and operationId are required",
})),
));
}
let operation = state
.spacetime_client()
.get_custom_world_agent_operation(
session_id,
authenticated.claims().user_id().to_string(),
operation_id,
)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
map_custom_world_agent_operation_response(operation),
))
}
pub async fn execute_custom_world_agent_action(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<ExecuteCustomWorldAgentActionRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": error.body_text(),
})),
)
})?;
if session_id.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": "sessionId is required",
})),
));
}
let action = payload.action.trim().to_string();
if action.is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "action is required",
})),
));
}
let owner_user_id = authenticated.claims().user_id().to_string();
let submitted_at_micros = current_utc_micros();
let payload_json = if matches!(
action.as_str(),
"draft_foundation" | "generate_characters" | "generate_landmarks"
) {
let session = state
.spacetime_client()
.get_custom_world_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
let llm_client = state.llm_client().ok_or_else(|| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "custom-world-agent",
"message": "服务端尚未配置可用的 LLM API Key",
})),
)
})?;
if action == "draft_foundation" {
if session.progress_percent < 100 {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "draft_foundation requires progressPercent >= 100",
})),
));
}
let operation_id = build_prefixed_uuid_id("operation-");
let operation = state
.spacetime_client()
.upsert_custom_world_agent_operation_progress(
CustomWorldAgentOperationProgressRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
operation_id: operation_id.clone(),
operation_type: "draft_foundation".to_string(),
operation_status: "running".to_string(),
phase_label: "整理世界骨架".to_string(),
phase_detail: "正在校验已确认锚点,并准备第一版世界框架生成链路。"
.to_string(),
operation_progress: 12,
error_message: None,
updated_at_micros: submitted_at_micros,
},
)
.await
.map_err(|error| {
custom_world_error_response(
&request_context,
map_custom_world_client_error(error),
)
})?;
spawn_custom_world_draft_foundation_job(
state.clone(),
session,
owner_user_id,
operation_id,
payload,
);
return Ok(json_success_body(
Some(&request_context),
json!({
"operation": map_custom_world_agent_operation_response(operation),
}),
));
} else {
let generation_result =
generate_custom_world_agent_entities(llm_client, &session, &payload)
.await
.map_err(|message| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "custom-world-agent",
"message": message,
})),
)
})?;
generation_result.payload_json
}
} else if action == "publish_world" {
let mut publish_payload = serde_json::to_value(&payload).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?;
if let Some(object) = publish_payload.as_object_mut() {
// 发布到广场时必须写入真实作者公开信息,避免 gallery 投影落成匿名兜底数据。
object.insert(
"authorPublicUserCode".to_string(),
Value::String(resolve_author_public_user_code(
&state,
&authenticated,
&request_context,
)?),
);
object.insert(
"authorDisplayName".to_string(),
Value::String(resolve_author_display_name(&state, &authenticated)),
);
}
serde_json::to_string(&publish_payload).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?
} else {
serde_json::to_string(&payload).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?
};
let result = state
.spacetime_client()
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
operation_id: build_prefixed_uuid_id("operation-"),
action: action.clone(),
payload_json: Some(payload_json),
submitted_at_micros,
})
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
if matches!(
action.as_str(),
"sync_result_profile" | "publish_world" | "draft_foundation"
) {
if let Ok(session) = state
.spacetime_client()
.get_custom_world_agent_session(session_id.clone(), owner_user_id.clone())
.await
{
log_custom_world_publish_gate_diagnostics(action.as_str(), &session);
}
}
Ok(json_success_body(
Some(&request_context),
json!({
"operation": map_custom_world_agent_operation_response(result.operation),
}),
))
}
fn spawn_custom_world_draft_foundation_job(
state: AppState,
session: CustomWorldAgentSessionRecord,
owner_user_id: String,
operation_id: String,
payload: ExecuteCustomWorldAgentActionRequest,
) {
tokio::spawn(async move {
let Some(llm_client) = state.llm_client().cloned() else {
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"failed",
"底稿生成失败",
"服务端尚未配置可用的 LLM API Key",
DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_START,
Some("服务端尚未配置可用的 LLM API Key".to_string()),
)
.await;
return;
};
let progress_state = state.clone();
let progress_session_id = session.session_id.clone();
let progress_owner_user_id = owner_user_id.clone();
let progress_operation_id = operation_id.clone();
let existing_draft_profile = reusable_draft_profile_for_asset_generation(&session);
let draft_result = if let Some(profile) = existing_draft_profile {
// 失败后“继续生成草稿”复用已经写入 session 的底稿,
// 只继续执行素材补齐、草稿卡编译和结果页写回。
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"running",
"继续生成草稿",
"已读取上次保存的世界底稿,正在继续补齐素材与结果页。",
DRAFT_FOUNDATION_PROGRESS_ASSET_START,
None,
)
.await;
match serde_json::to_string(&profile) {
Ok(draft_profile_json) => Ok(
crate::custom_world_foundation_draft::CustomWorldFoundationDraftResult {
draft_profile_json,
},
),
Err(error) => Err(format!("已保存底稿序列化失败:{error}")),
}
} else {
generate_custom_world_foundation_draft(&llm_client, &session, move |progress| {
let progress_state = progress_state.clone();
let session_id = progress_session_id.clone();
let owner_user_id = progress_owner_user_id.clone();
let operation_id = progress_operation_id.clone();
tokio::spawn(async move {
let _ = upsert_custom_world_draft_foundation_progress(
&progress_state,
&session_id,
&owner_user_id,
&operation_id,
"running",
progress.phase_label.as_str(),
progress.phase_detail.as_str(),
progress.progress,
None,
)
.await;
});
})
.await
};
let draft_result = match draft_result {
Ok(result) => result,
Err(message) => {
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"failed",
"底稿生成失败",
message.clone().as_str(),
fallback_draft_foundation_failure_progress("底稿生成失败"),
Some(message.clone()),
)
.await;
return;
}
};
let mut draft_profile_json = draft_result.draft_profile_json;
let mut draft_profile_value = match serde_json::from_str::<Value>(&draft_profile_json) {
Ok(Value::Object(object)) => Value::Object(object),
Ok(_) => {
let message = "foundation draft JSON 必须是 object".to_string();
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"failed",
"底稿素材生成失败",
message.as_str(),
fallback_draft_foundation_failure_progress("底稿素材生成失败"),
Some(message.clone()),
)
.await;
return;
}
Err(error) => {
let message = format!("foundation draft JSON 非法:{error}");
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"failed",
"底稿素材生成失败",
message.as_str(),
fallback_draft_foundation_failure_progress("底稿素材生成失败"),
Some(message.clone()),
)
.await;
return;
}
};
let image_generation_limiter = Arc::new(Semaphore::new(
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();
// 角色主形象与幕背景图互不依赖,必须并行发起;上游生图请求统一限流,避免同批草稿瞬时打满供应商接口。
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,
image_generation_limiter.clone(),
)
.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,
image_generation_limiter.clone(),
)
.await
.map(|_| profile)
}
);
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,
&owner_user_id,
&operation_id,
&draft_profile_value,
phase_label,
message.as_str(),
)
.await;
return;
}
draft_profile_json = match serde_json::to_string(&draft_profile_value) {
Ok(value) => value,
Err(error) => {
let message = format!("带素材的 foundation draft JSON 序列化失败:{error}");
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"failed",
"底稿素材写回失败",
message.as_str(),
fallback_draft_foundation_failure_progress("底稿素材写回失败"),
Some(message.clone()),
)
.await;
return;
}
};
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"running",
"编译草稿卡",
"正在把世界底稿整理成可浏览的卡片摘要和详情结构。",
98,
None,
)
.await;
let payload_json =
match build_draft_foundation_action_payload_json(&payload, &draft_profile_json) {
Ok(value) => value,
Err(error) => {
let message = match error {
DraftFoundationPayloadError::SerializePayload(message) => message,
DraftFoundationPayloadError::InvalidPayloadShape => {
"action payload 必须是 object".to_string()
}
DraftFoundationPayloadError::InvalidGeneratedDraft(message) => message,
};
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"failed",
"底稿写入失败",
message.clone().as_str(),
fallback_draft_foundation_failure_progress("底稿写入失败"),
Some(message),
)
.await;
return;
}
};
if let Err(error) = state
.spacetime_client()
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
session_id: session.session_id.clone(),
owner_user_id: owner_user_id.clone(),
operation_id: operation_id.clone(),
action: "draft_foundation".to_string(),
payload_json: Some(payload_json),
submitted_at_micros: current_utc_micros(),
})
.await
{
let message = error.to_string();
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"failed",
"底稿写入失败",
message.clone().as_str(),
fallback_draft_foundation_failure_progress("底稿写入失败"),
Some(message),
)
.await;
}
});
}
async fn generate_draft_foundation_role_visuals(
state: &AppState,
session: &CustomWorldAgentSessionRecord,
owner_user_id: &str,
operation_id: &str,
draft_profile: &mut Value,
image_generation_limiter: Arc<Semaphore>,
) -> Result<(), String> {
let Some(profile_object) = draft_profile.as_object_mut() else {
return Err("foundation draft JSON 必须是 object".to_string());
};
let mut role_refs = Vec::new();
for key in ["playableNpcs", "storyNpcs"] {
if let Some(roles) = profile_object.get(key).and_then(Value::as_array) {
for index in 0..roles.len() {
role_refs.push((key.to_string(), index));
}
}
}
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)
.and_then(|roles| roles.get(index))
.cloned()
.unwrap_or(Value::Null);
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").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();
let task_limiter = image_generation_limiter.clone();
generation_tasks.spawn(async move {
let mut last_error = None;
for attempt in 1..=DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
let generation_result = {
let _permit = task_limiter
.acquire()
.await
.map_err(|error| format!("图片生成并发控制失效:{error}"))?;
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(),
)
.await
};
match generation_result {
Ok(generated) => {
return Ok::<_, String>((role_ref.key, role_ref.index, generated));
}
Err(error) => {
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),
))
.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)
.and_then(|roles| roles.get_mut(index))
.and_then(Value::as_object_mut)
{
role_object.insert("imageSrc".to_string(), Value::String(generated.image_src));
role_object.insert(
"generatedVisualAssetId".to_string(),
Value::String(generated.asset_id),
);
}
}
if !errors.is_empty() {
return Err(join_unique_error_messages(errors));
}
Ok(())
}
async fn generate_draft_foundation_act_backgrounds(
state: &AppState,
session: &CustomWorldAgentSessionRecord,
owner_user_id: &str,
operation_id: &str,
draft_profile: &mut Value,
image_generation_limiter: Arc<Semaphore>,
) -> Result<(), String> {
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,
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();
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| {
(
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.scene_name.as_str(),
act_ref.scene_description.as_str(),
act_ref.scene_image_prompt.as_str(),
)
.await
};
match generation_result {
Ok(generated) => {
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) => {
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),
))
.await;
}
}
}
}
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((chapter_index, act_index, message)) => {
mark_scene_act_background_generation_error(
draft_profile,
chapter_index,
act_index,
&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(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(
"backgroundImageSrc".to_string(),
Value::String(generated.image_src),
);
act_object.insert(
"backgroundAssetId".to_string(),
Value::String(generated.asset_id),
);
act_object.insert(
"generatedScenePrompt".to_string(),
Value::String(generated.prompt),
);
act_object.insert(
"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
.into_iter()
.map(|message| message.trim().to_string())
.filter(|message| !message.is_empty())
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>()
.join("")
}
struct RoleVisualGenerationRef {
key: String,
index: usize,
role_id: String,
name: String,
prompt: String,
}
struct SceneActGenerationRef {
chapter_index: usize,
act_index: usize,
scene_id: String,
scene_name: String,
scene_description: String,
prompt: String,
scene_image_prompt: String,
}
fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
let world_name =
json_text_from_value(draft_profile, "name").unwrap_or_else(|| "未命名世界".to_string());
let world_tone = json_text_from_value(draft_profile, "tone").unwrap_or_default();
let scene_context_by_id = collect_scene_context_by_id(draft_profile);
draft_profile
.get("sceneChapterBlueprints")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
.flat_map(|(chapter_index, chapter)| {
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(),
});
let scene_contexts = scene_context_by_id.clone();
let world_name = world_name.clone();
let world_tone = world_tone.clone();
chapter
.get("acts")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
.map(move |(act_index, act)| {
let prompt = json_first_text_from_value(
act,
&[
"backgroundPromptText",
"scenePromptText",
"visualPromptText",
"promptText",
"imagePromptText",
"backgroundPrompt",
"visualPrompt",
],
)
.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(),
});
let title = json_text_from_value(act, "title")
.unwrap_or_else(|| format!("{}", act_index + 1));
let summary = json_text_from_value(act, "summary").unwrap_or_default();
let act_goal = json_text_from_value(act, "actGoal").unwrap_or_default();
let transition_hook =
json_text_from_value(act, "transitionHook").unwrap_or_default();
let primary_role_name = json_first_text_from_value(
act,
&["primaryRoleName", "primaryRole", "mainRoleName"],
)
.unwrap_or_default();
let scene_image_prompt =
build_scene_act_background_image_prompt(SceneActBackgroundPromptParams {
world_name: world_name.as_str(),
world_tone: world_tone.as_str(),
scene_name: scene_context.name.as_str(),
title: title.as_str(),
summary: summary.as_str(),
act_goal: act_goal.as_str(),
transition_hook: transition_hook.as_str(),
primary_role_name: primary_role_name.as_str(),
support_role_names: collect_scene_act_support_role_names(act),
prompt_text: prompt.as_str(),
});
SceneActGenerationRef {
chapter_index,
act_index,
scene_id: act_scene_id,
scene_name: scene_context.name,
scene_description: scene_context.description,
prompt,
scene_image_prompt,
}
})
})
.collect()
}
#[derive(Clone, Debug)]
struct SceneImageContext {
id: String,
name: String,
description: 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(),
})
}
fn collect_scene_act_support_role_names(act: &Value) -> Vec<String> {
// 兼容旧 Node 自动资产链路可能写入的 supportRoleNames也兼容单字段字符串避免迁移后丢上下文。
let mut names = act
.get("supportRoleNames")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
names.extend(json_text_from_value(act, "supportRoleName"));
names.extend(json_text_from_value(act, "supportRoles"));
names
}
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.scene_name
));
}
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)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.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: fallback_draft_foundation_failure_progress(phase_label),
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,
fallback_draft_foundation_failure_progress(phase_label),
Some(error_message.to_string()),
)
.await;
}
}
async fn upsert_custom_world_draft_foundation_progress(
state: &AppState,
session_id: &str,
owner_user_id: &str,
operation_id: &str,
status: &str,
phase_label: &str,
phase_detail: &str,
progress: u32,
error_message: Option<String>,
) -> Result<CustomWorldAgentOperationRecord, SpacetimeClientError> {
state
.spacetime_client()
.upsert_custom_world_agent_operation_progress(
CustomWorldAgentOperationProgressRecordInput {
session_id: session_id.to_string(),
owner_user_id: owner_user_id.to_string(),
operation_id: operation_id.to_string(),
operation_type: "draft_foundation".to_string(),
operation_status: status.to_string(),
phase_label: phase_label.to_string(),
phase_detail: phase_detail.to_string(),
operation_progress: progress.min(100),
error_message,
updated_at_micros: current_utc_micros(),
},
)
.await
.map(|operation| {
info!(
operation_id = %operation.operation_id,
phase_label = %operation.phase_label,
progress = operation.progress,
"世界草稿生成阶段进度已写入"
);
operation
})
}
fn map_custom_world_library_entry_response(
entry: CustomWorldLibraryEntryRecord,
) -> CustomWorldLibraryEntryResponse {
CustomWorldLibraryEntryResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: entry.author_public_user_code,
profile: entry.profile,
visibility: entry.visibility,
published_at: entry.published_at,
updated_at: entry.updated_at,
author_display_name: entry.author_display_name,
world_name: entry.world_name,
subtitle: entry.subtitle,
summary_text: entry.summary_text,
cover_image_src: entry.cover_image_src,
theme_mode: entry.theme_mode,
playable_npc_count: entry.playable_npc_count,
landmark_count: entry.landmark_count,
}
}
fn map_custom_world_gallery_card_response(
entry: CustomWorldGalleryEntryRecord,
) -> CustomWorldGalleryCardResponse {
CustomWorldGalleryCardResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: entry.author_public_user_code,
visibility: entry.visibility,
published_at: entry.published_at,
updated_at: entry.updated_at,
author_display_name: entry.author_display_name,
world_name: entry.world_name,
subtitle: entry.subtitle,
summary_text: entry.summary_text,
cover_image_src: entry.cover_image_src,
theme_mode: entry.theme_mode,
playable_npc_count: entry.playable_npc_count,
landmark_count: entry.landmark_count,
}
}
fn map_custom_world_work_summary_response(
item: CustomWorldWorkSummaryRecord,
) -> CustomWorldWorkSummaryResponse {
CustomWorldWorkSummaryResponse {
work_id: item.work_id,
source_type: item.source_type,
status: item.status,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
cover_image_src: item.cover_image_src,
cover_render_mode: item.cover_render_mode,
cover_character_image_srcs: item.cover_character_image_srcs,
updated_at: item.updated_at,
published_at: item.published_at,
stage: item.stage,
stage_label: item.stage_label,
playable_npc_count: item.playable_npc_count,
landmark_count: item.landmark_count,
role_visual_ready_count: item.role_visual_ready_count,
role_animation_ready_count: item.role_animation_ready_count,
role_asset_summary_label: item.role_asset_summary_label,
session_id: item.session_id,
profile_id: item.profile_id,
can_resume: item.can_resume,
can_enter_world: item.can_enter_world,
blocker_count: item.blocker_count,
publish_ready: item.publish_ready,
}
}
fn map_custom_world_agent_session_response(
session: CustomWorldAgentSessionRecord,
) -> CustomWorldAgentSessionSnapshotResponse {
CustomWorldAgentSessionSnapshotResponse {
session_id: session.session_id,
current_turn: session.current_turn,
anchor_content: session.anchor_content,
progress_percent: session.progress_percent,
last_assistant_reply: session.last_assistant_reply,
stage: session.stage,
focus_card_id: session.focus_card_id,
creator_intent: session.creator_intent,
creator_intent_readiness: session.creator_intent_readiness,
anchor_pack: session.anchor_pack,
lock_state: session.lock_state,
draft_profile: session.draft_profile,
messages: session
.messages
.into_iter()
.map(map_custom_world_agent_message_response)
.collect(),
draft_cards: session
.draft_cards
.into_iter()
.map(map_custom_world_draft_card_response)
.collect(),
pending_clarifications: session.pending_clarifications,
suggested_actions: session.suggested_actions,
recommended_replies: session.recommended_replies,
quality_findings: session.quality_findings,
asset_coverage: session.asset_coverage,
checkpoints: session
.checkpoints
.into_iter()
.map(map_custom_world_agent_checkpoint_response)
.collect(),
supported_actions: session
.supported_actions
.into_iter()
.map(map_custom_world_supported_action_response)
.collect(),
publish_gate: session
.publish_gate
.map(map_custom_world_publish_gate_response),
result_preview: session.result_preview,
updated_at: session.updated_at,
}
}
fn log_custom_world_publish_gate_diagnostics(
source: &str,
session: &CustomWorldAgentSessionRecord,
) {
let draft_profile = session.draft_profile.as_object();
let preview_profile = session
.result_preview
.as_ref()
.and_then(|preview| preview.get("preview"))
.and_then(Value::as_object);
let profile = preview_profile.or(draft_profile);
let blocker_codes = session
.publish_gate
.as_ref()
.map(|gate| {
gate.blockers
.iter()
.map(|blocker| blocker.code.as_str())
.collect::<Vec<_>>()
.join(",")
})
.unwrap_or_default();
info!(
target: "custom_world.publish_gate",
source,
session_id = %session.session_id,
stage = %session.stage,
publish_ready = session.publish_gate.as_ref().map(|gate| gate.publish_ready).unwrap_or(false),
blocker_count = session.publish_gate.as_ref().map(|gate| gate.blocker_count).unwrap_or(0),
blocker_codes = %blocker_codes,
has_draft_profile = session.draft_profile.as_object().map(|value| !value.is_empty()).unwrap_or(false),
has_result_preview = session.result_preview.is_some(),
preview_source = session.result_preview.as_ref().and_then(|value| value.get("source")).and_then(serde_json::Value::as_str).unwrap_or(""),
has_world_hook = has_custom_world_publish_text(profile, &["worldHook", "creatorIntent.worldHook", "anchorContent.worldPromise", "anchorContent.worldPromise.hook", "settingText"]),
has_player_premise = has_custom_world_publish_text(profile, &["playerPremise", "creatorIntent.playerPremise", "anchorContent.playerEntryPoint", "anchorContent.playerEntryPoint.openingIdentity", "anchorContent.playerEntryPoint.openingProblem", "anchorContent.playerEntryPoint.entryMotivation"]),
has_core_conflicts = has_custom_world_non_empty_text_array(profile, "coreConflicts"),
has_main_chapter = has_custom_world_array(profile, "chapters") || has_custom_world_array(profile, "sceneChapterBlueprints") || has_custom_world_array(profile, "sceneChapters"),
has_scene_act = has_custom_world_scene_act(profile),
"custom world publish gate diagnostics"
);
}
fn has_custom_world_publish_text(profile: Option<&Map<String, Value>>, paths: &[&str]) -> bool {
paths.iter().any(|path| {
let mut segments = path.split('.');
let Some(first_segment) = segments.next() else {
return false;
};
let mut current = profile.and_then(|value| value.get(first_segment));
for segment in segments {
current = current.and_then(|value| value.get(segment));
}
current
.and_then(Value::as_str)
.map(str::trim)
.map(|value| !value.is_empty())
.unwrap_or(false)
})
}
fn has_custom_world_non_empty_text_array(profile: Option<&Map<String, Value>>, key: &str) -> bool {
profile
.and_then(|value| value.get(key))
.and_then(Value::as_array)
.map(|items| {
items.iter().any(|item| {
item.as_str()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
})
})
.unwrap_or(false)
}
fn has_custom_world_array(profile: Option<&Map<String, Value>>, key: &str) -> bool {
profile
.and_then(|value| value.get(key))
.and_then(Value::as_array)
.map(|items| !items.is_empty())
.unwrap_or(false)
}
fn has_custom_world_scene_act(profile: Option<&Map<String, Value>>) -> bool {
profile
.and_then(|value| {
value
.get("sceneChapterBlueprints")
.or_else(|| value.get("sceneChapters"))
})
.and_then(Value::as_array)
.map(|chapters| {
chapters.iter().any(|chapter| {
chapter
.get("acts")
.and_then(Value::as_array)
.map(|acts| !acts.is_empty())
.unwrap_or(false)
})
})
.unwrap_or(false)
}
fn map_custom_world_publish_gate_response(
gate: CustomWorldPublishGateRecord,
) -> CustomWorldPublishGateResponse {
CustomWorldPublishGateResponse {
profile_id: gate.profile_id,
blockers: gate
.blockers
.into_iter()
.map(map_custom_world_result_preview_blocker_response)
.collect(),
blocker_count: gate.blocker_count,
publish_ready: gate.publish_ready,
can_enter_world: gate.can_enter_world,
}
}
fn map_custom_world_agent_message_response(
message: CustomWorldAgentMessageRecord,
) -> CustomWorldAgentMessageResponse {
CustomWorldAgentMessageResponse {
id: message.message_id,
role: message.role,
kind: message.kind,
text: message.text,
created_at: message.created_at,
related_operation_id: message.related_operation_id,
}
}
fn map_custom_world_agent_operation_response(
operation: CustomWorldAgentOperationRecord,
) -> CustomWorldAgentOperationResponse {
CustomWorldAgentOperationResponse {
operation_id: operation.operation_id,
operation_type: operation.operation_type,
status: operation.status,
phase_label: operation.phase_label,
phase_detail: operation.phase_detail,
progress: operation.progress,
error: operation.error_message,
started_at: Some(timestamp_micros_to_rfc3339(operation.started_at_micros)),
updated_at: Some(timestamp_micros_to_rfc3339(operation.updated_at_micros)),
}
}
fn map_custom_world_draft_card_response(
card: CustomWorldDraftCardRecord,
) -> CustomWorldDraftCardSummaryResponse {
CustomWorldDraftCardSummaryResponse {
id: card.card_id,
kind: card.kind,
title: card.title,
subtitle: card.subtitle,
summary: card.summary,
status: card.status,
linked_ids: card.linked_ids,
warning_count: card.warning_count,
asset_status: card.asset_status,
asset_status_label: card.asset_status_label,
}
}
fn map_custom_world_draft_card_detail_response(
card: CustomWorldDraftCardDetailRecord,
) -> CustomWorldDraftCardDetailResponse {
CustomWorldDraftCardDetailResponse {
id: card.card_id,
kind: card.kind,
title: card.title,
sections: card
.sections
.into_iter()
.map(map_custom_world_draft_card_detail_section_response)
.collect(),
linked_ids: card.linked_ids,
locked: card.locked,
editable: card.editable,
editable_section_ids: card.editable_section_ids,
warning_messages: card.warning_messages,
asset_status: card.asset_status,
asset_status_label: card.asset_status_label,
}
}
fn map_custom_world_draft_card_detail_section_response(
section: CustomWorldDraftCardDetailSectionRecord,
) -> CustomWorldDraftCardDetailSectionResponse {
CustomWorldDraftCardDetailSectionResponse {
id: section.section_id,
label: section.label,
value: section.value,
}
}
fn map_custom_world_agent_checkpoint_response(
checkpoint: CustomWorldAgentCheckpointRecord,
) -> CustomWorldAgentCheckpointResponse {
CustomWorldAgentCheckpointResponse {
checkpoint_id: checkpoint.checkpoint_id,
created_at: checkpoint.created_at,
label: checkpoint.label,
}
}
fn map_custom_world_supported_action_response(
action: CustomWorldSupportedActionRecord,
) -> CustomWorldSupportedActionResponse {
CustomWorldSupportedActionResponse {
action: action.action,
enabled: action.enabled,
reason: action.reason,
}
}
fn map_custom_world_result_preview_blocker_response(
blocker: CustomWorldResultPreviewBlockerRecord,
) -> CustomWorldResultPreviewBlockerResponse {
CustomWorldResultPreviewBlockerResponse {
id: blocker.id,
code: blocker.code,
message: blocker.message,
}
}
fn map_custom_world_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Procedure(message)
if message.contains("custom_world_profile 不存在") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("custom_world_agent_session 不存在") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("custom_world_agent_operation 不存在") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("当前模型不可用")
|| message.contains("设定生成失败")
|| message.contains("解析失败")
|| message.contains("缺少有效回复") =>
{
StatusCode::BAD_GATEWAY
}
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn custom_world_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
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",
"message": format!("{field_name} is required"),
})),
));
}
Ok(())
}
fn custom_world_sse_json_event(event_name: &str, payload: Value) -> Result<Event, AppError> {
Event::default()
.event(event_name)
.json_data(payload)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "sse",
"message": format!("SSE payload 序列化失败:{error}"),
}))
})
}
fn custom_world_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
match custom_world_sse_json_event(event_name, payload) {
Ok(event) => event,
Err(_) => custom_world_sse_error_event_message("SSE payload 序列化失败".to_string()),
}
}
fn custom_world_sse_error_event_message(message: String) -> Event {
let payload = format!(
"{{\"message\":{}}}",
serde_json::to_string(&message)
.unwrap_or_else(|_| "\"SSE 错误事件序列化失败\"".to_string())
);
Event::default().event("error").data(payload)
}
fn resolve_author_display_name(
state: &AppState,
authenticated: &AuthenticatedAccessToken,
) -> String {
state
.auth_user_service()
.get_user_by_id(authenticated.claims().user_id())
.ok()
.flatten()
.map(|user| user.display_name)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "玩家".to_string())
}
fn resolve_author_public_user_code(
state: &AppState,
authenticated: &AuthenticatedAccessToken,
request_context: &RequestContext,
) -> Result<String, Response> {
state
.auth_user_service()
.get_user_by_id(authenticated.claims().user_id())
.map_err(|error| {
custom_world_error_response(
request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-library",
"message": format!("作者叙世号读取失败:{error}"),
})),
)
})?
.map(|user| user.public_user_code)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
custom_world_error_response(
request_context,
AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
"provider": "custom-world-library",
"message": "当前登录用户缺少叙世号",
})),
)
})
}
fn build_custom_world_agent_welcome_text(seed_text: &str) -> String {
if seed_text.trim().is_empty() {
return "我会先帮你把世界的核心锚点整理出来。你可以从世界钩子、玩家身份、主题氛围、核心冲突、关键关系或标志性元素开始。"
.to_string();
}
"我已经收到你的世界起点,会先把它整理成创作锚点。你可以继续补充玩家身份、核心冲突、关键关系或标志性元素。".to_string()
}
struct CustomWorldProfileMetadata {
world_name: String,
subtitle: String,
summary_text: String,
cover_image_src: Option<String>,
theme_mode: CustomWorldThemeMode,
playable_npc_count: u32,
landmark_count: u32,
}
fn extract_custom_world_metadata(profile: &Value) -> Result<CustomWorldProfileMetadata, String> {
let object = profile
.as_object()
.ok_or_else(|| "profile 必须是 JSON object".to_string())?;
let world_name = read_string_field(object, "name").unwrap_or_else(|| "未命名世界".to_string());
let subtitle = read_string_field(object, "subtitle").unwrap_or_default();
let summary_text = read_string_field(object, "summary").unwrap_or_default();
let cover_image_src = resolve_cover_image_src(object);
let theme_mode = read_string_field(object, "themeMode")
.and_then(|value| CustomWorldThemeMode::from_client_str(&value))
.unwrap_or(CustomWorldThemeMode::Mythic);
let playable_npc_count = count_profile_roles(object);
let landmark_count = object
.get("landmarks")
.and_then(Value::as_array)
.map(|entries| entries.len() as u32)
.unwrap_or(0);
Ok(CustomWorldProfileMetadata {
world_name,
subtitle,
summary_text,
cover_image_src,
theme_mode,
playable_npc_count,
landmark_count,
})
}
fn read_string_field(object: &Map<String, Value>, key: &str) -> Option<String> {
object
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn resolve_cover_image_src(object: &Map<String, Value>) -> Option<String> {
object
.get("cover")
.and_then(Value::as_object)
.and_then(|cover| read_string_field(cover, "imageSrc"))
.or_else(|| {
object
.get("camp")
.and_then(Value::as_object)
.and_then(|camp| read_string_field(camp, "imageSrc"))
})
.or_else(|| {
object
.get("landmarks")
.and_then(Value::as_array)
.and_then(|entries| entries.first())
.and_then(Value::as_object)
.and_then(|landmark| read_string_field(landmark, "imageSrc"))
})
}
fn count_profile_roles(object: &Map<String, Value>) -> u32 {
let playable = object
.get("playableNpcs")
.and_then(Value::as_array)
.map(|entries| entries.len() as u32)
.unwrap_or(0);
let story = object
.get("storyNpcs")
.and_then(Value::as_array)
.map(|entries| entries.len() as u32)
.unwrap_or(0);
playable.saturating_add(story)
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.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 incomplete_role_asset_text_draft_profile_is_not_reused() {
let mut session = CustomWorldAgentSessionRecord {
session_id: "session-with-broken-draft".to_string(),
seed_text: "深海异常调查".to_string(),
current_turn: 1,
anchor_content: json!({}),
progress_percent: 80,
last_assistant_reply: None,
stage: "foundation_review".to_string(),
focus_card_id: None,
creator_intent: json!({}),
creator_intent_readiness: json!({}),
anchor_pack: json!({}),
lock_state: json!({}),
draft_profile: json!({
"name": "深海裂隙",
"playableNpcs": [{
"name": "海洋生物学家",
"title": "深海观察员",
"role": "调查者",
"description": "记录异常海沟的人"
}],
"storyNpcs": []
}),
messages: Vec::new(),
draft_cards: Vec::new(),
pending_clarifications: Vec::new(),
suggested_actions: Vec::new(),
recommended_replies: Vec::new(),
quality_findings: Vec::new(),
asset_coverage: json!({}),
checkpoints: Vec::new(),
supported_actions: Vec::new(),
publish_gate: None,
result_preview: None,
updated_at: "2026-04-25T00:00:00Z".to_string(),
};
assert!(reusable_draft_profile_for_asset_generation(&session).is_none());
if let Some(role) = session
.draft_profile
.get_mut("playableNpcs")
.and_then(Value::as_array_mut)
.and_then(|roles| roles.first_mut())
.and_then(Value::as_object_mut)
{
role.insert(
"visualDescription".to_string(),
json!("防水研究外套挂满盐痕,护目镜映着蓝绿海光,手提样本箱。"),
);
role.insert(
"actionDescription".to_string(),
json!("蹲身取样并快速记录潮汐数据,遇险时护住样本箱后撤。"),
);
role.insert(
"sceneVisualDescription".to_string(),
json!("她常站在潮湿实验船甲板边,身后是发光海沟与摇晃仪器。"),
);
}
assert!(reusable_draft_profile_for_asset_generation(&session).is_some());
}
#[test]
fn collect_scene_act_refs_accepts_scene_prompt_text_alias() {
let draft_profile = json!({
"name": "雾港纪元",
"tone": "潮湿、悬疑、低照度",
"landmarks": [
{
"id": "scene-office",
"name": "旧港办公室",
"description": "旧港边缘的玻璃办公室,窗外能看到潮湿码头。"
}
],
"sceneChapterBlueprints": [
{
"sceneId": "scene-office",
"sceneName": "旧港办公室",
"acts": [
{
"title": "深夜工位",
"summary": "团队在凌晨三点继续赶版本。",
"actGoal": "找到丢失的部署钥匙",
"transitionHook": "电梯门在无人操作时打开",
"primaryRoleName": "林澈",
"supportRoleNames": ["阿岚"],
"scenePromptText": "现代创业公司办公室,凌晨灯光,紧张忙碌"
}
]
}
]
});
let act_refs = collect_scene_act_refs(&draft_profile);
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());
}
}