1
This commit is contained in:
@@ -10,7 +10,8 @@ use axum::{
|
||||
},
|
||||
};
|
||||
use module_custom_world::{
|
||||
CustomWorldThemeMode, empty_agent_anchor_content_json, empty_agent_asset_coverage_json,
|
||||
CustomWorldThemeMode, canonicalize_custom_world_profile_before_save,
|
||||
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};
|
||||
@@ -18,14 +19,15 @@ use shared_contracts::runtime::{
|
||||
CreateCustomWorldAgentSessionRequest, CustomWorldAgentCardDetailResponse,
|
||||
CustomWorldAgentCheckpointResponse, CustomWorldAgentMessageResponse,
|
||||
CustomWorldAgentOperationResponse, CustomWorldAgentSessionResponse,
|
||||
CustomWorldAgentSessionSnapshotResponse, CustomWorldDraftCardDetailResponse,
|
||||
CustomWorldDraftCardDetailSectionResponse, CustomWorldDraftCardSummaryResponse,
|
||||
CustomWorldGalleryCardResponse, CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryEntryResponse, CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse, CustomWorldProfileUpsertRequest, CustomWorldPublishGateResponse,
|
||||
CustomWorldAgentSessionSnapshotResponse, CustomWorldCreationResultViewResponse,
|
||||
CustomWorldDraftCardDetailResponse, CustomWorldDraftCardDetailSectionResponse,
|
||||
CustomWorldDraftCardSummaryResponse, CustomWorldGalleryCardResponse,
|
||||
CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryEntryResponse,
|
||||
CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse,
|
||||
CustomWorldProfileUpsertRequest, CustomWorldPublishGateResponse,
|
||||
CustomWorldResultPreviewBlockerResponse, CustomWorldSupportedActionResponse,
|
||||
CustomWorldWorkSummaryResponse, CustomWorldWorksResponse, ExecuteCustomWorldAgentActionRequest,
|
||||
SendCustomWorldAgentMessageRequest,
|
||||
GenerateCustomWorldProfileRequest, SendCustomWorldAgentMessageRequest,
|
||||
};
|
||||
use shared_kernel::build_prefixed_uuid_id;
|
||||
use spacetime_client::{
|
||||
@@ -62,7 +64,7 @@ use crate::{
|
||||
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,
|
||||
generate_custom_world_foundation_draft, stable_ascii_slug,
|
||||
},
|
||||
http_error::AppError,
|
||||
prompt::scene_background::{
|
||||
@@ -135,6 +137,251 @@ fn reusable_draft_profile_for_asset_generation(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_custom_world_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<GenerateCustomWorldProfileRequest>, 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-profile",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let setting_text = payload.setting_text.trim().to_string();
|
||||
if setting_text.is_empty() {
|
||||
return Err(custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-profile",
|
||||
"message": "settingText is required",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
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-profile",
|
||||
"message": "服务端尚未配置可用的 LLM API Key",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let generation_mode = normalize_profile_generation_mode(payload.generation_mode.as_deref());
|
||||
let creator_intent = payload.creator_intent.unwrap_or(Value::Null);
|
||||
let session = build_profile_generation_session(
|
||||
setting_text.clone(),
|
||||
creator_intent.clone(),
|
||||
authenticated.claims().user_id().to_string(),
|
||||
);
|
||||
|
||||
// 中文注释:profile 生成需要外部 LLM,必须留在 Axum/api-server;SpacetimeDB reducer 只接收确定结果。
|
||||
let result = generate_custom_world_foundation_draft(llm_client, &session, |_| {})
|
||||
.await
|
||||
.map_err(|message| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "custom-world-profile",
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let mut profile =
|
||||
serde_json::from_str::<Value>(&result.draft_profile_json).map_err(|error| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "custom-world-profile",
|
||||
"message": format!("profile JSON 解析失败:{error}"),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
finalize_generated_custom_world_profile(
|
||||
&mut profile,
|
||||
setting_text.as_str(),
|
||||
generation_mode,
|
||||
creator_intent,
|
||||
);
|
||||
|
||||
Ok(json_success_body(Some(&request_context), profile))
|
||||
}
|
||||
|
||||
fn normalize_profile_generation_mode(value: Option<&str>) -> &'static str {
|
||||
if value.is_some_and(|item| item.trim().eq_ignore_ascii_case("fast")) {
|
||||
"fast"
|
||||
} else {
|
||||
"full"
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_generation_session(
|
||||
setting_text: String,
|
||||
creator_intent: Value,
|
||||
owner_user_id: String,
|
||||
) -> CustomWorldAgentSessionRecord {
|
||||
CustomWorldAgentSessionRecord {
|
||||
session_id: build_prefixed_uuid_id("profile-generation-session-"),
|
||||
seed_text: setting_text,
|
||||
current_turn: 1,
|
||||
anchor_content: build_profile_generation_anchor_content(&creator_intent),
|
||||
progress_percent: 100,
|
||||
last_assistant_reply: None,
|
||||
stage: "foundation_review".to_string(),
|
||||
focus_card_id: None,
|
||||
creator_intent,
|
||||
creator_intent_readiness: json!({ "isReady": true }),
|
||||
anchor_pack: json!({}),
|
||||
lock_state: json!({}),
|
||||
draft_profile: Value::Null,
|
||||
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: format!("profile-generation:{owner_user_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_profile_generation_anchor_content(creator_intent: &Value) -> Value {
|
||||
let world_hook = read_value_path_text(creator_intent, &["worldHook"])
|
||||
.or_else(|| read_value_path_text(creator_intent, &["rawSettingText"]));
|
||||
let player_premise = read_value_path_text(creator_intent, &["playerPremise"]);
|
||||
let opening_situation = read_value_path_text(creator_intent, &["openingSituation"]);
|
||||
let core_conflicts = read_value_string_array(creator_intent, "coreConflicts");
|
||||
let iconic_elements = read_value_string_array(creator_intent, "iconicElements");
|
||||
|
||||
json!({
|
||||
"worldPromise": {
|
||||
"hook": world_hook.unwrap_or_default(),
|
||||
"differentiator": iconic_elements.first().cloned().unwrap_or_default(),
|
||||
"desiredExperience": read_value_string_array(creator_intent, "toneDirectives").join("、"),
|
||||
},
|
||||
"playerEntryPoint": {
|
||||
"openingIdentity": player_premise.unwrap_or_default(),
|
||||
"openingProblem": opening_situation.unwrap_or_default(),
|
||||
"entryMotivation": core_conflicts.first().cloned().unwrap_or_default(),
|
||||
},
|
||||
"coreConflict": {
|
||||
"conflicts": core_conflicts,
|
||||
},
|
||||
"iconicElements": iconic_elements,
|
||||
})
|
||||
}
|
||||
|
||||
fn finalize_generated_custom_world_profile(
|
||||
profile: &mut Value,
|
||||
setting_text: &str,
|
||||
generation_mode: &str,
|
||||
creator_intent: Value,
|
||||
) {
|
||||
if !profile.is_object() {
|
||||
*profile = json!({});
|
||||
}
|
||||
let Some(object) = profile.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
insert_profile_text_if_missing(
|
||||
object,
|
||||
"id",
|
||||
format!("custom-world-{}", stable_ascii_slug(setting_text)).as_str(),
|
||||
);
|
||||
insert_profile_text_if_missing(object, "settingText", setting_text);
|
||||
insert_profile_text_if_missing(object, "templateWorldType", "WUXIA");
|
||||
if !object
|
||||
.get("compatibilityTemplateWorldType")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
let template_world_type = object
|
||||
.get("templateWorldType")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("WUXIA")
|
||||
.to_string();
|
||||
object.insert(
|
||||
"compatibilityTemplateWorldType".to_string(),
|
||||
Value::String(template_world_type),
|
||||
);
|
||||
}
|
||||
if !object.get("items").is_some_and(Value::is_array) {
|
||||
object.insert("items".to_string(), Value::Array(Vec::new()));
|
||||
}
|
||||
object.insert(
|
||||
"generationMode".to_string(),
|
||||
Value::String(generation_mode.to_string()),
|
||||
);
|
||||
object.insert(
|
||||
"generationStatus".to_string(),
|
||||
Value::String(
|
||||
if generation_mode == "fast" {
|
||||
"key_only"
|
||||
} else {
|
||||
"complete"
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
if !matches!(creator_intent, Value::Null) {
|
||||
object.insert("creatorIntent".to_string(), creator_intent);
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_profile_text_if_missing(object: &mut Map<String, Value>, key: &str, fallback: &str) {
|
||||
if object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
object.insert(key.to_string(), Value::String(fallback.to_string()));
|
||||
}
|
||||
|
||||
fn read_value_path_text(value: &Value, path: &[&str]) -> Option<String> {
|
||||
let mut current = value;
|
||||
for segment in path {
|
||||
current = current.get(*segment)?;
|
||||
}
|
||||
current
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn read_value_string_array(value: &Value, key: &str) -> Vec<String> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str().map(str::trim))
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn missing_role_asset_text_report(draft_profile: &Value) -> Option<String> {
|
||||
let profile_object = draft_profile.as_object()?;
|
||||
let mut missing_items = Vec::new();
|
||||
@@ -245,15 +492,16 @@ pub async fn put_custom_world_library_profile(
|
||||
));
|
||||
}
|
||||
|
||||
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 (profile, metadata) = canonicalize_custom_world_library_profile_payload(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)?;
|
||||
@@ -270,7 +518,7 @@ pub async fn put_custom_world_library_profile(
|
||||
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| {
|
||||
profile_payload_json: serde_json::to_string(&profile).map_err(|error| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
@@ -600,6 +848,27 @@ pub async fn get_custom_world_agent_session(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_custom_world_agent_result_view(
|
||||
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 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_result_view", &session);
|
||||
let result_view = build_custom_world_creation_result_view_response(session);
|
||||
|
||||
Ok(json_success_body(Some(&request_context), result_view))
|
||||
}
|
||||
|
||||
pub async fn get_custom_world_works(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -1199,6 +1468,16 @@ pub async fn execute_custom_world_agent_action(
|
||||
})?;
|
||||
generation_result.payload_json
|
||||
}
|
||||
} else if action == "sync_result_profile" {
|
||||
serialize_sync_result_profile_action_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,
|
||||
})),
|
||||
)
|
||||
})?
|
||||
} else if action == "publish_world" {
|
||||
let mut publish_payload = serde_json::to_value(&payload).map_err(|error| {
|
||||
custom_world_error_response(
|
||||
@@ -1308,6 +1587,27 @@ pub async fn execute_custom_world_agent_action(
|
||||
))
|
||||
}
|
||||
|
||||
fn serialize_sync_result_profile_action_payload(
|
||||
payload: &ExecuteCustomWorldAgentActionRequest,
|
||||
) -> Result<String, String> {
|
||||
let mut payload_value = serde_json::to_value(payload)
|
||||
.map_err(|error| format!("action payload JSON 序列化失败:{error}"))?;
|
||||
if let Some(profile) = payload_value.get_mut("profile") {
|
||||
canonicalize_custom_world_profile_before_save(profile);
|
||||
}
|
||||
|
||||
serde_json::to_string(&payload_value)
|
||||
.map_err(|error| format!("action payload JSON 序列化失败:{error}"))
|
||||
}
|
||||
|
||||
fn canonicalize_custom_world_library_profile_payload(
|
||||
mut profile: Value,
|
||||
) -> Result<(Value, CustomWorldProfileMetadata), String> {
|
||||
canonicalize_custom_world_profile_before_save(&mut profile);
|
||||
let metadata = extract_custom_world_metadata(&profile)?;
|
||||
Ok((profile, metadata))
|
||||
}
|
||||
|
||||
fn spawn_custom_world_draft_foundation_job(
|
||||
state: AppState,
|
||||
session: CustomWorldAgentSessionRecord,
|
||||
@@ -2456,6 +2756,155 @@ fn map_custom_world_agent_session_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_custom_world_creation_result_view_response(
|
||||
session: CustomWorldAgentSessionRecord,
|
||||
) -> CustomWorldCreationResultViewResponse {
|
||||
let profile_from_preview = session
|
||||
.result_preview
|
||||
.as_ref()
|
||||
.and_then(|preview| preview.get("preview"))
|
||||
.and_then(normalize_json_object_value);
|
||||
let profile_from_draft =
|
||||
if profile_from_preview.is_none() && is_agent_result_stage(session.stage.as_str()) {
|
||||
normalize_json_object_value(&session.draft_profile)
|
||||
// 中文注释:legacyResultProfile 只在服务端作为历史会话恢复兜底,
|
||||
// 前端不再直接解释 legacy 字段的真相优先级。
|
||||
.or_else(|| {
|
||||
session
|
||||
.draft_profile
|
||||
.get("legacyResultProfile")
|
||||
.and_then(normalize_json_object_value)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (profile, profile_source) = match (profile_from_preview, profile_from_draft) {
|
||||
(Some(profile), _) => (Some(profile), "result_preview"),
|
||||
(None, Some(profile)) => (Some(profile), "draft_profile"),
|
||||
(None, None) => (None, "none"),
|
||||
};
|
||||
let publish_ready = session
|
||||
.publish_gate
|
||||
.as_ref()
|
||||
.map(|gate| gate.publish_ready)
|
||||
.or_else(|| {
|
||||
session
|
||||
.result_preview
|
||||
.as_ref()
|
||||
.and_then(|preview| preview.get("publishReady"))
|
||||
.and_then(Value::as_bool)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let can_enter_world = session
|
||||
.publish_gate
|
||||
.as_ref()
|
||||
.map(|gate| gate.can_enter_world)
|
||||
.or_else(|| {
|
||||
session
|
||||
.result_preview
|
||||
.as_ref()
|
||||
.and_then(|preview| preview.get("canEnterWorld"))
|
||||
.and_then(Value::as_bool)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let blocker_count = session
|
||||
.publish_gate
|
||||
.as_ref()
|
||||
.map(|gate| gate.blocker_count)
|
||||
.or_else(|| {
|
||||
session
|
||||
.result_preview
|
||||
.as_ref()
|
||||
.and_then(|preview| preview.get("blockers"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.len() as u32)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let has_profile = profile.is_some();
|
||||
let generation_failed = session.stage == "error"
|
||||
|| session
|
||||
.messages
|
||||
.iter()
|
||||
.any(|message| message.kind == "warning" && message.text.contains("失败"));
|
||||
let result_stage = is_agent_result_stage(session.stage.as_str());
|
||||
let (
|
||||
target_stage,
|
||||
generation_view_source,
|
||||
result_view_source,
|
||||
recovery_action,
|
||||
recovery_reason,
|
||||
) = if has_profile && result_stage {
|
||||
(
|
||||
"custom-world-result",
|
||||
None,
|
||||
Some("agent-draft"),
|
||||
"open_result",
|
||||
None,
|
||||
)
|
||||
} else if generation_failed {
|
||||
(
|
||||
"custom-world-generating",
|
||||
Some("agent-draft-foundation"),
|
||||
None,
|
||||
"resume_generation",
|
||||
Some("当前草稿生成失败或缺少结果预览,需要回到生成过程页继续处理。"),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"agent-workspace",
|
||||
None,
|
||||
None,
|
||||
"continue_agent",
|
||||
Some("当前会话还没有可打开的结果页真相源。"),
|
||||
)
|
||||
};
|
||||
let can_sync_result_profile = is_agent_result_profile_sync_stage(session.stage.as_str());
|
||||
|
||||
CustomWorldCreationResultViewResponse {
|
||||
session: map_custom_world_agent_session_response(session),
|
||||
profile,
|
||||
profile_source: profile_source.to_string(),
|
||||
target_stage: target_stage.to_string(),
|
||||
generation_view_source: generation_view_source.map(ToOwned::to_owned),
|
||||
result_view_source: result_view_source.map(ToOwned::to_owned),
|
||||
can_autosave_library: has_profile && result_stage,
|
||||
can_sync_result_profile,
|
||||
publish_ready,
|
||||
can_enter_world,
|
||||
blocker_count,
|
||||
recovery_action: recovery_action.to_string(),
|
||||
recovery_reason: recovery_reason.map(ToOwned::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_agent_result_stage(stage: &str) -> bool {
|
||||
matches!(
|
||||
stage,
|
||||
"object_refining"
|
||||
| "visual_refining"
|
||||
| "long_tail_review"
|
||||
| "ready_to_publish"
|
||||
| "published"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_agent_result_profile_sync_stage(stage: &str) -> bool {
|
||||
matches!(
|
||||
stage,
|
||||
"object_refining" | "visual_refining" | "long_tail_review" | "ready_to_publish"
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_json_object_value(value: &Value) -> Option<Value> {
|
||||
value.as_object().and_then(|object| {
|
||||
if object.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Object(object.clone()))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn log_custom_world_publish_gate_diagnostics(
|
||||
source: &str,
|
||||
session: &CustomWorldAgentSessionRecord,
|
||||
@@ -2918,6 +3367,10 @@ fn current_utc_micros() -> i64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{Router, body::Body, http::Request};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig};
|
||||
|
||||
#[test]
|
||||
fn incomplete_role_asset_text_draft_profile_is_not_reused() {
|
||||
@@ -2984,6 +3437,173 @@ mod tests {
|
||||
assert!(reusable_draft_profile_for_asset_generation(&session).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_profile_finalize_adds_required_frontend_fields() {
|
||||
let mut profile = json!({
|
||||
"name": "雾港归航",
|
||||
"summary": "守灯人与群岛议会围绕沉船旧案对峙。",
|
||||
"playableNpcs": [],
|
||||
"storyNpcs": [],
|
||||
"landmarks": []
|
||||
});
|
||||
|
||||
finalize_generated_custom_world_profile(
|
||||
&mut profile,
|
||||
"在失真的海图上追查一场被篡改的沉船事故。",
|
||||
"fast",
|
||||
json!({ "worldHook": "海图会撒谎" }),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
profile.get("settingText").and_then(Value::as_str),
|
||||
Some("在失真的海图上追查一场被篡改的沉船事故。")
|
||||
);
|
||||
assert_eq!(
|
||||
profile.get("generationMode").and_then(Value::as_str),
|
||||
Some("fast")
|
||||
);
|
||||
assert_eq!(
|
||||
profile.get("generationStatus").and_then(Value::as_str),
|
||||
Some("key_only")
|
||||
);
|
||||
assert_eq!(
|
||||
profile.get("templateWorldType").and_then(Value::as_str),
|
||||
Some("WUXIA")
|
||||
);
|
||||
assert!(profile.get("items").and_then(Value::as_array).is_some());
|
||||
assert!(
|
||||
profile
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.starts_with("custom-world-"))
|
||||
);
|
||||
assert!(profile.get("creatorIntent").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_result_profile_payload_is_canonicalized_on_server() {
|
||||
let payload = ExecuteCustomWorldAgentActionRequest {
|
||||
action: "sync_result_profile".to_string(),
|
||||
profile: Some(json!({
|
||||
"id": "cwprof_001",
|
||||
"settingText": "前端保存前不再改写这段文字",
|
||||
"creatorIntent": {
|
||||
"worldHook": "海图会在午夜改写群岛航路",
|
||||
"playerPremise": "玩家是失忆领航员",
|
||||
"openingSituation": "正在禁航区醒来",
|
||||
"themeKeywords": ["海雾"],
|
||||
"toneDirectives": ["悬疑"],
|
||||
"coreConflicts": ["议会隐瞒沉船真相"],
|
||||
"keyCharacters": [{
|
||||
"name": "顾潮音",
|
||||
"role": "守灯人",
|
||||
"relationToPlayer": "旧识",
|
||||
"hiddenHook": "掌握伪造海图"
|
||||
}],
|
||||
"iconicElements": ["会说谎的罗盘"]
|
||||
}
|
||||
})),
|
||||
profile_id: None,
|
||||
draft_profile: None,
|
||||
legacy_result_profile: None,
|
||||
setting_text: None,
|
||||
card_id: None,
|
||||
sections: None,
|
||||
count: None,
|
||||
role_type: None,
|
||||
prompt_text: None,
|
||||
anchor_card_ids: None,
|
||||
role_ids: None,
|
||||
role_id: None,
|
||||
scene_ids: None,
|
||||
portrait_path: None,
|
||||
generated_visual_asset_id: None,
|
||||
generated_animation_set_id: None,
|
||||
animation_map: None,
|
||||
scene_id: None,
|
||||
scene_kind: None,
|
||||
image_src: None,
|
||||
generated_scene_asset_id: None,
|
||||
generated_scene_prompt: None,
|
||||
generated_scene_model: None,
|
||||
checkpoint_id: None,
|
||||
};
|
||||
|
||||
let payload_json =
|
||||
serialize_sync_result_profile_action_payload(&payload).expect("payload serializes");
|
||||
let payload_value: Value =
|
||||
serde_json::from_str(&payload_json).expect("payload should be valid JSON");
|
||||
|
||||
assert_eq!(
|
||||
payload_value
|
||||
.get("profile")
|
||||
.and_then(|profile| profile.get("settingText"))
|
||||
.and_then(Value::as_str),
|
||||
Some(
|
||||
"世界一句话:海图会在午夜改写群岛航路\n玩家开局:玩家是失忆领航员;正在禁航区醒来\n主题气质:海雾 / 悬疑\n核心冲突:议会隐瞒沉船真相\n关键关系:顾潮音 · 守灯人 · 与玩家 旧识 · 暗线 掌握伪造海图\n标志元素:会说谎的罗盘"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_world_library_profile_payload_is_canonicalized_on_server() {
|
||||
let (profile, metadata) = canonicalize_custom_world_library_profile_payload(json!({
|
||||
"id": "cwprof_001",
|
||||
"name": "潮雾列岛",
|
||||
"summary": "群岛与旧灯塔之间的沉船疑案。",
|
||||
"settingText": "前端保存前不再改写这段文字",
|
||||
"playableNpcs": [{"id": "pc-1"}],
|
||||
"storyNpcs": [{"id": "story-1"}],
|
||||
"landmarks": [{"id": "scene-1"}],
|
||||
"creatorIntent": {
|
||||
"worldHook": "海图会在午夜改写群岛航路",
|
||||
"playerPremise": "玩家是失忆领航员",
|
||||
"openingSituation": "正在禁航区醒来",
|
||||
"themeKeywords": ["海雾"],
|
||||
"toneDirectives": ["悬疑"],
|
||||
"coreConflicts": ["议会隐瞒沉船真相"],
|
||||
"keyCharacters": [{
|
||||
"name": "顾潮音",
|
||||
"role": "守灯人",
|
||||
"relationToPlayer": "旧识",
|
||||
"hiddenHook": "掌握伪造海图"
|
||||
}],
|
||||
"iconicElements": ["会说谎的罗盘"]
|
||||
}
|
||||
}))
|
||||
.expect("profile should be canonicalized");
|
||||
|
||||
assert_eq!(metadata.world_name, "潮雾列岛");
|
||||
assert_eq!(metadata.playable_npc_count, 2);
|
||||
assert_eq!(metadata.landmark_count, 1);
|
||||
assert_eq!(
|
||||
profile.get("settingText").and_then(Value::as_str),
|
||||
Some(
|
||||
"世界一句话:海图会在午夜改写群岛航路\n玩家开局:玩家是失忆领航员;正在禁航区醒来\n主题气质:海雾 / 悬疑\n核心冲突:议会隐瞒沉船真相\n关键关系:顾潮音 · 守灯人 · 与玩家 旧识 · 暗线 掌握伪造海图\n标志元素:会说谎的罗盘"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_world_profile_generation_requires_authentication() {
|
||||
let app: Router =
|
||||
build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/custom-world/profile")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"settingText":"海雾会吞掉记错航线的人。"}"#))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_scene_act_refs_accepts_scene_prompt_text_alias() {
|
||||
let draft_profile = json!({
|
||||
|
||||
Reference in New Issue
Block a user