diff --git a/docs/technical/API_SERVER_CHARACTER_VISUAL_EXTERNAL_GENERATION_RUNTIME_FIX_2026-04-23.md b/docs/technical/API_SERVER_CHARACTER_VISUAL_EXTERNAL_GENERATION_RUNTIME_FIX_2026-04-23.md new file mode 100644 index 00000000..dc71a826 --- /dev/null +++ b/docs/technical/API_SERVER_CHARACTER_VISUAL_EXTERNAL_GENERATION_RUNTIME_FIX_2026-04-23.md @@ -0,0 +1,124 @@ +# API Server 角色主形象真实外部生成运行修复记录 + +日期:`2026-04-23` + +## 1. 文档目的 + +这份文档用于记录本次为了恢复 `api-server` 角色主形象真实外部生成链路而做的最小修复项,避免后续再次出现“源码已切到真实 DashScope + OSS,但实际运行的仍是旧二进制占位链”的误判。 + +## 2. 背景 + +在人工验证 `POST /api/assets/character-visual/generate` 时,运行中的本地 `api-server` 返回了 `.svg` 候选图,这与当前 `server-rs/crates/api-server/src/character_visual_assets.rs` 已切到 DashScope 真实图片生成的源码状态不一致。 + +进一步核查发现,问题不在角色主形象实现本身,而在于当前工作区存在若干增量改动没有补齐编译链,导致 Rust `api-server` 无法重新编译启动,本地仍在运行旧版本二进制。 + +## 3. 本次最小修复项 + +### 3.1 `spacetime-client` 缺少 `serde` 依赖 + +文件: + +`server-rs/crates/spacetime-client/Cargo.toml` + +现象: + +1. 新增 `BigFishWorkSummaryRecord` 时使用了 `serde::Serialize / serde::Deserialize` +2. `Cargo.toml` 未声明 `serde` +3. 导致 `cargo check -p api-server --bin api-server` 在依赖阶段直接失败 + +修复: + +1. 为 `spacetime-client` 补充 `serde = { version = "1", features = ["derive"] }` + +### 3.2 `password_entry` 错误映射漏掉 `InvalidPublicUserCode` + +文件: + +`server-rs/crates/api-server/src/password_entry.rs` + +现象: + +1. `module-auth` 的 `PasswordEntryError` 新增了 `InvalidPublicUserCode` +2. `api-server` 侧的错误映射 `match` 未覆盖该分支 +3. 导致 `api-server` 编译失败 + +修复: + +1. 在 `map_password_entry_error(...)` 中补充 `InvalidPublicUserCode` +2. 返回中文错误文案 `叙世号格式不正确` + +### 3.3 `module-custom-world` 的 `Display` 分支未覆盖新字段错误 + +文件: + +`server-rs/crates/module-custom-world/src/lib.rs` + +现象: + +1. `CustomWorldFieldError` 新增了 `MissingPublicWorkCode` +2. `impl fmt::Display for CustomWorldFieldError` 未覆盖该枚举分支 +3. 导致依赖 `module-custom-world` 的 `api-server` 编译链继续失败 + +修复: + +1. 为 `MissingPublicWorkCode` 补充显示文案 +2. 文案口径为 `custom_world_gallery_detail.public_work_code 不能为空` + +### 3.4 `spacetime-module / spacetime-client` 绑定链路需要重新同步 + +文件: + +1. `server-rs/crates/spacetime-module/src/lib.rs` +2. `server-rs/crates/spacetime-module/src/big_fish/*.rs` +3. `server-rs/crates/spacetime-client/src/lib.rs` +4. `server-rs/crates/spacetime-client/src/module_bindings/*` + +现象: + +1. `custom_world` 新增 `public_work_code / author_public_user_code` 后,`spacetime-module` 与 `spacetime-client` 的手写 facade / 自动生成 bindings 不一致 +2. `spacetime generate` 无法顺利完成,导致 `spacetime-client` 继续引用过期 schema +3. `Big Fish` 子模块拆分后,子文件缺少表 accessor trait 导入,阻断 wasm 构建与 bindings 生成 + +修复: + +1. 补齐 `Big Fish` 子模块对表 accessor trait 的导入 +2. 补齐 `CustomWorldPublishWorldInput` 在 agent 发布动作中的新字段 +3. 补齐 `spacetime-client` 对 `publish_custom_world_profile` 与 `get_custom_world_gallery_detail_by_code` 的 facade 映射 +4. 重新执行: + +```powershell +spacetime generate --no-config --lang rust --out-dir D:\Genarrative\server-rs\crates\spacetime-client\src\module_bindings --module-path D:\Genarrative\server-rs\crates\spacetime-module --include-private --yes +``` + +说明: + +1. 这一步完成后,`spacetime-client` 已重新拿到最新 `custom_world_*` / `big_fish_*` bindings +2. `wasm-opt` 缺失只影响优化,不影响 bindings 生成与本地运行验证 + +## 4. 修复后结论 + +修复完成后,执行: + +```powershell +cargo check -p api-server --bin api-server +``` + +已通过。 + +## 5. 新运行结果 + +在新的本地 `api-server` 实例上执行: + +`POST /api/assets/character-visual/generate` + +返回结果已经从旧 `.svg` 候选切换为真实 `.png`: + +`/generated-character-drafts/codex-direct-test-character-v2/visual/aitask_6501f99c694c3/candidate-01.png` + +同时通过 OSS 签名读取再次确认: + +1. `HTTP 200` +2. `Content-Type: image/png` +3. PNG 文件头校验通过 + +这说明当前源码级的 Rust `api-server` 已具备重新启动并承载角色主形象真实外部图片生成链的条件,本地旧 SVG 返回问题的根因就是运行进程落后于当前源码与 bindings 状态。 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 2fd99cc3..85d24a25 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -86,6 +86,7 @@ dependencies = [ "module-custom-world", "module-inventory", "module-npc", + "module-puzzle", "module-runtime", "module-runtime-item", "module-runtime-story-compat", @@ -2666,6 +2667,7 @@ dependencies = [ "module-runtime", "module-runtime-item", "module-story", + "serde", "serde_json", "shared-kernel", "spacetimedb-sdk", diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 868b834d..cc3ad307 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -21,6 +21,7 @@ module-combat = { path = "../module-combat" } module-custom-world = { path = "../module-custom-world" } module-inventory = { path = "../module-inventory" } module-npc = { path = "../module-npc" } +module-puzzle = { path = "../module-puzzle" } module-runtime = { path = "../module-runtime" } module-runtime-story-compat = { path = "../module-runtime-story-compat" } module-runtime-item = { path = "../module-runtime-item" } diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs new file mode 100644 index 00000000..afecb122 --- /dev/null +++ b/server-rs/crates/api-server/src/admin.rs @@ -0,0 +1,1084 @@ +use std::collections::BTreeSet; + +use axum::{ + Json, + extract::{Extension, Request, State}, + http::{ + HeaderMap, HeaderName, HeaderValue, Method, StatusCode, + header::{AUTHORIZATION, CONTENT_TYPE}, + }, + middleware::Next, + response::{Html, Response}, +}; +use reqwest::Client; +use serde::Deserialize; +use serde_json::Value; +use shared_contracts::admin::{ + AdminDatabaseOverviewPayload, AdminDatabaseTableStatPayload, AdminDebugHeaderInput, + AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest, AdminLoginResponse, + AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload, +}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +use crate::{ + api_response::json_success_body, http_error::AppError, request_context::RequestContext, + state::{AdminRuntime, AppState}, +}; + +const MAX_DEBUG_BODY_BYTES: usize = 128 * 1024; +const BLOCKED_DEBUG_HEADERS: &[&str] = &[ + "host", + "content-length", + "connection", + "transfer-encoding", + "expect", +]; +const DATABASE_OVERVIEW_TABLES: &[&str] = &[ + "runtime_setting", + "runtime_snapshot", + "user_browse_history", + "profile_dashboard_state", + "profile_wallet_ledger", + "profile_played_world", + "profile_save_archive", + "story_session", + "story_event", + "battle_state", + "inventory_slot", + "quest_record", + "quest_log", + "treasure_record", + "npc_state", + "custom_world_profile", + "custom_world_gallery_entry", + "custom_world_agent_session", + "custom_world_agent_message", + "custom_world_agent_operation", + "custom_world_draft_card", + "big_fish_creation_session", + "big_fish_agent_message", + "big_fish_asset_slot", + "big_fish_runtime_run", + "puzzle_work_profile", + "puzzle_agent_session", + "puzzle_agent_message", + "puzzle_runtime_run", + "ai_task", + "ai_task_stage", + "ai_text_chunk", + "ai_result_reference", + "asset_object", + "asset_entity_binding", +]; + +#[derive(Clone, Debug)] +pub struct AuthenticatedAdmin { + session: AdminSessionPayload, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SpacetimeDatabaseInfoResponse { + database_identity: Option, + owner_identity: Option, + host_type: Option, +} + +#[derive(Debug, Deserialize)] +struct SpacetimeSchemaResponse { + tables: Option>, +} + +#[derive(Debug, Deserialize)] +struct SpacetimeSchemaTable { + name: Option, +} + +#[derive(Debug, Deserialize)] +struct SpacetimeSqlRow { + #[serde(flatten)] + columns: serde_json::Map, +} + +#[derive(Debug, Deserialize)] +struct SpacetimeSqlResponse { + rows: Option>, +} + +impl AuthenticatedAdmin { + pub fn new(session: AdminSessionPayload) -> Self { + Self { session } + } + + pub fn session(&self) -> &AdminSessionPayload { + &self.session + } +} + +pub async fn admin_console_page() -> Html<&'static str> { + Html(ADMIN_CONSOLE_HTML) +} + +pub async fn admin_login( + State(state): State, + Extension(request_context): Extension, + Json(payload): Json, +) -> Result, AppError> { + let runtime = state + .admin_runtime() + .ok_or_else(|| AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用"))?; + + let expected_username = runtime.username().trim(); + let expected_password = runtime.password().trim(); + let submitted_username = payload.username.trim(); + let submitted_password = payload.password.trim(); + if expected_username.is_empty() || expected_password.is_empty() { + return Err( + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用") + ); + } + + if submitted_username != expected_username || submitted_password != expected_password { + return Err(AppError::from_status(StatusCode::UNAUTHORIZED).with_message("管理员用户名或密码错误")); + } + + let now = OffsetDateTime::now_utc(); + let claims = runtime + .build_claims(now) + .map_err(|error| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error))?; + let token = runtime + .sign_token(&claims) + .map_err(|error| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error))?; + + Ok(json_success_body( + Some(&request_context), + AdminLoginResponse { + token, + admin: build_admin_session_payload(runtime.build_session(&claims)), + }, + )) +} + +pub async fn admin_me( + Extension(request_context): Extension, + Extension(admin): Extension, +) -> Json { + json_success_body( + Some(&request_context), + AdminMeResponse { + admin: admin.session().clone(), + }, + ) +} + +pub async fn admin_overview( + State(state): State, + Extension(request_context): Extension, + Extension(_admin): Extension, +) -> Result, AppError> { + let runtime = state + .admin_runtime() + .ok_or_else(|| AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用"))?; + + let overview = build_admin_overview(&state, runtime).await?; + Ok(json_success_body(Some(&request_context), overview)) +} + +pub async fn admin_debug_http( + State(state): State, + Extension(request_context): Extension, + Extension(_admin): Extension, + Json(payload): Json, +) -> Result, AppError> { + let response = execute_admin_debug_http(&state, payload).await?; + Ok(json_success_body(Some(&request_context), response)) +} + +pub async fn require_admin_auth( + State(state): State, + mut request: Request, + next: Next, +) -> Result { + let runtime = state + .admin_runtime() + .ok_or_else(|| AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用"))?; + let bearer_token = extract_bearer_token(request.headers())?; + let claims = runtime + .verify_token(&bearer_token) + .map_err(|error| AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error))?; + + let admin_session = runtime + .validate_claims(&claims) + .map_err(|error| AppError::from_status(StatusCode::FORBIDDEN).with_message(error))?; + + request + .extensions_mut() + .insert(AuthenticatedAdmin::new(build_admin_session_payload(admin_session))); + Ok(next.run(request).await) +} + +fn extract_bearer_token(headers: &HeaderMap) -> Result { + let authorization = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?; + + let token = authorization + .strip_prefix("Bearer ") + .or_else(|| authorization.strip_prefix("bearer ")) + .map(str::trim) + .filter(|token| !token.is_empty()) + .ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?; + + Ok(token.to_string()) +} + +async fn build_admin_overview( + state: &AppState, + runtime: &AdminRuntime, +) -> Result { + let service = AdminServiceOverviewPayload { + bind_host: state.config.bind_host.clone(), + bind_port: state.config.bind_port, + jwt_issuer: state.config.jwt_issuer.clone(), + admin_enabled: runtime.is_enabled(), + spacetime_server_url: state.config.spacetime_server_url.clone(), + spacetime_database: state.config.spacetime_database.clone(), + }; + let database = fetch_database_overview(state).await; + + Ok(AdminOverviewResponse { service, database }) +} + +async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPayload { + let client = Client::new(); + let server_root = state.config.spacetime_server_url.trim_end_matches('/'); + let database = state.config.spacetime_database.trim(); + let token = state.config.spacetime_token.as_deref().map(str::trim).filter(|value| !value.is_empty()); + let mut fetch_errors = Vec::new(); + + let database_info = fetch_spacetime_json::( + &client, + &format!("{server_root}/v1/database/{database}"), + token, + ) + .await + .map_err(|error| fetch_errors.push(format!("数据库信息读取失败:{error}"))) + .ok() + .flatten(); + + let schema = fetch_spacetime_json::( + &client, + &format!("{server_root}/v1/database/{database}/schema"), + token, + ) + .await + .map_err(|error| fetch_errors.push(format!("数据库 schema 读取失败:{error}"))) + .ok() + .flatten(); + + let mut schema_table_names = schema + .as_ref() + .and_then(|value| value.tables.as_ref()) + .map(|tables| { + tables + .iter() + .filter_map(|table| table.name.as_deref()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + .into_iter() + .collect::>() + }) + .unwrap_or_default(); + + let mut table_stats = Vec::new(); + for table_name in DATABASE_OVERVIEW_TABLES { + let sql = format!("SELECT COUNT(*) AS row_count FROM {table_name}"); + match fetch_spacetime_sql_count(&client, server_root, database, token, &sql).await { + Ok(row_count) => table_stats.push(AdminDatabaseTableStatPayload { + table_name: (*table_name).to_string(), + row_count: Some(row_count), + error_message: None, + }), + Err(error) => { + table_stats.push(AdminDatabaseTableStatPayload { + table_name: (*table_name).to_string(), + row_count: None, + error_message: Some(error), + }); + } + } + } + + for table_name in DATABASE_OVERVIEW_TABLES { + if !schema_table_names.iter().any(|name| name == table_name) { + schema_table_names.push((*table_name).to_string()); + } + } + schema_table_names.sort(); + + AdminDatabaseOverviewPayload { + database_identity: database_info.as_ref().and_then(|value| value.database_identity.clone()), + owner_identity: database_info.as_ref().and_then(|value| value.owner_identity.clone()), + host_type: database_info.as_ref().and_then(|value| value.host_type.clone()), + schema_table_names, + table_stats, + fetch_errors, + } +} + +async fn fetch_spacetime_json( + client: &Client, + url: &str, + token: Option<&str>, +) -> Result, String> +where + T: for<'de> Deserialize<'de>, +{ + let mut request = client.get(url); + if let Some(token) = token { + request = request.bearer_auth(token); + } + + let response = request + .send() + .await + .map_err(|error| format!("请求失败:{error}"))?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("HTTP {}:{}", status.as_u16(), trim_preview(&body))); + } + + response + .json::() + .await + .map(Some) + .map_err(|error| format!("响应解析失败:{error}")) +} + +async fn fetch_spacetime_sql_count( + client: &Client, + server_root: &str, + database: &str, + token: Option<&str>, + sql: &str, +) -> Result { + let mut request = client + .post(format!("{server_root}/v1/database/{database}/sql")) + .header(CONTENT_TYPE, "text/plain; charset=utf-8") + .body(sql.to_string()); + if let Some(token) = token { + request = request.bearer_auth(token); + } + + let response = request + .send() + .await + .map_err(|error| format!("SQL 请求失败:{error}"))?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("HTTP {}:{}", status.as_u16(), trim_preview(&body))); + } + + let payload = response + .json::() + .await + .map_err(|error| format!("SQL 响应解析失败:{error}"))?; + let row = payload + .rows + .and_then(|rows| rows.into_iter().next()) + .ok_or_else(|| "SQL 结果为空".to_string())?; + extract_sql_count(row.columns) +} + +fn extract_sql_count(columns: serde_json::Map) -> Result { + for key in ["row_count", "count", "COUNT(*)"] { + if let Some(value) = columns.get(key) { + return parse_count_value(value); + } + } + columns + .values() + .next() + .ok_or_else(|| "SQL 结果缺少 count 字段".to_string()) + .and_then(parse_count_value) +} + +fn parse_count_value(value: &Value) -> Result { + match value { + Value::Number(number) => number + .as_u64() + .ok_or_else(|| "count 字段不是无符号整数".to_string()), + Value::String(text) => text + .trim() + .parse::() + .map_err(|error| format!("count 字段解析失败:{error}")), + _ => Err("count 字段类型非法".to_string()), + } +} + +async fn execute_admin_debug_http( + state: &AppState, + payload: AdminDebugHttpRequest, +) -> Result { + let method = Method::from_bytes(payload.method.trim().as_bytes()).map_err(|_| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("HTTP 方法不合法") + })?; + let path = normalize_debug_path(&payload.path)?; + let base_url = format!( + "http://{}:{}", + state.config.bind_host.trim(), + state.config.bind_port + ); + let target_url = format!("{base_url}{path}"); + let body_text = payload.body.unwrap_or_default(); + if body_text.len() > MAX_DEBUG_BODY_BYTES { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试请求体超过长度限制") + ); + } + + let client = Client::new(); + let mut request = client.request(method, &target_url); + if !body_text.is_empty() { + request = request.body(body_text.clone()); + } + + for header in payload.headers.unwrap_or_default() { + let header_name = header.name.trim().to_ascii_lowercase(); + if BLOCKED_DEBUG_HEADERS.iter().any(|blocked| *blocked == header_name) { + continue; + } + let name = HeaderName::from_bytes(header_name.as_bytes()).map_err(|_| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试请求头名称不合法") + })?; + let value = HeaderValue::from_str(header.value.trim()).map_err(|_| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试请求头值不合法") + })?; + request = request.header(name, value); + } + + let response = request.send().await.map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!("调试请求失败:{error}")) + })?; + let status = response.status(); + let headers = response + .headers() + .iter() + .map(|(name, value)| AdminDebugHeaderInput { + name: name.to_string(), + value: value.to_str().unwrap_or_default().to_string(), + }) + .collect::>(); + let response_body = response.bytes().await.map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!("调试响应读取失败:{error}")) + })?; + let body_preview = build_body_preview(&response_body); + let body_json = serde_json::from_slice::(&response_body).ok(); + + Ok(AdminDebugHttpResponse { + status: status.as_u16(), + status_text: status.canonical_reason().unwrap_or("Unknown").to_string(), + headers, + body_text: body_preview, + body_json, + }) +} + +fn normalize_debug_path(path: &str) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试路径不能为空")); + } + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("只允许调试同源相对路径")); + } + if !trimmed.starts_with('/') { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试路径必须以 / 开头")); + } + if trimmed == "/admin/api/login" { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("禁止调试后台登录接口")); + } + Ok(trimmed.to_string()) +} + +fn build_body_preview(bytes: &[u8]) -> String { + if bytes.is_empty() { + return String::new(); + } + let text = String::from_utf8_lossy(bytes).to_string(); + trim_preview(&text) +} + +fn trim_preview(text: &str) -> String { + let trimmed = text.trim(); + if trimmed.chars().count() <= 4000 { + return trimmed.to_string(); + } + trimmed.chars().take(4000).collect::() +} + +fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSessionPayload { + AdminSessionPayload { + subject: session.subject, + username: session.username, + display_name: session.display_name, + roles: session.roles, + issued_at: session + .issued_at + .format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()), + expires_at: session + .expires_at + .format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()), + } +} + +static ADMIN_CONSOLE_HTML: &str = r#" + + + + + Genarrative 管理后台 + + + +
+
+
+

Genarrative 管理后台

+

查看服务状态、数据库概览,并对当前 API 做受控调试。

+
+
未登录
+
+ +
+
+
+
+

管理员登录

+

使用配置的后台账号进入管理域。

+
+
+
+ + +
+ + +
+
+
+
+
+ +
+
+

API 调试

+

对当前服务做同源受控请求。

+
+
+
+ + + + +
+ +
+
+
+
+
+ +
+
+
+

数据库概览

+

读取当前服务配置和 SpacetimeDB 数据库真相。

+
+
+
+ +
+
+
+
+
+
+
+ +
+
+

调试结果

+

返回状态、响应头和内容预览。

+
+
+
+
尚未执行调试请求。
+
+
+
+
+
+
+ + + +"#; + +#[cfg(test)] +mod tests { + use super::{build_body_preview, normalize_debug_path, trim_preview}; + use axum::http::StatusCode; + + #[test] + fn normalize_debug_path_rejects_absolute_url() { + let error = normalize_debug_path("https://example.com/api").expect_err("absolute url should fail"); + + assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn normalize_debug_path_rejects_admin_login_route() { + let error = normalize_debug_path("/admin/api/login").expect_err("admin login route should fail"); + + assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn trim_preview_limits_length() { + let text = "a".repeat(5000); + + assert_eq!(trim_preview(&text).chars().count(), 4000); + } + + #[test] + fn build_body_preview_handles_utf8() { + let preview = build_body_preview("后台测试".as_bytes()); + + assert_eq!(preview, "后台测试"); + } +} diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 4ec48b83..ce7f5278 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -13,6 +13,10 @@ use tower_http::{ use tracing::{Level, Span, error, info, info_span, warn}; use crate::{ + admin::{ + admin_console_page, admin_debug_http, admin_login, admin_me, admin_overview, + require_admin_auth, + }, ai_tasks::{ append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage, complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage, @@ -26,10 +30,11 @@ use crate::{ require_bearer_auth, }, auth_me::auth_me, + auth_public_user::get_public_user_by_code, auth_sessions::auth_sessions, big_fish::{ create_big_fish_session, execute_big_fish_action, get_big_fish_run, get_big_fish_session, - start_big_fish_run, stream_big_fish_message, submit_big_fish_input, + get_big_fish_works, start_big_fish_run, stream_big_fish_message, submit_big_fish_input, submit_big_fish_message, }, character_animation_assets::{ @@ -44,8 +49,9 @@ use crate::{ create_custom_world_agent_session, delete_custom_world_library_profile, execute_custom_world_agent_action, get_custom_world_agent_card_detail, get_custom_world_agent_operation, get_custom_world_agent_session, - get_custom_world_gallery_detail, get_custom_world_library, get_custom_world_library_detail, - get_custom_world_works, list_custom_world_gallery, publish_custom_world_library_profile, + get_custom_world_gallery_detail, get_custom_world_gallery_detail_by_code, + get_custom_world_library, get_custom_world_library_detail, get_custom_world_works, + list_custom_world_gallery, publish_custom_world_library_profile, put_custom_world_library_profile, stream_custom_world_agent_message, submit_custom_world_agent_message, unpublish_custom_world_library_profile, }, @@ -105,6 +111,29 @@ pub fn build_router(state: AppState) -> Router { let slow_request_threshold_ms = state.config.slow_request_threshold_ms; Router::new() + .route("/admin", get(admin_console_page)) + .route("/admin/api/login", post(admin_login)) + .route( + "/admin/api/me", + get(admin_me).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) + .route( + "/admin/api/overview", + get(admin_overview).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) + .route( + "/admin/api/debug/http", + post(admin_debug_http).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) .route( "/healthz", get(|Extension(request_context): Extension<_>| async move { @@ -126,6 +155,10 @@ pub fn build_router(state: AppState) -> Router { )), ) .route("/api/auth/login-options", get(auth_login_options)) + .route( + "/api/auth/public-users/by-code/{code}", + get(get_public_user_by_code), + ) .route( "/generated-character-drafts/{*path}", get(proxy_generated_character_drafts), @@ -391,6 +424,10 @@ pub fn build_router(state: AppState) -> Router { "/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}", get(get_custom_world_gallery_detail), ) + .route( + "/api/runtime/custom-world-gallery/by-code/{code}", + get(get_custom_world_gallery_detail_by_code), + ) .route( "/api/runtime/custom-world/agent/sessions", post(create_custom_world_agent_session).route_layer(middleware::from_fn_with_state( @@ -482,6 +519,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/big-fish/works", + get(get_big_fish_works).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/big-fish/sessions/{session_id}/runs", post(start_big_fish_run).route_layer(middleware::from_fn_with_state( @@ -2905,4 +2949,168 @@ mod tests { .is_some_and(|value| value.contains("Max-Age=0")) ); } + + #[tokio::test] + async fn admin_login_returns_token_when_configured() { + let mut config = AppConfig::default(); + config.admin_username = Some("root".to_string()); + config.admin_password = Some("secret123".to_string()); + let app = build_router(AppState::new(config).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/api/login") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "username": "root", + "password": "secret123" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert!(payload["token"].as_str().is_some()); + assert_eq!(payload["admin"]["username"], Value::String("root".to_string())); + } + + #[tokio::test] + async fn admin_route_rejects_regular_user_token() { + let mut config = AppConfig::default(); + config.admin_username = Some("root".to_string()); + config.admin_password = Some("secret123".to_string()); + let state = AppState::new(config).expect("state should build"); + let app = build_router(state.clone()); + + let login_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/entry") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "username": "guest_admin_forbidden", + "password": "secret123" + }) + .to_string(), + )) + .expect("login request should build"), + ) + .await + .expect("login should succeed"); + let login_body = login_response + .into_body() + .collect() + .await + .expect("login body should collect") + .to_bytes(); + let login_payload: Value = + serde_json::from_slice(&login_body).expect("login payload should be json"); + let access_token = login_payload["token"] + .as_str() + .expect("token should exist") + .to_string(); + + let response = app + .oneshot( + Request::builder() + .uri("/admin/api/me") + .header("authorization", format!("Bearer {access_token}")) + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn admin_debug_http_can_probe_healthz() { + let mut config = AppConfig::default(); + config.admin_username = Some("root".to_string()); + config.admin_password = Some("secret123".to_string()); + let app = build_router(AppState::new(config).expect("state should build")); + + let login_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/api/login") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "username": "root", + "password": "secret123" + }) + .to_string(), + )) + .expect("login request should build"), + ) + .await + .expect("login should succeed"); + let login_body = login_response + .into_body() + .collect() + .await + .expect("login body should collect") + .to_bytes(); + let login_payload: Value = + serde_json::from_slice(&login_body).expect("login payload should be json"); + let access_token = login_payload["token"] + .as_str() + .expect("token should exist") + .to_string(); + + let debug_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/api/debug/http") + .header("authorization", format!("Bearer {access_token}")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "method": "GET", + "path": "/healthz", + "headers": [], + "body": "" + }) + .to_string(), + )) + .expect("debug request should build"), + ) + .await + .expect("debug request should succeed"); + + assert_eq!(debug_response.status(), StatusCode::OK); + let body = debug_response + .into_body() + .collect() + .await + .expect("debug body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("debug payload should be json"); + + assert_eq!(payload["status"], Value::Number(200.into())); + } } diff --git a/server-rs/crates/api-server/src/auth_me.rs b/server-rs/crates/api-server/src/auth_me.rs index b1684e26..b08bcf10 100644 --- a/server-rs/crates/api-server/src/auth_me.rs +++ b/server-rs/crates/api-server/src/auth_me.rs @@ -3,11 +3,12 @@ use axum::{ extract::{Extension, State}, http::StatusCode, }; -use shared_contracts::auth::{AuthMeResponse, AuthUserPayload, build_available_login_methods}; +use shared_contracts::auth::{AuthMeResponse, build_available_login_methods}; use crate::{ - api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, - request_context::RequestContext, state::AppState, + api_response::json_success_body, auth::AuthenticatedAccessToken, + auth_payload::map_auth_user_payload, http_error::AppError, request_context::RequestContext, + state::AppState, }; pub async fn auth_me( @@ -30,15 +31,7 @@ pub async fn auth_me( Ok(json_success_body( Some(&request_context), AuthMeResponse { - user: AuthUserPayload { - id: user.user.id, - username: user.user.username, - display_name: user.user.display_name, - phone_number_masked: user.user.phone_number_masked, - login_method: user.user.login_method.as_str().to_string(), - binding_status: user.user.binding_status.as_str().to_string(), - wechat_bound: user.user.wechat_bound, - }, + user: map_auth_user_payload(user.user), available_login_methods: build_available_login_methods( state.config.sms_auth_enabled, state.config.wechat_auth_enabled, diff --git a/server-rs/crates/api-server/src/auth_payload.rs b/server-rs/crates/api-server/src/auth_payload.rs new file mode 100644 index 00000000..0e55b2cb --- /dev/null +++ b/server-rs/crates/api-server/src/auth_payload.rs @@ -0,0 +1,23 @@ +use module_auth::AuthUser; +use shared_contracts::auth::{AuthUserPayload, PublicUserSummaryPayload}; + +pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload { + AuthUserPayload { + id: user.id, + public_user_code: user.public_user_code, + username: user.username, + display_name: user.display_name, + phone_number_masked: user.phone_number_masked, + login_method: user.login_method.as_str().to_string(), + binding_status: user.binding_status.as_str().to_string(), + wechat_bound: user.wechat_bound, + } +} + +pub fn map_public_user_summary_payload(user: AuthUser) -> PublicUserSummaryPayload { + PublicUserSummaryPayload { + id: user.id, + public_user_code: user.public_user_code, + display_name: user.display_name, + } +} diff --git a/server-rs/crates/api-server/src/auth_public_user.rs b/server-rs/crates/api-server/src/auth_public_user.rs new file mode 100644 index 00000000..e975ba68 --- /dev/null +++ b/server-rs/crates/api-server/src/auth_public_user.rs @@ -0,0 +1,50 @@ +use axum::{ + Json, + extract::{Extension, Path, State}, + http::StatusCode, +}; +use shared_contracts::auth::PublicUserSearchResponse; + +use crate::{ + api_response::json_success_body, + auth_payload::map_public_user_summary_payload, + http_error::AppError, + request_context::RequestContext, + state::AppState, +}; + +pub async fn get_public_user_by_code( + State(state): State, + Extension(request_context): Extension, + Path(code): Path, +) -> Result, AppError> { + let user = state + .password_entry_service() + .get_user_by_public_user_code(&code) + .map_err(map_public_user_search_error)? + .ok_or_else(|| { + AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应叙世号用户") + })?; + + Ok(json_success_body( + Some(&request_context), + PublicUserSearchResponse { + user: map_public_user_summary_payload(user.user), + }, + )) +} + +fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppError { + match error { + module_auth::PasswordEntryError::InvalidPublicUserCode => { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("叙世号格式不正确") + } + module_auth::PasswordEntryError::Store(_) + | module_auth::PasswordEntryError::PasswordHash(_) + | module_auth::PasswordEntryError::InvalidUsername + | module_auth::PasswordEntryError::InvalidPasswordLength + | module_auth::PasswordEntryError::InvalidCredentials => { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) + } + } +} diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index a1ef305a..11ad02b3 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -26,7 +26,8 @@ use shared_contracts::big_fish::{ BigFishVector2Response, CreateBigFishSessionRequest, ExecuteBigFishActionRequest, SendBigFishMessageRequest, SubmitBigFishInputRequest, }; -use shared_kernel::build_prefixed_uuid_id; +use shared_contracts::big_fish_works::{BigFishWorkSummaryResponse, BigFishWorksResponse}; +use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::{ BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, @@ -34,7 +35,7 @@ use spacetime_client::{ BigFishMessageSubmitRecordInput, BigFishRunInputSubmitRecordInput, BigFishRunStartRecordInput, BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRecord, BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record, - SpacetimeClientError, + BigFishWorkSummaryRecord, SpacetimeClientError, }; use tokio::time::sleep; @@ -107,6 +108,30 @@ pub async fn get_big_fish_session( )) } +pub async fn get_big_fish_works( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let items = state + .spacetime_client() + .list_big_fish_works(authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + BigFishWorksResponse { + items: items + .into_iter() + .map(map_big_fish_work_summary_response) + .collect(), + }, + )) +} + pub async fn submit_big_fish_message( State(state): State, Path(session_id): Path, @@ -610,6 +635,26 @@ fn map_big_fish_runtime_response(run: BigFishRuntimeRecord) -> BigFishRuntimeSna } } +fn map_big_fish_work_summary_response( + item: BigFishWorkSummaryRecord, +) -> BigFishWorkSummaryResponse { + BigFishWorkSummaryResponse { + work_id: item.work_id, + source_session_id: item.source_session_id, + 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), + 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, + } +} + fn map_big_fish_entity_response( entity: BigFishRuntimeEntityRecord, ) -> BigFishRuntimeEntityResponse { @@ -1475,3 +1520,7 @@ fn current_utc_micros() -> i64 { .expect("system clock should be after unix epoch"); i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") } + +fn current_timestamp_micros_to_string(value: i64) -> String { + format_timestamp_micros(value) +} diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 3e287f47..bbb66038 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -1,11 +1,22 @@ -use std::{collections::BTreeMap, path::PathBuf}; +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, +}; use axum::{ Json, - extract::{Extension, Path, State, rejection::JsonRejection}, + extract::{Extension, Path as AxumPath, State, rejection::JsonRejection}, http::StatusCode, response::Response, }; +use image::{ + ColorType, ImageEncoder, ImageFormat, Rgba, RgbaImage, codecs::png::PngEncoder, + imageops::FilterType, +}; use module_ai::{ AiStageCompletionInput, AiTaskCreateInput, AiTaskKind, AiTaskServiceError, AiTaskSnapshot, AiTaskStageKind, AiTaskStatus, generate_ai_task_id, @@ -35,8 +46,9 @@ use crate::{ api_response::json_success_body, http_error::AppError, request_context::RequestContext, state::AppState, }; +use tokio::time::sleep; -const CHARACTER_ANIMATION_MODEL: &str = "rust-placeholder-character-animation"; +const CHARACTER_ANIMATION_MODEL: &str = "doubao-seedance-2-0-fast-260128"; const CHARACTER_ANIMATION_ASSET_KIND: &str = "character_animation"; const CHARACTER_ANIMATION_REFERENCE_ASSET_KIND: &str = "character_animation_reference_video"; const CHARACTER_WORKFLOW_CACHE_ASSET_KIND: &str = "character_workflow_cache"; @@ -48,10 +60,10 @@ const CHARACTER_ANIMATION_DRAFT_SLOT: &str = "animation_draft"; const CHARACTER_ANIMATION_PREVIEW_SLOT: &str = "animation_preview"; const DEFAULT_ANIMATION_FRAME_WIDTH: u32 = 192; const DEFAULT_ANIMATION_FRAME_HEIGHT: u32 = 256; -const PLACEHOLDER_PREVIEW_VIDEO_CANDIDATES: [&str; 2] = [ - "public/generated-character-drafts/qwen-sprite-demo/animation/idle/animation-video-1775633611867/preview.mp4", - "public/generated-character-drafts/story-npc-1/animation/idle/animation-video-1776329042002/preview.mp4", -]; +const FIXED_ARK_CHARACTER_VIDEO_RESOLUTION: &str = "480p"; +const FIXED_ARK_CHARACTER_VIDEO_RATIO: &str = "1:1"; +const FIXED_ARK_CHARACTER_VIDEO_DURATION_SECONDS: u32 = 4; +const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000; const BUILT_IN_MOTION_TEMPLATES: [MotionTemplate; 4] = [ MotionTemplate { @@ -188,6 +200,7 @@ pub async fn generate_character_animation( let character_id = normalize_required_text(payload.character_id.as_str(), "character"); let animation = normalize_required_text(payload.animation.as_str(), "idle"); let prompt = build_character_animation_prompt( + &strategy, payload.prompt_text.as_str(), payload.character_brief_text.as_deref(), payload.action_template_id.as_deref(), @@ -254,19 +267,73 @@ pub async fn generate_character_animation( current_utc_micros(), ) .map_err(map_ai_task_error)?; - state - .ai_task_service() - .complete_stage(AiStageCompletionInput { - task_id: task_id.clone(), - stage_kind: AiTaskStageKind::RequestModel, - text_output: Some("Stage 1 使用 Rust 本地占位动作产物打通 OSS 链路。".to_string()), - structured_payload_json: None, - warning_messages: Vec::new(), - completed_at_micros: current_utc_micros(), - }) - .map_err(map_ai_task_error)?; let generated = match strategy { + CharacterAnimationStrategy::ImageToVideo => { + let settings = require_ark_video_settings(&state, &payload)?; + let http_client = build_upstream_http_client(settings.request_timeout_ms)?; + let visual_data_url = resolve_media_source_as_data_url( + &state, + &http_client, + payload.visual_source.as_str(), + "visualSource", + ) + .await?; + let last_frame_data_url = resolve_media_source_as_data_url( + &state, + &http_client, + payload + .last_frame_image_data_url + .as_deref() + .unwrap_or(payload.visual_source.as_str()), + "lastFrameImageDataUrl", + ) + .await?; + let fallback_prompt = build_fallback_moderation_safe_animation_prompt( + animation.as_str(), + payload.loop_, + payload.use_chroma_key, + ); + let generated = request_image_to_video_preview( + &state, + &http_client, + &settings, + owner_user_id.as_str(), + character_id.as_str(), + animation.as_str(), + task_id.as_str(), + prompt.as_str(), + fallback_prompt.as_str(), + visual_data_url.as_str(), + last_frame_data_url.as_str(), + ) + .await?; + + state + .ai_task_service() + .complete_stage(AiStageCompletionInput { + task_id: task_id.clone(), + stage_kind: AiTaskStageKind::RequestModel, + text_output: Some(generated.submitted_prompt.clone()), + structured_payload_json: Some( + json!({ + "provider": "ark", + "taskId": generated.upstream_task_id, + "model": settings.model, + "moderationFallbackApplied": generated.moderation_fallback_applied, + }) + .to_string(), + ), + warning_messages: Vec::new(), + completed_at_micros: current_utc_micros(), + }) + .map_err(map_ai_task_error)?; + + CharacterAnimationGeneratedDraft { + image_sources: Vec::new(), + preview_video_path: Some(generated.preview_video_path), + } + } CharacterAnimationStrategy::ImageSequence => { let image_sources = persist_animation_draft_frames( &state, @@ -278,6 +345,23 @@ pub async fn generate_character_animation( normalize_frame_count(payload.frame_count), ) .await?; + state + .ai_task_service() + .complete_stage(AiStageCompletionInput { + task_id: task_id.clone(), + stage_kind: AiTaskStageKind::RequestModel, + text_output: Some("当前仍使用 Stage 1 序列帧占位链。".to_string()), + structured_payload_json: Some( + json!({ + "provider": "character-animation", + "mode": "stage1-image-sequence-placeholder", + }) + .to_string(), + ), + warning_messages: Vec::new(), + completed_at_micros: current_utc_micros(), + }) + .map_err(map_ai_task_error)?; CharacterAnimationGeneratedDraft { image_sources, preview_video_path: None, @@ -296,6 +380,24 @@ pub async fn generate_character_animation( .map(String::as_str), ) .await?; + state + .ai_task_service() + .complete_stage(AiStageCompletionInput { + task_id: task_id.clone(), + stage_kind: AiTaskStageKind::RequestModel, + text_output: Some("当前仍使用 Stage 1 视频占位链。".to_string()), + structured_payload_json: Some( + json!({ + "provider": "character-animation", + "mode": "stage1-video-placeholder", + "strategy": strategy, + }) + .to_string(), + ), + warning_messages: Vec::new(), + completed_at_micros: current_utc_micros(), + }) + .map_err(map_ai_task_error)?; CharacterAnimationGeneratedDraft { image_sources: Vec::new(), preview_video_path: Some(preview_video_path), @@ -372,7 +474,7 @@ pub async fn generate_character_animation( pub async fn get_character_animation_job( State(state): State, Extension(request_context): Extension, - Path(task_id): Path, + AxumPath(task_id): AxumPath, ) -> Result, Response> { let task = state .ai_task_service() @@ -465,7 +567,7 @@ pub async fn publish_character_animation( pub async fn get_character_workflow_cache( State(state): State, Extension(request_context): Extension, - Path(character_id): Path, + AxumPath(character_id): AxumPath, ) -> Result, Response> { let character_id = normalize_required_text(character_id.as_str(), ""); if character_id.is_empty() { @@ -627,7 +729,14 @@ async fn persist_animation_preview_video( extension: parsed_video.extension, bytes: parsed_video.bytes, }, - None => load_stage1_placeholder_preview_video()?, + None => { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "character-animation", + "message": "当前策略需要真实生成视频结果,不再支持回退到仓库占位预览视频。", + })), + ) + } }; let put_result = put_character_animation_object( state, @@ -655,6 +764,378 @@ async fn persist_animation_preview_video( Ok(put_result.legacy_public_path) } +fn require_ark_video_settings( + state: &AppState, + payload: &CharacterAnimationGenerateRequest, +) -> Result { + let base_url = state + .config + .ark_character_video_base_url + .trim() + .trim_end_matches('/'); + if base_url.is_empty() { + return Err( + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "ark", + "reason": "ARK_CHARACTER_VIDEO_BASE_URL 未配置", + })), + ); + } + + let api_key = state + .config + .ark_character_video_api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "ark", + "reason": "ARK_CHARACTER_VIDEO_API_KEY 未配置", + })) + })?; + + let requested_model = normalize_required_text( + payload.video_model.as_str(), + state.config.ark_character_video_model.as_str(), + ); + + Ok(ArkVideoSettings { + base_url: base_url.to_string(), + api_key: api_key.to_string(), + request_timeout_ms: state.config.ark_character_video_request_timeout_ms.max(1), + model: requested_model, + }) +} + +fn build_upstream_http_client(timeout_ms: u64) -> Result { + reqwest::Client::builder() + .timeout(Duration::from_millis(timeout_ms)) + .build() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "character-animation", + "message": format!("构造上游 HTTP 客户端失败:{error}"), + })) + }) +} + +async fn resolve_media_source_as_data_url( + state: &AppState, + http_client: &reqwest::Client, + source: &str, + field: &str, +) -> Result { + let payload = load_media_source_payload(state, source).await?; + if !(payload.mime_type.starts_with("image/") || payload.mime_type.starts_with("video/")) { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "character-animation", + "field": field, + "message": "媒体资源必须是图片或视频。", + })), + ); + } + let _ = http_client; + Ok(format!( + "data:{};base64,{}", + payload.mime_type, + encode_base64(payload.bytes.as_slice()) + )) +} + +async fn request_image_to_video_preview( + state: &AppState, + http_client: &reqwest::Client, + settings: &ArkVideoSettings, + owner_user_id: &str, + character_id: &str, + animation: &str, + task_id: &str, + prompt: &str, + fallback_prompt: &str, + first_frame_data_url: &str, + last_frame_data_url: &str, +) -> Result { + let (submitted_prompt, upstream_task_id, moderation_fallback_applied) = + create_ark_image_to_video_task( + http_client, + settings, + prompt, + fallback_prompt, + first_frame_data_url, + last_frame_data_url, + ) + .await?; + let video_url = + wait_for_ark_content_generation_task(http_client, settings, upstream_task_id.as_str()) + .await?; + let preview_payload = + download_generated_video(http_client, video_url.as_str(), "下载角色动作视频失败。").await?; + let preview_video_path = put_generated_preview_video( + state, + owner_user_id, + character_id, + animation, + task_id, + preview_payload, + ) + .await?; + + Ok(GeneratedAnimationPreview { + preview_video_path, + upstream_task_id, + submitted_prompt, + moderation_fallback_applied, + }) +} + +async fn create_ark_image_to_video_task( + http_client: &reqwest::Client, + settings: &ArkVideoSettings, + prompt: &str, + fallback_prompt: &str, + first_frame_data_url: &str, + last_frame_data_url: &str, +) -> Result<(String, String, bool), AppError> { + let first_try = send_ark_image_to_video_request( + http_client, + settings, + prompt, + first_frame_data_url, + last_frame_data_url, + ) + .await?; + if first_try.status().is_success() { + let body = first_try.text().await.map_err(|error| { + map_character_animation_upstream_error(format!("读取 Ark 视频任务响应失败:{error}")) + })?; + let payload = parse_animation_json_payload(body.as_str(), "创建图生视频任务失败。")?; + let task_id = extract_animation_task_id(&payload.payload).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "ark", + "message": "图生视频任务未返回任务 id。", + })) + })?; + return Ok((prompt.to_string(), task_id, false)); + } + + let error_text = first_try.text().await.map_err(|error| { + map_character_animation_upstream_error(format!("读取 Ark 视频错误响应失败:{error}")) + })?; + if fallback_prompt.trim().is_empty() + || fallback_prompt.trim() == prompt.trim() + || !is_inappropriate_content_message(error_text.as_str()) + { + return Err(parse_animation_upstream_error( + error_text.as_str(), + "创建图生视频任务失败。", + )); + } + + let second_try = send_ark_image_to_video_request( + http_client, + settings, + fallback_prompt, + first_frame_data_url, + last_frame_data_url, + ) + .await?; + let second_status = second_try.status(); + let second_text = second_try.text().await.map_err(|error| { + map_character_animation_upstream_error(format!("读取 Ark 视频重试响应失败:{error}")) + })?; + if !second_status.is_success() { + return Err(parse_animation_upstream_error( + second_text.as_str(), + "创建图生视频任务失败。", + )); + } + let second_payload = + parse_animation_json_payload(second_text.as_str(), "创建图生视频任务失败。")?; + let task_id = extract_animation_task_id(&second_payload.payload).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "ark", + "message": "图生视频任务未返回任务 id。", + })) + })?; + Ok((fallback_prompt.to_string(), task_id, true)) +} + +async fn send_ark_image_to_video_request( + http_client: &reqwest::Client, + settings: &ArkVideoSettings, + prompt: &str, + first_frame_data_url: &str, + last_frame_data_url: &str, +) -> Result { + 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": first_frame_data_url, + }, + "role": "first_frame", + }, + { + "type": "image_url", + "image_url": { + "url": last_frame_data_url, + }, + "role": "last_frame", + } + ], + "resolution": FIXED_ARK_CHARACTER_VIDEO_RESOLUTION, + "ratio": FIXED_ARK_CHARACTER_VIDEO_RATIO, + "duration": FIXED_ARK_CHARACTER_VIDEO_DURATION_SECONDS, + "watermark": false, + })) + .send() + .await + .map_err(|error| map_character_animation_upstream_error(format!("请求 Ark 视频服务失败:{error}"))) +} + +async fn wait_for_ark_content_generation_task( + http_client: &reqwest::Client, + settings: &ArkVideoSettings, + task_id: &str, +) -> Result { + 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_character_animation_upstream_error(format!("查询 Ark 视频任务失败:{error}")))?; + let status = response.status(); + let text = response.text().await.map_err(|error| { + map_character_animation_upstream_error(format!("读取 Ark 视频任务响应失败:{error}")) + })?; + if !status.is_success() { + return Err(parse_animation_upstream_error( + text.as_str(), + "查询视频生成任务失败。", + )); + } + let payload = parse_animation_json_payload(text.as_str(), "查询视频生成任务失败。")?; + let normalized_status = normalize_generation_task_status( + extract_generation_task_status(&payload.payload).as_str(), + ); + if let Some(video_url) = extract_video_url(&payload.payload) { + return Ok(video_url); + } + if is_completed_generation_task_status(normalized_status.as_str()) { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "ark", + "message": "视频任务完成但没有返回 video_url。", + "taskId": task_id, + })), + ); + } + if is_failed_generation_task_status(normalized_status.as_str()) { + return Err(parse_animation_upstream_error( + text.as_str(), + "视频生成任务执行失败。", + )); + } + sleep(Duration::from_millis(ARK_VIDEO_TASK_POLL_INTERVAL_MS)).await; + } + + Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "ark", + "message": "视频生成任务执行超时,请稍后重试。", + "taskId": task_id, + }))) +} + +async fn download_generated_video( + http_client: &reqwest::Client, + video_url: &str, + fallback_message: &str, +) -> Result { + let response = http_client + .get(video_url) + .send() + .await + .map_err(|error| map_character_animation_upstream_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 bytes = response.bytes().await.map_err(|error| { + map_character_animation_upstream_error(format!("{fallback_message}:{error}")) + })?; + if !status.is_success() { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "character-animation", + "message": fallback_message, + "status": status.as_u16(), + }))); + } + Ok(MediaPayload { + mime_type: content_type.clone(), + extension: mime_to_extension(content_type.as_str()).to_string(), + bytes: bytes.to_vec(), + }) +} + +async fn put_generated_preview_video( + state: &AppState, + owner_user_id: &str, + character_id: &str, + animation: &str, + task_id: &str, + preview_payload: MediaPayload, +) -> Result { + let put_result = put_character_animation_object( + state, + LegacyAssetPrefix::CharacterDrafts, + vec![ + sanitize_storage_segment(character_id, "character"), + "animation".to_string(), + sanitize_storage_segment(animation, "clip"), + task_id.to_string(), + ], + format!("preview.{}", preview_payload.extension), + preview_payload.mime_type, + preview_payload.bytes, + build_asset_metadata( + CHARACTER_ANIMATION_ASSET_KIND, + owner_user_id, + CHARACTER_ANIMATION_ENTITY_KIND, + character_id, + CHARACTER_ANIMATION_PREVIEW_SLOT, + animation, + ), + ) + .await?; + Ok(put_result.legacy_public_path) +} + async fn publish_animation_set( state: &AppState, owner_user_id: &str, @@ -665,12 +1146,9 @@ async fn publish_animation_set( ) -> Result { let mut action_manifests = Vec::new(); let mut animation_map = serde_json::Map::new(); + let extraction_settings = resolve_backend_frame_extraction_settings(state); for (action, draft) in animations { - if draft.frames_data_urls.is_empty() { - continue; - } - let published = publish_single_animation_action( state, owner_user_id, @@ -679,6 +1157,7 @@ async fn publish_animation_set( animation_set_id, action.as_str(), draft, + &extraction_settings, ) .await?; @@ -759,17 +1238,49 @@ async fn publish_single_animation_action( animation_set_id: &str, action: &str, draft: CharacterAnimationDraftPayload, + extraction_settings: &BackendFrameExtractionSettings, ) -> Result { let action_segment = sanitize_storage_segment(action, "clip"); let frame_width = normalize_dimension(draft.frame_width, DEFAULT_ANIMATION_FRAME_WIDTH); let frame_height = normalize_dimension(draft.frame_height, DEFAULT_ANIMATION_FRAME_HEIGHT); let fps = draft.fps.clamp(1, 60); let preview_video_path = draft.preview_video_path.clone(); - let mut frame_paths = Vec::with_capacity(draft.frames_data_urls.len()); + let loop_ = draft.loop_; + let frame_plan = normalize_animation_frame_extraction_plan(&draft); + let finalized_frames = if draft.frames_data_urls.is_empty() { + let preview_video_path = preview_video_path.as_deref().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "character-animation", + "message": format!("动作 {action} 缺少 framesDataUrls 与 previewVideoPath,无法发布。"), + })) + })?; + extract_animation_frames_from_preview_video( + state, + preview_video_path, + frame_width, + frame_height, + extraction_settings, + &frame_plan, + ) + .await? + } else { + let mut frames = Vec::with_capacity(draft.frames_data_urls.len()); + for frame_source in &draft.frames_data_urls { + let frame_payload = load_media_source_payload(state, frame_source.as_str()).await?; + let finalized = finalize_animation_frame_payload( + frame_payload.bytes.as_slice(), + frame_payload.mime_type.as_str(), + frame_width, + frame_height, + frame_plan.apply_chroma_key, + )?; + frames.push(finalized); + } + frames + }; + let mut frame_paths = Vec::with_capacity(finalized_frames.len()); let mut frame_extension = "png".to_string(); - - for (index, frame_source) in draft.frames_data_urls.iter().enumerate() { - let frame_payload = load_media_source_payload(state, frame_source.as_str()).await?; + for (index, frame_payload) in finalized_frames.iter().enumerate() { frame_extension = frame_payload.extension.clone(); let frame_file_name = format!("frame{:02}.{}", index + 1, frame_payload.extension); let put_result = put_character_animation_object( @@ -781,8 +1292,8 @@ async fn publish_single_animation_action( action_segment.clone(), ], frame_file_name, - frame_payload.mime_type, - frame_payload.bytes, + frame_payload.mime_type.clone(), + frame_payload.bytes.clone(), build_asset_metadata( CHARACTER_ANIMATION_ASSET_KIND, owner_user_id, @@ -810,7 +1321,7 @@ async fn publish_single_animation_action( "action": action, "frameCount": frame_paths.len(), "fps": fps, - "loop": draft.loop_, + "loop": loop_, "frameWidth": frame_width, "frameHeight": frame_height, "previewVideoPath": preview_video_path, @@ -854,9 +1365,9 @@ async fn publish_single_animation_action( ("frameWidth".to_string(), json!(frame_width)), ("frameHeight".to_string(), json!(frame_height)), ("fps".to_string(), json!(fps)), - ("loop".to_string(), json!(draft.loop_)), + ("loop".to_string(), json!(loop_)), ]); - if let Some(preview_video_path) = draft.preview_video_path { + if let Some(preview_video_path) = preview_video_path { animation_config.insert("previewVideoPath".to_string(), json!(preview_video_path)); } @@ -1197,6 +1708,7 @@ fn build_character_animation_job_payload(task: AiTaskSnapshot) -> CharacterAsset } fn build_character_animation_prompt( + strategy: &CharacterAnimationStrategy, prompt_text: &str, character_brief_text: Option<&str>, action_template_id: Option<&str>, @@ -1207,30 +1719,288 @@ fn build_character_animation_prompt( loop_: bool, use_chroma_key: bool, ) -> String { - let merged = [character_brief_text.unwrap_or_default(), prompt_text] - .into_iter() - .map(str::trim) - .filter(|value| !value.is_empty()) - .collect::>() - .join("\n"); - let base = if merged.is_empty() { - "自定义世界角色动作,保持角色服装、轮廓和朝向一致。" - } else { - merged.as_str() - }; - format!( - "{base}\n动作:{animation}。模板:{template}。输出 {frames} 帧,{fps} fps,时长约 {duration} 秒,{loop_text},{chroma_text},纯绿色背景,角色主体居中,避免额外 UI 字幕。", - template = trim_optional_text(action_template_id).unwrap_or_else(|| "未指定".to_string()), - frames = normalize_frame_count(frame_count), - fps = fps.clamp(1, 60), - duration = duration_seconds.clamp(1, 8), - loop_text = if loop_ { "可循环" } else { "非循环" }, - chroma_text = if use_chroma_key { - "保留绿幕抠像边界" + match strategy { + CharacterAnimationStrategy::ImageToVideo => build_ark_character_animation_prompt( + animation, + prompt_text, + character_brief_text, + action_template_id, + loop_, + use_chroma_key, + ), + CharacterAnimationStrategy::ImageSequence => build_image_sequence_prompt( + animation, + prompt_text, + frame_count, + use_chroma_key, + ), + CharacterAnimationStrategy::MotionTransfer + | CharacterAnimationStrategy::ReferenceToVideo => build_npc_animation_prompt( + animation, + prompt_text, + character_brief_text, + action_template_id, + loop_, + use_chroma_key, + fps, + duration_seconds, + ), + } +} + +fn build_image_sequence_prompt( + animation: &str, + prompt_text: &str, + frame_count: u32, + use_chroma_key: bool, +) -> String { + [ + format!("同一角色连续 {} 帧动作序列,动作主题是 {}。", frame_count, animation), + "固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。".to_string(), + "帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。".to_string(), + if use_chroma_key { + "纯绿色背景,无地面装饰,方便后期抠像。".to_string() } else { - "不强制绿幕抠像" + "背景尽量纯净,避免复杂场景。".to_string() }, - ) + prompt_text.trim().to_string(), + ] + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join(" ") +} + +fn build_npc_animation_prompt( + animation: &str, + prompt_text: &str, + character_brief_text: Option<&str>, + action_template_id: Option<&str>, + loop_: bool, + use_chroma_key: bool, + fps: u32, + duration_seconds: u32, +) -> String { + let character_brief = build_compact_animation_character_brief(character_brief_text); + let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140); + let loop_rule = if loop_ { + "这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。".to_string() + } else if animation == "die" { + "这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。".to_string() + } else { + "这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。".to_string() + }; + + if let Some(template) = action_template_id + .and_then(|id| find_motion_template(id)) + { + return [ + format!( + "单人 NPC 全身动作视频,动作主题是 {}。角色固定为同一人,右向斜侧身,镜头稳定,轮廓清晰,武器不可丢失。", + template.animation + ), + if use_chroma_key { + "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() + } else { + "背景简洁纯净,无复杂场景。".to_string() + }, + if character_brief.is_empty() { + String::new() + } else { + format!("角色设定:{}。", character_brief) + }, + format!("动作补充:{}。", template.prompt_suffix), + if action_detail_text.is_empty() { + String::new() + } else { + format!("动作细节:{}。", action_detail_text) + }, + format!("目标帧率 {} fps,时长约 {} 秒。", fps.clamp(1, 60), duration_seconds.clamp(1, 8)), + loop_rule, + ] + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join(" "); + } + + [ + format!("单人 NPC 全身动作视频,动作主题是 {}。", animation), + "角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。".to_string(), + "动作连贯,避免服装、发型、面部、武器随机漂移。".to_string(), + if use_chroma_key { + "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() + } else { + "背景简洁纯净,无复杂场景。".to_string() + }, + if character_brief.is_empty() { + String::new() + } else { + format!("角色设定:{}。", character_brief) + }, + if action_detail_text.is_empty() { + String::new() + } else { + action_detail_text + }, + format!("目标帧率 {} fps,时长约 {} 秒。", fps.clamp(1, 60), duration_seconds.clamp(1, 8)), + loop_rule, + ] + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join(" ") +} + +fn build_ark_character_animation_prompt( + animation: &str, + prompt_text: &str, + character_brief_text: Option<&str>, + action_template_id: Option<&str>, + loop_: bool, + use_chroma_key: bool, +) -> String { + let normalized_animation_name = animation.trim().replace(char::is_whitespace, "_"); + let normalized_animation_name = if normalized_animation_name.is_empty() { + "idle".to_string() + } else { + normalized_animation_name + }; + let character_brief = build_compact_animation_character_brief(character_brief_text); + let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140); + let frame_rule = if loop_ { + "首帧严格使用图片1,尾帧严格使用图片2,循环动作必须自然闭环,不要静止开场。".to_string() + } else { + "首帧严格使用图片1,尾帧严格使用图片2,中段完成完整动作变化,收束干净。".to_string() + }; + + if let Some(template) = action_template_id.and_then(find_motion_template) { + return [ + format!( + "单人 NPC 全身动作视频,动作英文名是 {}。角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。", + normalized_animation_name + ), + "动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。".to_string(), + if use_chroma_key { + "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() + } else { + "背景简洁纯净,无复杂场景。".to_string() + }, + if character_brief.is_empty() { + String::new() + } else { + format!("角色设定:{}。", character_brief) + }, + format!("动作补充:{}。", template.prompt_suffix), + if action_detail_text.is_empty() { + String::new() + } else { + format!("动作细节:{}。", action_detail_text) + }, + frame_rule, + ] + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join(" "); + } + + [ + format!("单人 NPC 全身动作视频,动作英文名是 {}。", normalized_animation_name), + "角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。".to_string(), + "动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。".to_string(), + if use_chroma_key { + "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() + } else { + "背景简洁纯净,无复杂场景。".to_string() + }, + if character_brief.is_empty() { + String::new() + } else { + format!("角色设定:{}。", character_brief) + }, + if action_detail_text.is_empty() { + String::new() + } else { + format!("动作细节:{}。", action_detail_text) + }, + frame_rule, + ] + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join(" ") +} + +fn build_fallback_moderation_safe_animation_prompt( + animation: &str, + loop_: bool, + use_chroma_key: bool, +) -> String { + [ + format!("单人全身角色动作视频,动作主题是 {}。", animation), + "角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。".to_string(), + if loop_ { + "循环动作直接进入稳定循环,不要静止开场,不要定格首帧。".to_string() + } else { + "非循环动作首尾回到角色标准站姿,中段完成动作变化。".to_string() + }, + if use_chroma_key { + "背景为纯绿色绿幕,无其他人物和场景元素。".to_string() + } else { + "背景简洁纯净。".to_string() + }, + ] + .join(" ") +} + +fn sanitize_animation_prompt_text(value: &str, max_length: usize) -> String { + value + .replace(char::is_whitespace, " ") + .replace("血浆", "") + .replace("喷血", "") + .replace("鲜血", "") + .replace("断肢", "") + .replace("斩首", "") + .replace("裸体", "") + .replace("裸露", "") + .replace("色情", "") + .replace("性交", "") + .replace("死亡", "倒地结束") + .replace("死去", "倒地结束") + .replace("击杀", "倒地结束") + .replace("受击", "失衡") + .replace("受伤", "失衡") + .replace("砍杀", "挥击") + .replace("斩击", "挥击") + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(max_length) + .collect::() + .trim() + .to_string() +} + +fn build_compact_animation_character_brief(value: Option<&str>) -> String { + let normalized = sanitize_animation_prompt_text(value.unwrap_or_default(), 160); + if normalized.is_empty() { + return String::new(); + } + normalized + .split(['/', '|', '\n', ',', ',', '。', ';', ';']) + .map(str::trim) + .filter(|item| !item.is_empty()) + .take(4) + .collect::>() + .join(",") +} + +fn find_motion_template(id: &str) -> Option<&'static MotionTemplate> { + BUILT_IN_MOTION_TEMPLATES + .iter() + .find(|template| template.id == id.trim()) } fn resolve_character_animation_model(payload: &CharacterAnimationGenerateRequest) -> String { @@ -1254,6 +2024,370 @@ fn build_animation_generate_result_payload(generated: &CharacterAnimationGenerat } } +fn resolve_backend_frame_extraction_settings(state: &AppState) -> BackendFrameExtractionSettings { + BackendFrameExtractionSettings { + ffmpeg_path: normalize_required_text( + state.config.character_animation_ffmpeg_path.as_str(), + "ffmpeg", + ), + ffprobe_path: normalize_required_text( + state.config.character_animation_ffprobe_path.as_str(), + "ffprobe", + ), + timeout_ms: state + .config + .character_animation_frame_extract_timeout_ms + .max(1_000), + } +} + +fn normalize_animation_frame_extraction_plan( + draft: &CharacterAnimationDraftPayload, +) -> AnimationFrameExtractionPlan { + let frame_count = normalize_frame_count(draft.frame_count.unwrap_or(8)); + let apply_chroma_key = draft.apply_chroma_key.unwrap_or(true); + let default_start_ratio: f32 = if draft.loop_ { 0.12 } else { 0.0 }; + let default_end_ratio: f32 = if draft.loop_ { 0.94 } else { 1.0 }; + let sample_start_ratio = normalize_sample_ratio(draft.sample_start_ratio, default_start_ratio); + let sample_end_ratio = normalize_sample_ratio( + draft.sample_end_ratio, + default_end_ratio.max(sample_start_ratio + 0.05), + ) + .max(sample_start_ratio + 0.05) + .min(1.0); + + AnimationFrameExtractionPlan { + frame_count, + apply_chroma_key, + sample_start_ratio, + sample_end_ratio, + } +} + +fn normalize_sample_ratio(value: Option, fallback: f32) -> f32 { + let candidate = value.unwrap_or(fallback); + if candidate.is_finite() { + candidate.clamp(0.0, 1.0) + } else { + fallback.clamp(0.0, 1.0) + } +} + +async fn extract_animation_frames_from_preview_video( + state: &AppState, + preview_video_path: &str, + frame_width: u32, + frame_height: u32, + extraction_settings: &BackendFrameExtractionSettings, + plan: &AnimationFrameExtractionPlan, +) -> Result, AppError> { + let preview_payload = load_media_source_payload(state, preview_video_path).await?; + if !preview_payload.mime_type.starts_with("video/") { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "character-animation", + "message": "previewVideoPath 必须指向视频资源。", + })), + ); + } + + let temp_dir = create_animation_temp_dir()?; + let input_path = temp_dir.join(format!("preview.{}", preview_payload.extension)); + fs::write(&input_path, preview_payload.bytes).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "character-animation", + "message": format!("写入动作抽帧临时视频失败:{error}"), + })) + })?; + + let extraction_result = (|| { + let duration_seconds = + probe_video_duration_seconds(&input_path, extraction_settings)?.max(0.001); + let mut finalized_frames = Vec::with_capacity(plan.frame_count as usize); + for frame_index in 0..plan.frame_count { + let target_seconds = compute_sample_time_seconds( + duration_seconds, + frame_index, + plan.frame_count, + plan.sample_start_ratio, + plan.sample_end_ratio, + plan.frame_count > 1 && plan.sample_end_ratio < 1.0, + ); + let raw_frame_path = temp_dir.join(format!("raw-frame-{:02}.png", frame_index + 1)); + extract_video_frame_to_png( + &input_path, + &raw_frame_path, + target_seconds, + extraction_settings, + )?; + let frame_bytes = fs::read(&raw_frame_path).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "character-animation", + "message": format!("读取动作抽帧结果失败:{error}"), + })) + })?; + finalized_frames.push(finalize_animation_frame_payload( + frame_bytes.as_slice(), + "image/png", + frame_width, + frame_height, + plan.apply_chroma_key, + )?); + } + + Ok::<_, AppError>(finalized_frames) + })(); + + let _ = fs::remove_dir_all(&temp_dir); + extraction_result +} + +fn create_animation_temp_dir() -> Result { + let temp_dir = std::env::temp_dir().join(format!( + "genarrative-character-animation-{}", + current_utc_micros() + )); + fs::create_dir_all(&temp_dir).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "character-animation", + "message": format!("创建动作抽帧临时目录失败:{error}"), + })) + })?; + Ok(temp_dir) +} + +fn probe_video_duration_seconds( + input_path: &Path, + extraction_settings: &BackendFrameExtractionSettings, +) -> Result { + let output = run_process_with_timeout( + &extraction_settings.ffprobe_path, + &[ + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + input_path.to_string_lossy().as_ref(), + ], + extraction_settings.timeout_ms, + "探测动作预览视频时长失败", + )?; + let duration_text = String::from_utf8_lossy(&output.stdout).trim().to_string(); + duration_text.parse::().map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "character-animation", + "message": format!("解析动作预览视频时长失败:{error}"), + })) + }) +} + +fn compute_sample_time_seconds( + duration_seconds: f64, + frame_index: u32, + frame_count: u32, + sample_start_ratio: f32, + sample_end_ratio: f32, + loop_mode: bool, +) -> f64 { + let duration_seconds = duration_seconds.max(0.001); + let sample_start = duration_seconds * sample_start_ratio as f64; + let sample_end = duration_seconds * sample_end_ratio as f64; + let sample_window = (sample_end - sample_start).max(0.001); + let progress = if loop_mode { + frame_index as f64 / frame_count.max(1) as f64 + } else { + frame_index as f64 / frame_count.saturating_sub(1).max(1) as f64 + }; + + (sample_start + sample_window * progress).min((duration_seconds - 0.001).max(0.0)) +} + +fn extract_video_frame_to_png( + input_path: &Path, + output_path: &Path, + target_seconds: f64, + extraction_settings: &BackendFrameExtractionSettings, +) -> Result<(), AppError> { + run_process_with_timeout( + &extraction_settings.ffmpeg_path, + &[ + "-y", + "-ss", + &format!("{target_seconds:.3}"), + "-i", + input_path.to_string_lossy().as_ref(), + "-frames:v", + "1", + "-f", + "image2", + output_path.to_string_lossy().as_ref(), + ], + extraction_settings.timeout_ms, + "抽取动作视频帧失败", + )?; + + if !output_path.is_file() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "character-animation", + "message": "ffmpeg 已执行但未产出动作帧文件。", + })), + ); + } + Ok(()) +} + +fn run_process_with_timeout( + program: &str, + args: &[&str], + timeout_ms: u64, + fallback_message: &str, +) -> Result { + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "character-animation", + "message": format!("{fallback_message}:无法启动进程 {program}:{error}"), + })) + })?; + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + + loop { + if let Some(status) = child.try_wait().map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "character-animation", + "message": format!("{fallback_message}:等待进程状态失败:{error}"), + })) + })? { + let output = child.wait_with_output().map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "character-animation", + "message": format!("{fallback_message}:读取进程输出失败:{error}"), + })) + })?; + if !status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + format!("{program} 返回非零退出码:{status}") + }; + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "character-animation", + "message": format!("{fallback_message}:{detail}"), + })), + ); + } + return Ok(output); + } + + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err( + AppError::from_status(StatusCode::GATEWAY_TIMEOUT).with_details(json!({ + "provider": "character-animation", + "message": format!("{fallback_message}:执行超时,已等待 {} ms。", timeout_ms), + })), + ); + } + + thread::sleep(Duration::from_millis(20)); + } +} + +fn finalize_animation_frame_payload( + source: &[u8], + mime_type: &str, + frame_width: u32, + frame_height: u32, + apply_chroma_key: bool, +) -> Result { + let image_format = match mime_type { + "image/png" => Some(ImageFormat::Png), + "image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg), + "image/webp" => Some(ImageFormat::WebP), + _ => None, + }; + let mut image = match image_format { + Some(format) => image::load_from_memory_with_format(source, format), + None => image::load_from_memory(source), + } + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "character-animation", + "message": format!("解析动作帧图片失败:{error}"), + })) + })? + .to_rgba8(); + let (source_width, source_height) = image.dimensions(); + + if apply_chroma_key { + remove_background_from_rgba( + image.as_mut(), + source_width as usize, + source_height as usize, + ); + } + + let normalized = contain_rgba_image( + &image, + frame_width.max(1), + frame_height.max(1), + ); + let mut encoded = Vec::new(); + let encoder = PngEncoder::new(&mut encoded); + encoder + .write_image( + normalized.as_raw(), + normalized.width(), + normalized.height(), + ColorType::Rgba8.into(), + ) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "character-animation", + "message": format!("编码动作帧 PNG 失败:{error}"), + })) + })?; + + Ok(FinalizedAnimationFrame { + bytes: encoded, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} + +fn contain_rgba_image(source: &RgbaImage, target_width: u32, target_height: u32) -> RgbaImage { + let mut canvas = RgbaImage::from_pixel(target_width, target_height, Rgba([0, 0, 0, 0])); + let source_width = source.width().max(1); + let source_height = source.height().max(1); + let scale = (target_width as f32 / source_width as f32) + .min(target_height as f32 / source_height as f32); + let draw_width = ((source_width as f32 * scale).round() as u32) + .max(1) + .min(target_width); + let draw_height = ((source_height as f32 * scale).round() as u32) + .max(1) + .min(target_height); + let resized = image::imageops::resize(source, draw_width, draw_height, FilterType::Triangle); + let offset_x = ((target_width - draw_width) / 2) as i64; + let offset_y = ((target_height - draw_height) / 2) as i64; + image::imageops::overlay(&mut canvas, &resized, offset_x, offset_y); + canvas +} + async fn load_media_source_payload( state: &AppState, source: &str, @@ -1378,6 +2512,30 @@ fn parse_video_data_url(value: &str) -> Option { }) } +fn encode_base64(bytes: &[u8]) -> String { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b0 = *chunk.first().unwrap_or(&0); + let b1 = *chunk.get(1).unwrap_or(&0); + let b2 = *chunk.get(2).unwrap_or(&0); + let packed = ((b0 as u32) << 16) | ((b1 as u32) << 8) | b2 as u32; + output.push(TABLE[((packed >> 18) & 0x3f) as usize] as char); + output.push(TABLE[((packed >> 12) & 0x3f) as usize] as char); + output.push(if chunk.len() > 1 { + TABLE[((packed >> 6) & 0x3f) as usize] as char + } else { + '=' + }); + output.push(if chunk.len() > 2 { + TABLE[(packed & 0x3f) as usize] as char + } else { + '=' + }); + } + output +} + fn mime_to_extension(mime_type: &str) -> &str { match mime_type { "image/svg+xml" => "svg", @@ -1394,34 +2552,6 @@ fn mime_to_extension(mime_type: &str) -> &str { } } -fn load_stage1_placeholder_preview_video() -> Result { - for candidate in PLACEHOLDER_PREVIEW_VIDEO_CANDIDATES { - let path = PathBuf::from(candidate); - if path.is_file() { - let bytes = std::fs::read(&path).map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "character-animation", - "message": format!("读取 Stage 1 角色动作占位视频失败:{error}"), - })) - })?; - if !bytes.is_empty() { - return Ok(MediaPayload { - mime_type: "video/mp4".to_string(), - extension: "mp4".to_string(), - bytes, - }); - } - } - } - - Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "character-animation", - "message": "Stage 1 视频策略需要 referenceVideoDataUrls[0],或保留仓库内占位预览视频。", - })), - ) -} - fn decode_base64(value: &str) -> Option> { let cleaned = value.trim().replace(char::is_whitespace, ""); let mut output = Vec::with_capacity(cleaned.len() * 3 / 4); @@ -1449,6 +2579,143 @@ fn decode_base64(value: &str) -> Option> { Some(output) } +fn parse_animation_json_payload( + raw_text: &str, + fallback_message: &str, +) -> Result { + serde_json::from_str::(raw_text) + .map(|payload| ParsedAnimationJsonPayload { payload }) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "character-animation", + "message": format!("{fallback_message}:解析响应失败:{error}"), + })) + }) +} + +fn extract_animation_task_id(payload: &Value) -> Option { + payload + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| find_first_string_by_key(payload, "task_id")) +} + +fn extract_video_url(payload: &Value) -> Option { + find_first_string_by_key(payload, "video_url") + .or_else(|| find_first_string_by_key(payload, "url")) +} + +fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec) { + match value { + Value::Array(entries) => { + for entry in entries { + collect_strings_by_key(entry, target_key, results); + } + } + Value::Object(object) => { + for (key, nested_value) in object { + if key == target_key + && let Some(text) = nested_value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + results.push(text.to_string()); + continue; + } + collect_strings_by_key(nested_value, target_key, results); + } + } + _ => {} + } +} + +fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { + let mut results = Vec::new(); + collect_strings_by_key(value, target_key, &mut results); + results.into_iter().next() +} + +fn extract_generation_task_status(payload: &Value) -> String { + payload + .get("status") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| find_first_string_by_key(payload, "task_status")) + .or_else(|| find_first_string_by_key(payload, "status")) + .unwrap_or_default() +} + +fn normalize_generation_task_status(value: &str) -> String { + value.trim().to_ascii_lowercase().replace(' ', "_") +} + +fn is_completed_generation_task_status(status: &str) -> bool { + matches!( + status, + "completed" | "complete" | "done" | "finished" | "success" | "succeeded" | "succeed" + ) +} + +fn is_failed_generation_task_status(status: &str) -> bool { + matches!( + status, + "failed" | "canceled" | "cancelled" | "error" | "aborted" | "rejected" | "expired" | "unknown" + ) +} + +fn parse_animation_api_error_message(raw_text: &str, fallback_message: &str) -> String { + if raw_text.trim().is_empty() { + return fallback_message.to_string(); + } + if let Ok(parsed) = serde_json::from_str::(raw_text) { + if let Some(message) = parsed + .pointer("/error/message") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return message.to_string(); + } + if let Some(message) = parsed + .get("message") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return message.to_string(); + } + } + raw_text.trim().to_string() +} + +fn parse_animation_upstream_error(raw_text: &str, fallback_message: &str) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "character-animation", + "message": parse_animation_api_error_message(raw_text, fallback_message), + })) +} + +fn map_character_animation_upstream_error(message: String) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "character-animation", + "message": message, + })) +} + +fn is_inappropriate_content_message(value: &str) -> bool { + let normalized = value.to_ascii_lowercase(); + normalized.contains("inappropriate content") + || normalized.contains("finappropriate-content") + || value.contains("不适当内容") + || value.contains("违规内容") +} + fn build_asset_metadata( asset_kind: &str, owner_user_id: &str, @@ -1689,6 +2956,454 @@ fn escape_svg_text(value: &str) -> String { .replace('>', ">") } +fn clamp01(value: f32) -> f32 { + value.clamp(0.0, 1.0) +} + +fn lerp(from: f32, to: f32, t: f32) -> f32 { + from + (to - from) * clamp01(t) +} + +fn compute_green_background_score(red: u8, green: u8, blue: u8, alpha: u8) -> f32 { + if alpha == 0 { + return 1.0; + } + let green = green as f32; + let red = red as f32; + let blue = blue as f32; + let green_lead = green - red.max(blue); + if green < 52.0 || green_lead <= 8.0 { + return 0.0; + } + let green_ratio = green / (red + blue).max(1.0); + if green_ratio <= 0.52 { + return 0.0; + } + clamp01( + ((green - 52.0) / 168.0) * 0.22 + + ((green_lead - 8.0) / 96.0) * 0.53 + + ((green_ratio - 0.52) / 0.82) * 0.25, + ) +} + +fn compute_white_background_score(red: u8, green: u8, blue: u8, alpha: u8) -> f32 { + if alpha == 0 { + return 1.0; + } + let red = red as f32; + let green = green as f32; + let blue = blue as f32; + let max_channel = red.max(green).max(blue); + let min_channel = red.min(green).min(blue); + let average = (red + green + blue) / 3.0; + if average < 188.0 || min_channel < 168.0 { + return 0.0; + } + + let spread = max_channel - min_channel; + let neutrality = 1.0 - clamp01((spread - 6.0) / 34.0); + let brightness = clamp01((average - 188.0) / 55.0); + let floor = clamp01((min_channel - 168.0) / 60.0); + clamp01(neutrality * (brightness * 0.85 + floor * 0.15)) +} + +fn collect_foreground_neighbor_color( + pixels: &[u8], + width: usize, + height: usize, + x: usize, + y: usize, + background_mask: &[u8], + background_hints: &[f32], +) -> Option<(u8, u8, u8)> { + let mut total_weight = 0.0f32; + let mut total_red = 0.0f32; + let mut total_green = 0.0f32; + let mut total_blue = 0.0f32; + + for offset_y in -2i32..=2 { + for offset_x in -2i32..=2 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { + continue; + } + let next_pixel_index = next_y as usize * width + next_x as usize; + if background_mask[next_pixel_index] != 0 { + continue; + } + if background_hints[next_pixel_index] >= 0.18 { + continue; + } + let next_offset = next_pixel_index * 4; + let next_alpha = pixels[next_offset + 3]; + if next_alpha < 96 { + continue; + } + let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs(); + let weight = (next_alpha as f32 / 255.0) + * if distance <= 1 { + 1.8 + } else if distance == 2 { + 1.2 + } else { + 0.7 + }; + + total_weight += weight; + total_red += pixels[next_offset] as f32 * weight; + total_green += pixels[next_offset + 1] as f32 * weight; + total_blue += pixels[next_offset + 2] as f32 * weight; + } + } + + if total_weight <= 0.0 { + return None; + } + + Some(( + (total_red / total_weight).round() as u8, + (total_green / total_weight).round() as u8, + (total_blue / total_weight).round() as u8, + )) +} + +fn remove_background_from_rgba(pixels: &mut [u8], width: usize, height: usize) -> bool { + const SOFT_EDGE_ALPHA_THRESHOLD: u8 = 224; + const FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD: u8 = 96; + + let pixel_count = width * height; + if pixel_count == 0 { + return false; + } + + let mut background_mask = vec![0u8; pixel_count]; + let mut green_scores = vec![0.0f32; pixel_count]; + let mut white_scores = vec![0.0f32; pixel_count]; + let mut background_hints = vec![0.0f32; pixel_count]; + let mut queue = Vec::::new(); + let mut queue_index = 0usize; + let mut changed = false; + + for pixel_index in 0..pixel_count { + let offset = pixel_index * 4; + let red = pixels[offset]; + let green = pixels[offset + 1]; + let blue = pixels[offset + 2]; + let alpha = pixels[offset + 3]; + let green_score = compute_green_background_score(red, green, blue, alpha); + let white_score = compute_white_background_score(red, green, blue, alpha); + let transparency_hint = clamp01((56.0 - alpha as f32) / 56.0) * 0.75; + + green_scores[pixel_index] = green_score; + white_scores[pixel_index] = white_score; + background_hints[pixel_index] = green_score.max(white_score).max(transparency_hint); + } + + let try_seed_background = + |pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec| { + if background_mask[pixel_index] != 0 { + return; + } + let offset = pixel_index * 4; + let alpha = pixels[offset + 3]; + let strong_candidate = alpha < 40 + || green_scores[pixel_index] > 0.12 + || white_scores[pixel_index] > 0.32; + if !strong_candidate { + return; + } + background_mask[pixel_index] = 1; + queue.push(pixel_index); + }; + + for x in 0..width { + try_seed_background(x, &mut background_mask, &mut queue); + try_seed_background((height - 1) * width + x, &mut background_mask, &mut queue); + } + for y in 1..height.saturating_sub(1) { + try_seed_background(y * width, &mut background_mask, &mut queue); + try_seed_background(y * width + width - 1, &mut background_mask, &mut queue); + } + + while queue_index < queue.len() { + let pixel_index = queue[queue_index]; + queue_index += 1; + + let x = pixel_index % width; + let y = pixel_index / width; + let neighbor_indexes = [ + if x > 0 { Some(pixel_index - 1) } else { None }, + if x + 1 < width { Some(pixel_index + 1) } else { None }, + if y > 0 { Some(pixel_index - width) } else { None }, + if y + 1 < height { Some(pixel_index + width) } else { None }, + ]; + + for next_pixel_index in neighbor_indexes.into_iter().flatten() { + if background_mask[next_pixel_index] != 0 { + continue; + } + let next_offset = next_pixel_index * 4; + let next_alpha = pixels[next_offset + 3]; + let next_green_score = green_scores[next_pixel_index]; + let next_white_score = white_scores[next_pixel_index]; + let next_hint = background_hints[next_pixel_index]; + let reachable_soft_edge = next_hint > 0.08 + && next_alpha < SOFT_EDGE_ALPHA_THRESHOLD + && (next_green_score > 0.04 + || next_white_score > 0.08 + || next_alpha < 180); + + if next_alpha < 40 + || next_green_score > 0.12 + || next_white_score > 0.32 + || reachable_soft_edge + { + background_mask[next_pixel_index] = 1; + queue.push(next_pixel_index); + } + } + } + + for _ in 0..2 { + let mut expanded_mask = background_mask.clone(); + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if expanded_mask[pixel_index] != 0 { + continue; + } + let alpha = pixels[pixel_index * 4 + 3]; + let hint = background_hints[pixel_index]; + if alpha >= SOFT_EDGE_ALPHA_THRESHOLD || hint <= 0.06 { + continue; + } + + let mut adjacent_background_count = 0usize; + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 + || next_x >= width as i32 + || next_y < 0 + || next_y >= height as i32 + { + continue; + } + if background_mask[next_y as usize * width + next_x as usize] != 0 { + adjacent_background_count += 1; + } + } + } + + if adjacent_background_count >= 2 + || (adjacent_background_count >= 1 && hint > 0.18) + { + expanded_mask[pixel_index] = 1; + } + } + } + background_mask = expanded_mask; + } + + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if background_mask[pixel_index] == 0 { + continue; + } + + let offset = pixel_index * 4; + let alpha = pixels[offset + 3]; + if alpha == 0 { + continue; + } + + let matte_score = background_hints[pixel_index] + .max(green_scores[pixel_index]) + .max(white_scores[pixel_index]); + let mut foreground_support = 0usize; + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 + || next_x >= width as i32 + || next_y < 0 + || next_y >= height as i32 + { + continue; + } + let next_pixel_index = next_y as usize * width + next_x as usize; + if background_mask[next_pixel_index] != 0 { + continue; + } + let next_alpha = pixels[next_pixel_index * 4 + 3]; + if next_alpha >= FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD { + foreground_support += 1; + } + } + } + + let next_alpha = if matte_score > 0.9 || foreground_support == 0 { + 0 + } else if matte_score > 0.72 && foreground_support <= 1 { + ((alpha as f32) * 0.08).round() as u8 + } else { + ((alpha as f32) * (0.08f32.max(1.0 - matte_score * 0.95))).round() as u8 + }; + let mut next_alpha = next_alpha; + + if foreground_support >= 3 && matte_score < 0.55 { + next_alpha = next_alpha.max(((alpha as f32) * 0.22).round() as u8); + } + if next_alpha < 10 { + next_alpha = 0; + } + + if next_alpha != alpha { + pixels[offset + 3] = next_alpha; + changed = true; + } + } + } + + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + let offset = pixel_index * 4; + let alpha = pixels[offset + 3]; + if alpha == 0 { + continue; + } + + let mut touches_transparent_edge = false; + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 + || next_x >= width as i32 + || next_y < 0 + || next_y >= height as i32 + { + touches_transparent_edge = true; + continue; + } + let next_pixel_index = next_y as usize * width + next_x as usize; + if background_mask[next_pixel_index] != 0 + || pixels[next_pixel_index * 4 + 3] < 16 + { + touches_transparent_edge = true; + } + } + } + + if !touches_transparent_edge { + continue; + } + + let green_score = green_scores[pixel_index]; + let white_score = white_scores[pixel_index]; + let contamination = green_score + .max(white_score) + .max(if background_mask[pixel_index] != 0 { 0.35 } else { 0.0 }) + .max(if alpha < 220 { + ((220 - alpha) as f32 / 220.0) * 0.25 + } else { + 0.0 + }); + + if contamination < 0.06 { + continue; + } + + let mut red = pixels[offset] as f32; + let mut green = pixels[offset + 1] as f32; + let mut blue = pixels[offset + 2] as f32; + let sample = collect_foreground_neighbor_color( + pixels, + width, + height, + x, + y, + &background_mask, + &background_hints, + ); + let blend = clamp01(contamination.max(if touches_transparent_edge { 0.22 } else { 0.0 })); + + if let Some((sample_red, sample_green, sample_blue)) = sample { + red = lerp(red, sample_red as f32, blend); + green = lerp(green, sample_green as f32, blend); + blue = lerp(blue, sample_blue as f32, blend); + + if green_score > 0.04 { + green = green.min(sample_green as f32 + 18.0); + } + if white_score > 0.1 { + red = red.min(sample_red as f32 + 26.0); + green = green.min(sample_green as f32 + 26.0); + blue = blue.min(sample_blue as f32 + 26.0); + } + } else { + if green_score > 0.04 { + green = green.max(red.max(blue)) + .max((green - (green - red.max(blue)) * 0.78).round()); + } + + if white_score > 0.12 { + let spread = red.max(green).max(blue) - red.min(green).min(blue); + if spread < 20.0 { + let toned_value = ((red + green + blue) / 3.0 * 0.88).round(); + red = red.min(toned_value); + green = green.min(toned_value); + blue = blue.min(toned_value); + } + } + } + + let mut next_alpha = alpha; + let edge_fade = (green_score * 0.35).max(white_score * 0.28); + if edge_fade > 0.08 { + next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8; + if next_alpha < 10 { + next_alpha = 0; + } + } + + let next_red = red.round() as u8; + let next_green = green.round() as u8; + let next_blue = blue.round() as u8; + if next_red != pixels[offset] + || next_green != pixels[offset + 1] + || next_blue != pixels[offset + 2] + || next_alpha != alpha + { + pixels[offset] = next_red; + pixels[offset + 1] = next_green; + pixels[offset + 2] = next_blue; + pixels[offset + 3] = next_alpha; + changed = true; + } + } + } + + changed +} + fn map_ai_task_error(error: AiTaskServiceError) -> AppError { let status = match error { AiTaskServiceError::TaskNotFound => StatusCode::NOT_FOUND, @@ -1772,6 +3487,43 @@ struct ParsedVideoDataUrl { bytes: Vec, } +struct ParsedAnimationJsonPayload { + payload: Value, +} + +struct ArkVideoSettings { + base_url: String, + api_key: String, + request_timeout_ms: u64, + model: String, +} + +struct GeneratedAnimationPreview { + preview_video_path: String, + upstream_task_id: String, + submitted_prompt: String, + moderation_fallback_applied: bool, +} + +struct BackendFrameExtractionSettings { + ffmpeg_path: String, + ffprobe_path: String, + timeout_ms: u64, +} + +struct AnimationFrameExtractionPlan { + frame_count: u32, + apply_chroma_key: bool, + sample_start_ratio: f32, + sample_end_ratio: f32, +} + +struct FinalizedAnimationFrame { + bytes: Vec, + mime_type: String, + extension: String, +} + // 统一收口动作生成阶段返回的草稿载荷,避免图片序列和视频预览分支在 handler 层分叉太散。 struct CharacterAnimationGeneratedDraft { image_sources: Vec, diff --git a/server-rs/crates/api-server/src/character_visual_assets.rs b/server-rs/crates/api-server/src/character_visual_assets.rs index 179c8595..2f8d04c2 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -1,4 +1,7 @@ -use std::collections::BTreeMap; +use std::{ + collections::BTreeMap, + time::{Duration, Instant}, +}; use axum::{ Json, @@ -6,6 +9,8 @@ use axum::{ http::StatusCode, response::Response, }; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use image::{ColorType, ImageEncoder, ImageFormat, codecs::png::PngEncoder}; use module_ai::{ AiResultReferenceKind, AiStageCompletionInput, AiTaskCreateInput, AiTaskKind, AiTaskServiceError, AiTaskSnapshot, AiTaskStageKind, AiTaskStatus, generate_ai_task_id, @@ -14,7 +19,6 @@ use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; -use platform_llm::{LlmMessage, LlmTextRequest}; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, @@ -31,11 +35,13 @@ use crate::{ api_response::json_success_body, http_error::AppError, request_context::RequestContext, state::AppState, }; +use tokio::time::sleep; -const CHARACTER_VISUAL_MODEL: &str = "rust-svg-character-visual"; +const CHARACTER_VISUAL_MODEL: &str = "wan2.7-image-pro"; const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual"; const CHARACTER_VISUAL_ENTITY_KIND: &str = "character"; const CHARACTER_VISUAL_SLOT: &str = "primary_visual"; +const CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS: u64 = 2_500; pub async fn generate_character_visual( State(state): State, @@ -75,6 +81,9 @@ pub async fn generate_character_visual( .map_err(|error| character_visual_error_response(&request_context, error))?; let result = async { + let settings = require_dashscope_settings(&state)?; + let http_client = build_dashscope_http_client(&settings)?; + state .ai_task_service() .start_task(task_id.as_str(), current_utc_micros()) @@ -95,11 +104,11 @@ pub async fn generate_character_visual( text_output: Some(prompt.clone()), structured_payload_json: Some( json!({ - "characterId": character_id, - "sourceMode": payload.source_mode, - "size": size, - "referenceImageCount": payload.reference_image_data_urls.len(), - }) + "characterId": character_id, + "sourceMode": payload.source_mode, + "size": size, + "referenceImageCount": payload.reference_image_data_urls.len(), + }) .to_string(), ), warning_messages: Vec::new(), @@ -107,8 +116,6 @@ pub async fn generate_character_visual( }) .map_err(map_ai_task_error)?; - let visual_seed = generate_visual_seed_with_llm(&state, &prompt, &character_id).await; - state .ai_task_service() .start_stage( @@ -117,13 +124,67 @@ pub async fn generate_character_visual( current_utc_micros(), ) .map_err(map_ai_task_error)?; + + let reference_images = match payload.source_mode { + shared_contracts::assets::CharacterVisualSourceMode::TextToImage => Vec::new(), + _ => { + if payload.reference_image_data_urls.is_empty() { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details( + json!({ + "provider": "character-visual", + "message": "图生主形象至少需要一张参考图。", + }), + )); + } + + let mut normalized_reference_images = + Vec::with_capacity(payload.reference_image_data_urls.len()); + for (index, source) in payload.reference_image_data_urls.iter().enumerate() { + normalized_reference_images.push( + resolve_reference_image_as_data_url( + &state, + &http_client, + source, + format!("referenceImageDataUrls[{index}]").as_str(), + ) + .await?, + ); + } + normalized_reference_images + } + }; + + let generated = create_character_visual_generation( + &http_client, + &settings, + model.as_str(), + prompt.as_str(), + size.as_str(), + candidate_count, + &reference_images, + ) + .await?; + state .ai_task_service() .complete_stage(AiStageCompletionInput { task_id: task_id.clone(), stage_kind: AiTaskStageKind::RequestModel, - text_output: Some(visual_seed.clone()), - structured_payload_json: None, + text_output: Some( + generated + .actual_prompt + .clone() + .unwrap_or_else(|| prompt.clone()), + ), + structured_payload_json: Some( + json!({ + "provider": "dashscope", + "taskId": generated.task_id, + "model": model, + "imageCount": generated.images.len(), + }) + .to_string(), + ), warning_messages: Vec::new(), completed_at_micros: current_utc_micros(), }) @@ -134,9 +195,8 @@ pub async fn generate_character_visual( &owner_user_id, &character_id, &task_id, - &visual_seed, - &size, - candidate_count, + generated.images, + size.as_str(), ) .await?; @@ -316,55 +376,17 @@ fn create_visual_task( .map_err(map_ai_task_error) } -async fn generate_visual_seed_with_llm( - state: &AppState, - prompt: &str, - character_id: &str, -) -> String { - let fallback = format!("{character_id}:{prompt}"); - let Some(llm_client) = state.llm_client() else { - return fallback; - }; - - let request = LlmTextRequest::new(vec![ - LlmMessage::system( - "你是游戏角色主形象草稿描述器。只输出一句中文视觉摘要,不要输出 Markdown。", - ), - LlmMessage::user( - json!({ - "task": "summarize_character_visual_seed", - "characterId": character_id, - "prompt": prompt, - }) - .to_string(), - ), - ]) - .with_max_tokens(96); - - llm_client - .request_text(request) - .await - .ok() - .map(|response| response.content.trim().to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or(fallback) -} - async fn persist_visual_drafts( state: &AppState, owner_user_id: &str, character_id: &str, task_id: &str, - visual_seed: &str, + images: Vec, size: &str, - candidate_count: u32, ) -> Result, AppError> { - let mut drafts = Vec::with_capacity(candidate_count as usize); - for index in 0..candidate_count { - let file_name = format!("candidate-{:02}.svg", index + 1); - let body = - build_character_visual_svg(size, visual_seed, format!("候选 {}", index + 1).as_str()) - .into_bytes(); + let mut drafts = Vec::with_capacity(images.len()); + for (index, image) in images.into_iter().enumerate() { + let file_name = format!("candidate-{:02}.{}", index + 1, image.extension); let put_result = put_character_visual_object( state, LegacyAssetPrefix::CharacterDrafts, @@ -374,8 +396,8 @@ async fn persist_visual_drafts( task_id.to_string(), ], file_name, - "image/svg+xml".to_string(), - body, + image.mime_type, + image.bytes, build_asset_metadata( CHARACTER_VISUAL_ASSET_KIND, owner_user_id, @@ -667,50 +689,382 @@ fn build_character_visual_prompt(prompt_text: &str, character_brief_text: Option ) } -fn build_character_visual_svg(size: &str, label: &str, candidate_label: &str) -> String { - let (width, height) = parse_size(size); - format!( - r##" - - - - - -{title} -{candidate} -"##, - width = width, - height = height, - shadow_x = width / 2, - shadow_y = height * 5 / 6, - shadow_rx = width / 5, - shadow_ry = height / 28, - body_x = width * 45 / 100, - body_y = height * 34 / 100, - body_c1x = width * 34 / 100, - body_c1y = height * 50 / 100, - body_c2x = width * 43 / 100, - body_c2y = height * 72 / 100, - body_x2 = width * 56 / 100, - body_y2 = height * 72 / 100, - leg_x = width * 48 / 100, - leg_y = height * 84 / 100, - leg2_x = width * 62 / 100, - head_x = width * 53 / 100, - head_y = height * 25 / 100, - head_r = (width.min(height) / 12).max(18), - weapon_x = width * 57 / 100, - weapon_y = height * 42 / 100, - weapon_x2 = width * 76 / 100, - weapon_y2 = height * 34 / 100, - weapon_w = (width.min(height) / 90).max(4), - text_y = height * 91 / 100, - sub_y = height * 96 / 100, - font_main = (width.min(height) / 28).max(14), - font_sub = (width.min(height) / 36).max(11), - title = escape_svg_text(label), - candidate = escape_svg_text(candidate_label), - ) +fn build_character_visual_negative_prompt() -> String { + [ + "正面视角", + "左朝向", + "完全 90 度纯右视图", + "镜头透视", + "半身像", + "脚被裁切", + "头顶被裁切", + "多角色", + "复杂背景", + "建筑场景", + "漂浮物", + "烟雾环境", + "武器消失", + "武器换手", + "额外手臂", + "额外腿", + "服装变化", + "脸部变化", + "模糊", + "运动模糊", + "文字", + "水印", + "UI 元素", + "软萌 Q版大头贴", + "儿童绘本风", + "厚涂插画感", + "低对比柔边", + ] + .join(",") +} + +fn require_dashscope_settings(state: &AppState) -> Result { + let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/'); + if base_url.is_empty() { + return Err( + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "dashscope", + "reason": "DASHSCOPE_BASE_URL 未配置", + })), + ); + } + + let api_key = state + .config + .dashscope_api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "dashscope", + "reason": "DASHSCOPE_API_KEY 未配置", + })) + })?; + + Ok(DashScopeSettings { + base_url: base_url.to_string(), + api_key: api_key.to_string(), + request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1), + }) +} + +fn build_dashscope_http_client(settings: &DashScopeSettings) -> Result { + reqwest::Client::builder() + .timeout(Duration::from_millis(settings.request_timeout_ms)) + .build() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "dashscope", + "message": format!("构造 DashScope HTTP 客户端失败:{error}"), + })) + }) +} + +async fn resolve_reference_image_as_data_url( + state: &AppState, + http_client: &reqwest::Client, + source: &str, + field: &str, +) -> Result { + let trimmed = source.trim(); + if trimmed.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "character-visual", + "field": field, + "message": "参考图不能为空。", + })), + ); + } + + if let Some(parsed) = parse_image_data_url(trimmed) { + return Ok(format!( + "data:{};base64,{}", + parsed.mime_type, + BASE64_STANDARD.encode(parsed.bytes) + )); + } + + if !trimmed.starts_with('/') { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "character-visual", + "field": field, + "message": "参考图必须是 Data URL 或 /generated-* 旧路径。", + })), + ); + } + + let object_key = trimmed.trim_start_matches('/'); + if LegacyAssetPrefix::from_object_key(object_key).is_none() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "character-visual", + "field": field, + "message": "参考图当前只支持 /generated-* 旧路径。", + })), + ); + } + + let oss_client = require_oss_client(state)?; + let signed = oss_client + .sign_get_object_url(OssSignedGetObjectUrlRequest { + object_key: object_key.to_string(), + expire_seconds: Some(60), + }) + .map_err(map_character_visual_oss_error)?; + let response = http_client + .get(signed.signed_url) + .send() + .await + .map_err(|error| { + map_dashscope_request_error(format!("读取角色主形象参考图失败:{error}")) + })?; + let status = response.status(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("image/png") + .to_string(); + let body = response + .bytes() + .await + .map_err(|error| { + map_dashscope_request_error(format!("读取角色主形象参考图内容失败:{error}")) + })?; + if !status.is_success() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "aliyun-oss", + "field": field, + "message": format!("读取参考图失败,状态码:{status}"), + "objectKey": object_key, + })), + ); + } + if body.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "aliyun-oss", + "field": field, + "message": "读取参考图失败:对象内容为空", + "objectKey": object_key, + })), + ); + } + + Ok(format!( + "data:{};base64,{}", + content_type, + BASE64_STANDARD.encode(body) + )) +} + +async fn create_character_visual_generation( + http_client: &reqwest::Client, + settings: &DashScopeSettings, + model: &str, + prompt: &str, + size: &str, + candidate_count: u32, + reference_images: &[String], +) -> Result { + let mut content = vec![json!({ "text": prompt })]; + for image in reference_images { + content.push(json!({ "image": image })); + } + + let response = http_client + .post(format!( + "{}/services/aigc/image-generation/generation", + settings.base_url + )) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header("X-DashScope-Async", "enable") + .json(&json!({ + "model": model, + "input": { + "messages": [ + { + "role": "user", + "content": content, + } + ], + }, + "parameters": { + "n": candidate_count, + "size": size, + "negative_prompt": build_character_visual_negative_prompt(), + "prompt_extend": true, + "watermark": false, + }, + })) + .send() + .await + .map_err(|error| { + map_dashscope_request_error(format!("创建角色主形象任务失败:{error}")) + })?; + let response_status = response.status(); + let response_text = response.text().await.map_err(|error| { + map_dashscope_request_error(format!("读取角色主形象任务响应失败:{error}")) + })?; + if !response_status.is_success() { + return Err(map_dashscope_upstream_error( + response_text.as_str(), + "创建角色主形象任务失败。", + )); + } + let response_json = parse_json_payload(response_text.as_str(), "创建角色主形象任务失败。")?; + let task_id = extract_task_id(&response_json.payload).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": "角色主形象任务未返回 task_id", + })) + })?; + + let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms); + while Instant::now() < deadline { + let poll_response = http_client + .get(format!("{}/tasks/{}", settings.base_url, task_id)) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .send() + .await + .map_err(|error| { + map_dashscope_request_error(format!("查询角色主形象任务失败:{error}")) + })?; + let poll_status = poll_response.status(); + let poll_text = poll_response.text().await.map_err(|error| { + map_dashscope_request_error(format!("读取角色主形象任务状态失败:{error}")) + })?; + if !poll_status.is_success() { + return Err(map_dashscope_upstream_error( + poll_text.as_str(), + "查询角色主形象任务失败。", + )); + } + let poll_json = parse_json_payload(poll_text.as_str(), "查询角色主形象任务失败。")?; + let task_status = find_first_string_by_key(&poll_json.payload, "task_status") + .unwrap_or_default() + .trim() + .to_string(); + if task_status == "SUCCEEDED" { + let image_urls = extract_image_urls(&poll_json.payload); + if image_urls.is_empty() { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( + json!({ + "provider": "dashscope", + "message": "角色主形象生成成功,但没有返回可下载图片。", + }), + )); + } + + let mut images = Vec::with_capacity(image_urls.len()); + for image_url in image_urls { + images.push( + download_generated_image(http_client, image_url.as_str(), "下载角色主形象候选图失败。") + .await?, + ); + } + + return Ok(GeneratedCharacterVisuals { + task_id, + actual_prompt: find_first_string_by_key(&poll_json.payload, "actual_prompt"), + images, + }); + } + if matches!(task_status.as_str(), "FAILED" | "UNKNOWN" | "CANCELED") { + return Err(map_dashscope_upstream_error( + poll_text.as_str(), + "角色主形象任务执行失败。", + )); + } + + sleep(Duration::from_millis(CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS)).await; + } + + Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": "角色主形象任务执行超时,请稍后重试。", + }))) +} + +async fn download_generated_image( + http_client: &reqwest::Client, + image_url: &str, + fallback_message: &str, +) -> Result { + let response = http_client + .get(image_url) + .send() + .await + .map_err(|error| map_dashscope_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("image/jpeg") + .to_string(); + let body = response + .bytes() + .await + .map_err(|error| map_dashscope_request_error(format!("{fallback_message}:{error}")))?; + if !status.is_success() { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": fallback_message, + "status": status.as_u16(), + }))); + } + + let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str()); + let mut bytes = body.to_vec(); + let mut extension = mime_to_extension(normalized_mime_type.as_str()).to_string(); + let mut mime_type = normalized_mime_type; + + if mime_type == "image/png" + && let Some(optimized) = try_apply_background_alpha_to_png(bytes.as_slice()) + { + bytes = optimized; + extension = "png".to_string(); + mime_type = "image/png".to_string(); + } + + Ok(DownloadedGeneratedImage { + bytes, + mime_type, + extension, + }) +} + +fn try_apply_background_alpha_to_png(source: &[u8]) -> Option> { + let mut image = image::load_from_memory_with_format(source, ImageFormat::Png) + .ok()? + .to_rgba8(); + let (width, height) = image.dimensions(); + if !remove_background_from_rgba(image.as_mut(), width as usize, height as usize) { + return Some(source.to_vec()); + } + + let mut encoded = Vec::new(); + let encoder = PngEncoder::new(&mut encoded); + encoder + .write_image(image.as_raw(), width, height, ColorType::Rgba8.into()) + .ok()?; + Some(encoded) } fn resolve_object_key_from_legacy_path(value: &str) -> Result { @@ -823,13 +1177,6 @@ fn parse_size(size: &str) -> (u32, u32) { (width, height) } -fn escape_svg_text(value: &str) -> String { - value - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") -} - fn format_utc_micros(micros: i64) -> String { module_runtime::format_utc_micros(micros) } @@ -897,6 +1244,641 @@ fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError { })) } +fn parse_json_payload(raw_text: &str, fallback_message: &str) -> Result { + serde_json::from_str::(raw_text) + .map(|payload| ParsedJsonPayload { payload }) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": format!("{fallback_message}:解析响应失败:{error}"), + })) + }) +} + +fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { + if raw_text.trim().is_empty() { + return fallback_message.to_string(); + } + + if let Ok(parsed) = serde_json::from_str::(raw_text) { + if let Some(message) = parsed + .pointer("/error/message") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return message.to_string(); + } + if let Some(message) = parsed + .get("message") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return message.to_string(); + } + if let Some(code) = parsed + .pointer("/error/code") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return format!("{fallback_message}({code})"); + } + if let Some(code) = parsed + .get("code") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return format!("{fallback_message}({code})"); + } + } + + raw_text.trim().to_string() +} + +fn map_dashscope_request_error(message: String) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": message, + })) +} + +fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": parse_api_error_message(raw_text, fallback_message), + })) +} + +fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec) { + match value { + Value::Array(entries) => { + for entry in entries { + collect_strings_by_key(entry, target_key, results); + } + } + Value::Object(object) => { + for (key, nested_value) in object { + if key == target_key + && let Some(text) = nested_value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + results.push(text.to_string()); + continue; + } + collect_strings_by_key(nested_value, target_key, results); + } + } + _ => {} + } +} + +fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { + let mut results = Vec::new(); + collect_strings_by_key(value, target_key, &mut results); + results.into_iter().next() +} + +fn extract_task_id(payload: &Value) -> Option { + find_first_string_by_key(payload, "task_id") +} + +fn extract_image_urls(payload: &Value) -> Vec { + let mut urls = Vec::new(); + collect_strings_by_key(payload, "image", &mut urls); + collect_strings_by_key(payload, "url", &mut urls); + let mut deduped = Vec::new(); + for url in urls { + if !deduped.contains(&url) { + deduped.push(url); + } + } + deduped +} + +fn normalize_downloaded_image_mime_type(content_type: &str) -> String { + let mime_type = content_type + .split(';') + .next() + .map(str::trim) + .unwrap_or("image/jpeg"); + match mime_type { + "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { + mime_type.to_string() + } + _ => "image/jpeg".to_string(), + } +} + +fn mime_to_extension(mime_type: &str) -> &str { + match mime_type { + "image/png" => "png", + "image/webp" => "webp", + "image/gif" => "gif", + _ => "jpg", + } +} + +fn parse_image_data_url(value: &str) -> Option { + let body = value.trim().strip_prefix("data:")?; + let (mime_type, data) = body.split_once(";base64,")?; + if !mime_type.starts_with("image/") { + return None; + } + let bytes = decode_base64(data)?; + if bytes.is_empty() { + return None; + } + Some(ParsedImageDataUrl { + mime_type: mime_type.to_string(), + bytes, + }) +} + +fn decode_base64(value: &str) -> Option> { + let cleaned = value.trim().replace(char::is_whitespace, ""); + let mut output = Vec::with_capacity(cleaned.len() * 3 / 4); + let mut buffer = 0u32; + let mut bits = 0u8; + + for byte in cleaned.bytes() { + let value = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'+' => 62, + b'/' => 63, + b'=' => break, + _ => return None, + } as u32; + buffer = (buffer << 6) | value; + bits += 6; + while bits >= 8 { + bits -= 8; + output.push(((buffer >> bits) & 0xFF) as u8); + } + } + + Some(output) +} + +fn clamp01(value: f32) -> f32 { + value.clamp(0.0, 1.0) +} + +fn lerp(from: f32, to: f32, t: f32) -> f32 { + from + (to - from) * clamp01(t) +} + +fn compute_green_background_score(red: u8, green: u8, blue: u8, alpha: u8) -> f32 { + if alpha == 0 { + return 1.0; + } + let green = green as f32; + let red = red as f32; + let blue = blue as f32; + let green_lead = green - red.max(blue); + if green < 52.0 || green_lead <= 8.0 { + return 0.0; + } + let green_ratio = green / (red + blue).max(1.0); + if green_ratio <= 0.52 { + return 0.0; + } + clamp01( + ((green - 52.0) / 168.0) * 0.22 + + ((green_lead - 8.0) / 96.0) * 0.53 + + ((green_ratio - 0.52) / 0.82) * 0.25, + ) +} + +fn compute_white_background_score(red: u8, green: u8, blue: u8, alpha: u8) -> f32 { + if alpha == 0 { + return 1.0; + } + let red = red as f32; + let green = green as f32; + let blue = blue as f32; + let max_channel = red.max(green).max(blue); + let min_channel = red.min(green).min(blue); + let average = (red + green + blue) / 3.0; + if average < 188.0 || min_channel < 168.0 { + return 0.0; + } + + let spread = max_channel - min_channel; + let neutrality = 1.0 - clamp01((spread - 6.0) / 34.0); + let brightness = clamp01((average - 188.0) / 55.0); + let floor = clamp01((min_channel - 168.0) / 60.0); + clamp01(neutrality * (brightness * 0.85 + floor * 0.15)) +} + +fn collect_foreground_neighbor_color( + pixels: &[u8], + width: usize, + height: usize, + x: usize, + y: usize, + background_mask: &[u8], + background_hints: &[f32], +) -> Option<(u8, u8, u8)> { + let mut total_weight = 0.0f32; + let mut total_red = 0.0f32; + let mut total_green = 0.0f32; + let mut total_blue = 0.0f32; + + for offset_y in -2i32..=2 { + for offset_x in -2i32..=2 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { + continue; + } + let next_pixel_index = next_y as usize * width + next_x as usize; + if background_mask[next_pixel_index] != 0 { + continue; + } + if background_hints[next_pixel_index] >= 0.18 { + continue; + } + let next_offset = next_pixel_index * 4; + let next_alpha = pixels[next_offset + 3]; + if next_alpha < 96 { + continue; + } + let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs(); + let weight = (next_alpha as f32 / 255.0) + * if distance <= 1 { + 1.8 + } else if distance == 2 { + 1.2 + } else { + 0.7 + }; + + total_weight += weight; + total_red += pixels[next_offset] as f32 * weight; + total_green += pixels[next_offset + 1] as f32 * weight; + total_blue += pixels[next_offset + 2] as f32 * weight; + } + } + + if total_weight <= 0.0 { + return None; + } + + Some(( + (total_red / total_weight).round() as u8, + (total_green / total_weight).round() as u8, + (total_blue / total_weight).round() as u8, + )) +} + +pub(crate) fn remove_background_from_rgba( + pixels: &mut [u8], + width: usize, + height: usize, +) -> bool { + const SOFT_EDGE_ALPHA_THRESHOLD: u8 = 224; + const FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD: u8 = 96; + + let pixel_count = width * height; + if pixel_count == 0 { + return false; + } + + let mut background_mask = vec![0u8; pixel_count]; + let mut green_scores = vec![0.0f32; pixel_count]; + let mut white_scores = vec![0.0f32; pixel_count]; + let mut background_hints = vec![0.0f32; pixel_count]; + let mut queue = Vec::::new(); + let mut queue_index = 0usize; + let mut changed = false; + + for pixel_index in 0..pixel_count { + let offset = pixel_index * 4; + let red = pixels[offset]; + let green = pixels[offset + 1]; + let blue = pixels[offset + 2]; + let alpha = pixels[offset + 3]; + let green_score = compute_green_background_score(red, green, blue, alpha); + let white_score = compute_white_background_score(red, green, blue, alpha); + let transparency_hint = clamp01((56.0 - alpha as f32) / 56.0) * 0.75; + + green_scores[pixel_index] = green_score; + white_scores[pixel_index] = white_score; + background_hints[pixel_index] = + green_score.max(white_score).max(transparency_hint); + } + + let try_seed_background = + |pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec| { + if background_mask[pixel_index] != 0 { + return; + } + let offset = pixel_index * 4; + let alpha = pixels[offset + 3]; + let strong_candidate = alpha < 40 + || green_scores[pixel_index] > 0.12 + || white_scores[pixel_index] > 0.32; + if !strong_candidate { + return; + } + background_mask[pixel_index] = 1; + queue.push(pixel_index); + }; + + for x in 0..width { + try_seed_background(x, &mut background_mask, &mut queue); + try_seed_background((height - 1) * width + x, &mut background_mask, &mut queue); + } + for y in 1..height.saturating_sub(1) { + try_seed_background(y * width, &mut background_mask, &mut queue); + try_seed_background(y * width + width - 1, &mut background_mask, &mut queue); + } + + while queue_index < queue.len() { + let pixel_index = queue[queue_index]; + queue_index += 1; + + let x = pixel_index % width; + let y = pixel_index / width; + let neighbor_indexes = [ + if x > 0 { Some(pixel_index - 1) } else { None }, + if x + 1 < width { Some(pixel_index + 1) } else { None }, + if y > 0 { Some(pixel_index - width) } else { None }, + if y + 1 < height { Some(pixel_index + width) } else { None }, + ]; + + for next_pixel_index in neighbor_indexes.into_iter().flatten() { + if background_mask[next_pixel_index] != 0 { + continue; + } + let next_offset = next_pixel_index * 4; + let next_alpha = pixels[next_offset + 3]; + let next_green_score = green_scores[next_pixel_index]; + let next_white_score = white_scores[next_pixel_index]; + let next_hint = background_hints[next_pixel_index]; + let reachable_soft_edge = next_hint > 0.08 + && next_alpha < SOFT_EDGE_ALPHA_THRESHOLD + && (next_green_score > 0.04 + || next_white_score > 0.08 + || next_alpha < 180); + + if next_alpha < 40 + || next_green_score > 0.12 + || next_white_score > 0.32 + || reachable_soft_edge + { + background_mask[next_pixel_index] = 1; + queue.push(next_pixel_index); + } + } + } + + for _ in 0..2 { + let mut expanded_mask = background_mask.clone(); + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if expanded_mask[pixel_index] != 0 { + continue; + } + let alpha = pixels[pixel_index * 4 + 3]; + let hint = background_hints[pixel_index]; + if alpha >= SOFT_EDGE_ALPHA_THRESHOLD || hint <= 0.06 { + continue; + } + + let mut adjacent_background_count = 0usize; + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 + || next_x >= width as i32 + || next_y < 0 + || next_y >= height as i32 + { + continue; + } + if background_mask[next_y as usize * width + next_x as usize] != 0 { + adjacent_background_count += 1; + } + } + } + + if adjacent_background_count >= 2 + || (adjacent_background_count >= 1 && hint > 0.18) + { + expanded_mask[pixel_index] = 1; + } + } + } + background_mask = expanded_mask; + } + + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if background_mask[pixel_index] == 0 { + continue; + } + + let offset = pixel_index * 4; + let alpha = pixels[offset + 3]; + if alpha == 0 { + continue; + } + + let matte_score = background_hints[pixel_index] + .max(green_scores[pixel_index]) + .max(white_scores[pixel_index]); + let mut foreground_support = 0usize; + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 + || next_x >= width as i32 + || next_y < 0 + || next_y >= height as i32 + { + continue; + } + let next_pixel_index = next_y as usize * width + next_x as usize; + if background_mask[next_pixel_index] != 0 { + continue; + } + let next_alpha = pixels[next_pixel_index * 4 + 3]; + if next_alpha >= FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD { + foreground_support += 1; + } + } + } + + let next_alpha = if matte_score > 0.9 || foreground_support == 0 { + 0 + } else if matte_score > 0.72 && foreground_support <= 1 { + ((alpha as f32) * 0.08).round() as u8 + } else { + ((alpha as f32) * (0.08f32.max(1.0 - matte_score * 0.95))).round() as u8 + }; + let mut next_alpha = next_alpha; + + if foreground_support >= 3 && matte_score < 0.55 { + next_alpha = next_alpha.max(((alpha as f32) * 0.22).round() as u8); + } + if next_alpha < 10 { + next_alpha = 0; + } + + if next_alpha != alpha { + pixels[offset + 3] = next_alpha; + changed = true; + } + } + } + + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + let offset = pixel_index * 4; + let alpha = pixels[offset + 3]; + if alpha == 0 { + continue; + } + + let mut touches_transparent_edge = false; + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 + || next_x >= width as i32 + || next_y < 0 + || next_y >= height as i32 + { + touches_transparent_edge = true; + continue; + } + let next_pixel_index = next_y as usize * width + next_x as usize; + if background_mask[next_pixel_index] != 0 + || pixels[next_pixel_index * 4 + 3] < 16 + { + touches_transparent_edge = true; + } + } + } + + if !touches_transparent_edge { + continue; + } + + let green_score = green_scores[pixel_index]; + let white_score = white_scores[pixel_index]; + let contamination = green_score + .max(white_score) + .max(if background_mask[pixel_index] != 0 { 0.35 } else { 0.0 }) + .max(if alpha < 220 { + ((220 - alpha) as f32 / 220.0) * 0.25 + } else { + 0.0 + }); + + if contamination < 0.06 { + continue; + } + + let mut red = pixels[offset] as f32; + let mut green = pixels[offset + 1] as f32; + let mut blue = pixels[offset + 2] as f32; + let sample = collect_foreground_neighbor_color( + pixels, + width, + height, + x, + y, + &background_mask, + &background_hints, + ); + let blend = clamp01(contamination.max(if touches_transparent_edge { 0.22 } else { 0.0 })); + + if let Some((sample_red, sample_green, sample_blue)) = sample { + red = lerp(red, sample_red as f32, blend); + green = lerp(green, sample_green as f32, blend); + blue = lerp(blue, sample_blue as f32, blend); + + if green_score > 0.04 { + green = green.min(sample_green as f32 + 18.0); + } + if white_score > 0.1 { + red = red.min(sample_red as f32 + 26.0); + green = green.min(sample_green as f32 + 26.0); + blue = blue.min(sample_blue as f32 + 26.0); + } + } else { + if green_score > 0.04 { + green = green.max(red.max(blue)) + .max((green - (green - red.max(blue)) * 0.78).round()); + } + + if white_score > 0.12 { + let spread = red.max(green).max(blue) - red.min(green).min(blue); + if spread < 20.0 { + let toned_value = ((red + green + blue) / 3.0 * 0.88).round(); + red = red.min(toned_value); + green = green.min(toned_value); + blue = blue.min(toned_value); + } + } + } + + let mut next_alpha = alpha; + let edge_fade = (green_score * 0.35).max(white_score * 0.28); + if edge_fade > 0.08 { + next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8; + if next_alpha < 10 { + next_alpha = 0; + } + } + + let next_red = red.round() as u8; + let next_green = green.round() as u8; + let next_blue = blue.round() as u8; + if next_red != pixels[offset] + || next_green != pixels[offset + 1] + || next_blue != pixels[offset + 2] + || next_alpha != alpha + { + pixels[offset] = next_red; + pixels[offset + 1] = next_green; + pixels[offset + 2] = next_blue; + pixels[offset + 3] = next_alpha; + changed = true; + } + } + } + + changed +} + fn character_visual_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } @@ -915,6 +1897,33 @@ impl EmptyFallback for String { } } +struct DashScopeSettings { + base_url: String, + api_key: String, + request_timeout_ms: u64, +} + +struct GeneratedCharacterVisuals { + task_id: String, + actual_prompt: Option, + images: Vec, +} + +struct DownloadedGeneratedImage { + bytes: Vec, + mime_type: String, + extension: String, +} + +struct ParsedJsonPayload { + payload: Value, +} + +struct ParsedImageDataUrl { + mime_type: String, + bytes: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 744acfe2..054b9974 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -15,6 +15,9 @@ pub struct AppConfig { pub bind_host: String, pub bind_port: u16, pub log_filter: String, + pub admin_username: Option, + pub admin_password: Option, + pub admin_token_ttl_seconds: u64, pub internal_api_secret: Option, pub jwt_issuer: String, pub jwt_secret: String, @@ -78,6 +81,13 @@ pub struct AppConfig { pub dashscope_base_url: String, pub dashscope_api_key: Option, pub dashscope_image_request_timeout_ms: u64, + pub ark_character_video_base_url: String, + pub ark_character_video_api_key: Option, + pub ark_character_video_request_timeout_ms: u64, + pub ark_character_video_model: String, + pub character_animation_ffmpeg_path: String, + pub character_animation_ffprobe_path: String, + pub character_animation_frame_extract_timeout_ms: u64, pub slow_request_threshold_ms: u64, } @@ -87,6 +97,9 @@ impl Default for AppConfig { bind_host: "127.0.0.1".to_string(), bind_port: 3000, log_filter: "info,tower_http=info".to_string(), + admin_username: None, + admin_password: None, + admin_token_ttl_seconds: 4 * 60 * 60, internal_api_secret: Some(DEFAULT_INTERNAL_API_SECRET.to_string()), jwt_issuer: "https://auth.genarrative.local".to_string(), jwt_secret: "genarrative-dev-secret".to_string(), @@ -151,6 +164,13 @@ impl Default for AppConfig { dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(), dashscope_api_key: None, dashscope_image_request_timeout_ms: 150_000, + ark_character_video_base_url: DEFAULT_ARK_BASE_URL.to_string(), + ark_character_video_api_key: None, + ark_character_video_request_timeout_ms: 420_000, + ark_character_video_model: "doubao-seedance-2-0-fast-260128".to_string(), + character_animation_ffmpeg_path: "ffmpeg".to_string(), + character_animation_ffprobe_path: "ffprobe".to_string(), + character_animation_frame_extract_timeout_ms: 120_000, slow_request_threshold_ms: 1_000, } } @@ -182,6 +202,14 @@ impl AppConfig { config.log_filter = log_filter; } + config.admin_username = read_first_non_empty_env(&["GENARRATIVE_ADMIN_USERNAME"]); + config.admin_password = read_first_non_empty_env(&["GENARRATIVE_ADMIN_PASSWORD"]); + if let Some(admin_token_ttl_seconds) = + read_first_duration_seconds_env(&["GENARRATIVE_ADMIN_TOKEN_TTL_SECONDS"]) + { + config.admin_token_ttl_seconds = admin_token_ttl_seconds; + } + config.internal_api_secret = read_first_non_empty_env(&["GENARRATIVE_INTERNAL_API_SECRET"]); if let Some(jwt_issuer) = @@ -430,6 +458,56 @@ impl AppConfig { config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms; } + if let Some(ark_character_video_base_url) = read_first_non_empty_env(&[ + "ARK_CHARACTER_VIDEO_BASE_URL", + "ARK_BASE_URL", + "GENARRATIVE_LLM_BASE_URL", + "LLM_BASE_URL", + ]) { + config.ark_character_video_base_url = ark_character_video_base_url; + } + + config.ark_character_video_api_key = read_first_non_empty_env(&[ + "ARK_CHARACTER_VIDEO_API_KEY", + "ARK_API_KEY", + "GENARRATIVE_LLM_API_KEY", + "LLM_API_KEY", + ]); + + if let Some(ark_character_video_request_timeout_ms) = read_first_positive_u64_env(&[ + "ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS", + "DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS", + ]) { + config.ark_character_video_request_timeout_ms = + ark_character_video_request_timeout_ms; + } + + if let Some(ark_character_video_model) = read_first_non_empty_env(&[ + "ARK_CHARACTER_VIDEO_MODEL", + "DASHSCOPE_CHARACTER_VIDEO_MODEL", + ]) { + config.ark_character_video_model = ark_character_video_model; + } + + if let Some(character_animation_ffmpeg_path) = + read_first_non_empty_env(&["CHARACTER_ANIMATION_FFMPEG_PATH"]) + { + config.character_animation_ffmpeg_path = character_animation_ffmpeg_path; + } + + if let Some(character_animation_ffprobe_path) = + read_first_non_empty_env(&["CHARACTER_ANIMATION_FFPROBE_PATH"]) + { + config.character_animation_ffprobe_path = character_animation_ffprobe_path; + } + + if let Some(character_animation_frame_extract_timeout_ms) = read_first_positive_u64_env(&[ + "CHARACTER_ANIMATION_FRAME_EXTRACT_TIMEOUT_MS", + ]) { + config.character_animation_frame_extract_timeout_ms = + character_animation_frame_extract_timeout_ms; + } + if let Some(slow_request_threshold_ms) = read_first_positive_u64_env(&["GENARRATIVE_SLOW_REQUEST_THRESHOLD_MS"]) { diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index 3477faa3..b2d2a9ce 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -40,12 +40,19 @@ use spacetime_client::{ use std::convert::Infallible; use crate::{ - api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + api_response::json_success_body, + auth::AuthenticatedAccessToken, custom_world_agent_turn::{ CustomWorldAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input, run_custom_world_agent_turn, }, - request_context::RequestContext, state::AppState, + custom_world_foundation_draft::{ + DraftFoundationPayloadError, build_draft_foundation_action_payload_json, + generate_custom_world_foundation_draft, + }, + http_error::AppError, + request_context::RequestContext, + state::AppState, }; pub async fn get_custom_world_library( @@ -142,12 +149,16 @@ pub async fn put_custom_world_library_profile( })), ) })?; - let author_display_name = resolve_author_display_name(&authenticated); + let author_display_name = resolve_author_display_name(&state, &authenticated); + let author_public_user_code = + resolve_author_public_user_code(&state, &authenticated, &request_context)?; let mutation = state .spacetime_client() .upsert_custom_world_profile(CustomWorldProfileUpsertRecordInput { profile_id: profile_id.clone(), owner_user_id: owner_user_id.clone(), + public_work_code: None, + author_public_user_code: Some(author_public_user_code), source_agent_session_id: payload.source_agent_session_id.clone(), world_name: metadata.world_name, subtitle: metadata.subtitle, @@ -240,7 +251,9 @@ pub async fn publish_custom_world_library_profile( .publish_custom_world_profile( profile_id, owner_user_id, - resolve_author_display_name(&authenticated), + None, + resolve_author_public_user_code(&state, &authenticated, &request_context)?, + resolve_author_display_name(&state, &authenticated), current_utc_micros(), ) .await @@ -279,7 +292,7 @@ pub async fn unpublish_custom_world_library_profile( .unpublish_custom_world_profile( profile_id, owner_user_id, - resolve_author_display_name(&authenticated), + resolve_author_display_name(&state, &authenticated), current_utc_micros(), ) .await @@ -350,6 +363,37 @@ pub async fn get_custom_world_gallery_detail( )) } +pub async fn get_custom_world_gallery_detail_by_code( + State(state): State, + Path(code): Path, + Extension(request_context): Extension, +) -> Result, Response> { + if code.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-gallery", + "message": "code is required", + })), + )); + } + + let detail = state + .spacetime_client() + .get_custom_world_gallery_detail_by_code(code) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldGalleryDetailResponse { + entry: map_custom_world_library_entry_response(detail.entry), + }, + )) +} + pub async fn create_custom_world_agent_session( State(state): State, Extension(request_context): Extension, @@ -870,25 +914,88 @@ pub async fn execute_custom_world_agent_action( )); } - let payload_json = serde_json::to_string(&payload).map_err(|error| { - custom_world_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "custom-world-agent", - "message": format!("action payload JSON 序列化失败:{error}"), - })), - ) - })?; + let owner_user_id = authenticated.claims().user_id().to_string(); + let submitted_at_micros = current_utc_micros(); + let payload_json = if action == "draft_foundation" { + let session = state + .spacetime_client() + .get_custom_world_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + if session.progress_percent < 100 { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": "draft_foundation requires progressPercent >= 100", + })), + )); + } + let llm_client = state.llm_client().ok_or_else(|| { + custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "custom-world-agent", + "message": "服务端尚未配置可用的 LLM API Key", + })), + ) + })?; + let draft_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-agent", + "message": message, + })), + ) + })?; + build_draft_foundation_action_payload_json(&payload, &draft_result.draft_profile_json) + .map_err(|error| { + let (status, message) = match error { + DraftFoundationPayloadError::SerializePayload(message) => { + (StatusCode::BAD_REQUEST, message) + } + DraftFoundationPayloadError::InvalidPayloadShape => ( + StatusCode::BAD_REQUEST, + "action payload 必须是 object".to_string(), + ), + DraftFoundationPayloadError::InvalidGeneratedDraft(message) => { + (StatusCode::BAD_GATEWAY, message) + } + }; + custom_world_error_response( + &request_context, + AppError::from_status(status).with_details(json!({ + "provider": "custom-world-agent", + "message": message, + })), + ) + })? + } else { + serde_json::to_string(&payload).map_err(|error| { + custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": format!("action payload JSON 序列化失败:{error}"), + })), + ) + })? + }; let result = state .spacetime_client() .execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput { session_id, - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id, operation_id: build_prefixed_uuid_id("operation-"), action, payload_json: Some(payload_json), - submitted_at_micros: current_utc_micros(), + submitted_at_micros, }) .await .map_err(|error| { @@ -909,6 +1016,8 @@ fn map_custom_world_library_entry_response( CustomWorldLibraryEntryResponse { owner_user_id: entry.owner_user_id, profile_id: entry.profile_id, + public_work_code: entry.public_work_code, + author_public_user_code: entry.author_public_user_code, profile: entry.profile, visibility: entry.visibility, published_at: entry.published_at, @@ -930,6 +1039,8 @@ fn map_custom_world_gallery_card_response( CustomWorldGalleryCardResponse { owner_user_id: entry.owner_user_id, profile_id: entry.profile_id, + public_work_code: entry.public_work_code, + author_public_user_code: entry.author_public_user_code, visibility: entry.visibility, published_at: entry.published_at, updated_at: entry.updated_at, @@ -1213,8 +1324,48 @@ fn custom_world_sse_error_event_message(message: String) -> Event { Event::default().event("error").data(payload) } -fn resolve_author_display_name(_authenticated: &AuthenticatedAccessToken) -> String { - "玩家".to_string() +fn resolve_author_display_name( + state: &AppState, + authenticated: &AuthenticatedAccessToken, +) -> String { + state + .auth_user_service() + .get_user_by_id(authenticated.claims().user_id()) + .ok() + .flatten() + .map(|user| user.display_name) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "玩家".to_string()) +} + +fn resolve_author_public_user_code( + state: &AppState, + authenticated: &AuthenticatedAccessToken, + request_context: &RequestContext, +) -> Result { + state + .auth_user_service() + .get_user_by_id(authenticated.claims().user_id()) + .map_err(|error| { + custom_world_error_response( + request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "custom-world-library", + "message": format!("作者叙世号读取失败:{error}"), + })), + ) + })? + .map(|user| user.public_user_code) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + custom_world_error_response( + request_context, + AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({ + "provider": "custom-world-library", + "message": "当前登录用户缺少叙世号", + })), + ) + }) } fn build_custom_world_agent_welcome_text(seed_text: &str) -> String { diff --git a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs new file mode 100644 index 00000000..4d095788 --- /dev/null +++ b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs @@ -0,0 +1,669 @@ +use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; +use serde_json::{Map as JsonMap, Value as JsonValue, json}; +use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest; +use spacetime_client::CustomWorldAgentSessionRecord; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldFoundationDraftResult { + pub draft_profile_json: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DraftFoundationPayloadError { + SerializePayload(String), + InvalidPayloadShape, + InvalidGeneratedDraft(String), +} + +pub async fn generate_custom_world_foundation_draft( + llm_client: &LlmClient, + session: &CustomWorldAgentSessionRecord, +) -> Result { + let system_prompt = build_foundation_draft_system_prompt(); + let user_prompt = build_foundation_draft_user_prompt(session); + let response = llm_client + .request_text(LlmTextRequest::new(vec![ + LlmMessage::system(system_prompt), + LlmMessage::user(user_prompt), + ])) + .await + .map_err(|error| format!("foundation draft LLM 请求失败:{error}"))?; + let parsed = parse_json_response_text(response.content.as_str()) + .map_err(|error| format!("foundation draft JSON 解析失败:{error}"))?; + let draft_profile = normalize_foundation_draft_profile(parsed, session); + let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile)) + .map_err(|error| format!("foundation draft JSON 序列化失败:{error}"))?; + + Ok(CustomWorldFoundationDraftResult { draft_profile_json }) +} + +// foundation draft 已经由 api-server 真实生成,落库前只负责把它注入现有 action payload。 +pub fn build_draft_foundation_action_payload_json( + payload: &ExecuteCustomWorldAgentActionRequest, + draft_profile_json: &str, +) -> Result { + let mut payload_value = serde_json::to_value(payload).map_err(|error| { + DraftFoundationPayloadError::SerializePayload(format!( + "action payload JSON 序列化失败:{error}" + )) + })?; + let payload_object = payload_value + .as_object_mut() + .ok_or(DraftFoundationPayloadError::InvalidPayloadShape)?; + let draft_profile_value = + serde_json::from_str::(draft_profile_json).map_err(|error| { + DraftFoundationPayloadError::InvalidGeneratedDraft(format!( + "foundation draft JSON 非法:{error}" + )) + })?; + if !draft_profile_value.is_object() { + return Err(DraftFoundationPayloadError::InvalidGeneratedDraft( + "foundation draft JSON 必须是 object".to_string(), + )); + } + + payload_object.insert("draftProfile".to_string(), draft_profile_value); + serde_json::to_string(&payload_value).map_err(|error| { + DraftFoundationPayloadError::SerializePayload(format!( + "action payload JSON 序列化失败:{error}" + )) + }) +} + +fn build_foundation_draft_system_prompt() -> String { + [ + "你是 RPG 世界共创后端里的底稿编译器。", + "你的任务是根据当前会话已经确认的世界锚点,生成第一版“世界设定草稿” JSON。", + "必须只输出一个 JSON object,不要输出 markdown、解释、前后缀。", + "输出必须使用中文内容。", + "不要返回占位符,不要写“待补充”“略”“TBD”“placeholder”。", + "如果某些信息不完整,也要基于已知锚点给出一版合理、可继续精修的首稿。", + "字段必须至少包含:name、subtitle、summary、worldHook、playerPremise、coreConflicts、playableNpcs、storyNpcs、landmarks、chapters、sceneChapterBlueprints。", + "sceneChapterBlueprints 至少包含 1 个 chapter,且 chapter.acts 至少包含 1 个 act。", + "playableNpcs、storyNpcs、landmarks 可以是小规模首批关键对象,不要求长尾铺满。", + ] + .join("\n") +} + +fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String { + let anchor_content = to_pretty_json(&session.anchor_content); + let creator_intent = to_pretty_json(&session.creator_intent); + let anchor_pack = to_pretty_json(&session.anchor_pack); + let current_draft = if is_non_null_json(&session.draft_profile) { + to_pretty_json(&session.draft_profile) + } else { + "{}".to_string() + }; + let quality_findings = to_pretty_json(&JsonValue::Array(session.quality_findings.clone())); + + [ + format!("seedText:{}", session.seed_text.trim()), + format!("当前 stage:{}", session.stage.trim()), + format!("当前 progressPercent:{}", session.progress_percent), + format!( + "当前最后一条 assistant 回复:{}", + session.last_assistant_reply.clone().unwrap_or_default() + ), + format!("当前 anchorContent:\n{anchor_content}"), + format!("当前 creatorIntent:\n{creator_intent}"), + format!("当前 anchorPack:\n{anchor_pack}"), + format!("当前已有 draftProfile:\n{current_draft}"), + format!("当前 qualityFindings:\n{quality_findings}"), + "请直接返回第一版 foundation draft JSON。".to_string(), + "约束:".to_string(), + "1. worldHook 必须是一句可以直接用于发布门禁校验的世界钩子。".to_string(), + "2. playerPremise 必须明确玩家身份与切入前提。".to_string(), + "3. coreConflicts 必须至少 1 条。".to_string(), + "4. chapters 或 sceneChapterBlueprints 必须体现主线第一幕。".to_string(), + "5. sceneChapterBlueprints[0].acts 至少 1 条。".to_string(), + "6. summary 要像结果页摘要,不要只是原始 seed 重复。".to_string(), + ] + .join("\n\n") +} + +fn normalize_foundation_draft_profile( + value: JsonValue, + session: &CustomWorldAgentSessionRecord, +) -> JsonMap { + let mut object = value.as_object().cloned().unwrap_or_default(); + let fallback_title = derive_world_name(&object, session); + let fallback_world_hook = derive_world_hook(&object, session); + let fallback_player_premise = derive_player_premise(&object, session); + ensure_text_field(&mut object, "name", fallback_title.as_str()); + ensure_text_field(&mut object, "subtitle", "世界底稿已生成"); + ensure_text_field( + &mut object, + "summary", + "第一版世界底稿已经整理完成,可继续精修关键角色、地点和主线第一幕。", + ); + ensure_text_field(&mut object, "worldHook", fallback_world_hook.as_str()); + ensure_text_field( + &mut object, + "playerPremise", + fallback_player_premise.as_str(), + ); + ensure_text_array_field( + &mut object, + "coreConflicts", + vec!["核心冲突仍需继续深化,但已经具备第一版主线推进方向。"], + ); + ensure_object_array_field(&mut object, "playableNpcs"); + ensure_object_array_field(&mut object, "storyNpcs"); + ensure_object_array_field(&mut object, "landmarks"); + ensure_object_array_field(&mut object, "chapters"); + ensure_scene_chapter_blueprints(&mut object); + object +} + +fn ensure_text_field(object: &mut JsonMap, key: &str, fallback: &str) { + let current = object + .get(key) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + object.insert( + key.to_string(), + JsonValue::String(current.unwrap_or_else(|| fallback.to_string())), + ); +} + +fn ensure_text_array_field( + object: &mut JsonMap, + key: &str, + fallback_items: Vec<&str>, +) { + let current_items = object + .get(key) + .and_then(JsonValue::as_array) + .map(|entries| { + entries + .iter() + .filter_map(|entry| entry.as_str().map(str::trim)) + .filter(|value| !value.is_empty()) + .map(|value| JsonValue::String(value.to_string())) + .collect::>() + }) + .unwrap_or_default(); + if current_items.is_empty() { + object.insert( + key.to_string(), + JsonValue::Array( + fallback_items + .into_iter() + .map(|value| JsonValue::String(value.to_string())) + .collect(), + ), + ); + } else { + object.insert(key.to_string(), JsonValue::Array(current_items)); + } +} + +fn ensure_object_array_field(object: &mut JsonMap, key: &str) { + let current = object + .get(key) + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_default(); + object.insert(key.to_string(), JsonValue::Array(current)); +} + +fn ensure_scene_chapter_blueprints(object: &mut JsonMap) { + let blueprints = object + .get("sceneChapterBlueprints") + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_default(); + if blueprints.is_empty() { + object.insert( + "sceneChapterBlueprints".to_string(), + JsonValue::Array(vec![build_fallback_scene_chapter_blueprint()]), + ); + return; + } + + let normalized = blueprints + .into_iter() + .map(|chapter| normalize_scene_chapter_blueprint(chapter)) + .collect::>(); + object.insert( + "sceneChapterBlueprints".to_string(), + JsonValue::Array(normalized), + ); +} + +fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue { + let mut object = chapter.as_object().cloned().unwrap_or_default(); + let title = object + .get("title") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("第一幕"); + object.insert("title".to_string(), JsonValue::String(title.to_string())); + let acts = object + .get("acts") + .and_then(JsonValue::as_array) + .cloned() + .unwrap_or_default(); + if acts.is_empty() { + object.insert( + "acts".to_string(), + JsonValue::Array(vec![build_fallback_scene_act()]), + ); + } + JsonValue::Object(object) +} + +fn build_fallback_scene_chapter_blueprint() -> JsonValue { + json!({ + "id": "chapter-act-1", + "title": "第一幕", + "summary": "第一幕用于让玩家进入当前世界的主线矛盾,并看见最初的风险与方向。", + "acts": [build_fallback_scene_act()], + }) +} + +fn build_fallback_scene_act() -> JsonValue { + json!({ + "id": "scene-act-1", + "title": "开场场景幕", + "summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。", + }) +} + +fn derive_world_name( + object: &JsonMap, + session: &CustomWorldAgentSessionRecord, +) -> String { + read_text_field(object, &["name", "title"]) + .or_else(|| { + session + .anchor_content + .get("worldPromise") + .and_then(JsonValue::as_object) + .and_then(|entry| entry.get("hook")) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| "未命名世界草稿".to_string()) +} + +fn derive_world_hook( + object: &JsonMap, + session: &CustomWorldAgentSessionRecord, +) -> String { + read_text_field(object, &["worldHook"]) + .or_else(|| { + session + .anchor_content + .get("worldPromise") + .and_then(JsonValue::as_object) + .and_then(|entry| entry.get("hook")) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| { + "这个世界正被一条持续扩大的主线危机推向失衡,而玩家会被卷入其中心。".to_string() + }) +} + +fn derive_player_premise( + object: &JsonMap, + session: &CustomWorldAgentSessionRecord, +) -> String { + read_text_field(object, &["playerPremise"]) + .or_else(|| { + session + .anchor_content + .get("playerEntryPoint") + .and_then(JsonValue::as_object) + .map(|entry| { + let identity = entry + .get("openingIdentity") + .and_then(JsonValue::as_str) + .map(str::trim) + .unwrap_or_default(); + let problem = entry + .get("openingProblem") + .and_then(JsonValue::as_str) + .map(str::trim) + .unwrap_or_default(); + let motivation = entry + .get("entryMotivation") + .and_then(JsonValue::as_str) + .map(str::trim) + .unwrap_or_default(); + [identity, problem, motivation] + .into_iter() + .filter(|value| !value.is_empty()) + .collect::>() + .join(";") + }) + .filter(|value| !value.trim().is_empty()) + }) + .unwrap_or_else(|| { + "玩家会以一名已经卷入当前局势的人物进入世界,并被迫尽快确认自己的立场与行动方向。" + .to_string() + }) +} + +fn read_text_field(object: &JsonMap, keys: &[&str]) -> Option { + for key in keys { + let mut current = JsonValue::Object(object.clone()); + let mut found = true; + for segment in key.split('.') { + if let Some(next) = current.get(segment) { + current = next.clone(); + } else { + found = false; + break; + } + } + if found + && let Some(value) = current + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(value.to_string()); + } + } + None +} + +fn parse_json_response_text(text: &str) -> Result { + let trimmed = text.trim(); + if let Some(start) = trimmed.find('{') + && let Some(end) = trimmed.rfind('}') + && end > start + { + return serde_json::from_str::(&trimmed[start..=end]); + } + serde_json::from_str::(trimmed) +} + +fn to_pretty_json(value: &JsonValue) -> String { + serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string()) +} + +fn is_non_null_json(value: &JsonValue) -> bool { + !matches!(value, JsonValue::Null) +} + +#[cfg(test)] +mod tests { + use std::{ + io::{Read, Write}, + net::TcpListener, + sync::{Arc, Mutex}, + thread, + time::Duration as StdDuration, + }; + + use platform_llm::{DEFAULT_REQUEST_TIMEOUT_MS, LlmConfig, LlmProvider}; + + use super::*; + + #[test] + fn foundation_prompt_uses_real_seed_text() { + let session = build_test_session(); + + let prompt = build_foundation_draft_user_prompt(&session); + + assert!(prompt.contains("seedText:海雾会吞掉记错航线的人。")); + assert!(!prompt.contains("seedText:custom-world-agent-session-1")); + } + + #[test] + fn build_draft_foundation_action_payload_json_injects_generated_profile() { + let payload = ExecuteCustomWorldAgentActionRequest { + action: "draft_foundation".to_string(), + profile_id: Some("profile-1".to_string()), + draft_profile: Some(json!({ "name": "旧草稿" })), + legacy_result_profile: None, + setting_text: Some("旧设定".to_string()), + card_id: None, + sections: None, + profile: None, + count: None, + prompt_text: Some("补充提示".to_string()), + anchor_card_ids: Some(vec!["card-1".to_string()]), + role_ids: None, + role_id: None, + portrait_path: None, + generated_visual_asset_id: None, + generated_animation_set_id: None, + animation_map: None, + scene_ids: 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 = build_draft_foundation_action_payload_json( + &payload, + r#"{"name":"新草稿","worldHook":"失灯海域会吞掉所有记错航线的人。"}"#, + ) + .expect("payload json should build"); + let payload_value = + serde_json::from_str::(&payload_json).expect("payload json should parse"); + + assert_eq!( + payload_value.get("action"), + Some(&json!("draft_foundation")) + ); + assert_eq!(payload_value.get("profileId"), Some(&json!("profile-1"))); + assert_eq!( + payload_value + .get("draftProfile") + .and_then(|value| value.get("name")), + Some(&json!("新草稿")) + ); + } + + #[tokio::test] + async fn generate_custom_world_foundation_draft_uses_seed_text_and_normalizes_fields() { + let request_capture = Arc::new(Mutex::new(String::new())); + let server_url = spawn_mock_server( + request_capture.clone(), + r#"{"id":"resp_01","choices":[{"message":{"content":"{\"name\":\"雾港归航\",\"coreConflicts\":[\"守灯塔的旧档案被人改写。\"]}"}}]}"# + .to_string(), + ); + let llm_client = build_test_llm_client(server_url); + let session = build_test_session(); + + let result = generate_custom_world_foundation_draft(&llm_client, &session) + .await + .expect("draft generation should succeed"); + let draft_profile = serde_json::from_str::(&result.draft_profile_json) + .expect("draft profile should parse"); + let request_text = request_capture + .lock() + .expect("request capture should lock") + .clone(); + + assert!(request_text.contains("海雾会吞掉记错航线的人。")); + assert!(!request_text.contains("seedText\\uff1acustom-world-agent-session-1")); + assert_eq!(draft_profile.get("name"), Some(&json!("雾港归航"))); + assert!( + draft_profile + .get("worldHook") + .and_then(JsonValue::as_str) + .is_some() + ); + assert!( + draft_profile + .get("playerPremise") + .and_then(JsonValue::as_str) + .is_some() + ); + assert_eq!( + draft_profile + .get("sceneChapterBlueprints") + .and_then(JsonValue::as_array) + .and_then(|entries| entries.first()) + .and_then(|entry| entry.get("acts")) + .and_then(JsonValue::as_array) + .map(|entries| !entries.is_empty()), + Some(true) + ); + } + + fn build_test_session() -> CustomWorldAgentSessionRecord { + CustomWorldAgentSessionRecord { + session_id: "custom-world-agent-session-1".to_string(), + seed_text: "海雾会吞掉记错航线的人。".to_string(), + current_turn: 2, + anchor_content: json!({ + "worldPromise": { + "hook": "在失真的海图上追查一场被篡改的沉船事故。" + }, + "playerEntryPoint": { + "openingIdentity": "被停职返乡的守灯人", + "openingProblem": "灯塔记录被人改写", + "entryMotivation": "查清父亲沉船真相" + } + }), + progress_percent: 100, + last_assistant_reply: Some("世界锚点已经基本齐全,可以整理第一版底稿。".to_string()), + stage: "foundation_review".to_string(), + focus_card_id: None, + creator_intent: json!({ + "theme": "悬疑航海", + "playerPremise": "玩家是返乡调查旧案的守灯人。" + }), + creator_intent_readiness: json!({ + "isReady": true + }), + anchor_pack: json!({ + "coreConflict": "群岛议会正在掩盖沉船真相。" + }), + lock_state: json!({}), + draft_profile: JsonValue::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: "2026-04-23T00:00:00Z".to_string(), + } + } + + fn build_test_llm_client(base_url: String) -> LlmClient { + let config = LlmConfig::new( + LlmProvider::Ark, + base_url, + "test-key".to_string(), + "test-model".to_string(), + DEFAULT_REQUEST_TIMEOUT_MS, + 0, + 1, + ) + .expect("llm config should build"); + + LlmClient::new(config).expect("llm client should build") + } + + fn spawn_mock_server(request_capture: Arc>, response_body: String) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener + .local_addr() + .expect("listener should expose address"); + + thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("request should connect"); + let request_text = read_request(&mut stream); + *request_capture.lock().expect("request capture should lock") = request_text; + write_response(&mut stream, response_body); + }); + + format!("http://{address}") + } + + fn read_request(stream: &mut std::net::TcpStream) -> String { + stream + .set_read_timeout(Some(StdDuration::from_secs(1))) + .expect("read timeout should be configured"); + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 1024]; + let mut expected_total = None; + + loop { + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(bytes_read) => { + buffer.extend_from_slice(&chunk[..bytes_read]); + + if expected_total.is_none() + && let Some(header_end) = find_header_end(&buffer) + { + let content_length = + read_content_length(&buffer[..header_end]).unwrap_or(0); + expected_total = Some(header_end + content_length); + } + + if let Some(total_bytes) = expected_total + && buffer.len() >= total_bytes + { + break; + } + } + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + || error.kind() == std::io::ErrorKind::TimedOut => + { + break; + } + Err(error) => panic!("mock server failed to read request: {error}"), + } + } + + String::from_utf8(buffer).expect("request should be utf-8") + } + + fn write_response(stream: &mut std::net::TcpStream, body: String) { + let raw_response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(raw_response.as_bytes()) + .expect("mock response should be written"); + stream.flush().expect("mock response should flush"); + } + + fn find_header_end(buffer: &[u8]) -> Option { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) + } + + fn read_content_length(headers: &[u8]) -> Option { + let text = String::from_utf8_lossy(headers); + text.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("content-length") { + return value.trim().parse::().ok(); + } + None + }) + } +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index c5ba45fb..4c921317 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -1,9 +1,12 @@ +mod admin; mod ai_tasks; mod api_response; mod app; mod assets; mod auth; mod auth_me; +mod auth_payload; +mod auth_public_user; mod auth_session; mod auth_sessions; mod big_fish; @@ -13,6 +16,7 @@ mod config; mod custom_world; mod custom_world_agent_turn; mod custom_world_ai; +mod custom_world_foundation_draft; mod error_middleware; mod health; mod http_error; @@ -24,6 +28,7 @@ mod logout_all; mod password_entry; mod phone_auth; mod puzzle; +mod puzzle_agent_turn; mod refresh_session; mod request_context; mod response_headers; diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index b9e8e676..6ba2797f 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -6,10 +6,11 @@ use axum::{ }; use module_auth::{PasswordEntryError, PasswordEntryInput}; use serde_json::json; -use shared_contracts::auth::{AuthUserPayload, PasswordEntryRequest, PasswordEntryResponse}; +use shared_contracts::auth::{PasswordEntryRequest, PasswordEntryResponse}; use crate::{ api_response::json_success_body, + auth_payload::map_auth_user_payload, auth_session::{ attach_set_cookie_header, build_refresh_session_cookie_header, create_password_auth_session, }, @@ -48,15 +49,7 @@ pub async fn password_entry( Some(&request_context), PasswordEntryResponse { token: signed_session.access_token, - user: AuthUserPayload { - id: result.user.id, - username: result.user.username, - display_name: result.user.display_name, - phone_number_masked: result.user.phone_number_masked, - login_method: result.user.login_method.as_str().to_string(), - binding_status: result.user.binding_status.as_str().to_string(), - wechat_bound: result.user.wechat_bound, - }, + user: map_auth_user_payload(result.user), }, ), )) @@ -74,6 +67,11 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError { .with_details(json!({ "field": "password", })), + PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("叙世号格式不正确") + .with_details(json!({ + "field": "username", + })), PasswordEntryError::InvalidCredentials => { AppError::from_status(StatusCode::UNAUTHORIZED).with_message("用户名或密码错误") } diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index e9318c56..30503e25 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -9,8 +9,7 @@ use module_auth::{ }; use serde_json::json; use shared_contracts::auth::{ - AuthUserPayload, PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest, - PhoneSendCodeResponse, + PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest, PhoneSendCodeResponse, }; use time::OffsetDateTime; use tracing::{info, warn}; @@ -20,6 +19,7 @@ use crate::{ auth_session::{ attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session, }, + auth_payload::map_auth_user_payload, http_error::AppError, request_context::RequestContext, session_client::resolve_session_client_context, @@ -166,15 +166,7 @@ pub async fn phone_login( Some(&request_context), PhoneLoginResponse { token: signed_session.access_token, - user: AuthUserPayload { - id: result.user.id, - username: result.user.username, - display_name: result.user.display_name, - phone_number_masked: result.user.phone_number_masked, - login_method: result.user.login_method.as_str().to_string(), - binding_status: result.user.binding_status.as_str().to_string(), - wechat_bound: result.user.wechat_bound, - }, + user: map_auth_user_payload(result.user), }, ), )) diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 18cd97cd..01a99157 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -8,7 +8,10 @@ use axum::{ Json, extract::{Extension, Path as AxumPath, State, rejection::JsonRejection}, http::{HeaderName, StatusCode, header}, - response::{IntoResponse, Response}, + response::{ + IntoResponse, Response, + sse::{Event, Sse}, + }, }; use serde_json::{Value, json}; use shared_contracts::{ @@ -46,9 +49,14 @@ use spacetime_client::{ PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, SpacetimeClientError, }; +use std::convert::Infallible; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + puzzle_agent_turn::{ + PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input, + run_puzzle_agent_turn, + }, request_context::RequestContext, state::AppState, }; @@ -169,11 +177,12 @@ pub async fn submit_puzzle_agent_message( )); } - let session = state + let owner_user_id = authenticated.claims().user_id().to_string(); + let submitted_session = state .spacetime_client() .submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput { - session_id, - owner_user_id: authenticated.claims().user_id().to_string(), + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), user_message_id: client_message_id, user_message_text: message_text, submitted_at_micros: current_utc_micros(), @@ -186,6 +195,41 @@ pub async fn submit_puzzle_agent_message( map_puzzle_client_error(error), ) })?; + let turn_result = run_puzzle_agent_turn( + PuzzleAgentTurnRequest { + llm_client: state.llm_client(), + session: &submitted_session, + }, + |_| {}, + ) + .await; + let finalize_input = match turn_result { + Ok(turn_result) => build_finalize_record_input( + session_id.clone(), + owner_user_id.clone(), + format!("assistant-{session_id}-{}", current_utc_micros()), + turn_result, + current_utc_micros(), + ), + Err(error) => build_failed_finalize_record_input( + session_id.clone(), + owner_user_id.clone(), + &submitted_session, + error.to_string(), + current_utc_micros(), + ), + }; + let session = state + .spacetime_client() + .finalize_puzzle_agent_message(finalize_input) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; Ok(json_success_body( Some(&request_context), @@ -219,11 +263,12 @@ pub async fn stream_puzzle_agent_message( "sessionId", )?; + let owner_user_id = authenticated.claims().user_id().to_string(); let session = state .spacetime_client() .submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput { - session_id, - owner_user_id: authenticated.claims().user_id().to_string(), + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), user_message_id: payload.client_message_id.trim().to_string(), user_message_text: payload.text.trim().to_string(), submitted_at_micros: current_utc_micros(), @@ -236,32 +281,100 @@ pub async fn stream_puzzle_agent_message( map_puzzle_client_error(error), ) })?; + let state = state.clone(); + let session_id_for_stream = session_id.clone(); + let owner_user_id_for_stream = owner_user_id.clone(); + let stream = async_stream::stream! { + let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::(); + let turn_result = { + let run_turn = run_puzzle_agent_turn( + PuzzleAgentTurnRequest { + llm_client: state.llm_client(), + session: &session, + }, + move |text| { + let _ = reply_tx.send(text.to_string()); + }, + ); + tokio::pin!(run_turn); - let session_response = map_puzzle_agent_session_response(session); - let reply_text = session_response - .last_assistant_reply - .clone() - .unwrap_or_else(|| "拼图锚点已更新。".to_string()); - let mut sse_body = String::new(); - append_sse_event( - &request_context, - &mut sse_body, - "reply_delta", - &json!({ "text": reply_text }), - )?; - append_sse_event( - &request_context, - &mut sse_body, - "session", - &json!({ "session": session_response }), - )?; - append_sse_event( - &request_context, - &mut sse_body, - "done", - &json!({ "ok": true }), - )?; - Ok(build_event_stream_response(sse_body)) + loop { + tokio::select! { + result = &mut run_turn => break result, + maybe_text = reply_rx.recv() => { + if let Some(text) = maybe_text { + yield Ok::(puzzle_sse_json_event_or_error( + "reply_delta", + json!({ "text": text }), + )); + } + } + } + } + }; + + while let Some(text) = reply_rx.recv().await { + yield Ok::(puzzle_sse_json_event_or_error( + "reply_delta", + json!({ "text": text }), + )); + } + + let finalize_input = match turn_result { + Ok(turn_result) => build_finalize_record_input( + session_id_for_stream.clone(), + owner_user_id_for_stream.clone(), + format!("assistant-{session_id_for_stream}-{}", current_utc_micros()), + turn_result, + current_utc_micros(), + ), + Err(error) => build_failed_finalize_record_input( + session_id_for_stream.clone(), + owner_user_id_for_stream.clone(), + &session, + error.to_string(), + current_utc_micros(), + ), + }; + let finalize_result = state + .spacetime_client() + .finalize_puzzle_agent_message(finalize_input) + .await; + let _final_session = match finalize_result { + Ok(session) => session, + Err(error) => { + yield Ok::(puzzle_sse_json_event_or_error( + "error", + json!({ "message": error.to_string() }), + )); + return; + } + }; + let final_session = match state + .spacetime_client() + .get_puzzle_agent_session(session_id_for_stream, owner_user_id_for_stream) + .await + { + Ok(session) => session, + Err(error) => { + yield Ok::(puzzle_sse_json_event_or_error( + "error", + json!({ "message": error.to_string() }), + )); + return; + } + }; + let session_response = map_puzzle_agent_session_response(final_session); + yield Ok::(puzzle_sse_json_event_or_error( + "session", + json!({ "session": session_response }), + )); + yield Ok::(puzzle_sse_json_event_or_error( + "done", + json!({ "ok": true }), + )); + }; + Ok(Sse::new(stream).into_response()) } pub async fn execute_puzzle_agent_action( @@ -413,13 +526,15 @@ pub async fn execute_puzzle_agent_action( ) } "publish_puzzle_work" => { + let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id); let profile = state .spacetime_client() .publish_puzzle_work(PuzzlePublishRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), - work_id: build_prefixed_uuid_id("puzzle-work-"), - profile_id: build_prefixed_uuid_id("puzzle-profile-"), + // 发布沿用 session 派生的稳定作品 ID,避免草稿卡与已发布卡重复。 + work_id, + profile_id, author_display_name: resolve_author_display_name(&state, &authenticated), level_name: payload.level_name.clone(), summary: payload.summary.clone(), @@ -1153,6 +1268,14 @@ fn build_puzzle_welcome_text(seed_text: &str) -> String { "我先接住你的画面灵感,再一起把它收束成正式拼图关卡。".to_string() } +fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) { + let stable_suffix = session_id.strip_prefix("puzzle-session-").unwrap_or(session_id); + ( + format!("puzzle-work-{stable_suffix}"), + format!("puzzle-profile-{stable_suffix}"), + ) +} + fn ensure_non_empty( request_context: &RequestContext, provider: &str, @@ -1193,6 +1316,14 @@ fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError { { StatusCode::NOT_FOUND } + SpacetimeClientError::Procedure(message) + if message.contains("当前模型不可用") + || message.contains("生成失败") + || message.contains("解析失败") + || message.contains("缺少有效回复") => + { + StatusCode::BAD_GATEWAY + } _ => StatusCode::BAD_GATEWAY, }; @@ -1216,41 +1347,32 @@ fn puzzle_error_response( response } -fn append_sse_event( - request_context: &RequestContext, - body: &mut String, - event_name: &str, - payload: &Value, -) -> Result<(), Response> { - let payload = serde_json::to_string(payload).map_err(|error| { - puzzle_error_response( - request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, +fn puzzle_sse_json_event(event_name: &str, payload: Value) -> Result { + Event::default() + .event(event_name) + .json_data(payload) + .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "provider": "sse", "message": format!("SSE payload 序列化失败:{error}"), - })), - ) - })?; - body.push_str("event: "); - body.push_str(event_name); - body.push('\n'); - body.push_str("data: "); - body.push_str(&payload); - body.push_str("\n\n"); - Ok(()) + })) + }) } -fn build_event_stream_response(body: String) -> Response { - ( - [ - (header::CONTENT_TYPE, "text/event-stream; charset=utf-8"), - (header::CACHE_CONTROL, "no-cache, no-transform"), - (header::CONNECTION, "keep-alive"), - ], - body, - ) - .into_response() +fn puzzle_sse_json_event_or_error(event_name: &str, payload: Value) -> Event { + match puzzle_sse_json_event(event_name, payload) { + Ok(event) => event, + Err(_) => puzzle_sse_error_event_message("SSE payload 序列化失败".to_string()), + } +} + +fn puzzle_sse_error_event_message(message: String) -> Event { + let payload = format!( + "{{\"message\":{}}}", + serde_json::to_string(&message) + .unwrap_or_else(|_| "\"SSE 错误事件序列化失败\"".to_string()) + ); + Event::default().event("error").data(payload) } fn build_placeholder_puzzle_candidates( diff --git a/server-rs/crates/api-server/src/puzzle_agent_turn.rs b/server-rs/crates/api-server/src/puzzle_agent_turn.rs new file mode 100644 index 00000000..3d61a074 --- /dev/null +++ b/server-rs/crates/api-server/src/puzzle_agent_turn.rs @@ -0,0 +1,372 @@ +use module_puzzle::{ + PuzzleAgentStage, PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack, +}; +use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value as JsonValue, json}; +use spacetime_client::{ + PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, PuzzleAgentSessionRecord, +}; + +#[derive(Clone, Debug)] +pub(crate) struct PuzzleAgentTurnRequest<'a> { + pub llm_client: Option<&'a LlmClient>, + pub session: &'a PuzzleAgentSessionRecord, +} + +#[derive(Clone, Debug)] +pub(crate) struct PuzzleAgentTurnResult { + pub assistant_reply_text: String, + pub stage: String, + pub progress_percent: u32, + pub anchor_pack_json: String, + pub error_message: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct PuzzleAgentTurnError { + message: String, +} + +impl PuzzleAgentTurnError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl std::fmt::Display for PuzzleAgentTurnError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for PuzzleAgentTurnError {} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PuzzleAgentModelOutput { + reply_text: String, + progress_percent: u32, + next_anchor_pack: PuzzleAnchorPack, +} + +const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创拼图画面的中文创意策划。 + +你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。 + +你必须同时输出: +1. 一段直接发给用户的中文回复 replyText +2. 当前进度 progressPercent +3. 下一轮完整可用的 nextAnchorPack + +拼图创作固定围绕 5 个视觉锚点: +1. themePromise:题材承诺 +2. visualSubject:画面主体 +3. visualMood:视觉气质 +4. compositionHooks:拼图记忆点 +5. tagsAndForbidden:标签与禁忌 + +硬约束: +1. 只能输出 JSON,不能输出代码块或解释 +2. nextAnchorPack 必须是完整对象,不能只输出 patch +3. replyText 必须是自然中文,不能提“字段”“锚点”“结构”“JSON”等内部词 +4. replyText 一次最多推进一个最关键问题 +5. 如果用户已经给出明确方向,就优先吸收和收束,不要机械反问 +6. progressPercent 范围只能是 0 到 100 +7. status 只能使用 missing / inferred / confirmed / locked +"#; + +const PUZZLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字: +{ + "replyText": "", + "progressPercent": 0, + "nextAnchorPack": { + "themePromise": { + "key": "themePromise", + "label": "题材承诺", + "value": "", + "status": "missing" + }, + "visualSubject": { + "key": "visualSubject", + "label": "画面主体", + "value": "", + "status": "missing" + }, + "visualMood": { + "key": "visualMood", + "label": "视觉气质", + "value": "", + "status": "missing" + }, + "compositionHooks": { + "key": "compositionHooks", + "label": "拼图记忆点", + "value": "", + "status": "missing" + }, + "tagsAndForbidden": { + "key": "tagsAndForbidden", + "label": "标签与禁忌", + "value": "", + "status": "missing" + } + } +}"#; + +pub(crate) async fn run_puzzle_agent_turn( + request: PuzzleAgentTurnRequest<'_>, + mut on_reply_update: F, +) -> Result +where + F: FnMut(&str), +{ + let llm_client = request + .llm_client + .ok_or_else(|| PuzzleAgentTurnError::new("当前模型不可用,请稍后重试。"))?; + + let prompt = build_puzzle_agent_prompt(request.session); + let mut latest_reply_text = String::new(); + let response = llm_client + .stream_text( + LlmTextRequest::new(vec![ + LlmMessage::system(format!("{PUZZLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}")), + LlmMessage::user("请按约定输出这一轮的 JSON。"), + ]), + |delta: &LlmStreamDelta| { + if let Some(reply_progress) = + extract_reply_text_from_partial_json(delta.accumulated_text.as_str()) + && reply_progress != latest_reply_text + { + latest_reply_text = reply_progress.clone(); + on_reply_update(reply_progress.as_str()); + } + }, + ) + .await + .map_err(|_| PuzzleAgentTurnError::new("拼图聊天生成失败,请稍后重试。"))?; + + let parsed = parse_json_response_text(response.content.as_str()) + .map_err(|_| PuzzleAgentTurnError::new("拼图聊天结果解析失败,请稍后重试。"))?; + let output = parse_model_output(&parsed)?; + if output.reply_text != latest_reply_text { + on_reply_update(output.reply_text.as_str()); + } + + Ok(PuzzleAgentTurnResult { + assistant_reply_text: output.reply_text, + stage: resolve_puzzle_agent_stage(output.progress_percent).as_str().to_string(), + progress_percent: output.progress_percent, + anchor_pack_json: serde_json::to_string(&output.next_anchor_pack) + .unwrap_or_else(|_| serde_json::to_string(&empty_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: PuzzleAgentTurnResult, + updated_at_micros: i64, +) -> PuzzleAgentMessageFinalizeRecordInput { + PuzzleAgentMessageFinalizeRecordInput { + 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: &PuzzleAgentSessionRecord, + error_message: String, + updated_at_micros: i64, +) -> PuzzleAgentMessageFinalizeRecordInput { + let anchor_pack_json = serde_json::to_string(&map_record_anchor_pack(&session.anchor_pack)) + .unwrap_or_else(|_| serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string())); + PuzzleAgentMessageFinalizeRecordInput { + 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, + error_message: Some(error_message), + updated_at_micros, + } +} + +fn build_puzzle_agent_prompt(session: &PuzzleAgentSessionRecord) -> String { + format!( + "当前是第 {turn} 轮,当前进度 {progress}% 。\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}", + turn = session.current_turn.saturating_add(1), + progress = session.progress_percent, + anchor_pack = serde_json::to_string_pretty(&map_record_anchor_pack(&session.anchor_pack)) + .unwrap_or_else(|_| "{}".to_string()), + chat_history = serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice())) + .unwrap_or_else(|_| "[]".to_string()), + contract = PUZZLE_AGENT_OUTPUT_CONTRACT, + ) +} + +fn build_chat_history(messages: &[PuzzleAgentMessageRecord]) -> Vec { + messages + .iter() + .map(|message| { + json!({ + "role": message.role, + "kind": message.kind, + "content": message.text, + }) + }) + .collect() +} + +fn parse_model_output(parsed: &JsonValue) -> Result { + let reply_text = parsed + .get("replyText") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| PuzzleAgentTurnError::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 = parsed + .get("nextAnchorPack") + .cloned() + .ok_or_else(|| PuzzleAgentTurnError::new("拼图聊天结果缺少 nextAnchorPack。")) + .and_then(|value| { + serde_json::from_value::(value) + .map_err(|_| PuzzleAgentTurnError::new("拼图 anchor pack 解析失败,请稍后重试。")) + })?; + Ok(PuzzleAgentModelOutput { + reply_text, + progress_percent, + next_anchor_pack, + }) +} + +fn resolve_puzzle_agent_stage(progress_percent: u32) -> PuzzleAgentStage { + if progress_percent >= 85 { + PuzzleAgentStage::DraftReady + } else { + PuzzleAgentStage::CollectingAnchors + } +} + +fn map_record_anchor_pack(record: &spacetime_client::PuzzleAnchorPackRecord) -> PuzzleAnchorPack { + PuzzleAnchorPack { + theme_promise: map_record_anchor_item(&record.theme_promise), + visual_subject: map_record_anchor_item(&record.visual_subject), + visual_mood: map_record_anchor_item(&record.visual_mood), + composition_hooks: map_record_anchor_item(&record.composition_hooks), + tags_and_forbidden: map_record_anchor_item(&record.tags_and_forbidden), + } +} + +fn map_record_anchor_item(record: &spacetime_client::PuzzleAnchorItemRecord) -> module_puzzle::PuzzleAnchorItem { + module_puzzle::PuzzleAnchorItem { + key: record.key.clone(), + label: record.label.clone(), + value: record.value.clone(), + status: parse_anchor_status(record.status.as_str()), + } +} + +fn parse_anchor_status(value: &str) -> PuzzleAnchorStatus { + match value { + "confirmed" => PuzzleAnchorStatus::Confirmed, + "locked" => PuzzleAnchorStatus::Locked, + "inferred" => PuzzleAnchorStatus::Inferred, + _ => PuzzleAnchorStatus::Missing, + } +} + +fn parse_json_response_text(text: &str) -> Result { + let trimmed = text.trim(); + if let Some(start) = trimmed.find('{') + && let Some(end) = trimmed.rfind('}') + && end > start + { + return serde_json::from_str::(&trimmed[start..=end]); + } + serde_json::from_str::(trimmed) +} + +fn extract_reply_text_from_partial_json(text: &str) -> Option { + let key_index = text.find("\"replyText\"")?; + let colon_index = text[key_index..].find(':')? + key_index; + let mut cursor = colon_index + 1; + while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() { + cursor += 1; + } + if text.as_bytes().get(cursor).copied() != Some(b'"') { + return None; + } + cursor += 1; + let mut decoded = String::new(); + let remainder = text.get(cursor..)?; + let mut characters = remainder.chars().peekable(); + while let Some(current) = characters.next() { + if current == '"' { + return Some(decoded); + } + if current == '\\' { + let escaped = characters.next()?; + match escaped { + '"' => decoded.push('"'), + '\\' => decoded.push('\\'), + '/' => decoded.push('/'), + 'b' => decoded.push('\u{0008}'), + 'f' => decoded.push('\u{000C}'), + 'n' => decoded.push('\n'), + 'r' => decoded.push('\r'), + 't' => decoded.push('\t'), + 'u' => { + let mut hex = String::new(); + for _ in 0..4 { + hex.push(characters.next()?); + } + if let Ok(code) = u16::from_str_radix(hex.as_str(), 16) + && let Some(character) = char::from_u32(code as u32) + { + decoded.push(character); + } + } + other => decoded.push(other), + } + continue; + } + decoded.push(current); + } + Some(decoded) +} + +#[cfg(test)] +mod tests { + use super::extract_reply_text_from_partial_json; + + #[test] + fn extract_reply_text_from_partial_json_preserves_chinese_characters() { + let partial_json = r#"{"replyText":"夜雨猫咪遗迹","progressPercent":42"#; + + let extracted = extract_reply_text_from_partial_json(partial_json); + + assert_eq!(extracted.as_deref(), Some("夜雨猫咪遗迹")); + } +} diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index e602b43b..19309aca 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -1,4 +1,4 @@ -use std::{error::Error, fmt}; +use std::{error::Error, fmt, sync::Arc}; #[cfg(test)] use std::{ @@ -15,17 +15,22 @@ use module_runtime::RuntimeSnapshotRecord; #[cfg(test)] use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros}; use platform_auth::{ - JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError, + RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, + sign_access_token, verify_access_token, SmsAuthConfig, SmsAuthProvider, SmsAuthProviderKind, SmsProviderError, }; use platform_llm::{LlmClient, LlmConfig, LlmError}; use platform_oss::{OssClient, OssConfig, OssError}; use serde_json::Value; use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError}; +use time::OffsetDateTime; use crate::config::AppConfig; use crate::wechat_provider::{WechatProvider, build_wechat_provider}; +const ADMIN_ROLE: &str = "admin"; + // 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。 #[derive(Clone, Debug)] pub struct AppState { @@ -33,6 +38,7 @@ pub struct AppState { #[allow(dead_code)] pub config: AppConfig, auth_jwt_config: JwtConfig, + admin_runtime: Option, refresh_cookie_config: RefreshCookieConfig, oss_client: Option, password_entry_service: PasswordEntryService, @@ -51,6 +57,34 @@ pub struct AppState { test_runtime_snapshot_store: Arc>>, } +#[derive(Clone, Debug)] +pub struct AdminRuntime { + username: Arc, + password: Arc, + subject: Arc, + display_name: Arc, + token_ttl_seconds: u64, + jwt_config: JwtConfig, +} + +#[derive(Clone, Debug)] +pub struct AdminClaims { + pub subject: String, + pub username: String, + pub issued_at: OffsetDateTime, + pub expires_at: OffsetDateTime, +} + +#[derive(Clone, Debug)] +pub struct AdminSession { + pub subject: String, + pub username: String, + pub display_name: String, + pub roles: Vec, + pub issued_at: OffsetDateTime, + pub expires_at: OffsetDateTime, +} + #[derive(Debug)] pub enum AppStateInitError { Jwt(JwtError), @@ -67,6 +101,7 @@ impl AppState { config.jwt_secret.clone(), config.jwt_access_token_ttl_seconds, )?; + let admin_runtime = build_admin_runtime(&config, &auth_jwt_config)?; let refresh_cookie_same_site = RefreshCookieSameSite::parse(&config.refresh_cookie_same_site).ok_or( RefreshCookieError::InvalidConfig("refresh cookie SameSite 取值非法"), @@ -123,6 +158,7 @@ impl AppState { Ok(Self { config, auth_jwt_config, + admin_runtime, refresh_cookie_config, oss_client, password_entry_service, @@ -144,6 +180,10 @@ impl AppState { &self.auth_jwt_config } + pub fn admin_runtime(&self) -> Option<&AdminRuntime> { + self.admin_runtime.as_ref() + } + pub fn refresh_cookie_config(&self) -> &RefreshCookieConfig { &self.refresh_cookie_config } @@ -394,6 +434,90 @@ impl From for AppStateInitError { } } +impl AdminRuntime { + pub fn is_enabled(&self) -> bool { + !self.username.trim().is_empty() && !self.password.trim().is_empty() + } + + pub fn username(&self) -> &str { + &self.username + } + + pub fn password(&self) -> &str { + &self.password + } + + pub fn build_claims(&self, now: OffsetDateTime) -> Result { + let expires_at = now + .checked_add(time::Duration::seconds( + i64::try_from(self.token_ttl_seconds) + .map_err(|_| "后台 token TTL 超出 i64 上限".to_string())?, + )) + .ok_or_else(|| "后台 token 过期时间计算溢出".to_string())?; + Ok(AdminClaims { + subject: self.subject.to_string(), + username: self.username.to_string(), + issued_at: now, + expires_at, + }) + } + + pub fn sign_token(&self, claims: &AdminClaims) -> Result { + let jwt_claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: claims.subject.clone(), + session_id: format!("admin-session-{}", claims.username), + provider: AuthProvider::Password, + roles: vec![ADMIN_ROLE.to_string()], + token_version: 1, + phone_verified: false, + binding_status: BindingStatus::Active, + display_name: Some(self.display_name.to_string()), + }, + &self.jwt_config, + claims.issued_at, + ) + .map_err(|error| error.to_string())?; + sign_access_token(&jwt_claims, &self.jwt_config).map_err(|error| error.to_string()) + } + + pub fn verify_token(&self, token: &str) -> Result { + verify_access_token(token, &self.jwt_config).map_err(|error| error.to_string()) + } + + pub fn validate_claims(&self, claims: &AccessTokenClaims) -> Result { + if claims.user_id() != self.subject.as_ref() { + return Err("后台管理员主体不匹配".to_string()); + } + if !claims.roles.iter().any(|role| role == ADMIN_ROLE) { + return Err("当前令牌不是管理员令牌".to_string()); + } + let issued_at = OffsetDateTime::from_unix_timestamp(claims.iat as i64) + .map_err(|_| "后台令牌签发时间无效".to_string())?; + let expires_at = OffsetDateTime::from_unix_timestamp(claims.exp as i64) + .map_err(|_| "后台令牌过期时间无效".to_string())?; + Ok(AdminSession { + subject: claims.user_id().to_string(), + username: self.username.to_string(), + display_name: self.display_name.to_string(), + roles: claims.roles.clone(), + issued_at, + expires_at, + }) + } + + pub fn build_session(&self, claims: &AdminClaims) -> AdminSession { + AdminSession { + subject: claims.subject.clone(), + username: claims.username.clone(), + display_name: self.display_name.to_string(), + roles: vec![ADMIN_ROLE.to_string()], + issued_at: claims.issued_at, + expires_at: claims.expires_at, + } + } +} + fn build_oss_client(config: &AppConfig) -> Result, AppStateInitError> { let has_any_oss_field = config.oss_bucket.is_some() || config.oss_endpoint.is_some() @@ -441,6 +565,42 @@ fn build_llm_client(config: &AppConfig) -> Result, AppStateIni Ok(Some(LlmClient::new(llm_config)?)) } +fn build_admin_runtime( + config: &AppConfig, + base_jwt_config: &JwtConfig, +) -> Result, AppStateInitError> { + let Some(username) = config + .admin_username + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + else { + return Ok(None); + }; + let Some(password) = config + .admin_password + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + else { + return Ok(None); + }; + + let jwt_config = JwtConfig::new( + base_jwt_config.issuer().to_string(), + config.jwt_secret.clone(), + config.admin_token_ttl_seconds, + )?; + Ok(Some(AdminRuntime { + username: Arc::::from(username), + password: Arc::::from(password), + subject: Arc::::from(format!("admin:{username}")), + display_name: Arc::::from(format!("管理员 {username}")), + token_ttl_seconds: config.admin_token_ttl_seconds, + jwt_config, + })) +} + #[cfg(test)] mod tests { use module_ai::{AiTaskKind, generate_ai_task_id}; diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat_auth.rs index 7ac1e688..bda40b0c 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat_auth.rs @@ -9,8 +9,8 @@ use module_auth::{ WechatAuthScene, }; use shared_contracts::auth::{ - AuthUserPayload, WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, - WechatStartQuery, WechatStartResponse, + WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, WechatStartQuery, + WechatStartResponse, }; use time::OffsetDateTime; use url::Url; @@ -18,6 +18,7 @@ use url::Url; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, + auth_payload::map_auth_user_payload, auth_session::{ attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session, }, @@ -199,15 +200,7 @@ pub async fn bind_wechat_phone( Some(&request_context), WechatBindPhoneResponse { token: signed_session.access_token, - user: AuthUserPayload { - id: result.user.id, - username: result.user.username, - display_name: result.user.display_name, - phone_number_masked: result.user.phone_number_masked, - login_method: result.user.login_method.as_str().to_string(), - binding_status: result.user.binding_status.as_str().to_string(), - wechat_bound: result.user.wechat_bound, - }, + user: map_auth_user_payload(result.user), }, ), )) diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index de8d2567..93c7ec62 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -41,6 +41,7 @@ pub enum AuthBindingStatus { #[derive(Clone, Debug, PartialEq, Eq)] pub struct AuthUser { pub id: String, + pub public_user_code: String, pub username: String, pub display_name: String, pub phone_number_masked: Option, @@ -55,6 +56,11 @@ pub struct AuthMeResult { pub user: AuthUser, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublicUserSearchResult { + pub user: AuthUser, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PasswordEntryInput { pub username: String, @@ -262,6 +268,7 @@ pub struct LogoutAllSessionsResult { pub enum PasswordEntryError { InvalidUsername, InvalidPasswordLength, + InvalidPublicUserCode, InvalidCredentials, Store(String), PasswordHash(String), @@ -457,6 +464,16 @@ impl PasswordEntryService { .find_by_user_id(user_id) .map(|maybe_user| maybe_user.map(|stored| AuthMeResult { user: stored.user })) } + + pub fn get_user_by_public_user_code( + &self, + public_user_code: &str, + ) -> Result, PasswordEntryError> { + let normalized_public_user_code = normalize_public_user_code(public_user_code)?; + self.store + .find_by_public_user_code(&normalized_public_user_code) + .map(|maybe_user| maybe_user.map(|stored| PublicUserSearchResult { user: stored.user })) + } } impl RefreshSessionService { @@ -870,6 +887,18 @@ impl AuthUserService { .map_err(map_password_error_to_logout_error) } + pub fn get_user_by_public_user_code( + &self, + public_user_code: &str, + ) -> Result, LogoutError> { + let normalized_public_user_code = normalize_public_user_code(public_user_code) + .map_err(map_password_error_to_logout_error)?; + self.store + .find_by_public_user_code(&normalized_public_user_code) + .map(|maybe_user| maybe_user.map(|stored| stored.user)) + .map_err(map_password_error_to_logout_error) + } + pub fn logout_current_session( &self, input: LogoutCurrentSessionInput, @@ -962,6 +991,22 @@ impl InMemoryAuthStore { .cloned()) } + fn find_by_public_user_code( + &self, + public_user_code: &str, + ) -> Result, PasswordEntryError> { + let state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + + Ok(state + .users_by_username + .values() + .find(|stored_user| stored_user.user.public_user_code == public_user_code) + .cloned()) + } + fn find_by_phone_number( &self, phone_number: &str, @@ -994,11 +1039,14 @@ impl InMemoryAuthStore { return Err(CreateUserError::AlreadyExists); } - let user_id = format!("user_{:08}", state.next_user_id); + let sequence = state.next_user_id; + let user_id = format!("user_{sequence:08}"); + let public_user_code = build_public_user_code(sequence); state.next_user_id += 1; let user = AuthUser { id: user_id, + public_user_code, username: username.clone(), display_name: username.clone(), phone_number_masked: None, @@ -1035,11 +1083,14 @@ impl InMemoryAuthStore { )); } - let user_id = format!("user_{:08}", state.next_user_id); + let sequence = state.next_user_id; + let user_id = format!("user_{sequence:08}"); + let public_user_code = build_public_user_code(sequence); state.next_user_id += 1; let username = build_system_username("phone", state.next_user_id); let user = AuthUser { id: user_id.clone(), + public_user_code, username: username.clone(), display_name, phone_number_masked: Some(phone_number.masked_national_number.clone()), @@ -1073,7 +1124,9 @@ impl InMemoryAuthStore { .lock() .map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?; - let user_id = format!("user_{:08}", state.next_user_id); + let sequence = state.next_user_id; + let user_id = format!("user_{sequence:08}"); + let public_user_code = build_public_user_code(sequence); state.next_user_id += 1; let username = build_system_username("wechat", state.next_user_id); let display_name = profile @@ -1085,6 +1138,7 @@ impl InMemoryAuthStore { .to_string(); let user = AuthUser { id: user_id.clone(), + public_user_code, username: username.clone(), display_name, phone_number_masked: None, @@ -1722,6 +1776,7 @@ impl fmt::Display for PasswordEntryError { match self { Self::InvalidUsername => f.write_str("用户名只允许 3 到 24 位字母、数字、下划线"), Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"), + Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"), Self::InvalidCredentials => f.write_str("用户名或密码错误"), Self::Store(message) | Self::PasswordHash(message) => f.write_str(message), } @@ -1794,6 +1849,7 @@ fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError { PasswordEntryError::Store(message) => RefreshSessionError::Store(message), PasswordEntryError::InvalidUsername | PasswordEntryError::InvalidPasswordLength + | PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidCredentials | PasswordEntryError::PasswordHash(_) => { RefreshSessionError::Store("用户仓储读取失败".to_string()) @@ -1807,6 +1863,7 @@ fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthErro PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message), PasswordEntryError::InvalidUsername | PasswordEntryError::InvalidPasswordLength + | PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidCredentials => { PhoneAuthError::Store("用户仓储读取失败".to_string()) } @@ -1818,6 +1875,7 @@ fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError PasswordEntryError::Store(message) => LogoutError::Store(message), PasswordEntryError::InvalidUsername | PasswordEntryError::InvalidPasswordLength + | PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidCredentials | PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()), } @@ -1923,6 +1981,30 @@ fn build_system_username(prefix: &str, sequence: u64) -> String { format!("{prefix}_{sequence:08}") } +// 公开叙世号是稳定的公开检索键,不替代内部 user_id,仅用于展示、分享与搜索。 +fn build_public_user_code(sequence: u64) -> String { + format!("SY-{sequence:08}") +} + +pub fn normalize_public_user_code(input: &str) -> Result { + let normalized = input + .trim() + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .collect::() + .to_ascii_uppercase(); + let digits = normalized.strip_prefix("SY").unwrap_or(&normalized); + + if digits.is_empty() + || digits.len() > 8 + || !digits.chars().all(|character| character.is_ascii_digit()) + { + return Err(PasswordEntryError::InvalidPublicUserCode); + } + + Ok(format!("SY-{digits:0>8}")) +} + fn format_rfc3339(value: OffsetDateTime) -> Result { format_shared_rfc3339(value) } diff --git a/server-rs/crates/module-big-fish/src/lib.rs b/server-rs/crates/module-big-fish/src/lib.rs index 61082bdb..330085ff 100644 --- a/server-rs/crates/module-big-fish/src/lib.rs +++ b/server-rs/crates/module-big-fish/src/lib.rs @@ -252,6 +252,38 @@ pub struct BigFishSessionProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BigFishWorkSummarySnapshot { + pub work_id: String, + pub source_session_id: String, + pub title: String, + pub subtitle: String, + pub summary: String, + pub cover_image_src: Option, + pub status: String, + pub updated_at_micros: i64, + pub publish_ready: bool, + pub level_count: u32, + pub level_main_image_ready_count: u32, + pub level_motion_ready_count: u32, + pub background_ready: bool, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BigFishWorksListInput { + pub owner_user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BigFishWorksProcedureResult { + pub ok: bool, + pub items_json: Option, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BigFishRunProcedureResult { @@ -693,6 +725,13 @@ pub fn validate_session_get_input(input: &BigFishSessionGetInput) -> Result<(), validate_session_owner(&input.session_id, &input.owner_user_id) } +pub fn validate_works_list_input(input: &BigFishWorksListInput) -> Result<(), BigFishFieldError> { + if normalize_required_string(&input.owner_user_id).is_none() { + return Err(BigFishFieldError::MissingOwnerUserId); + } + Ok(()) +} + pub fn validate_session_create_input( input: &BigFishSessionCreateInput, ) -> Result<(), BigFishFieldError> { diff --git a/server-rs/crates/module-custom-world/src/lib.rs b/server-rs/crates/module-custom-world/src/lib.rs index a62d94d4..d4117763 100644 --- a/server-rs/crates/module-custom-world/src/lib.rs +++ b/server-rs/crates/module-custom-world/src/lib.rs @@ -140,6 +140,7 @@ pub enum CustomWorldFieldError { MissingProfileId, MissingSessionId, MissingOwnerUserId, + MissingPublicWorkCode, MissingAction, MissingWorldName, MissingDraftProfileJson, @@ -170,6 +171,8 @@ pub enum CustomWorldFieldError { pub struct CustomWorldProfileSnapshot { pub profile_id: String, pub owner_user_id: String, + pub public_work_code: Option, + pub author_public_user_code: Option, pub source_agent_session_id: Option, pub publication_status: CustomWorldPublicationStatus, pub world_name: String, @@ -192,6 +195,8 @@ pub struct CustomWorldProfileSnapshot { pub struct CustomWorldGalleryEntrySnapshot { pub profile_id: String, pub owner_user_id: String, + pub public_work_code: String, + pub author_public_user_code: String, pub author_display_name: String, pub world_name: String, pub subtitle: String, @@ -408,6 +413,8 @@ pub struct CustomWorldAgentSessionProcedureResult { pub struct CustomWorldProfileUpsertInput { pub profile_id: String, pub owner_user_id: String, + pub public_work_code: Option, + pub author_public_user_code: Option, pub source_agent_session_id: Option, pub world_name: String, pub subtitle: String, @@ -426,6 +433,8 @@ pub struct CustomWorldProfileUpsertInput { pub struct CustomWorldProfilePublishInput { pub profile_id: String, pub owner_user_id: String, + pub public_work_code: Option, + pub author_public_user_code: String, pub author_display_name: String, pub published_at_micros: i64, } @@ -467,6 +476,12 @@ pub struct CustomWorldGalleryDetailInput { pub profile_id: String, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldGalleryDetailByCodeInput { + pub public_work_code: String, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CustomWorldAgentSessionCreateInput { @@ -630,6 +645,8 @@ pub struct CustomWorldPublishWorldInput { pub session_id: String, pub profile_id: String, pub owner_user_id: String, + pub public_work_code: Option, + pub author_public_user_code: String, pub draft_profile_json: String, pub legacy_result_profile_json: Option, pub setting_text: String, @@ -862,6 +879,9 @@ pub fn validate_custom_world_published_profile_compile_input( pub fn validate_custom_world_publish_world_input( input: &CustomWorldPublishWorldInput, ) -> Result<(), CustomWorldFieldError> { + if input.author_public_user_code.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } validate_custom_world_published_profile_compile_input( &CustomWorldPublishedProfileCompileInput { session_id: input.session_id.clone(), @@ -905,6 +925,9 @@ pub fn validate_custom_world_profile_publish_input( if input.author_display_name.trim().is_empty() { return Err(CustomWorldFieldError::MissingAuthorDisplayName); } + if input.author_public_user_code.trim().is_empty() { + return Err(CustomWorldFieldError::MissingOwnerUserId); + } Ok(()) } @@ -974,6 +997,16 @@ pub fn validate_custom_world_gallery_detail_input( Ok(()) } +pub fn validate_custom_world_gallery_detail_by_code_input( + input: &CustomWorldGalleryDetailByCodeInput, +) -> Result<(), CustomWorldFieldError> { + if input.public_work_code.trim().is_empty() { + return Err(CustomWorldFieldError::MissingPublicWorkCode); + } + + Ok(()) +} + pub fn validate_custom_world_session_fields( session_id: &str, owner_user_id: &str, @@ -1562,6 +1595,9 @@ impl fmt::Display for CustomWorldFieldError { Self::MissingProfileId => f.write_str("custom_world.profile_id 不能为空"), Self::MissingSessionId => f.write_str("custom_world.session_id 不能为空"), Self::MissingOwnerUserId => f.write_str("custom_world.owner_user_id 不能为空"), + Self::MissingPublicWorkCode => { + f.write_str("custom_world_gallery_detail.public_work_code 不能为空") + } Self::MissingAction => f.write_str("custom_world_agent_action.action 不能为空"), Self::MissingWorldName => f.write_str("custom_world.world_name 不能为空"), Self::MissingDraftProfileJson => { diff --git a/server-rs/crates/module-puzzle/src/lib.rs b/server-rs/crates/module-puzzle/src/lib.rs index ab407774..963d3156 100644 --- a/server-rs/crates/module-puzzle/src/lib.rs +++ b/server-rs/crates/module-puzzle/src/lib.rs @@ -302,6 +302,20 @@ pub struct PuzzleAgentMessageSubmitInput { pub submitted_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleAgentMessageFinalizeInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub stage: PuzzleAgentStage, + pub progress_percent: u32, + pub anchor_pack_json: String, + pub error_message: Option, + pub updated_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleDraftCompileInput { diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs new file mode 100644 index 00000000..c23632b3 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -0,0 +1,96 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminLoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminLoginResponse { + pub token: String, + pub admin: AdminSessionPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminSessionPayload { + pub subject: String, + pub username: String, + pub display_name: String, + pub roles: Vec, + pub issued_at: String, + pub expires_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminMeResponse { + pub admin: AdminSessionPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminOverviewResponse { + pub service: AdminServiceOverviewPayload, + pub database: AdminDatabaseOverviewPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminServiceOverviewPayload { + pub bind_host: String, + pub bind_port: u16, + pub jwt_issuer: String, + pub admin_enabled: bool, + pub spacetime_server_url: String, + pub spacetime_database: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminDatabaseOverviewPayload { + pub database_identity: Option, + pub owner_identity: Option, + pub host_type: Option, + pub schema_table_names: Vec, + pub table_stats: Vec, + pub fetch_errors: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminDatabaseTableStatPayload { + pub table_name: String, + pub row_count: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminDebugHttpRequest { + pub method: String, + pub path: String, + pub headers: Option>, + pub body: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminDebugHeaderInput { + pub name: String, + pub value: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminDebugHttpResponse { + pub status: u16, + pub status_text: String, + pub headers: Vec, + pub body_text: String, + pub body_json: Option, +} diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index d5b6ce13..70f88117 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -277,6 +277,7 @@ pub struct CharacterAnimationGenerateResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterAnimationDraftPayload { + #[serde(default)] pub frames_data_urls: Vec, pub fps: u32, #[serde(rename = "loop")] @@ -284,6 +285,14 @@ pub struct CharacterAnimationDraftPayload { pub frame_width: u32, pub frame_height: u32, #[serde(default)] + pub frame_count: Option, + #[serde(default)] + pub apply_chroma_key: Option, + #[serde(default)] + pub sample_start_ratio: Option, + #[serde(default)] + pub sample_end_ratio: Option, + #[serde(default)] pub preview_video_path: Option, } @@ -815,4 +824,26 @@ mod tests { assert_eq!(payload["animationSetId"], json!("animation-set-1")); assert_eq!(payload["animationMap"]["idle"]["frames"], json!(2)); } + + #[test] + fn character_animation_draft_payload_accepts_backend_extraction_fields() { + let payload = serde_json::from_value::(json!({ + "fps": 8, + "loop": true, + "frameWidth": 192, + "frameHeight": 256, + "frameCount": 8, + "applyChromaKey": true, + "sampleStartRatio": 0.12, + "sampleEndRatio": 0.94, + "previewVideoPath": "/generated-character-drafts/hero/animation/idle/task/preview.mp4" + })) + .expect("draft payload should deserialize without framesDataUrls"); + + assert!(payload.frames_data_urls.is_empty()); + assert_eq!(payload.frame_count, Some(8)); + assert_eq!(payload.apply_chroma_key, Some(true)); + assert_eq!(payload.sample_start_ratio, Some(0.12)); + assert_eq!(payload.sample_end_ratio, Some(0.94)); + } } diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index 1298d70f..bc99bcdd 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -16,6 +16,7 @@ pub struct AuthLoginOptionsResponse { #[serde(rename_all = "camelCase")] pub struct AuthUserPayload { pub id: String, + pub public_user_code: String, pub username: String, pub display_name: String, pub phone_number_masked: Option, @@ -24,6 +25,20 @@ pub struct AuthUserPayload { pub wechat_bound: bool, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PublicUserSummaryPayload { + pub id: String, + pub public_user_code: String, + pub display_name: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PublicUserSearchResponse { + pub user: PublicUserSummaryPayload, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PasswordEntryRequest { diff --git a/server-rs/crates/shared-contracts/src/big_fish_works.rs b/server-rs/crates/shared-contracts/src/big_fish_works.rs new file mode 100644 index 00000000..acddc497 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/big_fish_works.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BigFishWorkSummaryResponse { + pub work_id: String, + pub source_session_id: String, + pub title: String, + pub subtitle: String, + pub summary: String, + #[serde(default)] + pub cover_image_src: Option, + pub status: String, + pub updated_at: String, + pub publish_ready: bool, + pub level_count: u32, + pub level_main_image_ready_count: u32, + pub level_motion_ready_count: u32, + pub background_ready: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BigFishWorksResponse { + pub items: Vec, +} diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 05ced81d..d397916b 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -1,8 +1,10 @@ +pub mod admin; pub mod ai; pub mod api; pub mod assets; pub mod auth; pub mod big_fish; +pub mod big_fish_works; pub mod llm; pub mod puzzle_agent; pub mod puzzle_gallery; diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 35d006ea..b6ad22a0 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -233,6 +233,8 @@ pub struct CustomWorldProfileUpsertRequest { pub struct CustomWorldLibraryEntryResponse { pub owner_user_id: String, pub profile_id: String, + pub public_work_code: Option, + pub author_public_user_code: Option, pub profile: serde_json::Value, pub visibility: String, pub published_at: Option, @@ -252,6 +254,8 @@ pub struct CustomWorldLibraryEntryResponse { pub struct CustomWorldGalleryCardResponse { pub owner_user_id: String, pub profile_id: String, + pub public_work_code: String, + pub author_public_user_code: String, pub visibility: String, pub published_at: Option, pub updated_at: String, diff --git a/server-rs/crates/spacetime-client/Cargo.toml b/server-rs/crates/spacetime-client/Cargo.toml index 44ece89f..f3233690 100644 --- a/server-rs/crates/spacetime-client/Cargo.toml +++ b/server-rs/crates/spacetime-client/Cargo.toml @@ -16,6 +16,7 @@ module-puzzle = { path = "../module-puzzle" } module-runtime = { path = "../module-runtime" } module-runtime-item = { path = "../module-runtime-item" } module-story = { path = "../module-story" } +serde = { version = "1", features = ["derive"] } serde_json = "1" shared-kernel = { path = "../shared-kernel" } spacetimedb-sdk = "2.1.0" diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index fd769a1a..5ed09a76 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -155,7 +155,10 @@ use crate::module_bindings::{ BigFishSessionGetInput as BindingBigFishSessionGetInput, BigFishSessionProcedureResult as BindingBigFishSessionProcedureResult, BigFishSessionSnapshot as BindingBigFishSessionSnapshot, - BigFishVector2 as BindingBigFishVector2, CombatOutcome as BindingCombatOutcome, + BigFishVector2 as BindingBigFishVector2, + BigFishWorksListInput as BindingBigFishWorksListInput, + BigFishWorksProcedureResult as BindingBigFishWorksProcedureResult, + CombatOutcome as BindingCombatOutcome, CustomWorldAgentActionExecuteInput as BindingCustomWorldAgentActionExecuteInput, CustomWorldAgentActionExecuteResult as BindingCustomWorldAgentActionExecuteResult, CustomWorldAgentCardDetailGetInput as BindingCustomWorldAgentCardDetailGetInput, @@ -173,6 +176,7 @@ use crate::module_bindings::{ CustomWorldDraftCardDetailSectionSnapshot as BindingCustomWorldDraftCardDetailSectionSnapshot, CustomWorldDraftCardDetailSnapshot as BindingCustomWorldDraftCardDetailSnapshot, CustomWorldDraftCardSnapshot as BindingCustomWorldDraftCardSnapshot, + CustomWorldGalleryDetailByCodeInput as BindingCustomWorldGalleryDetailByCodeInput, CustomWorldGalleryDetailInput as BindingCustomWorldGalleryDetailInput, CustomWorldGalleryEntrySnapshot as BindingCustomWorldGalleryEntrySnapshot, CustomWorldGalleryListResult as BindingCustomWorldGalleryListResult, @@ -205,6 +209,7 @@ use crate::module_bindings::{ NpcInteractionStatus as BindingNpcInteractionStatus, NpcRelationStance as BindingNpcRelationStance, NpcRelationState as BindingNpcRelationState, NpcStanceProfile as BindingNpcStanceProfile, NpcStateSnapshot as BindingNpcStateSnapshot, + PuzzleAgentMessageFinalizeInput as BindingPuzzleAgentMessageFinalizeInput, PuzzleAgentMessageSubmitInput as BindingPuzzleAgentMessageSubmitInput, PuzzleAgentSessionCreateInput as BindingPuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput as BindingPuzzleAgentSessionGetInput, @@ -297,6 +302,7 @@ use crate::module_bindings::{ execute_custom_world_agent_action_procedure::execute_custom_world_agent_action as _, fail_ai_task_and_return_procedure::fail_ai_task_and_return as _, finalize_custom_world_agent_message_turn_procedure::finalize_custom_world_agent_message_turn as _, + finalize_puzzle_agent_message_turn_procedure::finalize_puzzle_agent_message_turn as _, generate_big_fish_asset_procedure::generate_big_fish_asset as _, get_battle_state_procedure::get_battle_state as _, get_big_fish_run_procedure::get_big_fish_run as _, @@ -304,6 +310,7 @@ use crate::module_bindings::{ get_custom_world_agent_card_detail_procedure::get_custom_world_agent_card_detail as _, get_custom_world_agent_operation_procedure::get_custom_world_agent_operation as _, get_custom_world_agent_session_procedure::get_custom_world_agent_session as _, + get_custom_world_gallery_detail_by_code_procedure::get_custom_world_gallery_detail_by_code as _, get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail as _, get_custom_world_library_detail_procedure::get_custom_world_library_detail as _, get_profile_dashboard_procedure::get_profile_dashboard as _, @@ -319,6 +326,7 @@ use crate::module_bindings::{ list_custom_world_gallery_entries_procedure::list_custom_world_gallery_entries as _, list_custom_world_profiles_procedure::list_custom_world_profiles as _, list_custom_world_works_procedure::list_custom_world_works as _, + list_big_fish_works_procedure::list_big_fish_works as _, list_platform_browse_history_procedure::list_platform_browse_history as _, list_profile_save_archives_procedure::list_profile_save_archives as _, list_profile_wallet_ledger_procedure::list_profile_wallet_ledger as _, @@ -739,12 +747,16 @@ impl SpacetimeClient { &self, profile_id: String, owner_user_id: String, + public_work_code: Option, + author_public_user_code: String, author_display_name: String, published_at_micros: i64, ) -> Result { let procedure_input = BindingCustomWorldProfilePublishInput { profile_id, owner_user_id, + public_work_code, + author_public_user_code, author_display_name, published_at_micros, }; @@ -856,6 +868,25 @@ impl SpacetimeClient { .await } + pub async fn get_custom_world_gallery_detail_by_code( + &self, + public_work_code: String, + ) -> Result { + let procedure_input = BindingCustomWorldGalleryDetailByCodeInput { public_work_code }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_custom_world_gallery_detail_by_code_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn publish_custom_world_world( &self, input: CustomWorldPublishWorldRecordInput, @@ -1086,6 +1117,35 @@ impl SpacetimeClient { .await } + pub async fn finalize_puzzle_agent_message( + &self, + input: PuzzleAgentMessageFinalizeRecordInput, + ) -> Result { + let procedure_input = BindingPuzzleAgentMessageFinalizeInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + assistant_message_id: input.assistant_message_id, + assistant_reply_text: input.assistant_reply_text, + stage: parse_puzzle_agent_stage_record(input.stage.as_str())?, + progress_percent: input.progress_percent, + anchor_pack_json: input.anchor_pack_json, + error_message: input.error_message, + updated_at_micros: input.updated_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .finalize_puzzle_agent_message_turn_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn compile_puzzle_agent_draft( &self, session_id: String, @@ -1467,6 +1527,26 @@ impl SpacetimeClient { .await } + pub async fn list_big_fish_works( + &self, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = BindingBigFishWorksListInput { owner_user_id }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().list_big_fish_works_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_works_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn submit_big_fish_message( &self, input: BigFishMessageSubmitRecordInput, @@ -1707,15 +1787,17 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { - connection.procedures().finalize_custom_world_agent_message_turn_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_custom_world_agent_operation_procedure_result); - send_once(&sender, mapped); - }, - ); + connection + .procedures() + .finalize_custom_world_agent_message_turn_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_agent_operation_procedure_result); + send_once(&sender, mapped); + }, + ); }) .await } @@ -2764,6 +2846,8 @@ fn map_custom_world_profile_upsert_input( BindingCustomWorldProfileUpsertInput { profile_id: input.profile_id, owner_user_id: input.owner_user_id, + public_work_code: input.public_work_code, + author_public_user_code: input.author_public_user_code, source_agent_session_id: input.source_agent_session_id, world_name: input.world_name, subtitle: input.subtitle, @@ -2785,6 +2869,8 @@ fn map_custom_world_publish_world_input( session_id: input.session_id, profile_id: input.profile_id, owner_user_id: input.owner_user_id, + public_work_code: input.public_work_code, + author_public_user_code: input.author_public_user_code, draft_profile_json: input.draft_profile_json, legacy_result_profile_json: input.legacy_result_profile_json, setting_text: input.setting_text, @@ -3480,6 +3566,26 @@ fn map_big_fish_session_procedure_result( Ok(map_big_fish_session_snapshot(session)) } +fn map_big_fish_works_procedure_result( + result: BindingBigFishWorksProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let items_json = result.items_json.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 big fish works 快照".to_string(), + ) + })?; + serde_json::from_str::>(&items_json) + .map_err(|error| SpacetimeClientError::Runtime(format!("big fish works items_json 非法: {error}"))) +} + fn map_big_fish_run_procedure_result( result: BindingBigFishRunProcedureResult, ) -> Result { @@ -3817,6 +3923,8 @@ fn map_custom_world_library_entry_from_profile_snapshot( Ok(CustomWorldLibraryEntryRecord { owner_user_id: snapshot.owner_user_id, profile_id: snapshot.profile_id, + public_work_code: snapshot.public_work_code, + author_public_user_code: snapshot.author_public_user_code, profile, visibility: map_custom_world_publication_status(snapshot.publication_status).to_string(), published_at: snapshot.published_at_micros.map(format_timestamp_micros), @@ -3841,6 +3949,8 @@ fn map_custom_world_gallery_entry_snapshot( Ok(CustomWorldGalleryEntryRecord { owner_user_id: snapshot.owner_user_id, profile_id: snapshot.profile_id, + public_work_code: snapshot.public_work_code, + author_public_user_code: snapshot.author_public_user_code, visibility: "published".to_string(), published_at: Some(format_timestamp_micros(snapshot.published_at_micros)), updated_at: format_timestamp_micros(snapshot.updated_at_micros), @@ -3990,6 +4100,7 @@ fn map_custom_world_agent_session_snapshot( Ok(CustomWorldAgentSessionRecord { session_id: snapshot.session_id, + seed_text: snapshot.seed_text, current_turn: snapshot.current_turn, anchor_content, progress_percent: snapshot.progress_percent, @@ -5021,6 +5132,21 @@ fn map_rpg_agent_stage(value: crate::module_bindings::RpgAgentStage) -> String { .to_string() } +fn parse_puzzle_agent_stage_record( + value: &str, +) -> Result { + match value.trim() { + "collecting_anchors" => Ok(crate::module_bindings::PuzzleAgentStage::CollectingAnchors), + "draft_ready" => Ok(crate::module_bindings::PuzzleAgentStage::DraftReady), + "image_refining" => Ok(crate::module_bindings::PuzzleAgentStage::ImageRefining), + "ready_to_publish" => Ok(crate::module_bindings::PuzzleAgentStage::ReadyToPublish), + "published" => Ok(crate::module_bindings::PuzzleAgentStage::Published), + other => Err(SpacetimeClientError::Runtime(format!( + "未知 puzzle agent stage: {other}" + ))), + } +} + fn parse_rpg_agent_stage_record( value: &str, ) -> Result { @@ -5680,6 +5806,8 @@ pub struct ResolveCombatActionRecord { pub struct CustomWorldLibraryEntryRecord { pub owner_user_id: String, pub profile_id: String, + pub public_work_code: Option, + pub author_public_user_code: Option, pub profile: serde_json::Value, pub visibility: String, pub published_at: Option, @@ -5698,6 +5826,8 @@ pub struct CustomWorldLibraryEntryRecord { pub struct CustomWorldGalleryEntryRecord { pub owner_user_id: String, pub profile_id: String, + pub public_work_code: String, + pub author_public_user_code: String, pub visibility: String, pub published_at: Option, pub updated_at: String, @@ -5863,6 +5993,7 @@ pub struct CustomWorldDraftCardDetailRecord { #[derive(Clone, Debug, PartialEq)] pub struct CustomWorldAgentSessionRecord { pub session_id: String, + pub seed_text: String, pub current_turn: u32, pub anchor_content: serde_json::Value, pub progress_percent: u32, @@ -5892,6 +6023,8 @@ pub struct CustomWorldAgentSessionRecord { pub struct CustomWorldProfileUpsertRecordInput { pub profile_id: String, pub owner_user_id: String, + pub public_work_code: Option, + pub author_public_user_code: Option, pub source_agent_session_id: Option, pub world_name: String, pub subtitle: String, @@ -5910,6 +6043,8 @@ pub struct CustomWorldPublishWorldRecordInput { pub session_id: String, pub profile_id: String, pub owner_user_id: String, + pub public_work_code: Option, + pub author_public_user_code: String, pub draft_profile_json: String, pub legacy_result_profile_json: Option, pub setting_text: String, @@ -6011,6 +6146,19 @@ pub struct PuzzleAgentMessageSubmitRecordInput { pub submitted_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAgentMessageFinalizeRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub stage: String, + pub progress_percent: u32, + pub anchor_pack_json: String, + pub error_message: Option, + pub updated_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleGeneratedImagesSaveRecordInput { pub session_id: String, @@ -6438,6 +6586,23 @@ pub struct BigFishSessionRecord { pub updated_at: String, } +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct BigFishWorkSummaryRecord { + pub work_id: String, + pub source_session_id: String, + pub title: String, + pub subtitle: String, + pub summary: String, + pub cover_image_src: Option, + pub status: String, + pub updated_at_micros: i64, + pub publish_ready: bool, + pub level_count: u32, + pub level_main_image_ready_count: u32, + pub level_motion_ready_count: u32, + pub background_ready: bool, +} + #[derive(Clone, Debug, PartialEq)] pub struct BigFishVector2Record { pub x: f32, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_list_input_type.rs new file mode 100644 index 00000000..72220ff9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_list_input_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BigFishWorksListInput { + pub owner_user_id: String, +} + + +impl __sdk::InModule for BigFishWorksListInput { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_procedure_result_type.rs new file mode 100644 index 00000000..a8fbe37b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_procedure_result_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BigFishWorksProcedureResult { + pub ok: bool, + pub items_json: Option::, + pub error_message: Option::, +} + + +impl __sdk::InModule for BigFishWorksProcedureResult { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_detail_by_code_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_detail_by_code_input_type.rs new file mode 100644 index 00000000..acdc32d0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_detail_by_code_input_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldGalleryDetailByCodeInput { + pub public_work_code: String, +} + + +impl __sdk::InModule for CustomWorldGalleryDetailByCodeInput { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs index 9d81b728..04616d88 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs @@ -16,6 +16,8 @@ use super::custom_world_theme_mode_type::CustomWorldThemeMode; pub struct CustomWorldGalleryEntrySnapshot { pub profile_id: String, pub owner_user_id: String, + pub public_work_code: String, + pub author_public_user_code: String, pub author_display_name: String, pub world_name: String, pub subtitle: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs index 471ad9fb..976ccc10 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs @@ -16,6 +16,8 @@ use super::custom_world_theme_mode_type::CustomWorldThemeMode; pub struct CustomWorldGalleryEntry { pub profile_id: String, pub owner_user_id: String, + pub public_work_code: String, + pub author_public_user_code: String, pub author_display_name: String, pub world_name: String, pub subtitle: String, @@ -40,6 +42,8 @@ impl __sdk::InModule for CustomWorldGalleryEntry { pub struct CustomWorldGalleryEntryCols { pub profile_id: __sdk::__query_builder::Col, pub owner_user_id: __sdk::__query_builder::Col, + pub public_work_code: __sdk::__query_builder::Col, + pub author_public_user_code: __sdk::__query_builder::Col, pub author_display_name: __sdk::__query_builder::Col, pub world_name: __sdk::__query_builder::Col, pub subtitle: __sdk::__query_builder::Col, @@ -58,6 +62,8 @@ impl __sdk::__query_builder::HasCols for CustomWorldGalleryEntry { CustomWorldGalleryEntryCols { profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + public_work_code: __sdk::__query_builder::Col::new(table_name, "public_work_code"), + author_public_user_code: __sdk::__query_builder::Col::new(table_name, "author_public_user_code"), author_display_name: __sdk::__query_builder::Col::new(table_name, "author_display_name"), world_name: __sdk::__query_builder::Col::new(table_name, "world_name"), subtitle: __sdk::__query_builder::Col::new(table_name, "subtitle"), @@ -79,6 +85,7 @@ impl __sdk::__query_builder::HasCols for CustomWorldGalleryEntry { pub struct CustomWorldGalleryEntryIxCols { pub owner_user_id: __sdk::__query_builder::IxCol, pub profile_id: __sdk::__query_builder::IxCol, + pub public_work_code: __sdk::__query_builder::IxCol, pub theme_mode: __sdk::__query_builder::IxCol, } @@ -88,6 +95,7 @@ impl __sdk::__query_builder::HasIxCols for CustomWorldGalleryEntry { CustomWorldGalleryEntryIxCols { owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"), + public_work_code: __sdk::__query_builder::IxCol::new(table_name, "public_work_code"), theme_mode: __sdk::__query_builder::IxCol::new(table_name, "theme_mode"), } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_publish_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_publish_input_type.rs index 42b63f84..3a88edf7 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_publish_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_publish_input_type.rs @@ -15,6 +15,8 @@ use spacetimedb_sdk::__codegen::{ pub struct CustomWorldProfilePublishInput { pub profile_id: String, pub owner_user_id: String, + pub public_work_code: Option::, + pub author_public_user_code: String, pub author_display_name: String, pub published_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_snapshot_type.rs index 7c64effa..0d404e2b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_snapshot_type.rs @@ -17,6 +17,8 @@ use super::custom_world_publication_status_type::CustomWorldPublicationStatus; pub struct CustomWorldProfileSnapshot { pub profile_id: String, pub owner_user_id: String, + pub public_work_code: Option::, + pub author_public_user_code: Option::, pub source_agent_session_id: Option::, pub publication_status: CustomWorldPublicationStatus, pub world_name: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs index 49405210..ebcb964a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs @@ -17,6 +17,8 @@ use super::custom_world_publication_status_type::CustomWorldPublicationStatus; pub struct CustomWorldProfile { pub profile_id: String, pub owner_user_id: String, + pub public_work_code: Option::, + pub author_public_user_code: Option::, pub source_agent_session_id: Option::, pub publication_status: CustomWorldPublicationStatus, pub world_name: String, @@ -46,6 +48,8 @@ impl __sdk::InModule for CustomWorldProfile { pub struct CustomWorldProfileCols { pub profile_id: __sdk::__query_builder::Col, pub owner_user_id: __sdk::__query_builder::Col, + pub public_work_code: __sdk::__query_builder::Col>, + pub author_public_user_code: __sdk::__query_builder::Col>, pub source_agent_session_id: __sdk::__query_builder::Col>, pub publication_status: __sdk::__query_builder::Col, pub world_name: __sdk::__query_builder::Col, @@ -69,6 +73,8 @@ impl __sdk::__query_builder::HasCols for CustomWorldProfile { CustomWorldProfileCols { profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + public_work_code: __sdk::__query_builder::Col::new(table_name, "public_work_code"), + author_public_user_code: __sdk::__query_builder::Col::new(table_name, "author_public_user_code"), source_agent_session_id: __sdk::__query_builder::Col::new(table_name, "source_agent_session_id"), publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), world_name: __sdk::__query_builder::Col::new(table_name, "world_name"), diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_upsert_input_type.rs index 53761a14..0ea2ef8c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_upsert_input_type.rs @@ -16,6 +16,8 @@ use super::custom_world_theme_mode_type::CustomWorldThemeMode; pub struct CustomWorldProfileUpsertInput { pub profile_id: String, pub owner_user_id: String, + pub public_work_code: Option::, + pub author_public_user_code: Option::, pub source_agent_session_id: Option::, pub world_name: String, pub subtitle: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_publish_world_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_publish_world_input_type.rs index f59454ef..74160cd9 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_publish_world_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_publish_world_input_type.rs @@ -16,6 +16,8 @@ pub struct CustomWorldPublishWorldInput { pub session_id: String, pub profile_id: String, pub owner_user_id: String, + pub public_work_code: Option::, + pub author_public_user_code: String, pub draft_profile_json: String, pub legacy_result_profile_json: Option::, pub setting_text: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/finalize_puzzle_agent_message_turn_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/finalize_puzzle_agent_message_turn_procedure.rs new file mode 100644 index 00000000..e2d6566c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/finalize_puzzle_agent_message_turn_procedure.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::puzzle_agent_session_procedure_result_type::PuzzleAgentSessionProcedureResult; +use super::puzzle_agent_message_finalize_input_type::PuzzleAgentMessageFinalizeInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct FinalizePuzzleAgentMessageTurnArgs { + pub input: PuzzleAgentMessageFinalizeInput, +} + + +impl __sdk::InModule for FinalizePuzzleAgentMessageTurnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `finalize_puzzle_agent_message_turn`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait finalize_puzzle_agent_message_turn { + fn finalize_puzzle_agent_message_turn(&self, input: PuzzleAgentMessageFinalizeInput, +) { + self.finalize_puzzle_agent_message_turn_then(input, |_, _| {}); + } + + fn finalize_puzzle_agent_message_turn_then( + &self, + input: PuzzleAgentMessageFinalizeInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl finalize_puzzle_agent_message_turn for super::RemoteProcedures { + fn finalize_puzzle_agent_message_turn_then( + &self, + input: PuzzleAgentMessageFinalizeInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, PuzzleAgentSessionProcedureResult>( + "finalize_puzzle_agent_message_turn", + FinalizePuzzleAgentMessageTurnArgs { input, }, + __callback, + ); + } +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_by_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_by_code_procedure.rs new file mode 100644 index 00000000..455eb2fc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_by_code_procedure.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::custom_world_library_mutation_result_type::CustomWorldLibraryMutationResult; +use super::custom_world_gallery_detail_by_code_input_type::CustomWorldGalleryDetailByCodeInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct GetCustomWorldGalleryDetailByCodeArgs { + pub input: CustomWorldGalleryDetailByCodeInput, +} + + +impl __sdk::InModule for GetCustomWorldGalleryDetailByCodeArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_custom_world_gallery_detail_by_code`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_custom_world_gallery_detail_by_code { + fn get_custom_world_gallery_detail_by_code(&self, input: CustomWorldGalleryDetailByCodeInput, +) { + self.get_custom_world_gallery_detail_by_code_then(input, |_, _| {}); + } + + fn get_custom_world_gallery_detail_by_code_then( + &self, + input: CustomWorldGalleryDetailByCodeInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl get_custom_world_gallery_detail_by_code for super::RemoteProcedures { + fn get_custom_world_gallery_detail_by_code_then( + &self, + input: CustomWorldGalleryDetailByCodeInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, CustomWorldLibraryMutationResult>( + "get_custom_world_gallery_detail_by_code", + GetCustomWorldGalleryDetailByCodeArgs { input, }, + __callback, + ); + } +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_big_fish_works_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_big_fish_works_procedure.rs new file mode 100644 index 00000000..26eb3b8a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_big_fish_works_procedure.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::big_fish_works_list_input_type::BigFishWorksListInput; +use super::big_fish_works_procedure_result_type::BigFishWorksProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct ListBigFishWorksArgs { + pub input: BigFishWorksListInput, +} + + +impl __sdk::InModule for ListBigFishWorksArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_big_fish_works`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_big_fish_works { + fn list_big_fish_works(&self, input: BigFishWorksListInput, +) { + self.list_big_fish_works_then(input, |_, _| {}); + } + + fn list_big_fish_works_then( + &self, + input: BigFishWorksListInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl list_big_fish_works for super::RemoteProcedures { + fn list_big_fish_works_then( + &self, + input: BigFishWorksListInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, BigFishWorksProcedureResult>( + "list_big_fish_works", + ListBigFishWorksArgs { input, }, + __callback, + ); + } +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 7558f9b7..34e0702a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -86,6 +86,8 @@ pub mod big_fish_session_get_input_type; pub mod big_fish_session_procedure_result_type; pub mod big_fish_session_snapshot_type; pub mod big_fish_vector_2_type; +pub mod big_fish_works_list_input_type; +pub mod big_fish_works_procedure_result_type; pub mod chapter_pace_band_type; pub mod chapter_progression_type; pub mod chapter_progression_get_input_type; @@ -116,6 +118,7 @@ pub mod custom_world_draft_card_detail_result_type; pub mod custom_world_draft_card_detail_section_snapshot_type; pub mod custom_world_draft_card_detail_snapshot_type; pub mod custom_world_draft_card_snapshot_type; +pub mod custom_world_gallery_detail_by_code_input_type; pub mod custom_world_gallery_detail_input_type; pub mod custom_world_gallery_entry_type; pub mod custom_world_gallery_entry_snapshot_type; @@ -179,6 +182,7 @@ pub mod profile_dashboard_state_type; pub mod profile_played_world_type; pub mod profile_save_archive_type; pub mod profile_wallet_ledger_type; +pub mod puzzle_agent_message_finalize_input_type; pub mod puzzle_agent_message_kind_type; pub mod puzzle_agent_message_role_type; pub mod puzzle_agent_message_row_type; @@ -328,7 +332,44 @@ pub mod unpublish_custom_world_profile_reducer; pub mod upsert_chapter_progression_reducer; pub mod upsert_custom_world_profile_reducer; pub mod upsert_npc_state_reducer; +pub mod ai_result_reference_table; +pub mod ai_task_table; +pub mod ai_task_stage_table; +pub mod ai_text_chunk_table; +pub mod asset_entity_binding_table; +pub mod asset_object_table; +pub mod battle_state_table; +pub mod big_fish_agent_message_table; +pub mod big_fish_asset_slot_table; +pub mod big_fish_creation_session_table; +pub mod big_fish_runtime_run_table; +pub mod chapter_progression_table; +pub mod custom_world_agent_message_table; +pub mod custom_world_agent_operation_table; +pub mod custom_world_agent_session_table; +pub mod custom_world_draft_card_table; pub mod custom_world_gallery_entry_table; +pub mod custom_world_profile_table; +pub mod custom_world_session_table; +pub mod inventory_slot_table; +pub mod npc_state_table; +pub mod player_progression_table; +pub mod profile_dashboard_state_table; +pub mod profile_played_world_table; +pub mod profile_save_archive_table; +pub mod profile_wallet_ledger_table; +pub mod puzzle_agent_message_table; +pub mod puzzle_agent_session_table; +pub mod puzzle_runtime_run_table; +pub mod puzzle_work_profile_table; +pub mod quest_log_table; +pub mod quest_record_table; +pub mod runtime_setting_table; +pub mod runtime_snapshot_table; +pub mod story_event_table; +pub mod story_session_table; +pub mod treasure_record_table; +pub mod user_browse_history_table; pub mod advance_puzzle_next_level_procedure; pub mod append_ai_text_chunk_and_return_procedure; pub mod apply_chapter_progression_ledger_entry_and_return_procedure; @@ -355,6 +396,7 @@ pub mod drag_puzzle_piece_or_group_procedure; pub mod execute_custom_world_agent_action_procedure; pub mod fail_ai_task_and_return_procedure; pub mod finalize_custom_world_agent_message_turn_procedure; +pub mod finalize_puzzle_agent_message_turn_procedure; pub mod generate_big_fish_asset_procedure; pub mod get_battle_state_procedure; pub mod get_big_fish_run_procedure; @@ -364,6 +406,7 @@ pub mod get_custom_world_agent_card_detail_procedure; pub mod get_custom_world_agent_operation_procedure; pub mod get_custom_world_agent_session_procedure; pub mod get_custom_world_gallery_detail_procedure; +pub mod get_custom_world_gallery_detail_by_code_procedure; pub mod get_custom_world_library_detail_procedure; pub mod get_player_progression_or_default_procedure; pub mod get_profile_dashboard_procedure; @@ -377,6 +420,7 @@ pub mod get_runtime_setting_or_default_procedure; pub mod get_runtime_snapshot_procedure; pub mod get_story_session_state_procedure; pub mod grant_player_progression_experience_and_return_procedure; +pub mod list_big_fish_works_procedure; pub mod list_custom_world_gallery_entries_procedure; pub mod list_custom_world_profiles_procedure; pub mod list_custom_world_works_procedure; @@ -488,6 +532,8 @@ pub use big_fish_session_get_input_type::BigFishSessionGetInput; pub use big_fish_session_procedure_result_type::BigFishSessionProcedureResult; pub use big_fish_session_snapshot_type::BigFishSessionSnapshot; pub use big_fish_vector_2_type::BigFishVector2; +pub use big_fish_works_list_input_type::BigFishWorksListInput; +pub use big_fish_works_procedure_result_type::BigFishWorksProcedureResult; pub use chapter_pace_band_type::ChapterPaceBand; pub use chapter_progression_type::ChapterProgression; pub use chapter_progression_get_input_type::ChapterProgressionGetInput; @@ -518,6 +564,7 @@ pub use custom_world_draft_card_detail_result_type::CustomWorldDraftCardDetailRe pub use custom_world_draft_card_detail_section_snapshot_type::CustomWorldDraftCardDetailSectionSnapshot; pub use custom_world_draft_card_detail_snapshot_type::CustomWorldDraftCardDetailSnapshot; pub use custom_world_draft_card_snapshot_type::CustomWorldDraftCardSnapshot; +pub use custom_world_gallery_detail_by_code_input_type::CustomWorldGalleryDetailByCodeInput; pub use custom_world_gallery_detail_input_type::CustomWorldGalleryDetailInput; pub use custom_world_gallery_entry_type::CustomWorldGalleryEntry; pub use custom_world_gallery_entry_snapshot_type::CustomWorldGalleryEntrySnapshot; @@ -581,6 +628,7 @@ pub use profile_dashboard_state_type::ProfileDashboardState; pub use profile_played_world_type::ProfilePlayedWorld; pub use profile_save_archive_type::ProfileSaveArchive; pub use profile_wallet_ledger_type::ProfileWalletLedger; +pub use puzzle_agent_message_finalize_input_type::PuzzleAgentMessageFinalizeInput; pub use puzzle_agent_message_kind_type::PuzzleAgentMessageKind; pub use puzzle_agent_message_role_type::PuzzleAgentMessageRole; pub use puzzle_agent_message_row_type::PuzzleAgentMessageRow; @@ -706,7 +754,44 @@ pub use treasure_record_snapshot_type::TreasureRecordSnapshot; pub use treasure_resolve_input_type::TreasureResolveInput; pub use unequip_inventory_item_input_type::UnequipInventoryItemInput; pub use user_browse_history_type::UserBrowseHistory; +pub use ai_result_reference_table::*; +pub use ai_task_table::*; +pub use ai_task_stage_table::*; +pub use ai_text_chunk_table::*; +pub use asset_entity_binding_table::*; +pub use asset_object_table::*; +pub use battle_state_table::*; +pub use big_fish_agent_message_table::*; +pub use big_fish_asset_slot_table::*; +pub use big_fish_creation_session_table::*; +pub use big_fish_runtime_run_table::*; +pub use chapter_progression_table::*; +pub use custom_world_agent_message_table::*; +pub use custom_world_agent_operation_table::*; +pub use custom_world_agent_session_table::*; +pub use custom_world_draft_card_table::*; pub use custom_world_gallery_entry_table::*; +pub use custom_world_profile_table::*; +pub use custom_world_session_table::*; +pub use inventory_slot_table::*; +pub use npc_state_table::*; +pub use player_progression_table::*; +pub use profile_dashboard_state_table::*; +pub use profile_played_world_table::*; +pub use profile_save_archive_table::*; +pub use profile_wallet_ledger_table::*; +pub use puzzle_agent_message_table::*; +pub use puzzle_agent_session_table::*; +pub use puzzle_runtime_run_table::*; +pub use puzzle_work_profile_table::*; +pub use quest_log_table::*; +pub use quest_record_table::*; +pub use runtime_setting_table::*; +pub use runtime_snapshot_table::*; +pub use story_event_table::*; +pub use story_session_table::*; +pub use treasure_record_table::*; +pub use user_browse_history_table::*; pub use accept_quest_reducer::accept_quest; pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion; pub use apply_chapter_progression_ledger_entry_reducer::apply_chapter_progression_ledger_entry; @@ -757,6 +842,7 @@ pub use drag_puzzle_piece_or_group_procedure::drag_puzzle_piece_or_group; pub use execute_custom_world_agent_action_procedure::execute_custom_world_agent_action; pub use fail_ai_task_and_return_procedure::fail_ai_task_and_return; pub use finalize_custom_world_agent_message_turn_procedure::finalize_custom_world_agent_message_turn; +pub use finalize_puzzle_agent_message_turn_procedure::finalize_puzzle_agent_message_turn; pub use generate_big_fish_asset_procedure::generate_big_fish_asset; pub use get_battle_state_procedure::get_battle_state; pub use get_big_fish_run_procedure::get_big_fish_run; @@ -766,6 +852,7 @@ pub use get_custom_world_agent_card_detail_procedure::get_custom_world_agent_car pub use get_custom_world_agent_operation_procedure::get_custom_world_agent_operation; pub use get_custom_world_agent_session_procedure::get_custom_world_agent_session; pub use get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail; +pub use get_custom_world_gallery_detail_by_code_procedure::get_custom_world_gallery_detail_by_code; pub use get_custom_world_library_detail_procedure::get_custom_world_library_detail; pub use get_player_progression_or_default_procedure::get_player_progression_or_default; pub use get_profile_dashboard_procedure::get_profile_dashboard; @@ -779,6 +866,7 @@ pub use get_runtime_setting_or_default_procedure::get_runtime_setting_or_default pub use get_runtime_snapshot_procedure::get_runtime_snapshot; pub use get_story_session_state_procedure::get_story_session_state; pub use grant_player_progression_experience_and_return_procedure::grant_player_progression_experience_and_return; +pub use list_big_fish_works_procedure::list_big_fish_works; pub use list_custom_world_gallery_entries_procedure::list_custom_world_gallery_entries; pub use list_custom_world_profiles_procedure::list_custom_world_profiles; pub use list_custom_world_works_procedure::list_custom_world_works; @@ -1064,7 +1152,44 @@ fn args_bsatn(&self) -> Result, __sats::bsatn::EncodeError> { #[allow(non_snake_case)] #[doc(hidden)] pub struct DbUpdate { - custom_world_gallery_entry: __sdk::TableUpdate, + ai_result_reference: __sdk::TableUpdate, + ai_task: __sdk::TableUpdate, + ai_task_stage: __sdk::TableUpdate, + ai_text_chunk: __sdk::TableUpdate, + asset_entity_binding: __sdk::TableUpdate, + asset_object: __sdk::TableUpdate, + battle_state: __sdk::TableUpdate, + big_fish_agent_message: __sdk::TableUpdate, + big_fish_asset_slot: __sdk::TableUpdate, + big_fish_creation_session: __sdk::TableUpdate, + big_fish_runtime_run: __sdk::TableUpdate, + chapter_progression: __sdk::TableUpdate, + custom_world_agent_message: __sdk::TableUpdate, + custom_world_agent_operation: __sdk::TableUpdate, + custom_world_agent_session: __sdk::TableUpdate, + custom_world_draft_card: __sdk::TableUpdate, + custom_world_gallery_entry: __sdk::TableUpdate, + custom_world_profile: __sdk::TableUpdate, + custom_world_session: __sdk::TableUpdate, + inventory_slot: __sdk::TableUpdate, + npc_state: __sdk::TableUpdate, + player_progression: __sdk::TableUpdate, + profile_dashboard_state: __sdk::TableUpdate, + profile_played_world: __sdk::TableUpdate, + profile_save_archive: __sdk::TableUpdate, + profile_wallet_ledger: __sdk::TableUpdate, + puzzle_agent_message: __sdk::TableUpdate, + puzzle_agent_session: __sdk::TableUpdate, + puzzle_runtime_run: __sdk::TableUpdate, + puzzle_work_profile: __sdk::TableUpdate, + quest_log: __sdk::TableUpdate, + quest_record: __sdk::TableUpdate, + runtime_setting: __sdk::TableUpdate, + runtime_snapshot: __sdk::TableUpdate, + story_event: __sdk::TableUpdate, + story_session: __sdk::TableUpdate, + treasure_record: __sdk::TableUpdate, + user_browse_history: __sdk::TableUpdate, } @@ -1075,7 +1200,44 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { for table_update in __sdk::transaction_update_iter_table_updates(raw) { match &table_update.table_name[..] { - "custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(custom_world_gallery_entry_table::parse_table_update(table_update)?), + "ai_result_reference" => db_update.ai_result_reference.append(ai_result_reference_table::parse_table_update(table_update)?), + "ai_task" => db_update.ai_task.append(ai_task_table::parse_table_update(table_update)?), + "ai_task_stage" => db_update.ai_task_stage.append(ai_task_stage_table::parse_table_update(table_update)?), + "ai_text_chunk" => db_update.ai_text_chunk.append(ai_text_chunk_table::parse_table_update(table_update)?), + "asset_entity_binding" => db_update.asset_entity_binding.append(asset_entity_binding_table::parse_table_update(table_update)?), + "asset_object" => db_update.asset_object.append(asset_object_table::parse_table_update(table_update)?), + "battle_state" => db_update.battle_state.append(battle_state_table::parse_table_update(table_update)?), + "big_fish_agent_message" => db_update.big_fish_agent_message.append(big_fish_agent_message_table::parse_table_update(table_update)?), + "big_fish_asset_slot" => db_update.big_fish_asset_slot.append(big_fish_asset_slot_table::parse_table_update(table_update)?), + "big_fish_creation_session" => db_update.big_fish_creation_session.append(big_fish_creation_session_table::parse_table_update(table_update)?), + "big_fish_runtime_run" => db_update.big_fish_runtime_run.append(big_fish_runtime_run_table::parse_table_update(table_update)?), + "chapter_progression" => db_update.chapter_progression.append(chapter_progression_table::parse_table_update(table_update)?), + "custom_world_agent_message" => db_update.custom_world_agent_message.append(custom_world_agent_message_table::parse_table_update(table_update)?), + "custom_world_agent_operation" => db_update.custom_world_agent_operation.append(custom_world_agent_operation_table::parse_table_update(table_update)?), + "custom_world_agent_session" => db_update.custom_world_agent_session.append(custom_world_agent_session_table::parse_table_update(table_update)?), + "custom_world_draft_card" => db_update.custom_world_draft_card.append(custom_world_draft_card_table::parse_table_update(table_update)?), + "custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(custom_world_gallery_entry_table::parse_table_update(table_update)?), + "custom_world_profile" => db_update.custom_world_profile.append(custom_world_profile_table::parse_table_update(table_update)?), + "custom_world_session" => db_update.custom_world_session.append(custom_world_session_table::parse_table_update(table_update)?), + "inventory_slot" => db_update.inventory_slot.append(inventory_slot_table::parse_table_update(table_update)?), + "npc_state" => db_update.npc_state.append(npc_state_table::parse_table_update(table_update)?), + "player_progression" => db_update.player_progression.append(player_progression_table::parse_table_update(table_update)?), + "profile_dashboard_state" => db_update.profile_dashboard_state.append(profile_dashboard_state_table::parse_table_update(table_update)?), + "profile_played_world" => db_update.profile_played_world.append(profile_played_world_table::parse_table_update(table_update)?), + "profile_save_archive" => db_update.profile_save_archive.append(profile_save_archive_table::parse_table_update(table_update)?), + "profile_wallet_ledger" => db_update.profile_wallet_ledger.append(profile_wallet_ledger_table::parse_table_update(table_update)?), + "puzzle_agent_message" => db_update.puzzle_agent_message.append(puzzle_agent_message_table::parse_table_update(table_update)?), + "puzzle_agent_session" => db_update.puzzle_agent_session.append(puzzle_agent_session_table::parse_table_update(table_update)?), + "puzzle_runtime_run" => db_update.puzzle_runtime_run.append(puzzle_runtime_run_table::parse_table_update(table_update)?), + "puzzle_work_profile" => db_update.puzzle_work_profile.append(puzzle_work_profile_table::parse_table_update(table_update)?), + "quest_log" => db_update.quest_log.append(quest_log_table::parse_table_update(table_update)?), + "quest_record" => db_update.quest_record.append(quest_record_table::parse_table_update(table_update)?), + "runtime_setting" => db_update.runtime_setting.append(runtime_setting_table::parse_table_update(table_update)?), + "runtime_snapshot" => db_update.runtime_snapshot.append(runtime_snapshot_table::parse_table_update(table_update)?), + "story_event" => db_update.story_event.append(story_event_table::parse_table_update(table_update)?), + "story_session" => db_update.story_session.append(story_session_table::parse_table_update(table_update)?), + "treasure_record" => db_update.treasure_record.append(treasure_record_table::parse_table_update(table_update)?), + "user_browse_history" => db_update.user_browse_history.append(user_browse_history_table::parse_table_update(table_update)?), unknown => { return Err(__sdk::InternalError::unknown_name( @@ -1098,7 +1260,44 @@ impl __sdk::DbUpdate for DbUpdate { fn apply_to_client_cache(&self, cache: &mut __sdk::ClientCache) -> AppliedDiff<'_> { let mut diff = AppliedDiff::default(); - diff.custom_world_gallery_entry = cache.apply_diff_to_table::("custom_world_gallery_entry", &self.custom_world_gallery_entry).with_updates_by_pk(|row| &row.profile_id); + diff.ai_result_reference = cache.apply_diff_to_table::("ai_result_reference", &self.ai_result_reference).with_updates_by_pk(|row| &row.result_reference_row_id); + diff.ai_task = cache.apply_diff_to_table::("ai_task", &self.ai_task).with_updates_by_pk(|row| &row.task_id); + diff.ai_task_stage = cache.apply_diff_to_table::("ai_task_stage", &self.ai_task_stage).with_updates_by_pk(|row| &row.task_stage_id); + diff.ai_text_chunk = cache.apply_diff_to_table::("ai_text_chunk", &self.ai_text_chunk).with_updates_by_pk(|row| &row.text_chunk_row_id); + diff.asset_entity_binding = cache.apply_diff_to_table::("asset_entity_binding", &self.asset_entity_binding).with_updates_by_pk(|row| &row.binding_id); + diff.asset_object = cache.apply_diff_to_table::("asset_object", &self.asset_object).with_updates_by_pk(|row| &row.asset_object_id); + diff.battle_state = cache.apply_diff_to_table::("battle_state", &self.battle_state).with_updates_by_pk(|row| &row.battle_state_id); + diff.big_fish_agent_message = cache.apply_diff_to_table::("big_fish_agent_message", &self.big_fish_agent_message).with_updates_by_pk(|row| &row.message_id); + diff.big_fish_asset_slot = cache.apply_diff_to_table::("big_fish_asset_slot", &self.big_fish_asset_slot).with_updates_by_pk(|row| &row.slot_id); + diff.big_fish_creation_session = cache.apply_diff_to_table::("big_fish_creation_session", &self.big_fish_creation_session).with_updates_by_pk(|row| &row.session_id); + diff.big_fish_runtime_run = cache.apply_diff_to_table::("big_fish_runtime_run", &self.big_fish_runtime_run).with_updates_by_pk(|row| &row.run_id); + diff.chapter_progression = cache.apply_diff_to_table::("chapter_progression", &self.chapter_progression).with_updates_by_pk(|row| &row.chapter_progression_id); + diff.custom_world_agent_message = cache.apply_diff_to_table::("custom_world_agent_message", &self.custom_world_agent_message).with_updates_by_pk(|row| &row.message_id); + diff.custom_world_agent_operation = cache.apply_diff_to_table::("custom_world_agent_operation", &self.custom_world_agent_operation).with_updates_by_pk(|row| &row.operation_id); + diff.custom_world_agent_session = cache.apply_diff_to_table::("custom_world_agent_session", &self.custom_world_agent_session).with_updates_by_pk(|row| &row.session_id); + diff.custom_world_draft_card = cache.apply_diff_to_table::("custom_world_draft_card", &self.custom_world_draft_card).with_updates_by_pk(|row| &row.card_id); + diff.custom_world_gallery_entry = cache.apply_diff_to_table::("custom_world_gallery_entry", &self.custom_world_gallery_entry).with_updates_by_pk(|row| &row.profile_id); + diff.custom_world_profile = cache.apply_diff_to_table::("custom_world_profile", &self.custom_world_profile).with_updates_by_pk(|row| &row.profile_id); + diff.custom_world_session = cache.apply_diff_to_table::("custom_world_session", &self.custom_world_session).with_updates_by_pk(|row| &row.session_id); + diff.inventory_slot = cache.apply_diff_to_table::("inventory_slot", &self.inventory_slot).with_updates_by_pk(|row| &row.slot_id); + diff.npc_state = cache.apply_diff_to_table::("npc_state", &self.npc_state).with_updates_by_pk(|row| &row.npc_state_id); + diff.player_progression = cache.apply_diff_to_table::("player_progression", &self.player_progression).with_updates_by_pk(|row| &row.user_id); + diff.profile_dashboard_state = cache.apply_diff_to_table::("profile_dashboard_state", &self.profile_dashboard_state).with_updates_by_pk(|row| &row.user_id); + diff.profile_played_world = cache.apply_diff_to_table::("profile_played_world", &self.profile_played_world).with_updates_by_pk(|row| &row.played_world_id); + diff.profile_save_archive = cache.apply_diff_to_table::("profile_save_archive", &self.profile_save_archive).with_updates_by_pk(|row| &row.archive_id); + diff.profile_wallet_ledger = cache.apply_diff_to_table::("profile_wallet_ledger", &self.profile_wallet_ledger).with_updates_by_pk(|row| &row.wallet_ledger_id); + diff.puzzle_agent_message = cache.apply_diff_to_table::("puzzle_agent_message", &self.puzzle_agent_message).with_updates_by_pk(|row| &row.message_id); + diff.puzzle_agent_session = cache.apply_diff_to_table::("puzzle_agent_session", &self.puzzle_agent_session).with_updates_by_pk(|row| &row.session_id); + diff.puzzle_runtime_run = cache.apply_diff_to_table::("puzzle_runtime_run", &self.puzzle_runtime_run).with_updates_by_pk(|row| &row.run_id); + diff.puzzle_work_profile = cache.apply_diff_to_table::("puzzle_work_profile", &self.puzzle_work_profile).with_updates_by_pk(|row| &row.profile_id); + diff.quest_log = cache.apply_diff_to_table::("quest_log", &self.quest_log).with_updates_by_pk(|row| &row.log_id); + diff.quest_record = cache.apply_diff_to_table::("quest_record", &self.quest_record).with_updates_by_pk(|row| &row.quest_id); + diff.runtime_setting = cache.apply_diff_to_table::("runtime_setting", &self.runtime_setting).with_updates_by_pk(|row| &row.user_id); + diff.runtime_snapshot = cache.apply_diff_to_table::("runtime_snapshot", &self.runtime_snapshot).with_updates_by_pk(|row| &row.user_id); + diff.story_event = cache.apply_diff_to_table::("story_event", &self.story_event).with_updates_by_pk(|row| &row.event_id); + diff.story_session = cache.apply_diff_to_table::("story_session", &self.story_session).with_updates_by_pk(|row| &row.story_session_id); + diff.treasure_record = cache.apply_diff_to_table::("treasure_record", &self.treasure_record).with_updates_by_pk(|row| &row.treasure_record_id); + diff.user_browse_history = cache.apply_diff_to_table::("user_browse_history", &self.user_browse_history).with_updates_by_pk(|row| &row.browse_history_id); diff } @@ -1106,7 +1305,44 @@ fn parse_initial_rows(raw: __ws::v2::QueryRows) -> __sdk::Result { let mut db_update = DbUpdate::default(); for table_rows in raw.tables { match &table_rows.table[..] { - "custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "ai_result_reference" => db_update.ai_result_reference.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "ai_task" => db_update.ai_task.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "ai_task_stage" => db_update.ai_task_stage.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "ai_text_chunk" => db_update.ai_text_chunk.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "asset_entity_binding" => db_update.asset_entity_binding.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "asset_object" => db_update.asset_object.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "battle_state" => db_update.battle_state.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "big_fish_agent_message" => db_update.big_fish_agent_message.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "big_fish_asset_slot" => db_update.big_fish_asset_slot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "big_fish_creation_session" => db_update.big_fish_creation_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "big_fish_runtime_run" => db_update.big_fish_runtime_run.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "chapter_progression" => db_update.chapter_progression.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "custom_world_agent_message" => db_update.custom_world_agent_message.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "custom_world_agent_operation" => db_update.custom_world_agent_operation.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "custom_world_agent_session" => db_update.custom_world_agent_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "custom_world_draft_card" => db_update.custom_world_draft_card.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "custom_world_profile" => db_update.custom_world_profile.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "custom_world_session" => db_update.custom_world_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "inventory_slot" => db_update.inventory_slot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "npc_state" => db_update.npc_state.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "player_progression" => db_update.player_progression.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_dashboard_state" => db_update.profile_dashboard_state.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_played_world" => db_update.profile_played_world.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_save_archive" => db_update.profile_save_archive.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_wallet_ledger" => db_update.profile_wallet_ledger.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "puzzle_agent_message" => db_update.puzzle_agent_message.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "puzzle_agent_session" => db_update.puzzle_agent_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "puzzle_runtime_run" => db_update.puzzle_runtime_run.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "puzzle_work_profile" => db_update.puzzle_work_profile.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "quest_log" => db_update.quest_log.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "quest_record" => db_update.quest_record.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "runtime_setting" => db_update.runtime_setting.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "runtime_snapshot" => db_update.runtime_snapshot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "story_event" => db_update.story_event.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "story_session" => db_update.story_session.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "treasure_record" => db_update.treasure_record.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "user_browse_history" => db_update.user_browse_history.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), unknown => { return Err(__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into()); } }} Ok(db_update) } @@ -1114,7 +1350,44 @@ fn parse_unsubscribe_rows(raw: __ws::v2::QueryRows) -> __sdk::Result { let mut db_update = DbUpdate::default(); for table_rows in raw.tables { match &table_rows.table[..] { - "custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "ai_result_reference" => db_update.ai_result_reference.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "ai_task" => db_update.ai_task.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "ai_task_stage" => db_update.ai_task_stage.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "ai_text_chunk" => db_update.ai_text_chunk.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "asset_entity_binding" => db_update.asset_entity_binding.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "asset_object" => db_update.asset_object.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "battle_state" => db_update.battle_state.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "big_fish_agent_message" => db_update.big_fish_agent_message.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "big_fish_asset_slot" => db_update.big_fish_asset_slot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "big_fish_creation_session" => db_update.big_fish_creation_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "big_fish_runtime_run" => db_update.big_fish_runtime_run.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "chapter_progression" => db_update.chapter_progression.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "custom_world_agent_message" => db_update.custom_world_agent_message.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "custom_world_agent_operation" => db_update.custom_world_agent_operation.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "custom_world_agent_session" => db_update.custom_world_agent_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "custom_world_draft_card" => db_update.custom_world_draft_card.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "custom_world_gallery_entry" => db_update.custom_world_gallery_entry.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "custom_world_profile" => db_update.custom_world_profile.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "custom_world_session" => db_update.custom_world_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "inventory_slot" => db_update.inventory_slot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "npc_state" => db_update.npc_state.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "player_progression" => db_update.player_progression.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_dashboard_state" => db_update.profile_dashboard_state.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_played_world" => db_update.profile_played_world.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_save_archive" => db_update.profile_save_archive.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_wallet_ledger" => db_update.profile_wallet_ledger.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "puzzle_agent_message" => db_update.puzzle_agent_message.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "puzzle_agent_session" => db_update.puzzle_agent_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "puzzle_runtime_run" => db_update.puzzle_runtime_run.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "puzzle_work_profile" => db_update.puzzle_work_profile.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "quest_log" => db_update.quest_log.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "quest_record" => db_update.quest_record.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "runtime_setting" => db_update.runtime_setting.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "runtime_snapshot" => db_update.runtime_snapshot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "story_event" => db_update.story_event.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "story_session" => db_update.story_session.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "treasure_record" => db_update.treasure_record.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "user_browse_history" => db_update.user_browse_history.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), unknown => { return Err(__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into()); } }} Ok(db_update) } @@ -1124,7 +1397,44 @@ for table_rows in raw.tables { #[allow(non_snake_case)] #[doc(hidden)] pub struct AppliedDiff<'r> { - custom_world_gallery_entry: __sdk::TableAppliedDiff<'r, CustomWorldGalleryEntry>, + ai_result_reference: __sdk::TableAppliedDiff<'r, AiResultReference>, + ai_task: __sdk::TableAppliedDiff<'r, AiTask>, + ai_task_stage: __sdk::TableAppliedDiff<'r, AiTaskStage>, + ai_text_chunk: __sdk::TableAppliedDiff<'r, AiTextChunk>, + asset_entity_binding: __sdk::TableAppliedDiff<'r, AssetEntityBinding>, + asset_object: __sdk::TableAppliedDiff<'r, AssetObject>, + battle_state: __sdk::TableAppliedDiff<'r, BattleState>, + big_fish_agent_message: __sdk::TableAppliedDiff<'r, BigFishAgentMessage>, + big_fish_asset_slot: __sdk::TableAppliedDiff<'r, BigFishAssetSlot>, + big_fish_creation_session: __sdk::TableAppliedDiff<'r, BigFishCreationSession>, + big_fish_runtime_run: __sdk::TableAppliedDiff<'r, BigFishRuntimeRun>, + chapter_progression: __sdk::TableAppliedDiff<'r, ChapterProgression>, + custom_world_agent_message: __sdk::TableAppliedDiff<'r, CustomWorldAgentMessage>, + custom_world_agent_operation: __sdk::TableAppliedDiff<'r, CustomWorldAgentOperation>, + custom_world_agent_session: __sdk::TableAppliedDiff<'r, CustomWorldAgentSession>, + custom_world_draft_card: __sdk::TableAppliedDiff<'r, CustomWorldDraftCard>, + custom_world_gallery_entry: __sdk::TableAppliedDiff<'r, CustomWorldGalleryEntry>, + custom_world_profile: __sdk::TableAppliedDiff<'r, CustomWorldProfile>, + custom_world_session: __sdk::TableAppliedDiff<'r, CustomWorldSession>, + inventory_slot: __sdk::TableAppliedDiff<'r, InventorySlot>, + npc_state: __sdk::TableAppliedDiff<'r, NpcState>, + player_progression: __sdk::TableAppliedDiff<'r, PlayerProgression>, + profile_dashboard_state: __sdk::TableAppliedDiff<'r, ProfileDashboardState>, + profile_played_world: __sdk::TableAppliedDiff<'r, ProfilePlayedWorld>, + profile_save_archive: __sdk::TableAppliedDiff<'r, ProfileSaveArchive>, + profile_wallet_ledger: __sdk::TableAppliedDiff<'r, ProfileWalletLedger>, + puzzle_agent_message: __sdk::TableAppliedDiff<'r, PuzzleAgentMessageRow>, + puzzle_agent_session: __sdk::TableAppliedDiff<'r, PuzzleAgentSessionRow>, + puzzle_runtime_run: __sdk::TableAppliedDiff<'r, PuzzleRuntimeRunRow>, + puzzle_work_profile: __sdk::TableAppliedDiff<'r, PuzzleWorkProfileRow>, + quest_log: __sdk::TableAppliedDiff<'r, QuestLog>, + quest_record: __sdk::TableAppliedDiff<'r, QuestRecord>, + runtime_setting: __sdk::TableAppliedDiff<'r, RuntimeSetting>, + runtime_snapshot: __sdk::TableAppliedDiff<'r, RuntimeSnapshotRow>, + story_event: __sdk::TableAppliedDiff<'r, StoryEvent>, + story_session: __sdk::TableAppliedDiff<'r, StorySession>, + treasure_record: __sdk::TableAppliedDiff<'r, TreasureRecord>, + user_browse_history: __sdk::TableAppliedDiff<'r, UserBrowseHistory>, __unused: std::marker::PhantomData<&'r ()>, } @@ -1135,7 +1445,44 @@ impl __sdk::InModule for AppliedDiff<'_> { impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { fn invoke_row_callbacks(&self, event: &EventContext, callbacks: &mut __sdk::DbCallbacks) { - callbacks.invoke_table_row_callbacks::("custom_world_gallery_entry", &self.custom_world_gallery_entry, event); + callbacks.invoke_table_row_callbacks::("ai_result_reference", &self.ai_result_reference, event); + callbacks.invoke_table_row_callbacks::("ai_task", &self.ai_task, event); + callbacks.invoke_table_row_callbacks::("ai_task_stage", &self.ai_task_stage, event); + callbacks.invoke_table_row_callbacks::("ai_text_chunk", &self.ai_text_chunk, event); + callbacks.invoke_table_row_callbacks::("asset_entity_binding", &self.asset_entity_binding, event); + callbacks.invoke_table_row_callbacks::("asset_object", &self.asset_object, event); + callbacks.invoke_table_row_callbacks::("battle_state", &self.battle_state, event); + callbacks.invoke_table_row_callbacks::("big_fish_agent_message", &self.big_fish_agent_message, event); + callbacks.invoke_table_row_callbacks::("big_fish_asset_slot", &self.big_fish_asset_slot, event); + callbacks.invoke_table_row_callbacks::("big_fish_creation_session", &self.big_fish_creation_session, event); + callbacks.invoke_table_row_callbacks::("big_fish_runtime_run", &self.big_fish_runtime_run, event); + callbacks.invoke_table_row_callbacks::("chapter_progression", &self.chapter_progression, event); + callbacks.invoke_table_row_callbacks::("custom_world_agent_message", &self.custom_world_agent_message, event); + callbacks.invoke_table_row_callbacks::("custom_world_agent_operation", &self.custom_world_agent_operation, event); + callbacks.invoke_table_row_callbacks::("custom_world_agent_session", &self.custom_world_agent_session, event); + callbacks.invoke_table_row_callbacks::("custom_world_draft_card", &self.custom_world_draft_card, event); + callbacks.invoke_table_row_callbacks::("custom_world_gallery_entry", &self.custom_world_gallery_entry, event); + callbacks.invoke_table_row_callbacks::("custom_world_profile", &self.custom_world_profile, event); + callbacks.invoke_table_row_callbacks::("custom_world_session", &self.custom_world_session, event); + callbacks.invoke_table_row_callbacks::("inventory_slot", &self.inventory_slot, event); + callbacks.invoke_table_row_callbacks::("npc_state", &self.npc_state, event); + callbacks.invoke_table_row_callbacks::("player_progression", &self.player_progression, event); + callbacks.invoke_table_row_callbacks::("profile_dashboard_state", &self.profile_dashboard_state, event); + callbacks.invoke_table_row_callbacks::("profile_played_world", &self.profile_played_world, event); + callbacks.invoke_table_row_callbacks::("profile_save_archive", &self.profile_save_archive, event); + callbacks.invoke_table_row_callbacks::("profile_wallet_ledger", &self.profile_wallet_ledger, event); + callbacks.invoke_table_row_callbacks::("puzzle_agent_message", &self.puzzle_agent_message, event); + callbacks.invoke_table_row_callbacks::("puzzle_agent_session", &self.puzzle_agent_session, event); + callbacks.invoke_table_row_callbacks::("puzzle_runtime_run", &self.puzzle_runtime_run, event); + callbacks.invoke_table_row_callbacks::("puzzle_work_profile", &self.puzzle_work_profile, event); + callbacks.invoke_table_row_callbacks::("quest_log", &self.quest_log, event); + callbacks.invoke_table_row_callbacks::("quest_record", &self.quest_record, event); + callbacks.invoke_table_row_callbacks::("runtime_setting", &self.runtime_setting, event); + callbacks.invoke_table_row_callbacks::("runtime_snapshot", &self.runtime_snapshot, event); + callbacks.invoke_table_row_callbacks::("story_event", &self.story_event, event); + callbacks.invoke_table_row_callbacks::("story_session", &self.story_session, event); + callbacks.invoke_table_row_callbacks::("treasure_record", &self.treasure_record, event); + callbacks.invoke_table_row_callbacks::("user_browse_history", &self.user_browse_history, event); } } @@ -1787,9 +2134,83 @@ impl __sdk::SpacetimeModule for RemoteModule { type QueryBuilder = __sdk::QueryBuilder; fn register_tables(client_cache: &mut __sdk::ClientCache) { - custom_world_gallery_entry_table::register_table(client_cache); + ai_result_reference_table::register_table(client_cache); + ai_task_table::register_table(client_cache); + ai_task_stage_table::register_table(client_cache); + ai_text_chunk_table::register_table(client_cache); + asset_entity_binding_table::register_table(client_cache); + asset_object_table::register_table(client_cache); + battle_state_table::register_table(client_cache); + big_fish_agent_message_table::register_table(client_cache); + big_fish_asset_slot_table::register_table(client_cache); + big_fish_creation_session_table::register_table(client_cache); + big_fish_runtime_run_table::register_table(client_cache); + chapter_progression_table::register_table(client_cache); + custom_world_agent_message_table::register_table(client_cache); + custom_world_agent_operation_table::register_table(client_cache); + custom_world_agent_session_table::register_table(client_cache); + custom_world_draft_card_table::register_table(client_cache); + custom_world_gallery_entry_table::register_table(client_cache); + custom_world_profile_table::register_table(client_cache); + custom_world_session_table::register_table(client_cache); + inventory_slot_table::register_table(client_cache); + npc_state_table::register_table(client_cache); + player_progression_table::register_table(client_cache); + profile_dashboard_state_table::register_table(client_cache); + profile_played_world_table::register_table(client_cache); + profile_save_archive_table::register_table(client_cache); + profile_wallet_ledger_table::register_table(client_cache); + puzzle_agent_message_table::register_table(client_cache); + puzzle_agent_session_table::register_table(client_cache); + puzzle_runtime_run_table::register_table(client_cache); + puzzle_work_profile_table::register_table(client_cache); + quest_log_table::register_table(client_cache); + quest_record_table::register_table(client_cache); + runtime_setting_table::register_table(client_cache); + runtime_snapshot_table::register_table(client_cache); + story_event_table::register_table(client_cache); + story_session_table::register_table(client_cache); + treasure_record_table::register_table(client_cache); + user_browse_history_table::register_table(client_cache); } const ALL_TABLE_NAMES: &'static [&'static str] = &[ - "custom_world_gallery_entry", + "ai_result_reference", + "ai_task", + "ai_task_stage", + "ai_text_chunk", + "asset_entity_binding", + "asset_object", + "battle_state", + "big_fish_agent_message", + "big_fish_asset_slot", + "big_fish_creation_session", + "big_fish_runtime_run", + "chapter_progression", + "custom_world_agent_message", + "custom_world_agent_operation", + "custom_world_agent_session", + "custom_world_draft_card", + "custom_world_gallery_entry", + "custom_world_profile", + "custom_world_session", + "inventory_slot", + "npc_state", + "player_progression", + "profile_dashboard_state", + "profile_played_world", + "profile_save_archive", + "profile_wallet_ledger", + "puzzle_agent_message", + "puzzle_agent_session", + "puzzle_runtime_run", + "puzzle_work_profile", + "quest_log", + "quest_record", + "runtime_setting", + "runtime_snapshot", + "story_event", + "story_session", + "treasure_record", + "user_browse_history", ]; } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_agent_message_finalize_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_agent_message_finalize_input_type.rs new file mode 100644 index 00000000..22dfb32e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_agent_message_finalize_input_type.rs @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::puzzle_agent_stage_type::PuzzleAgentStage; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleAgentMessageFinalizeInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option::, + pub assistant_reply_text: Option::, + pub stage: PuzzleAgentStage, + pub progress_percent: u32, + pub anchor_pack_json: String, + pub error_message: Option::, + pub updated_at_micros: i64, +} + + +impl __sdk::InModule for PuzzleAgentMessageFinalizeInput { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-module/README.md b/server-rs/crates/spacetime-module/README.md index eb07e874..d87a0411 100644 --- a/server-rs/crates/spacetime-module/README.md +++ b/server-rs/crates/spacetime-module/README.md @@ -55,6 +55,76 @@ - `cancel_ai_task_and_return` 18. `turn_in_quest` 与 `resolve_combat_action(Victory)` 到 `player_progression / chapter_progression` 的最小经验联动 +## 2.1 `src/lib.rs` 拆分路由规则 + +从 `2026-04-23` 起,`src/lib.rs` 不再允许继续承载具体业务域的 table / reducer / procedure / tx helper。 + +根入口后续只允许保留: + +1. `use` 聚合 +2. `mod` 声明 +3. 少量跨域共享 helper +4. 迁移过渡期测试 + +根入口与子模块的导入导出规则同步冻结为: + +1. `src/lib.rs` 对外统一优先使用 `pub use xxx::*;` 重新导出模块内容 +2. 已拆业务模块内部统一优先使用 `use crate::*;` 复用主入口已聚合的类型与函数 +3. 只有当 `use crate::*;` 无法覆盖或会引入明显歧义时,才补局部显式 `use` +4. 新增业务域内容禁止为了堆 `use` 列表再回写到 `src/lib.rs` + +具体内容必须落到下面的模块: + +1. `src/entry.rs` + - SpacetimeDB `init` 入口 +2. `src/domain_types.rs` + - 跨域共享的 SpacetimeDB 类型 +3. `src/asset_metadata/` + - 资产对象与资产绑定真相表 +4. `src/big_fish/` + - Big Fish 创作与运行态 +5. `src/runtime/` + - runtime setting / snapshot / browse history / profile 投影 +6. `src/gameplay/` + - `story / combat / inventory / npc / quest / runtime_item / progression` +7. `src/custom_world/` + - custom world profile / session / agent / publishing / gallery / works +8. `src/ai/` + - ai task / stage / chunk / result reference +9. `src/puzzle.rs` + - 拼图玩法当前仍为单文件域模块 + +### 已冻结的二级模块落位点 + +1. `src/asset_metadata/objects.rs` +2. `src/asset_metadata/bindings.rs` +3. `src/big_fish/tables.rs` +4. `src/big_fish/session.rs` +5. `src/big_fish/assets.rs` +6. `src/big_fish/runtime.rs` +7. `src/runtime/settings.rs` +8. `src/runtime/snapshots.rs` +9. `src/runtime/browse_history.rs` +10. `src/runtime/profile.rs` +11. `src/gameplay/combat.rs` +12. `src/gameplay/inventory.rs` +13. `src/gameplay/npc.rs` +14. `src/gameplay/progression.rs` +15. `src/gameplay/quest.rs` +16. `src/gameplay/runtime_item.rs` +17. `src/gameplay/story.rs` +18. `src/custom_world/profile.rs` +19. `src/custom_world/session.rs` +20. `src/custom_world/agent.rs` +21. `src/custom_world/publishing.rs` +22. `src/custom_world/gallery.rs` +23. `src/custom_world/works.rs` +24. `src/ai/tasks.rs` +25. `src/ai/stages.rs` +26. `src/ai/snapshots.rs` + +后续如果新增 SpacetimeDB 表、reducer、procedure 或同域 helper,必须先判断属于哪个一级模块与二级落位点,再写入对应文件;禁止直接追加到 `src/lib.rs`。 + `asset_object` 的详细设计见: 1. [../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md) diff --git a/server-rs/crates/spacetime-module/src/ai/snapshots.rs b/server-rs/crates/spacetime-module/src/ai/snapshots.rs new file mode 100644 index 00000000..4ecf75e6 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/ai/snapshots.rs @@ -0,0 +1 @@ +// AI snapshot / row 转换 helper 落位点。 diff --git a/server-rs/crates/spacetime-module/src/ai/stages.rs b/server-rs/crates/spacetime-module/src/ai/stages.rs new file mode 100644 index 00000000..0d91d412 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/ai/stages.rs @@ -0,0 +1 @@ +// AI stage、chunk、reference 与阶段级 helper 落位点。 diff --git a/server-rs/crates/spacetime-module/src/ai/tasks.rs b/server-rs/crates/spacetime-module/src/ai/tasks.rs new file mode 100644 index 00000000..084b05f6 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/ai/tasks.rs @@ -0,0 +1 @@ +// AI task reducer / procedure 与任务状态迁移落位点。 diff --git a/server-rs/crates/spacetime-module/src/asset_metadata/bindings.rs b/server-rs/crates/spacetime-module/src/asset_metadata/bindings.rs new file mode 100644 index 00000000..1b344028 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/asset_metadata/bindings.rs @@ -0,0 +1,142 @@ +use crate::*; + +#[spacetimedb::table( + accessor = asset_entity_binding, + index(accessor = by_entity_slot, btree(columns = [entity_kind, entity_id, slot])), + index(accessor = by_asset_object_id, btree(columns = [asset_object_id])) +)] +pub struct AssetEntityBinding { + #[primary_key] + binding_id: String, + asset_object_id: String, + entity_kind: String, + entity_id: String, + slot: String, + asset_kind: String, + owner_user_id: Option, + profile_id: Option, + created_at: Timestamp, + updated_at: Timestamp, +} + +// reducer 负责把已确认对象绑定到实体槽位,强业务资产表稳定前先用通用绑定表承接关系。 +#[spacetimedb::reducer] +pub fn bind_asset_object_to_entity( + ctx: &ReducerContext, + input: AssetEntityBindingInput, +) -> Result<(), String> { + upsert_asset_entity_binding(ctx, input).map(|_| ()) +} + +// procedure 面向 Axum 同步绑定接口,返回最终绑定快照,避免 HTTP 层读取 private table。 +#[spacetimedb::procedure] +pub fn bind_asset_object_to_entity_and_return( + ctx: &mut ProcedureContext, + input: AssetEntityBindingInput, +) -> AssetEntityBindingProcedureResult { + match ctx.try_with_tx(|tx| upsert_asset_entity_binding(tx, input.clone())) { + Ok(record) => AssetEntityBindingProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => AssetEntityBindingProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +fn upsert_asset_entity_binding( + ctx: &ReducerContext, + input: AssetEntityBindingInput, +) -> Result { + validate_asset_entity_binding_fields( + &input.binding_id, + &input.asset_object_id, + &input.entity_kind, + &input.entity_id, + &input.slot, + &input.asset_kind, + ) + .map_err(|error| error.to_string())?; + + if !has_asset_object(ctx, &input.asset_object_id) { + return Err("asset_entity_binding.asset_object_id 对应的 asset_object 不存在".to_string()); + } + + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + // 首版绑定按 entity_kind + entity_id + slot 幂等定位,后续访问量明确后再改为组合索引扫描。 + let current = ctx.db.asset_entity_binding().iter().find(|row| { + row.entity_kind == input.entity_kind + && row.entity_id == input.entity_id + && row.slot == input.slot + }); + + let snapshot = match current { + Some(existing) => { + ctx.db + .asset_entity_binding() + .binding_id() + .delete(&existing.binding_id); + let row = AssetEntityBinding { + binding_id: existing.binding_id.clone(), + asset_object_id: input.asset_object_id.clone(), + entity_kind: input.entity_kind.clone(), + entity_id: input.entity_id.clone(), + slot: input.slot.clone(), + asset_kind: input.asset_kind.clone(), + owner_user_id: input.owner_user_id.clone(), + profile_id: input.profile_id.clone(), + created_at: existing.created_at, + updated_at, + }; + ctx.db.asset_entity_binding().insert(row); + + AssetEntityBindingSnapshot { + binding_id: existing.binding_id, + asset_object_id: input.asset_object_id, + entity_kind: input.entity_kind, + entity_id: input.entity_id, + slot: input.slot, + asset_kind: input.asset_kind, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + created_at_micros: existing.created_at.to_micros_since_unix_epoch(), + updated_at_micros: input.updated_at_micros, + } + } + None => { + let created_at = updated_at; + let row = AssetEntityBinding { + binding_id: input.binding_id.clone(), + asset_object_id: input.asset_object_id.clone(), + entity_kind: input.entity_kind.clone(), + entity_id: input.entity_id.clone(), + slot: input.slot.clone(), + asset_kind: input.asset_kind.clone(), + owner_user_id: input.owner_user_id.clone(), + profile_id: input.profile_id.clone(), + created_at, + updated_at, + }; + ctx.db.asset_entity_binding().insert(row); + + AssetEntityBindingSnapshot { + binding_id: input.binding_id, + asset_object_id: input.asset_object_id, + entity_kind: input.entity_kind, + entity_id: input.entity_id, + slot: input.slot, + asset_kind: input.asset_kind, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + created_at_micros: input.updated_at_micros, + updated_at_micros: input.updated_at_micros, + } + } + }; + + Ok(snapshot) +} diff --git a/server-rs/crates/spacetime-module/src/asset_metadata/mod.rs b/server-rs/crates/spacetime-module/src/asset_metadata/mod.rs index 66353476..a12edc80 100644 --- a/server-rs/crates/spacetime-module/src/asset_metadata/mod.rs +++ b/server-rs/crates/spacetime-module/src/asset_metadata/mod.rs @@ -1,305 +1,5 @@ -#[spacetimedb::table( - accessor = asset_object, - index(accessor = by_bucket_object_key, btree(columns = [bucket, object_key])) -)] -pub struct AssetObject { - #[primary_key] - asset_object_id: String, - // 正式对象定位固定拆成 bucket + object_key 两列,避免后续再从单字符串路径做 schema 拆分。 - bucket: String, - object_key: String, - access_policy: AssetObjectAccessPolicy, - content_type: Option, - content_length: u64, - content_hash: Option, - version: u32, - source_job_id: Option, - owner_user_id: Option, - profile_id: Option, - entity_id: Option, - #[index(btree)] - asset_kind: String, - created_at: Timestamp, - updated_at: Timestamp, -} +mod bindings; +mod objects; -#[spacetimedb::table( - accessor = asset_entity_binding, - index(accessor = by_entity_slot, btree(columns = [entity_kind, entity_id, slot])), - index(accessor = by_asset_object_id, btree(columns = [asset_object_id])) -)] -pub struct AssetEntityBinding { - #[primary_key] - binding_id: String, - asset_object_id: String, - entity_kind: String, - entity_id: String, - slot: String, - asset_kind: String, - owner_user_id: Option, - profile_id: Option, - created_at: Timestamp, - updated_at: Timestamp, -} - -// reducer 负责固定资产对象的正式写规则,供后续内部模块逻辑复用。 -#[spacetimedb::reducer] -pub fn confirm_asset_object( - ctx: &ReducerContext, - input: AssetObjectUpsertInput, -) -> Result<(), String> { - upsert_asset_object(ctx, input).map(|_| ()) -} - -// procedure 面向 Axum 同步确认接口,返回最终持久化后的对象记录,避免 HTTP 层再额外查询 private table。 -#[spacetimedb::procedure] -pub fn confirm_asset_object_and_return( - ctx: &mut ProcedureContext, - input: AssetObjectUpsertInput, -) -> AssetObjectProcedureResult { - match ctx.try_with_tx(|tx| upsert_asset_object(tx, input.clone())) { - Ok(record) => AssetObjectProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => AssetObjectProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// reducer 负责把已确认对象绑定到实体槽位,强业务资产表稳定前先用通用绑定表承接关系。 -#[spacetimedb::reducer] -pub fn bind_asset_object_to_entity( - ctx: &ReducerContext, - input: AssetEntityBindingInput, -) -> Result<(), String> { - upsert_asset_entity_binding(ctx, input).map(|_| ()) -} - -// procedure 面向 Axum 同步绑定接口,返回最终绑定快照,避免 HTTP 层读取 private table。 -#[spacetimedb::procedure] -pub fn bind_asset_object_to_entity_and_return( - ctx: &mut ProcedureContext, - input: AssetEntityBindingInput, -) -> AssetEntityBindingProcedureResult { - match ctx.try_with_tx(|tx| upsert_asset_entity_binding(tx, input.clone())) { - Ok(record) => AssetEntityBindingProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => AssetEntityBindingProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} -fn upsert_asset_object( - ctx: &ReducerContext, - input: AssetObjectUpsertInput, -) -> Result { - validate_asset_object_fields( - &input.bucket, - &input.object_key, - &input.asset_kind, - input.version, - ) - .map_err(|error| error.to_string())?; - - let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); - // 这里先保持最小可发布实现:查重语义已经冻结,后续再把实现优化回组合索引扫描。 - let current = ctx - .db - .asset_object() - .iter() - .find(|row| row.bucket == input.bucket && row.object_key == input.object_key); - - let snapshot = match current { - Some(existing) => { - ctx.db - .asset_object() - .asset_object_id() - .delete(&existing.asset_object_id); - let row = AssetObject { - asset_object_id: existing.asset_object_id.clone(), - bucket: input.bucket.clone(), - object_key: input.object_key.clone(), - access_policy: input.access_policy, - content_type: input.content_type.clone(), - content_length: input.content_length, - content_hash: input.content_hash.clone(), - version: input.version, - source_job_id: input.source_job_id.clone(), - owner_user_id: input.owner_user_id.clone(), - profile_id: input.profile_id.clone(), - entity_id: input.entity_id.clone(), - asset_kind: input.asset_kind.clone(), - created_at: existing.created_at, - updated_at, - }; - ctx.db.asset_object().insert(row); - - AssetObjectUpsertSnapshot { - asset_object_id: existing.asset_object_id, - bucket: input.bucket, - object_key: input.object_key, - access_policy: input.access_policy, - content_type: input.content_type, - content_length: input.content_length, - content_hash: input.content_hash, - version: input.version, - source_job_id: input.source_job_id, - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - entity_id: input.entity_id, - asset_kind: input.asset_kind, - created_at_micros: existing.created_at.to_micros_since_unix_epoch(), - updated_at_micros: input.updated_at_micros, - } - } - None => { - let created_at = updated_at; - let row = AssetObject { - asset_object_id: input.asset_object_id.clone(), - bucket: input.bucket.clone(), - object_key: input.object_key.clone(), - access_policy: input.access_policy, - content_type: input.content_type.clone(), - content_length: input.content_length, - content_hash: input.content_hash.clone(), - version: input.version, - source_job_id: input.source_job_id.clone(), - owner_user_id: input.owner_user_id.clone(), - profile_id: input.profile_id.clone(), - entity_id: input.entity_id.clone(), - asset_kind: input.asset_kind.clone(), - created_at, - updated_at, - }; - ctx.db.asset_object().insert(row); - - AssetObjectUpsertSnapshot { - asset_object_id: input.asset_object_id, - bucket: input.bucket, - object_key: input.object_key, - access_policy: input.access_policy, - content_type: input.content_type, - content_length: input.content_length, - content_hash: input.content_hash, - version: input.version, - source_job_id: input.source_job_id, - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - entity_id: input.entity_id, - asset_kind: input.asset_kind, - created_at_micros: input.updated_at_micros, - updated_at_micros: input.updated_at_micros, - } - } - }; - - Ok(snapshot) -} -fn upsert_asset_entity_binding( - ctx: &ReducerContext, - input: AssetEntityBindingInput, -) -> Result { - validate_asset_entity_binding_fields( - &input.binding_id, - &input.asset_object_id, - &input.entity_kind, - &input.entity_id, - &input.slot, - &input.asset_kind, - ) - .map_err(|error| error.to_string())?; - - if ctx - .db - .asset_object() - .asset_object_id() - .find(&input.asset_object_id) - .is_none() - { - return Err("asset_entity_binding.asset_object_id 对应的 asset_object 不存在".to_string()); - } - - let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); - // 首版绑定按 entity_kind + entity_id + slot 幂等定位,后续访问量明确后再改为组合索引扫描。 - let current = ctx.db.asset_entity_binding().iter().find(|row| { - row.entity_kind == input.entity_kind - && row.entity_id == input.entity_id - && row.slot == input.slot - }); - - let snapshot = match current { - Some(existing) => { - ctx.db - .asset_entity_binding() - .binding_id() - .delete(&existing.binding_id); - let row = AssetEntityBinding { - binding_id: existing.binding_id.clone(), - asset_object_id: input.asset_object_id.clone(), - entity_kind: input.entity_kind.clone(), - entity_id: input.entity_id.clone(), - slot: input.slot.clone(), - asset_kind: input.asset_kind.clone(), - owner_user_id: input.owner_user_id.clone(), - profile_id: input.profile_id.clone(), - created_at: existing.created_at, - updated_at, - }; - ctx.db.asset_entity_binding().insert(row); - - AssetEntityBindingSnapshot { - binding_id: existing.binding_id, - asset_object_id: input.asset_object_id, - entity_kind: input.entity_kind, - entity_id: input.entity_id, - slot: input.slot, - asset_kind: input.asset_kind, - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - created_at_micros: existing.created_at.to_micros_since_unix_epoch(), - updated_at_micros: input.updated_at_micros, - } - } - None => { - let created_at = updated_at; - let row = AssetEntityBinding { - binding_id: input.binding_id.clone(), - asset_object_id: input.asset_object_id.clone(), - entity_kind: input.entity_kind.clone(), - entity_id: input.entity_id.clone(), - slot: input.slot.clone(), - asset_kind: input.asset_kind.clone(), - owner_user_id: input.owner_user_id.clone(), - profile_id: input.profile_id.clone(), - created_at, - updated_at, - }; - ctx.db.asset_entity_binding().insert(row); - - AssetEntityBindingSnapshot { - binding_id: input.binding_id, - asset_object_id: input.asset_object_id, - entity_kind: input.entity_kind, - entity_id: input.entity_id, - slot: input.slot, - asset_kind: input.asset_kind, - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - created_at_micros: input.updated_at_micros, - updated_at_micros: input.updated_at_micros, - } - } - }; - - Ok(snapshot) -} +pub use bindings::*; +pub use objects::*; diff --git a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs new file mode 100644 index 00000000..a294915a --- /dev/null +++ b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs @@ -0,0 +1,169 @@ +use crate::*; + +#[spacetimedb::table( + accessor = asset_object, + index(accessor = by_bucket_object_key, btree(columns = [bucket, object_key])) +)] +pub struct AssetObject { + #[primary_key] + asset_object_id: String, + // 正式对象定位固定拆成 bucket + object_key 两列,避免后续再从单字符串路径做 schema 拆分。 + bucket: String, + object_key: String, + access_policy: AssetObjectAccessPolicy, + content_type: Option, + content_length: u64, + content_hash: Option, + version: u32, + source_job_id: Option, + owner_user_id: Option, + profile_id: Option, + entity_id: Option, + #[index(btree)] + asset_kind: String, + created_at: Timestamp, + updated_at: Timestamp, +} + +// reducer 负责固定资产对象的正式写规则,供后续内部模块逻辑复用。 +#[spacetimedb::reducer] +pub fn confirm_asset_object( + ctx: &ReducerContext, + input: AssetObjectUpsertInput, +) -> Result<(), String> { + upsert_asset_object(ctx, input).map(|_| ()) +} + +// procedure 面向 Axum 同步确认接口,返回最终持久化后的对象记录,避免 HTTP 层再额外查询 private table。 +#[spacetimedb::procedure] +pub fn confirm_asset_object_and_return( + ctx: &mut ProcedureContext, + input: AssetObjectUpsertInput, +) -> AssetObjectProcedureResult { + match ctx.try_with_tx(|tx| upsert_asset_object(tx, input.clone())) { + Ok(record) => AssetObjectProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => AssetObjectProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +pub(crate) fn upsert_asset_object( + ctx: &ReducerContext, + input: AssetObjectUpsertInput, +) -> Result { + validate_asset_object_fields( + &input.bucket, + &input.object_key, + &input.asset_kind, + input.version, + ) + .map_err(|error| error.to_string())?; + + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + // 这里先保持最小可发布实现:查重语义已经冻结,后续再把实现优化回组合索引扫描。 + let current = ctx + .db + .asset_object() + .iter() + .find(|row| row.bucket == input.bucket && row.object_key == input.object_key); + + let snapshot = match current { + Some(existing) => { + ctx.db + .asset_object() + .asset_object_id() + .delete(&existing.asset_object_id); + let row = AssetObject { + asset_object_id: existing.asset_object_id.clone(), + bucket: input.bucket.clone(), + object_key: input.object_key.clone(), + access_policy: input.access_policy, + content_type: input.content_type.clone(), + content_length: input.content_length, + content_hash: input.content_hash.clone(), + version: input.version, + source_job_id: input.source_job_id.clone(), + owner_user_id: input.owner_user_id.clone(), + profile_id: input.profile_id.clone(), + entity_id: input.entity_id.clone(), + asset_kind: input.asset_kind.clone(), + created_at: existing.created_at, + updated_at, + }; + ctx.db.asset_object().insert(row); + + AssetObjectUpsertSnapshot { + asset_object_id: existing.asset_object_id, + bucket: input.bucket, + object_key: input.object_key, + access_policy: input.access_policy, + content_type: input.content_type, + content_length: input.content_length, + content_hash: input.content_hash, + version: input.version, + source_job_id: input.source_job_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + entity_id: input.entity_id, + asset_kind: input.asset_kind, + created_at_micros: existing.created_at.to_micros_since_unix_epoch(), + updated_at_micros: input.updated_at_micros, + } + } + None => { + let created_at = updated_at; + let row = AssetObject { + asset_object_id: input.asset_object_id.clone(), + bucket: input.bucket.clone(), + object_key: input.object_key.clone(), + access_policy: input.access_policy, + content_type: input.content_type.clone(), + content_length: input.content_length, + content_hash: input.content_hash.clone(), + version: input.version, + source_job_id: input.source_job_id.clone(), + owner_user_id: input.owner_user_id.clone(), + profile_id: input.profile_id.clone(), + entity_id: input.entity_id.clone(), + asset_kind: input.asset_kind.clone(), + created_at, + updated_at, + }; + ctx.db.asset_object().insert(row); + + AssetObjectUpsertSnapshot { + asset_object_id: input.asset_object_id, + bucket: input.bucket, + object_key: input.object_key, + access_policy: input.access_policy, + content_type: input.content_type, + content_length: input.content_length, + content_hash: input.content_hash, + version: input.version, + source_job_id: input.source_job_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + entity_id: input.entity_id, + asset_kind: input.asset_kind, + created_at_micros: input.updated_at_micros, + updated_at_micros: input.updated_at_micros, + } + } + }; + + Ok(snapshot) +} + +pub(crate) fn has_asset_object(ctx: &ReducerContext, asset_object_id: &str) -> bool { + ctx.db + .asset_object() + .iter() + .any(|row| row.asset_object_id == asset_object_id) +} diff --git a/server-rs/crates/spacetime-module/src/big_fish/assets.rs b/server-rs/crates/spacetime-module/src/big_fish/assets.rs new file mode 100644 index 00000000..c3c4902e --- /dev/null +++ b/server-rs/crates/spacetime-module/src/big_fish/assets.rs @@ -0,0 +1,238 @@ +use crate::*; +use crate::big_fish::tables::{big_fish_asset_slot, big_fish_creation_session}; + +#[spacetimedb::procedure] +pub fn generate_big_fish_asset( + ctx: &mut ProcedureContext, + input: BigFishAssetGenerateInput, +) -> BigFishSessionProcedureResult { + match ctx.try_with_tx(|tx| generate_big_fish_asset_tx(tx, input.clone())) { + Ok(session) => BigFishSessionProcedureResult { + ok: true, + session: Some(session), + error_message: None, + }, + Err(message) => BigFishSessionProcedureResult { + ok: false, + session: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn publish_big_fish_game( + ctx: &mut ProcedureContext, + input: BigFishPublishInput, +) -> BigFishSessionProcedureResult { + match ctx.try_with_tx(|tx| publish_big_fish_game_tx(tx, input.clone())) { + Ok(session) => BigFishSessionProcedureResult { + ok: true, + session: Some(session), + error_message: None, + }, + Err(message) => BigFishSessionProcedureResult { + ok: false, + session: None, + error_message: Some(message), + }, + } +} + +pub(crate) fn generate_big_fish_asset_tx( + ctx: &ReducerContext, + input: BigFishAssetGenerateInput, +) -> Result { + let session = ctx + .db + .big_fish_creation_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; + let draft = session + .draft_json + .as_deref() + .ok_or_else(|| "big_fish.draft 尚未编译".to_string()) + .and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?; + validate_asset_generate_input(&input, &draft).map_err(|error| error.to_string())?; + + let slot = build_generated_asset_slot( + &input.session_id, + &draft, + input.asset_kind, + input.level, + input.motion_key.clone(), + input.asset_url.clone(), + input.generated_at_micros, + ) + .map_err(|error| error.to_string())?; + upsert_big_fish_asset_slot(ctx, slot); + + let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id); + let coverage = build_asset_coverage(Some(&draft), &asset_slots); + let updated_at = Timestamp::from_micros_since_unix_epoch(input.generated_at_micros); + let uses_placeholder = input + .asset_url + .as_deref() + .map(str::trim) + .is_none_or(str::is_empty); + let reply = match (input.asset_kind, uses_placeholder) { + (BigFishAssetKind::LevelMainImage, true) => "本级主图占位图已生成,可在结果页继续预览。", + (BigFishAssetKind::LevelMainImage, false) => "本级主图已正式生成,可在结果页继续预览。", + (BigFishAssetKind::LevelMotion, true) => "本级动作占位图已生成,可在结果页继续预览。", + (BigFishAssetKind::LevelMotion, false) => "本级动作图已正式生成,可在结果页继续预览。", + (BigFishAssetKind::StageBackground, true) => { + "活动区域背景占位图已生成,可在结果页继续预览。" + } + (BigFishAssetKind::StageBackground, false) => { + "活动区域背景已正式生成,可在结果页继续预览。" + } + } + .to_string(); + let next_stage = if coverage.publish_ready { + BigFishCreationStage::ReadyToPublish + } else { + BigFishCreationStage::AssetRefining + }; + let next_session = BigFishCreationSession { + session_id: session.session_id.clone(), + owner_user_id: session.owner_user_id.clone(), + seed_text: session.seed_text.clone(), + current_turn: session.current_turn, + progress_percent: if coverage.publish_ready { 96 } else { 88 }, + stage: next_stage, + anchor_pack_json: session.anchor_pack_json.clone(), + draft_json: session.draft_json.clone(), + asset_coverage_json: serialize_asset_coverage(&coverage) + .map_err(|error| error.to_string())?, + last_assistant_reply: Some(reply.clone()), + publish_ready: coverage.publish_ready, + created_at: session.created_at, + updated_at, + }; + replace_big_fish_session(ctx, &session, next_session); + append_big_fish_system_message( + ctx, + &input.session_id, + format!("big-fish-message-asset-{}", input.generated_at_micros), + reply, + input.generated_at_micros, + ); + + get_big_fish_session_tx( + ctx, + BigFishSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +pub(crate) fn publish_big_fish_game_tx( + ctx: &ReducerContext, + input: BigFishPublishInput, +) -> Result { + validate_publish_input(&input).map_err(|error| error.to_string())?; + let session = ctx + .db + .big_fish_creation_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; + let draft = session + .draft_json + .as_deref() + .ok_or_else(|| "big_fish.draft 尚未编译".to_string()) + .and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?; + let coverage = build_asset_coverage( + Some(&draft), + &list_big_fish_asset_slots(ctx, &session.session_id), + ); + if !coverage.publish_ready { + return Err(format!( + "big_fish 发布校验未通过:{}", + coverage.blockers.join(";") + )); + } + + let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); + let next_session = BigFishCreationSession { + session_id: session.session_id.clone(), + owner_user_id: session.owner_user_id.clone(), + seed_text: session.seed_text.clone(), + current_turn: session.current_turn, + progress_percent: 100, + stage: BigFishCreationStage::Published, + anchor_pack_json: session.anchor_pack_json.clone(), + draft_json: session.draft_json.clone(), + asset_coverage_json: serialize_asset_coverage(&coverage) + .map_err(|error| error.to_string())?, + last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()), + publish_ready: true, + created_at: session.created_at, + updated_at: published_at, + }; + replace_big_fish_session(ctx, &session, next_session); + + get_big_fish_session_tx( + ctx, + BigFishSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +pub(crate) fn list_big_fish_asset_slots( + ctx: &ReducerContext, + session_id: &str, +) -> Vec { + let mut slots = ctx + .db + .big_fish_asset_slot() + .iter() + .filter(|slot| slot.session_id == session_id) + .map(|slot| BigFishAssetSlotSnapshot { + slot_id: slot.slot_id, + session_id: slot.session_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_micros: slot.updated_at.to_micros_since_unix_epoch(), + }) + .collect::>(); + slots.sort_by_key(|slot| { + ( + slot.level.unwrap_or(0), + slot.asset_kind.as_str().to_string(), + slot.motion_key.clone().unwrap_or_default(), + slot.slot_id.clone(), + ) + }); + slots +} + +pub(crate) fn upsert_big_fish_asset_slot(ctx: &ReducerContext, slot: BigFishAssetSlotSnapshot) { + if let Some(existing) = ctx.db.big_fish_asset_slot().slot_id().find(&slot.slot_id) { + ctx.db + .big_fish_asset_slot() + .slot_id() + .delete(&existing.slot_id); + } + ctx.db.big_fish_asset_slot().insert(BigFishAssetSlot { + slot_id: slot.slot_id, + session_id: slot.session_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: Timestamp::from_micros_since_unix_epoch(slot.updated_at_micros), + }); +} diff --git a/server-rs/crates/spacetime-module/src/big_fish/mod.rs b/server-rs/crates/spacetime-module/src/big_fish/mod.rs new file mode 100644 index 00000000..968034c3 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/big_fish/mod.rs @@ -0,0 +1,9 @@ +mod assets; +mod runtime; +mod session; +mod tables; + +pub use assets::*; +pub use runtime::*; +pub use session::*; +pub use tables::*; diff --git a/server-rs/crates/spacetime-module/src/big_fish/runtime.rs b/server-rs/crates/spacetime-module/src/big_fish/runtime.rs new file mode 100644 index 00000000..31bc57f9 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/big_fish/runtime.rs @@ -0,0 +1,190 @@ +use crate::*; +use crate::big_fish::tables::{big_fish_creation_session, big_fish_runtime_run}; + +#[spacetimedb::procedure] +pub fn start_big_fish_run( + ctx: &mut ProcedureContext, + input: BigFishRunStartInput, +) -> BigFishRunProcedureResult { + match ctx.try_with_tx(|tx| start_big_fish_run_tx(tx, input.clone())) { + Ok(run) => BigFishRunProcedureResult { + ok: true, + run: Some(run), + error_message: None, + }, + Err(message) => BigFishRunProcedureResult { + ok: false, + run: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn submit_big_fish_input( + ctx: &mut ProcedureContext, + input: BigFishRunInputSubmitInput, +) -> BigFishRunProcedureResult { + match ctx.try_with_tx(|tx| submit_big_fish_input_tx(tx, input.clone())) { + Ok(run) => BigFishRunProcedureResult { + ok: true, + run: Some(run), + error_message: None, + }, + Err(message) => BigFishRunProcedureResult { + ok: false, + run: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_big_fish_run( + ctx: &mut ProcedureContext, + input: BigFishRunGetInput, +) -> BigFishRunProcedureResult { + match ctx.try_with_tx(|tx| get_big_fish_run_tx(tx, input.clone())) { + Ok(run) => BigFishRunProcedureResult { + ok: true, + run: Some(run), + error_message: None, + }, + Err(message) => BigFishRunProcedureResult { + ok: false, + run: None, + error_message: Some(message), + }, + } +} + +fn start_big_fish_run_tx( + ctx: &ReducerContext, + input: BigFishRunStartInput, +) -> Result { + validate_run_start_input(&input).map_err(|error| error.to_string())?; + if ctx + .db + .big_fish_runtime_run() + .run_id() + .find(&input.run_id) + .is_some() + { + return Err("big_fish_runtime_run.run_id 已存在".to_string()); + } + let session = ctx + .db + .big_fish_creation_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; + let draft = session + .draft_json + .as_deref() + .ok_or_else(|| "big_fish.draft 尚未编译".to_string()) + .and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?; + let snapshot = build_initial_runtime_snapshot( + input.run_id.clone(), + input.session_id.clone(), + &draft, + input.started_at_micros, + ); + let now = Timestamp::from_micros_since_unix_epoch(input.started_at_micros); + ctx.db.big_fish_runtime_run().insert(BigFishRuntimeRun { + run_id: input.run_id, + session_id: input.session_id, + owner_user_id: input.owner_user_id, + status: snapshot.status, + snapshot_json: serialize_runtime_snapshot(&snapshot).map_err(|error| error.to_string())?, + last_input_x: 0.0, + last_input_y: 0.0, + tick: snapshot.tick, + created_at: now, + updated_at: now, + }); + + Ok(snapshot) +} + +fn submit_big_fish_input_tx( + ctx: &ReducerContext, + input: BigFishRunInputSubmitInput, +) -> Result { + validate_run_input_submit_input(&input).map_err(|error| error.to_string())?; + let run = ctx + .db + .big_fish_runtime_run() + .run_id() + .find(&input.run_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "big_fish_runtime_run 不存在".to_string())?; + let session = ctx + .db + .big_fish_creation_session() + .session_id() + .find(&run.session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; + let draft = session + .draft_json + .as_deref() + .ok_or_else(|| "big_fish.draft 尚未编译".to_string()) + .and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?; + let current_snapshot = + deserialize_runtime_snapshot(&run.snapshot_json).map_err(|error| error.to_string())?; + let next_snapshot = advance_runtime_snapshot( + current_snapshot, + &draft.runtime_params, + input.input_x, + input.input_y, + input.submitted_at_micros, + ); + replace_big_fish_run( + ctx, + &run, + BigFishRuntimeRun { + run_id: run.run_id.clone(), + session_id: run.session_id.clone(), + owner_user_id: run.owner_user_id.clone(), + status: next_snapshot.status, + snapshot_json: serialize_runtime_snapshot(&next_snapshot) + .map_err(|error| error.to_string())?, + last_input_x: input.input_x, + last_input_y: input.input_y, + tick: next_snapshot.tick, + created_at: run.created_at, + updated_at: Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros), + }, + ); + + Ok(next_snapshot) +} + +fn get_big_fish_run_tx( + ctx: &ReducerContext, + input: BigFishRunGetInput, +) -> Result { + validate_run_get_input(&input).map_err(|error| error.to_string())?; + let run = ctx + .db + .big_fish_runtime_run() + .run_id() + .find(&input.run_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "big_fish_runtime_run 不存在".to_string())?; + + deserialize_runtime_snapshot(&run.snapshot_json).map_err(|error| error.to_string()) +} + +fn replace_big_fish_run( + ctx: &ReducerContext, + current: &BigFishRuntimeRun, + next: BigFishRuntimeRun, +) { + ctx.db + .big_fish_runtime_run() + .run_id() + .delete(¤t.run_id); + ctx.db.big_fish_runtime_run().insert(next); +} diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs new file mode 100644 index 00000000..2cb4287c --- /dev/null +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -0,0 +1,494 @@ +use crate::*; +use crate::big_fish::tables::{big_fish_agent_message, big_fish_creation_session}; + +#[spacetimedb::procedure] +pub fn create_big_fish_session( + ctx: &mut ProcedureContext, + input: BigFishSessionCreateInput, +) -> BigFishSessionProcedureResult { + match ctx.try_with_tx(|tx| create_big_fish_session_tx(tx, input.clone())) { + Ok(session) => BigFishSessionProcedureResult { + ok: true, + session: Some(session), + error_message: None, + }, + Err(message) => BigFishSessionProcedureResult { + ok: false, + session: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_big_fish_session( + ctx: &mut ProcedureContext, + input: BigFishSessionGetInput, +) -> BigFishSessionProcedureResult { + match ctx.try_with_tx(|tx| get_big_fish_session_tx(tx, input.clone())) { + Ok(session) => BigFishSessionProcedureResult { + ok: true, + session: Some(session), + error_message: None, + }, + Err(message) => BigFishSessionProcedureResult { + ok: false, + session: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn list_big_fish_works( + ctx: &mut ProcedureContext, + input: BigFishWorksListInput, +) -> BigFishWorksProcedureResult { + match ctx.try_with_tx(|tx| list_big_fish_works_tx(tx, input.clone())) { + Ok(items) => match serde_json::to_string(&items) { + Ok(items_json) => BigFishWorksProcedureResult { + ok: true, + items_json: Some(items_json), + error_message: None, + }, + Err(error) => BigFishWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(error.to_string()), + }, + }, + Err(message) => BigFishWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn submit_big_fish_message( + ctx: &mut ProcedureContext, + input: BigFishMessageSubmitInput, +) -> BigFishSessionProcedureResult { + match ctx.try_with_tx(|tx| submit_big_fish_message_tx(tx, input.clone())) { + Ok(session) => BigFishSessionProcedureResult { + ok: true, + session: Some(session), + error_message: None, + }, + Err(message) => BigFishSessionProcedureResult { + ok: false, + session: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn compile_big_fish_draft( + ctx: &mut ProcedureContext, + input: BigFishDraftCompileInput, +) -> BigFishSessionProcedureResult { + match ctx.try_with_tx(|tx| compile_big_fish_draft_tx(tx, input.clone())) { + Ok(session) => BigFishSessionProcedureResult { + ok: true, + session: Some(session), + error_message: None, + }, + Err(message) => BigFishSessionProcedureResult { + ok: false, + session: None, + error_message: Some(message), + }, + } +} + +pub(crate) fn create_big_fish_session_tx( + ctx: &ReducerContext, + input: BigFishSessionCreateInput, +) -> Result { + validate_session_create_input(&input).map_err(|error| error.to_string())?; + if ctx + .db + .big_fish_creation_session() + .session_id() + .find(&input.session_id) + .is_some() + { + return Err("big_fish_creation_session.session_id 已存在".to_string()); + } + if ctx + .db + .big_fish_agent_message() + .message_id() + .find(&input.welcome_message_id) + .is_some() + { + return Err("big_fish_agent_message.message_id 已存在".to_string()); + } + + let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); + let anchor_pack = infer_anchor_pack(&input.seed_text, None); + let asset_coverage = build_asset_coverage(None, &[]); + ctx.db + .big_fish_creation_session() + .insert(BigFishCreationSession { + session_id: input.session_id.clone(), + owner_user_id: input.owner_user_id.clone(), + seed_text: input.seed_text.trim().to_string(), + current_turn: 0, + progress_percent: 20, + stage: BigFishCreationStage::CollectingAnchors, + anchor_pack_json: serialize_anchor_pack(&anchor_pack) + .map_err(|error| error.to_string())?, + draft_json: None, + asset_coverage_json: serialize_asset_coverage(&asset_coverage) + .map_err(|error| error.to_string())?, + last_assistant_reply: Some(input.welcome_message_text.clone()), + publish_ready: false, + created_at, + updated_at: created_at, + }); + ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { + message_id: input.welcome_message_id, + session_id: input.session_id.clone(), + role: BigFishAgentMessageRole::Assistant, + kind: BigFishAgentMessageKind::Chat, + text: input.welcome_message_text, + created_at, + }); + + get_big_fish_session_tx( + ctx, + BigFishSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +pub(crate) fn get_big_fish_session_tx( + ctx: &ReducerContext, + input: BigFishSessionGetInput, +) -> Result { + validate_session_get_input(&input).map_err(|error| error.to_string())?; + let session = ctx + .db + .big_fish_creation_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; + + build_big_fish_session_snapshot(ctx, &session) +} + +pub(crate) fn list_big_fish_works_tx( + ctx: &ReducerContext, + input: BigFishWorksListInput, +) -> Result, String> { + validate_works_list_input(&input).map_err(|error| error.to_string())?; + + let mut items = ctx + .db + .big_fish_creation_session() + .iter() + .filter(|row| row.owner_user_id == input.owner_user_id) + .map(|row| build_big_fish_work_summary(ctx, &row)) + .collect::, _>>()?; + + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.work_id.cmp(&right.work_id)) + }); + Ok(items) +} + +pub(crate) fn submit_big_fish_message_tx( + ctx: &ReducerContext, + input: BigFishMessageSubmitInput, +) -> Result { + validate_message_submit_input(&input).map_err(|error| error.to_string())?; + let session = ctx + .db + .big_fish_creation_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; + if ctx + .db + .big_fish_agent_message() + .message_id() + .find(&input.user_message_id) + .is_some() + { + return Err("big_fish_agent_message.user_message_id 已存在".to_string()); + } + if ctx + .db + .big_fish_agent_message() + .message_id() + .find(&input.assistant_message_id) + .is_some() + { + return Err("big_fish_agent_message.assistant_message_id 已存在".to_string()); + } + + let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); + ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { + message_id: input.user_message_id, + session_id: input.session_id.clone(), + role: BigFishAgentMessageRole::User, + kind: BigFishAgentMessageKind::Chat, + text: input.user_message_text.trim().to_string(), + created_at: submitted_at, + }); + + let anchor_pack = infer_anchor_pack(&session.seed_text, Some(&input.user_message_text)); + let assistant_text = + "我已经把这版方向收束成 4 个高杠杆锚点,可以继续细化,也可以直接编译第一版玩法草稿。" + .to_string(); + ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { + message_id: input.assistant_message_id, + session_id: input.session_id.clone(), + role: BigFishAgentMessageRole::Assistant, + kind: BigFishAgentMessageKind::Summary, + text: assistant_text.clone(), + created_at: submitted_at, + }); + + let next_session = BigFishCreationSession { + session_id: session.session_id.clone(), + owner_user_id: session.owner_user_id.clone(), + seed_text: session.seed_text.clone(), + current_turn: session.current_turn.saturating_add(1), + progress_percent: 60, + stage: BigFishCreationStage::CollectingAnchors, + anchor_pack_json: serialize_anchor_pack(&anchor_pack).map_err(|error| error.to_string())?, + draft_json: session.draft_json.clone(), + asset_coverage_json: session.asset_coverage_json.clone(), + last_assistant_reply: Some(assistant_text), + publish_ready: session.publish_ready, + created_at: session.created_at, + updated_at: submitted_at, + }; + replace_big_fish_session(ctx, &session, next_session); + + get_big_fish_session_tx( + ctx, + BigFishSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +pub(crate) fn compile_big_fish_draft_tx( + ctx: &ReducerContext, + input: BigFishDraftCompileInput, +) -> Result { + validate_draft_compile_input(&input).map_err(|error| error.to_string())?; + let session = ctx + .db + .big_fish_creation_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; + let anchor_pack = + deserialize_anchor_pack(&session.anchor_pack_json).map_err(|error| error.to_string())?; + let draft = compile_default_draft(&anchor_pack); + let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id); + let coverage = build_asset_coverage(Some(&draft), &asset_slots); + let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); + let reply = "第一版玩法草稿已编译完成,可以在结果页逐级生成主图、动作和场地背景。".to_string(); + + let next_session = BigFishCreationSession { + session_id: session.session_id.clone(), + owner_user_id: session.owner_user_id.clone(), + seed_text: session.seed_text.clone(), + current_turn: session.current_turn, + progress_percent: 80, + stage: BigFishCreationStage::DraftReady, + anchor_pack_json: session.anchor_pack_json.clone(), + draft_json: Some(serialize_draft(&draft).map_err(|error| error.to_string())?), + asset_coverage_json: serialize_asset_coverage(&coverage) + .map_err(|error| error.to_string())?, + last_assistant_reply: Some(reply.clone()), + publish_ready: coverage.publish_ready, + created_at: session.created_at, + updated_at: compiled_at, + }; + replace_big_fish_session(ctx, &session, next_session); + append_big_fish_system_message( + ctx, + &input.session_id, + format!("big-fish-message-compile-{}", input.compiled_at_micros), + reply, + input.compiled_at_micros, + ); + + get_big_fish_session_tx( + ctx, + BigFishSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +pub(crate) fn build_big_fish_session_snapshot( + ctx: &ReducerContext, + row: &BigFishCreationSession, +) -> Result { + let anchor_pack = + deserialize_anchor_pack(&row.anchor_pack_json).unwrap_or_else(|_| empty_anchor_pack()); + let draft = row + .draft_json + .as_deref() + .map(deserialize_draft) + .transpose() + .map_err(|error| format!("big_fish.draft_json 非法: {error}"))?; + let asset_slots = list_big_fish_asset_slots(ctx, &row.session_id); + let asset_coverage = build_asset_coverage(draft.as_ref(), &asset_slots); + let mut messages = ctx + .db + .big_fish_agent_message() + .iter() + .filter(|message| message.session_id == row.session_id) + .map(|message| BigFishAgentMessageSnapshot { + message_id: message.message_id, + session_id: message.session_id, + role: message.role, + kind: message.kind, + text: message.text, + created_at_micros: message.created_at.to_micros_since_unix_epoch(), + }) + .collect::>(); + messages.sort_by_key(|message| (message.created_at_micros, message.message_id.clone())); + + Ok(BigFishSessionSnapshot { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + seed_text: row.seed_text.clone(), + current_turn: row.current_turn, + progress_percent: row.progress_percent, + stage: row.stage, + anchor_pack, + draft, + asset_slots, + asset_coverage, + messages, + last_assistant_reply: row.last_assistant_reply.clone(), + publish_ready: row.publish_ready, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + }) +} + +pub(crate) fn build_big_fish_work_summary( + ctx: &ReducerContext, + row: &BigFishCreationSession, +) -> Result { + let draft = row + .draft_json + .as_deref() + .map(deserialize_draft) + .transpose() + .map_err(|error| format!("big_fish.draft_json 非法: {error}"))?; + let asset_slots = list_big_fish_asset_slots(ctx, &row.session_id); + let coverage = build_asset_coverage(draft.as_ref(), &asset_slots); + let cover_image_src = asset_slots + .iter() + .find(|slot| slot.asset_kind == BigFishAssetKind::StageBackground) + .and_then(|slot| slot.asset_url.clone()) + .or_else(|| { + asset_slots + .iter() + .find(|slot| slot.asset_kind == BigFishAssetKind::LevelMainImage) + .and_then(|slot| slot.asset_url.clone()) + }); + let title = draft + .as_ref() + .map(|value| value.title.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "未命名大鱼草稿".to_string()); + let subtitle = draft + .as_ref() + .map(|value| value.subtitle.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "等待整理玩法草稿".to_string()); + let summary = draft + .as_ref() + .map(|value| value.core_fun.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| { + row.last_assistant_reply + .clone() + .unwrap_or_else(|| "继续补齐锚点后即可生成玩法草稿。".to_string()) + }); + + Ok(BigFishWorkSummarySnapshot { + work_id: format!("big-fish-work-{}", row.session_id), + source_session_id: row.session_id.clone(), + title, + subtitle, + summary, + cover_image_src, + status: if row.stage == BigFishCreationStage::Published { + "published".to_string() + } else { + "draft".to_string() + }, + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + publish_ready: coverage.publish_ready, + level_count: draft + .as_ref() + .map(|value| value.runtime_params.level_count) + .unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT), + 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, + }) +} + +pub(crate) fn replace_big_fish_session( + ctx: &ReducerContext, + current: &BigFishCreationSession, + next: BigFishCreationSession, +) { + ctx.db + .big_fish_creation_session() + .session_id() + .delete(¤t.session_id); + ctx.db.big_fish_creation_session().insert(next); +} + +pub(crate) fn append_big_fish_system_message( + ctx: &ReducerContext, + session_id: &str, + message_id: String, + text: String, + created_at_micros: i64, +) { + if ctx + .db + .big_fish_agent_message() + .message_id() + .find(&message_id) + .is_some() + { + return; + } + ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { + message_id, + session_id: session_id.to_string(), + role: BigFishAgentMessageRole::Assistant, + kind: BigFishAgentMessageKind::ActionResult, + text, + created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros), + }); +} diff --git a/server-rs/crates/spacetime-module/src/big_fish/tables.rs b/server-rs/crates/spacetime-module/src/big_fish/tables.rs new file mode 100644 index 00000000..00b7f169 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/big_fish/tables.rs @@ -0,0 +1,72 @@ +use crate::*; + +#[spacetimedb::table( + accessor = big_fish_creation_session, + index(accessor = by_big_fish_session_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct BigFishCreationSession { + #[primary_key] + pub(crate) session_id: String, + pub(crate) owner_user_id: String, + pub(crate) seed_text: String, + pub(crate) current_turn: u32, + pub(crate) progress_percent: u32, + pub(crate) stage: BigFishCreationStage, + pub(crate) anchor_pack_json: String, + pub(crate) draft_json: Option, + pub(crate) asset_coverage_json: String, + pub(crate) last_assistant_reply: Option, + pub(crate) publish_ready: bool, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = big_fish_agent_message, + index(accessor = by_big_fish_message_session_id, btree(columns = [session_id])) +)] +pub struct BigFishAgentMessage { + #[primary_key] + pub(crate) message_id: String, + pub(crate) session_id: String, + pub(crate) role: BigFishAgentMessageRole, + pub(crate) kind: BigFishAgentMessageKind, + pub(crate) text: String, + pub(crate) created_at: Timestamp, +} + +#[spacetimedb::table( + accessor = big_fish_asset_slot, + index(accessor = by_big_fish_asset_session_id, btree(columns = [session_id])) +)] +pub struct BigFishAssetSlot { + #[primary_key] + pub(crate) slot_id: String, + pub(crate) session_id: String, + pub(crate) asset_kind: BigFishAssetKind, + pub(crate) level: Option, + pub(crate) motion_key: Option, + pub(crate) status: BigFishAssetStatus, + pub(crate) asset_url: Option, + pub(crate) prompt_snapshot: String, + pub(crate) updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = big_fish_runtime_run, + index(accessor = by_big_fish_run_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_big_fish_run_session_id, btree(columns = [session_id])) +)] +pub struct BigFishRuntimeRun { + #[primary_key] + pub(crate) run_id: String, + pub(crate) session_id: String, + pub(crate) owner_user_id: String, + pub(crate) status: BigFishRunStatus, + pub(crate) snapshot_json: String, + pub(crate) last_input_x: f32, + pub(crate) last_input_y: f32, + pub(crate) tick: u64, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} diff --git a/server-rs/crates/spacetime-module/src/custom_world/agent.rs b/server-rs/crates/spacetime-module/src/custom_world/agent.rs new file mode 100644 index 00000000..ab3ee017 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/custom_world/agent.rs @@ -0,0 +1 @@ +// Custom World agent message、operation、draft card 与 action 执行落位点。 diff --git a/server-rs/crates/spacetime-module/src/custom_world/gallery.rs b/server-rs/crates/spacetime-module/src/custom_world/gallery.rs new file mode 100644 index 00000000..5fd298e7 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/custom_world/gallery.rs @@ -0,0 +1 @@ +// Custom World gallery 与 detail 读模型落位点。 diff --git a/server-rs/crates/spacetime-module/src/custom_world/mod.rs b/server-rs/crates/spacetime-module/src/custom_world/mod.rs index 8bd90c2f..9fe9ef5b 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -995,15 +995,14 @@ fn upsert_custom_world_profile_record( .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) .or_else(|| { - input.source_agent_session_id.as_ref().and_then(|session_id| { - ctx.db.custom_world_profile().iter().find(|row| { - is_same_agent_draft_profile_candidate( - row, - &input.owner_user_id, - session_id, - ) + input + .source_agent_session_id + .as_ref() + .and_then(|session_id| { + ctx.db.custom_world_profile().iter().find(|row| { + is_same_agent_draft_profile_candidate(row, &input.owner_user_id, session_id) + }) }) - }) }); let next_row = match current { @@ -1432,18 +1431,16 @@ fn list_custom_world_work_snapshots( let mut items = Vec::new(); - for session in ctx - .db - .custom_world_agent_session() - .iter() - .filter(|row| row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published) - { + for session in ctx.db.custom_world_agent_session().iter().filter(|row| { + row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published + }) { let gate = build_custom_world_publish_gate_from_session(&session); let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()); let title = resolve_session_work_title(&session, draft_profile.as_ref()); let summary = resolve_session_work_summary(&session, draft_profile.as_ref()); let stage_label = Some(resolve_rpg_agent_stage_label(session.stage).to_string()); - let subtitle = resolve_session_work_subtitle(draft_profile.as_ref(), stage_label.as_deref()); + let subtitle = + resolve_session_work_subtitle(draft_profile.as_ref(), stage_label.as_deref()); let (playable_npc_count, landmark_count) = resolve_session_work_counts(ctx, &session, draft_profile.as_ref()); @@ -1516,8 +1513,16 @@ fn list_custom_world_work_snapshots( .updated_at_micros .cmp(&left.updated_at_micros) .then_with(|| { - let left_rank = if left.source_type == "agent_session" { 0 } else { 1 }; - let right_rank = if right.source_type == "agent_session" { 0 } else { 1 }; + let left_rank = if left.source_type == "agent_session" { + 0 + } else { + 1 + }; + let right_rank = if right.source_type == "agent_session" { + 0 + } else { + 1 + }; left_rank.cmp(&right_rank) }) .then(left.work_id.cmp(&right.work_id)) @@ -1578,7 +1583,9 @@ fn execute_custom_world_agent_action_tx( match input.action.trim() { "draft_foundation" => execute_draft_foundation_action(ctx, &session, &input, &payload), "update_draft_card" => execute_update_draft_card_action(ctx, &session, &input, &payload), - "sync_result_profile" => execute_sync_result_profile_action(ctx, &session, &input, &payload), + "sync_result_profile" => { + execute_sync_result_profile_action(ctx, &session, &input, &payload) + } "publish_world" => execute_publish_world_action(ctx, &session, &input, &payload), "revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload), "generate_characters" @@ -1603,18 +1610,16 @@ fn execute_draft_foundation_action( } let updated_at = input.submitted_at_micros; - let draft_profile = if let Some(profile) = payload.get("draftProfile").and_then(JsonValue::as_object) { - profile.clone() - } else if let Some(existing) = parse_optional_session_object(session.draft_profile_json.as_deref()) { - ensure_minimal_draft_profile(existing, &session.seed_text) - } else { - build_minimal_draft_profile_from_seed(&session.seed_text) - }; - - let draft_profile_json = - serde_json::to_string(&JsonValue::Object(draft_profile.clone())).map_err(|error| { - format!("draft_foundation 无法序列化 draft_profile_json: {error}") + let draft_profile = payload + .get("draftProfile") + .and_then(JsonValue::as_object) + .cloned() + .ok_or_else(|| { + "draft_foundation requires externally generated payload.draftProfile".to_string() })?; + + let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile.clone())) + .map_err(|error| format!("draft_foundation 无法序列化 draft_profile_json: {error}"))?; let gate = summarize_publish_gate_from_json( &input.session_id, RpgAgentStage::ObjectRefining, @@ -1627,8 +1632,12 @@ fn execute_draft_foundation_action( progress_percent: Some(100), stage: Some(RpgAgentStage::ObjectRefining), draft_profile_json: Some(Some(draft_profile_json.clone())), - last_assistant_reply: Some(Some("世界底稿已整理完成,接下来可以继续细化卡片和发布预览。".to_string())), - publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)), + last_assistant_reply: Some(Some( + "世界底稿已整理完成,接下来可以继续细化卡片和发布预览。".to_string(), + )), + publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( + &gate, + ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, @@ -1675,7 +1684,8 @@ fn execute_update_draft_card_action( ) -> Result { ensure_refining_stage(session.stage, "update_draft_card")?; - let card_id = read_required_payload_text(payload, "cardId", "update_draft_card requires cardId")?; + let card_id = + read_required_payload_text(payload, "cardId", "update_draft_card requires cardId")?; let card = ctx .db .custom_world_draft_card() @@ -1691,7 +1701,8 @@ fn execute_update_draft_card_action( return Err("update_draft_card requires sections".to_string()); } - let mut detail_object = parse_optional_session_object(card.detail_payload_json.as_deref()).unwrap_or_default(); + let mut detail_object = + parse_optional_session_object(card.detail_payload_json.as_deref()).unwrap_or_default(); let mut detail_sections = detail_object .get("sections") .and_then(JsonValue::as_array) @@ -1735,27 +1746,36 @@ fn execute_update_draft_card_action( } detail_object.insert("id".to_string(), JsonValue::String(card.card_id.clone())); - detail_object.insert("kind".to_string(), JsonValue::String(card.kind.as_str().to_string())); + detail_object.insert( + "kind".to_string(), + JsonValue::String(card.kind.as_str().to_string()), + ); detail_object.insert("title".to_string(), JsonValue::String(card.title.clone())); - detail_object.insert("sections".to_string(), JsonValue::Array(detail_sections.clone())); + detail_object.insert( + "sections".to_string(), + JsonValue::Array(detail_sections.clone()), + ); detail_object.insert( "linkedIds".to_string(), - serde_json::from_str::(&card.linked_ids_json).unwrap_or_else(|_| JsonValue::Array(Vec::new())), + serde_json::from_str::(&card.linked_ids_json) + .unwrap_or_else(|_| JsonValue::Array(Vec::new())), ); detail_object.insert("locked".to_string(), JsonValue::Bool(false)); detail_object.insert("editable".to_string(), JsonValue::Bool(false)); - detail_object.insert("editableSectionIds".to_string(), JsonValue::Array(Vec::new())); + detail_object.insert( + "editableSectionIds".to_string(), + JsonValue::Array(Vec::new()), + ); detail_object.insert("warningMessages".to_string(), JsonValue::Array(Vec::new())); - let updated_title = extract_detail_section_value(&detail_sections, "title").unwrap_or_else(|| card.title.clone()); - let updated_subtitle = - extract_detail_section_value(&detail_sections, "subtitle").unwrap_or_else(|| card.subtitle.clone()); - let updated_summary = - extract_detail_section_value(&detail_sections, "summary").unwrap_or_else(|| card.summary.clone()); - let detail_payload_json = - serde_json::to_string(&JsonValue::Object(detail_object)).map_err(|error| { - format!("update_draft_card 无法序列化 detail_payload_json: {error}") - })?; + let updated_title = extract_detail_section_value(&detail_sections, "title") + .unwrap_or_else(|| card.title.clone()); + let updated_subtitle = extract_detail_section_value(&detail_sections, "subtitle") + .unwrap_or_else(|| card.subtitle.clone()); + let updated_summary = extract_detail_section_value(&detail_sections, "summary") + .unwrap_or_else(|| card.summary.clone()); + let detail_payload_json = serde_json::to_string(&JsonValue::Object(detail_object)) + .map_err(|error| format!("update_draft_card 无法序列化 detail_payload_json: {error}"))?; replace_custom_world_draft_card( ctx, @@ -1778,7 +1798,14 @@ fn execute_update_draft_card_action( }, ); - let next_session = sync_session_draft_profile_from_card_update(session, &card, &updated_title, &updated_subtitle, &updated_summary, input.submitted_at_micros)?; + let next_session = sync_session_draft_profile_from_card_update( + session, + &card, + &updated_title, + &updated_subtitle, + &updated_summary, + input.submitted_at_micros, + )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( @@ -1816,7 +1843,10 @@ fn execute_sync_result_profile_action( .ok_or_else(|| "sync_result_profile requires profile".to_string())?; if let Some(stable_profile_id) = resolve_stable_agent_draft_profile_id(session) { // 结果页回写时必须沿用当前草稿的稳定身份,避免把同一草稿写成新条目。 - profile.insert("id".to_string(), JsonValue::String(stable_profile_id.clone())); + profile.insert( + "id".to_string(), + JsonValue::String(stable_profile_id.clone()), + ); upsert_nested_result_profile_id(&mut profile, &stable_profile_id); } let draft_profile = ensure_minimal_draft_profile(profile, &session.seed_text); @@ -1830,9 +1860,13 @@ fn execute_sync_result_profile_action( let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { - draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)), + draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( + draft_profile.clone(), + ))?)), last_assistant_reply: Some(Some("结果页草稿已同步回当前会话。".to_string())), - publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)), + publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( + &gate, + ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, @@ -1871,12 +1905,14 @@ fn execute_sync_result_profile_action( } fn resolve_stable_agent_draft_profile_id(session: &CustomWorldAgentSession) -> Option { - parse_optional_session_object(session.draft_profile_json.as_deref()).and_then(|profile| { - read_optional_text_field(&profile, &["legacyResultProfile.id", "id"]) - }) + parse_optional_session_object(session.draft_profile_json.as_deref()) + .and_then(|profile| read_optional_text_field(&profile, &["legacyResultProfile.id", "id"])) } -fn upsert_nested_result_profile_id(profile: &mut JsonMap, stable_profile_id: &str) { +fn upsert_nested_result_profile_id( + profile: &mut JsonMap, + stable_profile_id: &str, +) { let legacy_result_profile = profile .entry("legacyResultProfile".to_string()) .or_insert_with(|| JsonValue::Object(JsonMap::new())); @@ -1907,12 +1943,13 @@ fn execute_publish_world_action( ) -> Result { ensure_publishable_stage(session.stage, "publish_world")?; - let draft_profile = if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) { - explicit.clone() - } else { - parse_optional_session_object(session.draft_profile_json.as_deref()) - .ok_or_else(|| "publish_world requires draft_profile_json".to_string())? - }; + let draft_profile = + if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) { + explicit.clone() + } else { + parse_optional_session_object(session.draft_profile_json.as_deref()) + .ok_or_else(|| "publish_world requires draft_profile_json".to_string())? + }; let gate = summarize_publish_gate_from_json( &session.session_id, session.stage, @@ -1972,7 +2009,10 @@ fn execute_publish_world_action( &session.session_id, RpgAgentOperationType::PublishWorld, "世界已发布", - &format!("正式世界档案已写入作品库:{}。", publish_result.1.profile_id), + &format!( + "正式世界档案已写入作品库:{}。", + publish_result.1.profile_id + ), input.submitted_at_micros, ); @@ -2046,9 +2086,15 @@ fn execute_revert_checkpoint_action( .map(|value| serialize_json_value(&JsonValue::Object(value.clone()))) .transpose()?, ), - last_assistant_reply: Some(Some("已恢复到所选 checkpoint 的世界草稿状态。".to_string())), - quality_findings_json: Some(serialize_json_value(&JsonValue::Array(restored_quality_findings))?), - publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)), + last_assistant_reply: Some(Some( + "已恢复到所选 checkpoint 的世界草稿状态。".to_string(), + )), + quality_findings_json: Some(serialize_json_value(&JsonValue::Array( + restored_quality_findings, + ))?), + publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( + &gate, + ))?)), result_preview_json: Some(build_result_preview_json( restored_draft_profile.as_ref(), &gate, @@ -2099,7 +2145,10 @@ fn execute_placeholder_custom_world_action( ctx, &session.session_id, &input.operation_id, - &format!("动作 {} 已接入最小兼容占位,后续会继续补真实编排。", input.action), + &format!( + "动作 {} 已接入最小兼容占位,后续会继续补真实编排。", + input.action + ), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( @@ -2201,7 +2250,8 @@ fn summarize_publish_gate_from_json( blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_player_premise".to_string(), code: "publish_missing_player_premise".to_string(), - message: "当前世界缺少玩家身份与切入前提,发布前需要先补齐玩家 premise。".to_string(), + message: "当前世界缺少玩家身份与切入前提,发布前需要先补齐玩家 premise。" + .to_string(), }); } if !json_array_has_non_empty_text(profile.get("coreConflicts")) { @@ -2342,8 +2392,10 @@ fn build_supported_actions_json( let has_checkpoint = checkpoints .iter() .any(|entry| entry.get("snapshot").is_some()); - let draft_refining_enabled = - matches!(stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining); + let draft_refining_enabled = matches!( + stage, + RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining + ); let long_tail_enabled = matches!( stage, RpgAgentStage::ObjectRefining @@ -2462,8 +2514,10 @@ fn build_custom_world_draft_card_detail_snapshot( card: &CustomWorldDraftCard, ) -> Result { if let Some(detail_payload_json) = card.detail_payload_json.as_deref() { - let detail_value = serde_json::from_str::(detail_payload_json) - .map_err(|error| format!("custom_world_draft_card.detail_payload_json 非法: {error}"))?; + let detail_value = + serde_json::from_str::(detail_payload_json).map_err(|error| { + format!("custom_world_draft_card.detail_payload_json 非法: {error}") + })?; if let Some(object) = detail_value.as_object() { let sections = object .get("sections") @@ -2501,8 +2555,14 @@ fn build_custom_world_draft_card_detail_snapshot( .to_string(), sections, linked_ids_json: card.linked_ids_json.clone(), - locked: object.get("locked").and_then(JsonValue::as_bool).unwrap_or(false), - editable: object.get("editable").and_then(JsonValue::as_bool).unwrap_or(false), + locked: object + .get("locked") + .and_then(JsonValue::as_bool) + .unwrap_or(false), + editable: object + .get("editable") + .and_then(JsonValue::as_bool) + .unwrap_or(false), editable_section_ids_json: serialize_json_value( object .get("editableSectionIds") @@ -2534,7 +2594,9 @@ fn build_custom_world_draft_card_detail_snapshot( }) } -fn build_fallback_card_sections(card: &CustomWorldDraftCard) -> Vec { +fn build_fallback_card_sections( + card: &CustomWorldDraftCard, +) -> Vec { vec![ CustomWorldDraftCardDetailSectionSnapshot { section_id: "title".to_string(), @@ -2578,7 +2640,9 @@ fn rebuild_custom_world_agent_session_row( current_turn: current.current_turn, progress_percent: patch.progress_percent.unwrap_or(current.progress_percent), stage: patch.stage.unwrap_or(current.stage), - focus_card_id: patch.focus_card_id.unwrap_or_else(|| current.focus_card_id.clone()), + focus_card_id: patch + .focus_card_id + .unwrap_or_else(|| current.focus_card_id.clone()), anchor_content_json: patch .anchor_content_json .unwrap_or_else(|| current.anchor_content_json.clone()), @@ -2588,8 +2652,12 @@ fn rebuild_custom_world_agent_session_row( creator_intent_readiness_json: patch .creator_intent_readiness_json .unwrap_or_else(|| current.creator_intent_readiness_json.clone()), - anchor_pack_json: patch.anchor_pack_json.unwrap_or_else(|| current.anchor_pack_json.clone()), - lock_state_json: patch.lock_state_json.unwrap_or_else(|| current.lock_state_json.clone()), + anchor_pack_json: patch + .anchor_pack_json + .unwrap_or_else(|| current.anchor_pack_json.clone()), + lock_state_json: patch + .lock_state_json + .unwrap_or_else(|| current.lock_state_json.clone()), draft_profile_json: patch .draft_profile_json .unwrap_or_else(|| current.draft_profile_json.clone()), @@ -2741,7 +2809,8 @@ fn upsert_world_foundation_card( status: RpgAgentDraftCardStatus::Confirmed, title: read_optional_text_field(draft_profile, &["name", "title"]) .unwrap_or_else(|| "世界底稿".to_string()), - subtitle: read_optional_text_field(draft_profile, &["subtitle"]).unwrap_or_default(), + subtitle: read_optional_text_field(draft_profile, &["subtitle"]) + .unwrap_or_default(), summary: read_optional_text_field(draft_profile, &["summary"]) .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()), linked_ids_json: "[]".to_string(), @@ -2754,24 +2823,27 @@ fn upsert_world_foundation_card( }, ); } else { - ctx.db.custom_world_draft_card().insert(CustomWorldDraftCard { - card_id, - session_id: session_id.to_string(), - kind: RpgAgentDraftCardKind::World, - status: RpgAgentDraftCardStatus::Confirmed, - title: read_optional_text_field(draft_profile, &["name", "title"]) - .unwrap_or_else(|| "世界底稿".to_string()), - subtitle: read_optional_text_field(draft_profile, &["subtitle"]).unwrap_or_default(), - summary: read_optional_text_field(draft_profile, &["summary"]) - .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()), - linked_ids_json: "[]".to_string(), - warning_count: 0, - asset_status: None, - asset_status_label: None, - detail_payload_json: Some(detail_payload_json), - created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), - updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), - }); + ctx.db + .custom_world_draft_card() + .insert(CustomWorldDraftCard { + card_id, + session_id: session_id.to_string(), + kind: RpgAgentDraftCardKind::World, + status: RpgAgentDraftCardStatus::Confirmed, + title: read_optional_text_field(draft_profile, &["name", "title"]) + .unwrap_or_else(|| "世界底稿".to_string()), + subtitle: read_optional_text_field(draft_profile, &["subtitle"]) + .unwrap_or_default(), + summary: read_optional_text_field(draft_profile, &["summary"]) + .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()), + linked_ids_json: "[]".to_string(), + warning_count: 0, + asset_status: None, + asset_status_label: None, + detail_payload_json: Some(detail_payload_json), + created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), + updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), + }); } Ok(()) @@ -2788,7 +2860,10 @@ fn sync_session_draft_profile_from_card_update( let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) .unwrap_or_else(|| build_minimal_draft_profile_from_seed(&session.seed_text)); if card.kind == RpgAgentDraftCardKind::World { - draft_profile.insert("name".to_string(), JsonValue::String(updated_title.to_string())); + draft_profile.insert( + "name".to_string(), + JsonValue::String(updated_title.to_string()), + ); draft_profile.insert( "subtitle".to_string(), JsonValue::String(updated_subtitle.to_string()), @@ -2808,8 +2883,12 @@ fn sync_session_draft_profile_from_card_update( rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { - draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)), - publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)), + draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( + draft_profile.clone(), + ))?)), + publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( + &gate, + ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, @@ -2824,7 +2903,10 @@ fn sync_session_draft_profile_from_card_update( } fn ensure_refining_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { - if matches!(stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining) { + if matches!( + stage, + RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining + ) { Ok(()) } else { Err(format!( @@ -2933,10 +3015,7 @@ fn read_required_payload_text( .ok_or_else(|| error_message.to_string()) } -fn read_optional_text_field( - object: &JsonMap, - keys: &[&str], -) -> Option { +fn read_optional_text_field(object: &JsonMap, keys: &[&str]) -> Option { for key in keys { let mut current = JsonValue::Object(object.clone()); let mut found = true; @@ -2949,7 +3028,11 @@ fn read_optional_text_field( } } if found { - if let Some(value) = current.as_str().map(str::trim).filter(|value| !value.is_empty()) { + if let Some(value) = current + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + { return Some(value.to_string()); } } @@ -3144,21 +3227,28 @@ fn append_checkpoint_json(current: &str, checkpoint: &JsonValue) -> Result Option { sections.iter().find_map(|entry| { let object = entry.as_object()?; - (object.get("id").and_then(JsonValue::as_str) == Some(target_id)) - .then(|| { - object - .get("value") - .and_then(JsonValue::as_str) - .unwrap_or_default() - .to_string() - }) + (object.get("id").and_then(JsonValue::as_str) == Some(target_id)).then(|| { + object + .get("value") + .and_then(JsonValue::as_str) + .unwrap_or_default() + .to_string() + }) }) } fn json_array_has_non_empty_text(value: Option<&JsonValue>) -> bool { value .and_then(JsonValue::as_array) - .map(|entries| entries.iter().any(|entry| entry.as_str().map(str::trim).filter(|text| !text.is_empty()).is_some())) + .map(|entries| { + entries.iter().any(|entry| { + entry + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) + .is_some() + }) + }) .unwrap_or(false) } @@ -3341,12 +3431,14 @@ fn build_custom_world_agent_session_snapshot( recommended_replies_json: row.recommended_replies_json.clone(), asset_coverage_json: row.asset_coverage_json.clone(), checkpoints_json: row.checkpoints_json.clone(), - supported_actions_json: serialize_json_value(&JsonValue::Array(build_supported_actions_json( - row.stage, - row.progress_percent, - &build_custom_world_publish_gate_from_session(row), - &parse_json_array_or_empty(&row.checkpoints_json), - ))) + supported_actions_json: serialize_json_value(&JsonValue::Array( + build_supported_actions_json( + row.stage, + row.progress_percent, + &build_custom_world_publish_gate_from_session(row), + &parse_json_array_or_empty(&row.checkpoints_json), + ), + )) .unwrap_or_else(|_| "[]".to_string()), messages, draft_cards, diff --git a/server-rs/crates/spacetime-module/src/custom_world/profile.rs b/server-rs/crates/spacetime-module/src/custom_world/profile.rs new file mode 100644 index 00000000..5309edb9 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/custom_world/profile.rs @@ -0,0 +1 @@ +// Custom World profile 读写落位点。 diff --git a/server-rs/crates/spacetime-module/src/custom_world/publishing.rs b/server-rs/crates/spacetime-module/src/custom_world/publishing.rs new file mode 100644 index 00000000..e4362af7 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/custom_world/publishing.rs @@ -0,0 +1 @@ +// Custom World publish gate、published profile compile 与 publish_world 落位点。 diff --git a/server-rs/crates/spacetime-module/src/custom_world/session.rs b/server-rs/crates/spacetime-module/src/custom_world/session.rs new file mode 100644 index 00000000..17ee5c61 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/custom_world/session.rs @@ -0,0 +1 @@ +// Custom World 旧 session 与 agent session 真相表落位点。 diff --git a/server-rs/crates/spacetime-module/src/custom_world/works.rs b/server-rs/crates/spacetime-module/src/custom_world/works.rs new file mode 100644 index 00000000..ea80320e --- /dev/null +++ b/server-rs/crates/spacetime-module/src/custom_world/works.rs @@ -0,0 +1 @@ +// Custom World works 聚合与 work summary 落位点。 diff --git a/server-rs/crates/spacetime-module/src/domain_types.rs b/server-rs/crates/spacetime-module/src/domain_types.rs index 27892a43..28290b2c 100644 --- a/server-rs/crates/spacetime-module/src/domain_types.rs +++ b/server-rs/crates/spacetime-module/src/domain_types.rs @@ -1,3 +1,5 @@ +use crate::*; + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ResolveNpcBattleInteractionInput { pub npc_interaction: ResolveNpcInteractionInput, @@ -17,7 +19,7 @@ pub struct ResolveNpcBattleInteractionInput { // 输出同时返回 NPC 交互结果与 battle_state 快照,避免 Axum 再回头读取 private table。 #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct NpcBattleInteractionResult { - pub interaction: module_npc::NpcInteractionResult, + pub interaction: NpcInteractionResult, pub battle_state: BattleStateSnapshot, } diff --git a/server-rs/crates/spacetime-module/src/entry.rs b/server-rs/crates/spacetime-module/src/entry.rs index 34a98165..29ca7385 100644 --- a/server-rs/crates/spacetime-module/src/entry.rs +++ b/server-rs/crates/spacetime-module/src/entry.rs @@ -1,3 +1,5 @@ +use crate::*; + // 当前阶段先落可发布的最小模块入口,后续再补对象确认、业务绑定与任务编排 reducer。 #[spacetimedb::reducer(init)] pub fn init(_ctx: &ReducerContext) { diff --git a/server-rs/crates/spacetime-module/src/gameplay/combat.rs b/server-rs/crates/spacetime-module/src/gameplay/combat.rs new file mode 100644 index 00000000..27ba632e --- /dev/null +++ b/server-rs/crates/spacetime-module/src/gameplay/combat.rs @@ -0,0 +1 @@ +// Combat 相关表、procedure 与 helper 落位点。 diff --git a/server-rs/crates/spacetime-module/src/gameplay/inventory.rs b/server-rs/crates/spacetime-module/src/gameplay/inventory.rs new file mode 100644 index 00000000..dcc3f4bc --- /dev/null +++ b/server-rs/crates/spacetime-module/src/gameplay/inventory.rs @@ -0,0 +1 @@ +// Inventory 相关表、procedure 与 helper 落位点。 diff --git a/server-rs/crates/spacetime-module/src/gameplay/npc.rs b/server-rs/crates/spacetime-module/src/gameplay/npc.rs new file mode 100644 index 00000000..628acd9d --- /dev/null +++ b/server-rs/crates/spacetime-module/src/gameplay/npc.rs @@ -0,0 +1 @@ +// NPC 相关表、procedure 与 helper 落位点。 diff --git a/server-rs/crates/spacetime-module/src/gameplay/progression.rs b/server-rs/crates/spacetime-module/src/gameplay/progression.rs new file mode 100644 index 00000000..f1eafbb7 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/gameplay/progression.rs @@ -0,0 +1 @@ +// Progression 相关表、procedure 与 helper 落位点。 diff --git a/server-rs/crates/spacetime-module/src/gameplay/quest.rs b/server-rs/crates/spacetime-module/src/gameplay/quest.rs new file mode 100644 index 00000000..54250846 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/gameplay/quest.rs @@ -0,0 +1 @@ +// Quest 相关表、procedure 与 helper 落位点。 diff --git a/server-rs/crates/spacetime-module/src/gameplay/runtime_item.rs b/server-rs/crates/spacetime-module/src/gameplay/runtime_item.rs new file mode 100644 index 00000000..6e0392e2 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/gameplay/runtime_item.rs @@ -0,0 +1 @@ +// Runtime item / treasure 相关表、procedure 与 helper 落位点。 diff --git a/server-rs/crates/spacetime-module/src/gameplay/story.rs b/server-rs/crates/spacetime-module/src/gameplay/story.rs new file mode 100644 index 00000000..c74d620f --- /dev/null +++ b/server-rs/crates/spacetime-module/src/gameplay/story.rs @@ -0,0 +1 @@ +// Story session / story event 相关表、procedure 与 helper 落位点。 diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index a5ecfa03..cb2dbb80 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -1,299 +1,39 @@ -use module_ai::{ - AI_RESULT_REF_ID_PREFIX, AI_TEXT_CHUNK_ID_PREFIX, AiResultReferenceInput, - AiResultReferenceKind, AiResultReferenceSnapshot, AiStageCompletionInput, AiTaskCancelInput, - AiTaskCreateInput, AiTaskFailureInput, AiTaskFinishInput, AiTaskKind, AiTaskProcedureResult, - AiTaskSnapshot, AiTaskStageKind, AiTaskStageSnapshot, AiTaskStageStartInput, AiTaskStageStatus, - AiTaskStartInput, AiTaskStatus, AiTextChunkAppendInput, AiTextChunkSnapshot, - INITIAL_AI_TASK_VERSION, generate_ai_result_ref_id, generate_ai_task_stage_id, - generate_ai_text_chunk_id, normalize_optional_text, normalize_string_list, - validate_task_create_input, -}; -use module_assets::{ - ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingInput, - AssetEntityBindingProcedureResult, AssetEntityBindingSnapshot, AssetObjectAccessPolicy, - AssetObjectProcedureResult, AssetObjectUpsertInput, AssetObjectUpsertSnapshot, - INITIAL_ASSET_OBJECT_VERSION, validate_asset_entity_binding_fields, - validate_asset_object_fields, -}; -use module_big_fish::{ - BigFishAgentMessageKind, BigFishAgentMessageRole, BigFishAgentMessageSnapshot, - BigFishAssetGenerateInput, BigFishAssetKind, BigFishAssetSlotSnapshot, BigFishAssetStatus, - BigFishCreationStage, BigFishDraftCompileInput, BigFishMessageSubmitInput, BigFishPublishInput, - BigFishRunGetInput, BigFishRunInputSubmitInput, BigFishRunProcedureResult, - BigFishRunStartInput, BigFishRunStatus, BigFishRuntimeSnapshot, BigFishSessionCreateInput, - BigFishSessionGetInput, BigFishSessionProcedureResult, BigFishSessionSnapshot, - advance_runtime_snapshot, build_asset_coverage, build_generated_asset_slot, - build_initial_runtime_snapshot, compile_default_draft, deserialize_anchor_pack, - deserialize_draft, deserialize_runtime_snapshot, empty_anchor_pack, infer_anchor_pack, - serialize_anchor_pack, serialize_asset_coverage, serialize_draft, serialize_runtime_snapshot, - validate_asset_generate_input, validate_draft_compile_input, validate_message_submit_input, - validate_publish_input, validate_run_get_input, validate_run_input_submit_input, - validate_run_start_input, validate_session_create_input, validate_session_get_input, -}; -use module_combat::{ - BATTLE_STATE_ID_PREFIX, BattleMode, BattleStateInput, BattleStateProcedureResult, - BattleStateQueryInput, BattleStateSnapshot, BattleStatus, CombatOutcome, - INITIAL_BATTLE_VERSION, ResolveCombatActionInput, ResolveCombatActionProcedureResult, - build_battle_state_snapshot, generate_battle_state_id, - resolve_combat_action as resolve_battle_state_action, validate_battle_state_input, - validate_battle_state_query_input, -}; -use module_custom_world::{ - CustomWorldAgentActionExecuteInput, CustomWorldAgentActionExecuteResult, - CustomWorldAgentCardDetailGetInput, CustomWorldAgentMessageSnapshot, - CustomWorldAgentMessageFinalizeInput, - CustomWorldAgentMessageSubmitInput, CustomWorldAgentOperationGetInput, - CustomWorldAgentOperationProcedureResult, CustomWorldAgentOperationSnapshot, - CustomWorldAgentSessionCreateInput, CustomWorldAgentSessionGetInput, - CustomWorldAgentSessionProcedureResult, CustomWorldAgentSessionSnapshot, - CustomWorldDraftCardDetailResult, CustomWorldDraftCardDetailSectionSnapshot, - CustomWorldDraftCardDetailSnapshot, CustomWorldDraftCardSnapshot, - CustomWorldGalleryDetailInput, CustomWorldGalleryEntrySnapshot, CustomWorldGalleryListResult, - CustomWorldGenerationMode, CustomWorldLibraryDetailInput, CustomWorldLibraryMutationResult, - CustomWorldProfileListInput, CustomWorldProfileListResult, CustomWorldProfilePublishInput, - CustomWorldProfileSnapshot, CustomWorldProfileUnpublishInput, CustomWorldProfileUpsertInput, - CustomWorldPublicationStatus, CustomWorldPublishBlockerSnapshot, - CustomWorldPublishGateSnapshot, CustomWorldPublishWorldInput, CustomWorldPublishWorldResult, - CustomWorldPublishedProfileCompileInput, CustomWorldPublishedProfileCompileResult, - CustomWorldRoleAssetStatus, CustomWorldSessionStatus, CustomWorldThemeMode, - CustomWorldWorkSummarySnapshot, CustomWorldWorksListInput, CustomWorldWorksListResult, - RpgAgentDraftCardKind, RpgAgentDraftCardStatus, RpgAgentMessageKind, RpgAgentMessageRole, - RpgAgentOperationStatus, RpgAgentOperationType, RpgAgentStage, - build_custom_world_published_profile_compile_snapshot, - validate_custom_world_agent_action_execute_input, - validate_custom_world_agent_card_detail_get_input, - validate_custom_world_agent_message_finalize_input, - validate_custom_world_agent_message_submit_input, - validate_custom_world_agent_operation_fields, - validate_custom_world_agent_operation_get_input, - validate_custom_world_agent_session_create_input, - validate_custom_world_agent_session_get_input, validate_custom_world_gallery_detail_input, - validate_custom_world_library_detail_input, validate_custom_world_profile_delete_input, - validate_custom_world_profile_list_input, validate_custom_world_profile_publish_input, - validate_custom_world_profile_unpublish_input, validate_custom_world_profile_upsert_input, - validate_custom_world_publish_world_input, validate_custom_world_works_list_input, -}; -use module_inventory::{ - GrantInventoryItemInput, INVENTORY_MUTATION_ID_PREFIX, INVENTORY_SLOT_ID_PREFIX, - InventoryContainerKind, InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSnapshot, - InventoryItemSourceKind, InventoryMutation, InventoryMutationInput, InventorySlotSnapshot, - RuntimeInventoryStateProcedureResult, RuntimeInventoryStateQueryInput, - RuntimeInventoryStateSnapshot, apply_inventory_mutation as apply_inventory_slot_mutation, - build_runtime_inventory_state_query_input, build_runtime_inventory_state_snapshot, - generate_inventory_mutation_id, generate_inventory_slot_id, -}; -use module_npc::{ - NPC_FIGHT_FUNCTION_ID, NPC_RECRUIT_AFFINITY_THRESHOLD, NPC_SPAR_FUNCTION_ID, - NPC_STATE_ID_PREFIX, NpcInteractionBattleMode, NpcInteractionProcedureResult, NpcRelationState, - NpcStanceProfile, NpcStateProcedureResult, NpcStateSnapshot, NpcStateUpsertInput, - ResolveNpcInteractionInput, ResolveNpcSocialActionInput, apply_npc_social_action, - generate_npc_state_id, normalize_npc_state_snapshot, - resolve_npc_interaction as resolve_npc_interaction_domain, -}; -use module_progression::{ - ChapterPaceBand, ChapterProgressionGetInput, ChapterProgressionInput, - ChapterProgressionLedgerInput, ChapterProgressionProcedureResult, ChapterProgressionSnapshot, - PlayerProgressionGetInput, PlayerProgressionGrantInput, PlayerProgressionGrantSource, - PlayerProgressionProcedureResult, PlayerProgressionSnapshot, apply_chapter_progression_ledger, - build_chapter_progression_snapshot, create_initial_player_progression, grant_player_experience, -}; -use module_quest::{ - QUEST_LOG_ID_PREFIX, QuestCompletionAckInput, QuestLogEventKind, QuestNarrativeBindingSnapshot, - QuestObjectiveSnapshot, QuestProgressSignal, QuestRecordInput, QuestRecordSnapshot, - QuestRewardEquipmentSlot, QuestRewardItem, QuestRewardItemRarity, QuestRewardSnapshot, - QuestSignalApplyInput, QuestSignalKind, QuestStatus, QuestStepSnapshot, QuestTurnInInput, - acknowledge_quest_completion as acknowledge_quest_record_completion, - apply_quest_signal as apply_quest_record_signal, build_quest_record_snapshot, - generate_quest_log_id, turn_in_quest_record, -}; -use module_runtime::{ - DEFAULT_MUSIC_VOLUME, DEFAULT_PLATFORM_THEME, DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT, - PROFILE_WALLET_LEDGER_LIST_LIMIT, RuntimeBrowseHistoryClearInput, - RuntimeBrowseHistoryListInput, RuntimeBrowseHistoryProcedureResult, - RuntimeBrowseHistorySnapshot, RuntimeBrowseHistorySyncInput, RuntimeBrowseHistoryThemeMode, - RuntimePlatformTheme, RuntimeProfileDashboardGetInput, RuntimeProfileDashboardProcedureResult, - RuntimeProfileDashboardSnapshot, RuntimeProfilePlayStatsGetInput, - RuntimeProfilePlayStatsProcedureResult, RuntimeProfilePlayStatsSnapshot, - RuntimeProfilePlayedWorldSnapshot, RuntimeProfileSaveArchiveListInput, - RuntimeProfileSaveArchiveProcedureResult, RuntimeProfileSaveArchiveResumeInput, - RuntimeProfileSaveArchiveSnapshot, RuntimeProfileWalletLedgerEntrySnapshot, - RuntimeProfileWalletLedgerListInput, RuntimeProfileWalletLedgerProcedureResult, - RuntimeProfileWalletLedgerSourceType, RuntimeSettingGetInput, RuntimeSettingProcedureResult, - RuntimeSettingSnapshot, RuntimeSettingUpsertInput, RuntimeSnapshot, RuntimeSnapshotDeleteInput, - RuntimeSnapshotGetInput, RuntimeSnapshotProcedureResult, RuntimeSnapshotUpsertInput, - SAVE_SNAPSHOT_VERSION, build_runtime_browse_history_clear_input, - build_runtime_browse_history_list_input, build_runtime_profile_dashboard_get_input, - build_runtime_profile_play_stats_get_input, build_runtime_profile_save_archive_list_input, - build_runtime_profile_save_archive_resume_input, - build_runtime_profile_wallet_ledger_list_input, build_runtime_setting_get_input, - build_runtime_setting_upsert_input, build_runtime_snapshot_delete_input, - build_runtime_snapshot_get_input, build_runtime_snapshot_upsert_input, - prepare_runtime_browse_history_entries, -}; -use module_runtime_item::{ - RuntimeItemRewardItemSnapshot, TREASURE_RECORD_ID_PREFIX, TreasureInteractionAction, - TreasureRecordProcedureResult, TreasureRecordSnapshot, TreasureResolveInput, - build_inventory_item_snapshot_from_reward_item, build_treasure_record_snapshot, -}; -use module_story::{ - INITIAL_STORY_SESSION_VERSION, STORY_EVENT_ID_PREFIX, STORY_SESSION_ID_PREFIX, - StoryContinueInput, StoryEventKind, StoryEventSnapshot, StorySessionInput, - StorySessionProcedureResult, StorySessionSnapshot, StorySessionStateInput, - StorySessionStateProcedureResult, StorySessionStatus, apply_story_continue, - build_story_session_snapshot, build_story_started_event, validate_story_continue_input, - validate_story_session_input, validate_story_session_state_input, -}; -use serde_json::{Map as JsonMap, Value as JsonValue, json}; -use shared_kernel::format_timestamp_micros; -use spacetimedb::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; +pub use module_ai::*; +pub use module_assets::*; +pub use module_big_fish::*; +pub use module_combat::*; +pub use module_custom_world::*; +pub use module_inventory::*; +pub use module_npc::*; +pub use module_progression::*; +pub use module_quest::*; +pub use module_runtime::*; +pub use module_runtime_item::*; +pub use module_story::*; +use module_combat::resolve_combat_action as resolve_battle_state_action; +use module_inventory::apply_inventory_mutation as apply_inventory_slot_mutation; +use module_npc::resolve_npc_interaction as resolve_npc_interaction_domain; +use module_quest::{ + acknowledge_quest_completion as acknowledge_quest_record_completion, + apply_quest_signal as apply_quest_record_signal, + normalize_optional_text as normalize_quest_optional_text, + normalize_string_list as normalize_quest_string_list, +}; +pub(crate) use serde_json::{json, Map as JsonMap, Value as JsonValue}; +pub(crate) use shared_kernel::format_timestamp_micros; +pub use spacetimedb::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; + +mod asset_metadata; +mod big_fish; +mod domain_types; +mod entry; mod puzzle; -// 这层输入只服务 NPC 开战编排;普通聊天、援手、招募继续走已有 resolve_npc_interaction 接口。 -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] -pub struct ResolveNpcBattleInteractionInput { - pub npc_interaction: ResolveNpcInteractionInput, - pub story_session_id: String, - pub actor_user_id: String, - pub battle_state_id: Option, - pub player_hp: i32, - pub player_max_hp: i32, - pub player_mana: i32, - pub player_max_mana: i32, - pub target_hp: i32, - pub target_max_hp: i32, - pub experience_reward: u32, - pub reward_items: Vec, -} - -// 输出同时返回 NPC 交互结果与 battle_state 快照,避免 Axum 再回头读取 private table。 -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] -pub struct NpcBattleInteractionResult { - pub interaction: module_npc::NpcInteractionResult, - pub battle_state: BattleStateSnapshot, -} - -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] -pub struct NpcBattleInteractionProcedureResult { - pub ok: bool, - pub result: Option, - pub error_message: Option, -} - -#[spacetimedb::table( - accessor = big_fish_creation_session, - index(accessor = by_big_fish_session_owner_user_id, btree(columns = [owner_user_id])) -)] -pub struct BigFishCreationSession { - #[primary_key] - session_id: String, - owner_user_id: String, - seed_text: String, - current_turn: u32, - progress_percent: u32, - stage: BigFishCreationStage, - anchor_pack_json: String, - draft_json: Option, - asset_coverage_json: String, - last_assistant_reply: Option, - publish_ready: bool, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = big_fish_agent_message, - index(accessor = by_big_fish_message_session_id, btree(columns = [session_id])) -)] -pub struct BigFishAgentMessage { - #[primary_key] - message_id: String, - session_id: String, - role: BigFishAgentMessageRole, - kind: BigFishAgentMessageKind, - text: String, - created_at: Timestamp, -} - -#[spacetimedb::table( - accessor = big_fish_asset_slot, - index(accessor = by_big_fish_asset_session_id, btree(columns = [session_id])) -)] -pub struct BigFishAssetSlot { - #[primary_key] - slot_id: String, - session_id: String, - asset_kind: BigFishAssetKind, - level: Option, - motion_key: Option, - status: BigFishAssetStatus, - asset_url: Option, - prompt_snapshot: String, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = big_fish_runtime_run, - index(accessor = by_big_fish_run_owner_user_id, btree(columns = [owner_user_id])), - index(accessor = by_big_fish_run_session_id, btree(columns = [session_id])) -)] -pub struct BigFishRuntimeRun { - #[primary_key] - run_id: String, - session_id: String, - owner_user_id: String, - status: BigFishRunStatus, - snapshot_json: String, - last_input_x: f32, - last_input_y: f32, - tick: u64, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = asset_object, - index(accessor = by_bucket_object_key, btree(columns = [bucket, object_key])) -)] -pub struct AssetObject { - #[primary_key] - asset_object_id: String, - // 正式对象定位固定拆成 bucket + object_key 两列,避免后续再从单字符串路径做 schema 拆分。 - bucket: String, - object_key: String, - access_policy: AssetObjectAccessPolicy, - content_type: Option, - content_length: u64, - content_hash: Option, - version: u32, - source_job_id: Option, - owner_user_id: Option, - profile_id: Option, - entity_id: Option, - #[index(btree)] - asset_kind: String, - created_at: Timestamp, - updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = asset_entity_binding, - index(accessor = by_entity_slot, btree(columns = [entity_kind, entity_id, slot])), - index(accessor = by_asset_object_id, btree(columns = [asset_object_id])) -)] -pub struct AssetEntityBinding { - #[primary_key] - binding_id: String, - asset_object_id: String, - entity_kind: String, - entity_id: String, - slot: String, - asset_kind: String, - owner_user_id: Option, - profile_id: Option, - created_at: Timestamp, - updated_at: Timestamp, -} +pub use asset_metadata::*; +pub use big_fish::*; +pub use domain_types::*; +pub use entry::*; #[spacetimedb::table(accessor = runtime_setting)] pub struct RuntimeSetting { @@ -790,6 +530,10 @@ pub struct CustomWorldProfile { profile_id: String, // 当前 profile 承接 library / publish / enter-world 的正式世界工件真相。 owner_user_id: String, + // 作品公开编号是稳定分享键,第一次发布时分配,后续重复发布沿用。 + public_work_code: Option, + // 作者公开叙世号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。 + author_public_user_code: Option, source_agent_session_id: Option, publication_status: CustomWorldPublicationStatus, world_name: String, @@ -929,13 +673,16 @@ pub struct CustomWorldDraftCard { accessor = custom_world_gallery_entry, public, index(accessor = by_custom_world_gallery_owner_user_id, btree(columns = [owner_user_id])), - index(accessor = by_custom_world_gallery_theme_mode, btree(columns = [theme_mode])) + index(accessor = by_custom_world_gallery_theme_mode, btree(columns = [theme_mode])), + index(accessor = by_custom_world_gallery_public_work_code, btree(columns = [public_work_code])) )] pub struct CustomWorldGalleryEntry { #[primary_key] profile_id: String, // 画廊是公开订阅读模型,不再运行时从 profile 即席拼装。 owner_user_id: String, + public_work_code: String, + author_public_user_code: String, author_display_name: String, world_name: String, subtitle: String, @@ -948,30 +695,6 @@ pub struct CustomWorldGalleryEntry { updated_at: Timestamp, } -// 当前阶段先落可发布的最小模块入口,后续再补对象确认、业务绑定与任务编排 reducer。 -#[spacetimedb::reducer(init)] -pub fn init(_ctx: &ReducerContext) { - log::info!( - "spacetime-module 初始化完成,asset_object 已固定 bucket/object_key 双列主存储口径,runtime_setting 已固定默认音量={} 和默认主题={},battle_state 前缀={},战斗初始版本={},npc_state 前缀={},npc 招募阈值={},story_session 前缀={},story_event 前缀={},inventory_slot 前缀={},inventory_mutation 前缀={},quest_log 前缀={},treasure_record 前缀={},player_progression 与 chapter_progression 已接入成长真相表,M5 custom_world_profile/session/agent/gallery 首批表骨架已接入,默认对象 ID 前缀={},默认绑定 ID 前缀={},资产初始版本={},故事会话初始版本={}", - DEFAULT_MUSIC_VOLUME, - DEFAULT_PLATFORM_THEME.as_str(), - BATTLE_STATE_ID_PREFIX, - INITIAL_BATTLE_VERSION, - NPC_STATE_ID_PREFIX, - NPC_RECRUIT_AFFINITY_THRESHOLD, - STORY_SESSION_ID_PREFIX, - STORY_EVENT_ID_PREFIX, - INVENTORY_SLOT_ID_PREFIX, - INVENTORY_MUTATION_ID_PREFIX, - QUEST_LOG_ID_PREFIX, - TREASURE_RECORD_ID_PREFIX, - ASSET_OBJECT_ID_PREFIX, - ASSET_BINDING_ID_PREFIX, - INITIAL_ASSET_OBJECT_VERSION, - INITIAL_STORY_SESSION_VERSION - ); -} - // 成长状态默认按 user_id 单行持久化;若尚未存在记录则返回 Lv.1 / 0 XP 的兼容初始值。 #[spacetimedb::procedure] pub fn get_player_progression_or_default( @@ -1583,177 +1306,6 @@ pub fn get_story_session_state( } } -#[spacetimedb::procedure] -pub fn create_big_fish_session( - ctx: &mut ProcedureContext, - input: BigFishSessionCreateInput, -) -> BigFishSessionProcedureResult { - match ctx.try_with_tx(|tx| create_big_fish_session_tx(tx, input.clone())) { - Ok(session) => BigFishSessionProcedureResult { - ok: true, - session: Some(session), - error_message: None, - }, - Err(message) => BigFishSessionProcedureResult { - ok: false, - session: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn get_big_fish_session( - ctx: &mut ProcedureContext, - input: BigFishSessionGetInput, -) -> BigFishSessionProcedureResult { - match ctx.try_with_tx(|tx| get_big_fish_session_tx(tx, input.clone())) { - Ok(session) => BigFishSessionProcedureResult { - ok: true, - session: Some(session), - error_message: None, - }, - Err(message) => BigFishSessionProcedureResult { - ok: false, - session: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn submit_big_fish_message( - ctx: &mut ProcedureContext, - input: BigFishMessageSubmitInput, -) -> BigFishSessionProcedureResult { - match ctx.try_with_tx(|tx| submit_big_fish_message_tx(tx, input.clone())) { - Ok(session) => BigFishSessionProcedureResult { - ok: true, - session: Some(session), - error_message: None, - }, - Err(message) => BigFishSessionProcedureResult { - ok: false, - session: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn compile_big_fish_draft( - ctx: &mut ProcedureContext, - input: BigFishDraftCompileInput, -) -> BigFishSessionProcedureResult { - match ctx.try_with_tx(|tx| compile_big_fish_draft_tx(tx, input.clone())) { - Ok(session) => BigFishSessionProcedureResult { - ok: true, - session: Some(session), - error_message: None, - }, - Err(message) => BigFishSessionProcedureResult { - ok: false, - session: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn generate_big_fish_asset( - ctx: &mut ProcedureContext, - input: BigFishAssetGenerateInput, -) -> BigFishSessionProcedureResult { - match ctx.try_with_tx(|tx| generate_big_fish_asset_tx(tx, input.clone())) { - Ok(session) => BigFishSessionProcedureResult { - ok: true, - session: Some(session), - error_message: None, - }, - Err(message) => BigFishSessionProcedureResult { - ok: false, - session: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn publish_big_fish_game( - ctx: &mut ProcedureContext, - input: BigFishPublishInput, -) -> BigFishSessionProcedureResult { - match ctx.try_with_tx(|tx| publish_big_fish_game_tx(tx, input.clone())) { - Ok(session) => BigFishSessionProcedureResult { - ok: true, - session: Some(session), - error_message: None, - }, - Err(message) => BigFishSessionProcedureResult { - ok: false, - session: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn start_big_fish_run( - ctx: &mut ProcedureContext, - input: BigFishRunStartInput, -) -> BigFishRunProcedureResult { - match ctx.try_with_tx(|tx| start_big_fish_run_tx(tx, input.clone())) { - Ok(run) => BigFishRunProcedureResult { - ok: true, - run: Some(run), - error_message: None, - }, - Err(message) => BigFishRunProcedureResult { - ok: false, - run: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn submit_big_fish_input( - ctx: &mut ProcedureContext, - input: BigFishRunInputSubmitInput, -) -> BigFishRunProcedureResult { - match ctx.try_with_tx(|tx| submit_big_fish_input_tx(tx, input.clone())) { - Ok(run) => BigFishRunProcedureResult { - ok: true, - run: Some(run), - error_message: None, - }, - Err(message) => BigFishRunProcedureResult { - ok: false, - run: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn get_big_fish_run( - ctx: &mut ProcedureContext, - input: BigFishRunGetInput, -) -> BigFishRunProcedureResult { - match ctx.try_with_tx(|tx| get_big_fish_run_tx(tx, input.clone())) { - Ok(run) => BigFishRunProcedureResult { - ok: true, - run: Some(run), - error_message: None, - }, - Err(message) => BigFishRunProcedureResult { - ok: false, - run: None, - error_message: Some(message), - }, - } -} - // Stage 6 先把 Agent 会话骨架写入 SpacetimeDB,LLM 采集与卡片生成后续再接入。 #[spacetimedb::procedure] pub fn create_custom_world_agent_session( @@ -1940,481 +1492,6 @@ fn get_story_session_state_tx( Ok((session_snapshot, events)) } -fn create_big_fish_session_tx( - ctx: &ReducerContext, - input: BigFishSessionCreateInput, -) -> Result { - validate_session_create_input(&input).map_err(|error| error.to_string())?; - if ctx - .db - .big_fish_creation_session() - .session_id() - .find(&input.session_id) - .is_some() - { - return Err("big_fish_creation_session.session_id 已存在".to_string()); - } - if ctx - .db - .big_fish_agent_message() - .message_id() - .find(&input.welcome_message_id) - .is_some() - { - return Err("big_fish_agent_message.message_id 已存在".to_string()); - } - - let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); - let anchor_pack = infer_anchor_pack(&input.seed_text, None); - let asset_coverage = build_asset_coverage(None, &[]); - ctx.db - .big_fish_creation_session() - .insert(BigFishCreationSession { - session_id: input.session_id.clone(), - owner_user_id: input.owner_user_id.clone(), - seed_text: input.seed_text.trim().to_string(), - current_turn: 0, - progress_percent: 20, - stage: BigFishCreationStage::CollectingAnchors, - anchor_pack_json: serialize_anchor_pack(&anchor_pack) - .map_err(|error| error.to_string())?, - draft_json: None, - asset_coverage_json: serialize_asset_coverage(&asset_coverage) - .map_err(|error| error.to_string())?, - last_assistant_reply: Some(input.welcome_message_text.clone()), - publish_ready: false, - created_at, - updated_at: created_at, - }); - ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { - message_id: input.welcome_message_id, - session_id: input.session_id.clone(), - role: BigFishAgentMessageRole::Assistant, - kind: BigFishAgentMessageKind::Chat, - text: input.welcome_message_text, - created_at, - }); - - get_big_fish_session_tx( - ctx, - BigFishSessionGetInput { - session_id: input.session_id, - owner_user_id: input.owner_user_id, - }, - ) -} - -fn get_big_fish_session_tx( - ctx: &ReducerContext, - input: BigFishSessionGetInput, -) -> Result { - validate_session_get_input(&input).map_err(|error| error.to_string())?; - let session = ctx - .db - .big_fish_creation_session() - .session_id() - .find(&input.session_id) - .filter(|row| row.owner_user_id == input.owner_user_id) - .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; - - build_big_fish_session_snapshot(ctx, &session) -} - -fn submit_big_fish_message_tx( - ctx: &ReducerContext, - input: BigFishMessageSubmitInput, -) -> Result { - validate_message_submit_input(&input).map_err(|error| error.to_string())?; - let session = ctx - .db - .big_fish_creation_session() - .session_id() - .find(&input.session_id) - .filter(|row| row.owner_user_id == input.owner_user_id) - .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; - if ctx - .db - .big_fish_agent_message() - .message_id() - .find(&input.user_message_id) - .is_some() - { - return Err("big_fish_agent_message.user_message_id 已存在".to_string()); - } - if ctx - .db - .big_fish_agent_message() - .message_id() - .find(&input.assistant_message_id) - .is_some() - { - return Err("big_fish_agent_message.assistant_message_id 已存在".to_string()); - } - - let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); - ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { - message_id: input.user_message_id, - session_id: input.session_id.clone(), - role: BigFishAgentMessageRole::User, - kind: BigFishAgentMessageKind::Chat, - text: input.user_message_text.trim().to_string(), - created_at: submitted_at, - }); - - let anchor_pack = infer_anchor_pack(&session.seed_text, Some(&input.user_message_text)); - let assistant_text = - "我已经把这版方向收束成 4 个高杠杆锚点,可以继续细化,也可以直接编译第一版玩法草稿。" - .to_string(); - ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { - message_id: input.assistant_message_id, - session_id: input.session_id.clone(), - role: BigFishAgentMessageRole::Assistant, - kind: BigFishAgentMessageKind::Summary, - text: assistant_text.clone(), - created_at: submitted_at, - }); - - let next_session = BigFishCreationSession { - session_id: session.session_id.clone(), - owner_user_id: session.owner_user_id.clone(), - seed_text: session.seed_text.clone(), - current_turn: session.current_turn.saturating_add(1), - progress_percent: 60, - stage: BigFishCreationStage::CollectingAnchors, - anchor_pack_json: serialize_anchor_pack(&anchor_pack).map_err(|error| error.to_string())?, - draft_json: session.draft_json.clone(), - asset_coverage_json: session.asset_coverage_json.clone(), - last_assistant_reply: Some(assistant_text), - publish_ready: session.publish_ready, - created_at: session.created_at, - updated_at: submitted_at, - }; - replace_big_fish_session(ctx, &session, next_session); - - get_big_fish_session_tx( - ctx, - BigFishSessionGetInput { - session_id: input.session_id, - owner_user_id: input.owner_user_id, - }, - ) -} - -fn compile_big_fish_draft_tx( - ctx: &ReducerContext, - input: BigFishDraftCompileInput, -) -> Result { - validate_draft_compile_input(&input).map_err(|error| error.to_string())?; - let session = ctx - .db - .big_fish_creation_session() - .session_id() - .find(&input.session_id) - .filter(|row| row.owner_user_id == input.owner_user_id) - .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; - let anchor_pack = - deserialize_anchor_pack(&session.anchor_pack_json).map_err(|error| error.to_string())?; - let draft = compile_default_draft(&anchor_pack); - let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id); - let coverage = build_asset_coverage(Some(&draft), &asset_slots); - let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); - let reply = "第一版玩法草稿已编译完成,可以在结果页逐级生成主图、动作和场地背景。".to_string(); - - let next_session = BigFishCreationSession { - session_id: session.session_id.clone(), - owner_user_id: session.owner_user_id.clone(), - seed_text: session.seed_text.clone(), - current_turn: session.current_turn, - progress_percent: 80, - stage: BigFishCreationStage::DraftReady, - anchor_pack_json: session.anchor_pack_json.clone(), - draft_json: Some(serialize_draft(&draft).map_err(|error| error.to_string())?), - asset_coverage_json: serialize_asset_coverage(&coverage) - .map_err(|error| error.to_string())?, - last_assistant_reply: Some(reply.clone()), - publish_ready: coverage.publish_ready, - created_at: session.created_at, - updated_at: compiled_at, - }; - replace_big_fish_session(ctx, &session, next_session); - append_big_fish_system_message( - ctx, - &input.session_id, - format!("big-fish-message-compile-{}", input.compiled_at_micros), - reply, - input.compiled_at_micros, - ); - - get_big_fish_session_tx( - ctx, - BigFishSessionGetInput { - session_id: input.session_id, - owner_user_id: input.owner_user_id, - }, - ) -} - -fn generate_big_fish_asset_tx( - ctx: &ReducerContext, - input: BigFishAssetGenerateInput, -) -> Result { - let session = ctx - .db - .big_fish_creation_session() - .session_id() - .find(&input.session_id) - .filter(|row| row.owner_user_id == input.owner_user_id) - .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; - let draft = session - .draft_json - .as_deref() - .ok_or_else(|| "big_fish.draft 尚未编译".to_string()) - .and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?; - validate_asset_generate_input(&input, &draft).map_err(|error| error.to_string())?; - - let slot = build_generated_asset_slot( - &input.session_id, - &draft, - input.asset_kind, - input.level, - input.motion_key.clone(), - input.asset_url.clone(), - input.generated_at_micros, - ) - .map_err(|error| error.to_string())?; - upsert_big_fish_asset_slot(ctx, slot); - - let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id); - let coverage = build_asset_coverage(Some(&draft), &asset_slots); - let updated_at = Timestamp::from_micros_since_unix_epoch(input.generated_at_micros); - let uses_placeholder = input - .asset_url - .as_deref() - .map(str::trim) - .is_none_or(str::is_empty); - let reply = match (input.asset_kind, uses_placeholder) { - (BigFishAssetKind::LevelMainImage, true) => "本级主图占位图已生成,可在结果页继续预览。", - (BigFishAssetKind::LevelMainImage, false) => "本级主图已正式生成,可在结果页继续预览。", - (BigFishAssetKind::LevelMotion, true) => "本级动作占位图已生成,可在结果页继续预览。", - (BigFishAssetKind::LevelMotion, false) => "本级动作图已正式生成,可在结果页继续预览。", - (BigFishAssetKind::StageBackground, true) => "活动区域背景占位图已生成,可在结果页继续预览。", - (BigFishAssetKind::StageBackground, false) => "活动区域背景已正式生成,可在结果页继续预览。", - } - .to_string(); - let next_stage = if coverage.publish_ready { - BigFishCreationStage::ReadyToPublish - } else { - BigFishCreationStage::AssetRefining - }; - let next_session = BigFishCreationSession { - session_id: session.session_id.clone(), - owner_user_id: session.owner_user_id.clone(), - seed_text: session.seed_text.clone(), - current_turn: session.current_turn, - progress_percent: if coverage.publish_ready { 96 } else { 88 }, - stage: next_stage, - anchor_pack_json: session.anchor_pack_json.clone(), - draft_json: session.draft_json.clone(), - asset_coverage_json: serialize_asset_coverage(&coverage) - .map_err(|error| error.to_string())?, - last_assistant_reply: Some(reply.clone()), - publish_ready: coverage.publish_ready, - created_at: session.created_at, - updated_at, - }; - replace_big_fish_session(ctx, &session, next_session); - append_big_fish_system_message( - ctx, - &input.session_id, - format!("big-fish-message-asset-{}", input.generated_at_micros), - reply, - input.generated_at_micros, - ); - - get_big_fish_session_tx( - ctx, - BigFishSessionGetInput { - session_id: input.session_id, - owner_user_id: input.owner_user_id, - }, - ) -} - -fn publish_big_fish_game_tx( - ctx: &ReducerContext, - input: BigFishPublishInput, -) -> Result { - validate_publish_input(&input).map_err(|error| error.to_string())?; - let session = ctx - .db - .big_fish_creation_session() - .session_id() - .find(&input.session_id) - .filter(|row| row.owner_user_id == input.owner_user_id) - .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; - let draft = session - .draft_json - .as_deref() - .ok_or_else(|| "big_fish.draft 尚未编译".to_string()) - .and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?; - let coverage = build_asset_coverage( - Some(&draft), - &list_big_fish_asset_slots(ctx, &session.session_id), - ); - if !coverage.publish_ready { - return Err(format!( - "big_fish 发布校验未通过:{}", - coverage.blockers.join(";") - )); - } - - let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); - let next_session = BigFishCreationSession { - session_id: session.session_id.clone(), - owner_user_id: session.owner_user_id.clone(), - seed_text: session.seed_text.clone(), - current_turn: session.current_turn, - progress_percent: 100, - stage: BigFishCreationStage::Published, - anchor_pack_json: session.anchor_pack_json.clone(), - draft_json: session.draft_json.clone(), - asset_coverage_json: serialize_asset_coverage(&coverage) - .map_err(|error| error.to_string())?, - last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()), - publish_ready: true, - created_at: session.created_at, - updated_at: published_at, - }; - replace_big_fish_session(ctx, &session, next_session); - - get_big_fish_session_tx( - ctx, - BigFishSessionGetInput { - session_id: input.session_id, - owner_user_id: input.owner_user_id, - }, - ) -} - -fn start_big_fish_run_tx( - ctx: &ReducerContext, - input: BigFishRunStartInput, -) -> Result { - validate_run_start_input(&input).map_err(|error| error.to_string())?; - if ctx - .db - .big_fish_runtime_run() - .run_id() - .find(&input.run_id) - .is_some() - { - return Err("big_fish_runtime_run.run_id 已存在".to_string()); - } - let session = ctx - .db - .big_fish_creation_session() - .session_id() - .find(&input.session_id) - .filter(|row| row.owner_user_id == input.owner_user_id) - .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; - let draft = session - .draft_json - .as_deref() - .ok_or_else(|| "big_fish.draft 尚未编译".to_string()) - .and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?; - let snapshot = build_initial_runtime_snapshot( - input.run_id.clone(), - input.session_id.clone(), - &draft, - input.started_at_micros, - ); - let now = Timestamp::from_micros_since_unix_epoch(input.started_at_micros); - ctx.db.big_fish_runtime_run().insert(BigFishRuntimeRun { - run_id: input.run_id, - session_id: input.session_id, - owner_user_id: input.owner_user_id, - status: snapshot.status, - snapshot_json: serialize_runtime_snapshot(&snapshot).map_err(|error| error.to_string())?, - last_input_x: 0.0, - last_input_y: 0.0, - tick: snapshot.tick, - created_at: now, - updated_at: now, - }); - - Ok(snapshot) -} - -fn submit_big_fish_input_tx( - ctx: &ReducerContext, - input: BigFishRunInputSubmitInput, -) -> Result { - validate_run_input_submit_input(&input).map_err(|error| error.to_string())?; - let run = ctx - .db - .big_fish_runtime_run() - .run_id() - .find(&input.run_id) - .filter(|row| row.owner_user_id == input.owner_user_id) - .ok_or_else(|| "big_fish_runtime_run 不存在".to_string())?; - let session = ctx - .db - .big_fish_creation_session() - .session_id() - .find(&run.session_id) - .filter(|row| row.owner_user_id == input.owner_user_id) - .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; - let draft = session - .draft_json - .as_deref() - .ok_or_else(|| "big_fish.draft 尚未编译".to_string()) - .and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?; - let current_snapshot = - deserialize_runtime_snapshot(&run.snapshot_json).map_err(|error| error.to_string())?; - let next_snapshot = advance_runtime_snapshot( - current_snapshot, - &draft.runtime_params, - input.input_x, - input.input_y, - input.submitted_at_micros, - ); - replace_big_fish_run( - ctx, - &run, - BigFishRuntimeRun { - run_id: run.run_id.clone(), - session_id: run.session_id.clone(), - owner_user_id: run.owner_user_id.clone(), - status: next_snapshot.status, - snapshot_json: serialize_runtime_snapshot(&next_snapshot) - .map_err(|error| error.to_string())?, - last_input_x: input.input_x, - last_input_y: input.input_y, - tick: next_snapshot.tick, - created_at: run.created_at, - updated_at: Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros), - }, - ); - - Ok(next_snapshot) -} - -fn get_big_fish_run_tx( - ctx: &ReducerContext, - input: BigFishRunGetInput, -) -> Result { - validate_run_get_input(&input).map_err(|error| error.to_string())?; - let run = ctx - .db - .big_fish_runtime_run() - .run_id() - .find(&input.run_id) - .filter(|row| row.owner_user_id == input.owner_user_id) - .ok_or_else(|| "big_fish_runtime_run 不存在".to_string())?; - - deserialize_runtime_snapshot(&run.snapshot_json).map_err(|error| error.to_string()) -} - fn create_custom_world_agent_session_tx( ctx: &ReducerContext, input: CustomWorldAgentSessionCreateInput, @@ -2642,10 +1719,9 @@ fn finalize_custom_world_agent_message_turn_tx( }, )? } else { - let assistant_message_id = input - .assistant_message_id - .clone() - .ok_or_else(|| "custom_world_agent_message.assistant_message_id 不能为空".to_string())?; + let assistant_message_id = input.assistant_message_id.clone().ok_or_else(|| { + "custom_world_agent_message.assistant_message_id 不能为空".to_string() + })?; let assistant_reply_text = input .assistant_reply_text .clone() @@ -3051,64 +2127,6 @@ pub fn turn_in_quest(ctx: &ReducerContext, input: QuestTurnInInput) -> Result<() Ok(()) } -// reducer 负责固定资产对象的正式写规则,供后续内部模块逻辑复用。 -#[spacetimedb::reducer] -pub fn confirm_asset_object( - ctx: &ReducerContext, - input: AssetObjectUpsertInput, -) -> Result<(), String> { - upsert_asset_object(ctx, input).map(|_| ()) -} - -// procedure 面向 Axum 同步确认接口,返回最终持久化后的对象记录,避免 HTTP 层再额外查询 private table。 -#[spacetimedb::procedure] -pub fn confirm_asset_object_and_return( - ctx: &mut ProcedureContext, - input: AssetObjectUpsertInput, -) -> AssetObjectProcedureResult { - match ctx.try_with_tx(|tx| upsert_asset_object(tx, input.clone())) { - Ok(record) => AssetObjectProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => AssetObjectProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// reducer 负责把已确认对象绑定到实体槽位,强业务资产表稳定前先用通用绑定表承接关系。 -#[spacetimedb::reducer] -pub fn bind_asset_object_to_entity( - ctx: &ReducerContext, - input: AssetEntityBindingInput, -) -> Result<(), String> { - upsert_asset_entity_binding(ctx, input).map(|_| ()) -} - -// procedure 面向 Axum 同步绑定接口,返回最终绑定快照,避免 HTTP 层读取 private table。 -#[spacetimedb::procedure] -pub fn bind_asset_object_to_entity_and_return( - ctx: &mut ProcedureContext, - input: AssetEntityBindingInput, -) -> AssetEntityBindingProcedureResult { - match ctx.try_with_tx(|tx| upsert_asset_entity_binding(tx, input.clone())) { - Ok(record) => AssetEntityBindingProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => AssetEntityBindingProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - // procedure 面向 Axum 同步读取设置;若没有持久化记录则返回默认值快照,但不产生额外写入。 #[spacetimedb::procedure] pub fn get_runtime_setting_or_default( @@ -3515,6 +2533,27 @@ pub fn get_custom_world_gallery_detail( } } +#[spacetimedb::procedure] +pub fn get_custom_world_gallery_detail_by_code( + ctx: &mut ProcedureContext, + input: module_custom_world::CustomWorldGalleryDetailByCodeInput, +) -> CustomWorldLibraryMutationResult { + match ctx.try_with_tx(|tx| get_custom_world_gallery_detail_record_by_code(tx, input.clone())) { + Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { + ok: true, + entry, + gallery_entry, + error_message: None, + }, + Err(message) => CustomWorldLibraryMutationResult { + ok: false, + entry: None, + gallery_entry: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn list_custom_world_works( ctx: &mut ProcedureContext, @@ -3829,113 +2868,6 @@ fn build_treasure_record_row( } } -fn upsert_asset_object( - ctx: &ReducerContext, - input: AssetObjectUpsertInput, -) -> Result { - validate_asset_object_fields( - &input.bucket, - &input.object_key, - &input.asset_kind, - input.version, - ) - .map_err(|error| error.to_string())?; - - let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); - // 这里先保持最小可发布实现:查重语义已经冻结,后续再把实现优化回组合索引扫描。 - let current = ctx - .db - .asset_object() - .iter() - .find(|row| row.bucket == input.bucket && row.object_key == input.object_key); - - let snapshot = match current { - Some(existing) => { - ctx.db - .asset_object() - .asset_object_id() - .delete(&existing.asset_object_id); - let row = AssetObject { - asset_object_id: existing.asset_object_id.clone(), - bucket: input.bucket.clone(), - object_key: input.object_key.clone(), - access_policy: input.access_policy, - content_type: input.content_type.clone(), - content_length: input.content_length, - content_hash: input.content_hash.clone(), - version: input.version, - source_job_id: input.source_job_id.clone(), - owner_user_id: input.owner_user_id.clone(), - profile_id: input.profile_id.clone(), - entity_id: input.entity_id.clone(), - asset_kind: input.asset_kind.clone(), - created_at: existing.created_at, - updated_at, - }; - ctx.db.asset_object().insert(row); - - AssetObjectUpsertSnapshot { - asset_object_id: existing.asset_object_id, - bucket: input.bucket, - object_key: input.object_key, - access_policy: input.access_policy, - content_type: input.content_type, - content_length: input.content_length, - content_hash: input.content_hash, - version: input.version, - source_job_id: input.source_job_id, - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - entity_id: input.entity_id, - asset_kind: input.asset_kind, - created_at_micros: existing.created_at.to_micros_since_unix_epoch(), - updated_at_micros: input.updated_at_micros, - } - } - None => { - let created_at = updated_at; - let row = AssetObject { - asset_object_id: input.asset_object_id.clone(), - bucket: input.bucket.clone(), - object_key: input.object_key.clone(), - access_policy: input.access_policy, - content_type: input.content_type.clone(), - content_length: input.content_length, - content_hash: input.content_hash.clone(), - version: input.version, - source_job_id: input.source_job_id.clone(), - owner_user_id: input.owner_user_id.clone(), - profile_id: input.profile_id.clone(), - entity_id: input.entity_id.clone(), - asset_kind: input.asset_kind.clone(), - created_at, - updated_at, - }; - ctx.db.asset_object().insert(row); - - AssetObjectUpsertSnapshot { - asset_object_id: input.asset_object_id, - bucket: input.bucket, - object_key: input.object_key, - access_policy: input.access_policy, - content_type: input.content_type, - content_length: input.content_length, - content_hash: input.content_hash, - version: input.version, - source_job_id: input.source_job_id, - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - entity_id: input.entity_id, - asset_kind: input.asset_kind, - created_at_micros: input.updated_at_micros, - updated_at_micros: input.updated_at_micros, - } - } - }; - - Ok(snapshot) -} - fn upsert_custom_world_profile_record( ctx: &ReducerContext, input: CustomWorldProfileUpsertInput, @@ -3956,15 +2888,14 @@ fn upsert_custom_world_profile_record( .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) .or_else(|| { - input.source_agent_session_id.as_ref().and_then(|session_id| { - ctx.db.custom_world_profile().iter().find(|row| { - is_same_agent_draft_profile_candidate( - row, - &input.owner_user_id, - session_id, - ) + input + .source_agent_session_id + .as_ref() + .and_then(|session_id| { + ctx.db.custom_world_profile().iter().find(|row| { + is_same_agent_draft_profile_candidate(row, &input.owner_user_id, session_id) + }) }) - }) }); let next_row = match current { @@ -3976,6 +2907,8 @@ fn upsert_custom_world_profile_record( CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), + public_work_code: existing.public_work_code.clone(), + author_public_user_code: existing.author_public_user_code.clone(), source_agent_session_id: input.source_agent_session_id.clone(), publication_status: existing.publication_status, world_name: input.world_name.clone(), @@ -3996,6 +2929,8 @@ fn upsert_custom_world_profile_record( None => CustomWorldProfile { profile_id: input.profile_id.clone(), owner_user_id: input.owner_user_id.clone(), + public_work_code: input.public_work_code.clone(), + author_public_user_code: input.author_public_user_code.clone(), source_agent_session_id: input.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Draft, world_name: input.world_name.clone(), @@ -4067,6 +3002,8 @@ fn publish_custom_world_world_record( CustomWorldProfileUpsertInput { profile_id: compiled_record.profile_id.clone(), owner_user_id: compiled_record.owner_user_id.clone(), + public_work_code: input.public_work_code.clone(), + author_public_user_code: Some(input.author_public_user_code.clone()), source_agent_session_id: Some(input.session_id.clone()), world_name: compiled_record.world_name.clone(), subtitle: compiled_record.subtitle.clone(), @@ -4086,6 +3023,8 @@ fn publish_custom_world_world_record( CustomWorldProfilePublishInput { profile_id: compiled_record.profile_id.clone(), owner_user_id: compiled_record.owner_user_id.clone(), + public_work_code: input.public_work_code.clone(), + author_public_user_code: input.author_public_user_code.clone(), author_display_name: compiled_record.author_display_name.clone(), published_at_micros: input.published_at_micros, }, @@ -4131,6 +3070,11 @@ fn publish_custom_world_profile_record( let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), + public_work_code: existing + .public_work_code + .clone() + .or_else(|| Some(build_public_work_code_from_profile_id(&existing.profile_id))), + author_public_user_code: Some(input.author_public_user_code.clone()), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Published, world_name: existing.world_name.clone(), @@ -4192,6 +3136,8 @@ fn unpublish_custom_world_profile_record( let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), + public_work_code: existing.public_work_code.clone(), + author_public_user_code: existing.author_public_user_code.clone(), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Draft, world_name: existing.world_name.clone(), @@ -4249,6 +3195,8 @@ fn delete_custom_world_profile_record( let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), + public_work_code: existing.public_work_code.clone(), + author_public_user_code: existing.author_public_user_code.clone(), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Draft, world_name: existing.world_name.clone(), @@ -4385,6 +3333,48 @@ fn get_custom_world_gallery_detail_record( )) } +fn get_custom_world_gallery_detail_record_by_code( + ctx: &ReducerContext, + input: module_custom_world::CustomWorldGalleryDetailByCodeInput, +) -> Result< + ( + Option, + Option, + ), + String, +> { + validate_custom_world_gallery_detail_by_code_input(&input) + .map_err(|error| error.to_string())?; + + let normalized_public_work_code = normalize_public_work_code(&input.public_work_code) + .ok_or_else(|| "public_work_code 格式不正确".to_string())?; + + let gallery_entry = ctx + .db + .custom_world_gallery_entry() + .iter() + .find(|row| row.public_work_code == normalized_public_work_code); + + let profile = gallery_entry.as_ref().and_then(|row| { + ctx.db + .custom_world_profile() + .profile_id() + .find(&row.profile_id) + .filter(|profile_row| { + profile_row.owner_user_id == row.owner_user_id + && profile_row.publication_status == CustomWorldPublicationStatus::Published + && profile_row.deleted_at.is_none() + }) + }); + + Ok(( + profile.as_ref().map(build_custom_world_profile_snapshot), + gallery_entry + .as_ref() + .map(build_custom_world_gallery_entry_snapshot), + )) +} + fn list_custom_world_work_snapshots( ctx: &ReducerContext, input: CustomWorldWorksListInput, @@ -4572,16 +3562,13 @@ fn execute_draft_foundation_action( } let updated_at = input.submitted_at_micros; - let draft_profile = - if let Some(profile) = payload.get("draftProfile").and_then(JsonValue::as_object) { - profile.clone() - } else if let Some(existing) = - parse_optional_session_object(session.draft_profile_json.as_deref()) - { - ensure_minimal_draft_profile(existing, &session.seed_text) - } else { - build_minimal_draft_profile_from_seed(&session.seed_text) - }; + let draft_profile = payload + .get("draftProfile") + .and_then(JsonValue::as_object) + .cloned() + .ok_or_else(|| { + "draft_foundation requires externally generated payload.draftProfile".to_string() + })?; let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile.clone())) .map_err(|error| format!("draft_foundation 无法序列化 draft_profile_json: {error}"))?; @@ -4808,7 +3795,10 @@ fn execute_sync_result_profile_action( .ok_or_else(|| "sync_result_profile requires profile".to_string())?; if let Some(stable_profile_id) = resolve_stable_agent_draft_profile_id(session) { // 结果页回写时必须沿用当前草稿的稳定身份,避免把同一草稿写成新条目。 - profile.insert("id".to_string(), JsonValue::String(stable_profile_id.clone())); + profile.insert( + "id".to_string(), + JsonValue::String(stable_profile_id.clone()), + ); upsert_nested_result_profile_id(&mut profile, &stable_profile_id); } let draft_profile = ensure_minimal_draft_profile(profile, &session.seed_text); @@ -4867,12 +3857,14 @@ fn execute_sync_result_profile_action( } fn resolve_stable_agent_draft_profile_id(session: &CustomWorldAgentSession) -> Option { - parse_optional_session_object(session.draft_profile_json.as_deref()).and_then(|profile| { - read_optional_text_field(&profile, &["legacyResultProfile.id", "id"]) - }) + parse_optional_session_object(session.draft_profile_json.as_deref()) + .and_then(|profile| read_optional_text_field(&profile, &["legacyResultProfile.id", "id"])) } -fn upsert_nested_result_profile_id(profile: &mut JsonMap, stable_profile_id: &str) { +fn upsert_nested_result_profile_id( + profile: &mut JsonMap, + stable_profile_id: &str, +) { let legacy_result_profile = profile .entry("legacyResultProfile".to_string()) .or_insert_with(|| JsonValue::Object(JsonMap::new())); @@ -4947,6 +3939,14 @@ fn execute_publish_world_action( session_id: session.session_id.clone(), profile_id, owner_user_id: session.owner_user_id.clone(), + public_work_code: None, + author_public_user_code: session + .owner_user_id + .trim_start_matches("user_") + .parse::() + .ok() + .map(|sequence| format!("SY-{sequence:08}")) + .unwrap_or_else(|| "SY-00000000".to_string()), draft_profile_json: serialize_json_value(&JsonValue::Object(draft_profile.clone()))?, legacy_result_profile_json, setting_text, @@ -5189,14 +4189,35 @@ fn summarize_publish_gate_from_json( } if let Some(profile) = draft_profile { - if read_optional_text_field(profile, &["worldHook"]).is_none() { + if read_optional_text_field( + profile, + &[ + "worldHook", + "creatorIntent.worldHook", + "anchorContent.worldPromise.hook", + "settingText", + ], + ) + .is_none() + { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_world_hook".to_string(), code: "publish_missing_world_hook".to_string(), message: "当前世界缺少 world hook,发布前需要先补齐世界一句话钩子。".to_string(), }); } - if read_optional_text_field(profile, &["playerPremise"]).is_none() { + if read_optional_text_field( + profile, + &[ + "playerPremise", + "creatorIntent.playerPremise", + "anchorContent.playerEntryPoint.openingIdentity", + "anchorContent.playerEntryPoint.openingProblem", + "anchorContent.playerEntryPoint.entryMotivation", + ], + ) + .is_none() + { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_player_premise".to_string(), code: "publish_missing_player_premise".to_string(), @@ -5211,12 +4232,22 @@ fn summarize_publish_gate_from_json( message: "当前世界缺少核心冲突,发布前需要先补齐核心冲突。".to_string(), }); } - if profile + let has_main_chapter = profile .get("chapters") .and_then(JsonValue::as_array) - .map(|value| value.is_empty()) - .unwrap_or(true) - { + .map(|value| !value.is_empty()) + .unwrap_or(false) + || profile + .get("sceneChapterBlueprints") + .and_then(JsonValue::as_array) + .map(|value| !value.is_empty()) + .unwrap_or(false) + || profile + .get("sceneChapters") + .and_then(JsonValue::as_array) + .map(|value| !value.is_empty()) + .unwrap_or(false); + if !has_main_chapter { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_main_chapter".to_string(), code: "publish_missing_main_chapter".to_string(), @@ -5224,7 +4255,8 @@ fn summarize_publish_gate_from_json( }); } let has_scene_act = profile - .get("sceneChapters") + .get("sceneChapterBlueprints") + .or_else(|| profile.get("sceneChapters")) .and_then(JsonValue::as_array) .map(|chapters| { chapters.iter().any(|chapter| { @@ -6180,6 +5212,9 @@ fn ensure_minimal_draft_profile( .entry("sceneChapters".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile + .entry("sceneChapterBlueprints".to_string()) + .or_insert_with(|| JsonValue::Array(Vec::new())); + profile } fn build_minimal_draft_profile_from_seed(seed_text: &str) -> JsonMap { @@ -6319,6 +5354,16 @@ fn sync_custom_world_gallery_entry_from_profile( let row = CustomWorldGalleryEntry { profile_id: profile.profile_id.clone(), owner_user_id: profile.owner_user_id.clone(), + public_work_code: profile + .public_work_code + .clone() + .ok_or_else(|| "published profile 缺少 public_work_code,无法同步 gallery".to_string())?, + author_public_user_code: profile + .author_public_user_code + .clone() + .ok_or_else(|| { + "published profile 缺少 author_public_user_code,无法同步 gallery".to_string() + })?, author_display_name: profile.author_display_name.clone(), world_name: profile.world_name.clone(), subtitle: profile.subtitle.clone(), @@ -6340,6 +5385,8 @@ fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldP CustomWorldProfileSnapshot { profile_id: row.profile_id.clone(), owner_user_id: row.owner_user_id.clone(), + public_work_code: row.public_work_code.clone(), + author_public_user_code: row.author_public_user_code.clone(), source_agent_session_id: row.source_agent_session_id.clone(), publication_status: row.publication_status, world_name: row.world_name.clone(), @@ -6492,6 +5539,8 @@ fn build_custom_world_gallery_entry_snapshot( CustomWorldGalleryEntrySnapshot { profile_id: row.profile_id.clone(), owner_user_id: row.owner_user_id.clone(), + public_work_code: row.public_work_code.clone(), + author_public_user_code: row.author_public_user_code.clone(), author_display_name: row.author_display_name.clone(), world_name: row.world_name.clone(), subtitle: row.subtitle.clone(), @@ -6505,6 +5554,43 @@ fn build_custom_world_gallery_entry_snapshot( } } +// 作品公开号保持稳定公开语义,本期先由 profile_id 派生 deterministic fallback, +// 后续若引入独立 sequence 表,可无痛替换生成来源而不影响读写接口。 +fn build_public_work_code_from_profile_id(profile_id: &str) -> String { + let digits = profile_id + .chars() + .filter(|character| character.is_ascii_digit()) + .collect::(); + 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}") +} + +fn normalize_public_work_code(input: &str) -> Option { + let normalized = input + .trim() + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .collect::() + .to_ascii_uppercase(); + let digits = normalized.strip_prefix("CW").unwrap_or(&normalized); + if digits.is_empty() + || digits.len() > 8 + || !digits.chars().all(|character| character.is_ascii_digit()) + { + return None; + } + + Some(format!("CW-{digits:0>8}")) +} + fn create_ai_task_tx( ctx: &ReducerContext, input: AiTaskCreateInput, @@ -6634,9 +5720,9 @@ fn complete_ai_stage_tx( stage.status = AiTaskStageStatus::Completed; stage.completed_at_micros = Some(input.completed_at_micros); - stage.text_output = normalize_optional_text(input.text_output.clone()); - stage.structured_payload_json = normalize_optional_text(input.structured_payload_json.clone()); - stage.warning_messages = normalize_string_list(input.warning_messages.clone()); + stage.text_output = normalize_quest_optional_text(input.text_output.clone()); + stage.structured_payload_json = normalize_quest_optional_text(input.structured_payload_json.clone()); + stage.warning_messages = normalize_quest_string_list(input.warning_messages.clone()); snapshot.latest_text_output = stage.text_output.clone(); snapshot.latest_structured_payload_json = stage.structured_payload_json.clone(); @@ -6664,7 +5750,7 @@ fn attach_ai_result_reference_tx( task_id: input.task_id.trim().to_string(), reference_kind: input.reference_kind, reference_id, - label: normalize_optional_text(input.label), + label: normalize_quest_optional_text(input.label), created_at_micros: input.created_at_micros, }; ctx.db @@ -6814,8 +5900,8 @@ fn build_ai_task_snapshot_from_create_input(input: &AiTaskCreateInput) -> AiTask owner_user_id: input.owner_user_id.trim().to_string(), request_label: input.request_label.trim().to_string(), source_module: input.source_module.trim().to_string(), - source_entity_id: normalize_optional_text(input.source_entity_id.clone()), - request_payload_json: normalize_optional_text(input.request_payload_json.clone()), + source_entity_id: normalize_quest_optional_text(input.source_entity_id.clone()), + request_payload_json: normalize_quest_optional_text(input.request_payload_json.clone()), status: AiTaskStatus::Pending, failure_message: None, stages: input @@ -7665,105 +6751,6 @@ fn try_update_chapter_progression_ledger_tx( update_chapter_progression_ledger_tx(ctx, input).map(Some) } -fn upsert_asset_entity_binding( - ctx: &ReducerContext, - input: AssetEntityBindingInput, -) -> Result { - validate_asset_entity_binding_fields( - &input.binding_id, - &input.asset_object_id, - &input.entity_kind, - &input.entity_id, - &input.slot, - &input.asset_kind, - ) - .map_err(|error| error.to_string())?; - - if ctx - .db - .asset_object() - .asset_object_id() - .find(&input.asset_object_id) - .is_none() - { - return Err("asset_entity_binding.asset_object_id 对应的 asset_object 不存在".to_string()); - } - - let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); - // 首版绑定按 entity_kind + entity_id + slot 幂等定位,后续访问量明确后再改为组合索引扫描。 - let current = ctx.db.asset_entity_binding().iter().find(|row| { - row.entity_kind == input.entity_kind - && row.entity_id == input.entity_id - && row.slot == input.slot - }); - - let snapshot = match current { - Some(existing) => { - ctx.db - .asset_entity_binding() - .binding_id() - .delete(&existing.binding_id); - let row = AssetEntityBinding { - binding_id: existing.binding_id.clone(), - asset_object_id: input.asset_object_id.clone(), - entity_kind: input.entity_kind.clone(), - entity_id: input.entity_id.clone(), - slot: input.slot.clone(), - asset_kind: input.asset_kind.clone(), - owner_user_id: input.owner_user_id.clone(), - profile_id: input.profile_id.clone(), - created_at: existing.created_at, - updated_at, - }; - ctx.db.asset_entity_binding().insert(row); - - AssetEntityBindingSnapshot { - binding_id: existing.binding_id, - asset_object_id: input.asset_object_id, - entity_kind: input.entity_kind, - entity_id: input.entity_id, - slot: input.slot, - asset_kind: input.asset_kind, - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - created_at_micros: existing.created_at.to_micros_since_unix_epoch(), - updated_at_micros: input.updated_at_micros, - } - } - None => { - let created_at = updated_at; - let row = AssetEntityBinding { - binding_id: input.binding_id.clone(), - asset_object_id: input.asset_object_id.clone(), - entity_kind: input.entity_kind.clone(), - entity_id: input.entity_id.clone(), - slot: input.slot.clone(), - asset_kind: input.asset_kind.clone(), - owner_user_id: input.owner_user_id.clone(), - profile_id: input.profile_id.clone(), - created_at, - updated_at, - }; - ctx.db.asset_entity_binding().insert(row); - - AssetEntityBindingSnapshot { - binding_id: input.binding_id, - asset_object_id: input.asset_object_id, - entity_kind: input.entity_kind, - entity_id: input.entity_id, - slot: input.slot, - asset_kind: input.asset_kind, - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - created_at_micros: input.updated_at_micros, - updated_at_micros: input.updated_at_micros, - } - } - }; - - Ok(snapshot) -} - fn get_runtime_setting_snapshot( ctx: &ReducerContext, input: RuntimeSettingGetInput, @@ -9006,157 +7993,6 @@ fn build_npc_state_snapshot_from_row(row: &NpcState) -> NpcStateSnapshot { } } -fn build_big_fish_session_snapshot( - ctx: &ReducerContext, - row: &BigFishCreationSession, -) -> Result { - let anchor_pack = - deserialize_anchor_pack(&row.anchor_pack_json).unwrap_or_else(|_| empty_anchor_pack()); - let draft = row - .draft_json - .as_deref() - .map(deserialize_draft) - .transpose() - .map_err(|error| format!("big_fish.draft_json 非法: {error}"))?; - let asset_slots = list_big_fish_asset_slots(ctx, &row.session_id); - let asset_coverage = build_asset_coverage(draft.as_ref(), &asset_slots); - let mut messages = ctx - .db - .big_fish_agent_message() - .iter() - .filter(|message| message.session_id == row.session_id) - .map(|message| BigFishAgentMessageSnapshot { - message_id: message.message_id, - session_id: message.session_id, - role: message.role, - kind: message.kind, - text: message.text, - created_at_micros: message.created_at.to_micros_since_unix_epoch(), - }) - .collect::>(); - messages.sort_by_key(|message| (message.created_at_micros, message.message_id.clone())); - - Ok(BigFishSessionSnapshot { - session_id: row.session_id.clone(), - owner_user_id: row.owner_user_id.clone(), - seed_text: row.seed_text.clone(), - current_turn: row.current_turn, - progress_percent: row.progress_percent, - stage: row.stage, - anchor_pack, - draft, - asset_slots, - asset_coverage, - messages, - last_assistant_reply: row.last_assistant_reply.clone(), - publish_ready: row.publish_ready, - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - }) -} - -fn list_big_fish_asset_slots( - ctx: &ReducerContext, - session_id: &str, -) -> Vec { - let mut slots = ctx - .db - .big_fish_asset_slot() - .iter() - .filter(|slot| slot.session_id == session_id) - .map(|slot| BigFishAssetSlotSnapshot { - slot_id: slot.slot_id, - session_id: slot.session_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_micros: slot.updated_at.to_micros_since_unix_epoch(), - }) - .collect::>(); - slots.sort_by_key(|slot| { - ( - slot.level.unwrap_or(0), - slot.asset_kind.as_str().to_string(), - slot.motion_key.clone().unwrap_or_default(), - slot.slot_id.clone(), - ) - }); - slots -} - -fn replace_big_fish_session( - ctx: &ReducerContext, - current: &BigFishCreationSession, - next: BigFishCreationSession, -) { - ctx.db - .big_fish_creation_session() - .session_id() - .delete(¤t.session_id); - ctx.db.big_fish_creation_session().insert(next); -} - -fn replace_big_fish_run( - ctx: &ReducerContext, - current: &BigFishRuntimeRun, - next: BigFishRuntimeRun, -) { - ctx.db - .big_fish_runtime_run() - .run_id() - .delete(¤t.run_id); - ctx.db.big_fish_runtime_run().insert(next); -} - -fn upsert_big_fish_asset_slot(ctx: &ReducerContext, slot: BigFishAssetSlotSnapshot) { - if let Some(existing) = ctx.db.big_fish_asset_slot().slot_id().find(&slot.slot_id) { - ctx.db - .big_fish_asset_slot() - .slot_id() - .delete(&existing.slot_id); - } - ctx.db.big_fish_asset_slot().insert(BigFishAssetSlot { - slot_id: slot.slot_id, - session_id: slot.session_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: Timestamp::from_micros_since_unix_epoch(slot.updated_at_micros), - }); -} - -fn append_big_fish_system_message( - ctx: &ReducerContext, - session_id: &str, - message_id: String, - text: String, - created_at_micros: i64, -) { - if ctx - .db - .big_fish_agent_message() - .message_id() - .find(&message_id) - .is_some() - { - return; - } - ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { - message_id, - session_id: session_id.to_string(), - role: BigFishAgentMessageRole::Assistant, - kind: BigFishAgentMessageKind::ActionResult, - text, - created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros), - }); -} - #[cfg(test)] mod tests { use super::*; @@ -9204,6 +8040,8 @@ mod tests { let matching = CustomWorldProfile { profile_id: "profile-1".to_string(), owner_user_id: "user-1".to_string(), + public_work_code: None, + author_public_user_code: None, source_agent_session_id: Some("session-1".to_string()), publication_status: CustomWorldPublicationStatus::Draft, world_name: "潮雾列岛".to_string(), @@ -9223,6 +8061,8 @@ mod tests { let deleted = CustomWorldProfile { profile_id: "profile-1".to_string(), owner_user_id: "user-1".to_string(), + public_work_code: None, + author_public_user_code: None, source_agent_session_id: Some("session-1".to_string()), publication_status: CustomWorldPublicationStatus::Draft, world_name: "潮雾列岛".to_string(), @@ -9242,6 +8082,8 @@ mod tests { let published = CustomWorldProfile { profile_id: "profile-1".to_string(), owner_user_id: "user-1".to_string(), + public_work_code: Some("CW-00000001".to_string()), + author_public_user_code: Some("SY-00000001".to_string()), source_agent_session_id: Some("session-1".to_string()), publication_status: CustomWorldPublicationStatus::Published, world_name: "潮雾列岛".to_string(), @@ -9285,4 +8127,80 @@ mod tests { "session-1", )); } + + #[test] + fn summarize_publish_gate_accepts_current_agent_result_schema() { + let draft_profile = serde_json::from_str::( + r#"{ + "id":"agent-draft-session-1", + "settingText":"海雾会吞掉记错航线的人。", + "creatorIntent":{"playerPremise":"玩家是带着旧航海日志返乡的守灯人。"}, + "anchorContent":{ + "worldPromise":{"hook":"在失真的海图上追查一场被篡改的沉船事故。"}, + "playerEntryPoint":{ + "openingIdentity":"被停职返乡的守灯人", + "openingProblem":"灯塔记录被人改写", + "entryMotivation":"查清父亲沉船真相" + } + }, + "coreConflicts":["群岛议会试图掩盖沉船真相。"], + "sceneChapterBlueprints":[ + { + "id":"scene-chapter-1", + "sceneId":"landmark-1", + "title":"失灯港", + "acts":[ + { + "id":"act-1", + "title":"第一幕" + } + ] + } + ] + }"#, + ) + .expect("draft profile should be valid json") + .as_object() + .cloned() + .expect("draft profile should be object"); + + let gate = summarize_publish_gate_from_json( + "session-1", + RpgAgentStage::ReadyToPublish, + Some(&draft_profile), + &[], + ); + + assert!(gate.publish_ready); + assert_eq!(gate.blocker_count, 0); + assert!(gate.blockers.is_empty()); + } + + #[test] + fn ensure_minimal_draft_profile_includes_scene_chapter_blueprints_slot() { + let profile = ensure_minimal_draft_profile(JsonMap::new(), "旧航路群岛"); + + assert_eq!( + profile.get("sceneChapterBlueprints"), + Some(&JsonValue::Array(Vec::new())) + ); + } + + #[test] + fn draft_foundation_payload_must_contain_external_draft_profile() { + let payload = JsonMap::new(); + + let result = payload + .get("draftProfile") + .and_then(JsonValue::as_object) + .cloned() + .ok_or_else(|| { + "draft_foundation requires externally generated payload.draftProfile".to_string() + }); + + assert_eq!( + result.expect_err("missing draftProfile should be rejected"), + "draft_foundation requires externally generated payload.draftProfile" + ); + } } diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index dceb2046..64d1b93d 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1,8 +1,8 @@ use module_puzzle::{ - PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageKind, PuzzleAgentMessageRole, - PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput, - PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, PuzzleAgentStage, - PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate, + PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind, + PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, + PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, + PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, @@ -158,6 +158,25 @@ pub fn submit_puzzle_agent_message( } } +#[spacetimedb::procedure] +pub fn finalize_puzzle_agent_message_turn( + ctx: &mut ProcedureContext, + input: PuzzleAgentMessageFinalizeInput, +) -> PuzzleAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| finalize_puzzle_agent_message_turn_tx(tx, input.clone())) { + Ok(session) => PuzzleAgentSessionProcedureResult { + ok: true, + session_json: Some(serialize_json(&session)), + error_message: None, + }, + Err(message) => PuzzleAgentSessionProcedureResult { + ok: false, + session_json: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn compile_puzzle_agent_draft( ctx: &mut ProcedureContext, @@ -472,11 +491,9 @@ fn submit_puzzle_agent_message_tx( ctx: &TxContext, input: module_puzzle::PuzzleAgentMessageSubmitInput, ) -> Result { - let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; + get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; ensure_message_missing(ctx, &input.user_message_id)?; let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); - let next_anchor_pack = infer_anchor_pack(&row.seed_text, Some(&input.user_message_text)); - let assistant_message_text = build_puzzle_assistant_reply(&next_anchor_pack); ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow { message_id: input.user_message_id.clone(), @@ -486,19 +503,75 @@ fn submit_puzzle_agent_message_tx( text: input.user_message_text.clone(), created_at: submitted_at, }); - let assistant_message_id = format!( - "{}assistant-{}", - input.session_id, input.submitted_at_micros - ); + + get_puzzle_agent_session_tx( + ctx, + PuzzleAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn finalize_puzzle_agent_message_turn_tx( + ctx: &TxContext, + input: PuzzleAgentMessageFinalizeInput, +) -> Result { + let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + + if let Some(error_message) = input + .error_message + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + replace_puzzle_agent_session( + ctx, + &row, + PuzzleAgentSessionRow { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + seed_text: row.seed_text.clone(), + current_turn: row.current_turn, + progress_percent: row.progress_percent, + stage: row.stage, + anchor_pack_json: row.anchor_pack_json.clone(), + draft_json: row.draft_json.clone(), + last_assistant_reply: row.last_assistant_reply.clone(), + published_profile_id: row.published_profile_id.clone(), + created_at: row.created_at, + updated_at, + }, + ); + return Err(error_message.to_string()); + } + + let assistant_message_id = input + .assistant_message_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "拼图 assistant_message_id 不能为空".to_string())? + .to_string(); + let assistant_reply_text = input + .assistant_reply_text + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "拼图 assistant_reply_text 不能为空".to_string())? + .to_string(); + ensure_message_missing(ctx, &assistant_message_id)?; + let next_anchor_pack = deserialize_anchor_pack(&input.anchor_pack_json)?; + ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow { message_id: assistant_message_id, session_id: input.session_id.clone(), role: PuzzleAgentMessageRole::Assistant, - kind: PuzzleAgentMessageKind::Summary, - text: assistant_message_text.clone(), - created_at: submitted_at, + kind: PuzzleAgentMessageKind::Chat, + text: assistant_reply_text.clone(), + created_at: updated_at, }); - replace_puzzle_agent_session( ctx, &row, @@ -507,14 +580,14 @@ fn submit_puzzle_agent_message_tx( owner_user_id: row.owner_user_id.clone(), seed_text: row.seed_text.clone(), current_turn: row.current_turn.saturating_add(1), - progress_percent: (row.progress_percent + 18).min(82), - stage: PuzzleAgentStage::CollectingAnchors, + progress_percent: input.progress_percent.min(100), + stage: input.stage, anchor_pack_json: serialize_json(&next_anchor_pack), draft_json: row.draft_json.clone(), - last_assistant_reply: Some(assistant_message_text), + last_assistant_reply: Some(assistant_reply_text), published_profile_id: row.published_profile_id.clone(), created_at: row.created_at, - updated_at: submitted_at, + updated_at, }, ); @@ -535,6 +608,15 @@ fn compile_puzzle_agent_draft_tx( let anchor_pack = deserialize_anchor_pack(&row.anchor_pack_json)?; let messages = list_session_messages(ctx, &row.session_id); let draft = compile_result_draft(&anchor_pack, &messages); + // 创作中心的拼图草稿卡只是 Agent session 的列表投影, + // 每次编译结果页时同步 upsert,保证后续能按 source_session_id 恢复聊天。 + upsert_puzzle_draft_work_profile( + ctx, + &row.session_id, + &row.owner_user_id, + &draft, + input.compiled_at_micros, + )?; let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); replace_puzzle_agent_session( ctx, @@ -601,6 +683,14 @@ fn save_puzzle_generated_images_tx( } else { PuzzleAgentStage::ImageRefining }; + // 结果页草稿封面和候选图发生变化后,草稿卡需要同步刷新。 + upsert_puzzle_draft_work_profile( + ctx, + &row.session_id, + &row.owner_user_id, + &draft, + input.saved_at_micros, + )?; replace_puzzle_agent_session( ctx, &row, @@ -642,6 +732,14 @@ fn select_puzzle_cover_image_tx( } else { PuzzleAgentStage::ImageRefining }; + // 选定正式封面后,创作中心草稿卡要立即反映最新正式图。 + upsert_puzzle_draft_work_profile( + ctx, + &row.session_id, + &row.owner_user_id, + &draft, + input.selected_at_micros, + )?; replace_puzzle_agent_session( ctx, &row, @@ -682,9 +780,10 @@ fn publish_puzzle_work_tx( input.theme_tags.clone(), ) .map_err(|error| error.to_string())?; + let (work_id, profile_id) = build_puzzle_work_ids_from_session_id(&input.session_id); let mut profile = create_work_profile( - input.work_id.clone(), - input.profile_id.clone(), + work_id, + profile_id, input.owner_user_id.clone(), Some(input.session_id.clone()), input.author_display_name.clone(), @@ -996,6 +1095,42 @@ fn build_puzzle_work_profile_from_row( }) } +fn build_puzzle_work_ids_from_session_id(session_id: &str) -> (String, String) { + let stable_suffix = session_id + .strip_prefix("puzzle-session-") + .unwrap_or(session_id); + ( + format!("puzzle-work-{stable_suffix}"), + format!("puzzle-profile-{stable_suffix}"), + ) +} + +fn upsert_puzzle_draft_work_profile( + ctx: &TxContext, + session_id: &str, + owner_user_id: &str, + draft: &PuzzleResultDraft, + updated_at_micros: i64, +) -> Result<(), String> { + let (work_id, profile_id) = build_puzzle_work_ids_from_session_id(session_id); + if let Some(existing) = ctx.db.puzzle_work_profile().profile_id().find(&profile_id) { + if existing.publication_status == PuzzlePublicationStatus::Published { + return Ok(()); + } + } + let profile = create_work_profile( + work_id, + profile_id, + owner_user_id.to_string(), + Some(session_id.to_string()), + "创作者".to_string(), + draft, + updated_at_micros, + ) + .map_err(|error| error.to_string())?; + upsert_puzzle_work_profile(ctx, profile) +} + fn list_session_messages(ctx: &TxContext, session_id: &str) -> Vec { let mut items = ctx .db @@ -1045,15 +1180,6 @@ fn build_puzzle_suggested_actions( } } -fn build_puzzle_assistant_reply(anchor_pack: &PuzzleAnchorPack) -> String { - format!( - "我先帮你收束成一版拼图方向:题材是“{}”,主体聚焦“{}”,氛围偏“{}”。", - anchor_pack.theme_promise.value, - anchor_pack.visual_subject.value, - anchor_pack.visual_mood.value - ) -} - fn append_system_message( ctx: &TxContext, session_id: &str, diff --git a/server-rs/crates/spacetime-module/src/runtime/browse_history.rs b/server-rs/crates/spacetime-module/src/runtime/browse_history.rs new file mode 100644 index 00000000..622f8e3e --- /dev/null +++ b/server-rs/crates/spacetime-module/src/runtime/browse_history.rs @@ -0,0 +1 @@ +// Browse history 相关表、procedure 与 helper 落位点。 diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs new file mode 100644 index 00000000..d1f1a06c --- /dev/null +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -0,0 +1 @@ +// Profile dashboard、wallet 与 played world 投影落位点。 diff --git a/server-rs/crates/spacetime-module/src/runtime/settings.rs b/server-rs/crates/spacetime-module/src/runtime/settings.rs new file mode 100644 index 00000000..e779b9a4 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/runtime/settings.rs @@ -0,0 +1 @@ +// Runtime settings 相关表、procedure 与 helper 落位点。 diff --git a/server-rs/crates/spacetime-module/src/runtime/snapshots.rs b/server-rs/crates/spacetime-module/src/runtime/snapshots.rs new file mode 100644 index 00000000..fcac58af --- /dev/null +++ b/server-rs/crates/spacetime-module/src/runtime/snapshots.rs @@ -0,0 +1 @@ +// Runtime snapshot 与 save archive 相关表、procedure 与 helper 落位点。