1
This commit is contained in:
@@ -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. 一句话结论
|
||||
|
||||
这轮工程大清洗的核心,不是“删旧代码看起来更清爽”,而是:
|
||||
|
||||
**用一轮有台账、有判定、有阶段、有验收的大清理,把无用历史代码、隐形多链路乱代码和半成品能力从主工程里真正清出去,让项目重新回到单一主链、单一真相源、目录可读、职责清楚的健康状态。**
|
||||
@@ -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):基于“前端只做表现、逻辑与数据全部后端化”的工程重构规划。
|
||||
|
||||
212
docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md
Normal file
212
docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md
Normal file
@@ -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 的历史职责
|
||||
@@ -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. 已保存作品的结果页编辑能力不受影响
|
||||
|
||||
148
docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md
Normal file
148
docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md
Normal file
@@ -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 结果页只负责预览与收口,不再继续充当旧编辑器。**
|
||||
@@ -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 主链与已保存作品编辑链仍然可用
|
||||
|
||||
@@ -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 冻结版本、热点文件编辑规则与集成窗口清单。
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
| {
|
||||
action: 'generate_characters';
|
||||
count: number;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
229
server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts
Normal file
229
server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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<string, unknown> | 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<string, unknown>,
|
||||
} satisfies Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>,
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<string, unknown> | null;
|
||||
const legacyResultProfile = draftRecord?.legacyResultProfile as
|
||||
| Record<string, unknown>
|
||||
| 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<string, unknown> | null;
|
||||
const legacyResultProfile = draftRecord?.legacyResultProfile as
|
||||
| Record<string, unknown>
|
||||
| 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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -171,6 +171,12 @@ function isLibraryEntry(
|
||||
);
|
||||
}
|
||||
|
||||
function isPublishedLibraryEntry(
|
||||
value: unknown,
|
||||
): value is CustomWorldLibraryEntry<CustomWorldProfileRecord> {
|
||||
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<string, unknown>;
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
<CustomWorldResultView
|
||||
profile={baseProfile}
|
||||
previewCharacters={[]}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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<CustomWorldEditorTarget | null>(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') {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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<typeof userEvent.setup>) {
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
expect(await screen.findByText('创作中心')).toBeTruthy();
|
||||
}
|
||||
|
||||
async function openNewRpgCreation(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
) {
|
||||
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(<TestWrapper />);
|
||||
|
||||
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(<TestWrapper withAuth />);
|
||||
|
||||
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(<TestWrapper />);
|
||||
|
||||
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(<TestWrapper />);
|
||||
|
||||
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(<TestWrapper />);
|
||||
|
||||
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(<TestWrapper />);
|
||||
|
||||
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(<TestWrapper withAuth />);
|
||||
|
||||
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(<TestWrapper withAuth />);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-full min-h-0 items-center justify-center">
|
||||
@@ -174,6 +185,37 @@ function LazyPanelFallback({ label }: { label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildCreationHubFallbackItems(
|
||||
entries: CustomWorldLibraryEntry<CustomWorldProfile>[],
|
||||
): 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<CustomWorldProfile>[]
|
||||
>([]);
|
||||
const [customWorldWorkEntries, setCustomWorldWorkEntries] = useState<
|
||||
CustomWorldWorkSummary[]
|
||||
>([]);
|
||||
const [publishedGalleryEntries, setPublishedGalleryEntries] = useState<
|
||||
CustomWorldGalleryCard[]
|
||||
>([]);
|
||||
@@ -250,6 +295,10 @@ export function PreGameSelectionFlow({
|
||||
const customWorldAutoSaveTimeoutRef = useRef<number | null>(null);
|
||||
const lastAutoSavedProfileSignatureRef = useRef<string | null>(null);
|
||||
const latestAutoSaveRequestIdRef = useRef(0);
|
||||
const latestAgentResultSyncSignatureRef = useRef<string | null>(null);
|
||||
// 用户手动返回工作区后,先抑制自动重开结果页,避免刚退出又被 session 快照顶回去。
|
||||
const isAgentDraftResultAutoOpenSuppressedRef = useRef(false);
|
||||
const isCustomWorldAutoSaveBusyRef = useRef(false);
|
||||
const platformTabBootstrapUserIdRef = useRef<string | null | undefined>(
|
||||
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<CustomWorldProfile>,
|
||||
) => {
|
||||
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<CustomWorldProfile>) => {
|
||||
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<string, unknown>,
|
||||
},
|
||||
);
|
||||
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<CustomWorldProfile>,
|
||||
@@ -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"
|
||||
>
|
||||
<PlatformHomeView
|
||||
activeTab={platformTab}
|
||||
onTabChange={setPlatformTab}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
saveEntries={saveEntries}
|
||||
saveError={saveError}
|
||||
featuredEntries={featuredGalleryEntries}
|
||||
latestEntries={publishedGalleryEntries}
|
||||
myEntries={savedCustomWorldEntries}
|
||||
historyEntries={historyEntries}
|
||||
profileDashboard={profileDashboard}
|
||||
isLoadingPlatform={isLoadingPlatform}
|
||||
isLoadingDashboard={isLoadingDashboard}
|
||||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
||||
platformError={
|
||||
isLoadingPlatform ? null : (platformError ?? creationTypeError)
|
||||
}
|
||||
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();
|
||||
{platformTab === 'create' ? (
|
||||
<CustomWorldCreationHub
|
||||
items={creationHubItems}
|
||||
loading={isLoadingPlatform}
|
||||
error={isLoadingPlatform ? null : (platformError ?? creationTypeError)}
|
||||
onBack={() => {
|
||||
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);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PlatformHomeView
|
||||
activeTab={platformTab}
|
||||
onTabChange={setPlatformTab}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
saveEntries={saveEntries}
|
||||
saveError={saveError}
|
||||
featuredEntries={featuredGalleryEntries}
|
||||
latestEntries={publishedGalleryEntries}
|
||||
myEntries={savedCustomWorldEntries}
|
||||
historyEntries={historyEntries}
|
||||
profileDashboard={profileDashboard}
|
||||
isLoadingPlatform={isLoadingPlatform}
|
||||
isLoadingDashboard={isLoadingDashboard}
|
||||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
||||
platformError={
|
||||
isLoadingPlatform ? null : (platformError ?? creationTypeError)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -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="进入世界"
|
||||
|
||||
Reference in New Issue
Block a user