Merge branch 'master' into feat/agc_call_rec
Project CI / Repository checks (pull_request) Failing after 59s
Project CI / Frontend tests (pull_request) Successful in 3m0s
Project CI / Backend tests (pull_request) Successful in 6m40s
Project CI / Native shell tests (pull_request) Failing after 18m17s

This commit is contained in:
2026-09-03 12:57:49 +08:00
1454 changed files with 358 additions and 582058 deletions
@@ -1,233 +0,0 @@
use module_ai::{
AiTaskCreateInput, AiTaskKind, AiTaskStageBlueprint, AiTaskStageKind, AiTaskStageStartInput,
AiTextChunkAppendInput,
};
use serde_json::json;
use spacetime_client::{SpacetimeClient, SpacetimeClientError};
use std::sync::{Arc, Mutex};
use tracing::warn;
#[derive(Clone, Debug)]
pub(crate) struct AiGenerationDraftContext {
pub task_id: String,
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: String,
pub template_key: String,
pub operation_id: String,
}
impl AiGenerationDraftContext {
pub fn new(
template_key: &str,
owner_user_id: &str,
session_id: &str,
operation_id: &str,
request_label: &str,
) -> Self {
let normalized_template = normalize_identifier_segment(template_key);
let normalized_session = normalize_identifier_segment(session_id);
let normalized_operation = normalize_identifier_segment(operation_id);
Self {
// 生成过程草稿使用稳定 task_id,保证同一模板会话操作重试时能继续定位已有内容。
task_id: format!(
"task-draft-{normalized_template}-{normalized_session}-{normalized_operation}"
),
owner_user_id: owner_user_id.trim().to_string(),
request_label: request_label.trim().to_string(),
source_module: normalized_template,
source_entity_id: session_id.trim().to_string(),
template_key: template_key.trim().to_string(),
operation_id: operation_id.trim().to_string(),
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct AiGenerationDraftSink {
context: AiGenerationDraftContext,
client: SpacetimeClient,
next_sequence: Arc<Mutex<u32>>,
persisted_text: Arc<Mutex<String>>,
}
impl AiGenerationDraftSink {
pub fn new(context: AiGenerationDraftContext, client: SpacetimeClient) -> Self {
Self {
context,
client,
next_sequence: Arc::new(Mutex::new(1)),
persisted_text: Arc::new(Mutex::new(String::new())),
}
}
pub fn persist_visible_text_async(&self, visible_text: &str) {
let (sequence, delta_text) = {
let mut persisted_text = self
.persisted_text
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let delta_text = visible_text
.strip_prefix(persisted_text.as_str())
.unwrap_or(visible_text)
.to_string();
*persisted_text = visible_text.to_string();
if delta_text.trim().is_empty() {
return;
}
let mut next_sequence = self
.next_sequence
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let sequence = *next_sequence;
*next_sequence = next_sequence.saturating_add(1);
(sequence, delta_text)
};
let context = self.context.clone();
let client = self.client.clone();
tokio::spawn(async move {
if let Err(error) = client
.append_ai_text_chunk(AiTextChunkAppendInput {
task_id: context.task_id.clone(),
stage_kind: AiTaskStageKind::RequestModel,
sequence,
delta_text,
created_at_micros: current_utc_micros(),
})
.await
{
warn!(
task_id = %context.task_id,
sequence,
error = %error,
"AI 生成草稿后台增量落库失败,主生成流程继续执行"
);
}
});
}
}
#[derive(Debug)]
pub(crate) struct AiGenerationDraftWriter {
context: AiGenerationDraftContext,
next_sequence: u32,
persisted_text: String,
}
impl AiGenerationDraftWriter {
pub fn new(context: AiGenerationDraftContext) -> Self {
Self {
context,
next_sequence: 1,
persisted_text: String::new(),
}
}
pub async fn ensure_started(
&mut self,
client: &SpacetimeClient,
) -> Result<(), SpacetimeClientError> {
let now_micros = current_utc_micros();
match client
.create_ai_task(AiTaskCreateInput {
task_id: self.context.task_id.clone(),
task_kind: AiTaskKind::CustomWorldGeneration,
owner_user_id: self.context.owner_user_id.clone(),
request_label: self.context.request_label.clone(),
source_module: self.context.source_module.clone(),
source_entity_id: Some(self.context.source_entity_id.clone()),
request_payload_json: Some(
json!({
"templateKey": self.context.template_key,
"operationId": self.context.operation_id,
})
.to_string(),
),
stages: vec![AiTaskStageBlueprint {
stage_kind: AiTaskStageKind::RequestModel,
label: "请求模型".to_string(),
detail: "模板生成过程中持续写入模型已生成文本。".to_string(),
order: 1,
}],
created_at_micros: now_micros,
})
.await
{
Ok(_) => {}
Err(error) if is_duplicate_ai_task_error(&error) => {}
Err(error) => return Err(error),
}
client
.start_ai_task_stage(AiTaskStageStartInput {
task_id: self.context.task_id.clone(),
stage_kind: AiTaskStageKind::RequestModel,
started_at_micros: now_micros,
})
.await
}
pub async fn persist_visible_text(&mut self, client: &SpacetimeClient, visible_text: &str) {
let delta_text = match visible_text.strip_prefix(self.persisted_text.as_str()) {
Some(delta) => delta,
None => visible_text,
};
if delta_text.trim().is_empty() {
self.persisted_text = visible_text.to_string();
return;
}
let sequence = self.next_sequence;
self.next_sequence = self.next_sequence.saturating_add(1);
self.persisted_text = visible_text.to_string();
if let Err(error) = client
.append_ai_text_chunk(AiTextChunkAppendInput {
task_id: self.context.task_id.clone(),
stage_kind: AiTaskStageKind::RequestModel,
sequence,
delta_text: delta_text.to_string(),
created_at_micros: current_utc_micros(),
})
.await
{
warn!(
task_id = %self.context.task_id,
sequence,
error = %error,
"AI 生成草稿增量落库失败,主生成流程继续执行"
);
}
}
}
fn normalize_identifier_segment(value: &str) -> String {
let normalized = value
.trim()
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
character
} else {
'_'
}
})
.collect::<String>();
if normalized.is_empty() {
"unknown".to_string()
} else {
normalized
}
}
fn is_duplicate_ai_task_error(error: &SpacetimeClientError) -> bool {
error.to_string().contains("ai_task.task_id 已存在")
}
fn current_utc_micros() -> i64 {
time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64 / 1_000
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,336 +0,0 @@
use super::*;
use spacetime_client::SpacetimeClientStage;
pub(super) fn map_big_fish_session_response(
session: BigFishSessionRecord,
) -> BigFishSessionSnapshotResponse {
BigFishSessionSnapshotResponse {
session_id: session.session_id,
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: session.stage,
anchor_pack: map_big_fish_anchor_pack_response(session.anchor_pack),
draft: session.draft.map(map_big_fish_draft_response),
asset_slots: session
.asset_slots
.into_iter()
.map(map_big_fish_asset_slot_response)
.collect(),
asset_coverage: map_big_fish_asset_coverage_response(session.asset_coverage),
messages: session
.messages
.into_iter()
.map(map_big_fish_agent_message_response)
.collect(),
last_assistant_reply: session.last_assistant_reply,
publish_ready: session.publish_ready,
updated_at: session.updated_at,
}
}
pub(super) fn map_big_fish_anchor_pack_response(
anchor_pack: BigFishAnchorPackRecord,
) -> BigFishAnchorPackResponse {
BigFishAnchorPackResponse {
gameplay_promise: map_big_fish_anchor_item_response(anchor_pack.gameplay_promise),
ecology_visual_theme: map_big_fish_anchor_item_response(anchor_pack.ecology_visual_theme),
growth_ladder: map_big_fish_anchor_item_response(anchor_pack.growth_ladder),
risk_tempo: map_big_fish_anchor_item_response(anchor_pack.risk_tempo),
}
}
pub(super) fn map_big_fish_anchor_item_response(
anchor: BigFishAnchorItemRecord,
) -> BigFishAnchorItemResponse {
BigFishAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: anchor.value,
status: anchor.status,
}
}
pub(super) fn map_big_fish_draft_response(
draft: BigFishGameDraftRecord,
) -> BigFishGameDraftResponse {
BigFishGameDraftResponse {
title: draft.title,
subtitle: draft.subtitle,
core_fun: draft.core_fun,
ecology_theme: draft.ecology_theme,
levels: draft
.levels
.into_iter()
.map(map_big_fish_level_response)
.collect(),
background: map_big_fish_background_response(draft.background),
runtime_params: map_big_fish_runtime_params_response(draft.runtime_params),
}
}
pub(super) fn map_big_fish_level_response(
level: BigFishLevelBlueprintRecord,
) -> BigFishLevelBlueprintResponse {
BigFishLevelBlueprintResponse {
level: level.level,
name: level.name,
one_line_fantasy: level.one_line_fantasy,
text_description: level.text_description,
silhouette_direction: level.silhouette_direction,
size_ratio: level.size_ratio,
visual_description: level.visual_description,
visual_prompt_seed: level.visual_prompt_seed,
idle_motion_description: level.idle_motion_description,
move_motion_description: level.move_motion_description,
motion_prompt_seed: level.motion_prompt_seed,
merge_source_level: level.merge_source_level,
prey_window: level.prey_window,
threat_window: level.threat_window,
is_final_level: level.is_final_level,
}
}
pub(super) fn map_big_fish_background_response(
background: BigFishBackgroundBlueprintRecord,
) -> BigFishBackgroundBlueprintResponse {
BigFishBackgroundBlueprintResponse {
theme: background.theme,
color_mood: background.color_mood,
foreground_hints: background.foreground_hints,
midground_composition: background.midground_composition,
background_depth: background.background_depth,
safe_play_area_hint: background.safe_play_area_hint,
spawn_edge_hint: background.spawn_edge_hint,
background_prompt_seed: background.background_prompt_seed,
}
}
pub(super) fn map_big_fish_runtime_params_response(
params: BigFishRuntimeParamsRecord,
) -> BigFishRuntimeParamsResponse {
BigFishRuntimeParamsResponse {
level_count: params.level_count,
merge_count_per_upgrade: params.merge_count_per_upgrade,
spawn_target_count: params.spawn_target_count,
leader_move_speed: params.leader_move_speed,
follower_catch_up_speed: params.follower_catch_up_speed,
offscreen_cull_seconds: params.offscreen_cull_seconds,
prey_spawn_delta_levels: params.prey_spawn_delta_levels,
threat_spawn_delta_levels: params.threat_spawn_delta_levels,
win_level: params.win_level,
}
}
pub(super) fn map_big_fish_asset_slot_response(
slot: BigFishAssetSlotRecord,
) -> BigFishAssetSlotResponse {
BigFishAssetSlotResponse {
slot_id: slot.slot_id,
asset_kind: slot.asset_kind,
level: slot.level,
motion_key: slot.motion_key,
status: slot.status,
asset_url: slot.asset_url,
prompt_snapshot: slot.prompt_snapshot,
updated_at: slot.updated_at,
}
}
pub(super) fn map_big_fish_asset_coverage_response(
coverage: BigFishAssetCoverageRecord,
) -> BigFishAssetCoverageResponse {
BigFishAssetCoverageResponse {
level_main_image_ready_count: coverage.level_main_image_ready_count,
level_motion_ready_count: coverage.level_motion_ready_count,
background_ready: coverage.background_ready,
required_level_count: coverage.required_level_count,
publish_ready: coverage.publish_ready,
blockers: coverage.blockers,
}
}
pub(super) fn map_big_fish_run_response(
run: BigFishRuntimeRunRecord,
) -> BigFishRuntimeSnapshotResponse {
BigFishRuntimeSnapshotResponse {
run_id: run.run_id,
session_id: run.session_id,
status: run.status,
tick: run.tick,
player_level: run.player_level,
win_level: run.win_level,
leader_entity_id: run.leader_entity_id,
owned_entities: run
.owned_entities
.into_iter()
.map(map_big_fish_runtime_entity_response)
.collect(),
wild_entities: run
.wild_entities
.into_iter()
.map(map_big_fish_runtime_entity_response)
.collect(),
camera_center: map_big_fish_vector2_response(run.camera_center),
last_input: map_big_fish_vector2_response(run.last_input),
event_log: run.event_log,
updated_at: run.updated_at,
}
}
pub(super) fn map_big_fish_runtime_entity_response(
entity: BigFishRuntimeEntityRecord,
) -> BigFishRuntimeEntityResponse {
BigFishRuntimeEntityResponse {
entity_id: entity.entity_id,
level: entity.level,
position: map_big_fish_vector2_response(entity.position),
radius: entity.radius,
offscreen_seconds: entity.offscreen_seconds,
}
}
pub(super) fn map_big_fish_vector2_response(
vector: BigFishVector2Record,
) -> BigFishVector2Response {
BigFishVector2Response {
x: vector.x,
y: vector.y,
}
}
pub(super) async fn compile_big_fish_draft_only(
state: &AppState,
session_id: String,
owner_user_id: String,
now: i64,
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
// 中文注释:大鱼吃小鱼草稿阶段只负责编译结果页草稿,不在这一步串行生成主图、动作或背景。
// 这些资产统一留在结果页工坊按需触发,避免 compile action 因长耗时资产任务卡在首步等待态。
let session =
load_big_fish_session_with_retry(state, session_id.clone(), owner_user_id.clone()).await?;
let anchor_pack = map_record_anchor_pack_to_domain(&session.anchor_pack);
let compiled_draft =
compile_big_fish_draft_with_fallback(state.llm_client(), &anchor_pack).await;
let draft_json = serde_json::to_string(&compiled_draft).ok();
state
.spacetime_client()
.compile_big_fish_draft(BigFishDraftCompileRecordInput {
session_id,
owner_user_id,
draft_json,
compiled_at_micros: now,
})
.await
}
pub(super) async fn load_big_fish_session_with_retry(
state: &AppState,
session_id: String,
owner_user_id: String,
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
let mut last_retryable_error = None;
for attempt in 0..2 {
match state
.spacetime_client()
.get_big_fish_session(session_id.clone(), owner_user_id.clone())
.await
{
Ok(session) => return Ok(session),
Err(error @ SpacetimeClientError::Timeout(_))
| Err(error @ SpacetimeClientError::ConnectDropped) => {
last_retryable_error = Some(error);
if attempt == 0 {
sleep(Duration::from_millis(250)).await;
continue;
}
}
Err(error) => return Err(error),
}
}
Err(
last_retryable_error.unwrap_or(SpacetimeClientError::Timeout(
SpacetimeClientStage::ProcedureResult,
)),
)
}
pub(super) fn map_record_anchor_pack_to_domain(
anchor_pack: &BigFishAnchorPackRecord,
) -> module_big_fish::BigFishAnchorPack {
module_big_fish::BigFishAnchorPack {
gameplay_promise: map_record_anchor_item_to_domain(&anchor_pack.gameplay_promise),
ecology_visual_theme: map_record_anchor_item_to_domain(&anchor_pack.ecology_visual_theme),
growth_ladder: map_record_anchor_item_to_domain(&anchor_pack.growth_ladder),
risk_tempo: map_record_anchor_item_to_domain(&anchor_pack.risk_tempo),
}
}
pub(super) fn map_record_anchor_item_to_domain(
anchor_item: &BigFishAnchorItemRecord,
) -> module_big_fish::BigFishAnchorItem {
module_big_fish::BigFishAnchorItem {
key: anchor_item.key.clone(),
label: anchor_item.label.clone(),
value: anchor_item.value.clone(),
status: match anchor_item.status.as_str() {
"confirmed" => module_big_fish::BigFishAnchorStatus::Confirmed,
"locked" => module_big_fish::BigFishAnchorStatus::Locked,
"inferred" => module_big_fish::BigFishAnchorStatus::Inferred,
_ => module_big_fish::BigFishAnchorStatus::Missing,
},
}
}
pub(super) fn map_big_fish_agent_message_response(
message: BigFishAgentMessageRecord,
) -> BigFishAgentMessageResponse {
BigFishAgentMessageResponse {
id: message.message_id,
role: message.role,
kind: message.kind,
text: message.text,
created_at: message.created_at,
}
}
pub(super) fn map_big_fish_work_summary_response(
state: &AppState,
item: BigFishWorkSummaryRecord,
) -> BigFishWorkSummaryResponse {
let author = resolve_work_author_by_user_id(state, &item.owner_user_id, None, None);
BigFishWorkSummaryResponse {
work_id: item.work_id,
source_session_id: item.source_session_id,
owner_user_id: item.owner_user_id,
author_display_name: author.display_name,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
cover_image_src: item.cover_image_src,
status: item.status,
updated_at: current_timestamp_micros_to_string(item.updated_at_micros),
published_at: item
.published_at_micros
.map(current_timestamp_micros_to_string),
publish_ready: item.publish_ready,
level_count: item.level_count,
level_main_image_ready_count: item.level_main_image_ready_count,
level_motion_ready_count: item.level_motion_ready_count,
background_ready: item.background_ready,
play_count: item.play_count,
remix_count: item.remix_count,
like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
}
}
pub(super) fn build_big_fish_welcome_text(seed_text: &str) -> String {
if seed_text.trim().is_empty() {
return "我会先帮你确定大鱼吃小鱼的核心锚点。可以从主题生态、成长阶梯或风险节奏开始。"
.to_string();
}
"我已经收到你的玩法起点,会先把它整理成锚点并准备结果页草稿。".to_string()
}
@@ -1,302 +0,0 @@
use module_big_fish::{BigFishAnchorPack, BigFishAnchorStatus, BigFishCreationStage};
use platform_llm::LlmClient;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use spacetime_client::{BigFishMessageFinalizeRecordInput, BigFishSessionRecord};
use crate::creation_agent_llm_turn::{
CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn,
};
use crate::prompt::big_fish::{
BIG_FISH_AGENT_SYSTEM_PROMPT, build_big_fish_agent_prompt, serialize_record_anchor_pack,
};
#[derive(Clone, Debug)]
pub(crate) struct BigFishAgentTurnRequest<'a> {
pub llm_client: Option<&'a LlmClient>,
pub session: &'a BigFishSessionRecord,
pub quick_fill_requested: bool,
pub enable_web_search: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct BigFishAgentTurnResult {
pub assistant_reply_text: String,
pub stage: String,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct BigFishAgentTurnError {
message: String,
}
impl BigFishAgentTurnError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for BigFishAgentTurnError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for BigFishAgentTurnError {}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BigFishAgentModelOutput {
reply_text: String,
progress_percent: u32,
next_anchor_pack: BigFishAnchorPack,
}
pub(crate) async fn run_big_fish_agent_turn<F>(
request: BigFishAgentTurnRequest<'_>,
on_reply_update: F,
) -> Result<BigFishAgentTurnResult, BigFishAgentTurnError>
where
F: FnMut(&str),
{
let prompt = build_big_fish_agent_prompt(request.session, request.quick_fill_requested);
let turn_output = stream_creation_agent_json_turn(
request.llm_client,
format!("{BIG_FISH_AGENT_SYSTEM_PROMPT}\n\n{prompt}"),
"请按约定输出这一轮的 JSON。",
request.enable_web_search,
CreationAgentLlmTurnErrorMessages {
model_unavailable: "当前模型不可用,请稍后重试。",
generation_failed: "大鱼吃小鱼聊天生成失败,请稍后重试。",
parse_failed: "大鱼吃小鱼聊天结果解析失败,请稍后重试。",
},
on_reply_update,
BigFishAgentTurnError::new,
)
.await?;
let output = parse_big_fish_model_output(&turn_output.parsed)?;
Ok(BigFishAgentTurnResult {
assistant_reply_text: output.reply_text,
stage: BigFishCreationStage::CollectingAnchors.as_str().to_string(),
progress_percent: if request.quick_fill_requested {
100
} else {
output.progress_percent.min(100)
},
anchor_pack_json: serde_json::to_string(&output.next_anchor_pack)
.unwrap_or_else(|_| "{}".to_string()),
error_message: None,
})
}
pub(crate) fn build_finalize_record_input(
session_id: String,
owner_user_id: String,
assistant_message_id: String,
result: BigFishAgentTurnResult,
updated_at_micros: i64,
) -> BigFishMessageFinalizeRecordInput {
BigFishMessageFinalizeRecordInput {
session_id,
owner_user_id,
assistant_message_id: Some(assistant_message_id),
assistant_reply_text: Some(result.assistant_reply_text),
stage: result.stage,
progress_percent: result.progress_percent,
anchor_pack_json: result.anchor_pack_json,
error_message: result.error_message,
updated_at_micros,
}
}
pub(crate) fn build_failed_finalize_record_input(
session_id: String,
owner_user_id: String,
session: &BigFishSessionRecord,
error_message: String,
updated_at_micros: i64,
) -> BigFishMessageFinalizeRecordInput {
BigFishMessageFinalizeRecordInput {
session_id,
owner_user_id,
assistant_message_id: None,
assistant_reply_text: None,
stage: session.stage.clone(),
progress_percent: session.progress_percent,
anchor_pack_json: serialize_record_anchor_pack(&session.anchor_pack),
error_message: Some(error_message),
updated_at_micros,
}
}
fn parse_big_fish_model_output(
parsed: &JsonValue,
) -> Result<BigFishAgentModelOutput, BigFishAgentTurnError> {
let reply_text = parsed
.get("replyText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| BigFishAgentTurnError::new("大鱼吃小鱼聊天结果缺少有效回复,请稍后重试。"))?
.to_string();
let progress_percent = parsed
.get("progressPercent")
.and_then(JsonValue::as_u64)
.map(|value| value.min(100) as u32)
.unwrap_or(0);
let next_anchor_pack_value = parsed
.get("nextAnchorPack")
.cloned()
.ok_or_else(|| BigFishAgentTurnError::new("大鱼吃小鱼聊天结果缺少 nextAnchorPack。"))?;
let next_anchor_pack = parse_big_fish_model_anchor_pack(&next_anchor_pack_value)?;
Ok(BigFishAgentModelOutput {
reply_text,
progress_percent,
next_anchor_pack,
})
}
fn parse_big_fish_model_anchor_pack(
value: &JsonValue,
) -> Result<BigFishAnchorPack, BigFishAgentTurnError> {
Ok(BigFishAnchorPack {
// LLM 与 HTTP 契约使用 camelCase;SpacetimeDB 持久化结构保持 Rust snake_case,边界处必须显式翻译。
gameplay_promise: parse_big_fish_model_anchor_item(value, "gameplayPromise")?,
ecology_visual_theme: parse_big_fish_model_anchor_item(value, "ecologyVisualTheme")?,
growth_ladder: parse_big_fish_model_anchor_item(value, "growthLadder")?,
risk_tempo: parse_big_fish_model_anchor_item(value, "riskTempo")?,
})
}
fn parse_big_fish_model_anchor_item(
pack: &JsonValue,
field_name: &str,
) -> Result<module_big_fish::BigFishAnchorItem, BigFishAgentTurnError> {
let value = pack.get(field_name).ok_or_else(|| {
BigFishAgentTurnError::new(format!("大鱼吃小鱼 anchor pack 缺少 {field_name}。"))
})?;
let key = value
.get("key")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.unwrap_or(field_name)
.to_string();
let label = value
.get("label")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.unwrap_or_else(|| default_big_fish_anchor_label(field_name))
.to_string();
let item_value = value
.get("value")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default()
.to_string();
let status = value
.get("status")
.and_then(JsonValue::as_str)
.map(parse_big_fish_anchor_status)
.unwrap_or(BigFishAnchorStatus::Missing);
Ok(module_big_fish::BigFishAnchorItem {
key,
label,
value: item_value,
status,
})
}
fn default_big_fish_anchor_label(field_name: &str) -> &'static str {
match field_name {
"gameplayPromise" => "玩法承诺",
"ecologyVisualTheme" => "生态视觉主题",
"growthLadder" => "成长阶梯",
"riskTempo" => "风险节奏",
_ => "大鱼锚点",
}
}
fn parse_big_fish_anchor_status(value: &str) -> BigFishAnchorStatus {
match value {
"confirmed" => BigFishAnchorStatus::Confirmed,
"locked" => BigFishAnchorStatus::Locked,
"inferred" => BigFishAnchorStatus::Inferred,
_ => BigFishAnchorStatus::Missing,
}
}
#[cfg(test)]
mod tests {
use super::build_big_fish_agent_prompt;
fn anchor_item(
key: &str,
label: &str,
value: &str,
status: &str,
) -> spacetime_client::BigFishAnchorItemRecord {
spacetime_client::BigFishAnchorItemRecord {
key: key.to_string(),
label: label.to_string(),
value: value.to_string(),
status: status.to_string(),
}
}
fn empty_session_record() -> spacetime_client::BigFishSessionRecord {
spacetime_client::BigFishSessionRecord {
session_id: "big-fish-session-test".to_string(),
current_turn: 2,
progress_percent: 60,
stage: "collecting_anchors".to_string(),
anchor_pack: spacetime_client::BigFishAnchorPackRecord {
gameplay_promise: anchor_item(
"gameplayPromise",
"玩法承诺",
"微光小鱼逆袭深海巨兽",
"confirmed",
),
ecology_visual_theme: anchor_item(
"ecologyVisualTheme",
"生态视觉主题",
"幽蓝珊瑚海沟",
"confirmed",
),
growth_ladder: anchor_item("growthLadder", "成长阶梯", "", "missing"),
risk_tempo: anchor_item("riskTempo", "风险节奏", "", "missing"),
},
draft: None,
asset_slots: Vec::new(),
asset_coverage: spacetime_client::BigFishAssetCoverageRecord {
level_main_image_ready_count: 0,
level_motion_ready_count: 0,
background_ready: false,
required_level_count: 8,
publish_ready: false,
blockers: Vec::new(),
},
messages: Vec::new(),
last_assistant_reply: None,
publish_ready: false,
updated_at: "2026-04-24T10:00:00.000Z".to_string(),
}
}
#[test]
fn quick_fill_prompt_forbids_follow_up_questions() {
let prompt = build_big_fish_agent_prompt(&empty_session_record(), true);
assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字"));
assert!(prompt.contains("不要再继续提问"));
assert!(prompt.contains("progressPercent 直接输出为 100"));
}
}
@@ -1,306 +0,0 @@
use module_big_fish::{
BIG_FISH_MAX_LEVEL_COUNT, BIG_FISH_MIN_LEVEL_COUNT, BigFishAnchorPack, BigFishGameDraft,
BigFishLevelBlueprint, BigFishRuntimeParams, compile_default_draft,
};
use platform_llm::{LlmClient, LlmMessage, LlmRunRequest};
use serde::Deserialize;
use serde_json::Value as JsonValue;
use crate::creation_agent_llm_turn::parse_json_response_text;
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
const BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT: &str = r#"你是一个负责把“大鱼吃小鱼”玩法锚点编译成首版可实现草稿的中文玩法策划。
你必须直接输出单个 JSON 对象,不要输出 Markdown、代码块、解释、前言或补充说明。
硬约束:
1. 所有文案必须是中文。
2. 必须产出 6 到 12 级的连续等级阶梯,默认优先 8 级。
3. 每一级都必须有:name、oneLineFantasy、textDescription、visualDescription、idleMotionDescription、moveMotionDescription。
4. 每一级都必须体现等级递进关系:越高等级越大、越强、越有压迫感。
5. `visualPromptSeed` 必须能直接作为主图默认提示词。
6. `motionPromptSeed` 必须是该等级动作方向总提示词摘要,但不能替代具体 idle / move 描述。
7. `preyWindow` 和 `threatWindow` 必须是合法等级数组,围绕当前等级形成可玩窗口。
8. `background` 必须是竖屏 9:16 游戏背景口径,不出现主体和 UI。
9. `runtimeParams.levelCount` 必须与 levels 长度一致,`winLevel` 必须等于最高等级。
"#;
const BIG_FISH_DRAFT_JSON_REPAIR_SYSTEM_PROMPT: &str = "你是 JSON 修复器。\n你会收到一段本应为单个 JSON 对象的文本。\n你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。\n不要输出 Markdown、代码块、解释、注释或额外文字。";
#[derive(Debug)]
pub(crate) struct BigFishDraftCompileError(String);
impl BigFishDraftCompileError {
fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
impl std::fmt::Display for BigFishDraftCompileError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for BigFishDraftCompileError {}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BigFishDraftModelOutput {
title: String,
subtitle: String,
core_fun: String,
ecology_theme: String,
levels: Vec<BigFishLevelBlueprint>,
background: module_big_fish::BigFishBackgroundBlueprint,
runtime_params: BigFishRuntimeParams,
}
pub(crate) async fn compile_big_fish_draft_with_fallback(
llm_client: Option<&LlmClient>,
anchor_pack: &BigFishAnchorPack,
) -> BigFishGameDraft {
let fallback = compile_default_draft(anchor_pack);
let Some(llm_client) = llm_client else {
return fallback;
};
match request_big_fish_draft(llm_client, anchor_pack).await {
Ok(draft) => draft,
Err(error) => {
tracing::warn!(error = %error, "大鱼吃小鱼草稿结构化编译失败,回退到 deterministic fallback");
fallback
}
}
}
async fn request_big_fish_draft(
llm_client: &LlmClient,
anchor_pack: &BigFishAnchorPack,
) -> Result<BigFishGameDraft, BigFishDraftCompileError> {
let user_prompt = build_big_fish_draft_user_prompt(anchor_pack);
let parsed = request_big_fish_json_stage(
llm_client,
user_prompt,
"big-fish-draft-compile",
"大鱼吃小鱼草稿编译没有返回有效内容。",
)
.await?;
let output: BigFishDraftModelOutput = serde_json::from_value(parsed).map_err(|error| {
BigFishDraftCompileError::new(format!("大鱼吃小鱼草稿 JSON 结构非法:{error}"))
})?;
validate_big_fish_draft_output(&output)?;
Ok(BigFishGameDraft {
title: output.title,
subtitle: output.subtitle,
core_fun: output.core_fun,
ecology_theme: output.ecology_theme,
levels: output.levels,
background: output.background,
runtime_params: output.runtime_params,
})
}
async fn request_big_fish_json_stage(
llm_client: &LlmClient,
user_prompt: String,
debug_label: &str,
empty_response_message: &str,
) -> Result<JsonValue, BigFishDraftCompileError> {
let response = llm_client
.run(
LlmRunRequest::new(vec![
LlmMessage::system(BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT),
LlmMessage::user(user_prompt),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_openai_responses()
.with_web_search(true),
)
.await
.map_err(|error| {
BigFishDraftCompileError::new(format!("{debug_label} LLM 请求失败:{error}"))
})?;
let text = response.text.trim();
if text.is_empty() {
return Err(BigFishDraftCompileError::new(empty_response_message));
}
match parse_json_response_text(text) {
Ok(value) => Ok(value),
Err(_) => {
let repaired = llm_client
.run(
LlmRunRequest::new(vec![
LlmMessage::system(BIG_FISH_DRAFT_JSON_REPAIR_SYSTEM_PROMPT),
LlmMessage::user(format!(
"请把下面这段文本修复成单个合法 JSON 对象,不要补充额外解释:\n\n{text}"
)),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_openai_responses(),
)
.await
.map_err(|error| {
BigFishDraftCompileError::new(format!(
"{debug_label} JSON 修复请求失败:{error}"
))
})?;
parse_json_response_text(repaired.text.as_str()).map_err(|error| {
BigFishDraftCompileError::new(format!("{debug_label} JSON 解析失败:{error}"))
})
}
}
}
fn build_big_fish_draft_user_prompt(anchor_pack: &BigFishAnchorPack) -> String {
format!(
r#"请基于下面的大鱼吃小鱼玩法锚点,直接生成首版结果页草稿。
玩法承诺:{gameplay_promise}
生态与视觉母题:{ecology_visual_theme}
成长阶梯:{growth_ladder}
风险节奏:{risk_tempo}
请严格输出下列 JSON 结构:
{{
"title": "",
"subtitle": "",
"coreFun": "",
"ecologyTheme": "",
"levels": [
{{
"level": 1,
"name": "",
"oneLineFantasy": "",
"textDescription": "",
"silhouetteDirection": "",
"sizeRatio": 1.0,
"visualDescription": "",
"visualPromptSeed": "",
"idleMotionDescription": "",
"moveMotionDescription": "",
"motionPromptSeed": "",
"mergeSourceLevel": null,
"preyWindow": [1],
"threatWindow": [2],
"isFinalLevel": false
}}
],
"background": {{
"theme": "",
"colorMood": "",
"foregroundHints": "",
"midgroundComposition": "",
"backgroundDepth": "",
"safePlayAreaHint": "",
"spawnEdgeHint": "",
"backgroundPromptSeed": ""
}},
"runtimeParams": {{
"levelCount": 8,
"mergeCountPerUpgrade": 3,
"spawnTargetCount": 12,
"leaderMoveSpeed": 160,
"followerCatchUpSpeed": 120,
"offscreenCullSeconds": 3,
"preySpawnDeltaLevels": [1, 2],
"threatSpawnDeltaLevels": [1, 2],
"winLevel": 8
}}
}}
补充要求:
1. `title`、`subtitle`、`coreFun` 必须适合结果页直接展示。
2. 每一级 `textDescription` 要解释这一等级在成长链中的定位。
3. `visualDescription` 要能直接填入主图工坊。
4. `idleMotionDescription` 和 `moveMotionDescription` 要分别对应待机动作与移动动作工坊。
5. `visualPromptSeed` 必须是主图生成用的中文提示词,不要只写关键词。
6. `motionPromptSeed` 必须是该等级动作生成的总提示词摘要,要同时覆盖待机和移动方向。
7. 如果锚点没有明确等级数量,默认输出 8 级。
"#,
gameplay_promise = anchor_pack.gameplay_promise.value,
ecology_visual_theme = anchor_pack.ecology_visual_theme.value,
growth_ladder = anchor_pack.growth_ladder.value,
risk_tempo = anchor_pack.risk_tempo.value,
)
}
fn validate_big_fish_draft_output(
output: &BigFishDraftModelOutput,
) -> Result<(), BigFishDraftCompileError> {
let level_count = output.levels.len() as u32;
if !(BIG_FISH_MIN_LEVEL_COUNT..=BIG_FISH_MAX_LEVEL_COUNT).contains(&level_count) {
return Err(BigFishDraftCompileError::new(format!(
"大鱼吃小鱼草稿等级数非法:{level_count}"
)));
}
if output.runtime_params.level_count != level_count {
return Err(BigFishDraftCompileError::new(
"runtimeParams.levelCount 必须与 levels 数量一致",
));
}
if output.runtime_params.win_level != level_count {
return Err(BigFishDraftCompileError::new(
"runtimeParams.winLevel 必须等于最高等级",
));
}
for (index, level) in output.levels.iter().enumerate() {
let expected_level = (index + 1) as u32;
if level.level != expected_level {
return Err(BigFishDraftCompileError::new(format!(
"等级序号不连续:期望 {expected_level},实际 {}",
level.level
)));
}
ensure_non_empty(level.name.as_str(), "level.name")?;
ensure_non_empty(level.one_line_fantasy.as_str(), "level.oneLineFantasy")?;
ensure_non_empty(level.text_description.as_str(), "level.textDescription")?;
ensure_non_empty(level.visual_description.as_str(), "level.visualDescription")?;
ensure_non_empty(
level.idle_motion_description.as_str(),
"level.idleMotionDescription",
)?;
ensure_non_empty(
level.move_motion_description.as_str(),
"level.moveMotionDescription",
)?;
ensure_non_empty(level.visual_prompt_seed.as_str(), "level.visualPromptSeed")?;
ensure_non_empty(level.motion_prompt_seed.as_str(), "level.motionPromptSeed")?;
}
ensure_non_empty(output.title.as_str(), "title")?;
ensure_non_empty(output.subtitle.as_str(), "subtitle")?;
ensure_non_empty(output.core_fun.as_str(), "coreFun")?;
ensure_non_empty(output.ecology_theme.as_str(), "ecologyTheme")?;
Ok(())
}
fn ensure_non_empty(value: &str, field_name: &str) -> Result<(), BigFishDraftCompileError> {
if value.trim().is_empty() {
return Err(BigFishDraftCompileError::new(format!(
"{field_name} 不能为空"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use module_big_fish::infer_anchor_pack;
use super::build_big_fish_draft_user_prompt;
#[test]
fn big_fish_draft_prompt_requires_all_level_descriptions() {
let prompt = build_big_fish_draft_user_prompt(&infer_anchor_pack("深海机械鱼", None));
assert!(prompt.contains("textDescription"));
assert!(prompt.contains("visualDescription"));
assert!(prompt.contains("idleMotionDescription"));
assert!(prompt.contains("moveMotionDescription"));
assert!(prompt.contains("visualPromptSeed"));
assert!(prompt.contains("motionPromptSeed"));
}
}
@@ -1,127 +0,0 @@
{
"templates": [
{
"templateId": "rpg_world",
"displayName": "RPG 世界共创",
"creationGoal": "收束成可直接进入 RPG 运行时的世界、角色、冲突、入口与隐藏线索。",
"anchorQuestions": [
{
"key": "worldPromise",
"label": "世界承诺",
"question": "这个世界最想让玩家体验到什么独特承诺?",
"requiredEffect": "明确世界钩子、差异化体验与玩家期待。"
},
{
"key": "playerFantasy",
"label": "玩家幻想",
"question": "玩家进入后扮演谁、追求什么、害怕失去什么?",
"requiredEffect": "明确身份、目标与核心情绪压力。"
},
{
"key": "themeBoundary",
"label": "题材边界",
"question": "题材气质、视觉方向和禁忌边界分别是什么?",
"requiredEffect": "约束语气、审美与不可越界内容。"
},
{
"key": "playerEntryPoint",
"label": "玩家入口",
"question": "玩家第一幕以什么身份遇到什么问题?",
"requiredEffect": "形成可开局的身份、问题和行动动机。"
},
{
"key": "coreConflict",
"label": "核心冲突",
"question": "表层冲突、隐藏危机和首个触发冲突是什么?",
"requiredEffect": "建立运行时持续推进的矛盾发动机。"
},
{
"key": "keyRelationships",
"label": "关键关系",
"question": "哪些人物关系最能推动选择、秘密和代价?",
"requiredEffect": "形成 NPC、势力或亲密关系的互动张力。"
},
{
"key": "hiddenLine",
"label": "隐藏线",
"question": "世界真相如何被误导、铺垫并逐步揭示?",
"requiredEffect": "为中后期探索与反转留下结构化线索。"
},
{
"key": "iconicElements",
"label": "标志元素",
"question": "哪些标志物、机构、规则或意象让世界被记住?",
"requiredEffect": "提供可复用的视觉、剧情和系统记忆点。"
}
]
},
{
"templateId": "puzzle",
"displayName": "拼图共创",
"creationGoal": "收束成可以发布为拼图关卡的视觉方案。",
"anchorQuestions": [
{
"key": "themePromise",
"label": "题材",
"question": "这张拼图给玩家的题材和完成期待是什么?",
"requiredEffect": "明确拼图主题、辨识度和完成后的满足感。"
},
{
"key": "visualSubject",
"label": "画面主体",
"question": "画面中最需要被玩家一眼看懂的主体是什么?",
"requiredEffect": "明确主体、层级和可被切片识别的形状。"
},
{
"key": "visualMood",
"label": "视觉气质",
"question": "整体色彩、光线、情绪和美术风格是什么?",
"requiredEffect": "收束画面风格,避免结果图风格漂移。"
},
{
"key": "compositionHooks",
"label": "拼图记忆点",
"question": "哪些构图、纹理或局部细节会成为玩家拼接线索?",
"requiredEffect": "提供适合拼图玩法的边缘、块面和局部记忆点。"
},
{
"key": "tagsAndForbidden",
"label": "标签与禁忌",
"question": "需要保留哪些关键词,又必须避开什么内容?",
"requiredEffect": "锁定生成标签和负向约束。"
}
]
},
{
"templateId": "big_fish",
"displayName": "大鱼吃小鱼",
"creationGoal": "收束成可直接编译为竖屏大鱼吃小鱼玩法草稿的成长、生态、节奏方案。",
"anchorQuestions": [
{
"key": "gameplayPromise",
"label": "玩法爽点",
"question": "核心的吞噬成长爽点是什么?",
"requiredEffect": "明确玩家为什么要持续吞噬、升级和冒险。"
},
{
"key": "ecologyVisualTheme",
"label": "视觉主题",
"question": "场景和形象的视觉主题是什么?",
"requiredEffect": "提供后续角色图、动作图和背景图的一致视觉方向。"
},
{
"key": "growthLadder",
"label": "成长阶梯",
"question": "从弱小到终局巨兽的 6 到 12 级成长阶梯如何递进?",
"requiredEffect": "保证等级轮廓、体型、能力幻想逐级增强。"
},
{
"key": "riskTempo",
"label": "风险节奏",
"question": "玩家在每个阶段面对的威胁压力和爽快节奏如何变化?",
"requiredEffect": "确定猎物、威胁、压迫感和爽感之间的节奏比例。"
}
]
}
]
}
@@ -1,67 +0,0 @@
use std::sync::OnceLock;
use serde::Deserialize;
const ANCHOR_TEMPLATE_CONFIG: &str = include_str!("creation_agent_anchor_templates.json");
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CreationAgentAnchorTemplateConfig {
templates: Vec<CreationAgentAnchorTemplate>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CreationAgentAnchorTemplate {
pub template_id: String,
pub display_name: String,
pub creation_goal: String,
pub anchor_questions: Vec<CreationAgentAnchorQuestion>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CreationAgentAnchorQuestion {
pub key: String,
pub label: String,
pub question: String,
pub required_effect: String,
}
static ANCHOR_TEMPLATES: OnceLock<CreationAgentAnchorTemplateConfig> = OnceLock::new();
pub(crate) fn get_creation_agent_anchor_template(
template_id: &str,
) -> Option<&'static CreationAgentAnchorTemplate> {
load_creation_agent_anchor_templates()
.templates
.iter()
.find(|template| template.template_id == template_id)
}
pub(crate) fn render_anchor_question_block(template: &CreationAgentAnchorTemplate) -> String {
let mut lines = vec![
format!("模板目标:{}", template.creation_goal),
"".to_string(),
];
lines.push("本模板只通过以下锚点问题体现差异:".to_string());
for (index, question) in template.anchor_questions.iter().enumerate() {
lines.push(format!(
"{}. {}({} / {}):{};达成效果:{}",
index + 1,
question.label,
question.key,
template.display_name,
question.question,
question.required_effect
));
}
lines.join("\n")
}
fn load_creation_agent_anchor_templates() -> &'static CreationAgentAnchorTemplateConfig {
ANCHOR_TEMPLATES.get_or_init(|| {
serde_json::from_str(ANCHOR_TEMPLATE_CONFIG)
.expect("creation_agent_anchor_templates.json 必须是合法配置")
})
}
@@ -1,25 +0,0 @@
/// 共创聊天中“补充剩余关键字”的统一提示规则。
///
/// RPG、拼图、大鱼吃小鱼都通过聊天补充设定;这里集中维护点击补全后必须进入
/// 自行补齐、不可继续追问、进度推进到可提交状态的公共约束,避免各入口各写一套。
pub(crate) fn render_quick_fill_extra_rules(
acceptance_scope: &str,
forbidden_follow_up: &str,
completion_target: &str,
submit_hint: &str,
) -> String {
format!(
r#"用户刚刚主动要求你自动补充剩余关键字。
这表示用户接受你基于当前方向自行补完仍缺失的关键设定:{acceptance_scope}
本轮要求:
1. 不要再继续提问
2. {forbidden_follow_up}
3. 必须保留已有已确认内容,并直接补齐缺失或仍为空的关键项
4. 对你自行推断补齐的项,应标记或表达为系统推断;已有明确内容继续保持确认或锁定状态
5. progressPercent 直接输出为 100
6. {completion_target}
7. replyText 只做简短完成说明,引导用户可以{submit_hint},不能出现问号"#,
)
}
@@ -1,401 +0,0 @@
use std::io::{Cursor, Read};
use axum::{Json, extract::Extension, http::StatusCode};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use serde_json::{Value, json};
use shared_contracts::creation_agent_document_input::{
CreationAgentDocumentInputPayload, ParseCreationAgentDocumentInputRequest,
ParseCreationAgentDocumentInputResponse,
};
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
};
const MAX_DOCUMENT_INPUT_BYTES: usize = 256 * 1024;
const MAX_DOCUMENT_INPUT_BASE64_CHARS: usize = 360 * 1024;
const SUPPORTED_DOCUMENT_EXTENSIONS: &[&str] = &["txt", "md", "markdown", "docx", "csv", "json"];
pub async fn parse_creation_agent_document_input(
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<ParseCreationAgentDocumentInputRequest>,
) -> Result<Json<Value>, AppError> {
let file_name = normalize_file_name(&payload.file_name)?;
ensure_supported_extension(&file_name)?;
let content_base64 = payload.content_base64.trim();
if content_base64.len() > MAX_DOCUMENT_INPUT_BASE64_CHARS {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档过大,请上传 256KB 以内的文本文件。",
"field": "contentBase64",
"maxSizeBytes": MAX_DOCUMENT_INPUT_BYTES,
})),
);
}
let decoded = BASE64_STANDARD.decode(content_base64).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档内容编码无效,请重新选择文件。",
"field": "contentBase64",
}))
})?;
if decoded.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档内容为空,请选择有内容的文件。",
"field": "contentBase64",
})),
);
}
if decoded.len() > MAX_DOCUMENT_INPUT_BYTES {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档过大,请上传 256KB 以内的文本文件。",
"field": "contentBase64",
"maxSizeBytes": MAX_DOCUMENT_INPUT_BYTES,
"actualSizeBytes": decoded.len(),
})),
);
}
let extension = document_extension(&file_name)?;
let text = decode_document_text(&decoded, extension.as_str())?;
let normalized_text = normalize_document_text(&text);
if normalized_text.trim().is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档解析后没有可用文本,请换一个文件。",
"field": "contentBase64",
})),
);
}
Ok(json_success_body(
Some(&request_context),
ParseCreationAgentDocumentInputResponse {
document: CreationAgentDocumentInputPayload {
file_name,
content_type: payload
.content_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
size_bytes: decoded.len(),
text: normalized_text,
source_asset_id: None,
},
},
))
}
fn normalize_file_name(value: &str) -> Result<String, AppError> {
let normalized = value
.trim()
.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.trim()
.to_string();
if normalized.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "缺少文档文件名。",
"field": "fileName",
})),
);
}
Ok(normalized)
}
fn ensure_supported_extension(file_name: &str) -> Result<(), AppError> {
let extension = document_extension(file_name)?;
if !SUPPORTED_DOCUMENT_EXTENSIONS.contains(&extension.as_str()) {
return Err(unsupported_document_error(file_name));
}
Ok(())
}
fn document_extension(file_name: &str) -> Result<String, AppError> {
file_name
.rsplit_once('.')
.map(|(_, extension)| extension.trim().to_ascii_lowercase())
.filter(|extension| !extension.is_empty())
.ok_or_else(|| unsupported_document_error(file_name))
}
fn unsupported_document_error(file_name: &str) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "暂时只支持 txt、md、docx、csv、json 文档。",
"field": "fileName",
"fileName": file_name,
"supportedExtensions": SUPPORTED_DOCUMENT_EXTENSIONS,
}))
}
fn decode_document_text(bytes: &[u8], extension: &str) -> Result<String, AppError> {
if extension == "docx" {
return extract_docx_text(bytes);
}
String::from_utf8(bytes.to_vec()).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "暂时只支持 UTF-8 文本文档,请转换编码后再上传。",
"field": "contentBase64",
}))
})
}
fn extract_docx_text(bytes: &[u8]) -> Result<String, AppError> {
let reader = Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(reader).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "docx 文档结构无效,请重新选择文件。",
"field": "contentBase64",
}))
})?;
let mut document_xml = String::new();
archive
.by_name("word/document.xml")
.map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "docx 文档缺少正文内容。",
"field": "contentBase64",
}))
})?
.read_to_string(&mut document_xml)
.map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "docx 文档正文读取失败。",
"field": "contentBase64",
}))
})?;
Ok(extract_docx_visible_text(document_xml.as_str()))
}
fn extract_docx_visible_text(xml: &str) -> String {
let mut output = String::new();
let mut cursor = 0usize;
while let Some(start_offset) = xml[cursor..].find("<w:t") {
let start = cursor + start_offset;
let Some(tag_end_offset) = xml[start..].find('>') else {
break;
};
let text_start = start + tag_end_offset + 1;
let Some(end_offset) = xml[text_start..].find("</w:t>") else {
break;
};
let text_end = text_start + end_offset;
output.push_str(&decode_xml_text(&xml[text_start..text_end]));
cursor = text_end + "</w:t>".len();
if let Some(next_break) = xml[cursor..].find("<w:br") {
if next_break == 0 {
output.push('\n');
}
}
}
output
}
fn decode_xml_text(value: &str) -> String {
value
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&amp;", "&")
}
fn normalize_document_text(value: &str) -> String {
value
.trim_start_matches('\u{feff}')
.replace("\r\n", "\n")
.replace('\r', "\n")
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use axum::{body::Body, http::Request};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use http_body_util::BodyExt;
use module_auth::{PhoneAuthScene, PhoneLoginInput, SendPhoneCodeInput};
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
use tower::ServiceExt;
use super::MAX_DOCUMENT_INPUT_BASE64_CHARS;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::test]
async fn parse_document_input_returns_text_payload() {
let state = build_test_state("ok").await;
let access_token = seed_authenticated_token(&state, "13800138110").await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/creation-agent/document-inputs/parse")
.header("authorization", format!("Bearer {access_token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"fileName": "世界设定.md",
"contentType": "text/markdown",
"contentBase64": BASE64_STANDARD.encode("第一章\r\n潮湿的港口")
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), axum::http::StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["data"]["document"]["fileName"],
json!("世界设定.md")
);
assert_eq!(
payload["data"]["document"]["text"],
json!("第一章\n潮湿的港口")
);
}
#[tokio::test]
async fn parse_document_input_rejects_unsupported_extension() {
let state = build_test_state("bad-ext").await;
let access_token = seed_authenticated_token(&state, "13800138111").await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/creation-agent/document-inputs/parse")
.header("authorization", format!("Bearer {access_token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"fileName": "世界设定.docx",
"contentBase64": BASE64_STANDARD.encode("binary")
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn parse_document_input_rejects_large_base64_before_decode() {
let state = build_test_state("large-base64").await;
let access_token = seed_authenticated_token(&state, "13800138112").await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/creation-agent/document-inputs/parse")
.header("authorization", format!("Bearer {access_token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"fileName": "世界设定.txt",
"contentBase64": "A".repeat(MAX_DOCUMENT_INPUT_BASE64_CHARS + 1)
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
}
async fn seed_authenticated_token(state: &AppState, phone_number: &str) -> String {
let now = OffsetDateTime::now_utc();
state
.phone_auth_service()
.send_code(
SendPhoneCodeInput {
country_code: None,
pure_phone_number: phone_number.to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send");
let user = state
.phone_auth_service()
.login(
PhoneLoginInput {
country_code: None,
pure_phone_number: phone_number.to_string(),
verify_code: "123456".to_string(),
},
now + time::Duration::seconds(1),
)
.await
.expect("phone login should create user")
.user;
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: user.id.clone(),
session_id: state
.seed_test_refresh_session_for_user(&user, "sess_creation_doc_input"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: user.token_version,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some(user.display_name.clone()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
async fn build_test_state(label: &str) -> AppState {
let _ = label;
AppState::new(AppConfig::default()).expect("state should build")
}
}
File diff suppressed because it is too large Load Diff
@@ -1,491 +0,0 @@
use axum::{
Json,
body::Body,
extract::{Extension, State},
http::{HeaderMap, Request, StatusCode},
middleware::Next,
response::Response,
};
use serde_json::{Value, json};
use module_runtime::build_creation_entry_config_response;
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
pub use crate::modules::play_flow::resolve_creation_entry_route_id;
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PublicWorkInteractionAction {
Like,
Remix,
}
impl PublicWorkInteractionAction {
fn as_str(self) -> &'static str {
match self {
Self::Like => "like",
Self::Remix => "remix",
}
}
}
/// 中文注释:入口配置由 SpacetimeDB 表提供;api-server 只负责读取同一份配置并熔断运行态路由。
pub async fn get_creation_entry_config_handler(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
headers: HeaderMap,
) -> Result<Json<Value>, Response> {
let authenticated = crate::auth::optional_access_token_from_headers(
&state,
"/api/creation-entry/config".to_string(),
headers,
request_context.request_id().to_string(),
)
.await
.map_err(|error| creation_entry_error_response(&request_context, error))?;
let user_id = authenticated
.as_ref()
.map(|authenticated| authenticated.claims().user_id());
let config = state
.get_creation_entry_config_for_user(user_id)
.await
.map_err(|error| {
creation_entry_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
})),
)
})?;
Ok(json_success_body(Some(&request_context), config))
}
/// 中文注释:api-server 路由熔断只拦新建创作入口,不限制已有作品读取、发布作品游玩或公开广场浏览。
pub async fn require_creation_entry_route_enabled(
State(state): State<AppState>,
request: Request<Body>,
next: Next,
) -> Response {
let path = request.uri().path();
let route_id = resolve_creation_entry_route_id(path);
if route_id.is_some() {
let route_id = route_id.expect("route id should exist");
let request_context = request.extensions().get::<RequestContext>().cloned();
let request_id = request_context
.as_ref()
.map(|context| context.request_id().to_string())
.unwrap_or_else(|| "unknown".to_string());
let authenticated = match crate::auth::optional_access_token_from_headers(
&state,
path.to_string(),
request.headers().clone(),
request_id,
)
.await
{
Ok(authenticated) => authenticated,
Err(error) => return error.into(),
};
let user_id = authenticated
.as_ref()
.map(|authenticated| authenticated.claims().user_id());
match state
.is_creation_entry_route_enabled_for_user(route_id, user_id)
.await
{
Ok(true) => {}
Ok(false) => {
return AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("该玩法入口暂不可用")
.with_details(json!({
"reason": "creation_entry_disabled",
"creationTypeId": route_id,
}))
.into();
}
Err(error) => {
return AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("读取玩法入口配置失败")
.with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
.into();
}
}
}
next.run(request).await
}
/// 中文注释:公开作品互动配置只拦点赞 / 改造动作,不影响作品详情读取和正式游玩。
pub async fn require_public_work_interaction_enabled(
State(state): State<AppState>,
request: Request<Body>,
next: Next,
) -> Response {
let path = request.uri().path();
if let Some((source_type, action)) = resolve_public_work_interaction_route(path) {
match state
.is_public_work_interaction_enabled(source_type, action)
.await
{
Ok(true) => {}
Ok(false) => {
return AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("该作品互动暂不可用")
.with_details(json!({
"reason": "public_work_interaction_disabled",
"sourceType": source_type,
"action": action.as_str(),
}))
.into();
}
Err(error) => {
return AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("读取作品互动配置失败")
.with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
.into();
}
}
}
next.run(request).await
}
pub(crate) fn resolve_public_work_interaction_route(
path: &str,
) -> Option<(&'static str, PublicWorkInteractionAction)> {
let action = if path.ends_with("/like") {
PublicWorkInteractionAction::Like
} else if path.ends_with("/remix") {
PublicWorkInteractionAction::Remix
} else {
return None;
};
if path.starts_with("/api/runtime/custom-world-gallery/") {
return Some(("custom-world", action));
}
if path.starts_with("/api/runtime/big-fish/gallery/") {
return Some(("big-fish", action));
}
if path.starts_with("/api/runtime/puzzle/gallery/") {
return Some(("puzzle", action));
}
None
}
pub(crate) fn resolve_creation_entry_mud_point_cost_from_config(
config: &CreationEntryConfigResponse,
creation_type_id: &str,
fallback_cost: u64,
) -> u64 {
config
.creation_types
.iter()
.find(|item| item.id == creation_type_id)
.and_then(|item| item.unified_creation_spec.as_ref())
.map(|spec| u64::from(spec.mud_point_cost))
.filter(|cost| *cost > 0)
.unwrap_or(fallback_cost)
}
pub(crate) async fn resolve_creation_entry_mud_point_cost(
state: &AppState,
creation_type_id: &str,
fallback_cost: u64,
) -> u64 {
match state.get_creation_entry_config().await {
Ok(config) => resolve_creation_entry_mud_point_cost_from_config(
&config,
creation_type_id,
fallback_cost,
),
Err(error) => {
tracing::warn!(
creation_type_id,
fallback_cost,
error = %error,
"读取创作入口泥点成本失败,回退到代码默认值"
);
fallback_cost
}
}
}
fn creation_entry_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
/// 中文注释:本地 debug 兜底也来自后端领域默认种子,避免前端恢复硬编码入口配置。
pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigResponse {
build_creation_entry_config_response(module_runtime::CreationEntryConfigSnapshot {
config_id: module_runtime::CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(),
start_card: module_runtime::CreationEntryStartCardSnapshot {
title: module_runtime::DEFAULT_CREATION_ENTRY_START_TITLE.to_string(),
description: module_runtime::DEFAULT_CREATION_ENTRY_START_DESCRIPTION.to_string(),
idle_badge: module_runtime::DEFAULT_CREATION_ENTRY_START_IDLE_BADGE.to_string(),
busy_badge: module_runtime::DEFAULT_CREATION_ENTRY_START_BUSY_BADGE.to_string(),
},
type_modal: module_runtime::CreationEntryTypeModalSnapshot {
title: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(),
description: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(),
},
event_banner: module_runtime::CreationEntryEventBannerSnapshot {
title: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_TITLE.to_string(),
description: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION.to_string(),
cover_image_src: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC
.to_string(),
prize_pool_mud_points:
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS,
starts_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string(),
ends_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string(),
render_mode: "structured".to_string(),
html_code: None,
},
event_banners_json: Some(module_runtime::default_creation_entry_event_banners_json()),
creation_types: module_runtime::default_creation_entry_type_snapshots(0),
updated_at_micros: 0,
public_work_interactions_json: Some(
module_runtime::default_public_work_interaction_config_json(),
),
})
}
#[cfg(test)]
pub(crate) fn test_creation_entry_config_response() -> CreationEntryConfigResponse {
default_creation_entry_config_response()
}
#[cfg(test)]
mod tests {
use super::*;
use shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST;
#[test]
fn resolves_new_creation_paths_to_creation_type_ids() {
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/puzzle/agent/sessions"),
Some("puzzle"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/puzzle-clear/sessions"),
Some("puzzle-clear"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/puzzle/gallery/profile-1/remix"),
Some("puzzle"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/match3d/sessions"),
Some("match3d"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/square-hole/sessions"),
Some("square-hole"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
Some("visual-novel"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/big-fish/agent/sessions"),
Some("big-fish"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/custom-world/agent/sessions"),
Some("rpg"),
);
assert_eq!(
resolve_creation_entry_route_id(
"/api/runtime/custom-world-gallery/user-1/profile-1/remix"
),
Some("rpg"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/custom-world-gallery/user-1/profile-1"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/story/sessions/runtime"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/chat/npc/turn/stream"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/bark-battle/drafts"),
Some("bark-battle"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/wooden-fish/runs/run-1"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/puzzle-clear/runs/run-1"),
None,
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/wooden-fish/sessions"),
Some("wooden-fish"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/edutainment/baby-object-match/assets"),
Some("baby-object-match"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/edutainment/baby-love-drawing/magic"),
Some("baby-love-drawing"),
);
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
}
#[test]
fn resolves_public_work_interaction_routes() {
assert_eq!(
resolve_public_work_interaction_route("/api/runtime/puzzle/gallery/profile-1/like"),
Some(("puzzle", PublicWorkInteractionAction::Like)),
);
assert_eq!(
resolve_public_work_interaction_route(
"/api/runtime/custom-world-gallery/user-1/profile-1/remix"
),
Some(("custom-world", PublicWorkInteractionAction::Remix)),
);
assert_eq!(
resolve_public_work_interaction_route("/api/runtime/puzzle/gallery/profile-1"),
None,
);
assert_eq!(
resolve_public_work_interaction_route("/api/runtime/wooden-fish/runs/run-1"),
None,
);
}
#[test]
fn resolves_mud_point_cost_from_unified_creation_spec() {
let mut config = test_creation_entry_config_response();
let puzzle = config
.creation_types
.iter_mut()
.find(|item| item.id == "puzzle")
.expect("puzzle config should exist");
let spec = puzzle
.unified_creation_spec
.as_mut()
.expect("puzzle unified spec should exist");
spec.mud_point_cost = 8;
assert_eq!(
resolve_creation_entry_mud_point_cost_from_config(&config, "puzzle", 2),
8,
);
}
#[test]
fn resolves_mud_point_cost_with_fallback_for_legacy_config() {
let mut config = test_creation_entry_config_response();
let puzzle = config
.creation_types
.iter_mut()
.find(|item| item.id == "puzzle")
.expect("puzzle config should exist");
puzzle.unified_creation_spec = None;
assert_eq!(
resolve_creation_entry_mud_point_cost_from_config(&config, "puzzle", 2),
2,
);
assert_eq!(
resolve_creation_entry_mud_point_cost_from_config(
&config,
"missing-play",
u64::from(DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
),
u64::from(DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
);
}
#[test]
fn test_creation_entry_config_response_opens_bark_battle() {
let config = test_creation_entry_config_response();
let bark_battle = config
.creation_types
.iter()
.find(|item| item.id == "bark-battle")
.expect("test creation entry config should include bark-battle");
assert_eq!(bark_battle.title, "\u{6c6a}\u{6c6a}\u{58f0}\u{6d6a}");
assert!(bark_battle.visible);
assert!(bark_battle.open);
assert_eq!(bark_battle.badge, "\u{53ef}\u{521b}\u{5efa}");
assert_eq!(
bark_battle.image_src,
"/creation-type-references/bark-battle.webp"
);
}
#[test]
fn test_creation_entry_config_response_updates_jump_hop_metadata() {
let config = test_creation_entry_config_response();
let jump_hop = config
.creation_types
.iter()
.find(|item| item.id == "jump-hop")
.expect("test creation entry config should include jump-hop");
assert_eq!(jump_hop.title, "\u{8df3}\u{4e00}\u{8df3}");
assert!(jump_hop.visible);
assert!(jump_hop.open);
assert_eq!(jump_hop.badge, "\u{53ef}\u{521b}\u{5efa}");
assert_eq!(
jump_hop.subtitle,
"\u{4e3b}\u{9898}\u{9a71}\u{52a8}\u{5e73}\u{53f0}\u{8df3}\u{8dc3}"
);
assert_eq!(
jump_hop.image_src,
"/creation-type-references/jump-hop.webp"
);
}
#[test]
fn test_creation_entry_config_response_keeps_baby_object_match_visible() {
let config = test_creation_entry_config_response();
let baby_object_match = config
.creation_types
.iter()
.find(|item| item.id == "baby-object-match")
.expect("test creation entry config should include baby-object-match");
assert_eq!(baby_object_match.title, "\u{5b9d}\u{8d1d}\u{8bc6}\u{7269}");
assert!(baby_object_match.visible);
assert!(baby_object_match.open);
assert_eq!(baby_object_match.badge, "\u{53ef}\u{521b}\u{5efa}");
assert_eq!(baby_object_match.sort_order, 90);
assert_eq!(baby_object_match.category_id, "character");
assert_eq!(
baby_object_match.category_label,
"\u{89d2}\u{8272}\u{521b}\u{4f5c}"
);
assert_eq!(baby_object_match.category_sort_order, 40);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,69 +0,0 @@
use axum::response::sse::Event;
use serde::Serialize;
use serde_json::{Value, json};
use shared_contracts::creative_agent::{CreativeAgentErrorEvent, CreativeAgentSseEventType};
pub fn creative_sse_json_event<T>(event: CreativeAgentSseEventType, payload: T) -> Event
where
T: Serialize,
{
let event_name = creative_event_name(event);
match serde_json::to_value(payload)
.ok()
.and_then(|value| Event::default().event(event_name).json_data(value).ok())
{
Some(event) => event,
None => creative_sse_error_event(None, "SSE_SERIALIZE_FAILED", "SSE payload 序列化失败"),
}
}
pub fn creative_sse_json_value_event(event_name: &str, payload: Value) -> Event {
Event::default()
.event(event_name)
.json_data(payload)
.unwrap_or_else(|_| {
creative_sse_error_event(None, "SSE_SERIALIZE_FAILED", "SSE payload 序列化失败")
})
}
pub fn creative_sse_error_event(
session_id: Option<String>,
code: impl Into<String>,
message: impl Into<String>,
) -> Event {
let payload = serde_json::to_string(&CreativeAgentErrorEvent {
session_id,
code: code.into(),
message: message.into(),
recoverable: false,
})
.unwrap_or_else(|_| {
json!({
"sessionId": null,
"code": "SSE_ERROR_SERIALIZE_FAILED",
"message": "SSE 错误事件序列化失败",
"recoverable": false,
})
.to_string()
});
Event::default().event("error").data(payload)
}
fn creative_event_name(event: CreativeAgentSseEventType) -> &'static str {
match event {
CreativeAgentSseEventType::Stage => "stage",
CreativeAgentSseEventType::AgentMessageDelta => "agent_message_delta",
CreativeAgentSseEventType::ThoughtSummaryDelta => "thought_summary_delta",
CreativeAgentSseEventType::PuzzleTemplateCatalog => "puzzle_template_catalog",
CreativeAgentSseEventType::PuzzleTemplateSelection => "puzzle_template_selection",
CreativeAgentSseEventType::PuzzleCostRange => "puzzle_cost_range",
CreativeAgentSseEventType::PuzzleLevelPlan => "puzzle_level_plan",
CreativeAgentSseEventType::ToolStarted => "tool_started",
CreativeAgentSseEventType::ToolCompleted => "tool_completed",
CreativeAgentSseEventType::Reflection => "reflection",
CreativeAgentSseEventType::TargetSession => "target_session",
CreativeAgentSseEventType::Session => "session",
CreativeAgentSseEventType::Error => "error",
CreativeAgentSseEventType::Done => "done",
}
}
File diff suppressed because it is too large Load Diff
@@ -1,381 +0,0 @@
use super::*;
pub(super) fn map_custom_world_library_entry_response(
state: &AppState,
entry: CustomWorldLibraryEntryRecord,
) -> CustomWorldLibraryEntryResponse {
let author = resolve_work_author_by_user_id(
state,
&entry.owner_user_id,
Some(&entry.author_display_name),
entry.author_public_user_code.as_deref(),
);
CustomWorldLibraryEntryResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: author.public_user_code.or(entry.author_public_user_code),
profile: entry.profile,
visibility: entry.visibility,
published_at: entry.published_at,
updated_at: entry.updated_at,
author_display_name: 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,
play_count: entry.play_count,
remix_count: entry.remix_count,
like_count: entry.like_count,
recent_play_count_7d: 0,
}
}
pub(super) fn map_custom_world_library_entry_response_from_work_summary(
state: &AppState,
item: CustomWorldWorkSummaryRecord,
owner_user_id: &str,
) -> Option<CustomWorldLibraryEntryResponse> {
let profile_id = item.profile_id.as_ref()?.clone();
let profile = build_custom_world_library_list_profile_payload(&item, &profile_id);
let author = resolve_work_author_by_user_id(state, owner_user_id, None, None);
Some(CustomWorldLibraryEntryResponse {
owner_user_id: owner_user_id.to_string(),
public_work_code: (item.status == "published")
.then(|| build_public_work_code_from_profile_id(&profile_id)),
profile_id,
author_public_user_code: author.public_user_code,
profile,
visibility: item.status,
published_at: item.published_at,
updated_at: item.updated_at,
author_display_name: author.display_name,
world_name: item.title,
subtitle: item.subtitle,
summary_text: item.summary,
cover_image_src: item.cover_image_src,
theme_mode: "mythic".to_string(),
playable_npc_count: item.playable_npc_count,
landmark_count: item.landmark_count,
play_count: 0,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 0,
})
}
pub(super) fn build_public_work_code_from_profile_id(profile_id: &str) -> String {
let digits = profile_id
.chars()
.filter(|character| character.is_ascii_digit())
.collect::<String>();
let normalized_digits = if digits.is_empty() {
let checksum = profile_id.bytes().fold(0u32, |accumulator, value| {
accumulator.wrapping_mul(131) + u32::from(value)
});
format!("{:08}", checksum % 100_000_000)
} else {
format!("{:0>8}", &digits[digits.len().saturating_sub(8)..])
};
format!("CW-{normalized_digits}")
}
pub(super) fn build_custom_world_library_list_profile_payload(
item: &CustomWorldWorkSummaryRecord,
profile_id: &str,
) -> Value {
json!({
"id": profile_id,
"name": item.title,
"subtitle": item.subtitle,
"summary": item.summary,
"tone": "",
"playerGoal": "",
"settingText": "",
"themeMode": "mythic",
"templateWorldType": "WUXIA",
"compatibilityTemplateWorldType": Value::Null,
"cover": item.cover_image_src.as_ref().map(|image_src| json!({
"sourceType": "generated",
"imageSrc": image_src,
})),
"majorFactions": [],
"coreConflicts": [],
"playableNpcs": [],
"storyNpcs": [],
"items": [],
"camp": Value::Null,
"landmarks": [],
"ownedSettingLayers": Value::Null,
})
}
pub(super) fn map_public_work_custom_world_gallery_card_response(
state: &AppState,
entry: spacetime_client::PublicWorkGalleryEntryRecord,
) -> CustomWorldGalleryCardResponse {
let author = resolve_work_author_by_user_id(
state,
&entry.owner_user_id,
Some(&entry.author_display_name),
None,
);
CustomWorldGalleryCardResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: author.public_user_code.unwrap_or_default(),
visibility: "published".to_string(),
published_at: entry.published_at,
updated_at: entry.updated_at,
author_display_name: 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_tags
.first()
.cloned()
.unwrap_or_else(|| "mythic".to_string()),
playable_npc_count: 0,
landmark_count: 0,
play_count: entry.play_count,
remix_count: entry.remix_count,
like_count: entry.like_count,
recent_play_count_7d: entry.recent_play_count_7d,
}
}
pub(super) 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,
}
}
pub(super) 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,
}
}
pub(super) 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),
}
}
pub(super) fn is_agent_result_stage(stage: &str) -> bool {
matches!(
stage,
"object_refining"
| "visual_refining"
| "long_tail_review"
| "ready_to_publish"
| "published"
)
}
pub(super) fn is_agent_result_profile_sync_stage(stage: &str) -> bool {
matches!(
stage,
"object_refining" | "visual_refining" | "long_tail_review" | "ready_to_publish"
)
}
pub(super) 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()))
}
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,122 +0,0 @@
use super::opening_cg::{
map_asset_binding_prepare_error, map_asset_object_prepare_error,
map_custom_world_asset_oss_error, map_custom_world_asset_spacetime_error,
map_custom_world_generated_image_asset_error,
};
use super::*;
pub(super) async fn persist_custom_world_asset(
state: &AppState,
owner_user_id: &str,
upload: PreparedAssetUpload,
mut response: GeneratedAssetResponse,
) -> Result<GeneratedAssetResponse, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let http_client = reqwest::Client::new();
let PreparedAssetUpload {
prefix,
path_segments,
file_name,
content_type,
body,
asset_kind,
entity_kind,
entity_id,
profile_id,
slot,
source_job_id,
} = upload;
let file_stem = file_name
.rsplit_once('.')
.map(|(stem, _)| stem)
.unwrap_or(file_name.as_str())
.to_string();
let prepared =
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix,
path_segments,
file_stem,
image: GeneratedImageAssetDataUrl {
format: normalize_generated_image_asset_mime(content_type.as_str()),
bytes: body,
},
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(asset_kind.to_string()),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some(entity_kind.to_string()),
entity_id: Some(entity_id.clone()),
slot: Some(slot.to_string()),
provider: None,
task_id: source_job_id.clone(),
},
extra_metadata: profile_id
.as_ref()
.map(|profile_id| BTreeMap::from([("profile_id".to_string(), profile_id.clone())]))
.unwrap_or_default(),
})
.map_err(map_custom_world_generated_image_asset_error)?;
let persisted_mime_type = prepared.format.mime_type.clone();
let put_result = oss_client
.put_object(&http_client, prepared.request)
.await
.map_err(map_custom_world_asset_oss_error)?;
// custom world 图片链正式改为 OSS 真值确认,不再把 put_object 返回值直接当成唯一对象真相。
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(map_custom_world_asset_oss_error)?;
let now_micros = current_utc_micros();
let asset_object = state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(persisted_mime_type)),
head.content_length,
head.etag,
asset_kind.to_string(),
source_job_id,
Some(owner_user_id.to_string()),
profile_id.clone(),
Some(entity_id.clone()),
now_micros,
)
.map_err(map_asset_object_prepare_error)?,
)
.await
.map_err(map_custom_world_asset_spacetime_error)?;
state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(now_micros),
asset_object.asset_object_id,
entity_kind.to_string(),
entity_id.clone(),
slot.to_string(),
asset_kind.to_string(),
Some(owner_user_id.to_string()),
profile_id,
now_micros,
)
.map_err(map_asset_binding_prepare_error)?,
)
.await
.map_err(map_custom_world_asset_spacetime_error)?;
response.image_src = put_result.legacy_public_path;
Ok(response)
}
@@ -1,377 +0,0 @@
use super::*;
pub(super) async fn generate_opening_cg_storyboard(
state: &AppState,
owner_user_id: &str,
http_client: &reqwest::Client,
settings: &crate::openai_image_generation::OpenAiImageSettings,
normalized: &NormalizedOpeningCgRequest,
reference_images: &[String],
) -> Result<GeneratedOpeningCgStoryboard, AppError> {
let audit_settings = settings.clone();
let generated = create_openai_image_generation(
http_client,
&audit_settings,
normalized.storyboard_prompt.as_str(),
None,
OPENING_CG_STORYBOARD_IMAGE_SIZE,
1,
reference_images,
"开局 CG 故事板生成失败",
)
.await?;
let downloaded = generated
.images
.into_iter()
.next()
.map(downloaded_openai_to_custom_world_image)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "开局 CG 故事板生成成功但未返回图片。",
}))
})?;
let asset_id = format!("opening-cg-storyboard-{}", current_utc_millis());
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
path_segments: vec![
sanitize_storage_segment(
normalized
.profile_id
.as_deref()
.unwrap_or(normalized.world_name.as_str()),
"world",
),
"opening-cg".to_string(),
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
],
file_name: format!("storyboard.{}", downloaded.extension),
content_type: downloaded.mime_type,
body: downloaded.bytes,
asset_kind: OPENING_CG_STORYBOARD_ASSET_KIND,
entity_kind: OPENING_CG_ENTITY_KIND,
entity_id: normalized
.profile_id
.clone()
.unwrap_or_else(|| normalized.world_name.clone()),
profile_id: normalized.profile_id.clone(),
slot: OPENING_CG_STORYBOARD_SLOT,
source_job_id: Some(generated.task_id.clone()),
};
let asset = persist_custom_world_asset(
state,
owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some(GPT_IMAGE_2_MODEL.to_string()),
size: Some(OPENING_CG_STORYBOARD_IMAGE_SIZE.to_string()),
task_id: Some(generated.task_id.clone()),
prompt: Some(normalized.storyboard_prompt.clone()),
actual_prompt: generated.actual_prompt,
},
)
.await?;
Ok(GeneratedOpeningCgStoryboard {
image_src: asset.image_src,
asset_id,
})
}
pub(super) async fn generate_opening_cg_video(
state: &AppState,
owner_user_id: &str,
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
normalized: &NormalizedOpeningCgRequest,
storyboard_reference_data_url: &str,
) -> Result<GeneratedOpeningCgVideo, AppError> {
let upstream_task_id = create_ark_storyboard_to_video_task(
http_client,
settings,
normalized.video_prompt.as_str(),
storyboard_reference_data_url,
)
.await?;
let video_url =
wait_for_ark_content_generation_task(http_client, settings, upstream_task_id.as_str())
.await?;
let downloaded =
download_generated_video(http_client, video_url.as_str(), "下载开局 CG 视频失败").await?;
let asset_id = format!("opening-cg-video-{}", current_utc_millis());
let video_src = persist_opening_cg_video_asset(
state,
owner_user_id,
normalized,
asset_id.as_str(),
Some(upstream_task_id.clone()),
downloaded,
)
.await?;
Ok(GeneratedOpeningCgVideo {
video_src,
asset_id,
})
}
pub(super) async fn persist_opening_cg_video_asset(
state: &AppState,
owner_user_id: &str,
normalized: &NormalizedOpeningCgRequest,
asset_id: &str,
source_job_id: Option<String>,
video: DownloadedRemoteVideo,
) -> Result<String, AppError> {
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
path_segments: vec![
sanitize_storage_segment(
normalized
.profile_id
.as_deref()
.unwrap_or(normalized.world_name.as_str()),
"world",
),
"opening-cg".to_string(),
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
],
file_name: format!("opening.{}", video.extension),
content_type: video.mime_type,
body: video.bytes,
asset_kind: OPENING_CG_VIDEO_ASSET_KIND,
entity_kind: OPENING_CG_ENTITY_KIND,
entity_id: normalized
.profile_id
.clone()
.unwrap_or_else(|| normalized.world_name.clone()),
profile_id: normalized.profile_id.clone(),
slot: OPENING_CG_VIDEO_SLOT,
source_job_id,
};
let asset = persist_custom_world_asset(
state,
owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.to_string(),
source_type: "generated".to_string(),
model: Some("ark-seedance".to_string()),
size: Some(format!(
"{}:{}:{}s",
OPENING_CG_VIDEO_RESOLUTION,
OPENING_CG_VIDEO_RATIO,
OPENING_CG_VIDEO_DURATION_SECONDS
)),
task_id: None,
prompt: Some(normalized.video_prompt.clone()),
actual_prompt: None,
},
)
.await?;
Ok(asset.image_src)
}
async fn create_ark_storyboard_to_video_task(
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
prompt: &str,
storyboard_reference_data_url: &str,
) -> Result<String, AppError> {
let response = http_client
.post(format!("{}/contents/generations/tasks", settings.base_url))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&json!({
"model": settings.model,
"content": [
{
"type": "text",
"text": prompt,
},
{
"type": "image_url",
"image_url": {
"url": storyboard_reference_data_url,
},
"role": "reference_image",
}
],
"resolution": OPENING_CG_VIDEO_RESOLUTION,
"ratio": OPENING_CG_VIDEO_RATIO,
"duration": OPENING_CG_VIDEO_DURATION_SECONDS,
"watermark": false,
"audio": true,
"generate_audio": true,
"web_search": true,
"enable_web_search": true,
}))
.send()
.await
.map_err(|error| {
map_ark_video_request_error(format!("请求 Seedance 视频服务失败:{error}"))
})?;
let status = response.status();
let text = response.text().await.map_err(|error| {
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
})?;
if !status.is_success() {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"创建开局 CG 视频任务失败。",
));
}
let payload = parse_ark_video_json_payload(text.as_str(), "创建开局 CG 视频任务失败。")?;
extract_ark_task_id(&payload.payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频任务未返回任务 id。",
}))
})
}
async fn wait_for_ark_content_generation_task(
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
task_id: &str,
) -> Result<String, AppError> {
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while Instant::now() < deadline {
let response = http_client
.get(format!(
"{}/contents/generations/tasks/{}",
settings.base_url, task_id
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_ark_video_request_error(format!("查询 Seedance 视频任务失败:{error}"))
})?;
let status = response.status();
let text = response.text().await.map_err(|error| {
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
})?;
if !status.is_success() {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"查询开局 CG 视频任务失败。",
));
}
let payload = parse_ark_video_json_payload(text.as_str(), "查询开局 CG 视频任务失败。")?;
if let Some(video_url) = extract_video_url(&payload.payload) {
return Ok(video_url);
}
let normalized_status = normalize_generation_task_status(
extract_generation_task_status(&payload.payload).as_str(),
);
if is_completed_generation_task_status(normalized_status.as_str()) {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频任务完成但没有返回 video_url。",
"taskId": task_id,
})),
);
}
if is_failed_generation_task_status(normalized_status.as_str()) {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"开局 CG 视频任务执行失败。",
));
}
sleep(Duration::from_millis(ARK_VIDEO_TASK_POLL_INTERVAL_MS)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频生成超时,请稍后重试。",
"taskId": task_id,
})),
)
}
async fn download_generated_video(
http_client: &reqwest::Client,
video_url: &str,
fallback_message: &str,
) -> Result<DownloadedRemoteVideo, AppError> {
let response = http_client
.get(video_url)
.send()
.await
.map_err(|error| map_ark_video_request_error(format!("{fallback_message}:{error}")))?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("video/mp4")
.to_string();
let body = response
.bytes()
.await
.map_err(|error| map_ark_video_request_error(format!("{fallback_message}:{error}")))?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": fallback_message,
"status": status.as_u16(),
})),
);
}
let normalized_mime_type = normalize_downloaded_video_mime_type(content_type.as_str());
Ok(DownloadedRemoteVideo {
extension: video_mime_to_extension(normalized_mime_type.as_str()).to_string(),
mime_type: normalized_mime_type,
bytes: body.to_vec(),
})
}
pub(super) fn map_custom_world_generated_image_asset_error(
error: crate::generated_image_assets::helpers::GeneratedImageAssetHelperError,
) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "generated-image-assets",
"message": format!("准备自定义世界图片资产上传请求失败:{error:?}"),
}))
}
pub(super) fn map_asset_object_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
}))
}
pub(super) fn map_asset_binding_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-entity-binding",
"message": error.to_string(),
}))
}
pub(super) fn map_custom_world_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
pub(super) fn map_custom_world_asset_oss_error(error: platform_oss::OssError) -> AppError {
map_oss_error(error, "aliyun-oss")
}
File diff suppressed because it is too large Load Diff
@@ -1,39 +0,0 @@
use serde_json::{Value, json};
/// 结果页新增可扮演角色 / 场景角色 / 场景的提示词脚本。
/// 这里只生成 LLM 可审计输入,不处理 fallback,避免提示词规则和业务兜底混在一起。
pub(crate) fn build_result_entity_system_prompt() -> &'static str {
"你是 RPG 自定义世界实体生成器。只输出一个 JSON 对象,不要输出 Markdown。"
}
pub(crate) fn build_result_entity_user_prompt(
profile: &Value,
kind: &str,
fallback: &Value,
) -> String {
json!({
"task": "generate_custom_world_entity",
"kind": kind,
"profile": profile,
"fallback": fallback,
})
.to_string()
}
pub(crate) fn build_result_scene_npc_system_prompt() -> &'static str {
"你是 RPG 自定义世界场景 NPC 生成器。只输出一个 JSON 对象,不要输出 Markdown。"
}
pub(crate) fn build_result_scene_npc_user_prompt(
profile: &Value,
landmark_id: &str,
fallback: &Value,
) -> String {
json!({
"task": "generate_custom_world_scene_npc",
"landmarkId": landmark_id,
"profile": profile,
"fallback": fallback,
})
.to_string()
}
@@ -1 +0,0 @@
pub(crate) use crate::prompt::agent_chat::*;

Some files were not shown because too many files have changed in this diff Show More