diff --git a/.env.local b/.env.local index 79ef4b7e..593661a6 100644 --- a/.env.local +++ b/.env.local @@ -12,6 +12,7 @@ VOLCENGINE_ACCESS_KEY_ID="AKLTZWFjMmYzZTdjZTIxNDRiNTkzMTZiMTk2NzVmNTUxOGI" VOLCENGINE_SECRET_ACCESS_KEY="TURRMk56bGhZalE0TjJReE5ERmpNMkpoTUdaa1lqRmtaVGt5TVRrM1lXSQ==" WECHAT_AUTH_ENABLED="false" WECHAT_AUTH_PROVIDER="mock" +JWT_EXPIRES_IN="7d" SMS_AUTH_ENABLED="true" SMS_AUTH_PROVIDER="aliyun" diff --git a/AGENTS.md b/AGENTS.md index 6815ea28..b75313a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # AGENTS.md ## 项目约束 +- 在修改server-rs的内容时,不要去兼容server-node中的任何内容,只允许参考,以及把server-node中未迁移到server-rs的内容迁移过来 - 代码需要有完善的中文注释 - 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。 - 对工程的修改不仅要落地到代码更面,还要更改对应文档,若没有生成新的文档,文档统一存在doc目录中 diff --git a/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md b/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md index c2668461..0a0c8f75 100644 --- a/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md +++ b/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md @@ -7,7 +7,7 @@ 本方案为当前仓库补齐 3 条 Jenkins 流水线: 1. `构建`:只负责在仓库根目录执行 `npm run deploy:rust:remote -- --skip-upload`,生成发布包。 -2. `部署`:只负责把指定发布版本部署到 `/var/lib/jenkins/deploy/Genarrative/`,禁止人工直接点击执行,并支持按参数决定是否清空 SpacetimeDB 数据。 +2. `部署`:只负责把指定发布版本部署到 `/var/lib/jenkins/deploy/Genarrative/`,允许人工按参数启动,并支持按参数决定是否清空 SpacetimeDB 数据。 3. `构建并部署`:先构建,再把构建出的版本号传给 `部署` 流水线并等待部署完成;同时暴露 `WEB_PORT` 参数,默认把发布包 Web 端口写成 `80`,并透传是否清库。 本次只补 Jenkins 编排与本地部署脚本,不改现有 Rust 发布包构建逻辑,不恢复旧 `server-node` 部署链。 @@ -16,13 +16,14 @@ 1. 构建产物目录统一使用 `build/<版本号>/`。 2. 默认使用 Jenkins `BUILD_NUMBER` 作为版本号,避免依赖时间戳;如有需要也允许显式传 `BUILD_VERSION`。 -3. `部署` 流水线必须校验当前构建原因包含上游触发 cause,没有上游触发则直接失败。 -4. `部署` 流水线额外校验上游作业名与传入的 `EXPECTED_UPSTREAM_JOB` 一致;如配置了环境变量 `GENARRATIVE_ALLOWED_UPSTREAM_JOB`,还必须与该值一致。 -5. `构建并部署` 在触发 `部署` 前先释放自己的构建节点,避免单执行器节点出现死锁。 -6. `部署` 不重新构建,不重新上传,不从 Jenkins 插件仓库复制产物,直接使用上游构建节点的本地 `build/<版本号>/` 目录。 -7. `部署` 流水线读取触发原因时必须使用 `currentBuild.getBuildCauses(...)` 这类白名单方法,不能直接访问 `currentBuild.rawBuild`,否则会被 Jenkins Script Security 拦截。 -8. 由于 Jenkins Pipeline 的 `build` 步骤触发下游时,原因类型通常是 `org.jenkinsci.plugins.workflow.support.steps.build.BuildUpstreamCause`,实现上需要同时兼容它和经典的 `hudson.model.Cause$UpstreamCause`,否则会把真实的上游触发误判成人工执行。 -9. 如果线上进程的启停必须经过 `sudo`,只允许 `start.sh` / `stop.sh` 这两个 hook 使用 `sudo -n` 执行,部署目录清空与文件覆盖仍保持普通权限。 +3. `构建` 与 `构建并部署` 在 `checkout scm` 后、实际构建前必须执行 `git reset --hard HEAD` 与 `git clean -fd`,避免固定源码目录内的 Git 变更和未跟踪文件影响发布包;不使用 `-x`,避免删除 `node_modules/` 等忽略目录后与 `RUN_NPM_CI=false` 冲突。 +4. `部署` 流水线允许人工启动;没有上游触发 cause 时按人工部署处理,不再直接失败。 +5. `部署` 流水线仅在存在上游触发 cause 时校验上游作业名与传入的 `EXPECTED_UPSTREAM_JOB` 一致;如配置了环境变量 `GENARRATIVE_ALLOWED_UPSTREAM_JOB`,还必须与该值一致。 +6. `构建并部署` 在触发 `部署` 前先释放自己的构建节点,避免单执行器节点出现死锁。 +7. `部署` 不重新构建,不重新上传,不从 Jenkins 插件仓库复制产物,直接使用上游构建节点的本地 `build/<版本号>/` 目录。 +8. `部署` 流水线读取触发原因时必须使用 `currentBuild.getBuildCauses(...)` 这类白名单方法,不能直接访问 `currentBuild.rawBuild`,否则会被 Jenkins Script Security 拦截。 +9. 由于 Jenkins Pipeline 的 `build` 步骤触发下游时,原因类型通常是 `org.jenkinsci.plugins.workflow.support.steps.build.BuildUpstreamCause`,实现上需要同时兼容它和经典的 `hudson.model.Cause$UpstreamCause`,否则会把真实的上游触发误判成人工执行。 +10. 如果线上进程的启停必须经过 `sudo`,只允许 `start.sh` / `stop.sh` 这两个 hook 使用 `sudo -n` 执行,部署目录清空与文件覆盖仍保持普通权限。 ## 3. 节点与工作区要求 @@ -49,15 +50,16 @@ jenkins/Jenkinsfile.build 核心流程: -1. 可选执行 `npm ci`。 -2. 在源码根目录执行: +1. `checkout scm` 后执行 `git reset --hard HEAD` 与 `git clean -fd` 清理工作区。 +2. 可选执行 `npm ci`。 +3. 在源码根目录执行: ```bash npm run deploy:rust:remote -- --skip-upload --name ``` -3. 校验 `build//` 存在。 -4. 归档 `build//**` 作为 Jenkins 产物。 +4. 校验 `build//` 存在。 +5. 归档 `build//**` 作为 Jenkins 产物。 默认版本号: @@ -75,7 +77,7 @@ jenkins/Jenkinsfile.deploy 核心流程: -1. 校验触发原因必须是上游流水线,而不是人工点击;实现上同时兼容 `BuildUpstreamCause` 与经典 `UpstreamCause`。 +1. 读取触发原因;人工启动时跳过上游门禁,上游触发时同时兼容 `BuildUpstreamCause` 与经典 `UpstreamCause` 并继续校验上游作业名。 2. 校验 `BUILD_VERSION`、`SOURCE_WORKSPACE_ROOT`、`DEPLOY_DIRECTORY` 非空。 3. 执行: @@ -109,11 +111,12 @@ jenkins/Jenkinsfile.build-and-deploy 核心流程: -1. 复用与 `构建` 相同的构建命令生成 `build//`。 -2. 归档 `build//**`。 -3. 记录当前 `NODE_NAME`、源码根目录、版本号。 -4. 构建时额外透传 `--web-port `,默认生成监听 `80` 的发布包。 -5. 触发 `部署` 流水线,并传递: +1. `checkout scm` 后执行 `git reset --hard HEAD` 与 `git clean -fd` 清理工作区。 +2. 复用与 `构建` 相同的构建命令生成 `build//`。 +3. 归档 `build//**`。 +4. 记录当前 `NODE_NAME`、源码根目录、版本号。 +5. 构建时额外透传 `--web-port `,默认生成监听 `80` 的发布包。 +6. 触发 `部署` 流水线,并传递: - `BUILD_VERSION` - `SOURCE_WORKSPACE_ROOT` - `SOURCE_NODE_NAME` diff --git a/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md index 5f1184ff..20669207 100644 --- a/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md +++ b/docs/technical/SPACETIMEDB_CUSTOM_WORLD_AGENT_STAGE1_TABLE_DESIGN_2026-04-21.md @@ -261,6 +261,12 @@ | `created_at` | `Timestamp` | 是 | 创建时间 | | `updated_at` | `Timestamp` | 是 | 更新时间 | +### 主键约束 + +1. `card_id` 是 SpacetimeDB 表级全局主键,不能只使用 `world-foundation` 这类跨会话固定值。 +2. Agent 自动生成的世界底稿卡统一使用 `custom-world:{session_id}:world-foundation`,确保同一会话内稳定 upsert、不同会话间不会发生唯一键冲突。 +3. 不保留历史 `world-foundation` 主键兼容逻辑;线上旧脏数据如需清理,应通过一次性运维脚本处理,不进入 reducer 主链。 + ### 索引 1. `session_id` diff --git a/jenkins/Jenkinsfile.build b/jenkins/Jenkinsfile.build index f06f7ad9..db165ede 100644 --- a/jenkins/Jenkinsfile.build +++ b/jenkins/Jenkinsfile.build @@ -30,6 +30,16 @@ pipeline { dir("${env.WORKSPACE_ROOT}") { checkout scm + sh ''' + bash -lc ' + set -euo pipefail + # 构建前清理工作区内的 Git 变更和未跟踪文件,避免复用固定源码目录时受到上次构建残留影响。 + # 这里不使用 -x,避免删除 node_modules 等忽略目录后与 RUN_NPM_CI=false 的配置冲突。 + git reset --hard HEAD + git clean -fd + ' + ''' + script { // 是否重装依赖交给流水线参数决定,避免每次构建都重复执行 npm ci。 if (params.RUN_NPM_CI) { diff --git a/jenkins/Jenkinsfile.build-and-deploy b/jenkins/Jenkinsfile.build-and-deploy index a9607900..7fb541d2 100644 --- a/jenkins/Jenkinsfile.build-and-deploy +++ b/jenkins/Jenkinsfile.build-and-deploy @@ -37,6 +37,16 @@ pipeline { dir("${env.WORKSPACE_ROOT}") { checkout scm + sh ''' + bash -lc ' + set -euo pipefail + # 构建前清理工作区内的 Git 变更和未跟踪文件,避免复用固定源码目录时受到上次构建残留影响。 + # 这里不使用 -x,避免删除 node_modules 等忽略目录后与 RUN_NPM_CI=false 的配置冲突。 + git reset --hard HEAD + git clean -fd + ' + ''' + script { // 是否重装依赖交给流水线参数决定,避免每次构建都重复执行 npm ci。 if (params.RUN_NPM_CI) { diff --git a/jenkins/Jenkinsfile.deploy b/jenkins/Jenkinsfile.deploy index 523da57f..6dfdafe0 100644 --- a/jenkins/Jenkinsfile.deploy +++ b/jenkins/Jenkinsfile.deploy @@ -24,6 +24,7 @@ pipeline { steps { script { + // 部署流水线允许手动启动;如存在上游触发原因,则继续执行上游作业名门禁。 // Pipeline 的 build 步骤通常会把下游触发原因记录成 BuildUpstreamCause, // 直接只查经典 UpstreamCause 会把真实的上游触发误判成“人工执行”。 def pipelineUpstreamCauses = currentBuild.getBuildCauses('org.jenkinsci.plugins.workflow.support.steps.build.BuildUpstreamCause') @@ -36,10 +37,6 @@ pipeline { upstreamCause = classicUpstreamCauses[0] } - if (!upstreamCause) { - error('部署流水线禁止人工直接执行,只允许由上游构建并部署流水线触发。') - } - def actualUpstreamJob = upstreamCause?.upstreamProject ?: '' def expectedUpstreamJob = params.EXPECTED_UPSTREAM_JOB?.trim() def allowedUpstreamJob = env.GENARRATIVE_ALLOWED_UPSTREAM_JOB?.trim() @@ -56,19 +53,19 @@ pipeline { error('SOURCE_NODE_NAME 不能为空。') } - if (!actualUpstreamJob?.trim()) { + if (upstreamCause && !actualUpstreamJob?.trim()) { error('无法从上游触发原因中解析作业名,请检查 Jenkins Pipeline Build Step 插件版本与触发链。') } - if (expectedUpstreamJob && actualUpstreamJob != expectedUpstreamJob) { + if (actualUpstreamJob && expectedUpstreamJob && actualUpstreamJob != expectedUpstreamJob) { error("上游作业校验失败,期望 ${expectedUpstreamJob},实际 ${actualUpstreamJob}") } - if (allowedUpstreamJob && actualUpstreamJob != allowedUpstreamJob) { + if (actualUpstreamJob && allowedUpstreamJob && actualUpstreamJob != allowedUpstreamJob) { error("环境门禁校验失败,仅允许 ${allowedUpstreamJob} 触发,实际 ${actualUpstreamJob}") } - env.UPSTREAM_JOB_NAME = actualUpstreamJob + env.UPSTREAM_JOB_NAME = actualUpstreamJob ?: 'MANUAL' } } } diff --git a/scripts/deploy-rust-remote.sh b/scripts/deploy-rust-remote.sh index 1126d9bd..08feece4 100644 --- a/scripts/deploy-rust-remote.sh +++ b/scripts/deploy-rust-remote.sh @@ -550,8 +550,8 @@ PUBLISH_ARGS=( ) if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then - # 按当前 SpacetimeDB CLI 约定使用 -c=always,等价于 --delete-data always。 - PUBLISH_ARGS+=(-c always) + # 按当前 SpacetimeDB CLI 约定使用 -c,等价于 --delete-data always。 + PUBLISH_ARGS+=(-c) fi echo "[start] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE}" 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 5b616b8e..3a5c084c 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -1588,18 +1588,13 @@ fn execute_custom_world_agent_action_tx( } "publish_world" => execute_publish_world_action(ctx, &session, &input, &payload), "revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload), - "generate_characters" | "generate_landmarks" => { - execute_generate_entities_action(ctx, &session, &input, &payload) - } - "delete_characters" | "delete_landmarks" => { - execute_delete_entities_action(ctx, &session, &input, &payload) - } - "generate_role_assets" | "generate_scene_assets" => { - execute_prepare_asset_studio_action(ctx, &session, &input, &payload) - } - "sync_role_assets" => execute_sync_role_assets_action(ctx, &session, &input, &payload), - "sync_scene_assets" => execute_sync_scene_assets_action(ctx, &session, &input, &payload), - "expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input), + "generate_characters" + | "generate_landmarks" + | "generate_role_assets" + | "sync_role_assets" + | "generate_scene_assets" + | "sync_scene_assets" + | "expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input), other => Err(format!("custom world action `{other}` 当前尚未支持")), } } @@ -2139,537 +2134,6 @@ fn execute_revert_checkpoint_action( Ok(build_custom_world_agent_operation_snapshot(&operation)) } -fn execute_generate_entities_action( - ctx: &ReducerContext, - session: &CustomWorldAgentSession, - input: &CustomWorldAgentActionExecuteInput, - payload: &JsonMap, -) -> Result { - ensure_draft_refining_stage(session.stage, input.action.as_str())?; - let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) - .ok_or_else(|| format!("{} requires an existing draft foundation", input.action))?; - let (payload_key, profile_key, card_kind, operation_type, checkpoint_label, message_prefix) = - match input.action.as_str() { - "generate_characters" => ( - "generatedCharacters", - if payload.get("roleType").and_then(JsonValue::as_str) == Some("playable") { - "playableNpcs" - } else { - "storyNpcs" - }, - RpgAgentDraftCardKind::Character, - RpgAgentOperationType::GenerateCharacters, - if payload.get("roleType").and_then(JsonValue::as_str) == Some("playable") { - "新增可扮演角色" - } else { - "新增场景角色" - }, - if payload.get("roleType").and_then(JsonValue::as_str) == Some("playable") { - "已补出新可扮演角色" - } else { - "已补出新场景角色" - }, - ), - "generate_landmarks" => ( - "generatedLandmarks", - "landmarks", - RpgAgentDraftCardKind::Landmark, - RpgAgentOperationType::GenerateLandmarks, - "新增地点", - "已补出新地点", - ), - other => return Err(format!("unsupported generated entity action: {other}")), - }; - let generated_entities = payload - .get(payload_key) - .and_then(JsonValue::as_array) - .cloned() - .ok_or_else(|| format!("{} requires payload.{payload_key}", input.action))?; - if generated_entities.is_empty() { - return Err(format!("{} generated entity list is empty", input.action)); - } - - let profile_entities = draft_profile - .entry(profile_key.to_string()) - .or_insert_with(|| JsonValue::Array(Vec::new())) - .as_array_mut() - .ok_or_else(|| format!("draftProfile.{profile_key} must be array"))?; - let mut inserted_names = Vec::new(); - for entity in generated_entities { - let normalized_entity = - ensure_generated_entity_id(entity, card_kind, profile_entities.len()); - if let Some(name) = normalized_entity - .get("name") - .and_then(JsonValue::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - inserted_names.push(name.to_string()); - } - upsert_generated_entity_card( - ctx, - &session.session_id, - card_kind, - &normalized_entity, - input.submitted_at_micros, - )?; - profile_entities.push(normalized_entity); - } - - let gate = summarize_publish_gate_from_json( - &input.session_id, - session.stage, - Some(&draft_profile), - &parse_json_array_or_empty(&session.quality_findings_json), - ); - let draft_profile_json = serialize_json_value(&JsonValue::Object(draft_profile.clone()))?; - let next_session = rebuild_custom_world_agent_session_row( - session, - CustomWorldAgentSessionPatch { - draft_profile_json: Some(Some(draft_profile_json)), - focus_card_id: Some( - inserted_names - .first() - .map(|name| build_generated_entity_card_id(card_kind, name, 0)), - ), - last_assistant_reply: Some(Some(format!( - "{} {} 个,已同步到草稿卡片列表。", - message_prefix, - inserted_names.len() - ))), - 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, - &parse_json_array_or_empty(&session.quality_findings_json), - input.submitted_at_micros, - )?), - checkpoints_json: Some(append_checkpoint_json( - &session.checkpoints_json, - &build_session_checkpoint_value( - &format!("{}-{}", input.action, input.operation_id), - &format!("{} {} 个", checkpoint_label, inserted_names.len()), - session, - ), - )?), - updated_at_micros: Some(input.submitted_at_micros), - ..CustomWorldAgentSessionPatch::default() - }, - )?; - replace_custom_world_agent_session(ctx, session, next_session); - - append_custom_world_action_result_message( - ctx, - &session.session_id, - &input.operation_id, - &format!( - "{}:{}。", - message_prefix, - if inserted_names.is_empty() { - "无新增对象".to_string() - } else { - inserted_names.join("、") - } - ), - input.submitted_at_micros, - ); - let operation = build_and_insert_custom_world_operation( - ctx, - &input.operation_id, - &session.session_id, - operation_type, - checkpoint_label, - &format!("{} {} 个。", message_prefix, inserted_names.len()), - input.submitted_at_micros, - ); - - Ok(build_custom_world_agent_operation_snapshot(&operation)) -} - -fn execute_delete_entities_action( - ctx: &ReducerContext, - session: &CustomWorldAgentSession, - input: &CustomWorldAgentActionExecuteInput, - payload: &JsonMap, -) -> Result { - ensure_draft_refining_stage(session.stage, input.action.as_str())?; - let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) - .ok_or_else(|| format!("{} requires an existing draft foundation", input.action))?; - let (ids, operation_type, label, message_prefix) = match input.action.as_str() { - "delete_characters" => ( - read_payload_string_array(payload, "roleIds"), - RpgAgentOperationType::DeleteCharacters, - "删除角色", - "已删除角色", - ), - "delete_landmarks" => ( - read_payload_string_array(payload, "sceneIds"), - RpgAgentOperationType::DeleteLandmarks, - "删除场景", - "已删除场景", - ), - other => return Err(format!("unsupported delete entity action: {other}")), - }; - if ids.is_empty() { - return Err(format!("{} requires non-empty ids", input.action)); - } - - let removed_names = if input.action == "delete_characters" { - let mut names = remove_profile_entities_by_ids(&mut draft_profile, "playableNpcs", &ids)?; - names.extend(remove_profile_entities_by_ids(&mut draft_profile, "storyNpcs", &ids)?); - names - } else { - let names = remove_profile_entities_by_ids(&mut draft_profile, "landmarks", &ids)?; - remove_deleted_landmark_connections(&mut draft_profile, &ids); - names - }; - for id in &ids { - delete_draft_card_by_entity_id(ctx, &session.session_id, id); - } - - let gate = summarize_publish_gate_from_json( - &input.session_id, - session.stage, - Some(&draft_profile), - &parse_json_array_or_empty(&session.quality_findings_json), - ); - let next_session = rebuild_custom_world_agent_session_row( - session, - CustomWorldAgentSessionPatch { - draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( - draft_profile.clone(), - ))?)), - focus_card_id: Some(None), - last_assistant_reply: Some(Some(format!( - "{} {} 个,已同步更新草稿。", - message_prefix, - removed_names.len() - ))), - 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, - &parse_json_array_or_empty(&session.quality_findings_json), - input.submitted_at_micros, - )?), - checkpoints_json: Some(append_checkpoint_json( - &session.checkpoints_json, - &build_session_checkpoint_value( - &format!("{}-{}", input.action, input.operation_id), - &format!("{} {} 个", label, removed_names.len()), - session, - ), - )?), - updated_at_micros: Some(input.submitted_at_micros), - ..CustomWorldAgentSessionPatch::default() - }, - )?; - replace_custom_world_agent_session(ctx, session, next_session); - append_custom_world_action_result_message( - ctx, - &session.session_id, - &input.operation_id, - &format!( - "{}:{}。", - message_prefix, - if removed_names.is_empty() { - ids.join("、") - } else { - removed_names.join("、") - } - ), - input.submitted_at_micros, - ); - let operation = build_and_insert_custom_world_operation( - ctx, - &input.operation_id, - &session.session_id, - operation_type, - label, - &format!("{} {} 个。", message_prefix, ids.len()), - input.submitted_at_micros, - ); - - Ok(build_custom_world_agent_operation_snapshot(&operation)) -} - - -fn execute_prepare_asset_studio_action( - ctx: &ReducerContext, - session: &CustomWorldAgentSession, - input: &CustomWorldAgentActionExecuteInput, - payload: &JsonMap, -) -> Result { - ensure_draft_refining_stage(session.stage, input.action.as_str())?; - let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) - .ok_or_else(|| format!("{} requires an existing draft foundation", input.action))?; - let (focus_id, operation_type, message_text, phase_label, phase_detail) = - if input.action == "generate_role_assets" { - let role_id = read_first_payload_text(payload, "roleIds", "roleId") - .ok_or_else(|| "generate_role_assets requires roleIds".to_string())?; - let role = find_profile_entity_by_id(&draft_profile, &["playableNpcs", "storyNpcs"], &role_id) - .ok_or_else(|| "未找到目标角色,无法进入角色资产工坊。".to_string())?; - let role_name = read_optional_text_field(role, &["name"]).unwrap_or_else(|| "角色".to_string()); - ( - role_id, - RpgAgentOperationType::GenerateRoleAssets, - format!("已为「{}」准备好角色资产工坊,先生成主图候选,再补核心动作。", role_name), - "角色资产工坊已就绪", - format!("「{}」现在可以开始生成主图和动作。", role_name), - ) - } else { - let scene_id = read_first_payload_text(payload, "sceneIds", "sceneId") - .ok_or_else(|| "generate_scene_assets requires sceneIds".to_string())?; - let scene_kind = payload - .get("sceneKind") - .and_then(JsonValue::as_str) - .map(str::trim) - .unwrap_or("landmark"); - let scene = if scene_kind == "camp" { - draft_profile.get("camp").and_then(JsonValue::as_object) - } else { - find_profile_entity_by_id(&draft_profile, &["landmarks"], &scene_id) - } - .ok_or_else(|| "未找到目标场景,无法进入场景资产工坊。".to_string())?; - let scene_name = read_optional_text_field(scene, &["name"]) - .unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "未命名场景" }.to_string()); - ( - scene_id, - RpgAgentOperationType::GenerateSceneAssets, - format!("已为「{}」准备好场景图工坊,保存生成结果后会自动同步回当前草稿。", scene_name), - "场景资产工坊已就绪", - format!("「{}」现在可以继续生成和确认正式场景图。", scene_name), - ) - }; - - let next_session = rebuild_custom_world_agent_session_row( - session, - CustomWorldAgentSessionPatch { - stage: Some(RpgAgentStage::VisualRefining), - focus_card_id: Some(Some(focus_id)), - last_assistant_reply: Some(Some(message_text.clone())), - updated_at_micros: Some(input.submitted_at_micros), - ..CustomWorldAgentSessionPatch::default() - }, - )?; - replace_custom_world_agent_session(ctx, session, next_session); - append_custom_world_action_result_message( - ctx, - &session.session_id, - &input.operation_id, - &message_text, - input.submitted_at_micros, - ); - let operation = build_and_insert_custom_world_operation( - ctx, - &input.operation_id, - &session.session_id, - operation_type, - phase_label, - &phase_detail, - input.submitted_at_micros, - ); - Ok(build_custom_world_agent_operation_snapshot(&operation)) -} - -fn execute_sync_role_assets_action( - ctx: &ReducerContext, - session: &CustomWorldAgentSession, - input: &CustomWorldAgentActionExecuteInput, - payload: &JsonMap, -) -> Result { - ensure_draft_refining_stage(session.stage, "sync_role_assets")?; - let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) - .ok_or_else(|| "sync_role_assets requires an existing draft foundation".to_string())?; - let role_id = read_required_payload_text(payload, "roleId", "sync_role_assets requires roleId")?; - let portrait_path = read_required_payload_text(payload, "portraitPath", "sync_role_assets requires portraitPath")?; - let generated_visual_asset_id = read_required_payload_text( - payload, - "generatedVisualAssetId", - "sync_role_assets requires generatedVisualAssetId", - )?; - let generated_animation_set_id = payload - .get("generatedAnimationSetId") - .and_then(JsonValue::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned); - let animation_map = payload.get("animationMap").cloned(); - let updated_role = apply_role_asset_publish_result( - &mut draft_profile, - &role_id, - &portrait_path, - &generated_visual_asset_id, - generated_animation_set_id.as_deref(), - animation_map, - )?; - let role_name = read_optional_text_field(&updated_role, &["name"]).unwrap_or_else(|| "当前角色".to_string()); - let asset_status = resolve_role_asset_status(&updated_role); - let asset_status_label = resolve_role_asset_status_label(asset_status).to_string(); - upsert_asset_role_card( - ctx, - &session.session_id, - &role_id, - &updated_role, - asset_status, - &asset_status_label, - input.submitted_at_micros, - )?; - let gate = summarize_publish_gate_from_json( - &input.session_id, - RpgAgentStage::VisualRefining, - Some(&draft_profile), - &parse_json_array_or_empty(&session.quality_findings_json), - ); - let next_session = rebuild_custom_world_agent_session_row( - session, - CustomWorldAgentSessionPatch { - stage: Some(RpgAgentStage::VisualRefining), - focus_card_id: Some(Some(role_id.clone())), - draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)), - last_assistant_reply: Some(Some(format!( - "已把「{}」的角色资产写回草稿,当前状态:{}。", - role_name, asset_status_label - ))), - 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, - &parse_json_array_or_empty(&session.quality_findings_json), - input.submitted_at_micros, - )?), - checkpoints_json: Some(append_checkpoint_json( - &session.checkpoints_json, - &build_session_checkpoint_value( - "sync-role-assets", - &format!("同步角色资产 {}", role_name), - session, - ), - )?), - asset_coverage_json: Some(build_asset_coverage_json(&draft_profile)?), - updated_at_micros: Some(input.submitted_at_micros), - ..CustomWorldAgentSessionPatch::default() - }, - )?; - replace_custom_world_agent_session(ctx, session, next_session); - append_custom_world_action_result_message( - ctx, - &session.session_id, - &input.operation_id, - &format!("已把「{}」的角色资产写回草稿,当前状态:{}。", role_name, asset_status_label), - input.submitted_at_micros, - ); - let operation = build_and_insert_custom_world_operation( - ctx, - &input.operation_id, - &session.session_id, - RpgAgentOperationType::SyncRoleAssets, - "角色资产已同步", - &format!("「{}」的资产状态已更新为{}。", role_name, asset_status_label), - input.submitted_at_micros, - ); - Ok(build_custom_world_agent_operation_snapshot(&operation)) -} - -fn execute_sync_scene_assets_action( - ctx: &ReducerContext, - session: &CustomWorldAgentSession, - input: &CustomWorldAgentActionExecuteInput, - payload: &JsonMap, -) -> Result { - ensure_draft_refining_stage(session.stage, "sync_scene_assets")?; - let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) - .ok_or_else(|| "sync_scene_assets requires an existing draft foundation".to_string())?; - let scene_id = read_required_payload_text(payload, "sceneId", "sync_scene_assets requires sceneId")?; - let scene_kind = read_required_payload_text(payload, "sceneKind", "sync_scene_assets requires sceneKind")?; - let image_src = read_required_payload_text(payload, "imageSrc", "sync_scene_assets requires imageSrc")?; - let generated_scene_asset_id = read_required_payload_text( - payload, - "generatedSceneAssetId", - "sync_scene_assets requires generatedSceneAssetId", - )?; - let generated_scene_prompt = payload.get("generatedScenePrompt").cloned().unwrap_or(JsonValue::Null); - let generated_scene_model = payload.get("generatedSceneModel").cloned().unwrap_or(JsonValue::Null); - let updated_scene = apply_scene_asset_publish_result( - &mut draft_profile, - &scene_id, - &scene_kind, - &image_src, - &generated_scene_asset_id, - generated_scene_prompt, - generated_scene_model, - )?; - let scene_name = read_optional_text_field(&updated_scene, &["name"]) - .unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "当前场景" }.to_string()); - upsert_asset_scene_card( - ctx, - &session.session_id, - &scene_id, - &scene_kind, - &updated_scene, - input.submitted_at_micros, - )?; - let gate = summarize_publish_gate_from_json( - &input.session_id, - RpgAgentStage::VisualRefining, - Some(&draft_profile), - &parse_json_array_or_empty(&session.quality_findings_json), - ); - let next_session = rebuild_custom_world_agent_session_row( - session, - CustomWorldAgentSessionPatch { - stage: Some(RpgAgentStage::VisualRefining), - focus_card_id: Some(Some(scene_id.clone())), - draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)), - last_assistant_reply: Some(Some(format!( - "已把「{}」的场景图写回草稿,并同步刷新地点卡与幕背景状态。", - scene_name - ))), - 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, - &parse_json_array_or_empty(&session.quality_findings_json), - input.submitted_at_micros, - )?), - checkpoints_json: Some(append_checkpoint_json( - &session.checkpoints_json, - &build_session_checkpoint_value( - "sync-scene-assets", - &format!("同步场景资产 {}", scene_name), - session, - ), - )?), - asset_coverage_json: Some(build_asset_coverage_json(&draft_profile)?), - updated_at_micros: Some(input.submitted_at_micros), - ..CustomWorldAgentSessionPatch::default() - }, - )?; - replace_custom_world_agent_session(ctx, session, next_session); - append_custom_world_action_result_message( - ctx, - &session.session_id, - &input.operation_id, - &format!("已把「{}」的场景图写回草稿,并同步刷新地点卡与幕背景状态。", scene_name), - input.submitted_at_micros, - ); - let operation = build_and_insert_custom_world_operation( - ctx, - &input.operation_id, - &session.session_id, - RpgAgentOperationType::SyncSceneAssets, - "场景资产已同步", - &format!("「{}」的场景图已经进入当前草稿。", scene_name), - input.submitted_at_micros, - ); - Ok(build_custom_world_agent_operation_snapshot(&operation)) -} - - fn execute_placeholder_custom_world_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, @@ -2978,22 +2442,6 @@ fn build_supported_actions_json( .to_string() }), ), - build_supported_action_json( - "delete_characters", - draft_refining_enabled, - (!draft_refining_enabled).then(|| { - "delete_characters is only available during object_refining or visual_refining" - .to_string() - }), - ), - build_supported_action_json( - "delete_landmarks", - draft_refining_enabled, - (!draft_refining_enabled).then(|| { - "delete_landmarks is only available during object_refining or visual_refining" - .to_string() - }), - ), build_supported_action_json( "generate_role_assets", draft_refining_enabled, @@ -3677,6 +3125,11 @@ fn delete_draft_card_by_entity_id(ctx: &ReducerContext, session_id: &str, entity } } +fn build_world_foundation_card_id(session_id: &str) -> String { + // `custom_world_draft_card.card_id` 是全局主键,世界底稿卡必须带上会话维度,避免多会话写入时触发唯一键冲突。 + format!("custom-world:{session_id}:world-foundation") +} + fn sync_session_draft_profile_from_card_update( session: &CustomWorldAgentSession, card: &CustomWorldDraftCard, @@ -3770,8 +3223,6 @@ fn map_action_name_to_operation_type(action: &str) -> Option Some(RpgAgentOperationType::SyncResultProfile), "generate_characters" => Some(RpgAgentOperationType::GenerateCharacters), "generate_landmarks" => Some(RpgAgentOperationType::GenerateLandmarks), - "delete_characters" => Some(RpgAgentOperationType::DeleteCharacters), - "delete_landmarks" => Some(RpgAgentOperationType::DeleteLandmarks), "generate_role_assets" => Some(RpgAgentOperationType::GenerateRoleAssets), "sync_role_assets" => Some(RpgAgentOperationType::SyncRoleAssets), "generate_scene_assets" => Some(RpgAgentOperationType::GenerateSceneAssets), @@ -3827,460 +3278,6 @@ fn parse_json_array_or_empty(raw: &str) -> Vec { .unwrap_or_default() } - -fn read_first_payload_text( - payload: &JsonMap, - array_key: &str, - scalar_key: &str, -) -> Option { - payload - .get(array_key) - .and_then(JsonValue::as_array) - .and_then(|values| values.first()) - .and_then(JsonValue::as_str) - .or_else(|| payload.get(scalar_key).and_then(JsonValue::as_str)) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) -} - -fn find_profile_entity_by_id<'a>( - profile: &'a JsonMap, - fields: &[&str], - entity_id: &str, -) -> Option<&'a JsonMap> { - for field in fields { - if let Some(entries) = profile.get(*field).and_then(JsonValue::as_array) { - for entry in entries { - let Some(object) = entry.as_object() else { - continue; - }; - if read_optional_text_field(object, &["id"]).as_deref() == Some(entity_id) { - return Some(object); - } - } - } - } - None -} - -fn apply_role_asset_publish_result( - profile: &mut JsonMap, - role_id: &str, - portrait_path: &str, - generated_visual_asset_id: &str, - generated_animation_set_id: Option<&str>, - animation_map: Option, -) -> Result, String> { - for field in ["playableNpcs", "storyNpcs"] { - let Some(entries) = profile.get_mut(field).and_then(JsonValue::as_array_mut) else { - continue; - }; - for entry in entries { - let Some(object) = entry.as_object_mut() else { - continue; - }; - if read_optional_text_field(object, &["id"]).as_deref() != Some(role_id) { - continue; - } - object.insert("imageSrc".to_string(), JsonValue::String(portrait_path.to_string())); - object.insert( - "generatedVisualAssetId".to_string(), - JsonValue::String(generated_visual_asset_id.to_string()), - ); - if let Some(asset_id) = generated_animation_set_id { - object.insert( - "generatedAnimationSetId".to_string(), - JsonValue::String(asset_id.to_string()), - ); - } - if let Some(map) = animation_map { - object.insert("animationMap".to_string(), map); - } - return Ok(object.clone()); - } - } - Err("目标角色不存在,无法同步角色资产。".to_string()) -} - -fn apply_scene_asset_publish_result( - profile: &mut JsonMap, - scene_id: &str, - scene_kind: &str, - image_src: &str, - generated_scene_asset_id: &str, - generated_scene_prompt: JsonValue, - generated_scene_model: JsonValue, -) -> Result, String> { - let updated_scene = if scene_kind == "camp" { - let camp = profile - .get_mut("camp") - .and_then(JsonValue::as_object_mut) - .ok_or_else(|| "目标营地不存在,无法同步场景资产。".to_string())?; - if read_optional_text_field(camp, &["id"]).as_deref() != Some(scene_id) { - return Err("目标营地不存在,无法同步场景资产。".to_string()); - } - camp.insert("imageSrc".to_string(), JsonValue::String(image_src.to_string())); - camp.insert( - "generatedSceneAssetId".to_string(), - JsonValue::String(generated_scene_asset_id.to_string()), - ); - camp.insert("generatedScenePrompt".to_string(), generated_scene_prompt); - camp.insert("generatedSceneModel".to_string(), generated_scene_model); - camp.clone() - } else { - let landmarks = profile - .get_mut("landmarks") - .and_then(JsonValue::as_array_mut) - .ok_or_else(|| "目标地点不存在,无法同步场景资产。".to_string())?; - let mut updated = None; - for entry in landmarks { - let Some(object) = entry.as_object_mut() else { - continue; - }; - if read_optional_text_field(object, &["id"]).as_deref() != Some(scene_id) { - continue; - } - object.insert("imageSrc".to_string(), JsonValue::String(image_src.to_string())); - object.insert( - "generatedSceneAssetId".to_string(), - JsonValue::String(generated_scene_asset_id.to_string()), - ); - object.insert("generatedScenePrompt".to_string(), generated_scene_prompt.clone()); - object.insert("generatedSceneModel".to_string(), generated_scene_model.clone()); - updated = Some(object.clone()); - break; - } - updated.ok_or_else(|| "目标地点不存在,无法同步场景资产。".to_string())? - }; - update_scene_chapter_acts_for_scene(profile, scene_id, image_src, generated_scene_asset_id); - Ok(updated_scene) -} - -fn update_scene_chapter_acts_for_scene( - profile: &mut JsonMap, - scene_id: &str, - image_src: &str, - generated_scene_asset_id: &str, -) { - let Some(chapters) = profile.get_mut("sceneChapters").and_then(JsonValue::as_array_mut) else { - return; - }; - for chapter in chapters { - let Some(chapter_object) = chapter.as_object_mut() else { - continue; - }; - if read_optional_text_field(chapter_object, &["sceneId"]).as_deref() != Some(scene_id) { - continue; - } - let Some(acts) = chapter_object.get_mut("acts").and_then(JsonValue::as_array_mut) else { - continue; - }; - for act in acts { - if let Some(act_object) = act.as_object_mut() { - act_object.insert("backgroundImageSrc".to_string(), JsonValue::String(image_src.to_string())); - act_object.insert( - "backgroundAssetId".to_string(), - JsonValue::String(generated_scene_asset_id.to_string()), - ); - } - } - } -} - -fn resolve_role_asset_status(role: &JsonMap) -> CustomWorldRoleAssetStatus { - let has_portrait = read_optional_text_field(role, &["imageSrc"]).is_some() - && read_optional_text_field(role, &["generatedVisualAssetId"]).is_some(); - if !has_portrait { - return CustomWorldRoleAssetStatus::Missing; - } - let has_animation_set = read_optional_text_field(role, &["generatedAnimationSetId"]).is_some(); - let has_animation_map = role - .get("animationMap") - .and_then(JsonValue::as_object) - .map(|map| !map.is_empty()) - .unwrap_or(false); - if has_animation_set && has_animation_map { - CustomWorldRoleAssetStatus::Complete - } else if has_animation_set { - CustomWorldRoleAssetStatus::AnimationsReady - } else { - CustomWorldRoleAssetStatus::VisualReady - } -} - -fn resolve_role_asset_status_label(status: CustomWorldRoleAssetStatus) -> &'static str { - match status { - CustomWorldRoleAssetStatus::Complete => "动作已就绪", - CustomWorldRoleAssetStatus::AnimationsReady => "动作补齐中", - CustomWorldRoleAssetStatus::VisualReady => "主图已就绪", - CustomWorldRoleAssetStatus::Missing => "待生成主图", - } -} - -fn build_asset_coverage_json(profile: &JsonMap) -> Result { - let mut role_assets = Vec::new(); - for (field, role_kind) in [("playableNpcs", "playable"), ("storyNpcs", "story")] { - if let Some(entries) = profile.get(field).and_then(JsonValue::as_array) { - for entry in entries { - let Some(role) = entry.as_object() else { - continue; - }; - let Some(role_id) = read_optional_text_field(role, &["id"]) else { - continue; - }; - let status = resolve_role_asset_status(role); - role_assets.push(json!({ - "roleId": role_id, - "roleName": read_optional_text_field(role, &["name"]).unwrap_or_else(|| "角色".to_string()), - "roleKind": role_kind, - "priorityTier": if role_kind == "playable" { "hero" } else { "support" }, - "portraitPath": read_optional_text_field(role, &["imageSrc"]), - "generatedVisualAssetId": read_optional_text_field(role, &["generatedVisualAssetId"]), - "generatedAnimationSetId": read_optional_text_field(role, &["generatedAnimationSetId"]), - "status": role_asset_status_key(status), - "missingAnimations": [], - "nextPointCost": 0, - })); - } - } - } - let mut scene_assets = Vec::new(); - if let Some(camp) = profile.get("camp").and_then(JsonValue::as_object) { - if let Some(scene_id) = read_optional_text_field(camp, &["id"]) { - scene_assets.push(build_scene_asset_summary_json(&scene_id, "camp", camp)); - } - } - if let Some(landmarks) = profile.get("landmarks").and_then(JsonValue::as_array) { - for entry in landmarks { - let Some(scene) = entry.as_object() else { - continue; - }; - if let Some(scene_id) = read_optional_text_field(scene, &["id"]) { - scene_assets.push(build_scene_asset_summary_json(&scene_id, "landmark", scene)); - } - } - } - let all_role_assets_ready = !role_assets.is_empty() - && role_assets.iter().all(|entry| entry.get("status").and_then(JsonValue::as_str) != Some("missing")); - let all_scene_assets_ready = !scene_assets.is_empty() - && scene_assets.iter().all(|entry| entry.get("status").and_then(JsonValue::as_str) == Some("ready")); - serialize_json_value(&json!({ - "roleAssets": role_assets, - "sceneAssets": scene_assets, - "allRoleAssetsReady": all_role_assets_ready, - "allSceneAssetsReady": all_scene_assets_ready, - })) -} - -fn role_asset_status_key(status: CustomWorldRoleAssetStatus) -> &'static str { - match status { - CustomWorldRoleAssetStatus::Missing => "missing", - CustomWorldRoleAssetStatus::VisualReady => "visual_ready", - CustomWorldRoleAssetStatus::AnimationsReady => "animations_ready", - CustomWorldRoleAssetStatus::Complete => "complete", - } -} - -fn build_scene_asset_summary_json( - scene_id: &str, - scene_kind: &str, - scene: &JsonMap, -) -> JsonValue { - let image_src = read_optional_text_field(scene, &["imageSrc"]); - let asset_id = read_optional_text_field(scene, &["generatedSceneAssetId"]); - json!({ - "sceneId": scene_id, - "sceneName": read_optional_text_field(scene, &["name"]).unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "未命名场景" }.to_string()), - "actId": JsonValue::Null, - "actTitle": if scene_kind == "camp" { "营地正式背景图" } else { "场景正式背景图" }, - "imageSrc": image_src, - "assetId": asset_id, - "status": if read_optional_text_field(scene, &["imageSrc"]).is_some() || read_optional_text_field(scene, &["generatedSceneAssetId"]).is_some() { "ready" } else { "missing" }, - "nextPointCost": 0, - }) -} - -fn upsert_asset_role_card( - ctx: &ReducerContext, - session_id: &str, - role_id: &str, - role: &JsonMap, - asset_status: CustomWorldRoleAssetStatus, - asset_status_label: &str, - updated_at_micros: i64, -) -> Result<(), String> { - let card_id = resolve_existing_entity_card_id(ctx, session_id, role_id, RpgAgentDraftCardKind::Character) - .unwrap_or_else(|| role_id.to_string()); - let title = read_optional_text_field(role, &["name"]).unwrap_or_else(|| "角色".to_string()); - let subtitle = read_optional_text_field(role, &["role", "relationToPlayer", "publicMask"]) - .unwrap_or_else(|| asset_status_label.to_string()); - let summary = read_optional_text_field(role, &["summary", "description", "publicMask"]) - .unwrap_or_else(|| "角色资产已写回草稿。".to_string()); - upsert_asset_card( - ctx, - session_id, - &card_id, - RpgAgentDraftCardKind::Character, - &title, - &subtitle, - &summary, - asset_status, - asset_status_label, - json!({ - "id": role_id, - "kind": "character", - "title": title, - "sections": build_generated_entity_detail_sections(role, RpgAgentDraftCardKind::Character), - "linkedIds": [], - "locked": false, - "editable": true, - "editableSectionIds": ["summary"], - "warningMessages": [], - "asset": { - "imageSrc": read_optional_text_field(role, &["imageSrc"]), - "generatedVisualAssetId": read_optional_text_field(role, &["generatedVisualAssetId"]), - "generatedAnimationSetId": read_optional_text_field(role, &["generatedAnimationSetId"]), - "status": role_asset_status_key(asset_status), - "statusLabel": asset_status_label, - }, - }), - updated_at_micros, - ) -} - -fn upsert_asset_scene_card( - ctx: &ReducerContext, - session_id: &str, - scene_id: &str, - scene_kind: &str, - scene: &JsonMap, - updated_at_micros: i64, -) -> Result<(), String> { - let kind = if scene_kind == "camp" { - RpgAgentDraftCardKind::Camp - } else { - RpgAgentDraftCardKind::Landmark - }; - let card_id = resolve_existing_entity_card_id(ctx, session_id, scene_id, kind) - .unwrap_or_else(|| scene_id.to_string()); - let title = read_optional_text_field(scene, &["name"]).unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "场景" }.to_string()); - let subtitle = read_optional_text_field(scene, &["purpose", "mood", "dangerLevel"]) - .unwrap_or_else(|| "场景资产已就绪".to_string()); - let summary = read_optional_text_field(scene, &["summary", "description", "publicMask"]) - .unwrap_or_else(|| "场景图已写回草稿。".to_string()); - upsert_asset_card( - ctx, - session_id, - &card_id, - kind, - &title, - &subtitle, - &summary, - CustomWorldRoleAssetStatus::Complete, - "场景图已就绪", - json!({ - "id": scene_id, - "kind": kind.as_str(), - "title": title, - "sections": build_generated_entity_detail_sections(scene, kind), - "linkedIds": [], - "locked": false, - "editable": true, - "editableSectionIds": ["summary"], - "warningMessages": [], - "asset": { - "imageSrc": read_optional_text_field(scene, &["imageSrc"]), - "generatedSceneAssetId": read_optional_text_field(scene, &["generatedSceneAssetId"]), - "generatedScenePrompt": scene.get("generatedScenePrompt").cloned().unwrap_or(JsonValue::Null), - "generatedSceneModel": scene.get("generatedSceneModel").cloned().unwrap_or(JsonValue::Null), - "status": "ready", - "statusLabel": "场景图已就绪", - }, - }), - updated_at_micros, - ) -} - -fn upsert_asset_card( - ctx: &ReducerContext, - session_id: &str, - card_id: &str, - kind: RpgAgentDraftCardKind, - title: &str, - subtitle: &str, - summary: &str, - asset_status: CustomWorldRoleAssetStatus, - asset_status_label: &str, - detail_payload: JsonValue, - updated_at_micros: i64, -) -> Result<(), String> { - let row = CustomWorldDraftCard { - card_id: card_id.to_string(), - session_id: session_id.to_string(), - kind, - status: RpgAgentDraftCardStatus::Draft, - title: title.to_string(), - subtitle: subtitle.to_string(), - summary: summary.to_string(), - linked_ids_json: "[]".to_string(), - warning_count: 0, - asset_status: Some(asset_status), - asset_status_label: Some(asset_status_label.to_string()), - detail_payload_json: Some(serialize_json_value(&detail_payload)?), - created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), - updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), - }; - if let Some(existing) = ctx - .db - .custom_world_draft_card() - .card_id() - .find(&card_id.to_string()) - .filter(|entry| entry.session_id == session_id) - { - replace_custom_world_draft_card( - ctx, - &existing, - CustomWorldDraftCard { - created_at: existing.created_at, - ..row - }, - ); - } else { - ctx.db.custom_world_draft_card().insert(row); - } - Ok(()) -} - -fn resolve_existing_entity_card_id( - ctx: &ReducerContext, - session_id: &str, - entity_id: &str, - kind: RpgAgentDraftCardKind, -) -> Option { - for card in ctx - .db - .custom_world_draft_card() - .iter() - .filter(|row| row.session_id == session_id && row.kind == kind) - { - if card.card_id == entity_id { - return Some(card.card_id); - } - if let Some(detail) = card - .detail_payload_json - .as_deref() - .and_then(parse_optional_session_object) - { - if read_optional_text_field(&detail, &["id"]).as_deref() == Some(entity_id) { - return Some(card.card_id); - } - } - } - None -} - - fn serialize_json_value(value: &JsonValue) -> Result { serde_json::to_string(value).map_err(|error| format!("JSON 序列化失败: {error}")) } diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index a0af7df8..8bf740d0 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -4142,7 +4142,13 @@ fn upsert_world_foundation_card( draft_profile: &JsonMap, updated_at_micros: i64, ) -> Result<(), String> { - let card_id = "world-foundation".to_string(); + let card_id = build_world_foundation_card_id(session_id); + let existing_card = ctx + .db + .custom_world_draft_card() + .card_id() + .find(&card_id) + .filter(|row| row.session_id == session_id); let title = read_optional_text_field(draft_profile, &["name", "title"]) .unwrap_or_else(|| "世界底稿".to_string()); let subtitle = read_optional_text_field(draft_profile, &["subtitle"]).unwrap_or_default(); @@ -4164,13 +4170,7 @@ fn upsert_world_foundation_card( "warningMessages": [], }))?; - if let Some(existing) = ctx - .db - .custom_world_draft_card() - .card_id() - .find(&card_id) - .filter(|row| row.session_id == session_id) - { + if let Some(existing) = existing_card { replace_custom_world_draft_card( ctx, &existing, @@ -4221,6 +4221,11 @@ fn upsert_world_foundation_card( Ok(()) } +fn build_world_foundation_card_id(session_id: &str) -> String { + // `custom_world_draft_card.card_id` 是全局主键,世界底稿卡必须带上会话维度,避免多会话写入时触发唯一键冲突。 + format!("custom-world:{session_id}:world-foundation") +} + fn sync_session_draft_profile_from_card_update( session: &CustomWorldAgentSession, card: &CustomWorldDraftCard, diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index ea10192c..4ee4cc4e 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -384,10 +384,11 @@ function CatalogCard({ if (layout === 'compact') { return ( - + ); } return ( - + ); }