From effe0355bdf734f4a0e7e48678af7cd8a83ba4cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Tue, 21 Apr 2026 00:48:17 +0800 Subject: [PATCH 1/6] 1 --- ...D_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md | 579 ++++++++++++++++++ docs/planning/README.md | 1 + ...T_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md | 212 +++++++ ...T_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md | 75 +++ ...T_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md | 148 +++++ ...ON_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md | 92 +++ docs/technical/README.md | 4 + .../shared/src/contracts/customWorldAgent.ts | 5 + server-node/src/app.test.ts | 143 +++++ server-node/src/routes/customWorldAgent.ts | 4 + .../syncCustomWorldSavedProfileAssets.ts | 229 +++++++ .../services/customWorldAgentOrchestrator.ts | 143 +++++ .../services/customWorldAgentPhase4.test.ts | 224 +++++++ .../services/customWorldWorkSummaryService.ts | 112 ++-- src/components/CustomWorldResultView.test.tsx | 27 + src/components/CustomWorldResultView.tsx | 8 +- .../CustomWorldCreationHub.tsx | 4 +- ...meSelectionFlow.agent.interaction.test.tsx | 548 +++++++++++++++-- .../game-shell/PreGameSelectionFlow.tsx | 519 +++++++++++++--- 19 files changed, 2897 insertions(+), 180 deletions(-) create mode 100644 docs/planning/ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md create mode 100644 docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md create mode 100644 docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md create mode 100644 docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md create mode 100644 docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md create mode 100644 server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts 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="进入世界" From 3614e1f5a2bb69576661d7ffb0491d2910782362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Tue, 21 Apr 2026 09:44:17 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E5=88=9B=E4=BD=9C=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E6=94=B6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- README.md | 10 +- ...RAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md | 62 +- ...NG_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md | 141 ++ ...NG_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md | 145 ++ ...NG_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md | 177 +++ ...NG_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md | 56 + ...OGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md | 503 +++++++ docs/audits/engineering/README.md | 27 +- .../CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md | 8 +- ...ATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md | 31 + ...D_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md | 2 +- ...R_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md | 20 + ...R_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md | 30 + ...R_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md | 30 + ...R_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md | 30 + ...R_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md | 28 + ...R_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md | 22 + ...RST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md | 10 + ...TEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md | 11 +- .../BUSINESS_PROMPT_INVENTORY_2026-04-19.md | 6 +- .../CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md | 36 + ...ON_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md | 41 +- .../EDITOR_ASSET_API_MIGRATION_2026-04-08.md | 5 - .../EDITOR_ENTRY_CLEANUP_2026-04-21.md | 85 ++ .../NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md | 15 +- .../PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md | 5 - ...MOTION_REPRODUCTION_WORKFLOW_2026-04-07.md | 2 +- docs/technical/README.md | 1 + package.json | 1 - scripts/generate-build-tag-similarity.py | 357 ----- server-node/src/app.ts | 13 - server-node/src/context.ts | 2 - .../src/modules/assets/qwenSpriteRoutes.ts | 907 ------------- server-node/src/routes/runtimeRoutes.ts | 138 -- server-node/src/server.ts | 2 - .../services/customWorldGenerationService.ts | 33 - .../src/services/customWorldSessionStore.ts | 229 ---- src/components/GameShell.tsx | 807 ------------ src/components/auth/AuthGate.test.tsx | 10 +- src/components/auth/AuthGate.tsx | 10 +- ...ustomWorldAgentClarificationPanel.test.tsx | 98 -- .../CustomWorldAgentClarificationPanel.tsx | 64 - ...AgentDraftDetailPanel.interaction.test.tsx | 115 -- .../CustomWorldAgentDraftDetailPanel.test.tsx | 81 -- .../CustomWorldAgentDraftDetailPanel.tsx | 221 ---- .../CustomWorldAgentDraftDrawer.tsx | 117 -- ...ustomWorldAgentIntentSummaryPanel.test.tsx | 40 - .../CustomWorldAgentIntentSummaryPanel.tsx | 99 -- .../CustomWorldAgentLauncherModal.tsx | 90 -- .../CustomWorldAgentLockBar.tsx | 53 - .../CustomWorldAgentQuickActions.tsx | 132 -- .../CustomWorldAgentSummaryPanel.tsx | 58 - .../CustomWorldDraftCardDetailModal.tsx | 67 - .../CustomWorldDraftEditPanel.test.tsx | 102 -- .../CustomWorldDraftEditPanel.tsx | 141 -- .../CustomWorldGenerateEntityModal.tsx | 139 -- .../CustomWorldCreationHub.tsx | 45 +- .../CustomWorldCreationLauncherModal.tsx | 146 -- .../CustomWorldCreationStartCard.tsx | 43 +- .../custom-world-home/CustomWorldWorkCard.tsx | 44 +- .../custom-world-home/CustomWorldWorkTabs.tsx | 8 +- .../game-shell/PlatformHomeView.tsx | 171 ++- .../game-shell/PreGameSelectionFlow.tsx | 198 +-- src/data/buildTagSimilarity.generated.ts | 822 ------------ src/data/customWorldCharacterLoadout.stub.ts | 9 - src/editor/shared/EditorEmptyState.tsx | 7 - src/editor/shared/EditorSelectionCard.tsx | 48 - src/editor/shared/cloneValue.ts | 7 - src/editor/shared/editorApiClient.ts | 4 - src/editor/shared/useJsonSave.ts | 48 - src/hooks/story/storyBootstrap.ts | 249 ---- src/hooks/useEquipmentFlow.ts | 133 -- src/hooks/useForgeFlow.ts | 158 --- src/hooks/useInventoryFlow.ts | 99 -- src/index.css | 29 + src/prompts/customWorldOrchestratorPrompts.ts | 8 - src/prompts/qwenSpriteSheetToolPrompts.ts | 629 --------- src/prompts/storyOrchestratorPrompts.ts | 5 - src/routing/appRoutes.test.ts | 8 +- src/routing/appRoutes.tsx | 40 +- src/services/aiService.ts | 233 +--- src/services/apiClient.ts | 42 - src/services/authService.test.ts | 37 +- src/services/authService.ts | 8 +- src/services/customWorldPresentation.stub.ts | 54 - src/services/typewriter.ts | 6 - src/tools/QwenSpriteSheetTool.tsx | 1169 ----------------- src/tools/qwenSpriteSheetToolModel.test.ts | 147 --- src/tools/qwenSpriteSheetToolModel.ts | 1 - src/tools/qwenSpriteSheetToolPersistence.ts | 113 -- tsconfig.typecheck-guardrails.json | 1 - vitest.config.ts | 9 - 93 files changed, 1794 insertions(+), 8651 deletions(-) create mode 100644 docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md create mode 100644 docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md create mode 100644 docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md create mode 100644 docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md create mode 100644 docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md create mode 100644 docs/technical/CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md create mode 100644 docs/technical/EDITOR_ENTRY_CLEANUP_2026-04-21.md delete mode 100644 scripts/generate-build-tag-similarity.py delete mode 100644 server-node/src/modules/assets/qwenSpriteRoutes.ts delete mode 100644 server-node/src/services/customWorldGenerationService.ts delete mode 100644 server-node/src/services/customWorldSessionStore.ts delete mode 100644 src/components/GameShell.tsx delete mode 100644 src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx delete mode 100644 src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx delete mode 100644 src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.interaction.test.tsx delete mode 100644 src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.test.tsx delete mode 100644 src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx delete mode 100644 src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx delete mode 100644 src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.test.tsx delete mode 100644 src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.tsx delete mode 100644 src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx delete mode 100644 src/components/custom-world-agent/CustomWorldAgentLockBar.tsx delete mode 100644 src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx delete mode 100644 src/components/custom-world-agent/CustomWorldAgentSummaryPanel.tsx delete mode 100644 src/components/custom-world-agent/CustomWorldDraftCardDetailModal.tsx delete mode 100644 src/components/custom-world-agent/CustomWorldDraftEditPanel.test.tsx delete mode 100644 src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx delete mode 100644 src/components/custom-world-agent/CustomWorldGenerateEntityModal.tsx delete mode 100644 src/components/custom-world-home/CustomWorldCreationLauncherModal.tsx delete mode 100644 src/data/buildTagSimilarity.generated.ts delete mode 100644 src/data/customWorldCharacterLoadout.stub.ts delete mode 100644 src/editor/shared/EditorEmptyState.tsx delete mode 100644 src/editor/shared/EditorSelectionCard.tsx delete mode 100644 src/editor/shared/cloneValue.ts delete mode 100644 src/editor/shared/useJsonSave.ts delete mode 100644 src/hooks/story/storyBootstrap.ts delete mode 100644 src/hooks/useEquipmentFlow.ts delete mode 100644 src/hooks/useForgeFlow.ts delete mode 100644 src/hooks/useInventoryFlow.ts delete mode 100644 src/prompts/customWorldOrchestratorPrompts.ts delete mode 100644 src/prompts/qwenSpriteSheetToolPrompts.ts delete mode 100644 src/prompts/storyOrchestratorPrompts.ts delete mode 100644 src/services/customWorldPresentation.stub.ts delete mode 100644 src/services/typewriter.ts delete mode 100644 src/tools/QwenSpriteSheetTool.tsx delete mode 100644 src/tools/qwenSpriteSheetToolModel.test.ts delete mode 100644 src/tools/qwenSpriteSheetToolModel.ts delete mode 100644 src/tools/qwenSpriteSheetToolPersistence.ts diff --git a/AGENTS.md b/AGENTS.md index cf5aef51..994679a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,7 @@ docs/ │ ├─ AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md │ ├─ BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md │ └─ RUNTIME_ITEM_GENERATION_CURRENT_SYSTEM_DESIGN.md -├─ reference/[QwenSpriteSheetTool.tsx](src/tools/QwenSpriteSheetTool.tsx) +├─ reference/ │ ├─ README.md │ └─ FUNCTION_SCRIPT_CATALOG_2026-04-04.md └─ technical/ diff --git a/README.md b/README.md index 98cd591f..c9b219e4 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - NPC 交易、送礼、求助、招募 - 宝藏交互 - 同伴跟随与战斗 -- 预设编辑器 / NPC 视觉编辑器 / 行为编辑器 +- 游戏主流程内嵌的角色资产工坊、自定义世界实体编辑与角色形象编辑 - 自动存档与继续游戏 ## 运行 @@ -99,11 +99,11 @@ npm run check:content - [src/hooks/useCombatFlow.ts](/E:/Repos/Genarrative/src/hooks/useCombatFlow.ts) - [src/hooks/useStoryGeneration.ts](/E:/Repos/Genarrative/src/hooks/useStoryGeneration.ts) -编辑器: +主流程内嵌编辑能力: -- [src/components/PresetEditor.tsx](/E:/Repos/Genarrative/src/components/PresetEditor.tsx) -- [src/components/NpcVisualEditor.tsx](/E:/Repos/Genarrative/src/components/NpcVisualEditor.tsx) -- [src/components/StateFunctionEditor.tsx](/E:/Repos/Genarrative/src/components/StateFunctionEditor.tsx) +- [src/components/CustomWorldEntityEditorModal.tsx](/E:/Repos/Genarrative/src/components/CustomWorldEntityEditorModal.tsx) +- [src/components/CustomWorldNpcVisualEditor.tsx](/E:/Repos/Genarrative/src/components/CustomWorldNpcVisualEditor.tsx) +- [src/components/CustomWorldRoleAssetStudioModal.tsx](/E:/Repos/Genarrative/src/components/CustomWorldRoleAssetStudioModal.tsx) 核心数据: diff --git a/docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md b/docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md index d6aa8108..303fdc11 100644 --- a/docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md +++ b/docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md @@ -251,8 +251,8 @@ custom-world-library | `anchorContent / creatorIntent / anchorPack / lockState` 被直接塞进 legacy profile | 创作态元数据 | 会跟随自动保存一起写进作品库 profile,但 runtime 并不以这些字段为正式运行时输入 | 当前更像创作态元数据泄漏进运行时 profile | | `qualityFindings` | session / contract 字段 | contract、session store、测试里存在,但没形成生成、渲染、发布阻断闭环 | 当前主链悬空 | | `checkpoints` | session 字段 | session store 会记录,但主工作区和结果页没有真实展示入口 | 当前主链悬空 | -| `suggestedActions` | session 字段 | session 会生成,但主工作区没有接 `QuickActions` 等面板 | 当前主链悬空 | -| `pendingClarifications` | session 字段 | session 有数据,但澄清面板未接入主工作区 | 当前主链悬空 | +| `suggestedActions` | session 字段 | session 会生成,但当前正式工作区没有对应消费面;旧 `QuickActions` 面板已在 `2026-04-21` 判定退出当前版本主链并物理删除 | 当前主链悬空 | +| `pendingClarifications` | session 字段 | session 有数据,但当前正式工作区没有对应消费面;旧澄清面板已在 `2026-04-21` 判定退出当前版本主链并物理删除 | 当前主链悬空 | | `operations` 历史 | session 字段 | 主工作区只展示当前 `activeOperation` 横幅,不展示完整历史 | 当前主链弱消费 | | `roleAssetSummaryLabel / cover* / counts` 等 works 字段 | works 聚合字段 | 后端能返回,但主平台 create tab 没走 `works` 入口 | 当前主链弱消费 | @@ -266,11 +266,11 @@ custom-world-library | --- | --- | --- | | 结果页直接生成 playable/story/landmark | `CustomWorldResultView.tsx` 仍可直接调用 AI 生成 | 与 Agent 对象精修链重复,且不会同步回 session | | 结果页直接编辑 `CustomWorldProfile` | `CustomWorldEntityEditorModal` 仍挂在结果页 | 把结果页继续维持成旧编辑器,而不是 Agent 流程的收口层 | -| 旧 `custom-world/sessions` 世界生成 | `aiService.generateCustomWorldProfile()` 仍完整可用 | 与 Agent 八锚点世界创建重复 | +| 旧 `custom-world/sessions` 世界生成 | `2026-04-20` 审计时仍完整可用;`2026-04-21` 已完成物理删除 | 与 Agent 八锚点世界创建重复;当前遗留问题已转为文档口径清理 | | 作品库 `publish/unpublish` 与 Agent `publish_world` | 两套“发布”概念并行 | 一套作用于 library profile,一套想作用于 Agent session,但后者还未打通 | | 结果页自动保存 | `generatedCustomWorldProfile` 变化时自动 `upsertCustomWorldProfile()` | 让“草稿保存”“作品库存档”“正式发布”语义混在一起 | -## 7.2 冗余或未接线组件 +## 7.2 冗余或已退出当前版本主链的组件 `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` 当前只真正接了: @@ -280,7 +280,7 @@ custom-world-library 4. `CustomWorldAgentThread` 5. `CustomWorldAgentComposer` -但同目录下已经存在且主工作区未接线的组件包括: +在 `2026-04-21` 清理前,同目录下还存在一组未接线旧组件: 1. `CustomWorldAgentLockBar.tsx` 2. `CustomWorldAgentDraftDrawer.tsx` @@ -291,6 +291,25 @@ custom-world-library 7. `CustomWorldAgentClarificationPanel.tsx` 8. `CustomWorldGenerateEntityModal.tsx` +其中: + +1. `CustomWorldAgentDraftDrawer.tsx` 已在批次 A 清理中删除 +2. `CustomWorldAgentLockBar.tsx` +3. `CustomWorldAgentDraftDetailPanel.tsx` +4. `CustomWorldAgentQuickActions.tsx` +5. `CustomWorldAgentSummaryPanel.tsx` +6. `CustomWorldAgentIntentSummaryPanel.tsx` +7. `CustomWorldAgentClarificationPanel.tsx` +8. `CustomWorldGenerateEntityModal.tsx` + +已在批次 D 清理中判定为退出当前版本主链,并完成物理删除。 + +因此这里的审计结论需要更新为: + +1. 它们不再属于“待接线组件” +2. 它们属于已确认退场的旧副面板链 +3. 当前版本如果还要补 `suggestedActions / pendingClarifications / draftCards` 的消费面,应基于新的主链设计重新定义,而不是默认把旧面板接回来 + 另外,`src/components/custom-world-home/CustomWorldCreationHub.tsx` 也已存在,但平台 `create` tab 还没有把它接成主入口。 --- @@ -345,16 +364,16 @@ Agent session 2. 只有 `publish_world` 成功后,才产出正式 `CustomWorldProfile` 并允许主入口进入世界。 3. `qualityFindings / blocker` 必须在 foundation draft 完成、资产写回后、publish 前持续重跑。 -## P1:决定旧 world session 流程的命运 +## P1:继续做旧 world session 链的文档收口 -当前最容易继续制造重复复杂度的是旧 `custom-world/sessions` 链。 +`2026-04-21` 更新: -建议二选一: +旧 `custom-world/sessions` 链已经完成物理删除。 -1. 明确保留为“快速世界生成兼容模式”,但从主入口降级。 -2. 明确进入淘汰路径,逐步下线 `generateCustomWorldProfile()` 这条旧链。 +因此这里不再是“保留还是淘汰”的开放问题,而是: -不建议继续让它和 Agent 八锚点链同时作为主入口长期并存。 +1. 继续清理由这条旧链残留在审计、PRD、知识图谱中的过时口径 +2. 把当前正式主链与仍保留的兼容层边界写清楚 ## P1:把 works 创作中心接回主平台 @@ -365,16 +384,23 @@ Agent session 3. 已发布 profile 通过“进入世界”或“查看详情”进入。 4. `myEntries` 退回为作品库子集,而不是 create tab 的唯一数据源。 -## P1:补齐 Agent workspace 的最小闭环 +## P1:为悬空 session 字段重新定义最小闭环 -建议优先接上: +`2026-04-21` 更新: -1. `CustomWorldAgentQuickActions` -2. `CustomWorldAgentDraftDrawer` -3. `CustomWorldAgentDraftDetailPanel` -4. `CustomWorldAgentClarificationPanel` +原文这里建议把旧 `QuickActions / DraftDrawer / DraftDetailPanel / ClarificationPanel` 接回主工作区。 -如果这几个面板不接上,`suggestedActions / pendingClarifications / draftCards` 这些 session 字段会长期处于悬空状态。 +但这些旧副面板已经在当前版本收口判断中被明确认定为: + +1. 不属于现行主链 +2. 不再作为当前版本默认待落地项 +3. 已完成物理删除 + +因此当前更准确的建议应该是: + +1. 如果 `suggestedActions / pendingClarifications / draftCards` 仍要进入正式主流程,需要先重新定义符合当前极简工作区的消费方式 +2. 不应再以“把旧副面板接回来”作为默认方案 +3. 在没有新主链设计前,这些字段继续标记为“主链悬空” ## P2:等主链收口后再清桥接字段 diff --git a/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md new file mode 100644 index 00000000..26b2b81a --- /dev/null +++ b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md @@ -0,0 +1,141 @@ +# 工程死分支清理执行记录 A(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 本批次目标 + +这份记录对应: + +- `docs/planning/ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md` +- 其中的 `P0 + 批次 A` + +本批次只做一件事: + +**先清理高置信度、低耦合、无正式入口的小型孤岛与残留壳子。** + +这批对象有一个共同特征: + +1. 当前没有正式运行时引用 +2. 没有当前主链计划要接回 +3. 删除后有明确替代路径,或者本身只是历史占位 + +因此这批次不碰运行时真相链、不碰鉴权链、不碰任务物品主链,只先做低风险去噪。 + +--- + +## 1. 本批次已处理对象 + +## 1.1 已删除文件 + +| 文件 | 判定 | 删除原因 | 替代路径 / 当前真相源 | 验证口径 | +| --- | --- | --- | --- | --- | +| `src/services/customWorldPresentation.stub.ts` | 无引用占位 stub | 文件本身就是占位实现,且正式逻辑已由 `customWorldPresentation.ts` 承接 | `src/services/customWorldPresentation.ts` | 符号级检索确认正式调用方都指向正式实现 | +| `src/services/typewriter.ts` | 无引用 helper 残留 | 独立 helper 已失效,正式链路已在 `storyPresentation.ts` / `storyRenderingHelpers.ts` 等处内联或迁移 | `src/hooks/story/storyPresentation.ts`、`src/hooks/story/storyRenderingHelpers.ts` | `getTypewriterDelay` 调用点未指向该文件 | +| `src/prompts/customWorldOrchestratorPrompts.ts` | 前端孤岛 prompt 壳 | 当前无正式 import,正式主编排 prompt 已收口到后端 prompt 目录,前端 `ai.ts` 也保留自己的现行实现 | `server-node/src/prompts/customWorldOrchestratorPrompts.ts`、`src/services/ai.ts` | 全仓检索仅剩文档引用,无代码消费 | +| `src/prompts/storyOrchestratorPrompts.ts` | 前端孤岛 prompt 壳 | 当前无正式 import,剧情语言修复 prompt 已由后端 prompt 目录承接,前端当前执行路径不依赖该文件 | `server-node/src/prompts/storyOrchestratorPrompts.ts`、`src/services/ai.ts` | 全仓检索仅剩文档引用,无代码消费 | +| `src/components/custom-world-home/CustomWorldCreationLauncherModal.tsx` | 无入口 UI 壳层 | 最近两轮工程审计都确认无运行时引用,当前平台主流程未接这条入口 | 当前平台正式入口链 | 文件级检索确认无组件 import | +| `src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx` | 无入口 UI 壳层 | Agent 创作主流程已切到当前工作区链路,这个旧 modal 没有接线价值 | 当前 Agent 工作区主链 | 文件级检索确认无组件 import | +| `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx` | 无入口 UI 壳层 | 只有孤立 UI 实现,没有正式调用链,也不在当前结果页 / 工作区主链中 | 当前 Agent 工作区与结果页正式链 | 文件级检索确认无组件 import | + +--- + +## 2. 本批次为什么先删这 7 个 + +这批文件适合先处理,不是因为它们最大,而是因为它们最清晰: + +1. **没有正式入口。** + 本轮检索没有发现主工程 import。 +2. **删除后不会形成职责空洞。** + 要么已有正式替代路径,要么本身只是历史占位。 +3. **不会误伤当前重点链路。** + 这批不涉及运行时快照、鉴权、任务、物品、AI 正式编排主链。 +4. **可以最快降低目录噪音。** + 先把真假并存的壳子删掉,后面做批次 B/C/D 时判断成本会更低。 + +--- + +## 3. 本批次暂不处理对象 + +以下对象虽然已进入首轮台账,但本批次暂不删除: + +1. `src/components/GameShell.tsx` +2. `src/components/custom-world-home/CustomWorldCreationHub.tsx` +3. `src/hooks/story/storyBootstrap.ts` +4. `src/hooks/useEquipmentFlow.ts` +5. `src/hooks/useForgeFlow.ts` +6. `src/hooks/useInventoryFlow.ts` +7. `src/data/buildTagSimilarity.generated.ts` + +暂缓原因分别是: + +1. 仍属于旧主流程 / 旧 flow 级别对象,删除前要先核对更多历史依赖和替代路径 +2. 部分对象仍有测试引用或更大的上下文耦合 +3. `buildTagSimilarity.generated.ts` 虽无正式业务 import,但属于生成产物,处理前还要确认脚本链与文档链 + +这批对象更适合进入: + +1. `批次 B:旧 flow / 旧 shell / 旧 hook` +2. 或独立的数据产物复核批次 + +--- + +## 4. 本批次同步更新的文档 + +本批次除了删文件,还同步做了文档回填: + +1. 新增本执行记录,说明本批删了什么、为什么删、哪些对象暂缓 +2. 更新 `docs/audits/engineering/README.md`,把这份执行记录加入当前审计入口 + +这样做的目的,是避免再次出现: + +1. 代码删了 +2. 但审计入口还是旧状态 +3. 后续开发又从旧清单里重复判断一遍 + +--- + +## 5. 验证方式 + +本批次验证采用两层口径: + +## 5.1 删除前验证 + +1. 文件级检索确认无正式 import +2. 符号级检索确认关键导出没有被主链消费 +3. 结合 `2026-04-20` 工程审计交叉确认这些对象已被标记为高置信度孤岛 + +## 5.2 删除后验证 + +建议至少执行: + +1. `npm run check:encoding` +2. `npm run build` + +说明: + +- 当前仓库已知 `typecheck` 与 `lint` 仍处于红线阶段,因此本批不把它们作为“由本批引入的新失败”判断口径 +- 本批主要验证目标是:删除小残留后,不产生新的导入断裂和构建断裂 + +--- + +## 6. 本批次结果判断 + +本批次完成后,工程至少获得了 3 个直接收益: + +1. `src/prompts/`、`src/services/`、`src/components/custom-world-*` 中少了一批无入口孤岛 +2. 当前目录里“看起来像正式入口,其实已经废弃”的误导性对象减少 +3. 后续可以把精力集中到真正高价值的批次 B/C/D,而不是继续被小残留分散判断成本 + +--- + +## 7. 下一批建议 + +建议严格按计划继续往下推进: + +1. 批次 B:`GameShell`、`storyBootstrap`、`useEquipmentFlow`、`useForgeFlow`、`useInventoryFlow` +2. 批次 C:`runtimeStoryCoordinator`、`runtimeStoryService`、`apiClient` +3. 批次 D:`npcEncounterActions`、`questDirector`、`runtimeItemAiDirector`、`ai.ts` + +一句话总结本批次: + +**先把最确定的死分支和占位壳子清掉,让主工程少一些假入口、假主源、假能力,再进入更重的主链收口。** diff --git a/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md new file mode 100644 index 00000000..f4bb8093 --- /dev/null +++ b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md @@ -0,0 +1,145 @@ +# 工程死分支清理执行记录 B(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 本批次目标 + +这份记录对应清洗计划中的: + +- `批次 B:旧 flow / 旧 shell / 旧 hook` + +本批次聚焦的不是小型 stub,而是: + +**已经退出正式主流程、但仍占着高辨识度命名和旧职责心智的壳层与流程 Hook。** + +这类文件如果继续留在仓库里,问题比小 helper 更大,因为它们会持续制造误判: + +1. 新人会以为它们还是正式入口 +2. 后续开发会误判“应该往这里接逻辑” +3. review 时会多出一层“旧主链是不是还活着”的判断成本 + +--- + +## 1. 本批次已处理对象 + +## 1.1 已删除文件 + +| 文件 | 判定 | 删除原因 | 替代路径 / 当前真相源 | 验证口径 | +| --- | --- | --- | --- | --- | +| `src/components/GameShell.tsx` | 旧主流程壳层残留 | 当前正式壳层已由 `src/components/game-shell/GameShellRuntime.tsx` 承接,旧文件无正式 import | `src/components/game-shell/GameShellRuntime.tsx`、`src/hooks/useGameShellRuntime.ts` | 全仓检索未发现对旧 `GameShell` 组件的正式消费 | +| `src/hooks/story/storyBootstrap.ts` | 旧启动流程 Hook 残留 | 当前主剧情启动链已不再调用该 Hook,继续保留只会误导人以为它还是故事初始化入口 | 当前 story runtime / coordinator 链 | 全仓检索未发现 `useStoryBootstrap` 消费方 | +| `src/hooks/useEquipmentFlow.ts` | 旧装备流程 Hook 残留 | 当前正式背包与装备链未消费该 Hook,属于旧流程实现残留 | 当前 inventory / runtime 正式链 | 符号级检索仅命中定义文件自身 | +| `src/hooks/useForgeFlow.ts` | 旧锻造流程 Hook 残留 | 当前正式锻造入口未通过该 Hook 进入主链,保留会制造旧流程错觉 | 当前 inventory / runtime 正式链 | 符号级检索仅命中定义文件自身 | +| `src/hooks/useInventoryFlow.ts` | 旧背包使用流程 Hook 残留 | 当前主流程未消费该 Hook,属于旧状态推进实现残留 | 当前 inventory / runtime 正式链 | 符号级检索仅命中定义文件自身 | + +--- + +## 2. 为什么这批要紧跟批次 A 处理 + +批次 A 清掉的是“小型假入口”。 + +批次 B 清掉的是“高辨识度旧主链”。 + +这批必须紧跟着做,原因是: + +1. 它们虽然比 stub 更大,但引用关系同样清楚 +2. 它们的误导性比小残留更强 +3. 不先处理这批,后面做批次 C/D 时,很容易继续有人拿旧 flow Hook 当候选接线点 + +一句话讲: + +**批次 A 是去噪,批次 B 是拔掉旧路牌。** + +--- + +## 3. 本批次删除后的结构变化 + +本批次完成后,仓库里的流程心智会更清楚: + +1. 游戏壳层正式入口继续收敛到 `src/components/game-shell/**` +2. 旧 `GameShell.tsx` 不再和 `GameShellRuntime.tsx` 并存 +3. 旧的装备 / 锻造 / 背包单独 flow Hook 不再伪装成还在生效的正式实现 +4. 旧 `storyBootstrap` 不再和当前 story runtime 链并存 + +这会直接减少两类误判: + +1. “是不是还有旧主流程没迁完” +2. “我是不是应该把新逻辑继续补进这些旧 Hook” + +--- + +## 4. 本批次暂不处理对象 + +虽然批次 B 已经处理了旧 shell / old flow / old bootstrap,但以下对象仍暂缓: + +1. `src/components/custom-world-home/CustomWorldCreationHub.tsx` +2. `src/data/buildTagSimilarity.generated.ts` +3. 批次 C 的运行时真相链: + - `src/hooks/story/runtimeStoryCoordinator.ts` + - `src/services/runtimeStoryService.ts` + - `src/services/apiClient.ts` +4. 批次 D 的混合执行层: + - `src/hooks/story/npcEncounterActions.ts` + - `src/services/questDirector.ts` + - `src/services/runtimeItemAiDirector.ts` + - `src/services/ai.ts` + +暂缓原因很明确: + +1. 这些对象要么仍在当前正式链上 +2. 要么涉及运行时真相与鉴权边界 +3. 不能按“无引用旧壳”同一口径直接删除 + +--- + +## 5. 本批次验证方式 + +## 5.1 删除前验证 + +1. 全仓检索 `GameShell` 旧组件消费方,确认当前正式壳层已切到 `game-shell/` 目录 +2. 全仓检索 `useStoryBootstrap` +3. 全仓检索旧装备 / 锻造 / 背包 flow Hook 导出的 handler 名称 +4. 交叉确认当前正式主链入口已存在替代实现 + +## 5.2 删除后验证 + +建议至少执行: + +1. `npm run check:encoding` +2. `npm run build` + +如果这两项通过,说明: + +1. 删除没有引入新的导入断裂 +2. 主工程构建链仍然成立 + +--- + +## 6. 本批次结果判断 + +本批次完成后,工程获得的直接收益是: + +1. 旧主流程壳层不再和现行壳层并存 +2. 旧流程 Hook 不再占据 `src/hooks/` 的主路径注意力 +3. 当前正式入口和历史残留的边界更清楚 +4. 后续开发更不容易把新逻辑接回旧流程壳子 + +--- + +## 7. 下一批建议 + +建议下一步进入真正有结构价值的收口: + +1. `批次 C:运行时真相收口` + - `runtimeStoryCoordinator` + - `runtimeStoryService` + - `apiClient` +2. `批次 D:任务 / 物品 / AI 混合执行层收口` + - `npcEncounterActions` + - `questDirector` + - `runtimeItemAiDirector` + - `ai.ts` + +一句话总结本批次: + +**这一步不是在“删几个没用 Hook”,而是在把已经退场的旧主流程壳层和旧 flow 路牌从主工程里真正拔掉,让现行架构不再和历史壳子并排站着。** diff --git a/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md new file mode 100644 index 00000000..9e9f7a80 --- /dev/null +++ b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md @@ -0,0 +1,177 @@ +# 工程死分支清理执行记录 C(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 本批次目标 + +这份记录对应清洗计划中的: + +- `批次 C:运行时真相收口` + +但这次不是“一口气把运行时真相链全删干净”,而是先做其中最明确、风险最低、最不该继续拖的那一段: + +1. **收掉前端本地自动登录用户名 / 密码真相** +2. **把登录恢复改成优先依赖服务端 session / refresh** + +同时,这一批也明确记录了一件事: + +**运行时快照前置写入链当前还不能直接砍。** + +原因不是“不想动”,而是服务端当前 `runtime story` 动作入口仍然以远端快照作为执行基线。 +在后端 contract 没先改好之前,前端不能假装自己已经退出这条链。 + +--- + +## 1. 本批次已处理对象 + +## 1.1 已收口的鉴权链 + +| 文件 | 处理动作 | 本批结论 | +| --- | --- | --- | +| `src/services/apiClient.ts` | 删除本地自动登录用户名 / 密码存取逻辑 | 前端不再保存 auto auth 账号密码 | +| `src/services/authService.ts` | 去掉对本地游客凭证的读写依赖 | 自动游客登录改为仅本次生成凭证,不再长期落本地 | +| `src/components/auth/AuthGate.tsx` | 去掉“必须先有本地 access token 才尝试恢复”的前置假设 | 登录恢复改为优先尝试服务端 `getCurrentAuthUser()` / refresh session | +| `src/services/authService.test.ts` | 改写游客自动登录相关断言 | 验证改为“生成临时凭证并完成登录”,而不是“落本地账号密码” | +| `src/components/auth/AuthGate.test.tsx` | 改写登录恢复 mock | 验证改为“先尝试服务端会话恢复,再决定是否走游客兜底” | + +--- + +## 2. 本批次为什么先做这段 + +这批优先级高,是因为它同时满足 4 条: + +1. **风险明确。** + 浏览器保存自动登录用户名 / 密码,本身就不符合“前端只做表现、后端负责鉴权真相”的方向。 +2. **替代路径已经存在。** + 后端已经有 refresh session cookie 与 `getCurrentAuthUser()`,不是没有可替代能力。 +3. **改动边界清楚。** + 这一段主要落在前端鉴权恢复逻辑和测试,不会直接波及运行时战斗、任务、物品、剧情主链。 +4. **收益直接。** + 一旦收掉,前端就少了一份最不该长期保留的高风险真相。 + +一句话讲: + +**这一步先把“浏览器记住游客账号密码再重登”这条假真相链拔掉。** + +--- + +## 3. 本批次明确没做的事 + +## 3.1 没有直接删除 `runtimeStoryCoordinator.ts` 里的前置 `putSaveSnapshot(...)` + +这不是漏做,而是明确暂缓。 + +当前复核结果是: + +1. `server-node/src/modules/story/storyActionService.ts` +2. `server-node/src/routes/runtimeRoutes.ts` +3. `server-node/src/repositories/runtimeRepository.ts` + +这条后端链当前仍然通过远端快照读取运行时状态,再执行: + +1. `getRuntimeStoryState` +2. `resolveRuntimeStoryAction` + +也就是说,当前真实情况不是“前端多写了一份完全没用的镜像”,而是: + +**前端在提交动作前先把当前状态写回远端快照,后端再基于这份快照执行业务动作。** + +在这个 contract 没先升级为“前端只发 action,后端自己持有完整 session 真相”之前,前端不能直接把这一步砍掉。 + +否则会出现: + +1. 动作请求仍在走 +2. 但服务端读取到的执行基线不完整 +3. 最后不是收口真相,而是把主链打断 + +## 3.2 没有删除 `runtimeStoryService.ts` / `runtimeStoryCoordinator.ts` 的快照再水合逻辑 + +这一步本轮也做了复核,结论是: + +1. 我曾尝试把 `runtimeStoryCoordinator.ts` 中对服务端返回快照的重复再水合去掉 +2. 但对应的 `runtimeStoryCoordinator` 测试立即暴露出:当前后端返回的快照在部分战斗场景下还不是完整水合态 +3. 说明前端当前这层再水合仍然有现实职责,不是纯多余代码 + +所以这一步本批明确结论是: + +**暂不删除,等后端快照 contract 先补完整后再做。** + +--- + +## 4. 本批次验证结果 + +本批次已完成的定向验证: + +1. `npx vitest run src/services/authService.test.ts` +2. `npx vitest run src/components/auth/AuthGate.test.tsx` +3. `npx vitest run src/hooks/story/runtimeStoryCoordinator.test.ts` +4. `npm run check:encoding` + +结果: + +1. `authService` 测试通过 +2. `AuthGate` 测试通过 +3. `runtimeStoryCoordinator` 测试通过 +4. 编码检查通过 + +另外执行了: + +1. `npm run build` + +结果: + +构建产物生成成功,但 `build-gate` 仍因主包 chunk warning 拦截失败。 +当前失败点仍是已知的主包体积问题: + +- `AuthenticatedApp-*.js` 超过当前 warning 门槛 + +这属于仓库当前既有工程问题,不是本批次引入的新断裂。 + +--- + +## 5. 本批次完成后的实际收益 + +这一步完成后,工程在鉴权边界上有了两个明确改善: + +1. **前端不再保存自动登录用户名 / 密码。** + 浏览器只保留 access token,本地高风险游客凭证真相已经收掉。 +2. **登录恢复逻辑更接近服务端为真相源。** + `AuthGate` 不再假设“没有本地 token 就一定还没登录”,而是优先尝试服务端会话恢复。 + +这意味着前端鉴权链已经从: + +```text +本地用户名/密码 -> 再次 entry -> 拿 token +``` + +进一步收到了: + +```text +refresh session / 当前会话 -> 恢复用户 +兜底时才创建一次游客凭证 +``` + +--- + +## 6. 本批次后续建议 + +要继续完成批次 C,下一步不该直接在前端硬删,而应该先补后端 contract: + +1. 让 `runtime story` 动作链逐步摆脱“前端先写远端快照”的依赖 +2. 让服务端自己持有更完整的运行时 session 真相 +3. 等后端返回快照已经稳定水合后,再删前端的重复再水合 + +换句话说,批次 C 的后半段应该拆成: + +1. **C-1:鉴权真相收口** + 本批已完成 +2. **C-2:运行时快照 contract 后端化** + 需要先改后端 +3. **C-3:前端镜像写入与重复水合退场** + 依赖 C-2 + +--- + +## 7. 一句话总结 + +**批次 C 这一轮已经先把“浏览器长期保存游客账号密码”这条最不该存在的鉴权假真相链收掉了;而运行时快照前置写入这条链经过复核确认仍受后端 contract 约束,不能在服务端未先补齐前硬砍。** diff --git a/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md new file mode 100644 index 00000000..89315af5 --- /dev/null +++ b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md @@ -0,0 +1,56 @@ +# 工程死分支清理执行记录 D(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 本批次目标 + +本批次继续清理上一轮复核后剩余的低风险数据产物与测试占位: + +1. 未接入业务的生成产物 +2. 只为测试替换真实实现的空 stub +3. 支撑这些残留的配置与脚本 + +--- + +## 1. 已删除对象 + +| 文件 | 判定 | 删除原因 | 替代路径 / 当前真相源 | +| --- | --- | --- | --- | +| `src/data/buildTagSimilarity.generated.ts` | 未接入业务的生成产物 | 运行时代码不 import;Build 相似度当前由 `buildTags.ts` 中的属性亲和度逻辑计算 | `src/data/buildTags.ts` | +| `scripts/generate-build-tag-similarity.py` | 已无输出目标的生成脚本 | 只负责生成已删除的矩阵文件,继续保留会误导后续开发恢复旧主源 | `src/data/buildTags.ts` 的手工审表逻辑 | +| `src/data/customWorldCharacterLoadout.stub.ts` | 测试专用空 stub | 只通过 `vitest.config.ts` alias 替换真实实现;真实实现已经稳定存在 | `src/data/customWorldCharacterLoadout.ts` | + +--- + +## 2. 同步更新 + +本批次同步移除了: + +1. `vitest.config.ts` 中指向 `customWorldCharacterLoadout.stub.ts` 的 alias +2. `BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md` 中把旧 generated 矩阵描述为当前文件的表述 +3. 清理计划里对 `buildTagSimilarity.generated.ts` 的未处理状态说明 + +--- + +## 3. 验证口径 + +删除前已确认: + +1. `buildTagSimilarity.generated.ts` 无运行时代码引用 +2. `customWorldCharacterLoadout.stub.ts` 只被 `vitest.config.ts` alias 引用 +3. 真实 `customWorldCharacterLoadout.ts` 仍被 `characterPresets.ts` 与 `npcInteractions.ts` 使用,不能删除 + +删除后建议验证: + +1. `npm run check:encoding` +2. 与自定义世界开局物品相关的测试 + +--- + +## 4. 当前结论 + +本批次完成后,剩余清理对象已经不再适合按“无引用直接删”推进。后续如果继续清,需要先改 contract 或主链职责: + +1. 运行时快照真相链 +2. 任务 / 物品 / AI 混合执行层 +3. 大型主流程组件继续拆分,而不是直接删除 diff --git a/docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md b/docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md new file mode 100644 index 00000000..66b66ef5 --- /dev/null +++ b/docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md @@ -0,0 +1,503 @@ +# 前端应迁后端逻辑审计(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 审计目标 + +这份文档只回答一个问题: + +**当前前端代码里,哪些逻辑已经明显越过“前端只做表现,Express 后端负责逻辑、数据与存储”的边界,应该继续迁到后端。** + +本轮不改业务代码,只做: + +1. 基于当前仓库状态给出高置信度候选点 +2. 标明代码证据 +3. 给出迁移优先级 +4. 说明迁移后前端应该保留什么、移走什么 + +--- + +## 1. 结论先行 + +结合当前代码与已有边界文档,前端里仍有 6 类逻辑应该继续后移: + +1. **运行时快照前置写入与本地镜像解释** +2. **鉴权 token 的浏览器本地真相** +3. **平台浏览历史的本地真相与迁移状态** +4. **NPC 待接委托“换单”仍由前端直接触发正式生成** +5. **quest/runtime item 的双环境混合编排** +6. **浏览器侧大型 AI orchestration 与 prompt/repair/fallback 主链** + +一句话判断: + +**当前前端已经不是最早那种“大量主算”的状态,但仍然保留了运行时镜像、生成编排和部分正式真相。后端边界还需要再收一轮,前端才算真正退回表现层。** + +--- + +## 2. 审计依据 + +### 2.1 文档依据 + +1. `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md` +2. `docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md` +3. `docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md` +4. `docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md` +5. `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md` + +### 2.2 当前代码依据 + +1. `src/hooks/story/runtimeStoryCoordinator.ts` +2. `src/services/apiClient.ts` +3. `src/services/platformBrowseHistory.ts` +4. `src/components/game-shell/PreGameSelectionFlow.tsx` +5. `src/hooks/story/npcEncounterActions.ts` +6. `src/services/questDirector.ts` +7. `src/services/runtimeItemAiDirector.ts` +8. `src/services/ai.ts` + +--- + +## 3. 当前高置信度应后移逻辑 + +## 3.1 运行时快照前置写入仍在前端 + +### 代码证据 + +`src/hooks/story/runtimeStoryCoordinator.ts` 当前仍存在以下链路: + +1. `syncRuntimeSnapshot(...)` +2. `syncRuntimeSnapshot(...)` 内部直接调用 `putSaveSnapshot(...)` +3. `loadServerRuntimeOptionCatalog(...)` 在请求 `getRuntimeStoryState(...)` 之前先写本地快照 +4. `resolveServerRuntimeChoice(...)` 在请求 `resolveRuntimeStoryAction(...)` 之前先写本地快照 + +对应位置: + +1. `src/hooks/story/runtimeStoryCoordinator.ts:21` +2. `src/hooks/story/runtimeStoryCoordinator.ts:25` +3. `src/hooks/story/runtimeStoryCoordinator.ts:36` +4. `src/hooks/story/runtimeStoryCoordinator.ts:99` + +### 当前问题 + +这意味着运行时正式动作发起前,前端仍会先落一份自己的快照真相,再去请求后端。 + +这条链的问题不是“有没有缓存”,而是: + +1. 前端仍在承担正式提交前的状态镜像 +2. 快照解释权没有完全收回到后端 +3. 运行时主链仍处于“本地镜像 + 服务端会话”并存状态 + +### 迁移建议 + +后端继续承接: + +1. 运行时快照写入 +2. 快照版本解释 +3. 动作提交前的状态一致性校验 + +前端只保留: + +1. 当前展示用的 view model +2. 可选的只读恢复缓存 +3. 纯表现态的 loading / transition / animation state + +### 优先级 + +`P0` + +--- + +## 3.2 鉴权 token 仍由前端 localStorage 持有真相 + +### 代码证据 + +`src/services/apiClient.ts` 当前仍直接访问 `window.localStorage` 保存 access token: + +1. `getStoredAccessToken()` +2. `setStoredAccessToken(...)` +3. `clearStoredAccessToken(...)` +4. `withAuthorizationHeaders(...)` 直接从本地 token 组装请求头 + +对应位置: + +1. `src/services/apiClient.ts:333` +2. `src/services/apiClient.ts:341` +3. `src/services/apiClient.ts:362` +4. `src/services/apiClient.ts:382` + +### 当前问题 + +第三批清理已经收掉了“自动登录用户名/密码”本地真相,但 access token 仍然由浏览器长期持有。 + +这在当前项目边界下仍有两个问题: + +1. 正式鉴权真相仍没有完全收回后端 session 边界 +2. 前端 SDK 仍然负担 token 生命周期的关键部分 + +### 迁移建议 + +后端继续承接: + +1. session / refresh / cookie 真相 +2. 鉴权状态续期 +3. token 更新与失效策略 + +前端只保留: + +1. 当前是否已登录的展示态 +2. 统一的请求封装 +3. 401 后的 UI 响应 + +### 优先级 + +`P0` + +--- + +## 3.3 平台浏览历史仍是“前端本地历史 + 后端回填”的双真相 + +### 代码证据 + +`src/services/platformBrowseHistory.ts` 当前仍维护一整套本地历史真相: + +1. `readPlatformBrowseHistory(...)` +2. `writePlatformBrowseHistory(...)` +3. `hasPendingPlatformBrowseHistoryMigration(...)` +4. `markPlatformBrowseHistoryMigrated(...)` + +对应位置: + +1. `src/services/platformBrowseHistory.ts:77` +2. `src/services/platformBrowseHistory.ts:103` +3. `src/services/platformBrowseHistory.ts:151` +4. `src/services/platformBrowseHistory.ts:164` + +`src/components/game-shell/PreGameSelectionFlow.tsx` 当前仍显式做: + +1. 先 `writePlatformBrowseHistory(...)` +2. 再调用 `upsertProfileBrowseHistory(...)` +3. 同步成功后 `markPlatformBrowseHistoryMigrated(...)` +4. 启动阶段读取 `readPlatformBrowseHistory(...)` +5. 根据 `hasPendingPlatformBrowseHistoryMigration(...)` 决定是否补同步 + +对应位置: + +1. `src/components/game-shell/PreGameSelectionFlow.tsx:383` +2. `src/components/game-shell/PreGameSelectionFlow.tsx:392` +3. `src/components/game-shell/PreGameSelectionFlow.tsx:394` +4. `src/components/game-shell/PreGameSelectionFlow.tsx:433` +5. `src/components/game-shell/PreGameSelectionFlow.tsx:466` + +### 当前问题 + +这条链已经不是单纯缓存,而是: + +1. 本地历史存储 +2. 本地同步标记 +3. 后端历史持久化 + +三套状态同时存在。 + +### 迁移建议 + +后端继续承接: + +1. 浏览历史唯一持久化真相 +2. 历史去重、排序、截断 +3. 迁移完成标记 + +前端只保留: + +1. 展示缓存 +2. 弱网下的临时 optimistic UI +3. 刷新后重新拉取远端结果 + +### 优先级 + +`P1` + +--- + +## 3.4 NPC 待接委托“换单”仍由前端直接发起正式生成 + +### 代码证据 + +`src/hooks/story/npcEncounterActions.ts` 当前仍保留: + +1. `replacePendingNpcQuestOffer = async () => { ... }` +2. 内部直接调用 `generateQuestForNpcEncounter(...)` + +对应位置: + +1. `src/hooks/story/npcEncounterActions.ts:1561` +2. `src/hooks/story/npcEncounterActions.ts:1595` + +### 当前问题 + +聊天后是否挂出待接委托已经后移,但“换一份委托”这条分支仍然是: + +1. 前端组装上下文 +2. 前端决定调用生成 +3. 前端直接把结果写回当前 story UI + +这仍属于正式运行时任务编排没有收干净。 + +### 迁移建议 + +后端继续承接: + +1. NPC 待接委托换单决策 +2. 是否允许换单 +3. 换单后的任务草案生成 +4. 对应聊天态快照回填 + +前端只保留: + +1. 点击“换一份委托” +2. loading / error 展示 +3. 消费后端返回的新 pending quest offer + +### 优先级 + +`P0` + +--- + +## 3.5 questDirector 仍是前端 SDK 与生成编排混合体 + +### 代码证据 + +`src/services/questDirector.ts` 当前同时承担: + +1. `generateQuestForNpcEncounter(...)` +2. 浏览器路径 `requestJson('/api/runtime/quests/generate')` +3. 非浏览器路径 `requestChatMessageContent(...)` +4. 本地 `compileQuestIntentToQuest(...)` fallback + +对应位置: + +1. `src/services/questDirector.ts:213` +2. `src/services/questDirector.ts:242` +3. `src/services/questDirector.ts:267` +4. `src/services/questDirector.ts:256` +5. `src/services/questDirector.ts:281` +6. `src/services/questDirector.ts:293` + +### 当前问题 + +这类文件虽然浏览器正式路径已经优先走后端,但职责仍混在一起: + +1. 前端 SDK +2. Quest prompt 编排 +3. Quest intent 解析 +4. deterministic fallback compile + +这会导致边界长期模糊,也让前端仍像“半个服务端”。 + +### 迁移建议 + +后端继续承接: + +1. quest intent 生成 +2. prompt 组装 +3. JSON 解析 +4. fallback compile + +前端只保留: + +1. `requestGenerateQuest(...)` 这类轻量 SDK +2. 请求参数组装 +3. 结果消费 + +### 优先级 + +`P1` + +--- + +## 3.6 runtimeItemAiDirector 仍是前端 SDK 与意图生成混合体 + +### 代码证据 + +`src/services/runtimeItemAiDirector.ts` 当前同时承担: + +1. `generateRuntimeItemAiIntents(...)` +2. 浏览器路径 `requestJson('/api/runtime/items/runtime-intent')` +3. 非浏览器路径 `requestChatMessageContent(...)` +4. 本地 `buildRuntimeItemAiIntent(...)` fallback + +对应位置: + +1. `src/services/runtimeItemAiDirector.ts:84` +2. `src/services/runtimeItemAiDirector.ts:94` +3. `src/services/runtimeItemAiDirector.ts:118` + +### 当前问题 + +它和 `questDirector` 是同类问题: + +1. 正式浏览器路径已经走后端 +2. 但前端文件仍然承担完整生成逻辑认知 +3. 文件职责仍然是双环境混合 + +### 迁移建议 + +后端继续承接: + +1. runtime item intent prompt +2. 模型调用 +3. 结果解析与 fallback + +前端只保留: + +1. 轻量请求 SDK +2. 结果到 UI 的映射 + +### 优先级 + +`P1` + +--- + +## 3.7 `src/services/ai.ts` 仍是浏览器侧正式 AI orchestration 热点 + +### 代码证据 + +当前 `src/services/ai.ts` 仍直接承担以下正式链路: + +1. `requestChatMessageContent(...)` +2. `requestPlainTextCompletionFromClient(...)` +3. `streamPlainTextCompletionFromClient(...)` +4. `generateCustomWorldProfile(...)` +5. `generateInitialStory(...)` +6. `generateNextStep(...)` +7. `streamNpcChatDialogue(...)` +8. `streamNpcRecruitDialogue(...)` + +对应位置: + +1. `src/services/ai.ts:1732` +2. `src/services/ai.ts:1868` +3. `src/services/ai.ts:2038` +4. `src/services/ai.ts:2339` +5. `src/services/ai.ts:2447` +6. `src/services/ai.ts:2487` +7. `src/services/ai.ts:2529` +8. `src/services/ai.ts:2570` + +并且文件内仍保留: + +1. JSON repair +2. prompt 组装 +3. response normalize +4. fallback/offline 响应 +5. 角色聊天建议与摘要生成 + +### 当前问题 + +这说明浏览器端并不只是“请求一个后端接口”,而是还在承担: + +1. prompt source +2. 生成策略 +3. 错误修复 +4. fallback 编排 +5. 多类业务场景的正式 AI 出口 + +这与“前端只做表现”存在明确冲突。 + +### 迁移建议 + +后端继续承接: + +1. story / npc / recruit / custom-world 的 prompt 编排 +2. JSON repair +3. fallback 策略 +4. streaming orchestration +5. 模型调用与日志 + +前端只保留: + +1. 轻量 AI SDK +2. SSE 文本流展示 +3. UI fallback 呈现 + +### 优先级 + +`P0` + +--- + +## 4. 可以暂时保留在前端的部分 + +下面这些内容即使和上述模块同文件出现,也不属于必须后移的对象: + +1. 面板开关、loading、error、streaming 文本展示 +2. 动画时间线、过场状态、临时 UI 回显 +3. 表单草稿、筛选词、排序选项 +4. 只影响表现、不影响正式真相的 view model 拼接 + +迁移时要注意: + +**不是把所有前端代码都往后端搬,而是把“正式状态解释、规则裁决、生成编排、持久化真相”搬走。** + +--- + +## 5. 推荐迁移顺序 + +## 5.1 第一阶段 + +先收最危险的正式真相: + +1. `runtimeStoryCoordinator.ts` +2. `apiClient.ts` +3. `npcEncounterActions.ts` 里的 quest replace 分支 + +原因: + +1. 这三处最直接影响运行时真相和动作主链 +2. 不先收这些,前端仍然不是纯表现层 + +## 5.2 第二阶段 + +再拆双环境混合服务: + +1. `questDirector.ts` +2. `runtimeItemAiDirector.ts` +3. `platformBrowseHistory.ts` + +原因: + +1. 这几处已经有后端承接基础 +2. 迁移成本相对可控 + +## 5.3 第三阶段 + +最后继续压缩浏览器 AI orchestration: + +1. `src/services/ai.ts` +2. 相关 prompt builder / repair helper / offline fallback + +原因: + +1. 这部分体量大 +2. 链路多 +3. 更适合在前两阶段把 contract 稳住后集中拆 + +--- + +## 6. 建议产出物 + +如果后续按这份文档继续落地,建议每一批都至少同步产出: + +1. 一份落地文档,说明迁移了哪条链 +2. 一组 contract/route 变更说明 +3. 一组前端 SDK 收缩说明 +4. 一组防回退测试 + +--- + +## 7. 一句话结论 + +当前前端最需要继续后移的,不是零散小工具,而是: + +**运行时快照前置写入、鉴权 token、本地浏览历史真相、NPC 委托换单、quest/runtime item 双环境混合编排,以及 `src/services/ai.ts` 里仍然留在浏览器的正式 AI orchestration。** diff --git a/docs/audits/engineering/README.md b/docs/audits/engineering/README.md index e3ae7bcf..94efb4c8 100644 --- a/docs/audits/engineering/README.md +++ b/docs/audits/engineering/README.md @@ -4,21 +4,36 @@ ## 当前推荐入口 -1. [CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md](./CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md) +1. [FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md](./FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md) + 这一版是本轮前端越界逻辑专项审计,专门汇总当前仍应继续迁到 Express 后端的运行时、鉴权、生成编排与本地真相残留。 +2. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md) + 这一版是第四批落地记录,聚焦未接入业务的数据生成产物、测试专用 stub 与对应配置残留出清。 +3. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md) + 这一版是第三批落地记录,聚焦鉴权真相收口,先移除前端保存自动登录用户名/密码的本地真相,并明确运行时快照前置写入为什么当前还不能硬砍。 +4. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md) + 这一版是第二批落地记录,聚焦旧主流程壳层、旧 bootstrap 和旧 inventory / forge / equipment flow Hook 的正式出清。 +5. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md) + 这一版是第一批落地记录,聚焦高置信度小型孤岛、prompt 壳子、stub 和无入口 modal 的首轮清理。 +6. [CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md](./CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md) 这一版是面向当前仓库状态的优化点盘点,适合直接拿来排优先级和拆执行批次。 -2. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md) +7. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md) 这一版是对 `2026-04-19` 基线的当前仓库复核,明确哪些问题已经处理、哪些表述需要纠正、热点又迁移到了哪里。 -3. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md) +8. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md) 这一版保留原始问题快照和执行回填,适合回看“为什么会有这轮清理与边界收口”。 -4. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md) +9. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md) 这一版最适合作为当前工程基线,重点从“是否真正绿色”“门禁有没有覆盖真实风险”来判断仓库状态。 -5. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md) +10. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md) 适合回看运行时主链路、Story/Combat 边界、分层过渡期问题。 -6. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) +11. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) 适合看第一轮系统性工程扫描,了解最早的问题基线。 ## 融合结论 +- 最新专项审计已经把“前端哪些逻辑还该后移到后端”收敛到 6 类:运行时快照、本地 token、本地浏览历史、NPC 委托换单、quest/runtime item 混合编排、浏览器 AI orchestration。 +- 工程大清洗已经开始进入实际执行阶段,首批高置信度小型孤岛和残留壳子已开始清理。 +- 第二批已经开始清理旧主流程壳层与旧 flow Hook,当前主工程的“现行入口”和“历史入口”边界正在变得更清楚。 +- 第三批已经先完成鉴权真相收口的一段,前端不再保存自动登录用户名/密码;运行时快照链仍需先补后端 contract,再继续往前删。 +- 第四批已经继续收掉未接入业务的数据生成产物、测试专用 stub 与对应脚本/配置残留,主工程里的“假数据主源”进一步减少。 - 当前仓库已经完成“旧 dev 插件链路删除、根目录噪音清理、`server-node -> src/**` 反向依赖切断”这批第一阶段任务。 - 当前如果想直接判断“今天先优化什么”,优先看 `CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md`。 - 当前的新重点已经进一步收敛到三类:未接线孤岛模块、前端残留的运行时/鉴权真相、热点向 prompt/runtime profile/平台入口壳层迁移。 diff --git a/docs/experience/CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md b/docs/experience/CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md index 99166c90..5a15a1ab 100644 --- a/docs/experience/CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md +++ b/docs/experience/CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md @@ -22,20 +22,20 @@ ### 2.1 编辑器入口与页签整理 -- 保留 `/preset-editor`、`/npc-editor`、`/function-editor` -- 新增 `/behavior-editor` 作为“选项行为”编辑页别名 +- 当时曾保留 `/preset-editor`、`/npc-editor`、`/function-editor` +- 当时还新增过 `/behavior-editor` 作为“选项行为”编辑页别名 - 将原先单独的 `NPC 视觉` 标签并回 `NPC` 编辑页 - 将 `Function` 页签改名为 `选项行为` 结论: -- 路由层要尽量兼容旧入口,避免历史链接失效 +- 独立编辑器入口如果没有继续接入主流程,应及时物理删除,不要长期保留兼容壳 - 页签命名要贴近创作者语言,而不是内部实现命名 ### 2.2 NPC 视觉模块并入 NPC 编辑 完成内容: -- 将 [NpcVisualEditor](/E:/Repos/Genarrative/src/components/NpcVisualEditor.tsx) 嵌入 [PresetEditor](/E:/Repos/Genarrative/src/components/PresetEditor.tsx) 的 NPC 编辑页 +- 当时曾将 `NpcVisualEditor` 嵌入 `PresetEditor` 的 NPC 编辑页 - 让 NPC 文本字段与视觉字段围绕同一个当前选中 NPC 联动 - 保留视觉覆盖保存与全局布局保存能力 diff --git a/docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md b/docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md index 68fe5eff..16424b14 100644 --- a/docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md +++ b/docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md @@ -86,6 +86,22 @@ 2. 不为了“文档里写过”就把所有没接线面板都接进来 3. 不把当前工作区重新改造成一个更复杂的大后台 +补充更新(`2026-04-21`): + +上一轮审计里提到的一组旧 Agent 副面板,已经在工程清理中被明确判定为退出当前版本主链并物理删除,包括: + +1. `CustomWorldAgentLockBar.tsx` +2. `CustomWorldAgentQuickActions.tsx` +3. `CustomWorldAgentSummaryPanel.tsx` +4. `CustomWorldAgentIntentSummaryPanel.tsx` +5. `CustomWorldAgentClarificationPanel.tsx` +6. `CustomWorldAgentDraftDetailPanel.tsx` +7. `CustomWorldDraftCardDetailModal.tsx` +8. `CustomWorldDraftEditPanel.tsx` +9. `CustomWorldGenerateEntityModal.tsx` + +所以这里的“不为了文档里写过就全接进来”,现在不只是态度提醒,而是已经执行过的现实边界。 + ### 3.3 不把结果页继续当旧编辑器扩写 这轮明确不再鼓励: @@ -244,6 +260,15 @@ 尤其是旧 `custom-world/sessions` 这条链,如果还要保留,也只能是兼容入口,不能再和 Agent 主链平起平坐。 +补充更新(`2026-04-21`): + +这条旧 `custom-world/sessions` 世界生成链已经完成物理删除。 + +因此阶段三在当前仓库里的剩余任务,不再是“决定要不要保留这条旧链”,而是: + +1. 继续收掉结果页 legacy profile 直改能力的误导性职责 +2. 继续清理文档里把旧链当成现行选项的表述 + --- ## 4.5 第五件事:把文稿里那些“这轮不做”的未完成项从主叙事里移掉 @@ -323,6 +348,12 @@ 2. 把“未来也许做”从“这轮要做”里拆开 3. 让所有当前规划只服务当前版本 +就当前状态补一句最重要的执行口径: + +1. 已经物理删除的旧链和旧副面板,不再作为“本轮待落地项” +2. 历史 PRD 可以保留实现设想,但必须和“当前版本执行规划”分开 +3. 当前版本规划只保留仍对正式主链有现实约束的事项 + 这一阶段的目标是: **让接下来所有开发都围绕同一套现实目标执行。** 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 index 881927d7..e6c36e6b 100644 --- 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 @@ -164,7 +164,7 @@ Git 分支治理可以后置做,但不能和首轮工程清洗混在一起, 11. `src/services/typewriter.ts` 12. `src/prompts/customWorldOrchestratorPrompts.ts` 13. `src/prompts/storyOrchestratorPrompts.ts` -14. `src/data/buildTagSimilarity.generated.ts` +14. `src/data/buildTagSimilarity.generated.ts`(已在后续清理批次中删除) 这些文件不能直接判死刑,但必须进入“保留 / 接回 / 归档 / 删除”四选一清单。 diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md index 7f73a297..52c13fd7 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md @@ -2,6 +2,16 @@ 更新时间:`2026-04-13` +## 0.1 当前状态说明(2026-04-21) + +这份文档保留为第一阶段历史落地方案。 + +补充边界: + +1. 文中出现的旧 Agent 副面板、旧世界生成链,不代表它们仍是当前版本待落地项 +2. 已被工程清理判定退出主链并删除的对象,应以最新清理记录为准,不再按这里的历史计划继续接回 +3. 当前版本执行优先级,应回到 `docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md` + ## 0. 文档目的 这份文档用于把以下两份 PRD 收束成可直接开工的第一阶段实现方案: @@ -1056,6 +1066,16 @@ onSubmit({ ## 12. 落地文件清单 +### 当前状态补充(2026-04-21) + +下面这份文件清单是第一阶段编写当日的历史落地清单。 + +需要特别注意: + +1. 其中列出的 `CustomWorldAgentLockBar.tsx`、`CustomWorldAgentQuickActions.tsx` 等旧副面板,已经在当前版本清理中退出主链并物理删除 +2. 因此这里不能再被当作“当前版本仍要补齐的现行文件清单” +3. 当前仍然有效的执行边界,应以最新优化规划和清理记录为准 + ## 12.1 shared 必须新增: diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md index b1ae0d71..9f3ae993 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md @@ -2,6 +2,15 @@ 更新时间:`2026-04-13` +## 0.1 当前状态说明(2026-04-21) + +这份文档保留为第二阶段历史落地方案。 + +补充边界: + +1. 文中提到的 `CustomWorldAgentIntentSummaryPanel`、`CustomWorldAgentClarificationPanel`、`CustomWorldAgentLockBar`、`CustomWorldAgentQuickActions` 等旧副面板,已经在当前版本收口判断中退出主链并物理删除 +2. 这些内容现在只能作为历史设计参考,不再作为当前版本默认待接线项 +3. 当前如果要重新设计这些能力的消费方式,应基于现行主链重新定义 ## 0. 文档目的 这份文档用于把以下两份文档进一步收束成第二阶段实现方案: @@ -548,6 +557,21 @@ buildPendingClarifications(intent, readiness) ## 10. 前端实现方案 +### 当前状态补充(2026-04-21) + +这一节描述的是第二阶段编写当时的右侧副面板方案。 + +但当前版本已经明确: + +1. `CustomWorldAgentIntentSummaryPanel` +2. `CustomWorldAgentClarificationPanel` +3. `CustomWorldAgentLockBar` +4. `CustomWorldAgentQuickActions` + +这组旧副面板不再作为当前版本默认待接线方向,其中对应已落地但退出主链的文件也已物理删除。 + +因此本节以下内容应视为历史设计稿,不再直接代表当前版本执行方案。 + ## 10.1 修改 `CustomWorldAgentWorkspace.tsx` 第二阶段它不再只是空壳工作区,而要新增: @@ -679,6 +703,12 @@ buildPendingClarifications(intent, readiness) ## 13. 落地文件清单 +### 当前状态补充(2026-04-21) + +本节列的是第二阶段历史文件清单。 + +其中涉及旧副面板的部分,现在只保留历史参考价值,不再等同于当前版本待落地项。 + ## 13.1 shared 必须修改: diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md index 6aef83af..a4f1ddac 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md @@ -2,6 +2,15 @@ 更新时间:`2026-04-14` +## 0.1 当前状态说明(2026-04-21) + +这份文档保留为第三阶段历史落地方案。 + +补充边界: + +1. 文中涉及的 `CustomWorldAgentQuickActions`、`CustomWorldAgentDraftDetailPanel`、`CustomWorldDraftCardDetailModal` 等旧副面板,已在当前版本清理中退出主链并物理删除 +2. 因此这份文档里的对应实现章节,不能再直接视为当前版本待继续补完的目标 +3. 当前版本只保留对正式主链仍有现实约束的能力项 ## 0. 文档目的 这份文档用于把以下几份文档进一步收束成第三阶段实现方案: @@ -721,6 +730,21 @@ object_refining ## 11. 前端实现方案 +### 当前状态补充(2026-04-21) + +这一节描述的是第三阶段编写当时的草稿抽屉与详情侧栏方案。 + +但当前版本已经明确: + +1. `CustomWorldAgentDraftDrawer.tsx` 已在 earlier cleanup 中删除 +2. `CustomWorldAgentDraftDetailPanel.tsx` +3. `CustomWorldDraftCardDetailModal.tsx` +4. `CustomWorldAgentQuickActions.tsx` + +已在当前版本收口判断中退出主链并物理删除。 + +因此本节以下内容应视为历史设计稿,而不是当前版本默认待落地方案。 + ## 11.1 修改 `CustomWorldAgentQuickActions.tsx` 第三阶段必须让: @@ -918,6 +942,12 @@ creatorIntentReadiness.isReady === false ## 14. 落地文件清单 +### 当前状态补充(2026-04-21) + +本节列的是第三阶段历史文件清单。 + +其中涉及旧抽屉、旧详情面板、旧快捷动作面板的部分,当前仅保留历史参考意义。 + ## 14.1 shared 必须修改: diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md index cca30769..a936102d 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md @@ -2,6 +2,15 @@ 更新时间:`2026-04-14` +## 0.1 当前状态说明(2026-04-21) + +这份文档保留为第四阶段历史落地方案。 + +补充边界: + +1. 文中聚焦的草稿详情编辑链与实体生成弹窗链,在当前版本收口判断中已经退出主链 +2. 对应的 `CustomWorldAgentDraftDetailPanel`、`CustomWorldDraftEditPanel`、`CustomWorldGenerateEntityModal`、`CustomWorldAgentQuickActions` 等文件已物理删除 +3. 当前版本不再把这套旧副面板链作为默认待接线方向 ## 0. 文档目的 这份文档用于把以下几份文档进一步收束成第四阶段实现方案: @@ -665,6 +674,21 @@ generateAdditionalLandmarks(params) ## 10. 前端实现方案 +### 当前状态补充(2026-04-21) + +这一节描述的是第四阶段编写当时的草稿详情编辑链与实体生成弹窗链。 + +但当前版本已经明确: + +1. `CustomWorldAgentDraftDetailPanel.tsx` +2. `CustomWorldDraftEditPanel.tsx` +3. `CustomWorldGenerateEntityModal.tsx` +4. `CustomWorldAgentQuickActions.tsx` + +已在当前版本收口判断中退出主链并物理删除。 + +因此本节以下内容只保留历史设计参考价值,不再直接代表当前版本执行方向。 + ## 10.1 修改 `CustomWorldAgentDraftDetailPanel.tsx` 第四阶段它要从只读详情升级成: @@ -851,6 +875,12 @@ editableSectionIds: [] ## 13. 落地文件清单 +### 当前状态补充(2026-04-21) + +本节列的是第四阶段历史文件清单。 + +其中涉及草稿详情编辑链与实体生成弹窗链的部分,不再等同于当前版本待落地清单。 + ## 13.1 shared 必须修改: diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md index 6e9c4215..0e0cd627 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md @@ -2,6 +2,15 @@ 更新时间:`2026-04-14` +## 0.1 当前状态说明(2026-04-21) + +这份文档保留为第五阶段历史落地方案。 + +补充边界: + +1. 文中继续沿用的 `CustomWorldAgentDraftDetailPanel`、`CustomWorldAgentQuickActions` 等旧副面板,在当前版本已经退出主链并物理删除 +2. 因此这份文档里的实现安排,不应再被直接视为当前版本执行清单 +3. 当前版本是否重引入相关能力,必须基于新的主链设计重新判断 ## 0. 文档目的 这份文档用于把以下几份文档进一步收束成第五阶段实现方案: @@ -195,6 +204,19 @@ ## 7.2 入口位置 +### 当前状态补充(2026-04-21) + +这一节以下描述依赖当时仍被视为现行方案的旧详情面板链与旧快捷动作链。 + +但当前版本已经明确: + +1. `CustomWorldAgentDraftDetailPanel.tsx` +2. `CustomWorldAgentQuickActions.tsx` + +已退出主链并物理删除。 + +因此这里关于入口位置的说明,现在只能作为历史资产工坊设计参考,不再代表当前版本 UI 入口。 + ### 角色卡详情入口 在 `CustomWorldAgentDraftDetailPanel` 中,当当前卡类型为: @@ -548,6 +570,12 @@ mergeRoleAssetIntoDraftProfile(draftProfile, payload); ## 11. 前端实现方案 +### 当前状态补充(2026-04-21) + +本节以下内容依赖旧详情面板链与旧快捷动作面板。 + +这些文件当前已经退出主链并删除,所以这里不再是当前版本的直接执行清单。 + ## 11.1 修改 `CustomWorldAgentDraftDetailPanel.tsx` 当卡片类型为 `character` 时,新增: diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md index dd64840a..bb642bd1 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md @@ -2,6 +2,15 @@ 更新时间:`2026-04-14` +## 0.1 当前状态说明(2026-04-21) + +这份文档保留为第六阶段历史落地方案。 + +补充边界: + +1. 文中继续引用的 `CustomWorldAgentDraftDetailPanel`、`CustomWorldAgentQuickActions` 等旧副面板,在当前版本已经退出主链并物理删除 +2. 这份文档可用于回看当时的资产工坊设想,但不代表当前版本仍按这里逐项补齐 +3. 当前执行边界以最新优化规划和阶段四清理边界文档为准 ## 0. 文档目的 这份文档用于把以下几份文档进一步收束成第六阶段实现方案: @@ -190,6 +199,19 @@ ## 7.2 入口位置 +### 当前状态补充(2026-04-21) + +这一节以下描述依赖当时仍被视为现行方案的旧详情面板链与旧快捷动作链。 + +但当前版本已经明确: + +1. `CustomWorldAgentDraftDetailPanel.tsx` +2. `CustomWorldAgentQuickActions.tsx` + +已退出主链并物理删除。 + +因此这里关于入口位置的说明,现在只保留历史场景资产工坊设计参考价值。 + ### 地点卡详情入口 在 `CustomWorldAgentDraftDetailPanel` 中,当当前卡类型为: diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md index c4cf9d55..4f6a457a 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md @@ -2,6 +2,16 @@ 更新时间:`2026-04-12` +## 0.1 当前状态说明(2026-04-21) + +这份文档保留为历史 PRD 基线,用于回看当时的完整目标设计。 + +但需要特别注意: + +1. 文中涉及的一部分旧 Agent 副面板与旧世界生成链,已经在 `2026-04-21` 的工程清理中判定退出当前版本主链并完成物理删除 +2. 当前版本的真实执行边界,以 `docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md`、`docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md` 和最新工程清理批次记录为准 +3. 阅读这份文档时,应把它视为“历史完整设计稿”,而不是“当前版本仍要全部继续落地的执行清单” + ## 0. 文档目的 这份 PRD 用于把以下几份分析文档收束成一份可直接指导编码落地的新创作工具产品需求文档: diff --git a/docs/prd/BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md b/docs/prd/BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md index 69dd8516..79f35c3c 100644 --- a/docs/prd/BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md +++ b/docs/prd/BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md @@ -8,7 +8,6 @@ - `src/data/buildDamage.ts` - `src/data/buildTags.ts` -- `src/data/buildTagSimilarity.generated.ts` 现状不是“标签各自独立生效”,而是: @@ -263,7 +262,7 @@ type BuildDamageBreakdown = { ## 4.3 相似度来源 -当前仓库已有 `src/data/buildTagSimilarity.generated.ts`,但新方案不再以“标签-标签相似度矩阵”为主数据源。 +旧版曾生成过标签-标签相似度矩阵,但新方案不再以“标签-标签相似度矩阵”为主数据源。 建议改为新增: @@ -401,13 +400,13 @@ finalDamage = ### 风险 3:旧数据迁移成本 问题: -现有 `buildTagSimilarity.generated.ts` 将弱化甚至失去主要用途。 +旧标签相似度矩阵已经不再作为主数据源。 对策: -1. 本期不强制删除旧文件。 -2. 新逻辑只依赖新 affinity 表。 -3. 等新系统稳定后,再清理旧相似度矩阵和旧展示逻辑。 +1. 新逻辑只依赖标签定义中的属性亲和度。 +2. 旧相似度矩阵生成产物已从主工程移除。 +3. 后续若需要重新引入自动建议,也应输出为审表辅助数据,而不是运行时真相源。 ## 11. 一句话结论 diff --git a/docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md b/docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md index eac71f06..35d88d63 100644 --- a/docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md +++ b/docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md @@ -74,7 +74,6 @@ | `src/prompts/storyOrchestratorPrompts.ts` | 剧情中文修复 | `STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT` | | `src/prompts/customWorldRolePromptDefaults.ts` | 角色资产工作台默认词唯一主源 | `buildDefaultRolePromptBundle` | | `src/prompts/customWorldEntityActionPrompts.ts` | 编辑器技能动作词 | `buildSkillActionPrompt` | -| `src/prompts/qwenSpriteSheetToolPrompts.ts` | Qwen 精灵图工具 prompt 模型 | 主 prompt / sheet prompt / repair prompt / negative prompt 系列 | ### 3.3 共享层 @@ -91,7 +90,6 @@ - `src/services/questPrompt.ts` - `src/services/runtimeItemAiPrompt.ts` - `server-node/src/services/eightAnchorPromptBuilder.ts` -- `src/tools/qwenSpriteSheetToolModel.ts` - `src/components/asset-studio/customWorldRolePromptDefaults.ts` - `packages/shared/src/assets/qwenSprite.ts` @@ -110,14 +108,12 @@ | --- | --- | --- | | `server-node/src/prompts/characterAssetPrompts.ts` | 正式角色资产生成 prompt | 后端角色主图、动作试片、角色场景词主源 | | `packages/shared/src/prompts/qwenSprite.ts` | 共享角色主 prompt 模板 | 共享给后端资产链使用的基础模板 | -| `src/prompts/qwenSpriteSheetToolPrompts.ts` | 工具链 prompt 模型 | Qwen 精灵图工具主词、分镜词、修帧词、负面词 | | `src/prompts/customWorldRolePromptDefaults.ts` | 工作台默认词种子 | 角色视觉词、动画词、场景词默认值 | | `src/prompts/customWorldEntityActionPrompts.ts` | 编辑器动作词 | 技能动作描述 prompt builder | 当前调用关系: - `server-node/src/modules/assets/characterAssetRoutes.ts` 调用 `server-node/src/prompts/characterAssetPrompts.ts` -- `src/tools/QwenSpriteSheetTool.tsx` 通过兼容层消费 `src/prompts/qwenSpriteSheetToolPrompts.ts` - `src/components/CustomWorldRoleAssetStudioModal.tsx` 通过兼容层消费 `src/prompts/customWorldRolePromptDefaults.ts` - `src/components/CustomWorldEntityEditorModal.tsx` 直接调用 `src/prompts/customWorldEntityActionPrompts.ts` @@ -145,7 +141,7 @@ - `src/services/customWorld.ts` 中的自定义世界分阶段 prompt 与场景背景图 prompt - `src/services/ai.ts` 中的世界修复 / 语言修复 / JSON only system prompt - `src/services/prompt.ts`、`characterChatPrompt.ts`、`questPrompt.ts`、`runtimeItemAiPrompt.ts` 这批前端 prompt 脚本 -- `src/tools/qwenSpriteSheetToolModel.ts`、`src/components/asset-studio/customWorldRolePromptDefaults.ts`、`src/components/CustomWorldEntityEditorModal.tsx` 里的工具 / 编辑器 prompt 散点 +- `src/components/asset-studio/customWorldRolePromptDefaults.ts`、`src/components/CustomWorldEntityEditorModal.tsx` 里的工具 / 编辑器 prompt 散点 ## 8. 当前仍在非 Prompt 目录中的相关文件 diff --git a/docs/technical/CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md b/docs/technical/CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md new file mode 100644 index 00000000..c20759b5 --- /dev/null +++ b/docs/technical/CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md @@ -0,0 +1,36 @@ +# 创作页移动端 UI 修复记录 + +日期:`2026-04-21` + +## 问题定位 + +本轮修复只处理创作页表现层,不新增创作流程。 + +当前移动端问题主要来自三处: + +1. 平台页在 `platformTab === 'create'` 时直接渲染 `CustomWorldCreationHub`,绕过了 `PlatformHomeView` 的移动端外壳,导致底部 Tab 栏没有挂载。 +2. 创作中心内部仍混用 `pixel-*` 九宫格样式、`bg-black/*`、`text-white`、`border-white/*` 等暗色 Tailwind 类,亮色主题下会出现深色块和低对比文字。 +3. 创作中心根节点自带 `h-full overflow-y-auto`,放回平台页后容易与平台页主滚动区抢滚动权,手机上会显得布局混乱。 + +## 落地约束 + +1. 创作页仍复用现有平台首页,不新增页面和新系统。 +2. 移动端底部 Tab 必须始终由 `PlatformHomeView` 统一渲染,创作页只作为 `create` Tab 的内容。 +3. 创作中心内部不再使用深色硬编码作为默认底色,普通卡片、筛选 Tab、空状态和按钮统一使用 `platform-*` token。 +4. 创作中心不再自建整页滚动,只把内容交给平台页主滚动区,避免嵌套滚动。 +5. UI 中不增加规则说明类文案,只保留必要入口、状态和作品信息。 + +## 编码方案 + +1. 在 `PlatformHomeView` 增加可选的 `createTabContent`,让当前 Agent 创作中心接回平台页统一外壳。 +2. `PreGameSelectionFlow` 不再在 `platformTab === 'create'` 时绕过 `PlatformHomeView`,而是把 `CustomWorldCreationHub` 作为创作 Tab 内容传入。 +3. `CustomWorldCreationHub` 改为无内部整页滚动的内容容器,标题、返回、计数、错误、加载骨架都使用平台 token。 +4. `CustomWorldCreationStartCard` 与 `CustomWorldWorkCard` 从像素暗色面板切换为平台卡片样式,保留游戏化主视觉但跟随亮暗主题。 +5. `CustomWorldWorkTabs` 改用 `platform-tab`,并保持横向滚动与清晰选中态。 + +## 验收要点 + +1. 手机宽度下进入“创作”后,底部“首页 / 创作 / 存档 / 我的”Tab 始终可见。 +2. 亮色主题下创作页默认卡片不出现大面积黑色底板。 +3. 创作页只有平台页主内容区滚动,底部 Tab 不随作品列表滚走。 +4. 桌面端仍可通过左侧平台导航进入创作页。 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 index 4c4ae9e7..20c8aa9f 100644 --- 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 @@ -6,12 +6,13 @@ 当前这条 Agent 创作流已经完成阶段一到阶段三的主要收口。 -阶段四中的“文档清理”已经开始做,但还没有形成独立、完整的新主链。 +阶段四中的“文档清理”已经开始做,但还没有形成独立、完整的新主链审计闭环。 -因此这轮可以执行的清理只有一类: +因此这轮可以执行的清理现在有两类: 1. 删除已经不再从当前主入口可达的旧 `custom-world/sessions` 世界生成链 -2. 保留仍在服务 `Agent session` 主链或已保存作品兼容编辑体验的底层能力 +2. 删除已经完全脱离 `CustomWorldAgentWorkspace` 主链、只剩孤立互相引用与自测覆盖的 `custom-world-agent` 旧面板 +3. 保留仍在服务 `Agent session` 主链或已保存作品兼容编辑体验的底层能力 这轮不做: @@ -60,7 +61,8 @@ 原因: 1. 文档清理已经开始,但还没有完整收束到单一结论文档 -2. 旧 `custom-world/sessions` 生成链虽然已经不在主入口上,但还未清干净 +2. 旧 `custom-world/sessions` 生成链已经完成物理清理,但与之相关的审计/PRD/知识图谱文档仍需继续统一口径 +3. `custom-world-agent` 孤岛面板已经完成第二轮物理清理,但阶段四文档总收口仍未完全覆盖所有历史 PRD 口径 --- @@ -72,12 +74,29 @@ 2. `server-node/src/routes/runtimeRoutes.ts` 里的旧 `custom-world/sessions` 路由 3. `server-node/src/services/customWorldGenerationService.ts` 4. 与这条旧链对应的测试 +5. `server-node/src/services/customWorldSessionStore.ts` +6. `src/components/custom-world-agent/CustomWorldAgentLockBar.tsx` +7. `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx` +8. `src/components/custom-world-agent/CustomWorldAgentSummaryPanel.tsx` +9. `src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.tsx` +10. `src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx` +11. `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx` +12. `src/components/custom-world-agent/CustomWorldDraftCardDetailModal.tsx` +13. `src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx` +14. `src/components/custom-world-agent/CustomWorldGenerateEntityModal.tsx` +15. 仅为上述孤岛面板存在的对应测试文件 不允许删除: -1. `server-node/src/services/customWorldSessionStore.ts` -2. `server-node/src/repositories/runtimeRepository.ts` 中被 Agent session 复用的 session 持久化能力 -3. `src/services/aiService.ts` 里仍在使用的 `generateCustomWorldProfile` 及其现代封装 +1. `server-node/src/repositories/runtimeRepository.ts` 中被 Agent session 复用的 session 持久化能力 +2. `src/services/aiService.ts` 里仍在使用的 `generateCustomWorldProfile` 及其现代封装 +3. 已保存作品结果页仍在使用的 legacy 编辑器兼容能力 +4. `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` 及其仍在主链上的 5 个子模块: + - `CustomWorldAgentHeader` + - `EightAnchorProgressBar` + - `CustomWorldAgentOperationBanner` + - `CustomWorldAgentThread` + - `CustomWorldAgentComposer` --- @@ -87,6 +106,8 @@ 1. `src/services/aiService.ts` 不再暴露旧 `custom-world/sessions` 请求函数 2. `server-node/src/routes/runtimeRoutes.ts` 不再挂旧 session 路由 -3. 仓库里不再有主流程可达的旧世界生成入口 -4. Agent 主链与已保存作品编辑链仍然可用 - +3. `server-node/src/services/customWorldSessionStore.ts` 与 `server-node/src/services/customWorldGenerationService.ts` 已物理删除 +4. 仓库里不再有主流程可达的旧世界生成入口 +5. `CustomWorldAgentWorkspace.tsx` 只保留当前正式主链需要的 5 个子模块 +6. 与旧 Agent 草稿面板相关的孤岛 UI 与自测不再继续占据正式目录注意力 +7. Agent 主链与已保存作品编辑链仍然可用 diff --git a/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md b/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md index cd2ba7e6..1768e251 100644 --- a/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md +++ b/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md @@ -26,10 +26,6 @@ - `GET /api/assets/character-animation/jobs/:taskId` - `POST /api/assets/character-animation/import-video` - `GET /api/assets/character-animation/templates` -- `POST /api/assets/qwen-sprite/master` -- `POST /api/assets/qwen-sprite/sheet` -- `POST /api/assets/qwen-sprite/frame-repair` -- `POST /api/assets/qwen-sprite/save` --- @@ -50,7 +46,6 @@ - 状态行为覆盖保存 - 角色主形象生成、发布与任务查询 - 角色动作生成、导入、发布、模板读取与任务查询 -- Qwen 精灵主图、精灵表、修帧与资产保存 --- diff --git a/docs/technical/EDITOR_ENTRY_CLEANUP_2026-04-21.md b/docs/technical/EDITOR_ENTRY_CLEANUP_2026-04-21.md new file mode 100644 index 00000000..1b2b0f8d --- /dev/null +++ b/docs/technical/EDITOR_ENTRY_CLEANUP_2026-04-21.md @@ -0,0 +1,85 @@ +# 主流程外编辑器入口清理说明(2026-04-21) + +日期:`2026-04-21` + +## 1. 文档目标 + +记录本轮对“挂在主流程路由外的旧编辑器入口”和“仍把这些入口当现役能力的残留说明”做的收口,避免后续开发再次把历史入口误判成正式能力。 + +--- + +## 2. 本轮清理结论 + +本轮确认后,当前前端正式入口只保留游戏主流程: + +- `src/routing/appRoutes.tsx` 仅保留 `game` + +本轮删除或收口的对象: + +- 独立前端工具路由 `qwen-sprite-tool` +- 仅服务该独立入口的前端页面 `src/tools/QwenSpriteSheetTool.tsx` +- 仅服务该独立入口的工具模型与持久化封装 +- 仅服务该独立入口的后端路由 `server-node/src/modules/assets/qwenSpriteRoutes.ts` +- 路由测试里把旧编辑器 / 独立工具入口当作现役分支的断言 +- README、经验文档、类型检查配置中已经失效的旧编辑器文件引用 + +--- + +## 3. 为什么可以删除 + +本轮删除对象满足下面几个条件: + +1. 不在当前玩家主流程中可达 +2. 没有继续嵌入正式创作主链 +3. 当前仓库已有主流程内嵌的替代能力 +4. 保留它们只会继续制造“看起来还能进、实际上已经不走”的假入口 + +其中需要特别区分的是: + +- `src/editor/shared/editorApiClient.ts` +- `server-node/src/modules/editor/editorRoutes.ts` +- `src/components/CustomWorldEntityEditorModal.tsx` +- `src/components/CustomWorldNpcVisualEditor.tsx` +- `src/components/CustomWorldRoleAssetStudioModal.tsx` + +这些仍然服务当前主流程内嵌编辑能力,因此本轮不删除。 + +--- + +## 4. 当前保留的编辑能力边界 + +当前保留的是“嵌入主流程的编辑能力”,不是“独立编辑器站点”: + +- 自定义世界实体编辑 +- 自定义世界角色形象编辑 +- 主流程内的角色资产工坊模态 +- 与这些能力配套的 `/api/editor/*` 与 `/api/assets/character-*` 接口 + +后续如果还要新增编辑能力,应优先: + +1. 先确认是否真的需要独立入口 +2. 默认优先接回主流程模态或正式创作链 +3. 如果只是内部工具,不要长期挂在正式前端路由里 + +--- + +## 5. 本轮同步更新 + +本轮已同步更新: + +- `README.md` +- `docs/experience/CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md` +- `src/routing/appRoutes.tsx` +- `src/routing/appRoutes.test.ts` +- `server-node/src/app.ts` +- `tsconfig.typecheck-guardrails.json` + +--- + +## 6. 后续建议 + +后续继续清理时,优先沿着这条规则推进: + +1. 先识别是否还在主流程可达 +2. 再判断是否仍有正式嵌入点 +3. 若只剩文档、测试、兼容判断或独立路由壳,直接成批收口 diff --git a/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md b/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md index 7616c90e..20e1ddee 100644 --- a/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md +++ b/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md @@ -150,10 +150,11 @@ JWT 现状: - `POST /api/custom-world/scene-image` - `POST /api/runtime/story/initial` - `POST /api/runtime/story/continue` -- `POST /api/runtime/custom-world/sessions` -- `GET /api/runtime/custom-world/sessions/:sessionId` -- `POST /api/runtime/custom-world/sessions/:sessionId/answers` -- `GET /api/runtime/custom-world/sessions/:sessionId/generate/stream` +- `POST /api/runtime/custom-world/agent/sessions` +- `GET /api/runtime/custom-world/agent/sessions/:sessionId` +- `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages` +- `POST /api/runtime/custom-world/agent/sessions/:sessionId/actions` +- `GET /api/runtime/custom-world/works` - `POST /api/runtime/chat/character/suggestions` - `POST /api/runtime/chat/character/summary` - `POST /api/runtime/chat/character/reply/stream` @@ -183,10 +184,6 @@ JWT 现状: - `GET /api/assets/character-animation/jobs/:taskId` - `POST /api/assets/character-animation/import-video` - `GET /api/assets/character-animation/templates` -- `POST /api/assets/qwen-sprite/master` -- `POST /api/assets/qwen-sprite/sheet` -- `POST /api/assets/qwen-sprite/frame-repair` -- `POST /api/assets/qwen-sprite/save` 编辑器与资产接口门禁: @@ -227,9 +224,7 @@ Custom World: 编辑器与资产工具层: - `src/editor/shared/editorApiClient.ts` -- `src/editor/shared/useJsonSave.ts` - `src/components/preset-editor/characterAssetStudioPersistence.ts` -- `src/tools/qwenSpriteSheetToolPersistence.ts` ## 10. 当前 Vite 角色 diff --git a/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md b/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md index 1efe4240..beac32a6 100644 --- a/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md +++ b/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md @@ -11,7 +11,6 @@ - `server-node/src/services/eightAnchorPromptBuilder.ts` - `server-node/src/modules/assets/characterAssetRoutes.ts` - `src/services/**` -- `src/tools/qwenSpriteSheetToolModel.ts` - `src/components/**` 问题主要有三类: @@ -50,7 +49,6 @@ src/prompts/ ├─ customWorldPrompts.ts ├─ customWorldRolePromptDefaults.ts ├─ questPrompts.ts -├─ qwenSpriteSheetToolPrompts.ts ├─ runtimeItemPrompts.ts ├─ storyOrchestratorPrompts.ts └─ storyPromptBuilders.ts @@ -82,8 +80,6 @@ src/prompts/ - 八锚点状态推断、模式规则与正式单轮共创 prompt - `src/prompts/customWorldPrompts.ts` - 自定义世界分阶段生成 prompt 与场景背景图 prompt -- `src/prompts/qwenSpriteSheetToolPrompts.ts` - - 精灵图工具主词 / 分镜词 / 修帧词 / 负面词 - `src/prompts/customWorldRolePromptDefaults.ts` - 角色资产工作台默认 prompt 种子唯一主源 - `src/prompts/customWorldEntityActionPrompts.ts` @@ -127,7 +123,6 @@ src/prompts/ - `src/services/characterChatPrompt.ts` - `src/services/questPrompt.ts` - `src/services/runtimeItemAiPrompt.ts` -- `src/tools/qwenSpriteSheetToolModel.ts` - `src/components/asset-studio/customWorldRolePromptDefaults.ts` - `packages/shared/src/assets/qwenSprite.ts` diff --git a/docs/technical/QWEN_IMAGE_2_PIXELMOTION_REPRODUCTION_WORKFLOW_2026-04-07.md b/docs/technical/QWEN_IMAGE_2_PIXELMOTION_REPRODUCTION_WORKFLOW_2026-04-07.md index 3c124cec..83bd2db4 100644 --- a/docs/technical/QWEN_IMAGE_2_PIXELMOTION_REPRODUCTION_WORKFLOW_2026-04-07.md +++ b/docs/technical/QWEN_IMAGE_2_PIXELMOTION_REPRODUCTION_WORKFLOW_2026-04-07.md @@ -676,7 +676,7 @@ PixelMotion 很关键的一点,不是要求 16 帧都完美,而是允许修 ### 13.4 推荐目录结构 ```text -pixelmotion-qwen/ +pixelmotion-workflow/ refs/ master.png pose_board_run.png diff --git a/docs/technical/README.md b/docs/technical/README.md index 42272303..e98fb37a 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -13,6 +13,7 @@ - [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 主链与已保存作品兼容编辑链。 +- [CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md](./CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md):创作页移动端底部 Tab、亮色主题 token 与滚动权责修复记录。 - [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/package.json b/package.json index 42655d8c..2e82716e 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "test": "vitest run", "test:watch": "vitest", "check": "npm run lint && npm run test && npm run build && npm run check:content", - "generate:build-tags": "py -3 scripts/generate-build-tag-similarity.py", "check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts", "check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts", "check:smoke": "node scripts/run-tsx.cjs scripts/smoke-content.ts", diff --git a/scripts/generate-build-tag-similarity.py b/scripts/generate-build-tag-similarity.py deleted file mode 100644 index e9071fee..00000000 --- a/scripts/generate-build-tag-similarity.py +++ /dev/null @@ -1,357 +0,0 @@ -import json -import os -from pathlib import Path - -import numpy as np - -try: - from vikingdb import VikingDB, IAM, EmbeddingClient - from vikingdb.vector import EmbeddingData, EmbeddingModelOpt, EmbeddingRequest -except ImportError as exc: # pragma: no cover - raise SystemExit( - "Missing dependency: vikingdb-python-sdk.\n" - "Install it with: py -3 -m pip install vikingdb-python-sdk" - ) from exc - - -def zh(value: str) -> str: - return value.encode("utf-8").decode("unicode_escape") - - -BUILD_TAGS = [ - { - "label": zh(r"\u5feb\u5251"), - "aliases": ["duelist", "swift blade", "swiftblade", zh(r"\u5251\u5feb"), zh(r"\u5feb\u5203")], - "description": zh(r"\u4ee5\u9ad8\u901f\u8f7b\u5175\u5668\u3001\u8fde\u7eed\u51fa\u624b\u548c\u8d34\u8eab\u538b\u8feb\u4e3a\u6838\u5fc3\u7684\u8fd1\u6218\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u8fde\u6bb5"), - "aliases": ["combo", "chain", zh(r"\u8fde\u51fb")], - "description": zh(r"\u4f9d\u8d56\u8fde\u7eed\u547d\u4e2d\u4e0e\u591a\u6bb5\u8282\u594f\u538b\u5236\u7684\u8f93\u51fa\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u7a81\u8fdb"), - "aliases": ["dash", "lunge", "mobility engage"], - "description": zh(r"\u5f3a\u8c03\u5feb\u901f\u8d34\u8fd1\u76ee\u6807\u3001\u62a2\u5360\u8eab\u4f4d\u548c\u5148\u624b\u5207\u5165\u7684\u6218\u6597\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u8ffd\u51fb"), - "aliases": ["chase", "follow-up", "finisher chase"], - "description": zh(r"\u64c5\u957f\u5728\u5bf9\u624b\u5931\u8861\u6216\u88ab\u51fb\u9000\u540e\u7ee7\u7eed\u8ffd\u6253\u7684\u6218\u6597\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u5feb\u88ad"), - "aliases": ["assassin", "rogue", "ambush", zh(r"\u523a\u51fb")], - "description": zh(r"\u5f3a\u8c03\u77ed\u65f6\u5207\u5165\u3001\u70b9\u6740\u5f31\u70b9\u548c\u8fc5\u901f\u8131\u79bb\u7684\u523a\u51fb\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u8fdc\u5c04"), - "aliases": ["projectile", "ranged", "arrow", zh(r"\u5c04\u51fb")], - "description": zh(r"\u4ee5\u6295\u5c04\u7269\u3001\u4e2d\u8fdc\u8ddd\u79bb\u7275\u5236\u548c\u5b89\u5168\u8f93\u51fa\u4e3a\u6838\u5fc3\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u6e38\u51fb"), - "aliases": ["scout", "skirmish", "harass", "fieldcraft"], - "description": zh(r"\u5f3a\u8c03\u8fb9\u79fb\u52a8\u8fb9\u8f93\u51fa\u3001\u8bd5\u63a2\u62c9\u626f\u548c\u62e9\u673a\u518d\u5165\u573a\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u673a\u52a8"), - "aliases": ["mobility", "nimble", "agile"], - "description": zh(r"\u4ee3\u8868\u9ad8\u4f4d\u79fb\u3001\u9ad8\u8eab\u6cd5\u548c\u5feb\u901f\u6362\u4f4d\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u98ce\u884c"), - "aliases": ["wind", "gust", "speed", zh(r"\u75be\u884c")], - "description": zh(r"\u5f3a\u8c03\u8f7b\u7075\u6b65\u6cd5\u3001\u79fb\u901f\u4f18\u52bf\u548c\u8fc5\u901f\u8c03\u4f4d\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u91cd\u51fb"), - "aliases": ["heavy", "slam", "mighty", "crush"], - "description": zh(r"\u5f3a\u8c03\u539a\u91cd\u6253\u51fb\u3001\u5355\u6b21\u9ad8\u538b\u8f93\u51fa\u548c\u6b63\u9762\u7838\u7a7f\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u7206\u53d1"), - "aliases": ["burst", "nova", "sudden damage"], - "description": zh(r"\u4ee3\u8868\u77ed\u7a97\u53e3\u5185\u8fc5\u901f\u62ac\u9ad8\u4f24\u5bb3\u5cf0\u503c\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u7834\u7532"), - "aliases": ["breaker", "armor break", "shatter"], - "description": zh(r"\u64c5\u957f\u6495\u5f00\u9632\u5fa1\u3001\u6253\u65ad\u5b88\u52bf\u548c\u9488\u5bf9\u786c\u76ee\u6807\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u538b\u5236"), - "aliases": ["tempo", "pressure", "control offense"], - "description": zh(r"\u901a\u8fc7\u6301\u7eed\u4e3b\u52a8\u8fdb\u653b\u4e0e\u8282\u594f\u5360\u4f18\u8feb\u4f7f\u5bf9\u624b\u5931\u8bef\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u538b\u8840"), - "aliases": ["low hp", "berserk", "risk damage"], - "description": zh(r"\u4ee5\u5192\u9669\u538b\u4f4e\u8840\u7ebf\u6362\u53d6\u66f4\u5f3a\u653b\u51fb\u6027\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u5b88\u5fa1"), - "aliases": ["ward", "guard", "protector", "defense"], - "description": zh(r"\u5f3a\u8c03\u51cf\u4f24\u3001\u7a33\u5b88\u548c\u9876\u4f4f\u6b63\u9762\u4f24\u5bb3\u7684\u9632\u5fa1\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u62a4\u4f53"), - "aliases": ["barrier", "shielding", "spirit guard", "spirit"], - "description": zh(r"\u504f\u5411\u62a4\u7f69\u3001\u62a4\u8eab\u6c14\u52b2\u548c\u72b6\u6001\u6297\u538b\u7684\u9632\u5fa1\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u91cd\u7532"), - "aliases": ["tank", "heavy armor", "iron wall"], - "description": zh(r"\u4ee3\u8868\u9ad8\u786c\u5ea6\u62a4\u7532\u3001\u6b63\u9762\u627f\u4f24\u4e0e\u7a33\u5b9a\u7ad9\u573a\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u53cd\u51fb"), - "aliases": ["counter", "riposte", "retaliate"], - "description": zh(r"\u901a\u8fc7\u683c\u6321\u3001\u7ad9\u6869\u4e0e\u540e\u624b\u60e9\u7f5a\u5f62\u6210\u6536\u76ca\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u9547\u90aa"), - "aliases": ["banish", "holy ward", "warding seal"], - "description": zh(r"\u64c5\u957f\u538b\u5236\u90aa\u795f\u3001\u5492\u715e\u548c\u5f02\u7c7b\u80fd\u91cf\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u6cd5\u4fee"), - "aliases": ["caster", "mage", "arcane", "spell"], - "description": zh(r"\u4ee5\u6cd5\u672f\u9a71\u52a8\u8f93\u51fa\u3001\u63a7\u5236\u548c\u8d44\u6e90\u8fd0\u8f6c\u7684\u6838\u5fc3\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u6cd5\u529b"), - "aliases": ["mana", "magic", "essence", "spirit power"], - "description": zh(r"\u56f4\u7ed5\u6cd5\u529b\u4e0a\u9650\u3001\u6cd5\u672f\u6d88\u8017\u4e0e\u6cd5\u80fd\u5faa\u73af\u6784\u7b51\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u96f7\u6cd5"), - "aliases": ["lightning", "thunder", "storm"], - "description": zh(r"\u4ee3\u8868\u9ad8\u538b\u96f7\u7cfb\u672f\u6cd5\u3001\u77ac\u65f6\u9707\u8361\u548c\u9ebb\u75f9\u538b\u5236\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u7b26\u9635"), - "aliases": ["sigil", "formation", "seal", "rune"], - "description": zh(r"\u901a\u8fc7\u7b26\u7bb4\u3001\u6cd5\u9635\u548c\u9884\u5e03\u7f6e\u6548\u679c\u6539\u53d8\u6218\u573a\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u63a7\u573a"), - "aliases": ["control", "crowd control", "lockdown"], - "description": zh(r"\u4ee5\u9650\u5236\u884c\u52a8\u3001\u5c01\u9501\u7a7a\u95f4\u548c\u538b\u7f29\u9009\u62e9\u4e3a\u6838\u5fc3\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u8fc7\u8f7d"), - "aliases": ["overload", "surge", "power spike"], - "description": zh(r"\u5728\u77ed\u65f6\u95f4\u5185\u63a8\u52a8\u9ad8\u6cd5\u8017\u4e0e\u9ad8\u5f3a\u5ea6\u91ca\u653e\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u56de\u590d"), - "aliases": ["heal", "healing", "recovery", "restore"], - "description": zh(r"\u5f3a\u8c03\u5373\u65f6\u6062\u590d\u4e0e\u6218\u540e\u7eed\u63a5\u80fd\u529b\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u62a4\u6301"), - "aliases": ["support", "aid", "blessing"], - "description": zh(r"\u901a\u8fc7\u589e\u76ca\u3001\u62ac\u7a33\u6001\u548c\u4fdd\u62a4\u961f\u53cb\u6765\u5efa\u7acb\u4f18\u52bf\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u7eed\u6218"), - "aliases": ["sustain", "endurance", "long fight"], - "description": zh(r"\u9762\u5411\u957f\u7ebf\u6218\u6597\u3001\u8d44\u6e90\u6301\u7eed\u4e0e\u5bb9\u9519\u63d0\u5347\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u547d\u7eb9"), - "aliases": ["fate", "omen", "destiny"], - "description": zh(r"\u56f4\u7ed5\u547d\u8fd0\u3001\u5370\u8bb0\u4e0e\u89e6\u53d1\u5f0f\u8fde\u9501\u6536\u76ca\u6784\u7b51\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u673a\u7f18"), - "aliases": ["fortune", "luck", "opportunity"], - "description": zh(r"\u4f9d\u8d56\u65f6\u673a\u3001\u8fd0\u52bf\u548c\u989d\u5916\u6536\u76ca\u89e6\u53d1\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u51b7\u5374"), - "aliases": ["cooldown", "cdr", "recharge"], - "description": zh(r"\u901a\u8fc7\u66f4\u5feb\u5468\u8f6c\u6280\u80fd\u4e0e\u9053\u5177\u6765\u6eda\u52a8\u4f18\u52bf\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u7edf\u5fa1"), - "aliases": ["commander", "command", "leader"], - "description": zh(r"\u5f3a\u8c03\u6574\u4f53\u534f\u8c03\u3001\u56e2\u961f\u6536\u76ca\u548c\u7efc\u5408\u8c03\u5ea6\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u5747\u8861"), - "aliases": ["balanced", "adaptable", "all-round"], - "description": zh(r"\u6ca1\u6709\u660e\u663e\u77ed\u677f\uff0c\u504f\u91cd\u4e2d\u540e\u671f\u7a33\u5b9a\u6210\u578b\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u5de5\u5de7"), - "aliases": ["craft", "artisan", "utility", "socket"], - "description": zh(r"\u504f\u5411\u5de5\u827a\u3001\u5668\u68b0\u3001\u9576\u5d4c\u548c\u8f85\u52a9\u6784\u7b51\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u70bc\u836f"), - "aliases": ["alchemy", "potion", "tonic"], - "description": zh(r"\u56f4\u7ed5\u836f\u5242\u3001\u4e34\u65f6\u5f3a\u5316\u548c\u6218\u4e2d\u8865\u7ed9\u7684\u5de5\u827a\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u5148\u950b"), - "aliases": ["vanguard", "frontline"], - "description": zh(r"\u4ee3\u8868\u961f\u4f0d\u4e2d\u7684\u6b63\u9762\u5f00\u8def\u3001\u5403\u7ebf\u4e0e\u538b\u524d\u6392\u804c\u8d23\u3002"), - }, - { - "label": zh(r"\u72c2\u6218"), - "aliases": ["berserker", "rage"], - "description": zh(r"\u4ee5\u8840\u91cf\u4ea4\u6362\u3001\u731b\u653b\u548c\u9ad8\u98ce\u9669\u9ad8\u56de\u62a5\u4e3a\u7279\u8272\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u6cd5\u5251"), - "aliases": ["spellblade", "bladecaster"], - "description": zh(r"\u878d\u5408\u5175\u5203\u4e0e\u672f\u6cd5\uff0c\u64c5\u957f\u4e2d\u8ddd\u79bb\u538b\u8feb\u7684\u6df7\u5408\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u5723\u4f51"), - "aliases": ["paladin", "holy guard"], - "description": zh(r"\u517c\u5177\u9632\u62a4\u3001\u56de\u590d\u548c\u60e9\u6212\u80fd\u529b\u7684\u795d\u798f\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u5821\u5792"), - "aliases": ["fortress", "bulwark"], - "description": zh(r"\u4ee5\u7a33\u5b9a\u7ad9\u573a\u3001\u786c\u6297\u4e0e\u53cd\u6253\u4e3a\u6838\u5fc3\u7684\u91cd\u9632\u5fa1\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u8d77\u624b"), - "aliases": ["starter", "legacy"], - "description": zh(r"\u504f\u8fc7\u6e21\u4e0e\u8d77\u6b65\u7528\u9014\u7684\u65e9\u671f\u6784\u7b51\u6807\u7b7e\u3002"), - }, -] - - -def build_prompt(definition: dict) -> str: - aliases = "\u3001".join(definition["aliases"]) - return f"{definition['label']}:{definition['description']} 别名:{aliases}。" - - -def load_env_file(path: Path, protected_keys: set[str]) -> None: - if not path.exists(): - return - - for raw_line in path.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - - key, value = line.split("=", 1) - key = key.strip() - if not key or key in protected_keys: - continue - - value = value.strip() - if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: - value = value[1:-1] - - os.environ[key] = value - - -def load_local_env() -> None: - root_dir = Path(__file__).resolve().parents[1] - protected_keys = set(os.environ) - - load_env_file(root_dir / ".env", protected_keys) - load_env_file(root_dir / ".env.local", protected_keys) - - -def create_embedding_client() -> EmbeddingClient: - access_key = os.getenv("VOLCENGINE_ACCESS_KEY_ID") or os.getenv("VIKINGDB_ACCESS_KEY_ID") - secret_key = os.getenv("VOLCENGINE_SECRET_ACCESS_KEY") or os.getenv("VIKINGDB_SECRET_ACCESS_KEY") - host = os.getenv("VIKINGDB_HOST", "api-vikingdb.vikingdb.cn-beijing.volces.com") - region = os.getenv("VIKINGDB_REGION", "cn-beijing") - - if not access_key or not secret_key: - raise SystemExit( - "Missing VikingDB credentials.\n" - "Required:\n" - " VOLCENGINE_ACCESS_KEY_ID\n" - " VOLCENGINE_SECRET_ACCESS_KEY\n" - "Optional:\n" - " VIKINGDB_HOST (default: api-vikingdb.vikingdb.cn-beijing.volces.com)\n" - " VIKINGDB_REGION (default: cn-beijing)\n" - ) - - service = VikingDB( - host=host, - region=region, - auth=IAM(ak=access_key, sk=secret_key), - ) - return EmbeddingClient(service) - - -def encode_texts(client: EmbeddingClient, texts: list[str]) -> np.ndarray: - request = EmbeddingRequest( - data=[EmbeddingData(text=text) for text in texts], - dense_model=EmbeddingModelOpt(name="bge-large-zh"), - ) - response = client.embedding(request) - result = getattr(response, "result", None) - data = getattr(result, "data", None) if result is not None else None - if data is None and isinstance(result, dict): - data = result.get("data") - if data is None: - data = getattr(response, "data", None) - - if data is None: - raise ValueError("Embedding response did not include any data entries.") - - embeddings: list[list[float]] = [] - for item in data: - dense = getattr(item, "dense", None) - if dense is None and isinstance(item, dict): - dense = item.get("dense") - if dense is None: - dense = getattr(item, "embedding", None) - if dense is None and isinstance(item, dict): - dense = item.get("embedding") - if dense is None: - raise ValueError("Embedding response item did not include a dense vector.") - embeddings.append(dense) - - matrix = np.array(embeddings, dtype=np.float32) - norms = np.linalg.norm(matrix, axis=1, keepdims=True) - norms[norms == 0] = 1.0 - return matrix / norms - - -def main(): - load_local_env() - client = create_embedding_client() - prompts = [build_prompt(definition) for definition in BUILD_TAGS] - embeddings = encode_texts(client, prompts) - - threshold = 0.35 - pairs: list[tuple[str, str, float]] = [] - for index, left in enumerate(BUILD_TAGS): - for other_index in range(index + 1, len(BUILD_TAGS)): - right = BUILD_TAGS[other_index] - similarity = float(np.dot(embeddings[index], embeddings[other_index])) - if similarity < threshold: - continue - pairs.append((left["label"], right["label"], round(similarity, 4))) - - output_path = Path(__file__).resolve().parents[1] / "src" / "data" / "buildTagSimilarity.generated.ts" - lines = [ - "export const BUILD_TAG_SIMILARITY_PAIRS: Array = [" - ] - for left, right, similarity in pairs: - lines.append(f" ['{left}', '{right}', {similarity}],") - lines.append("] as const;") - output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") - - print(json.dumps({ - "output": str(output_path), - "pair_count": len(pairs), - "model": "bge-large-zh", - }, ensure_ascii=False)) - - -if __name__ == "__main__": - main() diff --git a/server-node/src/app.ts b/server-node/src/app.ts index e716912c..2227b813 100644 --- a/server-node/src/app.ts +++ b/server-node/src/app.ts @@ -8,7 +8,6 @@ import { errorHandler } from './middleware/errorHandler.js'; import { requestIdMiddleware } from './middleware/requestId.js'; import { responseEnvelopeMiddleware } from './middleware/responseEnvelope.js'; import { createCharacterAssetRoutes } from './modules/assets/characterAssetRoutes.js'; -import { createQwenSpriteRoutes } from './modules/assets/qwenSpriteRoutes.js'; import { createEditorRoutes } from './modules/editor/editorRoutes.js'; import { createStoryActionRoutes } from './modules/story/storyActionRoutes.js'; import { createAuthRoutes } from './routes/authRoutes.js'; @@ -119,18 +118,6 @@ export function createApp(context: AppContext) { createCharacterAssetRoutes(context.config, context.llmClient), ), ); - app.use( - scopeToPrefixes( - ['/api/assets/qwen-sprite'], - withRouteMeta({ routeVersion: '2026-04-08', operation: 'assets.qwen' }), - ), - ); - app.use( - scopeToPrefixes( - ['/api/assets/qwen-sprite'], - createQwenSpriteRoutes(context.config), - ), - ); app.use( '/api/auth', withRouteMeta({ routeVersion: '2026-04-08' }), diff --git a/server-node/src/context.ts b/server-node/src/context.ts index 8f195f8e..e978ac56 100644 --- a/server-node/src/context.ts +++ b/server-node/src/context.ts @@ -12,7 +12,6 @@ import { UserSessionRepository } from './repositories/userSessionRepository.js'; import { CaptchaChallengeStore } from './services/captchaChallengeStore.js'; import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js'; import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js'; -import { CustomWorldSessionStore } from './services/customWorldSessionStore.js'; import { UpstreamLlmClient } from './services/llmClient.js'; import type { SmsVerificationService } from './services/smsVerificationService.js'; import type { WechatAuthService } from './services/wechatAuthService.js'; @@ -30,7 +29,6 @@ export type AppContext = { userSessionRepository: UserSessionRepository; runtimeRepository: RuntimeRepository; llmClient: UpstreamLlmClient; - customWorldSessions: CustomWorldSessionStore; customWorldAgentSessions: CustomWorldAgentSessionStore; customWorldAgentOrchestrator: CustomWorldAgentOrchestrator; smsVerificationService: SmsVerificationService; diff --git a/server-node/src/modules/assets/qwenSpriteRoutes.ts b/server-node/src/modules/assets/qwenSpriteRoutes.ts deleted file mode 100644 index 4053e78d..00000000 --- a/server-node/src/modules/assets/qwenSpriteRoutes.ts +++ /dev/null @@ -1,907 +0,0 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import http, { - type IncomingMessage, - type RequestOptions, - type ServerResponse, -} from 'node:http'; -import https from 'node:https'; -import path from 'node:path'; - -import { Router, type NextFunction, type Request, type Response } from 'express'; -import type { AppConfig } from '../../config.js'; - -const QWEN_SPRITE_MASTER_GENERATE_PATH = '/api/assets/qwen-sprite/master'; -const QWEN_SPRITE_SHEET_GENERATE_PATH = '/api/assets/qwen-sprite/sheet'; -const QWEN_SPRITE_FRAME_REPAIR_PATH = '/api/assets/qwen-sprite/frame-repair'; -const QWEN_SPRITE_SAVE_PATH = '/api/assets/qwen-sprite/save'; -const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1'; -const DEFAULT_QWEN_IMAGE_MODEL = 'qwen-image-2.0'; - -function readJsonBody(req: IncomingMessage & { body?: unknown }) { - const parsedBody = req.body; - if (parsedBody && typeof parsedBody === 'object' && !Array.isArray(parsedBody)) { - return Promise.resolve(parsedBody as Record); - } - - return new Promise>((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - req.on('end', () => { - try { - const raw = - Buffer.concat(chunks) - .toString('utf8') - .replace(/^\uFEFF/u, '') || '{}'; - resolve(JSON.parse(raw)); - } catch (error) { - reject(error); - } - }); - req.on('error', reject); - }); -} - -function sendJson(res: ServerResponse, statusCode: number, payload: unknown) { - res.statusCode = statusCode; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify(payload)); -} - -function isRecordValue(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function isStringArray(value: unknown): value is string[] { - return ( - Array.isArray(value) && - value.every((item) => typeof item === 'string' && item.trim().length > 0) - ); -} - -function resolveRuntimeEnv(config: AppConfig) { - return config.rawEnv; -} - -function normalizeDashScopeBaseUrl(value: string) { - return value.replace(/\/$/u, ''); -} - -function extractApiErrorMessage(responseText: string, fallbackMessage: string) { - if (!responseText.trim()) { - return fallbackMessage; - } - - try { - const parsed = JSON.parse(responseText) as { - code?: string; - message?: string; - error?: { message?: string }; - }; - if ( - typeof parsed.error?.message === 'string' && - parsed.error.message.trim() - ) { - return parsed.error.message; - } - if (typeof parsed.message === 'string' && parsed.message.trim()) { - return parsed.message; - } - if (typeof parsed.code === 'string' && parsed.code.trim()) { - return `${fallbackMessage} (${parsed.code})`; - } - } catch { - // Fall through to raw text. - } - - return responseText; -} - -function sanitizePathSegment(value: string) { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9-_]+/gu, '-') - .replace(/-+/gu, '-') - .replace(/^-|-$/gu, ''); - - return normalized || 'asset'; -} - -function createTimestampId(prefix: string) { - return `${prefix}-${Date.now()}`; -} - -function requestTextResponse( - urlString: string, - options: { - method?: string; - headers?: Record; - bodyText?: string; - } = {}, -) { - return new Promise<{ - statusCode: number; - headers: Record; - bodyText: string; - }>((resolve, reject) => { - const url = new URL(urlString); - const transport = url.protocol === 'https:' ? https : http; - const payload = options.bodyText; - const requestOptions: RequestOptions = { - protocol: url.protocol, - hostname: url.hostname, - port: url.port ? Number(url.port) : undefined, - path: `${url.pathname}${url.search}`, - method: options.method ?? 'GET', - headers: { - ...(options.headers ?? {}), - ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}), - }, - }; - - const request = transport.request(requestOptions, (upstreamRes) => { - const chunks: Buffer[] = []; - upstreamRes.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - upstreamRes.on('end', () => { - resolve({ - statusCode: upstreamRes.statusCode ?? 502, - headers: upstreamRes.headers, - bodyText: Buffer.concat(chunks).toString('utf8'), - }); - }); - upstreamRes.on('error', reject); - }); - - request.on('error', reject); - if (payload) { - request.write(payload); - } - request.end(); - }); -} - -function requestBinaryResponse( - urlString: string, - options: { - method?: string; - headers?: Record; - } = {}, -) { - return new Promise<{ - statusCode: number; - headers: Record; - body: Buffer; - }>((resolve, reject) => { - const url = new URL(urlString); - const transport = url.protocol === 'https:' ? https : http; - const requestOptions: RequestOptions = { - protocol: url.protocol, - hostname: url.hostname, - port: url.port ? Number(url.port) : undefined, - path: `${url.pathname}${url.search}`, - method: options.method ?? 'GET', - headers: options.headers ?? {}, - }; - - const request = transport.request(requestOptions, (upstreamRes) => { - const chunks: Buffer[] = []; - upstreamRes.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - upstreamRes.on('end', () => { - resolve({ - statusCode: upstreamRes.statusCode ?? 502, - headers: upstreamRes.headers, - body: Buffer.concat(chunks), - }); - }); - upstreamRes.on('error', reject); - }); - - request.on('error', reject); - request.end(); - }); -} - -function proxyJsonRequest( - urlString: string, - apiKey: string, - body: Record, -) { - return requestTextResponse(urlString, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - bodyText: JSON.stringify(body), - }); -} - -function collectStringsByKey( - value: unknown, - targetKey: string, - results: string[], -) { - if (Array.isArray(value)) { - value.forEach((item) => collectStringsByKey(item, targetKey, results)); - return; - } - - if (!isRecordValue(value)) { - return; - } - - const directValue = value[targetKey]; - if (typeof directValue === 'string' && directValue.trim()) { - results.push(directValue.trim()); - } - - Object.values(value).forEach((nestedValue) => - collectStringsByKey(nestedValue, targetKey, results), - ); -} - -function extractImageUrls(payload: Record) { - const results: string[] = []; - collectStringsByKey(payload.output, 'image', results); - collectStringsByKey(payload.output, 'url', results); - return [...new Set(results)]; -} - -function parseDataUrl(source: string) { - const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source); - if (!matched) { - return null; - } - - const mimeType = matched[1]; - const base64Payload = matched[2]; - const extension = (() => { - switch (mimeType) { - case 'image/jpeg': - return 'jpg'; - case 'image/webp': - return 'webp'; - default: - return 'png'; - } - })(); - - return { - buffer: Buffer.from(base64Payload, 'base64'), - extension, - }; -} - -async function resolveImageSourcePayload(rootDir: string, source: string) { - const parsedDataUrl = parseDataUrl(source); - if (parsedDataUrl) { - return parsedDataUrl; - } - - if (!source.startsWith('/')) { - throw new Error('图像来源必须是 Data URL 或 public 目录 URL。'); - } - - const normalizedSource = path.posix.normalize(source).replace(/^\/+/u, ''); - const absolutePath = path.resolve( - rootDir, - 'public', - ...normalizedSource.split('/'), - ); - const publicRoot = path.resolve(rootDir, 'public'); - - if (!absolutePath.startsWith(publicRoot)) { - throw new Error('图像来源路径越界。'); - } - - const buffer = await readFile(absolutePath); - const extension = path.extname(absolutePath).replace(/^\./u, '') || 'png'; - - return { - buffer, - extension, - }; -} - -async function resolveImageSourceAsDataUrl(rootDir: string, source: string) { - if (/^data:image\/[^;]+;base64,/u.test(source)) { - return source; - } - - const payload = await resolveImageSourcePayload(rootDir, source); - const mimeType = (() => { - switch (payload.extension) { - case 'jpg': - case 'jpeg': - return 'image/jpeg'; - case 'webp': - return 'image/webp'; - default: - return 'image/png'; - } - })(); - - return `data:${mimeType};base64,${payload.buffer.toString('base64')}`; -} - -async function writeDraftImageFile( - rootDir: string, - relativePath: string, - buffer: Buffer, -) { - const absolutePath = path.resolve(rootDir, 'public', ...relativePath.split('/')); - await mkdir(path.dirname(absolutePath), { recursive: true }); - await writeFile(absolutePath, buffer); - return `/${relativePath}`; -} - -async function generateQwenImages( - config: AppConfig, - input: { - kind: 'master' | 'sheet' | 'repair'; - promptText: string; - negativePrompt: string; - model: string; - size: string; - promptExtend: boolean; - seed?: number; - candidateCount: number; - referenceImages: string[]; - }, -) { - const rootDir = config.projectRoot; - const runtimeEnv = resolveRuntimeEnv(config); - const baseUrl = normalizeDashScopeBaseUrl( - runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, - ); - const apiKey = runtimeEnv.DASHSCOPE_API_KEY || ''; - - if (!apiKey) { - throw new Error('服务端缺少 DASHSCOPE_API_KEY,无法调用 Qwen-Image。'); - } - - const content = [ - ...(await Promise.all( - input.referenceImages - .slice(0, 3) - .map(async (image) => ({ image: await resolveImageSourceAsDataUrl(rootDir, image) })), - )), - { text: input.promptText }, - ]; - - const requestPayload: Record = { - model: input.model || DEFAULT_QWEN_IMAGE_MODEL, - input: { - messages: [ - { - role: 'user', - content, - }, - ], - }, - parameters: { - n: Math.max(1, Math.min(6, input.candidateCount)), - negative_prompt: input.negativePrompt, - prompt_extend: input.promptExtend, - watermark: false, - size: input.size, - ...(typeof input.seed === 'number' && Number.isFinite(input.seed) - ? { seed: input.seed } - : {}), - }, - }; - - const response = await proxyJsonRequest( - `${baseUrl}/services/aigc/multimodal-generation/generation`, - apiKey, - requestPayload, - ); - - if (response.statusCode < 200 || response.statusCode >= 300) { - throw new Error( - extractApiErrorMessage(response.bodyText, 'Qwen-Image 生成失败。'), - ); - } - - const parsed = JSON.parse(response.bodyText) as Record; - const imageUrls = extractImageUrls(parsed); - - if (imageUrls.length === 0) { - throw new Error('Qwen-Image 未返回可下载的图片结果。'); - } - - const draftId = createTimestampId(`qwen-${input.kind}`); - const relativeDir = path.posix.join( - 'generated-qwen-sprites', - '_drafts', - input.kind, - draftId, - ); - - const drafts = await Promise.all( - imageUrls.map(async (imageUrl, index) => { - const binaryResponse = await requestBinaryResponse(imageUrl); - if ( - binaryResponse.statusCode < 200 || - binaryResponse.statusCode >= 300 - ) { - throw new Error(`下载生成图片失败(${binaryResponse.statusCode})。`); - } - - const imageSrc = await writeDraftImageFile( - rootDir, - path.posix.join(relativeDir, `candidate-${String(index + 1).padStart(2, '0')}.png`), - binaryResponse.body, - ); - - return { - id: `${draftId}-${index + 1}`, - label: `${input.kind === 'master' ? '主图' : input.kind === 'sheet' ? '精灵表' : '修帧'} ${index + 1}`, - imageSrc, - remoteUrl: imageUrl, - }; - }), - ); - - await writeFile( - path.resolve(rootDir, 'public', ...relativeDir.split('/'), 'job.json'), - JSON.stringify( - { - draftId, - kind: input.kind, - model: input.model, - size: input.size, - promptText: input.promptText, - negativePrompt: input.negativePrompt, - promptExtend: input.promptExtend, - seed: input.seed, - candidateCount: input.candidateCount, - referenceImageCount: input.referenceImages.length, - drafts, - createdAt: new Date().toISOString(), - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - return { - draftId, - drafts, - model: input.model, - size: input.size, - promptText: input.promptText, - negativePrompt: input.negativePrompt, - }; -} - -async function handleGenerateMaster( - config: AppConfig, - req: IncomingMessage & { body?: unknown }, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const promptText = - typeof body.promptText === 'string' ? body.promptText.trim() : ''; - const negativePrompt = - typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : ''; - const model = - typeof body.model === 'string' && body.model.trim() - ? body.model.trim() - : DEFAULT_QWEN_IMAGE_MODEL; - const size = - typeof body.size === 'string' && body.size.trim() - ? body.size.trim() - : '1024*1024'; - const promptExtend = body.promptExtend !== false; - const candidateCount = - typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount) - ? body.candidateCount - : 1; - const seed = - typeof body.seed === 'number' && Number.isFinite(body.seed) - ? body.seed - : undefined; - const referenceImages = isStringArray(body.referenceImages) - ? body.referenceImages - : []; - - if (!promptText) { - sendJson(res, 400, { error: { message: 'promptText is required.' } }); - return; - } - - try { - const result = await generateQwenImages(config, { - kind: 'master', - promptText, - negativePrompt, - model, - size, - promptExtend, - seed, - candidateCount, - referenceImages, - }); - - sendJson(res, 200, { - ok: true, - ...result, - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : '生成主图失败。', - }, - }); - } -} - -async function handleGenerateSheet( - config: AppConfig, - req: IncomingMessage & { body?: unknown }, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const promptText = - typeof body.promptText === 'string' ? body.promptText.trim() : ''; - const negativePrompt = - typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : ''; - const model = - typeof body.model === 'string' && body.model.trim() - ? body.model.trim() - : DEFAULT_QWEN_IMAGE_MODEL; - const size = - typeof body.size === 'string' && body.size.trim() - ? body.size.trim() - : '1024*1024'; - const promptExtend = body.promptExtend !== false; - const candidateCount = - typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount) - ? body.candidateCount - : 1; - const seed = - typeof body.seed === 'number' && Number.isFinite(body.seed) - ? body.seed - : undefined; - const referenceImages = isStringArray(body.referenceImages) - ? body.referenceImages - : []; - - if (!promptText) { - sendJson(res, 400, { error: { message: 'promptText is required.' } }); - return; - } - - try { - const result = await generateQwenImages(config, { - kind: 'sheet', - promptText, - negativePrompt, - model, - size, - promptExtend, - seed, - candidateCount, - referenceImages, - }); - - sendJson(res, 200, { - ok: true, - ...result, - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : '生成精灵表失败。', - }, - }); - } -} - -async function handleRepairFrame( - config: AppConfig, - req: IncomingMessage & { body?: unknown }, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const promptText = - typeof body.promptText === 'string' ? body.promptText.trim() : ''; - const negativePrompt = - typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : ''; - const model = - typeof body.model === 'string' && body.model.trim() - ? body.model.trim() - : DEFAULT_QWEN_IMAGE_MODEL; - const size = - typeof body.size === 'string' && body.size.trim() - ? body.size.trim() - : '512*512'; - const promptExtend = body.promptExtend !== false; - const seed = - typeof body.seed === 'number' && Number.isFinite(body.seed) - ? body.seed - : undefined; - const referenceImages = isStringArray(body.referenceImages) - ? body.referenceImages - : []; - - if (!promptText) { - sendJson(res, 400, { error: { message: 'promptText is required.' } }); - return; - } - - if (referenceImages.length === 0) { - sendJson(res, 400, { - error: { message: '至少需要一张参考图来修复帧。' }, - }); - return; - } - - try { - const result = await generateQwenImages(config, { - kind: 'repair', - promptText, - negativePrompt, - model, - size, - promptExtend, - seed, - candidateCount: 1, - referenceImages, - }); - - sendJson(res, 200, { - ok: true, - ...result, - repairedFrame: result.drafts[0] ?? null, - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : '修帧失败。', - }, - }); - } -} - -async function handleSaveAsset( - rootDir: string, - req: IncomingMessage & { body?: unknown }, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const assetKey = - typeof body.assetKey === 'string' ? sanitizePathSegment(body.assetKey) : ''; - const actionKey = - typeof body.actionKey === 'string' ? sanitizePathSegment(body.actionKey) : ''; - const masterSource = - typeof body.masterSource === 'string' ? body.masterSource.trim() : ''; - const sheetSource = - typeof body.sheetSource === 'string' ? body.sheetSource.trim() : ''; - const framesDataUrls = isStringArray(body.framesDataUrls) - ? body.framesDataUrls - : []; - const metadata = isRecordValue(body.metadata) ? body.metadata : {}; - const prompts = isRecordValue(body.prompts) ? body.prompts : {}; - - if (!assetKey) { - sendJson(res, 400, { error: { message: 'assetKey is required.' } }); - return; - } - - if (!actionKey) { - sendJson(res, 400, { error: { message: 'actionKey is required.' } }); - return; - } - - if (!sheetSource) { - sendJson(res, 400, { error: { message: 'sheetSource is required.' } }); - return; - } - - try { - const assetId = createTimestampId('qwen-sprite'); - const relativeDir = path.posix.join( - 'generated-qwen-sprites', - assetKey, - actionKey, - assetId, - ); - const absoluteDir = path.resolve(rootDir, 'public', ...relativeDir.split('/')); - await mkdir(path.join(absoluteDir, 'frames'), { recursive: true }); - - let masterImagePath: string | null = null; - if (masterSource) { - const payload = await resolveImageSourcePayload(rootDir, masterSource); - masterImagePath = await writeDraftImageFile( - rootDir, - path.posix.join(relativeDir, `master.${payload.extension}`), - payload.buffer, - ); - } - - const sheetPayload = await resolveImageSourcePayload(rootDir, sheetSource); - const sheetImagePath = await writeDraftImageFile( - rootDir, - path.posix.join(relativeDir, `sheet.${sheetPayload.extension}`), - sheetPayload.buffer, - ); - - const framePaths: string[] = []; - for (let index = 0; index < framesDataUrls.length; index += 1) { - const framePayload = await resolveImageSourcePayload( - rootDir, - framesDataUrls[index] ?? '', - ); - const framePath = await writeDraftImageFile( - rootDir, - path.posix.join( - relativeDir, - 'frames', - `frame-${String(index + 1).padStart(2, '0')}.${framePayload.extension}`, - ), - framePayload.buffer, - ); - framePaths.push(framePath); - } - - await writeFile( - path.join(absoluteDir, 'metadata.json'), - JSON.stringify( - { - assetId, - assetKey, - actionKey, - masterImagePath, - sheetImagePath, - framePaths, - metadata, - prompts, - createdAt: new Date().toISOString(), - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - sendJson(res, 200, { - ok: true, - assetId, - assetDir: `/${relativeDir}`, - masterImagePath, - sheetImagePath, - framePaths, - saveMessage: '已保存到 public/generated-qwen-sprites。', - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : '保存精灵表资产失败。', - }, - }); - } -} - -function toExpressHandler( - handler: ( - request: IncomingMessage & { body?: unknown }, - response: ServerResponse, - ) => Promise | void, -) { - return (request: Request, response: Response, next: NextFunction) => { - Promise.resolve( - handler( - request as Request & IncomingMessage & { body?: unknown }, - response as Response & ServerResponse, - ), - ).catch(next); - }; -} - -export function createQwenSpriteRoutes(config: AppConfig) { - const router = Router(); - - router.use((request, response, next) => { - if ( - request.path !== '/api/assets' && - !request.path.startsWith('/api/assets/') - ) { - next(); - return; - } - - if (!config.assetsApiEnabled) { - response.status(403).json({ - error: { - message: '资产工具接口当前未启用。', - }, - }); - return; - } - next(); - }); - - router.use( - QWEN_SPRITE_MASTER_GENERATE_PATH, - toExpressHandler((request, response) => - handleGenerateMaster(config, request, response), - ), - ); - router.use( - QWEN_SPRITE_SHEET_GENERATE_PATH, - toExpressHandler((request, response) => - handleGenerateSheet(config, request, response), - ), - ); - router.use( - QWEN_SPRITE_FRAME_REPAIR_PATH, - toExpressHandler((request, response) => - handleRepairFrame(config, request, response), - ), - ); - router.use( - QWEN_SPRITE_SAVE_PATH, - toExpressHandler((request, response) => - handleSaveAsset(config.projectRoot, request, response), - ), - ); - - return router; -} diff --git a/server-node/src/routes/runtimeRoutes.ts b/server-node/src/routes/runtimeRoutes.ts index 75b8dc27..b67a2538 100644 --- a/server-node/src/routes/runtimeRoutes.ts +++ b/server-node/src/routes/runtimeRoutes.ts @@ -3,8 +3,6 @@ import { z } from 'zod'; import type { ListCustomWorldWorksResponse } from '../../../packages/shared/src/contracts/customWorldAgent.js'; import type { - AnswerCustomWorldSessionQuestionRequest, - CreateCustomWorldSessionRequest, CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryMutationResponse, @@ -21,7 +19,6 @@ import type { SavedGameSnapshotInput, } from '../../../packages/shared/src/contracts/runtime.js'; import { - CUSTOM_WORLD_GENERATION_MODES, PLATFORM_THEMES, } from '../../../packages/shared/src/contracts/runtime.js'; import type { @@ -41,7 +38,6 @@ import { badRequest, notFound } from '../errors.js'; import { asyncHandler, jsonClone, - prepareEventStreamResponse, sendApiResponse, } from '../http.js'; import { requireJwtAuth } from '../middleware/auth.js'; @@ -67,7 +63,6 @@ import { npcRecruitDialogueRequestSchema, } from '../services/chatService.js'; import { generateCustomWorldEntity } from '../services/customWorldEntityGenerationService.js'; -import { generateCustomWorldProfile } from '../services/customWorldGenerationService.js'; import { generateSceneNpcForLandmark } from '../services/customWorldSceneNpcGenerationService.js'; import { listCustomWorldWorkSummaries } from '../services/customWorldWorkSummaryService.js'; import { generateQuestForNpcEncounter } from '../services/questService.js'; @@ -133,17 +128,6 @@ const customWorldEntitySchema = z.object({ kind: z.enum(['playable', 'story', 'landmark']), }); -const customWorldSessionSchema = z.object({ - settingText: z.string().trim().min(1), - creatorIntent: jsonObjectSchema.nullable().optional().default(null), - generationMode: z.enum(CUSTOM_WORLD_GENERATION_MODES).default('fast'), -}); - -const customWorldAnswerSchema = z.object({ - questionId: z.string().trim().min(1), - answer: z.string().trim().min(1), -}); - const runtimeItemIntentSchema = z.object({ context: jsonObjectSchema, plans: z.array(jsonObjectSchema), @@ -792,128 +776,6 @@ export function createRuntimeRoutes(context: AppContext) { }), ); - router.post( - '/runtime/custom-world/sessions', - routeMeta({ operation: 'runtime.customWorldSession.create' }), - asyncHandler(async (request, response) => { - const payload = customWorldSessionSchema.parse( - request.body, - ) as CreateCustomWorldSessionRequest; - sendApiResponse( - response, - await context.customWorldSessions.create( - request.userId!, - payload.settingText, - payload.creatorIntent, - payload.generationMode, - ), - ); - }), - ); - - router.get( - '/runtime/custom-world/sessions/:sessionId', - routeMeta({ operation: 'runtime.customWorldSession.get' }), - asyncHandler(async (request, response) => { - const session = await context.customWorldSessions.get( - request.userId!, - readParam(request.params.sessionId), - ); - if (!session) { - throw notFound('custom world session not found'); - } - sendApiResponse(response, session); - }), - ); - - router.post( - '/runtime/custom-world/sessions/:sessionId/answers', - routeMeta({ operation: 'runtime.customWorldSession.answer' }), - asyncHandler(async (request, response) => { - const payload = customWorldAnswerSchema.parse( - request.body, - ) as AnswerCustomWorldSessionQuestionRequest; - const session = await context.customWorldSessions.answer( - request.userId!, - readParam(request.params.sessionId), - payload.questionId, - payload.answer, - ); - if (!session) { - throw notFound('custom world session not found'); - } - sendApiResponse(response, session); - }), - ); - - router.get( - '/runtime/custom-world/sessions/:sessionId/generate/stream', - routeMeta({ operation: 'runtime.customWorldSession.generateStream' }), - asyncHandler(async (request, response) => { - const session = await context.customWorldSessions.get( - request.userId!, - readParam(request.params.sessionId), - ); - if (!session) { - throw notFound('custom world session not found'); - } - - prepareEventStreamResponse(request, response); - const controller = new AbortController(); - - request.on('close', () => { - controller.abort(); - }); - - const writeEvent = (event: string, payload: Record) => { - response.write(`event: ${event}\n`); - response.write(`data: ${JSON.stringify(payload)}\n\n`); - }; - - writeEvent('progress', { phase: 'preparing', progress: 10 }); - await context.customWorldSessions.updateStatus( - request.userId!, - readParam(request.params.sessionId), - 'generating', - ); - writeEvent('progress', { phase: 'requesting_llm', progress: 45 }); - - try { - const profile = await generateCustomWorldProfile(context, session, { - signal: controller.signal, - onProgress: (progress) => { - writeEvent( - 'progress', - progress as unknown as Record, - ); - }, - }); - await context.customWorldSessions.setResult( - request.userId!, - readParam(request.params.sessionId), - profile, - ); - writeEvent('progress', { phase: 'completed', progress: 100 }); - writeEvent('result', { profile }); - writeEvent('done', { ok: true }); - } catch (error) { - const message = - error instanceof Error - ? error.message - : 'custom world generation failed'; - await context.customWorldSessions.updateStatus( - request.userId!, - readParam(request.params.sessionId), - 'generation_error', - message, - ); - writeEvent('error', { message }); - } finally { - response.end(); - } - }), - ); - router.post( '/runtime/items/runtime-intent', routeMeta({ operation: 'runtime.items.intent' }), diff --git a/server-node/src/server.ts b/server-node/src/server.ts index 3c303014..813da85a 100644 --- a/server-node/src/server.ts +++ b/server-node/src/server.ts @@ -16,7 +16,6 @@ import { CaptchaChallengeStore } from './services/captchaChallengeStore.js'; import { CustomWorldAgentAutoAssetService } from './services/customWorldAgentAutoAssetService.js'; import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js'; import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js'; -import { CustomWorldSessionStore } from './services/customWorldSessionStore.js'; import { UpstreamLlmClient } from './services/llmClient.js'; import { createSmsVerificationService } from './services/smsVerificationService.js'; import { createWechatAuthService } from './services/wechatAuthService.js'; @@ -113,7 +112,6 @@ export async function createAppContext(config: AppConfig = loadConfig()) { userSessionRepository: new UserSessionRepository(db), runtimeRepository, llmClient: new UpstreamLlmClient(config, logger), - customWorldSessions: new CustomWorldSessionStore(runtimeRepository), customWorldAgentSessions, customWorldAgentOrchestrator: new CustomWorldAgentOrchestrator( customWorldAgentSessions, diff --git a/server-node/src/services/customWorldGenerationService.ts b/server-node/src/services/customWorldGenerationService.ts deleted file mode 100644 index 0a657318..00000000 --- a/server-node/src/services/customWorldGenerationService.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { AppContext } from '../context.js'; -import { - type CustomWorldGenerationProgress, - generateCustomWorldProfileFromOrchestrator, - type GenerateCustomWorldProfileInput, -} from '../modules/ai/customWorldOrchestrator.js'; -import type { CustomWorldSession } from './customWorldSessionStore.js'; - -export async function generateCustomWorldProfile( - context: AppContext, - session: CustomWorldSession, - options: { - onProgress?: (progress: CustomWorldGenerationProgress) => void; - signal?: AbortSignal; - } = {}, -) { - const input = { - settingText: session.settingText, - creatorIntent: session.creatorIntent, - generationMode: session.generationMode, - } satisfies GenerateCustomWorldProfileInput; - - const profile = await generateCustomWorldProfileFromOrchestrator( - context.llmClient, - input, - { - onProgress: options.onProgress, - signal: options.signal, - }, - ); - - return JSON.parse(JSON.stringify(profile)) as Record; -} diff --git a/server-node/src/services/customWorldSessionStore.ts b/server-node/src/services/customWorldSessionStore.ts deleted file mode 100644 index 6fb99c6a..00000000 --- a/server-node/src/services/customWorldSessionStore.ts +++ /dev/null @@ -1,229 +0,0 @@ -import crypto from 'node:crypto'; - -import type { JsonObject } from '../../../packages/shared/src/contracts/common.js'; -import type { - CustomWorldGenerationMode, - CustomWorldQuestion, - CustomWorldSessionRecord, - CustomWorldSessionStatus, -} from '../../../packages/shared/src/contracts/runtime.js'; -import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; - -export type CustomWorldSession = { - sessionId: string; - status: CustomWorldSessionStatus; - settingText: string; - creatorIntent: JsonObject | null; - generationMode: CustomWorldGenerationMode; - questions: CustomWorldQuestion[]; - result?: JsonObject; - lastError?: string; - createdAt: string; - updatedAt: string; -}; - -function cloneSession(session: CustomWorldSession) { - return JSON.parse(JSON.stringify(session)) as CustomWorldSession; -} - -function toSessionRecord(session: CustomWorldSession): CustomWorldSessionRecord { - return { - sessionId: session.sessionId, - status: session.status, - settingText: session.settingText, - creatorIntent: session.creatorIntent, - generationMode: session.generationMode, - questions: session.questions, - result: session.result, - lastError: session.lastError, - createdAt: session.createdAt, - updatedAt: session.updatedAt, - }; -} - -function toSession(record: CustomWorldSessionRecord) { - return cloneSession({ - sessionId: record.sessionId, - status: record.status, - settingText: record.settingText, - creatorIntent: record.creatorIntent ?? null, - generationMode: record.generationMode, - questions: record.questions, - result: record.result, - lastError: record.lastError, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - }); -} - -function hasPendingQuestion(questions: CustomWorldQuestion[]) { - return questions.some((question) => !question.answer?.trim()); -} - -function buildClarificationQuestions( - settingText: string, - creatorIntent: JsonObject | null, -) { - const questions: CustomWorldQuestion[] = []; - const worldHook = - typeof creatorIntent?.worldHook === 'string' ? creatorIntent.worldHook.trim() : ''; - const playerPremise = - typeof creatorIntent?.playerPremise === 'string' ? creatorIntent.playerPremise.trim() : ''; - const openingSituation = - typeof creatorIntent?.openingSituation === 'string' - ? creatorIntent.openingSituation.trim() - : ''; - const coreConflicts = Array.isArray(creatorIntent?.coreConflicts) - ? creatorIntent.coreConflicts - : []; - - if (!worldHook && settingText.trim().length < 24) { - questions.push({ - id: 'world_hook', - label: '世界核心', - question: '请用一句话补充这个世界最核心的命题或独特卖点。', - }); - } - if (!playerPremise) { - questions.push({ - id: 'player_premise', - label: '玩家身份', - question: '玩家在这个世界里是什么身份、立场或来历?', - }); - } - if (!openingSituation) { - questions.push({ - id: 'opening_situation', - label: '开局处境', - question: '故事开局时,玩家正处于什么局面?', - }); - } - if (coreConflicts.length === 0) { - questions.push({ - id: 'core_conflict', - label: '核心冲突', - question: '这个世界当前最核心的冲突、危机或悬念是什么?', - }); - } - - return questions; -} - -export class CustomWorldSessionStore { - constructor( - private readonly runtimeRepository: RuntimeRepositoryPort, - ) {} - - async create( - userId: string, - settingText: string, - creatorIntent: JsonObject | null, - generationMode: CustomWorldGenerationMode, - ) { - const sessionId = `custom-world-session-${crypto.randomBytes(16).toString('hex')}`; - const now = new Date().toISOString(); - const session: CustomWorldSession = { - sessionId, - status: 'ready_to_generate', - settingText, - creatorIntent, - generationMode, - questions: buildClarificationQuestions(settingText, creatorIntent), - createdAt: now, - updatedAt: now, - }; - - if (hasPendingQuestion(session.questions)) { - session.status = 'clarifying'; - } - - await this.runtimeRepository.upsertCustomWorldSession( - userId, - sessionId, - toSessionRecord(session), - ); - return cloneSession(session); - } - - async list(userId: string) { - const sessions = await this.runtimeRepository.listCustomWorldSessions(userId); - return sessions.map((session) => toSession(session)); - } - - async get(userId: string, sessionId: string) { - const session = await this.runtimeRepository.getCustomWorldSession( - userId, - sessionId, - ); - return session ? toSession(session) : null; - } - - async answer( - userId: string, - sessionId: string, - questionId: string, - answer: string, - ) { - const session = await this.get(userId, sessionId); - if (!session) { - return null; - } - - const question = session.questions.find((item) => item.id === questionId); - if (!question) { - return null; - } - - question.answer = answer; - session.status = hasPendingQuestion(session.questions) - ? 'clarifying' - : 'ready_to_generate'; - session.updatedAt = new Date().toISOString(); - await this.runtimeRepository.upsertCustomWorldSession( - userId, - sessionId, - toSessionRecord(session), - ); - return cloneSession(session); - } - - async updateStatus( - userId: string, - sessionId: string, - status: CustomWorldSessionStatus, - lastError = '', - ) { - const session = await this.get(userId, sessionId); - if (!session) { - return null; - } - - session.status = status; - session.lastError = lastError || undefined; - session.updatedAt = new Date().toISOString(); - await this.runtimeRepository.upsertCustomWorldSession( - userId, - sessionId, - toSessionRecord(session), - ); - return cloneSession(session); - } - - async setResult(userId: string, sessionId: string, result: JsonObject) { - const session = await this.get(userId, sessionId); - if (!session) { - return null; - } - - session.status = 'completed'; - session.lastError = undefined; - session.result = JSON.parse(JSON.stringify(result)) as JsonObject; - session.updatedAt = new Date().toISOString(); - await this.runtimeRepository.upsertCustomWorldSession( - userId, - sessionId, - toSessionRecord(session), - ); - return cloneSession(session); - } -} diff --git a/src/components/GameShell.tsx b/src/components/GameShell.tsx deleted file mode 100644 index 8837b85f..00000000 --- a/src/components/GameShell.tsx +++ /dev/null @@ -1,807 +0,0 @@ -import {AnimatePresence, motion} from 'motion/react'; -import {lazy, Suspense, useCallback, useEffect, useMemo, useState} from 'react'; - -import {getLiveGamePlayTimeMs} from '../data/runtimeStats'; -import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes'; -import {getWorldCampScenePreset} from '../data/scenePresets'; -import {BottomTab} from '../hooks/useGameFlow'; -import {resolveActiveSceneActBlueprint} from '../services/customWorldSceneActRuntime'; -import { - type BattleRewardUi, - type CharacterChatUi, - type GoalFlowUi, - type InventoryFlowUi, - type NpcChatQuestOfferUi, - type QuestFlowUi, - type StoryGenerationNpcUi, -} from '../hooks/useStoryGeneration'; -import { - type Character, - type CustomWorldProfile, - type CompanionRenderState, - type GameState, - type StoryMoment, - type StoryOption, -} from '../types'; -import {CHROME_ICONS, getNineSliceStyle, TAB_ICONS, UI_CHROME} from '../uiAssets'; -import {CharacterSelectionFlow} from './game-shell/CharacterSelectionFlow'; -import {PreGameSelectionFlow} from './game-shell/PreGameSelectionFlow'; -import {SCENE_TRANSITION_FUNCTION_MODES, useSceneTransitionModel} from './game-shell/useSceneTransitionModel'; -import {useGameShellViewModel} from './game-shell/useGameShellViewModel'; -import {GameCanvas} from './GameCanvas'; -import {PixelIcon} from './PixelIcon'; - -interface GameShellSessionProps { - gameState: GameState; - currentStory: StoryMoment | null; - isLoading: boolean; - aiError: string | null; - bottomTab: BottomTab; - setBottomTab: (tab: BottomTab) => void; - isMapOpen: boolean; - setIsMapOpen: (open: boolean) => void; -} - -interface GameShellStoryProps { - displayedOptions: StoryOption[]; - canRefreshOptions: boolean; - handleRefreshOptions: () => void; - handleChoice: (option: StoryOption) => void; - handleNpcChatInput: (input: string) => boolean; - exitNpcChat: () => boolean; - handleMapTravelToScene: (sceneId: string) => boolean; - npcUi: StoryGenerationNpcUi; - characterChatUi: CharacterChatUi; - inventoryUi: InventoryFlowUi; - battleRewardUi: BattleRewardUi; - questUi: QuestFlowUi; - npcChatQuestOfferUi: NpcChatQuestOfferUi; - goalUi: GoalFlowUi; -} - -interface GameShellEntryProps { - hasSavedGame: boolean; - savedSnapshot: HydratedSavedGameSnapshot | null; - handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void; - handleStartNewGame: () => void; - handleSaveAndExit: () => void; - handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void; - handleBackToWorldSelect: () => void; - handleCharacterSelect: (character: Character) => void; -} - -interface GameShellCompanionProps { - companionRenderStates: CompanionRenderState[]; - buildCompanionRenderStates: (state: GameState) => CompanionRenderState[]; - onBenchCompanion: (npcId: string) => void; - onActivateRosterCompanion: (npcId: string, swapNpcId?: string | null) => void; -} - -interface GameShellAudioProps { - musicVolume: number; - onMusicVolumeChange: (value: number) => void; -} - -interface GameShellProps { - session: GameShellSessionProps; - story: GameShellStoryProps; - entry: GameShellEntryProps; - companions: GameShellCompanionProps; - audio: GameShellAudioProps; -} - -const AdventureEntityModal = lazy(async () => { - const module = await import('./AdventureEntityModal'); - - return { - default: module.AdventureEntityModal, - }; -}); - -const CharacterChatModal = lazy(async () => { - const module = await import('./CharacterChatModal'); - - return { - default: module.CharacterChatModal, - }; -}); - -const CompanionCampModal = lazy(async () => { - const module = await import('./CompanionCampModal'); - - return { - default: module.CompanionCampModal, - }; -}); - -const MapModal = lazy(async () => { - const module = await import('./MapModal'); - - return { - default: module.MapModal, - }; -}); - -const NpcModals = lazy(async () => { - const module = await import('./NpcModals'); - - return { - default: module.NpcModals, - }; -}); - -const AdventurePanel = lazy(async () => { - const module = await import('./AdventurePanel'); - - return { - default: module.AdventurePanel, - }; -}); - -const CharacterPanel = lazy(async () => { - const module = await import('./CharacterPanel'); - - return { - default: module.CharacterPanel, - }; -}); - -const InventoryPanel = lazy(async () => { - const module = await import('./InventoryPanel'); - - return { - default: module.InventoryPanel, - }; -}); - -function ModalLoadingFallback({ - label, - onClose, -}: { - label: string; - onClose?: (() => void) | null; -}) { - return ( -
-
event.stopPropagation()} - > - {label} -
-
- ); -} - -function PanelLoadingFallback({ - label, -}: { - label: string; -}) { - return ( -
- {label} -
- ); -} - -export function GameShell({session, story, entry, companions, audio}: GameShellProps) { - const { - gameState, - currentStory, - isLoading, - aiError, - bottomTab, - setBottomTab, - isMapOpen, - setIsMapOpen, - } = session; - const { - displayedOptions, - canRefreshOptions, - handleRefreshOptions, - handleChoice, - handleNpcChatInput, - exitNpcChat, - handleMapTravelToScene, - npcUi, - characterChatUi, - inventoryUi, - battleRewardUi, - questUi, - npcChatQuestOfferUi, - goalUi, - } = story; - const { - hasSavedGame, - savedSnapshot, - handleContinueGame, - handleStartNewGame, - handleSaveAndExit, - handleCustomWorldSelect, - handleBackToWorldSelect, - handleCharacterSelect, - } = entry; - const { - companionRenderStates, - buildCompanionRenderStates, - onBenchCompanion, - onActivateRosterCompanion, - } = companions; - const {musicVolume, onMusicVolumeChange} = audio; - - const [clockNow, setClockNow] = useState(() => Date.now()); - const openingCampSceneId = useMemo( - () => (gameState.worldType ? getWorldCampScenePreset(gameState.worldType)?.id ?? null : null), - [gameState.worldType], - ); - const hasNpcModalOpen = Boolean(npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal); - const { - selectionStage, - setSelectionStage, - overlayPanel, - openOverlayPanel, - closeOverlayPanel, - selectedSceneEntity, - setSelectedSceneEntity, - openPartyMemberDetails, - closeAdventureEntityModal, - showTeamModal, - openCampModal, - closeCampModal, - resetForSaveAndExit, - shouldMountAdventureEntityModal, - shouldMountCampModal, - shouldMountMapModal, - shouldMountCharacterChatModal, - shouldMountNpcModals, - } = useGameShellViewModel({ - gameState, - isMapOpen, - characterChatModalOpen: Boolean(characterChatUi.modal), - hasNpcModalOpen, - }); - const { - visibleGameState, - visibleCurrentStory, - sceneTransitionPhase, - sceneTransitionToken, - setSceneTransitionDurations, - beginSceneTransition, - } = useSceneTransitionModel({ - gameState, - currentStory, - openingCampSceneId, - }); - const isCharacterSelectionStage = - gameState.currentScene === 'Selection' && - Boolean(gameState.worldType) && - !gameState.playerCharacter; - const collapseTopStage = gameState.currentScene === 'Selection'; - const shouldHideStoryOptions = sceneTransitionPhase !== 'idle'; - const visibleStoryForRender = visibleCurrentStory; - - const dialogueIndicator = useMemo(() => { - if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') { - return null; - } - - const lastSpeaker = visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1]?.speaker ?? null; - return { - showPlayer: true, - showEncounter: true, - activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null, - } as const; - }, [visibleCurrentStory?.dialogue, visibleCurrentStory?.displayMode, visibleGameState.currentEncounter?.kind, isLoading]); - - const characterChatSummaries = useMemo( - () => - Object.fromEntries( - Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [characterId, record.summary]), - ), - [gameState.characterChats], - ); - - const visibleCompanionRenderStates = useMemo( - () => buildCompanionRenderStates(visibleGameState), - [buildCompanionRenderStates, visibleGameState], - ); - - const canvasCompanionRenderStates = useMemo(() => { - const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc' - ? visibleGameState.currentEncounter.id ?? null - : null; - if (!activeEncounterNpcId) return visibleCompanionRenderStates; - return visibleCompanionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId); - }, [visibleCompanionRenderStates, visibleGameState.currentEncounter]); - - const livePlayTimeMs = useMemo( - () => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow), - [clockNow, gameState.runtimeStats], - ); - const activeSceneAct = useMemo( - () => resolveActiveSceneActBlueprint({ - profile: visibleGameState.customWorldProfile, - sceneId: visibleGameState.currentScenePreset?.id ?? null, - storyEngineMemory: visibleGameState.storyEngineMemory, - }), - [ - visibleGameState.currentScenePreset?.id, - visibleGameState.customWorldProfile, - visibleGameState.storyEngineMemory, - ], - ); - const activeSceneChapter = useMemo(() => { - if (!visibleGameState.customWorldProfile || !visibleGameState.currentScenePreset?.id) { - return null; - } - - return ( - visibleGameState.customWorldProfile.sceneChapterBlueprints?.find( - entry => entry.sceneId === visibleGameState.currentScenePreset?.id - || entry.linkedLandmarkIds.includes(visibleGameState.currentScenePreset?.id ?? ''), - ) ?? null - ); - }, [ - visibleGameState.currentScenePreset?.id, - visibleGameState.customWorldProfile, - ]); - - const adventureStatistics = useMemo( - () => ({ - playTimeMs: livePlayTimeMs, - hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated, - questsAccepted: gameState.runtimeStats.questsAccepted, - questsCompleted: visibleGameState.quests.filter(quest => quest.status === 'completed' || quest.status === 'turned_in').length, - questsTurnedIn: visibleGameState.quests.filter(quest => quest.status === 'turned_in').length, - itemsUsed: gameState.runtimeStats.itemsUsed, - scenesTraveled: gameState.runtimeStats.scenesTraveled, - currentSceneName: visibleGameState.currentScenePreset?.name ?? 'Current Area', - playerCurrency: visibleGameState.playerCurrency, - inventoryItemCount: visibleGameState.playerInventory.reduce((sum, item) => sum + item.quantity, 0), - inventoryStackCount: visibleGameState.playerInventory.length, - activeCompanionCount: visibleGameState.companions.length, - rosterCompanionCount: visibleGameState.roster.length, - }), - [ - gameState.runtimeStats.itemsUsed, - gameState.runtimeStats.hostileNpcsDefeated, - gameState.runtimeStats.questsAccepted, - gameState.runtimeStats.scenesTraveled, - livePlayTimeMs, - visibleGameState.companions.length, - visibleGameState.currentScenePreset?.name, - visibleGameState.playerCurrency, - visibleGameState.playerInventory, - visibleGameState.quests, - visibleGameState.roster.length, - ], - ); - - useEffect(() => { - if (!gameState.playerCharacter || gameState.currentScene !== 'Story') { - return; - } - - setClockNow(Date.now()); - const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000); - return () => window.clearInterval(intervalId); - }, [gameState.currentScene, gameState.playerCharacter]); - - const handleSceneTransitionChoice = useCallback((option: StoryOption) => { - const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId]; - if (transitionMode) { - beginSceneTransition(transitionMode); - } - handleChoice(option); - }, [beginSceneTransition, handleChoice]); - - return ( -
-
- {collapseTopStage ? null : ( - setIsMapOpen(true)} - sceneTransitionPhase={sceneTransitionPhase} - sceneTransitionToken={sceneTransitionToken} - onSceneTransitionDurationsChange={setSceneTransitionDurations} - /> - )} -
- -
- - {!gameState.worldType && ( - - )} - - {gameState.worldType && !gameState.playerCharacter && ( - - { - handleBackToWorldSelect(); - setSelectionStage('platform'); - }} - onConfirm={handleCharacterSelect} - /> - - )} - - {visibleGameState.playerCharacter && visibleStoryForRender && ( - -
- - - -
- - {bottomTab === 'character' && ( - }> - - - )} - - {bottomTab === 'adventure' && ( - }> - openOverlayPanel('character')} - onOpenInventory={() => openOverlayPanel('inventory')} - playerCharacter={visibleGameState.playerCharacter} - worldType={visibleGameState.worldType} - quests={visibleGameState.quests} - questUi={questUi} - npcChatQuestOfferUi={npcChatQuestOfferUi} - goalStack={goalUi.goalStack} - goalPulse={goalUi.pulse} - onDismissGoalPulse={goalUi.dismissPulse} - battleRewardUi={battleRewardUi} - playerHp={visibleGameState.playerHp} - playerMaxHp={visibleGameState.playerMaxHp} - playerMana={visibleGameState.playerMana} - playerMaxMana={visibleGameState.playerMaxMana} - playerSkillCooldowns={visibleGameState.playerSkillCooldowns} - inBattle={visibleGameState.inBattle} - currentNpcBattleMode={visibleGameState.currentNpcBattleMode} - chapterState={visibleGameState.chapterState ?? null} - journeyBeat={ - visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null - } - currentSceneActTitle={activeSceneAct?.title ?? null} - currentSceneActIndex={ - activeSceneChapter && activeSceneAct - ? (() => { - const actIndex = activeSceneChapter.acts.findIndex( - act => act.id === activeSceneAct.id, - ); - return actIndex >= 0 ? actIndex + 1 : null; - })() - : null - } - currentSceneActCount={activeSceneChapter?.acts.length ?? null} - statistics={adventureStatistics} - musicVolume={musicVolume} - onMusicVolumeChange={onMusicVolumeChange} - onSaveAndExit={() => { - resetForSaveAndExit(); - handleSaveAndExit(); - }} - /> - - )} - - {bottomTab === 'inventory' && ( - }> - - - )} -
- )} -
-
- - {shouldMountAdventureEntityModal && ( - }> - { - closeAdventureEntityModal(); - characterChatUi.openChat(target); - }} - /> - - )} - - - {overlayPanel && gameState.playerCharacter && ( - - event.stopPropagation()} - > -
-
{overlayPanel === 'character' ? '队伍' : '背包'}
- -
-
- {overlayPanel === 'character' ? ( - }> - { - closeOverlayPanel(); - openCampModal(); - }} - onOpenCharacterChat={target => { - closeOverlayPanel(); - characterChatUi.openChat(target); - }} - chatSummaries={characterChatSummaries} - onInspectMember={openPartyMemberDetails} - /> - - ) : ( - }> - - - )} -
-
-
- )} -
- - {shouldMountCampModal && ( - }> - - - )} - - {shouldMountMapModal && ( - setIsMapOpen(false)} />}> - { - const triggered = handleMapTravelToScene(scene.id); - if (triggered) { - setIsMapOpen(false); - } - }} - isTraveling={isLoading} - onClose={() => setIsMapOpen(false)} - /> - - )} - - {shouldMountCharacterChatModal && ( - }> - - - )} - - {shouldMountNpcModals && ( - }> - - - )} -
- ); -} diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 7514003f..65ba9ba2 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -9,9 +9,9 @@ import { AuthGate } from './AuthGate'; import { useAuthUi } from './AuthUiContext'; const authMocks = vi.hoisted(() => ({ - getStoredAccessToken: vi.fn(), ensureAutoAuthUser: vi.fn(), getAuthLoginOptions: vi.fn(), + getCurrentAuthUser: vi.fn(), loginWithPhoneCode: vi.fn(), sendPhoneLoginCode: vi.fn(), startWechatLogin: vi.fn(), @@ -20,7 +20,6 @@ const authMocks = vi.hoisted(() => ({ vi.mock('../../services/apiClient', () => ({ AUTH_STATE_EVENT: 'genarrative-auth-state-changed', - getStoredAccessToken: authMocks.getStoredAccessToken, })); vi.mock('../../services/authService', () => ({ @@ -31,9 +30,9 @@ vi.mock('../../services/authService', () => ({ getAuthAuditLogs: vi.fn(), getAuthLoginOptions: authMocks.getAuthLoginOptions, getAuthRiskBlocks: vi.fn(), + getCurrentAuthUser: authMocks.getCurrentAuthUser, getAuthSessions: vi.fn(), getCaptchaChallengeFromError: vi.fn(() => null), - getCurrentAuthUser: vi.fn(), liftAuthRiskBlock: vi.fn(), loginWithPhoneCode: authMocks.loginWithPhoneCode, logoutAllAuthSessions: vi.fn(), @@ -76,8 +75,11 @@ const mockUser: AuthUser = { beforeEach(() => { vi.clearAllMocks(); - authMocks.getStoredAccessToken.mockReturnValue(null); authMocks.consumeAuthCallbackResult.mockReturnValue(null); + authMocks.getCurrentAuthUser.mockResolvedValue({ + user: null, + availableLoginMethods: ['phone'], + }); authMocks.loginWithPhoneCode.mockResolvedValue(mockUser); authMocks.sendPhoneLoginCode.mockResolvedValue({ cooldownSeconds: 60, diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 31894296..919fdafd 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -10,7 +10,6 @@ import { import { useGameSettings } from '../../hooks/useGameSettings'; import { AUTH_STATE_EVENT, - getStoredAccessToken, } from '../../services/apiClient'; import { type AuthAuditLogEntry, @@ -228,12 +227,6 @@ export function AuthGate({ children }: AuthGateProps) { setShowLoginModal(true); } - const token = getStoredAccessToken(); - if (!token) { - await resolveGuestFallback(); - return; - } - try { const nextSession = await getCurrentAuthUser(); if (!isActive) { @@ -241,9 +234,8 @@ export function AuthGate({ children }: AuthGateProps) { } if (!nextSession.user) { - setUser(null); setAvailableLoginMethods(nextSession.availableLoginMethods); - setStatus('unauthenticated'); + await resolveGuestFallback(); return; } diff --git a/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx b/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx deleted file mode 100644 index 53a4f105..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* @vitest-environment jsdom */ - -import { render, screen } from '@testing-library/react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { afterEach, expect, test, vi } from 'vitest'; - -import { CustomWorldAgentClarificationPanel } from './CustomWorldAgentClarificationPanel'; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -test('clarification panel shows pending questions and ready state', () => { - const pendingHtml = renderToStaticMarkup( - , - ); - const readyHtml = renderToStaticMarkup( - , - ); - - expect(pendingHtml).toContain('待补充问题'); - expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里'); - expect(readyHtml).toContain('当前设定已齐备,可以进入下一阶段'); -}); - -test('falls back to stable keys when clarification ids are empty', () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined); - - render( - , - ); - - expect(screen.getByText(/玩家身份与开局/u)).toBeTruthy(); - expect(screen.getByText(/核心冲突/u)).toBeTruthy(); - - const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) => - call.some( - (arg) => - typeof arg === 'string' && - arg.includes('Encountered two children with the same key'), - ), - ); - - expect(duplicateKeyCalls).toHaveLength(0); -}); diff --git a/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx b/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx deleted file mode 100644 index a0e9ca8c..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import type { - CreatorIntentReadiness, - CustomWorldPendingClarification, -} from '../../../packages/shared/src/contracts/customWorldAgent'; - -type CustomWorldAgentClarificationPanelProps = { - pendingClarifications: CustomWorldPendingClarification[]; - readiness: CreatorIntentReadiness; -}; - -export function CustomWorldAgentClarificationPanel({ - pendingClarifications, - readiness, -}: CustomWorldAgentClarificationPanelProps) { - if (readiness.isReady) { - return ( -
-
- 下一阶段 -
-
- 当前设定已齐备,可以进入下一阶段 -
-
- ); - } - - return ( -
-
-
-
- 待补充问题 -
-
- 先补最关键的 1 到 3 项 -
-
- - {pendingClarifications.length} - -
- -
- {pendingClarifications.slice(0, 3).map((item, index) => ( -
-
-
- {index + 1}. {item.label} -
-
P{item.priority}
-
-
- {item.question} -
-
- ))} -
-
- ); -} diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.interaction.test.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.interaction.test.tsx deleted file mode 100644 index 14a2f771..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.interaction.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/* @vitest-environment jsdom */ - -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; -import { expect, test } from 'vitest'; - -import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent'; -import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel'; -import { CustomWorldGenerateEntityModal } from './CustomWorldGenerateEntityModal'; - -const CHARACTER_DETAIL: CustomWorldDraftCardDetail = { - id: 'character-1', - kind: 'character', - title: '沈砺', - sections: [ - { - id: 'name', - label: '角色名', - value: '沈砺', - }, - { - id: 'publicMask', - label: '外显身份', - value: '守灯会里最熟悉旧航道的人。', - }, - { - id: 'summary', - label: '角色摘要', - value: '他像旧友,但也像一把始终没收回鞘的刀。', - }, - ], - linkedIds: ['thread-1'], - locked: false, - editable: true, - editableSectionIds: ['name', 'publicMask', 'summary'], - warningMessages: [], - assetStatus: 'missing', - assetStatusLabel: '待生成主图', -}; - -function DetailInteractionHarness() { - const [editMode, setEditMode] = useState(false); - const [generateMode, setGenerateMode] = useState<'character' | 'landmark' | null>( - null, - ); - const [savedPayload, setSavedPayload] = useState(''); - - return ( - <> - {}} - onStartEdit={() => { - setEditMode(true); - }} - onCancelEdit={() => { - setEditMode(false); - }} - onSave={(sections) => { - setSavedPayload(JSON.stringify(sections)); - setEditMode(false); - }} - onGenerateCharacter={() => { - setGenerateMode('character'); - }} - onGenerateLandmark={() => { - setGenerateMode('landmark'); - }} - onOpenRoleAssetStudio={() => {}} - /> - { - setGenerateMode(null); - }} - onSubmit={() => { - setGenerateMode(null); - }} - /> -
{savedPayload}
- - ); -} - -test('draft detail panel supports edit save and opening generate modals', async () => { - const user = userEvent.setup(); - - render(); - - await user.click(screen.getByRole('button', { name: '编辑设定' })); - const summaryInput = screen.getByLabelText('角色摘要'); - await user.clear(summaryInput); - await user.type(summaryInput, '他像旧友,也像最早知道航道秘密的人。'); - await user.click(screen.getByRole('button', { name: '保存' })); - - expect(screen.getByTestId('saved-payload').textContent).toContain( - '他像旧友,也像最早知道航道秘密的人。', - ); - - await user.click(screen.getByRole('button', { name: '新增角色' })); - expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy(); - expect(screen.getByText('当前参考卡')).toBeTruthy(); - const closeButtons = screen.getAllByRole('button', { name: '关闭' }); - await user.click(closeButtons[closeButtons.length - 1]!); - - expect(screen.getByRole('button', { name: '角色资产' })).toBeTruthy(); - - await user.click(screen.getByRole('button', { name: '新增场景' })); - expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy(); -}); diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.test.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.test.tsx deleted file mode 100644 index 315e1c62..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server'; -import { expect, test } from 'vitest'; - -import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel'; - -test('draft detail panel renders sections and warnings', () => { - const html = renderToStaticMarkup( - {}} - onStartEdit={() => {}} - onGenerateCharacter={() => {}} - onGenerateLandmark={() => {}} - />, - ); - - expect(html).toContain('谁掌握航道解释权'); - expect(html).toContain('线程类型'); - expect(html).toContain('守灯会与沉船商盟'); - expect(html).toContain('继续精修'); - expect(html).toContain('编辑设定'); - expect(html).toContain('新增角色'); -}); - -test('draft detail panel renders scene chapter label and background preview', () => { - const html = renderToStaticMarkup( - {}} - onStartEdit={() => {}} - />, - ); - - expect(html).toContain('场景章节'); - expect(html).toContain('第 1 幕背景图'); - expect(html).toContain('img'); -}); diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx deleted file mode 100644 index 85e1c001..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent'; -import { CustomWorldDraftEditPanel } from './CustomWorldDraftEditPanel'; - -type CustomWorldAgentDraftDetailPanelProps = { - detail: CustomWorldDraftCardDetail | null; - loading: boolean; - busy?: boolean; - editMode?: boolean; - onClose: () => void; - onStartEdit?: () => void; - onCancelEdit?: () => void; - onSave?: ( - sections: Array<{ - sectionId: string; - value: string; - }>, - ) => void; - onGenerateCharacter?: () => void; - onGenerateLandmark?: () => void; - onOpenRoleAssetStudio?: () => void; -}; - -function resolveKindLabel(kind: CustomWorldDraftCardDetail['kind']) { - if (kind === 'world') return '世界总卡'; - if (kind === 'camp') return '营地'; - if (kind === 'faction') return '势力'; - if (kind === 'character') return '角色'; - if (kind === 'landmark') return '地点'; - if (kind === 'thread') return '线程'; - if (kind === 'chapter') return '第一幕'; - if (kind === 'scene_chapter') return '场景章节'; - return '草稿卡'; -} - -function ActionButton(props: { - label: string; - onClick?: () => void; - disabled?: boolean; - tone?: 'default' | 'sky'; -}) { - const { label, onClick, disabled = false, tone = 'default' } = props; - - if (!onClick) { - return null; - } - - return ( - - ); -} - -export function CustomWorldAgentDraftDetailPanel({ - detail, - loading, - busy = false, - editMode = false, - onClose, - onStartEdit, - onCancelEdit, - onSave, - onGenerateCharacter, - onGenerateLandmark, - onOpenRoleAssetStudio, -}: CustomWorldAgentDraftDetailPanelProps) { - const shouldRenderImagePreview = ( - detailKind: CustomWorldDraftCardDetail['kind'], - sectionId: string, - value: string, - ) => - detailKind === 'scene_chapter' && - sectionId.endsWith(':backgroundImageSrc') && - value !== '待继续精修'; - - return ( -
-
-
-
- 卡片详情 -
-
- {loading ? '正在读取' : detail?.title || '选择一张草稿卡'} -
-
- -
- - {loading ? ( -
- 正在整理这张卡的内容。 -
- ) : detail ? ( -
-
- - {resolveKindLabel(detail.kind)} - - - 关联 {detail.linkedIds.length} - - {detail.editable ? ( - - 可编辑 - - ) : null} - {detail.kind === 'character' && detail.assetStatusLabel ? ( - - {detail.assetStatusLabel} - - ) : null} -
- -
- {!editMode && detail.editable ? ( - - ) : null} - {!editMode && detail.kind === 'character' ? ( - - ) : null} - {!editMode ? ( - <> - - - - ) : null} -
- - {editMode && onSave && onCancelEdit ? ( - - ) : ( -
- {detail.sections.map((section) => ( -
-
- {section.label} -
- {shouldRenderImagePreview(detail.kind, section.id, section.value) ? ( - {section.label} - ) : null} -
- {section.value} -
-
- ))} -
- )} - - {detail.warningMessages.length > 0 ? ( -
-
- 继续精修 -
-
- {detail.warningMessages.map((message, index) => ( -
- {message} -
- ))} -
-
- ) : null} -
- ) : ( -
- 从草稿抽屉里点开一张卡,就能在这里看世界底稿的具体内容。 -
- )} -
- ); -} diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx deleted file mode 100644 index cd2a3812..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import type { CustomWorldDraftCardSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; - -type CustomWorldAgentDraftDrawerProps = { - draftCards: CustomWorldDraftCardSummary[]; - activeCardId?: string | null; - onSelectCard: (cardId: string) => void; -}; - -const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [ - 'world', - 'chapter', - 'scene_chapter', - 'thread', - 'faction', - 'character', - 'landmark', - 'camp', -]; - -function resolveGroupLabel(kind: CustomWorldDraftCardSummary['kind']) { - if (kind === 'world') return '世界总卡'; - if (kind === 'chapter') return '第一幕'; - if (kind === 'scene_chapter') return '场景章节'; - if (kind === 'thread') return '世界线程'; - if (kind === 'faction') return '势力'; - if (kind === 'character') return '关键角色'; - if (kind === 'landmark') return '关键地点'; - if (kind === 'camp') return '营地'; - return '草稿卡'; -} - -export function CustomWorldAgentDraftDrawer({ - draftCards, - activeCardId, - onSelectCard, -}: CustomWorldAgentDraftDrawerProps) { - const groupedCards = DRAWER_KIND_ORDER.map((kind) => ({ - kind, - items: draftCards.filter((card) => card.kind === kind), - })).filter((group) => group.items.length > 0); - - return ( -
-
- 草稿抽屉 -
- {groupedCards.length > 0 ? ( -
- {groupedCards.map((group) => ( -
-
-
- {resolveGroupLabel(group.kind)} -
-
- {group.items.length} -
-
-
- {group.items.map((card, index) => { - const isActive = activeCardId === card.id; - - return ( - - ); - })} -
-
- ))} -
- ) : ( -
- 当前设定收束后,世界底稿会先从这里长出来。 -
- )} -
- ); -} diff --git a/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.test.tsx b/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.test.tsx deleted file mode 100644 index 3b8d6cf5..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server'; -import { expect, test } from 'vitest'; - -import { CustomWorldAgentIntentSummaryPanel } from './CustomWorldAgentIntentSummaryPanel'; - -test('intent summary panel shows collected custom world anchors', () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain('已收集设定'); - expect(html).toContain('世界一句话'); - expect(html).toContain('一个被潮雾切开的列岛世界'); - expect(html).toContain('守灯会与沉船商盟争夺航道解释权'); - expect(html).toContain('5/6'); -}); diff --git a/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.tsx b/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.tsx deleted file mode 100644 index 3d00a72c..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import type { CreatorIntentReadiness } from '../../../packages/shared/src/contracts/customWorldAgent'; -import { - evaluateCustomWorldCreatorIntentReadiness, - hasMeaningfulCustomWorldCreatorIntent, - normalizeCustomWorldCreatorIntent, -} from '../../services/customWorldCreatorIntent'; - -type CustomWorldAgentIntentSummaryPanelProps = { - creatorIntent: Record | null; - readiness: CreatorIntentReadiness; -}; - -export function CustomWorldAgentIntentSummaryPanel({ - creatorIntent, - readiness, -}: CustomWorldAgentIntentSummaryPanelProps) { - const intent = normalizeCustomWorldCreatorIntent(creatorIntent); - const resolvedReadiness = - readiness ?? evaluateCustomWorldCreatorIntentReadiness(intent); - const items = [ - { - label: '世界一句话', - value: intent?.worldHook || '', - ready: resolvedReadiness.completedKeys.includes('world_hook'), - }, - { - label: '玩家身份', - value: intent?.playerPremise || '', - ready: Boolean(intent?.playerPremise), - }, - { - label: '开局处境', - value: intent?.openingSituation || '', - ready: Boolean(intent?.openingSituation), - }, - { - label: '核心冲突', - value: intent?.coreConflicts.join('、') || '', - ready: resolvedReadiness.completedKeys.includes('core_conflict'), - }, - { - label: '主题气质', - value: - [...(intent?.themeKeywords ?? []), ...(intent?.toneDirectives ?? [])] - .filter(Boolean) - .join('、') || '', - ready: resolvedReadiness.completedKeys.includes('theme_and_tone'), - }, - { - label: '标志性要素', - value: intent?.iconicElements.join('、') || '', - ready: resolvedReadiness.completedKeys.includes('iconic_element'), - }, - ]; - - return ( -
-
-
-
- 已收集设定 -
-
- {resolvedReadiness.isReady ? '创作输入已齐备' : '继续收世界骨架'} -
-
- - {resolvedReadiness.completedKeys.length}/6 - -
- - {hasMeaningfulCustomWorldCreatorIntent(intent) ? ( -
- {items.map((item) => ( -
-
- {item.label} -
-
- {item.value || '待补充'} -
-
- ))} -
- ) : ( -
- 还在收集你的世界设定 -
- )} -
- ); -} diff --git a/src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx b/src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx deleted file mode 100644 index 29f901b8..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { X } from 'lucide-react'; - -type CustomWorldAgentLauncherModalProps = { - isOpen: boolean; - seedText: string; - isBusy: boolean; - error: string | null; - onClose: () => void; - onSeedTextChange: (value: string) => void; - onConfirm: () => void; -}; - -export function CustomWorldAgentLauncherModal({ - isOpen, - seedText, - isBusy, - error, - onClose, - onSeedTextChange, - onConfirm, -}: CustomWorldAgentLauncherModalProps) { - if (!isOpen) { - return null; - } - - return ( -
-
-
-
-
- 开始和 Agent 共创 -
-
- 输入一段种子灵感,先进入新的工作区。 -
-
- -
- -
-