diff --git a/docs/planning/ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md b/docs/planning/ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md new file mode 100644 index 00000000..881927d7 --- /dev/null +++ b/docs/planning/ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md @@ -0,0 +1,579 @@ +# 工程无用分支、历史代码与隐形多链路大清洗执行计划(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 文档目标 + +这份文档只解决一件事: + +**对当前工程发起一轮“不是继续加功能,而是系统性减负、删重、收口、归档”的大清洗。** + +这轮重点不是做表面上的“代码变少”,而是把下面 3 类长期拉低可读性和可维护性的东西真正处理掉: + +1. 无用历史代码 +2. 隐形的多数据链路 / 多真相链路乱代码 +3. 实现到一半但长期挂在主工程里的半成品代码 + +本文目标不是重复现有审计,而是把已有结论整理成: + +1. 可执行的清洗范围 +2. 明确的判定标准 +3. 分阶段的推进顺序 +4. 每阶段的交付物 +5. 可以落地的验收与回滚口径 + +--- + +## 1. 先把“清什么”说清楚 + +这次文档里说的“无用分支”,优先指的是: + +1. 代码逻辑分支 +2. 数据链路分支 +3. 兼容实现分支 +4. 遗留入口分支 + +**不是先把 Git 分支清空。** + +Git 分支治理可以后置做,但不能和首轮工程清洗混在一起,否则很容易把“代码归因”“入口归因”“历史责任归因”一起搅乱。 + +--- + +## 2. 三类清洗对象的定义 + +## 2.1 无用历史代码 + +满足以下任一特征,即进入“无用历史代码候选”: + +1. 没有正式运行时入口,也没有当前规划要接回入口 +2. 只被测试或历史兼容层引用,但主流程已经不再依赖 +3. 与当前正式实现功能重复,但不是唯一真相源 +4. 只剩 stub、占位、迁移残骸、旧 prompt 壳子、旧 helper 壳子 +5. 生成产物仍留在主仓库,但已不再被正式流程消费 + +这类代码的处理目标是: + +**删除、归档、降级标记三选一,不再长期以“也许以后要用”为理由挂在主路径里。** + +## 2.2 隐形多数据链路乱代码 + +满足以下任一特征,即进入“隐形多链路问题候选”: + +1. 同一份运行时状态同时由前端本地镜像和后端会话共同解释 +2. 同一类任务、物品、剧情、鉴权逻辑在前后端或多模块里各维护一份 +3. 同一份数据在“提交前本地写一份、提交后服务端再回填一份” +4. 同一功能表面只有一个按钮,背后却有两到三条实现路径 +5. 正式链路和 fallback 链路长期并存,且没有退场时间 + +这类问题的处理目标是: + +**把每条正式能力收敛成单一主链、单一真相源、单一编排出口。** + +## 2.3 实现到一半的半成品代码 + +满足以下任一特征,即进入“半成品候选”: + +1. UI、Hook、Service 已存在,但没有正式入口 +2. 文档写了概念,代码只落了一半,后续也没有继续接完 +3. 只有局部测试或局部 mock 在用,真实流程不用 +4. 仍保留 TODO / stub / draft / launcher / modal,但未纳入当前主线 +5. 用户看不到、主流程不调用、团队也没有当前阶段交付计划 + +这类代码的处理目标是: + +**要么纳入当前主线尽快补完,要么明确归档,不允许继续以“半活状态”污染主工程。** + +--- + +## 3. 这轮清洗后的目标状态 + +本轮完成后,工程应至少达到下面 7 个状态: + +1. 同一领域只保留一条正式主链,不再出现前后端双真相或多桥接链路并存 +2. 无入口孤岛、旧兼容壳子、旧 prompt 壳子、旧 stub 文件有明确去留结果 +3. “实现到一半”的模块不再伪装成正式能力挂在主工程中 +4. 前端继续回到“表现层”,正式运行时逻辑、鉴权真相、任务物品编排继续向后端收口 +5. 热点大文件不再同时背负历史残留、兼容残留和新逻辑堆叠 +6. 文档与代码状态一致,不再让旧规划长期误导当前执行方向 +7. `lint + typecheck + test + build + check:content` 重新成为可信基线 + +--- + +## 4. 执行原则 + +## 4.1 不做大爆炸整仓改写 + +本轮只允许“小批次、可回归、可解释”的连续清洗,不做一次性整仓推翻。 + +## 4.2 先建台账,再动删除 + +任何删除、归档、重定向动作前,必须先确认: + +1. 当前入口关系 +2. 当前依赖关系 +3. 当前替代路径 +4. 删除后的验收路径 + +没有台账,不做大规模删改。 + +## 4.3 先收真相源,再谈瘦身 + +如果同一领域仍有多条真相链路并存,优先收口真相源,而不是只删表面代码量。 + +## 4.4 文档和代码同步收口 + +只要本轮确认某条旧链降级、冻结、归档,相关文档必须同步更新,不能让旧文档继续把团队往废链路上拉。 + +## 4.5 每批清洗必须可回归 + +每一批完成后至少要求: + +1. 入口可解释 +2. 回归路径明确 +3. 门禁可跑 +4. 回滚点存在 + +--- + +## 5. 当前已知问题基础 + +本计划基于现有文档已经确认的结论推进,重点参考: + +1. `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md` +2. `docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md` +3. `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md` +4. `docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md` +5. `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md` + +按当前审计结果,首轮就应重点关注下面 3 组对象。 + +## 5.1 当前高置信度“无入口 / 孤岛 / 残留”候选 + +以下对象已经在最近审计中被点名,默认进入首轮复核台账: + +1. `src/components/GameShell.tsx` +2. `src/components/custom-world-home/CustomWorldCreationHub.tsx` +3. `src/components/custom-world-home/CustomWorldCreationLauncherModal.tsx` +4. `src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx` +5. `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx` +6. `src/hooks/story/storyBootstrap.ts` +7. `src/hooks/useEquipmentFlow.ts` +8. `src/hooks/useForgeFlow.ts` +9. `src/hooks/useInventoryFlow.ts` +10. `src/services/customWorldPresentation.stub.ts` +11. `src/services/typewriter.ts` +12. `src/prompts/customWorldOrchestratorPrompts.ts` +13. `src/prompts/storyOrchestratorPrompts.ts` +14. `src/data/buildTagSimilarity.generated.ts` + +这些文件不能直接判死刑,但必须进入“保留 / 接回 / 归档 / 删除”四选一清单。 + +## 5.2 当前高置信度“隐形多链路 / 双真相”候选 + +以下对象应进入首轮主链收口清单: + +1. `src/hooks/story/runtimeStoryCoordinator.ts` +2. `src/services/runtimeStoryService.ts` +3. `src/services/apiClient.ts` +4. `src/hooks/story/npcEncounterActions.ts` +5. `src/services/questDirector.ts` +6. `src/services/runtimeItemAiDirector.ts` +7. `src/services/ai.ts` + +当前这批问题的共同特征是: + +1. 前端仍保留本地镜像、自动登录凭证或双环境编排残留 +2. NPC 任务换单、任务生成、运行时物品生成仍有前端发起和混合执行痕迹 +3. 浏览器侧大型 AI orchestration 仍未完全退出主工程 + +## 5.3 当前“新热点继续吸纳历史复杂度”候选 + +以下对象不一定是垃圾代码,但很容易继续成为历史残留的新容器: + +1. `src/components/CustomWorldEntityEditorModal.tsx` +2. `server-node/src/modules/assets/characterAssetRoutes.ts` +3. `src/prompts/storyPromptBuilders.ts` +4. `server-node/src/modules/custom-world/runtimeProfile.ts` +5. `src/components/game-shell/PreGameSelectionFlow.tsx` +6. `src/components/game-shell/PlatformHomeView.tsx` + +这批文件必须在本轮中被视为“禁止继续裸堆新逻辑”的重点区域。 + +--- + +## 6. 清洗判定表 + +每个候选对象进入清理台账后,只允许落到下面 4 类结果之一: + +| 结果类型 | 适用场景 | 处理动作 | +| --- | --- | --- | +| 删除 | 无入口、无当前规划、无兼容价值 | 直接删文件、删引用、补回归 | +| 归档 | 暂不继续,但保留历史价值 | 移出主路径、在文档中标明冻结状态 | +| 扶正 | 当前主线确实需要,只是入口丢失或命名混乱 | 接回正式入口、补测试、补文档 | +| 拆分收口 | 不是废代码,但混合了历史残留和正式逻辑 | 先拆职责,再删除残留分支 | + +禁止出现第 5 种状态: + +**“先留着,以后再说,但继续挂在主工程里。”** + +--- + +## 7. 分阶段执行计划 + +## P0:冻结新增污染,先建立清洗台账 + +### 目标 + +先把“哪些东西要清、为什么清、怎么判定是否能清”讲清楚,停止继续往旧热点和疑似废链上加逻辑。 + +### 主要动作 + +1. 建立 3 份清单: + - 无入口孤岛清单 + - 多真相链路清单 + - 半成品能力清单 +2. 为每个对象补 5 个字段: + - 当前入口 + - 当前调用方 + - 当前替代路径 + - 建议结论 + - 回归验证点 +3. 约束新增开发: + - 不再向疑似废链补功能 + - 不再向热点大文件直接叠逻辑 + - 新需求优先接到当前正式主链 +4. 明确本轮清洗后的唯一方向: + - 前端只做表现 + - 后端持有正式运行时真相 + - 旧兼容链不能继续膨胀 + +### 交付物 + +1. 清洗对象总台账 +2. 首轮批次拆分表 +3. 每批回归清单 + +### 完成标准 + +不是“开始删文件”才算开始。 + +只要台账、批次、判定口径和冻结规则明确,这一阶段就算完成。 + +--- + +## P1:先清无入口孤岛和明显历史残留 + +### 目标 + +先把最容易污染阅读体验、又不需要大规模业务改造的对象清掉,快速降低仓库噪音。 + +### 优先清理对象 + +1. 无运行时入口组件 +2. 只被测试引用的旧壳层 +3. 已迁移后留下的 stub / prompt 壳 / helper 壳 +4. 已不进入正式链路的 generated 文件 +5. 旧 launcher / draft / modal 壳层 + +### 处理顺序建议 + +1. 先处理 `prompt / stub / helper / launcher` 级别的小残留 +2. 再处理 `旧 hook / 旧 flow / 旧 shell` 级别的流程残留 +3. 最后处理“可能有历史价值但暂不接回”的 UI 大块头 + +### 本阶段输出结果 + +每个对象必须给出明确结果: + +1. 删除 +2. 归档 +3. 扶正接回 + +### 验收标准 + +1. 主工程中“没有正式入口的文件”显著减少 +2. 新人看目录时,不再大量遇到真假难辨的旧入口 +3. 相关引用、测试、文档同步更新 + +--- + +## P2:收单一真相源,清掉隐形多数据链路 + +### 目标 + +这阶段不以“删多少文件”为核心,而是以“同一件事最终只走一条正式链”作为核心。 + +### 第一优先级链路 + +1. 运行时快照链 +2. 鉴权与自动登录链 +3. NPC 任务生成 / 换单链 +4. 运行时物品生成链 +5. 浏览器端 AI orchestration 链 + +### 重点动作 + +1. 收掉前端“提交前先写本地真相,再等服务端回填”的链路 +2. 收掉本地存储中的自动登录用户名 / 密码真相 +3. 把 NPC 委托换单动作继续迁回后端运行时主链 +4. 将 `questDirector`、`runtimeItemAiDirector` 拆成: + - 前端 SDK 层 + - 后端正式执行层 +5. 继续压缩浏览器端 `src/services/ai.ts` 的正式职责 + +### 这阶段最重要的判断标准 + +不是“文件还在不在”,而是下面 4 条是否成立: + +1. 玩家一次动作只提交一个正式 action,而不是两边各写一遍状态 +2. 前端不再持有正式运行时镜像真相 +3. 前端不再长期持有自动登录账号密码 +4. 同一类生成能力不再同时存在“浏览器正式版”和“后端正式版” + +### 验收标准 + +1. 正式运行时状态解释权明确以后端为准 +2. 鉴权边界不再依赖浏览器保存高风险凭证 +3. NPC 任务、物品、剧情编排链路的职责边界清楚 + +--- + +## P3:集中处理实现到一半的半成品能力 + +### 目标 + +把“看起来像功能、实际上不是当前正式能力”的对象清出主路径。 + +### 清理规则 + +半成品对象统一按下面规则处理: + +1. 30 天内明确要接回主线的,进入补完批次 +2. 当前阶段不做的,降级为归档或实验稿 +3. 没有继续计划、也没有正式入口价值的,直接删除 + +### 本阶段重点对象 + +1. 只有 modal / launcher / draft 壳层,但没有正式调用链的 UI +2. 只有部分 hook / service 实现,但没有主链消费的流程模块 +3. 只剩“概念占位”的 prompt、adapter、presentation、stub 文件 +4. 文档里反复提到、代码里却长期不接线的能力块 + +### 必须同步做的事 + +1. 更新对应规划文档 +2. 从当前主叙事中移除本轮明确不做的项 +3. 给保留实验稿加清晰标签,避免被误读成正式能力 + +### 验收标准 + +1. 主工程里不再混着大量“像功能但不是正式功能”的对象 +2. 文档不再持续推动团队回头补本轮已冻结能力 +3. 目录层级和入口关系显著更清楚 + +--- + +## P4:在减负后的基础上拆热点,恢复可读性 + +### 目标 + +前 3 阶段做完后,再进入“真正让工程重新好读”的结构优化。 + +### 重点对象 + +1. `src/components/CustomWorldEntityEditorModal.tsx` +2. `server-node/src/modules/assets/characterAssetRoutes.ts` +3. `src/prompts/storyPromptBuilders.ts` +4. `server-node/src/modules/custom-world/runtimeProfile.ts` +5. `src/components/game-shell/PreGameSelectionFlow.tsx` +6. `src/components/game-shell/PlatformHomeView.tsx` + +### 拆分原则 + +1. 先按职责拆,不按文件长度拆 +2. 先把历史残留和兼容分支移走,再做正式模块化 +3. 拆完之后必须更清晰地回答: + - 谁负责 UI + - 谁负责数据准备 + - 谁负责正式规则 + - 谁负责调用后端 + +### 验收标准 + +1. 热点文件不再同时吞 UI、规则、编排、兼容残留 +2. 新功能不需要再跨四五层历史壳子一起改 +3. 后续 review 能更快定位责任边界 + +--- + +## 8. 批次拆分建议 + +为了避免清理动作过大失控,建议按下面粒度推进: + +## 批次 A:小型孤岛与残留壳子 + +处理对象: + +1. stub +2. prompt 壳 +3. 无入口 helper +4. 无入口 launcher / modal + +目标: + +快速去噪,降低目录误导性。 + +## 批次 B:旧 flow / 旧 shell / 旧 hook + +处理对象: + +1. `GameShell` +2. `storyBootstrap` +3. `useEquipmentFlow` +4. `useForgeFlow` +5. `useInventoryFlow` + +目标: + +清旧主流程壳层和旧流程残留。 + +## 批次 C:运行时真相收口 + +处理对象: + +1. `runtimeStoryCoordinator` +2. `runtimeStoryService` +3. `apiClient` + +目标: + +去掉本地镜像真相与本地鉴权真相。 + +## 批次 D:任务 / 物品 / AI 混合执行层收口 + +处理对象: + +1. `npcEncounterActions` +2. `questDirector` +3. `runtimeItemAiDirector` +4. `ai.ts` + +目标: + +消灭混合执行和双环境正式链。 + +## 批次 E:热点大文件拆分 + +处理对象: + +1. custom world +2. assets +3. game shell platform +4. prompt builder +5. runtime profile + +目标: + +在主链已收口后恢复可读性。 + +--- + +## 9. 每批必须产出的内容 + +每一批都必须带着下面 5 类产出结束: + +1. 代码改动 +2. 文档回填 +3. 去留说明 +4. 验收记录 +5. 回滚点说明 + +如果一个批次只能产出“删了几个文件”,但说不清: + +1. 删除后谁接手 +2. 主链是否更清楚 +3. 文档是否同步 + +那么这个批次不算完成。 + +--- + +## 10. 统一验收口径 + +本轮建议至少用下面 10 条作为统一验收口径: + +1. `npm run lint` +2. `npm run test` +3. `npm run build` +4. `npm run check:content` +5. 目录中高置信度孤岛数量下降 +6. 旧兼容链不再继续接收新逻辑 +7. 前端不再保存自动登录用户名 / 密码 +8. 运行时主状态不再由前端本地镜像优先解释 +9. 当前正式能力的入口关系能在文档中说清楚 +10. 新人阅读主目录和主流程文件时,不再频繁遇到真假并存入口 + +--- + +## 11. 风险与控制点 + +## 11.1 最大风险不是“删多了”,而是“边删边继续加废链” + +如果没有冻结规则,这轮会一边清旧,一边又把新逻辑接回旧壳子里,最后只会重复劳动。 + +## 11.2 不能把“兼容”当永久借口 + +兼容链可以短期存在,但必须写清: + +1. 为什么保留 +2. 保留到什么时候 +3. 谁负责后续移除 + +## 11.3 不能只删代码,不收文档 + +如果代码删了,旧文档不改,团队还是会持续把需求往旧链上接。 + +## 11.4 不能只盯文件大小,不盯真相链 + +有些文件很大但确实是正式主链。 +有些文件很小,却是双真相和多链路的根源。 + +本轮必须优先盯后者。 + +--- + +## 12. 当前不建议优先做的事 + +1. 不建议在清洗期间继续横向扩功能 +2. 不建议直接对热点文件做“纯格式化式拆分” +3. 不建议在未确认入口关系前整片删除可疑模块 +4. 不建议让前端继续补正式运行时逻辑作为短期兜底 +5. 不建议保留“也许以后有用”的主工程残留 + +原因很简单: + +**当前最需要恢复的不是功能宽度,而是工程的干净边界、单一主链和可读体验。** + +--- + +## 13. 推荐推进顺序 + +建议严格按下面顺序推进: + +1. 先做 P0:建台账、冻结污染 +2. 再做 P1:清无入口孤岛和小残留 +3. 再做 P2:收运行时、鉴权、任务物品的单一主链 +4. 再做 P3:处理半成品能力与文档冻结项 +5. 最后做 P4:拆热点、补结构可读性 + +不建议倒过来先拆热点。 + +因为如果历史残留和双真相还在,大文件拆完以后,复杂度只是换地方继续长。 + +--- + +## 14. 一句话结论 + +这轮工程大清洗的核心,不是“删旧代码看起来更清爽”,而是: + +**用一轮有台账、有判定、有阶段、有验收的大清理,把无用历史代码、隐形多链路乱代码和半成品能力从主工程里真正清出去,让项目重新回到单一主链、单一真相源、目录可读、职责清楚的健康状态。** diff --git a/docs/planning/README.md b/docs/planning/README.md index 0903a157..65201388 100644 --- a/docs/planning/README.md +++ b/docs/planning/README.md @@ -2,6 +2,7 @@ ## 当前入口 +- [ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md](./ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md):面向无用历史代码、隐形多数据链路和半成品实现的一轮工程大清洗执行计划,强调先建台账、再删重收口、最后恢复主工程可读性。 - [CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md](./CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md):当前阶段最值得优先做什么、为什么,以及它和审计/PRD 的对应关系。 - [CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md](./CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md):在不新增前端创作流程的前提下,围绕当前 Agent 创作动线做收口、删重、补通和文档收束的大白话执行规划。 - [EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md](./EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md):基于“前端只做表现、逻辑与数据全部后端化”的工程重构规划。 diff --git a/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md b/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md new file mode 100644 index 00000000..b006e0f0 --- /dev/null +++ b/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md @@ -0,0 +1,212 @@ +# Agent 结果页深度编辑回写主链方案(阶段一) + +更新时间:`2026-04-20` + +## 1. 这次阶段一先改什么 + +这次阶段一不做结果页只读化。 + +结果页继续保留当前已经可用、而且用户已经满意的这些能力: + +1. 结果页继续允许深度编辑世界设定 +2. 结果页继续允许编辑角色、场景、营地、封面 +3. 结果页继续允许直接新增角色与地点 +4. 结果页继续保留当前已有的浏览、自动保存、进入世界体验 + +这次真正要补的是: + +**把结果页里产出的完整 `CustomWorldProfile`,同步回 `Agent session`,让结果页编辑不再游离在主链之外。** + +--- + +## 2. 当前真正的问题 + +当前链路里,结果页虽然还能深度编辑,但数据职责是分裂的: + +```text +Agent session +-> 前端 buildCustomWorldProfileFromAgentDraft() +-> 结果页本地 profile +-> 结果页继续深度编辑 +-> 自动保存到 custom-world-library +-> 进入世界 +``` + +这里最大的问题不是“结果页能编辑”,而是: + +1. 结果页编辑后的最新世界结构,没有稳定回写到 `Agent session` +2. 用户从结果页返回 Agent 工作区后,session 侧仍可能停留在较旧的草稿状态 +3. “结果页当前看到的世界”“Agent session 当前保存的草稿”“作品库里自动保存的 profile”可能不是同一份东西 +4. 进入世界时如果直接吃当前前端内存态,也会继续放大这个分叉 + +所以阶段一要解决的是: + +**结果页仍然是深度编辑器,但它编辑的是 Agent 主链里的当前结果快照,不是脱链的本地副本。** + +--- + +## 3. 阶段一目标状态 + +阶段一把链路先收成下面这样: + +```text +Agent session +-> 前端 buildCustomWorldProfileFromAgentDraft() 生成结果页初始 profile +-> 用户在结果页继续深度编辑 profile +-> 前端调用新的 Agent action,把完整结果 profile 同步回 session +-> session 保留: + - 当前 foundation draft + - 当前 legacyResultProfile 结果快照 + - 重编译后的 draftCards / assetCoverage / suggestedActions +-> 自动保存与进入世界都优先基于已同步的 session 结果快照执行 +``` + +这一步仍然是过渡态,不是最终态。 + +因为: + +1. 阶段一还不打通 `publish_world` +2. 阶段一也不把结果页改造成完全原生的 draft 编辑器 +3. 阶段一允许继续保留 `draftProfile.legacyResultProfile` 作为兼容桥接字段 + +但至少要做到: + +**结果页的深度编辑,必须进入 Agent session 的单一主链。** + +--- + +## 4. 阶段一具体实现边界 + +## 4.1 新增 Agent action:`sync_result_profile` + +阶段一新增一个面向结果页的 Agent action: + +```ts +{ action: 'sync_result_profile'; profile: CustomWorldProfileRecord } +``` + +用途只有一个: + +把结果页当前完整 `CustomWorldProfile` 快照同步回 `CustomWorldAgentSessionRecord`。 + +它不是发布动作,也不是世界编译动作。 +它只是把结果页当前编辑结果认回主链。 + +--- + +## 4.2 服务端写回策略 + +服务端接到 `sync_result_profile` 后,按下面规则处理: + +1. 读取当前 session +2. 取当前 `draftProfile` +3. 保留当前 draft 层已有的结构化字段: + - `playableNpcs / storyNpcs / landmarks / camp` + - `factions / threads / chapters / sceneChapters` + - `worldHook / playerPremise / openingSituation / iconicElements` + - 以及现有资产、scene chapter 等字段 +4. 把结果页传来的完整 `CustomWorldProfile` 写入 `draftProfile.legacyResultProfile` +5. 对于 draft 层里本来就和结果页一一对应、且结果页已经改动的字段,同步覆盖基础摘要字段: + - `name` + - `subtitle` + - `summary` + - `tone` + - `playerGoal` + - `majorFactions` + - `coreConflicts` +6. 重新编译 `draftCards` +7. 重建 `assetCoverage` +8. 刷新 `suggestedActions` +9. 写入 action result message 和 checkpoint + +这里故意不在阶段一做“把完整 runtime profile 反解成一整套全量 foundation draft 结构”的大重构。 + +原因是: + +1. 结果页当前已经支持很多深度编辑字段 +2. 如果现在硬做全量反编译,最容易把场景章节、多幕、资产字段写坏 +3. 阶段一应该先保证“结果页编辑不脱链”,而不是一次性重做所有模型映射 + +--- + +## 4.3 前端触发策略 + +前端只在 `customWorldResultViewSource === 'agent-draft'` 时走这条同步链。 + +具体规则: + +1. 结果页 profile 每次发生变化时,继续允许本地即时更新 +2. 但在自动保存前,先把 profile 通过 `sync_result_profile` 同步到 Agent session +3. 返回创作时,如果要重新读 Agent 草稿,也应优先以最新 session 为准 +4. 点击“进入世界”时,先拉取最新 session,再重新 `buildCustomWorldProfileFromAgentDraft()`,避免吃到旧的前端缓存 profile + +这样阶段一就能做到: + +1. 结果页编辑体验不变 +2. Agent session 成为结果页编辑后的可恢复真相源 +3. 自动保存、返回创作、进入世界三条路都围绕同一份 session-backed 结果快照 + +--- + +## 5. 阶段一明确不做什么 + +这次阶段一明确不做: + +1. 不关闭结果页当前已有的编辑器能力 +2. 不删除结果页当前已有的 AI 新增角色/地点能力 +3. 不打通 `publish_world` +4. 不把 `legacyResultProfile` 直接删掉 +5. 不把结果页整个改写成只操作 draft card 的新系统 +6. 不把旧 `custom-world/sessions` 链在本阶段直接物理移除 + +--- + +## 6. 验收标准 + +阶段一做完后,至少要满足下面这些结果: + +1. Agent 草稿结果页继续保持当前深度编辑体验不变 +2. 结果页发生编辑后,Agent session 中能看到同步后的最新结果快照 +3. 从结果页返回创作后,不会明显回退到较旧的草稿态 +4. 点击“进入世界”时,会优先使用最新 session 重新编译结果,而不是只依赖前端旧内存态 +5. 自动保存到作品库的 profile 与当前 session 结果快照保持一致 + +--- + +## 7. 一句话结论 + +阶段一不是收掉结果页,而是把结果页继续保留为深度编辑器,同时补上一条正式的 session 回写链,让它不再游离在 Agent 主链之外。 + +--- + +## 8. 2026-04-20 实际落地结果 + +本轮已经按阶段一目标完成下面这些收口: + +1. 前端结果页自动保存时,若当前来源是 `agent-draft`,会先执行 `sync_result_profile` +2. `sync_result_profile` 完成后,自动保存不再直接写旧的前端内存 profile,而是优先保存从最新 session 重新 `buildCustomWorldProfileFromAgentDraft()` 得到的结果快照 +3. 点击“进入世界”时,仍会先同步 session,再基于最新 session 重编译 profile 后进入世界 +4. 点击“返回创作”时,也会先做一次结果页到 session 的同步兜底,再返回 Agent 工作区 +5. 为避免用户刚从结果页返回工作区又被自动重开逻辑顶回结果页,前端补了一层显式返回抑制标记 +6. 服务端 `sync_result_profile` 现已按阶段一边界收窄为“保留 foundation draft 结构,只更新基础摘要字段和 `legacyResultProfile`”,没有提前做整套 runtime -> draft 反解 + +这意味着阶段一当前已经把下面三条路径收回到同一条 session 主链: + +1. 自动保存到作品库 +2. 返回 Agent 工作区继续创作 +3. 从结果页直接进入世界 + +## 9. 本轮仍然保留的阶段性边界 + +这次落地后,仍然保留文档原先约定的过渡边界: + +1. 结果页深度编辑能力不做收缩 +2. `draftProfile.legacyResultProfile` 继续作为兼容桥接字段保留 +3. `publish_world` 仍未在这一轮打通 +4. 前端仍然使用 `buildCustomWorldProfileFromAgentDraft()` 作为 session -> 结果页的兼容编译层 + +所以下一阶段如果要继续推进,重点应转向: + +1. 降低前端对 legacy profile 编译桥接的依赖 +2. 继续把发布链路收口到 Agent session / service 侧 +3. 逐步缩减结果页直改 legacy profile 的历史职责 diff --git a/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md b/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md new file mode 100644 index 00000000..03b727b9 --- /dev/null +++ b/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md @@ -0,0 +1,75 @@ +# Agent 结果页与平台入口收口方案(阶段二) + +更新时间:`2026-04-20` + +## 1. 阶段二目标 + +阶段一已经把 Agent 结果页编辑快照同步回 session 主链。阶段二不继续扩大结果页编辑能力,而是把入口和职责继续收紧: + +1. 平台“创作”入口统一读取 `custom-world/works` 聚合列表 +2. Agent 草稿和已保存作品在同一个入口里展示 +3. 草稿点击后恢复 Agent session,已保存作品点击后进入作品详情 +4. Agent 结果页不再暴露“继续在结果页补世界结构”的新增入口 + +一句话目标: + +**让用户从平台创作入口能稳定找回草稿和作品,同时让结果页更像收口预览,而不是另一套编辑器。** + +--- + +## 2. 本阶段不做什么 + +阶段二明确不做: + +1. 不物理删除旧 `custom-world/sessions` 链 +2. 不打通 `publish_world` +3. 不重做结果页 UI +4. 不删除已保存作品的继续编辑入口 +5. 不把结果页整体改成只读 + +这些事项留给后续阶段继续拆。 + +--- + +## 3. 平台入口落地规则 + +平台“创作”Tab 改为优先展示 `listCustomWorldWorks()` 的聚合结果: + +1. `agent_session` 类型展示为草稿,可点击恢复 Agent 工作区 +2. `published_profile` 类型展示为作品,可点击进入作品详情 +3. 聚合接口失败时保留现有作品库 `myEntries` 兜底 +4. 不新增平行页面,复用已有 `CustomWorldCreationHub` + +这样用户不再需要依赖隐藏 sessionId 或旧作品库入口才能找回创作。 + +--- + +## 4. 结果页职责收口规则 + +Agent 来源结果页继续保留: + +1. 浏览世界、角色、场景 +2. 自动保存 +3. 返回 Agent 工作区 +4. 进入世界 + +Agent 来源结果页本阶段收紧: + +1. 不再显示直接新增可扮演角色、场景角色、场景的入口 +2. 不再把“去 Agent 调整设定”设计成结果页内部继续补世界结构 +3. 如需继续调整,返回 Agent 工作区 + +已保存作品的结果页仍保持现有编辑能力,避免破坏作品库已有体验。 + +--- + +## 5. 验收标准 + +阶段二完成后应满足: + +1. 平台“创作”Tab 能看到 Agent 草稿和已保存作品的统一列表 +2. 点击 Agent 草稿能恢复对应 Agent 工作区 +3. 点击已保存作品能进入原有作品详情 +4. Agent 结果页不再显示直接新增角色/地点的入口 +5. 已保存作品的结果页编辑能力不受影响 + diff --git a/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md b/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md new file mode 100644 index 00000000..93548f78 --- /dev/null +++ b/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md @@ -0,0 +1,148 @@ +# Agent 结果页旧链降级与预览冻结方案(阶段三) + +更新时间:`2026-04-20` + +## 1. 阶段三目标 + +阶段一已经把结果页编辑同步回 Agent session 主链。 + +阶段二已经把平台“创作”入口统一到 `custom-world/works` 聚合列表,并收紧了 Agent 结果页里的新增入口。 + +阶段三不继续扩功能,而是继续做两件事: + +1. 让旧 pipeline 在主入口里进一步降级,不再和 Agent 主链抢“草稿”职责 +2. 让 Agent 来源结果页进一步冻结为“预览/收口层”,不再继续承担 legacy profile 直改编辑器职责 + +一句话目标: + +**把还在和 Agent 主链并行的旧职责继续降级,避免系统自己和自己打架。** + +--- + +## 2. 当前剩余问题 + +虽然阶段一、二已经把主链收紧了不少,但当前还保留两个明显的并行口: + +### 2.1 创作中心里旧 library 草稿仍可能继续冒充主草稿 + +当前 `listCustomWorldWorkSummaries()` 会把 runtime library 里的所有 profile 都折成 `published_profile` 类型返回。 + +这意味着: + +1. `visibility = 'draft'` 的 library 草稿仍会继续出现在创作中心 +2. 创作中心里同时存在: + - Agent session 草稿 + - library 草稿 + - 已发布作品 +3. 用户看到的“草稿”概念仍然可能混成两套 + +阶段三需要明确: + +**创作中心主入口只认 Agent session 草稿 和 已发布作品,不再继续把 library draft 当主草稿展示。** + +--- + +### 2.2 Agent 结果页仍能继续打开旧 legacy 编辑器 + +当前 Agent 来源结果页虽然已经不再暴露“新增角色/新增地点”入口,但仍然保留下面这些旧编辑链: + +1. 点击世界概述/基本设定仍能打开 legacy world editor +2. 点击角色、场景、封面仍能继续进入旧 profile 编辑弹窗 +3. 这些编辑器本质上仍然是在改 legacy `CustomWorldProfile` + +这会带来两个问题: + +1. Agent 结果页继续像一套“旧编辑器” +2. “去 Agent 调整设定”和“结果页直接改 legacy profile”两条路仍然并行存在 + +阶段三需要明确: + +**Agent 来源结果页继续保留浏览、自动保存、返回创作、进入世界,但不再继续承担 legacy profile 深编辑职责。** + +--- + +## 3. 阶段三落地规则 + +## 3.1 创作中心只展示两类主入口内容 + +`custom-world/works` 在阶段三只保留下面两类条目: + +1. `agent_session` + - 统一视为草稿 + - 点击后恢复 Agent 工作区 +2. `published_profile` + - 统一视为已发布作品 + - 点击后进入现有作品详情 + +明确不再把下面这类内容继续塞进创作中心主入口: + +1. library 中 `visibility = 'draft'` 的兼容草稿 + +这些兼容草稿仍然保留在作品库/详情链路里,不在本阶段物理删除,但不再继续占创作中心“草稿主入口”。 + +--- + +## 3.2 Agent 来源结果页冻结为预览态 + +当 `customWorldResultViewSource === 'agent-draft'` 时,结果页阶段三继续保留: + +1. 浏览世界信息 +2. 浏览角色、地点、场景结构 +3. 自动保存 +4. 返回 Agent 工作区 +5. 进入世界 + +同时阶段三进一步收紧: + +1. 不再打开世界/角色/场景/封面的 legacy 编辑弹窗 +2. 不再提供删除角色、删除场景等旧 profile 直改入口 +3. Agent 来源结果页上的对象卡统一作为“查看详情”预览卡使用 + +已保存作品的结果页编辑能力继续保留,不在本阶段收缩,避免破坏已有作品库编辑体验。 + +--- + +## 3.3 结果页同步动作只在真的发生差异时执行 + +阶段一补的 `sync_result_profile` 仍然保留,但阶段三补一个行为约束: + +1. 如果当前 Agent 结果页 profile 和最新 session 重编译结果签名一致 +2. 那么返回创作、进入世界、自动保存前不再重复触发一次 `sync_result_profile` + +目的不是省接口,而是明确: + +**结果页同步是“有改动才回写”的主链动作,不是每次离开页面都机械重放。** + +--- + +## 4. 阶段三明确不做什么 + +这次阶段三明确不做: + +1. 不物理删除旧 `custom-world/sessions` 相关服务与兼容代码 +2. 不打通 `publish_world` +3. 不把前端 `buildCustomWorldProfileFromAgentDraft()` 兼容编译层移除 +4. 不删除 `draftProfile.legacyResultProfile` +5. 不收缩已保存作品的 legacy 编辑器能力 + +阶段三只做主入口降级与 Agent 结果页职责冻结,不做更大的模型替换。 + +--- + +## 5. 验收标准 + +阶段三完成后应满足: + +1. 创作中心不再把 library draft 兼容作品继续显示为“草稿主入口” +2. 创作中心里只保留 Agent 草稿和已发布作品两类主入口内容 +3. Agent 来源结果页不再能继续打开 legacy 世界/角色/场景编辑弹窗 +4. 已保存作品结果页编辑能力不受影响 +5. Agent 结果页在未发生改动时,返回创作/进入世界/自动保存不会重复触发无意义的 `sync_result_profile` + +--- + +## 6. 一句话结论 + +阶段三不是删除兼容层,而是把它们继续降级到不会抢主流程职责的位置上: + +**创作中心只认 Agent 草稿和已发布作品,Agent 结果页只负责预览与收口,不再继续充当旧编辑器。** diff --git a/docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md b/docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md new file mode 100644 index 00000000..4c4ae9e7 --- /dev/null +++ b/docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md @@ -0,0 +1,92 @@ +# Agent 创作流四阶段收口检查与旧链清理边界 + +更新时间:`2026-04-21` + +## 1. 结论先行 + +当前这条 Agent 创作流已经完成阶段一到阶段三的主要收口。 + +阶段四中的“文档清理”已经开始做,但还没有形成独立、完整的新主链。 + +因此这轮可以执行的清理只有一类: + +1. 删除已经不再从当前主入口可达的旧 `custom-world/sessions` 世界生成链 +2. 保留仍在服务 `Agent session` 主链或已保存作品兼容编辑体验的底层能力 + +这轮不做: + +1. 不删 `Agent session` 的底层持久化能力 +2. 不删已保存作品结果页的 legacy 编辑器兼容能力 +3. 不删 `custom-world/works` 聚合入口 + +--- + +## 2. 阶段完成度 + +### 2.1 阶段一 + +已完成。 + +证据: + +1. 结果页新增了 `sync_result_profile` +2. 结果页编辑后的快照可以回写到 `Agent session` +3. 自动保存、返回创作、进入世界都优先走 session 主链 + +### 2.2 阶段二 + +已完成。 + +证据: + +1. 平台创作入口已切到 `custom-world/works` +2. 草稿恢复优先回 Agent 工作区 +3. Agent 结果页不再继续新增旧编辑入口 + +### 2.3 阶段三 + +已完成。 + +证据: + +1. 创作中心不再把 library draft 当主草稿入口 +2. Agent 来源结果页冻结为预览收口层 +3. 重复同步动作已收敛为有差异才执行 + +### 2.4 阶段四 + +未完全完成。 + +原因: + +1. 文档清理已经开始,但还没有完整收束到单一结论文档 +2. 旧 `custom-world/sessions` 生成链虽然已经不在主入口上,但还未清干净 + +--- + +## 3. 本轮允许删除的旧链 + +允许删除: + +1. `src/services/aiService.ts` 里的旧 `custom-world/sessions` 请求函数 +2. `server-node/src/routes/runtimeRoutes.ts` 里的旧 `custom-world/sessions` 路由 +3. `server-node/src/services/customWorldGenerationService.ts` +4. 与这条旧链对应的测试 + +不允许删除: + +1. `server-node/src/services/customWorldSessionStore.ts` +2. `server-node/src/repositories/runtimeRepository.ts` 中被 Agent session 复用的 session 持久化能力 +3. `src/services/aiService.ts` 里仍在使用的 `generateCustomWorldProfile` 及其现代封装 + +--- + +## 4. 删除完成后的判断标准 + +如果旧链清理成功,应满足: + +1. `src/services/aiService.ts` 不再暴露旧 `custom-world/sessions` 请求函数 +2. `server-node/src/routes/runtimeRoutes.ts` 不再挂旧 session 路由 +3. 仓库里不再有主流程可达的旧世界生成入口 +4. Agent 主链与已保存作品编辑链仍然可用 + diff --git a/docs/technical/README.md b/docs/technical/README.md index 30c71580..42272303 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -9,6 +9,10 @@ - [CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md](./CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md):世界草稿生成失败后等待页误显示为“卡在编译草稿卡”的根因拆解、主链与增强链路边界,以及本次修复策略。 - [CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md](./CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md):世界草稿里“资产已生成但结果页看不到”的根因拆解,包含角色主形象展示、分幕背景露出和 fallback 资源格式修复。 - [CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md](./CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md):Phase4 新增角色/地点后草稿作品卡数量统计与测试断言的语义对齐说明。 +- [AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md):阶段一保持结果页深度编辑能力不变,同时把结果页完整世界快照同步回 Agent session 主链的方案说明。 +- [AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md):阶段二把平台创作入口统一到聚合作品列表,并收紧 Agent 结果页的新增入口职责边界。 +- [AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md):阶段三继续降级旧 pipeline,让创作中心只认 Agent 草稿与已发布作品,并把 Agent 结果页冻结为预览收口层。 +- [CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md](./CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md):对照当前优化计划核查四阶段完成度,并明确这轮只允许物理删除旧 `custom-world/sessions` 世界生成链,不误伤 Agent 主链与已保存作品兼容编辑链。 - [TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md](./TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md):把外部仓库 TXT 模式完整迁入当前项目的冻结边界、模块映射、分阶段计划与验收清单。 - [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。 - [EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md](./EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md):Express 后端当前 contract 冻结版本、热点文件编辑规则与集成窗口清单。 diff --git a/packages/shared/src/contracts/customWorldAgent.ts b/packages/shared/src/contracts/customWorldAgent.ts index c40f56a9..4be16d88 100644 --- a/packages/shared/src/contracts/customWorldAgent.ts +++ b/packages/shared/src/contracts/customWorldAgent.ts @@ -428,6 +428,7 @@ export type CustomWorldAgentOperationType = | 'regenerate_scope' | 'draft_foundation' | 'update_draft_card' + | 'sync_result_profile' | 'generate_characters' | 'generate_landmarks' | 'generate_role_assets' @@ -497,6 +498,10 @@ export type CustomWorldAgentActionRequest = value: string; }>; } + | { + action: 'sync_result_profile'; + profile: Record; + } | { action: 'generate_characters'; count: number; diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index e3760fbc..799f1353 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -2503,6 +2503,34 @@ test('custom world works endpoint returns draft sessions and published worlds to assert.equal(publishResponse.status, 200); + const publishMutationResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/world-published/publish`, + withBearer(entry.token, { + method: 'POST', + }), + ); + + assert.equal(publishMutationResponse.status, 200); + + const draftOnlyResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/world-draft-only`, + withBearer(entry.token, { + method: 'PUT', + body: JSON.stringify({ + profile: { + id: 'world-draft-only', + name: '旧兼容草稿', + subtitle: '仍保留在作品库,但不再进入创作中心', + summary: '这个条目用来验证阶段三不会继续把 library draft 当主草稿展示。', + playableNpcs: [{ id: 'hero-draft', name: '旧草稿角色' }], + landmarks: [{ id: 'port-draft', name: '旧草稿地点' }], + }, + }), + }), + ); + + assert.equal(draftOnlyResponse.status, 200); + const worksResponse = await httpRequest( `${baseUrl}/api/runtime/custom-world/works`, { @@ -2542,6 +2570,10 @@ test('custom world works endpoint returns draft sessions and published worlds to item.canEnterWorld === true, ), ); + assert.equal( + worksPayload.items.some((item) => item.profileId === 'world-draft-only'), + false, + ); }); }); @@ -3038,6 +3070,117 @@ test('custom world agent update_draft_card action updates draft profile and card ); }); +test('custom world agent sync_result_profile action writes result snapshot back over http', async () => { + await withTestServer( + 'custom-world-agent-sync-result-profile-http', + async ({ baseUrl, context }) => { + installTestCustomWorldAgentSingleTurnLlm(context); + const entry = await authEntry( + baseUrl, + 'cw_agent_sync_result', + 'secret123', + ); + const session = await createObjectRefiningCustomWorldAgentSession({ + baseUrl, + token: entry.token, + }); + + const actionResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + action: 'sync_result_profile', + profile: { + id: `agent-draft-${session.sessionId}`, + settingText: '被海雾吞没的旧航路群岛', + name: '潮雾列岛·结果页回写版', + subtitle: '旧灯塔与失控航路', + summary: '结果页里的最新世界概述已经回写到当前草稿。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船夜与假航灯背后的操盘链。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + attributeSchema: { + id: 'schema:test', + worldId: 'CUSTOM', + schemaVersion: 1, + schemaName: '测试', + generatedFrom: { + worldType: 'CUSTOM', + worldName: '潮雾列岛·结果页回写版', + settingSummary: '测试', + tone: '测试', + conflictCore: '测试', + }, + slots: [], + }, + playableNpcs: [], + storyNpcs: [], + items: [], + landmarks: [], + generationMode: 'full', + generationStatus: 'complete', + }, + }), + }), + ); + const actionPayload = (await actionResponse.json()) as { + operation: { + operationId: string; + status: string; + }; + }; + + assert.equal(actionResponse.status, 200); + assert.equal(actionPayload.operation.status, 'queued'); + + await waitForCustomWorldAgentOperation({ + baseUrl, + token: entry.token, + sessionId: session.sessionId, + operationId: actionPayload.operation.operationId, + expectedStatus: 'completed', + }); + + const sessionResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + const sessionPayload = (await sessionResponse.json()) as { + draftProfile: { + name?: string; + summary?: string; + legacyResultProfile?: { + name?: string; + playerGoal?: string; + }; + } | null; + }; + + assert.equal(sessionResponse.status, 200); + assert.equal(sessionPayload.draftProfile?.name, '潮雾列岛·结果页回写版'); + assert.equal( + sessionPayload.draftProfile?.summary, + '结果页里的最新世界概述已经回写到当前草稿。', + ); + assert.equal( + sessionPayload.draftProfile?.legacyResultProfile?.name, + '潮雾列岛·结果页回写版', + ); + assert.equal( + sessionPayload.draftProfile?.legacyResultProfile?.playerGoal, + '查清沉船夜与假航灯背后的操盘链。', + ); + }, + ); +}); + test('custom world agent generate_characters action appends character cards over http', async () => { await withTestServer( 'custom-world-agent-phase4-generate-characters-http', diff --git a/server-node/src/routes/customWorldAgent.ts b/server-node/src/routes/customWorldAgent.ts index 33e8762f..aa353e61 100644 --- a/server-node/src/routes/customWorldAgent.ts +++ b/server-node/src/routes/customWorldAgent.ts @@ -39,6 +39,10 @@ const actionSchema = z.discriminatedUnion('action', [ ) .min(1), }), + z.object({ + action: z.literal('sync_result_profile'), + profile: z.record(z.string(), z.unknown()), + }), z.object({ action: z.literal('generate_characters'), count: z.number().int().min(1).max(3), diff --git a/server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts b/server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts new file mode 100644 index 00000000..d64c6c3e --- /dev/null +++ b/server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts @@ -0,0 +1,229 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import sharp from 'sharp'; + +import { createDatabase } from '../db.js'; +import { RuntimeRepository } from '../repositories/runtimeRepository.js'; +import { loadConfig } from '../config.js'; +import { CustomWorldAgentSessionStore } from '../services/customWorldAgentSessionStore.js'; + +type RecordValue = Record; + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is RecordValue { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function toRecordArray(value: unknown) { + return Array.isArray(value) + ? value.filter((entry): entry is RecordValue => isRecord(entry)) + : []; +} + +function resolvePublicAssetPath(publicDir: string, imageSrc: unknown) { + const normalizedImageSrc = toText(imageSrc); + if (!normalizedImageSrc) { + return null; + } + + return path.join(publicDir, normalizedImageSrc.replace(/^\/+/u, '')); +} + +async function ensureSquareRoleImage(publicDir: string, imageSrc: unknown) { + const assetPath = resolvePublicAssetPath(publicDir, imageSrc); + if (!assetPath || !fs.existsSync(assetPath)) { + return null; + } + + const metadata = await sharp(assetPath).metadata(); + if ( + typeof metadata.width === 'number' && + typeof metadata.height === 'number' && + metadata.width === metadata.height + ) { + return { + imageSrc: toText(imageSrc), + updated: false, + width: metadata.width, + height: metadata.height, + }; + } + + const squaredBuffer = await sharp(assetPath) + .resize(1024, 1024, { + fit: 'cover', + position: 'attention', + }) + .png() + .toBuffer(); + fs.writeFileSync(assetPath, squaredBuffer); + + return { + imageSrc: toText(imageSrc), + updated: true, + width: 1024, + height: 1024, + }; +} + +async function main() { + const userId = 'user_02b1dea4e951b13fe53db236560bdf28'; + const sessionId = 'custom-world-agent-session-019e192a4060d18b92df127f1dafe8ae'; + const profileId = 'custom-world-mo744tca-深海奇境'; + const config = loadConfig({ + projectRoot: path.resolve(process.cwd(), '..'), + }); + const db = await createDatabase(config); + + try { + const runtimeRepository = new RuntimeRepository(db); + const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const session = await sessionStore.getSnapshot(userId, sessionId); + if (!session || !isRecord(session.draftProfile)) { + throw new Error('未找到目标世界草稿 session,无法同步历史保存档案。'); + } + + const savedProfileEntry = ( + await runtimeRepository.listCustomWorldProfiles(userId) + ).find((entry) => entry.profileId === profileId); + if (!savedProfileEntry) { + throw new Error('未找到目标 saved profile,无法同步历史保存档案。'); + } + + const draftProfile = session.draftProfile; + const nextProfile = JSON.parse( + JSON.stringify(savedProfileEntry.profile), + ) as RecordValue; + + const draftPlayableById = new Map( + toRecordArray(draftProfile.playableNpcs).map((entry) => [toText(entry.id), entry] as const), + ); + const draftStoryById = new Map( + toRecordArray(draftProfile.storyNpcs).map((entry) => [toText(entry.id), entry] as const), + ); + const draftLandmarkById = new Map( + toRecordArray(draftProfile.landmarks).map((entry) => [toText(entry.id), entry] as const), + ); + const draftSceneChapterBySceneId = new Map( + toRecordArray(draftProfile.sceneChapters).map((entry) => [toText(entry.sceneId), entry] as const), + ); + + const playableNpcs = toRecordArray(nextProfile.playableNpcs).map((role) => { + const draftRole = draftPlayableById.get(toText(role.id)); + if (!draftRole) { + return role; + } + + return { + ...role, + imageSrc: toText(draftRole.imageSrc) || role.imageSrc, + generatedVisualAssetId: + toText(draftRole.generatedVisualAssetId) || role.generatedVisualAssetId, + }; + }); + + const storyNpcs = toRecordArray(nextProfile.storyNpcs).map((role) => { + const draftRole = draftStoryById.get(toText(role.id)); + if (!draftRole) { + return role; + } + + return { + ...role, + imageSrc: toText(draftRole.imageSrc) || role.imageSrc, + generatedVisualAssetId: + toText(draftRole.generatedVisualAssetId) || role.generatedVisualAssetId, + }; + }); + + const landmarks = toRecordArray(nextProfile.landmarks).map((landmark) => { + const draftLandmark = draftLandmarkById.get(toText(landmark.id)); + const draftSceneChapter = draftSceneChapterBySceneId.get(toText(landmark.id)); + const firstActImageSrc = + toRecordArray(draftSceneChapter?.acts) + .map((act) => toText(act.backgroundImageSrc)) + .find(Boolean) || ''; + + return { + ...landmark, + imageSrc: + toText(draftLandmark?.imageSrc) || + firstActImageSrc || + toText(landmark.imageSrc) || + undefined, + }; + }); + + const sceneChapterBlueprints = toRecordArray( + nextProfile.sceneChapterBlueprints, + ).map((chapter) => { + const draftChapter = draftSceneChapterBySceneId.get(toText(chapter.sceneId)); + if (!draftChapter) { + return chapter; + } + + const draftActById = new Map( + toRecordArray(draftChapter.acts).map((act) => [toText(act.id), act] as const), + ); + + return { + ...chapter, + acts: toRecordArray(chapter.acts).map((act) => { + const draftAct = draftActById.get(toText(act.id)); + if (!draftAct) { + return act; + } + + return { + ...act, + backgroundImageSrc: + toText(draftAct.backgroundImageSrc) || act.backgroundImageSrc, + backgroundAssetId: + toText(draftAct.backgroundAssetId) || act.backgroundAssetId, + }; + }), + }; + }); + + const roleImageUpdates = await Promise.all( + [...playableNpcs, ...storyNpcs].map((role) => + ensureSquareRoleImage(config.publicDir, role.imageSrc), + ), + ); + + nextProfile.playableNpcs = playableNpcs; + nextProfile.storyNpcs = storyNpcs; + nextProfile.landmarks = landmarks; + nextProfile.sceneChapterBlueprints = sceneChapterBlueprints; + + const updatedEntry = await runtimeRepository.upsertCustomWorldProfile( + userId, + profileId, + nextProfile, + savedProfileEntry.authorDisplayName || '玩家', + ); + + const summary = { + profileId, + syncedPlayableCount: playableNpcs.length, + syncedStoryCount: storyNpcs.length, + syncedLandmarkCount: landmarks.length, + syncedSceneChapterCount: sceneChapterBlueprints.length, + squareRoleImagesUpdated: roleImageUpdates.filter((entry) => entry?.updated).length, + coverImageSrc: updatedEntry.entry.coverImageSrc, + }; + + console.log(JSON.stringify(summary, null, 2)); + } finally { + await db.close(); + } +} + +void main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/server-node/src/services/customWorldAgentOrchestrator.ts b/server-node/src/services/customWorldAgentOrchestrator.ts index 956f80d4..a1292a02 100644 --- a/server-node/src/services/customWorldAgentOrchestrator.ts +++ b/server-node/src/services/customWorldAgentOrchestrator.ts @@ -16,6 +16,8 @@ import type { } from '../../../packages/shared/src/contracts/customWorldAgent.js'; import { badRequest, notFound } from '../errors.js'; import { prepareEventStreamResponse } from '../http.js'; +import { normalizeCustomWorldProfile } from '../modules/custom-world/runtimeProfile.js'; +import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js'; import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js'; import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js'; import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js'; @@ -150,6 +152,8 @@ function buildOperation(type: CustomWorldAgentOperationRecord['type']) { ? '正在把已确认设定编成第一版世界底稿。' : type === 'update_draft_card' ? '正在把这次设定改动写回草稿。' + : type === 'sync_result_profile' + ? '正在把结果页里的世界快照同步回当前草稿。' : type === 'generate_characters' ? '正在围绕当前底稿补出新角色。' : type === 'generate_landmarks' @@ -194,6 +198,27 @@ function buildRoleAssetSyncResultText(params: { return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}。`; } +function syncResultProfileIntoDraftProfile(params: { + currentDraftProfile: Record | null | undefined; + resultProfile: CustomWorldProfile; +}) { + const currentDraftProfile = params.currentDraftProfile ?? {}; + const resultProfile = params.resultProfile; + + return { + // 阶段一只回写基础摘要和完整 legacy 快照,避免把结果页的运行时结构反向拆回 foundation draft。 + ...currentDraftProfile, + name: resultProfile.name, + subtitle: resultProfile.subtitle, + summary: resultProfile.summary, + tone: resultProfile.tone, + playerGoal: resultProfile.playerGoal, + majorFactions: resultProfile.majorFactions, + coreConflicts: resultProfile.coreConflicts, + legacyResultProfile: resultProfile as unknown as Record, + } satisfies Record; +} + function buildQuestionLines( pendingClarifications: CustomWorldPendingClarification[], ) { @@ -548,6 +573,7 @@ export class CustomWorldAgentOrchestrator { if ( payload.action === 'update_draft_card' || + payload.action === 'sync_result_profile' || payload.action === 'generate_characters' || payload.action === 'generate_landmarks' || payload.action === 'generate_role_assets' || @@ -595,6 +621,32 @@ export class CustomWorldAgentOrchestrator { }; } + if (payload.action === 'sync_result_profile') { + const normalizedProfile = normalizeCustomWorldProfile( + payload.profile, + '', + ); + if (!normalizedProfile) { + throw badRequest('sync_result_profile requires a valid profile'); + } + + const operation = buildOperation('sync_result_profile'); + await this.sessionStore.createOperation(userId, sessionId, operation); + void this.processSyncResultProfileOperation({ + userId, + sessionId, + operationId: operation.operationId, + payload: { + ...payload, + profile: normalizedProfile as unknown as Record, + }, + }); + + return { + operation, + }; + } + if (payload.action === 'generate_characters') { if (payload.count < 1 || payload.count > 3) { throw badRequest('generate_characters count must be between 1 and 3'); @@ -1113,6 +1165,97 @@ export class CustomWorldAgentOrchestrator { } } + private async processSyncResultProfileOperation(params: { + userId: string; + sessionId: string; + operationId: string; + payload: Extract< + CustomWorldAgentActionRequest, + { action: 'sync_result_profile' } + >; + }) { + const { userId, sessionId, operationId, payload } = params; + + try { + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'running', + phaseLabel: '同步结果页快照', + phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。', + progress: 36, + }); + + const latestSession = (await this.sessionStore.get( + userId, + sessionId, + )) as CustomWorldAgentSessionRecord | null; + if (!latestSession) { + throw new Error('custom world agent session not found'); + } + + const resultProfile = payload.profile as unknown as CustomWorldProfile; + const nextDraftProfile = syncResultProfileIntoDraftProfile({ + currentDraftProfile: latestSession.draftProfile, + resultProfile, + }); + + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + phaseLabel: '重编译草稿摘要', + phaseDetail: '正在刷新草稿卡摘要、资产覆盖和结果页恢复快照。', + progress: 72, + }); + + const nextDraftCards = this.draftCompiler.compileDraftCards(nextDraftProfile); + const assetCoverage = rebuildRoleAssetCoverage(nextDraftProfile); + const nextStage = + latestSession.stage === 'visual_refining' + ? ('visual_refining' as const) + : ('object_refining' as const); + const nextSuggestedActions = buildSuggestedActions({ + stage: nextStage, + isReady: true, + draftProfile: nextDraftProfile, + draftCards: nextDraftCards, + }); + + await this.sessionStore.replaceDerivedState(userId, sessionId, { + stage: nextStage, + draftProfile: nextDraftProfile, + draftCards: nextDraftCards, + assetCoverage, + suggestedActions: nextSuggestedActions, + recommendedReplies: [], + }); + await this.sessionStore.appendCheckpoint(userId, sessionId, { + label: '同步结果页编辑', + }); + await this.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: '结果页里的最新世界结构已经同步回当前草稿。', + }), + ); + + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'completed', + phaseLabel: '结果页快照已同步', + phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。', + progress: 100, + error: null, + }); + } catch (error) { + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'failed', + phaseLabel: '结果页同步失败', + phaseDetail: '这一轮结果页编辑没有成功写回当前草稿。', + progress: 100, + error: + error instanceof Error ? error.message : 'sync result profile failed', + }); + } + } + private async processGenerateCharactersOperation(params: { userId: string; sessionId: string; diff --git a/server-node/src/services/customWorldAgentPhase4.test.ts b/server-node/src/services/customWorldAgentPhase4.test.ts index b5e347f0..d2a6302b 100644 --- a/server-node/src/services/customWorldAgentPhase4.test.ts +++ b/server-node/src/services/customWorldAgentPhase4.test.ts @@ -227,6 +227,200 @@ test('phase4 update_draft_card writes back draft profile and recompiles summarie ); }); +test('phase4 sync_result_profile writes result-page snapshot back into session draft chain', async () => { + const runtimeRepository = createRuntimeRepositoryStub(); + const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); + const userId = 'user-phase4-sync-result-profile'; + const session = await createObjectRefiningSession(orchestrator, userId); + + const response = await orchestrator.executeAction(userId, session.sessionId, { + action: 'sync_result_profile', + profile: { + id: `agent-draft-${session.sessionId}`, + settingText: '被海雾吞没的旧航路群岛', + name: '潮雾列岛·结果页精修版', + subtitle: '旧灯塔与失控航路', + summary: '结果页已经把世界概述继续往沉船夜暗线收紧。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船夜与假航灯的真正操盘者。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + attributeSchema: { + id: 'schema:test', + worldId: 'CUSTOM', + schemaVersion: 1, + schemaName: '测试', + generatedFrom: { + worldType: 'CUSTOM', + worldName: '潮雾列岛·结果页精修版', + settingSummary: '测试', + tone: '测试', + conflictCore: '测试', + }, + slots: [], + }, + playableNpcs: [], + storyNpcs: [], + items: [], + landmarks: [], + generationMode: 'full', + generationStatus: 'complete', + }, + }); + const operation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + response.operation.operationId, + ); + const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); + const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); + const draftRecord = snapshot?.draftProfile as Record | null; + const legacyResultProfile = draftRecord?.legacyResultProfile as + | Record + | undefined; + + assert.equal(operation?.status, 'completed'); + assert.equal(profile?.name, '潮雾列岛·结果页精修版'); + assert.equal( + profile?.summary, + '结果页已经把世界概述继续往沉船夜暗线收紧。', + ); + assert.equal(legacyResultProfile?.name, '潮雾列岛·结果页精修版'); + assert.equal( + legacyResultProfile?.playerGoal, + '查清沉船夜与假航灯的真正操盘者。', + ); + assert.ok( + snapshot?.messages.some( + (message) => + message.kind === 'action_result' && + message.text.includes('结果页里的最新世界结构已经同步回当前草稿'), + ), + ); +}); + +test('phase4 sync_result_profile keeps existing foundation structure while updating summary snapshot', async () => { + const runtimeRepository = createRuntimeRepositoryStub(); + const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); + const userId = 'user-phase4-sync-result-profile-structure'; + const session = await createObjectRefiningSession(orchestrator, userId); + const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile); + const baselinePlayableName = baselineProfile?.playableNpcs[0]?.name; + const baselineStoryName = baselineProfile?.storyNpcs[0]?.name; + const baselineLandmarkName = baselineProfile?.landmarks[0]?.name; + + assert.ok(baselinePlayableName); + assert.ok(baselineStoryName); + assert.ok(baselineLandmarkName); + + const response = await orchestrator.executeAction(userId, session.sessionId, { + action: 'sync_result_profile', + profile: { + id: `agent-draft-${session.sessionId}`, + settingText: '被海雾吞没的旧航路群岛', + name: '潮雾列岛·结果页精修版', + subtitle: '旧灯塔与失控航路', + summary: '结果页已经把世界概述继续往沉船夜暗线收紧。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船夜与假航灯的真正操盘者。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + attributeSchema: { + id: 'schema:test', + worldId: 'CUSTOM', + schemaVersion: 1, + schemaName: '测试', + generatedFrom: { + worldType: 'CUSTOM', + worldName: '潮雾列岛·结果页精修版', + settingSummary: '测试', + tone: '测试', + conflictCore: '测试', + }, + slots: [], + }, + playableNpcs: [ + { + id: 'playable-runtime-only', + name: '结果页临时角色', + title: '运行时角色', + role: '测试角色', + description: '不应该直接覆盖 foundation draft。', + backstory: '仅用于验证 sync 边界。', + personality: '谨慎', + motivation: '验证同步边界', + combatStyle: '观察', + initialAffinity: 0, + relationshipHooks: [], + tags: [], + }, + ], + storyNpcs: [ + { + id: 'story-runtime-only', + name: '结果页临时场景角色', + title: '运行时场景角色', + role: '测试角色', + description: '不应该直接覆盖 foundation draft。', + backstory: '仅用于验证 sync 边界。', + personality: '克制', + motivation: '验证同步边界', + combatStyle: '观察', + initialAffinity: 0, + relationshipHooks: [], + tags: [], + }, + ], + items: [], + landmarks: [ + { + id: 'landmark-runtime-only', + name: '结果页临时地点', + description: '不应该直接覆盖 foundation draft。', + dangerLevel: '低', + sceneNpcIds: [], + connections: [], + }, + ], + generationMode: 'full', + generationStatus: 'complete', + }, + }); + const operation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + response.operation.operationId, + ); + const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); + const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); + const draftRecord = snapshot?.draftProfile as Record | null; + const legacyResultProfile = draftRecord?.legacyResultProfile as + | Record + | undefined; + + assert.equal(operation?.status, 'completed'); + assert.equal(profile?.name, '潮雾列岛·结果页精修版'); + assert.equal(profile?.playableNpcs[0]?.name, baselinePlayableName); + assert.equal(profile?.storyNpcs[0]?.name, baselineStoryName); + assert.equal(profile?.landmarks[0]?.name, baselineLandmarkName); + assert.equal(legacyResultProfile?.name, '潮雾列岛·结果页精修版'); + assert.equal( + (legacyResultProfile?.playableNpcs as Array<{ name?: string }> | undefined)?.[0] + ?.name, + '结果页临时角色', + ); +}); + test('phase4 generate_characters appends story npcs and updates work summary counts', async () => { const runtimeRepository = createRuntimeRepositoryStub(); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); @@ -323,3 +517,33 @@ test('phase4 generate_landmarks appends new landmark cards and checkpoints', asy ); assert.ok((latestSessionRecord?.checkpoints.length ?? 0) >= 2); }); + +test('phase4 work summaries exclude library draft entries after phase3 downgrade', async () => { + const runtimeRepository = createRuntimeRepositoryStub(); + const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); + const userId = 'user-phase4-work-summary-phase3'; + const session = await createObjectRefiningSession(orchestrator, userId); + + await runtimeRepository.upsertCustomWorldProfile(userId, 'library-draft-1', { + id: 'library-draft-1', + name: '旧兼容草稿', + subtitle: '仍保留在作品库', + summary: '不应该继续出现在创作中心 works 聚合里。', + playableNpcs: [], + landmarks: [], + }); + + const workItems = await listCustomWorldWorkSummaries(userId, { + runtimeRepository, + customWorldAgentSessions: sessionStore, + }); + + assert.ok(workItems.some((item) => item.sessionId === session.sessionId)); + assert.equal( + workItems.some((item) => item.profileId === 'library-draft-1'), + false, + ); +}); diff --git a/server-node/src/services/customWorldWorkSummaryService.ts b/server-node/src/services/customWorldWorkSummaryService.ts index 79989c2a..a77454d5 100644 --- a/server-node/src/services/customWorldWorkSummaryService.ts +++ b/server-node/src/services/customWorldWorkSummaryService.ts @@ -171,6 +171,12 @@ function isLibraryEntry( ); } +function isPublishedLibraryEntry( + value: unknown, +): value is CustomWorldLibraryEntry { + return isLibraryEntry(value) && value.visibility === 'published'; +} + export async function listCustomWorldWorkSummaries( userId: string, dependencies: { @@ -216,8 +222,10 @@ export async function listCustomWorldWorkSummaries( }; }); - const publishedItems: CustomWorldWorkSummary[] = profiles.map((profile) => { - const libraryEntry = isLibraryEntry(profile) ? profile : null; + const publishedItems: CustomWorldWorkSummary[] = profiles + .filter((profile) => isPublishedLibraryEntry(profile)) + .map((profile) => { + const libraryEntry = profile; const profileRecord = ( libraryEntry?.profile ?? profile ) as CustomWorldProfileRecord & Record; @@ -237,59 +245,55 @@ export async function listCustomWorldWorkSummaries( (entry) => Boolean(toText(entry.generatedAnimationSetId)), ).length; - return { - workId: `published:${toText(profileRecord.id) || updatedAt}`, - sourceType: 'published_profile', - status: 'published', - title: - (libraryEntry ? toText(libraryEntry.worldName) : '') || - toText(profileRecord.name) || - '未命名世界', - subtitle: - (libraryEntry ? toText(libraryEntry.subtitle) : '') || - toText(profileRecord.subtitle) || - '已保存作品', - summary: - (libraryEntry ? toText(libraryEntry.summaryText) : '') || - toText(profileRecord.summary) || - '这个世界已经可以直接进入体验。', - coverImageSrc: - (libraryEntry ? libraryEntry.coverImageSrc : null) || - coverPresentation.imageSrc, - coverRenderMode: coverPresentation.renderMode, - coverCharacterImageSrcs: coverPresentation.characterImageSrcs, - updatedAt, - publishedAt: - (libraryEntry ? toText(libraryEntry.publishedAt) : '') || - toText(profileRecord.publishedAt) || + return { + workId: `published:${toText(profileRecord.id) || updatedAt}`, + sourceType: 'published_profile', + status: 'published', + title: + toText(libraryEntry.worldName) || + toText(profileRecord.name) || + '未命名世界', + subtitle: + toText(libraryEntry.subtitle) || + toText(profileRecord.subtitle) || + '已保存作品', + summary: + toText(libraryEntry.summaryText) || + toText(profileRecord.summary) || + '这个世界已经可以直接进入体验。', + coverImageSrc: libraryEntry.coverImageSrc || coverPresentation.imageSrc, + coverRenderMode: coverPresentation.renderMode, + coverCharacterImageSrcs: coverPresentation.characterImageSrcs, updatedAt, - stage: 'published', - stageLabel: '已发布', - playableNpcCount: - (libraryEntry?.playableNpcCount ?? 0) > 0 - ? libraryEntry!.playableNpcCount - : playableNpcs.length, - landmarkCount: - (libraryEntry?.landmarkCount ?? 0) > 0 - ? libraryEntry!.landmarkCount - : landmarks.length, - roleVisualReadyCount, - roleAnimationReadyCount, - roleAssetSummaryLabel: - roleAnimationReadyCount > 0 - ? `动作已就绪 ${roleAnimationReadyCount}` - : roleVisualReadyCount > 0 - ? `主图已就绪 ${roleVisualReadyCount}` - : null, - sessionId: null, - profileId: - (libraryEntry ? toText(libraryEntry.profileId) : '') || - toText(profileRecord.id) || - null, - canResume: false, - canEnterWorld: true, - }; - }); + publishedAt: + toText(libraryEntry.publishedAt) || + toText(profileRecord.publishedAt) || + updatedAt, + stage: 'published', + stageLabel: '已发布', + playableNpcCount: + libraryEntry.playableNpcCount > 0 + ? libraryEntry.playableNpcCount + : playableNpcs.length, + landmarkCount: + libraryEntry.landmarkCount > 0 + ? libraryEntry.landmarkCount + : landmarks.length, + roleVisualReadyCount, + roleAnimationReadyCount, + roleAssetSummaryLabel: + roleAnimationReadyCount > 0 + ? `动作已就绪 ${roleAnimationReadyCount}` + : roleVisualReadyCount > 0 + ? `主图已就绪 ${roleVisualReadyCount}` + : null, + sessionId: null, + profileId: + toText(libraryEntry.profileId) || toText(profileRecord.id) || null, + canResume: false, + canEnterWorld: true, + }; + }); return [...draftItems, ...publishedItems].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt), diff --git a/src/components/CustomWorldResultView.test.tsx b/src/components/CustomWorldResultView.test.tsx index 679bb68a..05fe3658 100644 --- a/src/components/CustomWorldResultView.test.tsx +++ b/src/components/CustomWorldResultView.test.tsx @@ -398,3 +398,30 @@ test('landmark tab uses first act image as scene card preview and keeps chapter '/generated-custom-world-scenes/scene-act-1.png', ); }); + +test('readOnly result view hides edit and create actions for agent preview mode', async () => { + const user = userEvent.setup(); + + render( + {}} + onProfileChange={() => {}} + readOnly + compactAgentResultMode + />, + ); + + expect(screen.queryByRole('button', { name: /^编辑$/u })).toBeNull(); + + await user.click(screen.getByRole('button', { name: /可扮演角色/u })); + expect(screen.queryByRole('button', { name: '新增可扮演角色' })).toBeNull(); + + await user.click(screen.getByRole('button', { name: /场景角色/u })); + expect(screen.queryByRole('button', { name: /批量删除/u })).toBeNull(); +}); diff --git a/src/components/CustomWorldResultView.tsx b/src/components/CustomWorldResultView.tsx index 1ca3ddb8..ce4e89fc 100644 --- a/src/components/CustomWorldResultView.tsx +++ b/src/components/CustomWorldResultView.tsx @@ -40,6 +40,7 @@ interface CustomWorldResultViewProps { regenerateActionLabel?: string; enterWorldActionLabel?: string; autoSaveState?: 'idle' | 'saving' | 'saved' | 'error'; + compactAgentResultMode?: boolean; } type EntityGenerationKind = 'playable' | 'story' | 'landmark'; @@ -372,6 +373,7 @@ export function CustomWorldResultView({ regenerateActionLabel = '重新生成', enterWorldActionLabel = '进入世界', autoSaveState = 'idle', + compactAgentResultMode = false, }: CustomWorldResultViewProps) { const [editorTarget, setEditorTarget] = useState(null); @@ -609,9 +611,11 @@ export function CustomWorldResultView({ onProfileChange={onProfileChange} onDeleteStoryNpcs={handleDeleteStoryNpcs} onDeleteLandmarks={handleDeleteLandmarks} - createActionLabel={readOnly ? undefined : createLabel} + createActionLabel={ + readOnly || compactAgentResultMode ? undefined : createLabel + } onCreateAction={ - readOnly || !createTarget + readOnly || compactAgentResultMode || !createTarget ? undefined : () => { if (activeTab === 'playable') { diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 31247959..e4f4e97c 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -135,12 +135,12 @@ export function CustomWorldCreationHub({ key={item.workId} item={item} onClick={() => { - if (item.status === 'draft' && item.sessionId) { + if (item.sourceType === 'agent_session' && item.sessionId) { onResumeDraft(item.sessionId); return; } - if (item.status === 'published' && item.profileId) { + if (item.profileId) { onEnterPublished(item.profileId); } }} diff --git a/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx b/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx index 46f52405..94365b7d 100644 --- a/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx +++ b/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx @@ -6,14 +6,15 @@ import { useState } from 'react'; import { beforeEach, expect, test, vi } from 'vitest'; import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { createCustomWorldAgentSession, executeCustomWorldAgentAction, getCustomWorldAgentOperation, getCustomWorldAgentSession, + listCustomWorldWorks, streamCustomWorldAgentMessage, } from '../../services/aiService'; -import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { AuthUser } from '../../services/authService'; import { clearProfileBrowseHistory, @@ -54,12 +55,30 @@ async function clickFirstAsyncButtonByName( await user.click(buttons[0]!); } +async function openCreationHub(user: ReturnType) { + await clickFirstButtonByName(user, '创作'); + expect(await screen.findByText('创作中心')).toBeTruthy(); +} + +async function openNewRpgCreation( + user: ReturnType, +) { + await openCreationHub(user); + const createButtons = await screen.findAllByRole('button', { + name: /新建作品/u, + }); + await user.click(createButtons.at(-1)!); + expect(screen.getByText('选择创作类型')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u })); +} + vi.mock('../../services/aiService', () => ({ createCustomWorldAgentSession: vi.fn(), executeCustomWorldAgentAction: vi.fn(), generateCustomWorldProfile: vi.fn(), getCustomWorldAgentOperation: vi.fn(), getCustomWorldAgentSession: vi.fn(), + listCustomWorldWorks: vi.fn(), streamCustomWorldAgentMessage: vi.fn(), })); @@ -340,6 +359,7 @@ beforeEach(() => { vi.mocked(createCustomWorldAgentSession).mockResolvedValue({ session: mockSession, }); + vi.mocked(listCustomWorldWorks).mockResolvedValue([]); vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({ operation: { operationId: 'operation-draft-foundation-1', @@ -369,8 +389,11 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and render(); - await clickFirstButtonByName(user, '创作'); - await clickFirstButtonByName(user, /开启新的创作/u); + await openCreationHub(user); + const createButtons = await screen.findAllByRole('button', { + name: /新建作品/u, + }); + await user.click(createButtons.at(-1)!); expect(screen.getByText('选择创作类型')).toBeTruthy(); @@ -393,6 +416,52 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and ).toBeTruthy(); }); +test('create tab uses unified creation hub and can resume an agent draft', async () => { + const user = userEvent.setup(); + + vi.mocked(listCustomWorldWorks).mockResolvedValue([ + { + workId: 'draft:custom-world-agent-session-1', + sourceType: 'agent_session', + status: 'draft', + title: '潮雾列岛', + subtitle: '精修对象', + summary: '玩家是失职返乡的守灯人。', + coverImageSrc: null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + updatedAt: '2026-04-20T10:00:00.000Z', + publishedAt: null, + stage: 'object_refining', + stageLabel: '精修对象', + playableNpcCount: 3, + landmarkCount: 4, + roleVisualReadyCount: 1, + roleAnimationReadyCount: 0, + roleAssetSummaryLabel: '沈砺 · 主图已生成', + sessionId: 'custom-world-agent-session-1', + profileId: null, + canResume: true, + canEnterWorld: false, + }, + ]); + + render(); + + await openCreationHub(user); + + expect( + screen.getByRole('button', { name: /继续精修/u }), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: /继续精修/u })).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: /继续精修/u })); + + expect( + await screen.findByText('Agent工作区:custom-world-agent-session-1'), + ).toBeTruthy(); +}); + test('clicking a public work while logged out routes through requireAuth', async () => { const user = userEvent.setup(); const requireAuth = vi.fn(); @@ -448,10 +517,7 @@ test('selecting RPG creation while logged out routes through requireAuth', async />, ); - await clickFirstButtonByName(user, '创作'); - await clickFirstButtonByName(user, /开启新的创作/u); - await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u })); - + await openNewRpgCreation(user); expect(requireAuth).toHaveBeenCalledTimes(1); expect(createCustomWorldAgentSession).not.toHaveBeenCalled(); }); @@ -461,9 +527,7 @@ test('starting draft generation leaves the agent workspace and shows the generat render(); - await clickFirstButtonByName(user, '创作'); - await clickFirstButtonByName(user, /开启新的创作/u); - await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u })); + await openNewRpgCreation(user); expect( await screen.findByText('Agent工作区:custom-world-agent-session-1'), @@ -490,7 +554,7 @@ test('starting draft generation leaves the agent workspace and shows the generat expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull(); }); -test('existing draft sessions enter the legacy result layout directly', async () => { +test('existing draft sessions enter the agent preview layout without opening legacy editor', async () => { const user = userEvent.setup(); vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({ @@ -595,9 +659,7 @@ test('existing draft sessions enter the legacy result layout directly', async () render(); - await clickFirstButtonByName(user, '创作'); - await clickFirstButtonByName(user, /开启新的创作/u); - await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u })); + await openNewRpgCreation(user); await waitFor( async () => { @@ -611,13 +673,295 @@ test('existing draft sessions enter the legacy result layout directly', async () expect(screen.queryByText(/Agent工作区/u)).toBeNull(); expect(screen.queryByRole('button', { name: /^锚点/u })).toBeNull(); expect(screen.getByText(/基本设定/u)).toBeTruthy(); + expect(screen.queryByRole('button', { name: /新增场景角色/u })).toBeNull(); await user.click(screen.getByRole('button', { name: /场景角色/u })); - await user.click(screen.getByRole('button', { name: /顾潮音/u })); + expect(screen.getByRole('button', { name: /顾潮音/u })).toBeTruthy(); + expect(screen.queryByText(/编辑场景角色:顾潮音/u)).toBeNull(); + expect(screen.queryByRole('button', { name: /AI生成/u })).toBeNull(); + expect(screen.queryByText('技能')).toBeNull(); +}); - expect(await screen.findByText(/编辑场景角色:顾潮音/u)).toBeTruthy(); - expect(screen.getByRole('button', { name: /AI生成/u })).toBeTruthy(); - expect(screen.getByText('技能')).toBeTruthy(); +test('agent draft result back button returns to workspace without redundant sync when session is already latest', async () => { + const user = userEvent.setup(); + + vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({ + operation: { + operationId: 'operation-sync-result-profile-1', + type: 'sync_result_profile', + status: 'queued', + phaseLabel: '同步结果页快照', + phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。', + progress: 24, + error: null, + }, + }); + vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({ + operationId: 'operation-sync-result-profile-1', + type: 'sync_result_profile', + status: 'completed', + phaseLabel: '结果页快照已同步', + phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。', + progress: 100, + error: null, + }); + + const resultSession = { + ...mockSession, + stage: 'object_refining' as const, + creatorIntent: { + sourceMode: 'card', + worldHook: '被海雾吞没的旧航路群岛', + playerPremise: '玩家回到群岛调查沉船真相。', + themeKeywords: ['海雾', '旧航路'], + toneDirectives: ['压抑', '悬疑'], + openingSituation: '首夜就有陌生船只闯入禁航区。', + coreConflicts: ['航运公会与守灯会争夺航路控制权'], + keyFactions: [], + keyCharacters: [], + keyLandmarks: [], + iconicElements: ['会移动的海雾'], + forbiddenDirectives: [], + rawSettingText: '', + }, + draftProfile: { + name: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船与禁航区异动的真相。', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + playableNpcs: [ + { + id: 'playable-1', + name: '沈砺', + title: '旧航路引路人', + role: '关键同行者', + publicIdentity: '最熟悉旧航路的人。', + publicMask: '看上去像可靠旧友。', + currentPressure: '他必须在两股势力间站队。', + hiddenHook: '暗中替沉船商盟引路。', + relationToPlayer: '旧友兼潜在背叛者', + threadIds: ['thread-1'], + summary: '他像旧友,但也像一把始终没收回鞘的刀。', + }, + ], + storyNpcs: [ + { + id: 'story-1', + name: '顾潮音', + title: '守灯会值夜人', + role: '场景关键角色', + publicIdentity: '负责夜间巡灯与封锁。', + publicMask: '对外一直冷静克制。', + currentPressure: '她知道更多禁航区真相。', + hiddenHook: '曾亲眼见过失控海雾吞船。', + relationToPlayer: '最早愿意交换线索的人', + threadIds: ['thread-1'], + summary: '她总像比所有人更早知道海雾会往哪一侧压下来。', + }, + ], + landmarks: [ + { + id: 'landmark-1', + name: '回潮旧灯塔', + purpose: '观察雾潮与往来船只', + mood: '潮湿、压抑、风声不止', + importance: '开局核心场景', + characterIds: ['story-1'], + threadIds: ['thread-1'], + summary: '旧灯塔是整片群岛最先看见异动的地方。', + }, + ], + factions: [], + threads: [], + chapters: [], + worldHook: '被海雾吞没的旧航路群岛', + playerPremise: '玩家回到群岛调查沉船真相。', + openingSituation: '首夜就有陌生船只闯入禁航区。', + iconicElements: ['会移动的海雾'], + sourceAnchorSummary: '海雾、旧灯塔、失控航路。', + legacyResultProfile: { + id: 'agent-draft-custom-world-agent-session-1', + settingText: '被海雾吞没的旧航路群岛', + name: '潮雾列岛·同步后', + subtitle: '旧灯塔与失控航路', + summary: '同步后的结果页快照已经回写到 session。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船与禁航区异动的真相。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + playableNpcs: [], + storyNpcs: [], + items: [], + landmarks: [], + generationMode: 'full', + generationStatus: 'complete', + }, + }, + draftCards: [ + { + id: 'world-foundation', + kind: 'world', + title: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成。', + status: 'warning', + linkedIds: ['playable-1', 'story-1', 'landmark-1'], + warningCount: 0, + }, + ], + } satisfies CustomWorldAgentSessionSnapshot; + vi.mocked(getCustomWorldAgentSession).mockResolvedValue(resultSession); + + render(); + + await openNewRpgCreation(user); + + await waitFor( + async () => { + expect(await screen.findByText('世界档案')).toBeTruthy(); + expect(screen.getByRole('button', { name: /返回创作/u })).toBeTruthy(); + }, + { timeout: 2500 }, + ); + + await user.click(screen.getByRole('button', { name: /返回创作/u })); + + await waitFor(() => { + expect( + screen.getByText('Agent工作区:custom-world-agent-session-1'), + ).toBeTruthy(); + }); + + expect( + vi.mocked(executeCustomWorldAgentAction).mock.calls.some( + ([sessionId, payload]) => + sessionId === 'custom-world-agent-session-1' && + payload?.action === 'sync_result_profile', + ), + ).toBe(false); + expect(screen.queryByText('世界档案')).toBeNull(); +}); + +test('agent draft result auto-save persists the latest profile rebuilt from synced session', async () => { + const user = userEvent.setup(); + + vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({ + operation: { + operationId: 'operation-sync-result-profile-2', + type: 'sync_result_profile', + status: 'queued', + phaseLabel: '同步结果页快照', + phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。', + progress: 24, + error: null, + }, + }); + vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({ + operationId: 'operation-sync-result-profile-2', + type: 'sync_result_profile', + status: 'completed', + phaseLabel: '结果页快照已同步', + phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。', + progress: 100, + error: null, + }); + + const syncedSession = { + ...mockSession, + stage: 'object_refining' as const, + creatorIntent: { + sourceMode: 'card', + worldHook: '被海雾吞没的旧航路群岛', + playerPremise: '玩家回到群岛调查沉船真相。', + themeKeywords: ['海雾', '旧航路'], + toneDirectives: ['压抑', '悬疑'], + openingSituation: '首夜就有陌生船只闯入禁航区。', + coreConflicts: ['航运公会与守灯会争夺航路控制权'], + keyFactions: [], + keyCharacters: [], + keyLandmarks: [], + iconicElements: ['会移动的海雾'], + forbiddenDirectives: [], + rawSettingText: '', + }, + draftProfile: { + name: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船与禁航区异动的真相。', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + playableNpcs: [], + storyNpcs: [], + landmarks: [], + factions: [], + threads: [], + chapters: [], + worldHook: '被海雾吞没的旧航路群岛', + playerPremise: '玩家回到群岛调查沉船真相。', + openingSituation: '首夜就有陌生船只闯入禁航区。', + iconicElements: ['会移动的海雾'], + sourceAnchorSummary: '海雾、旧灯塔、失控航路。', + legacyResultProfile: { + id: 'agent-draft-custom-world-agent-session-1', + settingText: '被海雾吞没的旧航路群岛', + name: '潮雾列岛·session最新版', + subtitle: '旧灯塔与失控航路', + summary: '作品库应该保存这份同步后的最新快照。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船与禁航区异动的真相。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + playableNpcs: [], + storyNpcs: [], + items: [], + landmarks: [], + generationMode: 'full', + generationStatus: 'complete', + }, + }, + draftCards: [ + { + id: 'world-foundation', + kind: 'world', + title: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成。', + status: 'warning', + linkedIds: [], + warningCount: 0, + }, + ], + } satisfies CustomWorldAgentSessionSnapshot; + vi.mocked(getCustomWorldAgentSession).mockResolvedValue(syncedSession); + + render(); + + await openNewRpgCreation(user); + + await waitFor( + async () => { + expect(await screen.findByText('世界档案')).toBeTruthy(); + expect(screen.getByText('已自动保存')).toBeTruthy(); + }, + { timeout: 2500 }, + ); + + await waitFor(() => { + expect(upsertCustomWorldProfile).toHaveBeenCalled(); + }); + + const latestSavedProfile = vi.mocked(upsertCustomWorldProfile).mock.calls.at(-1)?.[0]; + expect(latestSavedProfile?.name).toBe('潮雾列岛·session最新版'); + expect(latestSavedProfile?.summary).toBe( + '作品库应该保存这份同步后的最新快照。', + ); }); test('authenticated users with save archives default into the saves tab', async () => { @@ -697,42 +1041,71 @@ test('owned world detail can delete a work and return to the create tab list', a const user = userEvent.setup(); vi.spyOn(window, 'confirm').mockReturnValue(true); - vi.mocked(listCustomWorldLibrary).mockResolvedValue([ - { - ownerUserId: 'user-1', - profileId: 'world-delete-1', - profile: { - id: 'world-delete-1', - name: '潮雾列岛', - subtitle: '旧灯塔与失控航路', - summary: '用于测试删除流程的作品。', - tone: '压抑、潮湿、悬疑', - playerGoal: '查清旧案。', - majorFactions: ['守灯会'], - coreConflicts: ['雾潮正在逼近港口'], - playableNpcs: [], - storyNpcs: [], - landmarks: [], - } as never, - visibility: 'draft', - publishedAt: null, - updatedAt: '2026-04-16T12:00:00.000Z', - authorDisplayName: '测试玩家', - worldName: '潮雾列岛', + const publishedWork = { + workId: 'published:world-delete-1', + sourceType: 'published_profile' as const, + status: 'published' as const, + title: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '用于测试删除流程的作品。', + coverImageSrc: null, + coverRenderMode: 'image' as const, + coverCharacterImageSrcs: [], + updatedAt: '2026-04-16T12:00:00.000Z', + publishedAt: '2026-04-16T12:00:00.000Z', + stage: null, + stageLabel: '已发布', + playableNpcCount: 0, + landmarkCount: 0, + roleVisualReadyCount: 0, + roleAnimationReadyCount: 0, + roleAssetSummaryLabel: null, + sessionId: null, + profileId: 'world-delete-1', + canResume: false, + canEnterWorld: true, + }; + const publishedLibraryEntry = { + ownerUserId: 'user-1', + profileId: 'world-delete-1', + profile: { + id: 'world-delete-1', + name: '潮雾列岛', subtitle: '旧灯塔与失控航路', - summaryText: '用于测试删除流程的作品。', - coverImageSrc: null, - themeMode: 'tide', - playableNpcCount: 0, - landmarkCount: 0, - }, - ]); + summary: '用于测试删除流程的作品。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清旧案。', + majorFactions: ['守灯会'], + coreConflicts: ['雾潮正在逼近港口'], + playableNpcs: [], + storyNpcs: [], + landmarks: [], + } as never, + visibility: 'published' as const, + publishedAt: '2026-04-16T12:00:00.000Z', + updatedAt: '2026-04-16T12:00:00.000Z', + authorDisplayName: '测试玩家', + worldName: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summaryText: '用于测试删除流程的作品。', + coverImageSrc: null, + themeMode: 'tide' as const, + playableNpcCount: 0, + landmarkCount: 0, + }; + + vi.mocked(listCustomWorldWorks) + .mockResolvedValueOnce([publishedWork]) + .mockResolvedValue([]); + vi.mocked(listCustomWorldLibrary) + .mockResolvedValueOnce([publishedLibraryEntry]) + .mockResolvedValue([]); vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]); render(); - await clickFirstButtonByName(user, '创作'); - await clickFirstAsyncButtonByName(user, /潮雾列岛/u); + await openCreationHub(user); + await user.click(screen.getByRole('button', { name: /进入世界/u })); await user.click(await screen.findByRole('button', { name: '删除作品' })); await waitFor(() => { @@ -742,8 +1115,77 @@ test('owned world detail can delete a work and return to the create tab list', a await waitFor(() => { expect(screen.queryByRole('button', { name: '删除作品' })).toBeNull(); }); - expect( - screen.getAllByText('你还没有保存任何自定义世界,先创建一个草稿开始吧。') - .length, - ).toBeTruthy(); + await waitFor(() => { + expect(screen.getByText('还没有作品')).toBeTruthy(); + }); +}); + +test('creation hub published work enters existing detail view', async () => { + const user = userEvent.setup(); + + vi.mocked(listCustomWorldWorks).mockResolvedValue([ + { + workId: 'published:world-public-1', + sourceType: 'published_profile', + status: 'published', + title: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '已经发布的群岛世界作品。', + coverImageSrc: null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + updatedAt: '2026-04-20T10:00:00.000Z', + publishedAt: '2026-04-20T10:00:00.000Z', + stage: null, + stageLabel: '已发布', + playableNpcCount: 3, + landmarkCount: 4, + roleVisualReadyCount: 1, + roleAnimationReadyCount: 0, + roleAssetSummaryLabel: null, + sessionId: null, + profileId: 'world-public-1', + canResume: false, + canEnterWorld: true, + }, + ]); + vi.mocked(listCustomWorldLibrary).mockResolvedValue([ + { + ownerUserId: 'user-1', + profileId: 'world-public-1', + profile: { + id: 'world-public-1', + name: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '已经发布的群岛世界作品。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清群岛旧案。', + majorFactions: ['守灯会'], + coreConflicts: ['假航灯正在扰乱航线'], + playableNpcs: [], + storyNpcs: [], + landmarks: [], + } as never, + visibility: 'published', + publishedAt: '2026-04-20T10:00:00.000Z', + updatedAt: '2026-04-20T10:00:00.000Z', + authorDisplayName: '测试玩家', + worldName: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summaryText: '已经发布的群岛世界作品。', + coverImageSrc: null, + themeMode: 'tide', + playableNpcCount: 3, + landmarkCount: 4, + }, + ]); + + render(); + + await openCreationHub(user); + await user.click(screen.getByRole('button', { name: /进入世界/u })); + + expect(await screen.findByText('世界信息')).toBeTruthy(); + expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy(); + expect(screen.getByText('已发布')).toBeTruthy(); }); diff --git a/src/components/game-shell/PreGameSelectionFlow.tsx b/src/components/game-shell/PreGameSelectionFlow.tsx index 46542469..a2f06cbf 100644 --- a/src/components/game-shell/PreGameSelectionFlow.tsx +++ b/src/components/game-shell/PreGameSelectionFlow.tsx @@ -14,6 +14,7 @@ import type { CustomWorldAgentMessage, CustomWorldAgentOperationRecord, CustomWorldAgentSessionSnapshot, + CustomWorldWorkSummary, SendCustomWorldAgentMessageRequest, } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { @@ -29,6 +30,7 @@ import { executeCustomWorldAgentAction, getCustomWorldAgentOperation, getCustomWorldAgentSession, + listCustomWorldWorks, streamCustomWorldAgentMessage, } from '../../services/aiService'; import { buildCustomWorldProfileFromAgentDraft } from '../../services/customWorldAgentDraftResult'; @@ -69,6 +71,7 @@ import { } from '../../services/storageService'; import { type CustomWorldProfile, type GameState } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; +import { CustomWorldCreationHub } from '../custom-world-home/CustomWorldCreationHub'; import { PlatformCreationTypeModal } from './PlatformCreationTypeModal'; import { type PlatformHomeTab, PlatformHomeView } from './PlatformHomeView'; import { PlatformWorldDetailView } from './PlatformWorldDetailView'; @@ -107,6 +110,10 @@ type CustomWorldGenerationViewSource = 'agent-draft-foundation' | null; type CustomWorldResultViewSource = 'saved-profile' | 'agent-draft' | null; type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error'; +type SyncedAgentDraftResult = { + session: CustomWorldAgentSessionSnapshot | null; + profile: CustomWorldProfile | null; +}; type PreGameSelectionFlowProps = { selectionStage: SelectionStage; @@ -164,6 +171,10 @@ function normalizeAgentBackedProfile(profile: CustomWorldProfile) { } satisfies CustomWorldProfile; } +function stringifyAgentBackedProfile(profile: CustomWorldProfile) { + return JSON.stringify(normalizeAgentBackedProfile(profile)); +} + function LazyPanelFallback({ label }: { label: string }) { return (
@@ -174,6 +185,37 @@ function LazyPanelFallback({ label }: { label: string }) { ); } +function buildCreationHubFallbackItems( + entries: CustomWorldLibraryEntry[], +): CustomWorldWorkSummary[] { + return entries + .filter((entry) => entry.visibility === 'published') + .map((entry) => ({ + workId: `fallback:${entry.profileId}`, + sourceType: 'published_profile', + status: 'published', + title: entry.worldName, + subtitle: entry.subtitle || '已发布作品', + summary: entry.summaryText || '继续补完这个世界的设定与游玩入口。', + coverImageSrc: entry.coverImageSrc, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + updatedAt: entry.updatedAt, + publishedAt: entry.publishedAt, + stage: null, + stageLabel: '已发布', + playableNpcCount: entry.playableNpcCount, + landmarkCount: entry.landmarkCount, + roleVisualReadyCount: 0, + roleAnimationReadyCount: 0, + roleAssetSummaryLabel: null, + sessionId: null, + profileId: entry.profileId, + canResume: false, + canEnterWorld: true, + })); +} + export function PreGameSelectionFlow({ selectionStage, setSelectionStage, @@ -191,6 +233,9 @@ export function PreGameSelectionFlow({ const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState< CustomWorldLibraryEntry[] >([]); + const [customWorldWorkEntries, setCustomWorldWorkEntries] = useState< + CustomWorldWorkSummary[] + >([]); const [publishedGalleryEntries, setPublishedGalleryEntries] = useState< CustomWorldGalleryCard[] >([]); @@ -250,6 +295,10 @@ export function PreGameSelectionFlow({ const customWorldAutoSaveTimeoutRef = useRef(null); const lastAutoSavedProfileSignatureRef = useRef(null); const latestAutoSaveRequestIdRef = useRef(0); + const latestAgentResultSyncSignatureRef = useRef(null); + // 用户手动返回工作区后,先抑制自动重开结果页,避免刚退出又被 session 快照顶回去。 + const isAgentDraftResultAutoOpenSuppressedRef = useRef(false); + const isCustomWorldAutoSaveBusyRef = useRef(false); const platformTabBootstrapUserIdRef = useRef( undefined, ); @@ -318,6 +367,17 @@ export function PreGameSelectionFlow({ } }, [authUi?.user]); + const refreshCustomWorldWorks = useCallback(async () => { + if (!authUi?.user) { + setCustomWorldWorkEntries([]); + return []; + } + + const nextItems = await listCustomWorldWorks(); + setCustomWorldWorkEntries(nextItems); + return nextItems; + }, [authUi?.user]); + const appendBrowseHistoryEntry = useCallback( async (entry: PlatformBrowseHistoryWriteEntry) => { const nextEntries = writePlatformBrowseHistory(authUi?.user, entry); @@ -380,6 +440,7 @@ export function PreGameSelectionFlow({ setDashboardError(null); if (!isAuthenticated) { setSavedCustomWorldEntries([]); + setCustomWorldWorkEntries([]); setSaveEntries([]); setProfileDashboard(null); } @@ -387,12 +448,14 @@ export function PreGameSelectionFlow({ try { const [ libraryEntriesResult, + workEntriesResult, galleryEntriesResult, dashboardResult, historyResult, saveArchivesResult, ] = await Promise.allSettled([ isAuthenticated ? listCustomWorldLibrary() : Promise.resolve([]), + isAuthenticated ? listCustomWorldWorks() : Promise.resolve([]), listCustomWorldGallery(), isAuthenticated ? getProfileDashboard() : Promise.resolve(null), isAuthenticated @@ -423,6 +486,12 @@ export function PreGameSelectionFlow({ setSavedCustomWorldEntries([]); } + if (workEntriesResult.status === 'fulfilled') { + setCustomWorldWorkEntries(workEntriesResult.value); + } else { + setCustomWorldWorkEntries([]); + } + if (galleryEntriesResult.status === 'fulfilled') { setPublishedGalleryEntries(galleryEntriesResult.value); } else { @@ -431,11 +500,14 @@ export function PreGameSelectionFlow({ if ( (isAuthenticated && libraryEntriesResult.status === 'rejected') || + (isAuthenticated && workEntriesResult.status === 'rejected') || galleryEntriesResult.status === 'rejected' ) { const platformFailure = libraryEntriesResult.status === 'rejected' ? libraryEntriesResult.reason + : workEntriesResult.status === 'rejected' + ? workEntriesResult.reason : galleryEntriesResult.status === 'rejected' ? galleryEntriesResult.reason : null; @@ -742,9 +814,14 @@ export function PreGameSelectionFlow({ return; } + if (isAgentDraftResultAutoOpenSuppressedRef.current) { + return; + } + if (selectionStage === 'agent-workspace') { setGeneratedCustomWorldProfile(agentDraftResultProfile); setCustomWorldResultViewSource('agent-draft'); + isAgentDraftResultAutoOpenSuppressedRef.current = false; setSelectionStage('custom-world-result'); return; } @@ -755,10 +832,12 @@ export function PreGameSelectionFlow({ ) { setGeneratedCustomWorldProfile(agentDraftResultProfile); setCustomWorldResultViewSource('agent-draft'); + isAgentDraftResultAutoOpenSuppressedRef.current = false; } }, [ agentDraftResultProfile, generatedCustomWorldProfile, + isAgentDraftResultAutoOpenSuppressedRef, selectionStage, setSelectionStage, shouldAutoOpenAgentDraftResult, @@ -776,6 +855,8 @@ export function PreGameSelectionFlow({ const isAgentDraftGenerationView = customWorldGenerationViewSource === 'agent-draft-foundation'; const isAgentDraftResultView = customWorldResultViewSource === 'agent-draft'; + const isAgentDraftResultEditingFrozen = + customWorldResultViewSource === 'agent-draft'; const activeGenerationSettingText = agentDraftSettingPreview; const activeGenerationProgress = agentDraftGenerationProgress; const isActiveGenerationRunning = @@ -822,6 +903,7 @@ export function PreGameSelectionFlow({ setIsCreatingAgentSession(true); setCreationTypeError(null); + isAgentDraftResultAutoOpenSuppressedRef.current = false; try { const { session } = await createCustomWorldAgentSession( @@ -921,6 +1003,7 @@ export function PreGameSelectionFlow({ const isDraftFoundationAction = payload.action === 'draft_foundation'; if (isDraftFoundationAction) { + isAgentDraftResultAutoOpenSuppressedRef.current = false; setGeneratedCustomWorldProfile(null); setCustomWorldError(null); setCustomWorldAutoSaveError(null); @@ -980,14 +1063,14 @@ export function PreGameSelectionFlow({ }; const leaveAgentDraftResult = () => { + isAgentDraftResultAutoOpenSuppressedRef.current = true; setGeneratedCustomWorldProfile(null); setCustomWorldError(null); setCustomWorldAutoSaveError(null); setCustomWorldAutoSaveState('idle'); setCustomWorldGenerationViewSource(null); setCustomWorldResultViewSource(null); - setPlatformTab('create'); - setSelectionStage('platform'); + setSelectionStage('agent-workspace'); }; const retryAgentDraftGeneration = () => { @@ -1000,25 +1083,79 @@ export function PreGameSelectionFlow({ openCreationTypePicker(); }; - const openLibraryDetail = ( - entry: CustomWorldLibraryEntry, - ) => { - if (entry.visibility === 'published') { - void appendBrowseHistoryEntry({ - ownerUserId: entry.ownerUserId, - profileId: entry.profileId, - worldName: entry.worldName, - subtitle: entry.subtitle, - summaryText: entry.summaryText, - coverImageSrc: entry.coverImageSrc, - themeMode: entry.themeMode, - authorDisplayName: entry.authorDisplayName, - }); - } - setSelectedDetailEntry(entry); - setDetailError(null); - setSelectionStage('detail'); - }; + const openLibraryDetail = useCallback( + (entry: CustomWorldLibraryEntry) => { + if (entry.visibility === 'published') { + void appendBrowseHistoryEntry({ + ownerUserId: entry.ownerUserId, + profileId: entry.profileId, + worldName: entry.worldName, + subtitle: entry.subtitle, + summaryText: entry.summaryText, + coverImageSrc: entry.coverImageSrc, + themeMode: entry.themeMode, + authorDisplayName: entry.authorDisplayName, + }); + } + setSelectedDetailEntry(entry); + setDetailError(null); + setSelectionStage('detail'); + }, + [appendBrowseHistoryEntry, setSelectionStage], + ); + + const handleOpenCreationWork = useCallback( + async (work: CustomWorldWorkSummary) => { + if (work.status === 'draft' && work.sessionId) { + // 阶段二要求草稿优先回到 Agent 工作区,而不是再次自动顶回结果页。 + isAgentDraftResultAutoOpenSuppressedRef.current = true; + persistAgentUiState(work.sessionId, null); + setGeneratedCustomWorldProfile(null); + setCustomWorldError(null); + setCustomWorldAutoSaveError(null); + setCustomWorldAutoSaveState('idle'); + setCustomWorldGenerationViewSource(null); + setCustomWorldResultViewSource(null); + setPlatformTab('create'); + setSelectionStage('agent-workspace'); + return; + } + + if (!work.profileId) { + return; + } + + try { + let matchedEntry = savedCustomWorldEntries.find( + (entry) => entry.profileId === work.profileId, + ); + + if (!matchedEntry && authUi?.user) { + const latestLibraryEntries = await listCustomWorldLibrary(); + setSavedCustomWorldEntries(latestLibraryEntries); + matchedEntry = latestLibraryEntries.find( + (entry) => entry.profileId === work.profileId, + ); + } + + if (matchedEntry) { + openLibraryDetail(matchedEntry); + return; + } + + setPlatformError('未找到对应作品,请刷新后重试。'); + } catch (error) { + setPlatformError(resolveErrorMessage(error, '读取作品详情失败。')); + } + }, + [ + authUi?.user, + openLibraryDetail, + persistAgentUiState, + savedCustomWorldEntries, + setSelectionStage, + ], + ); const openGalleryDetail = async (entry: CustomWorldGalleryCard) => { setSelectionStage('detail'); @@ -1083,7 +1220,7 @@ export function PreGameSelectionFlow({ } const normalizedProfile = normalizeAgentBackedProfile(profile); - const profileSignature = JSON.stringify(normalizedProfile); + const profileSignature = stringifyAgentBackedProfile(normalizedProfile); const requestId = latestAutoSaveRequestIdRef.current + 1; latestAutoSaveRequestIdRef.current = requestId; setCustomWorldAutoSaveState('saving'); @@ -1097,6 +1234,9 @@ export function PreGameSelectionFlow({ lastAutoSavedProfileSignatureRef.current = profileSignature; setSavedCustomWorldEntries(mutation.entries); + if (authUi?.user) { + void refreshCustomWorldWorks().catch(() => {}); + } setSelectedDetailEntry((current) => { if (!current || current.profileId === mutation.entry.profileId) { return mutation.entry; @@ -1119,7 +1259,99 @@ export function PreGameSelectionFlow({ return null; } }, - [generatedCustomWorldProfile], + [authUi?.user, generatedCustomWorldProfile, refreshCustomWorldWorks], + ); + + const syncAgentDraftResultProfile = useCallback( + async (profile: CustomWorldProfile) => { + if (!activeAgentSessionId) { + return { + session: null, + profile: null, + } satisfies SyncedAgentDraftResult; + } + + const normalizedProfile = normalizeAgentBackedProfile(profile); + const profileSignature = stringifyAgentBackedProfile(normalizedProfile); + const latestSessionProfileSignature = + agentSession && buildCustomWorldProfileFromAgentDraft(agentSession) + ? stringifyAgentBackedProfile( + buildCustomWorldProfileFromAgentDraft(agentSession)!, + ) + : ''; + if (latestSessionProfileSignature === profileSignature) { + latestAgentResultSyncSignatureRef.current = profileSignature; + return { + session: agentSession, + profile: normalizeAgentBackedProfile( + buildCustomWorldProfileFromAgentDraft(agentSession) ?? profile, + ), + } satisfies SyncedAgentDraftResult; + } + if (latestAgentResultSyncSignatureRef.current === profileSignature) { + return { + session: agentSession, + profile: normalizeAgentBackedProfile( + buildCustomWorldProfileFromAgentDraft(agentSession) ?? profile, + ), + } satisfies SyncedAgentDraftResult; + } + + const { operation } = await executeCustomWorldAgentAction( + activeAgentSessionId, + { + action: 'sync_result_profile', + profile: normalizedProfile as unknown as Record, + }, + ); + setAgentOperation(operation); + persistAgentUiState(activeAgentSessionId, operation.operationId); + + for (let attempt = 0; attempt < 60; attempt += 1) { + const latestOperation = await getCustomWorldAgentOperation( + activeAgentSessionId, + operation.operationId, + ); + setAgentOperation(latestOperation); + + if (latestOperation.status === 'failed') { + throw new Error( + latestOperation.error || + latestOperation.phaseDetail || + '同步结果页世界快照失败。', + ); + } + + if (latestOperation.status === 'completed') { + persistAgentUiState(activeAgentSessionId, null); + const latestSession = await syncAgentSessionSnapshot( + activeAgentSessionId, + ); + // 同步完成后统一从最新 session 重编译结果,保证结果页、作品库和进入世界吃同一份快照。 + const latestProfile = normalizeAgentBackedProfile( + buildCustomWorldProfileFromAgentDraft(latestSession) ?? profile, + ); + if (latestProfile) { + setGeneratedCustomWorldProfile(latestProfile); + } + latestAgentResultSyncSignatureRef.current = profileSignature; + return { + session: latestSession, + profile: latestProfile, + } satisfies SyncedAgentDraftResult; + } + + await new Promise((resolve) => window.setTimeout(resolve, 200)); + } + + throw new Error('同步结果页世界快照超时。'); + }, + [ + activeAgentSessionId, + agentSession, + persistAgentUiState, + syncAgentSessionSnapshot, + ], ); useEffect(() => { @@ -1127,6 +1359,7 @@ export function PreGameSelectionFlow({ setCustomWorldAutoSaveState('idle'); setCustomWorldAutoSaveError(null); lastAutoSavedProfileSignatureRef.current = null; + latestAgentResultSyncSignatureRef.current = null; if (customWorldAutoSaveTimeoutRef.current !== null) { window.clearTimeout(customWorldAutoSaveTimeoutRef.current); customWorldAutoSaveTimeoutRef.current = null; @@ -1138,7 +1371,11 @@ export function PreGameSelectionFlow({ return; } - const nextSignature = JSON.stringify(generatedCustomWorldProfile); + if (isCustomWorldAutoSaveBusyRef.current) { + return; + } + + const nextSignature = stringifyAgentBackedProfile(generatedCustomWorldProfile); if (nextSignature === lastAutoSavedProfileSignatureRef.current) { return; } @@ -1150,7 +1387,28 @@ export function PreGameSelectionFlow({ const profileToSave = generatedCustomWorldProfile; customWorldAutoSaveTimeoutRef.current = window.setTimeout(() => { - void saveGeneratedCustomWorld(profileToSave); + void (async () => { + isCustomWorldAutoSaveBusyRef.current = true; + try { + let latestProfileToSave = normalizeAgentBackedProfile(profileToSave); + if (isAgentDraftResultView) { + const syncedResult = + await syncAgentDraftResultProfile(profileToSave); + // 作品库自动保存优先落同步后 session 重编译出的结果,避免继续保存旧的前端内存态。 + latestProfileToSave = normalizeAgentBackedProfile( + syncedResult.profile ?? profileToSave, + ); + } + await saveGeneratedCustomWorld(latestProfileToSave); + } catch (error) { + setCustomWorldAutoSaveState('error'); + setCustomWorldAutoSaveError( + resolveErrorMessage(error, '保存自定义世界失败。'), + ); + } finally { + isCustomWorldAutoSaveBusyRef.current = false; + } + })(); customWorldAutoSaveTimeoutRef.current = null; }, 600); @@ -1160,7 +1418,13 @@ export function PreGameSelectionFlow({ customWorldAutoSaveTimeoutRef.current = null; } }; - }, [generatedCustomWorldProfile, saveGeneratedCustomWorld, selectionStage]); + }, [ + generatedCustomWorldProfile, + isAgentDraftResultView, + saveGeneratedCustomWorld, + selectionStage, + syncAgentDraftResultProfile, + ]); const openSavedCustomWorldEditor = ( entry: CustomWorldLibraryEntry, @@ -1200,6 +1464,7 @@ export function PreGameSelectionFlow({ selectedDetailEntry.profileId, ); setSavedCustomWorldEntries(mutation.entries); + await refreshCustomWorldWorks().catch(() => []); setSelectedDetailEntry(mutation.entry); setPublishedGalleryEntries(await listCustomWorldGallery()); } catch (error) { @@ -1221,6 +1486,7 @@ export function PreGameSelectionFlow({ selectedDetailEntry.profileId, ); setSavedCustomWorldEntries(mutation.entries); + await refreshCustomWorldWorks().catch(() => []); setSelectedDetailEntry(mutation.entry); setPublishedGalleryEntries(await listCustomWorldGallery()); } catch (error) { @@ -1249,6 +1515,7 @@ export function PreGameSelectionFlow({ selectedDetailEntry.profileId, ); setSavedCustomWorldEntries(entries); + await refreshCustomWorldWorks().catch(() => []); setSelectedDetailEntry(null); setPlatformTab('create'); setSelectionStage('platform'); @@ -1269,6 +1536,10 @@ export function PreGameSelectionFlow({ ), ); const resultViewError = customWorldAutoSaveError ?? customWorldError; + const creationHubItems = + customWorldWorkEntries.length > 0 + ? customWorldWorkEntries + : buildCreationHubFallbackItems(savedCustomWorldEntries); return ( <> @@ -1281,47 +1552,106 @@ export function PreGameSelectionFlow({ exit={{ opacity: 0, y: -12 }} className="flex h-full min-h-0 flex-col" > - { - void handleResumeSaveEntry(entry); - }} - onOpenCreateWorld={openCustomWorldCreator} - onOpenCreateTypePicker={openCreationTypePicker} - onOpenGalleryDetail={(entry) => { - runProtectedAction(() => { - void openGalleryDetail(entry); - }); - }} - onOpenLibraryDetail={(entry) => { - runProtectedAction(() => { - openLibraryDetail(entry); - }); - }} - onOpenProfileDashboardCard={() => { - if (dashboardError) { - void refreshProfileDashboard(); + {platformTab === 'create' ? ( + { + setPlatformTab('home'); + }} + onRetry={() => { + setPlatformError(null); + void refreshCustomWorldWorks().catch((error) => { + setPlatformError( + resolveErrorMessage(error, '读取创作作品列表失败。'), + ); + }); + }} + onCreateNew={openCreationTypePicker} + onResumeDraft={(sessionId) => { + runProtectedAction(() => { + void handleOpenCreationWork({ + workId: `draft:${sessionId}`, + sourceType: 'agent_session', + status: 'draft', + title: '', + subtitle: '', + summary: '', + coverImageSrc: null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + updatedAt: new Date().toISOString(), + publishedAt: null, + stage: null, + stageLabel: '', + playableNpcCount: 0, + landmarkCount: 0, + roleVisualReadyCount: 0, + roleAnimationReadyCount: 0, + roleAssetSummaryLabel: null, + sessionId, + profileId: null, + canResume: true, + canEnterWorld: false, + }); + }); + }} + onEnterPublished={(profileId) => { + runProtectedAction(() => { + const matchedWork = creationHubItems.find( + (entry) => entry.profileId === profileId, + ); + if (!matchedWork) { + return; + } + void handleOpenCreationWork(matchedWork); + }); + }} + /> + ) : ( + + dashboardError={isLoadingDashboard ? null : dashboardError} + onContinueGame={handleContinueGame} + onResumeSave={(entry) => { + void handleResumeSaveEntry(entry); + }} + onOpenCreateWorld={openCustomWorldCreator} + onOpenCreateTypePicker={openCreationTypePicker} + onOpenGalleryDetail={(entry) => { + runProtectedAction(() => { + void openGalleryDetail(entry); + }); + }} + onOpenLibraryDetail={(entry) => { + runProtectedAction(() => { + openLibraryDetail(entry); + }); + }} + onOpenProfileDashboardCard={() => { + if (dashboardError) { + void refreshProfileDashboard(); + } + }} + /> + )} )} @@ -1501,7 +1831,28 @@ export function PreGameSelectionFlow({ }} onBack={ isAgentDraftResultView - ? leaveAgentDraftResult + ? () => { + void (async () => { + const currentProfile = + generatedCustomWorldProfile ?? + buildCustomWorldProfileFromAgentDraft( + agentSession, + ); + + if (currentProfile && activeAgentSessionId) { + await syncAgentDraftResultProfile(currentProfile); + } + + leaveAgentDraftResult(); + })().catch((error) => { + setCustomWorldError( + resolveErrorMessage( + error, + '返回创作前同步草稿失败。', + ), + ); + }); + } : leaveCustomWorldResult } onEditSetting={undefined} @@ -1509,10 +1860,40 @@ export function PreGameSelectionFlow({ onContinueExpand={undefined} onEnterWorld={() => { runProtectedAction(() => { - handleCustomWorldSelect(generatedCustomWorldProfile); + void (async () => { + if (!isAgentDraftResultView || !activeAgentSessionId) { + handleCustomWorldSelect(generatedCustomWorldProfile); + return; + } + + const currentProfile = + generatedCustomWorldProfile ?? + buildCustomWorldProfileFromAgentDraft(agentSession); + if (!currentProfile) { + return; + } + + const latestResult = await syncAgentDraftResultProfile( + currentProfile, + ); + const latestProfile = normalizeAgentBackedProfile( + buildCustomWorldProfileFromAgentDraft( + latestResult.session ?? agentSession, + ) ?? + latestResult.profile ?? + currentProfile, + ); + setGeneratedCustomWorldProfile(latestProfile); + handleCustomWorldSelect(latestProfile); + })().catch((error) => { + setCustomWorldError( + resolveErrorMessage(error, '进入世界前同步草稿失败。'), + ); + }); }); }} - readOnly={false} + readOnly={isAgentDraftResultEditingFrozen} + compactAgentResultMode={isAgentDraftResultView} backLabel={isAgentDraftResultView ? '返回创作' : undefined} editActionLabel="去Agent调整设定" enterWorldActionLabel="进入世界"