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_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 创作动线做收口、删重、补通和文档收束的大白话执行规划。
|
- [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):基于“前端只做表现、逻辑与数据全部后端化”的工程重构规划。
|
- [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_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_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 新增角色/地点后草稿作品卡数量统计与测试断言的语义对齐说明。
|
- [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 模式完整迁入当前项目的冻结边界、模块映射、分阶段计划与验收清单。
|
- [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 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。
|
- [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 冻结版本、热点文件编辑规则与集成窗口清单。
|
- [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'
|
| 'regenerate_scope'
|
||||||
| 'draft_foundation'
|
| 'draft_foundation'
|
||||||
| 'update_draft_card'
|
| 'update_draft_card'
|
||||||
|
| 'sync_result_profile'
|
||||||
| 'generate_characters'
|
| 'generate_characters'
|
||||||
| 'generate_landmarks'
|
| 'generate_landmarks'
|
||||||
| 'generate_role_assets'
|
| 'generate_role_assets'
|
||||||
@@ -497,6 +498,10 @@ export type CustomWorldAgentActionRequest =
|
|||||||
value: string;
|
value: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
action: 'sync_result_profile';
|
||||||
|
profile: Record<string, unknown>;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
action: 'generate_characters';
|
action: 'generate_characters';
|
||||||
count: number;
|
count: number;
|
||||||
|
|||||||
@@ -2503,6 +2503,34 @@ test('custom world works endpoint returns draft sessions and published worlds to
|
|||||||
|
|
||||||
assert.equal(publishResponse.status, 200);
|
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(
|
const worksResponse = await httpRequest(
|
||||||
`${baseUrl}/api/runtime/custom-world/works`,
|
`${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,
|
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 () => {
|
test('custom world agent generate_characters action appends character cards over http', async () => {
|
||||||
await withTestServer(
|
await withTestServer(
|
||||||
'custom-world-agent-phase4-generate-characters-http',
|
'custom-world-agent-phase4-generate-characters-http',
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ const actionSchema = z.discriminatedUnion('action', [
|
|||||||
)
|
)
|
||||||
.min(1),
|
.min(1),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
action: z.literal('sync_result_profile'),
|
||||||
|
profile: z.record(z.string(), z.unknown()),
|
||||||
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
action: z.literal('generate_characters'),
|
action: z.literal('generate_characters'),
|
||||||
count: z.number().int().min(1).max(3),
|
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';
|
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||||
import { badRequest, notFound } from '../errors.js';
|
import { badRequest, notFound } from '../errors.js';
|
||||||
import { prepareEventStreamResponse } from '../http.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 { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js';
|
||||||
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
|
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
|
||||||
import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js';
|
import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js';
|
||||||
@@ -150,6 +152,8 @@ function buildOperation(type: CustomWorldAgentOperationRecord['type']) {
|
|||||||
? '正在把已确认设定编成第一版世界底稿。'
|
? '正在把已确认设定编成第一版世界底稿。'
|
||||||
: type === 'update_draft_card'
|
: type === 'update_draft_card'
|
||||||
? '正在把这次设定改动写回草稿。'
|
? '正在把这次设定改动写回草稿。'
|
||||||
|
: type === 'sync_result_profile'
|
||||||
|
? '正在把结果页里的世界快照同步回当前草稿。'
|
||||||
: type === 'generate_characters'
|
: type === 'generate_characters'
|
||||||
? '正在围绕当前底稿补出新角色。'
|
? '正在围绕当前底稿补出新角色。'
|
||||||
: type === 'generate_landmarks'
|
: type === 'generate_landmarks'
|
||||||
@@ -194,6 +198,27 @@ function buildRoleAssetSyncResultText(params: {
|
|||||||
return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}。`;
|
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(
|
function buildQuestionLines(
|
||||||
pendingClarifications: CustomWorldPendingClarification[],
|
pendingClarifications: CustomWorldPendingClarification[],
|
||||||
) {
|
) {
|
||||||
@@ -548,6 +573,7 @@ export class CustomWorldAgentOrchestrator {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
payload.action === 'update_draft_card' ||
|
payload.action === 'update_draft_card' ||
|
||||||
|
payload.action === 'sync_result_profile' ||
|
||||||
payload.action === 'generate_characters' ||
|
payload.action === 'generate_characters' ||
|
||||||
payload.action === 'generate_landmarks' ||
|
payload.action === 'generate_landmarks' ||
|
||||||
payload.action === 'generate_role_assets' ||
|
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.action === 'generate_characters') {
|
||||||
if (payload.count < 1 || payload.count > 3) {
|
if (payload.count < 1 || payload.count > 3) {
|
||||||
throw badRequest('generate_characters count must be between 1 and 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: {
|
private async processGenerateCharactersOperation(params: {
|
||||||
userId: string;
|
userId: string;
|
||||||
sessionId: 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 () => {
|
test('phase4 generate_characters appends story npcs and updates work summary counts', async () => {
|
||||||
const runtimeRepository = createRuntimeRepositoryStub();
|
const runtimeRepository = createRuntimeRepositoryStub();
|
||||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
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);
|
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(
|
export async function listCustomWorldWorkSummaries(
|
||||||
userId: string,
|
userId: string,
|
||||||
dependencies: {
|
dependencies: {
|
||||||
@@ -216,8 +222,10 @@ export async function listCustomWorldWorkSummaries(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const publishedItems: CustomWorldWorkSummary[] = profiles.map((profile) => {
|
const publishedItems: CustomWorldWorkSummary[] = profiles
|
||||||
const libraryEntry = isLibraryEntry(profile) ? profile : null;
|
.filter((profile) => isPublishedLibraryEntry(profile))
|
||||||
|
.map((profile) => {
|
||||||
|
const libraryEntry = profile;
|
||||||
const profileRecord = (
|
const profileRecord = (
|
||||||
libraryEntry?.profile ?? profile
|
libraryEntry?.profile ?? profile
|
||||||
) as CustomWorldProfileRecord & Record<string, unknown>;
|
) as CustomWorldProfileRecord & Record<string, unknown>;
|
||||||
@@ -237,59 +245,55 @@ export async function listCustomWorldWorkSummaries(
|
|||||||
(entry) => Boolean(toText(entry.generatedAnimationSetId)),
|
(entry) => Boolean(toText(entry.generatedAnimationSetId)),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workId: `published:${toText(profileRecord.id) || updatedAt}`,
|
workId: `published:${toText(profileRecord.id) || updatedAt}`,
|
||||||
sourceType: 'published_profile',
|
sourceType: 'published_profile',
|
||||||
status: 'published',
|
status: 'published',
|
||||||
title:
|
title:
|
||||||
(libraryEntry ? toText(libraryEntry.worldName) : '') ||
|
toText(libraryEntry.worldName) ||
|
||||||
toText(profileRecord.name) ||
|
toText(profileRecord.name) ||
|
||||||
'未命名世界',
|
'未命名世界',
|
||||||
subtitle:
|
subtitle:
|
||||||
(libraryEntry ? toText(libraryEntry.subtitle) : '') ||
|
toText(libraryEntry.subtitle) ||
|
||||||
toText(profileRecord.subtitle) ||
|
toText(profileRecord.subtitle) ||
|
||||||
'已保存作品',
|
'已保存作品',
|
||||||
summary:
|
summary:
|
||||||
(libraryEntry ? toText(libraryEntry.summaryText) : '') ||
|
toText(libraryEntry.summaryText) ||
|
||||||
toText(profileRecord.summary) ||
|
toText(profileRecord.summary) ||
|
||||||
'这个世界已经可以直接进入体验。',
|
'这个世界已经可以直接进入体验。',
|
||||||
coverImageSrc:
|
coverImageSrc: libraryEntry.coverImageSrc || coverPresentation.imageSrc,
|
||||||
(libraryEntry ? libraryEntry.coverImageSrc : null) ||
|
coverRenderMode: coverPresentation.renderMode,
|
||||||
coverPresentation.imageSrc,
|
coverCharacterImageSrcs: coverPresentation.characterImageSrcs,
|
||||||
coverRenderMode: coverPresentation.renderMode,
|
|
||||||
coverCharacterImageSrcs: coverPresentation.characterImageSrcs,
|
|
||||||
updatedAt,
|
|
||||||
publishedAt:
|
|
||||||
(libraryEntry ? toText(libraryEntry.publishedAt) : '') ||
|
|
||||||
toText(profileRecord.publishedAt) ||
|
|
||||||
updatedAt,
|
updatedAt,
|
||||||
stage: 'published',
|
publishedAt:
|
||||||
stageLabel: '已发布',
|
toText(libraryEntry.publishedAt) ||
|
||||||
playableNpcCount:
|
toText(profileRecord.publishedAt) ||
|
||||||
(libraryEntry?.playableNpcCount ?? 0) > 0
|
updatedAt,
|
||||||
? libraryEntry!.playableNpcCount
|
stage: 'published',
|
||||||
: playableNpcs.length,
|
stageLabel: '已发布',
|
||||||
landmarkCount:
|
playableNpcCount:
|
||||||
(libraryEntry?.landmarkCount ?? 0) > 0
|
libraryEntry.playableNpcCount > 0
|
||||||
? libraryEntry!.landmarkCount
|
? libraryEntry.playableNpcCount
|
||||||
: landmarks.length,
|
: playableNpcs.length,
|
||||||
roleVisualReadyCount,
|
landmarkCount:
|
||||||
roleAnimationReadyCount,
|
libraryEntry.landmarkCount > 0
|
||||||
roleAssetSummaryLabel:
|
? libraryEntry.landmarkCount
|
||||||
roleAnimationReadyCount > 0
|
: landmarks.length,
|
||||||
? `动作已就绪 ${roleAnimationReadyCount}`
|
roleVisualReadyCount,
|
||||||
: roleVisualReadyCount > 0
|
roleAnimationReadyCount,
|
||||||
? `主图已就绪 ${roleVisualReadyCount}`
|
roleAssetSummaryLabel:
|
||||||
: null,
|
roleAnimationReadyCount > 0
|
||||||
sessionId: null,
|
? `动作已就绪 ${roleAnimationReadyCount}`
|
||||||
profileId:
|
: roleVisualReadyCount > 0
|
||||||
(libraryEntry ? toText(libraryEntry.profileId) : '') ||
|
? `主图已就绪 ${roleVisualReadyCount}`
|
||||||
toText(profileRecord.id) ||
|
: null,
|
||||||
null,
|
sessionId: null,
|
||||||
canResume: false,
|
profileId:
|
||||||
canEnterWorld: true,
|
toText(libraryEntry.profileId) || toText(profileRecord.id) || null,
|
||||||
};
|
canResume: false,
|
||||||
});
|
canEnterWorld: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return [...draftItems, ...publishedItems].sort((left, right) =>
|
return [...draftItems, ...publishedItems].sort((left, right) =>
|
||||||
right.updatedAt.localeCompare(left.updatedAt),
|
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',
|
'/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;
|
regenerateActionLabel?: string;
|
||||||
enterWorldActionLabel?: string;
|
enterWorldActionLabel?: string;
|
||||||
autoSaveState?: 'idle' | 'saving' | 'saved' | 'error';
|
autoSaveState?: 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
compactAgentResultMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EntityGenerationKind = 'playable' | 'story' | 'landmark';
|
type EntityGenerationKind = 'playable' | 'story' | 'landmark';
|
||||||
@@ -372,6 +373,7 @@ export function CustomWorldResultView({
|
|||||||
regenerateActionLabel = '重新生成',
|
regenerateActionLabel = '重新生成',
|
||||||
enterWorldActionLabel = '进入世界',
|
enterWorldActionLabel = '进入世界',
|
||||||
autoSaveState = 'idle',
|
autoSaveState = 'idle',
|
||||||
|
compactAgentResultMode = false,
|
||||||
}: CustomWorldResultViewProps) {
|
}: CustomWorldResultViewProps) {
|
||||||
const [editorTarget, setEditorTarget] =
|
const [editorTarget, setEditorTarget] =
|
||||||
useState<CustomWorldEditorTarget | null>(null);
|
useState<CustomWorldEditorTarget | null>(null);
|
||||||
@@ -609,9 +611,11 @@ export function CustomWorldResultView({
|
|||||||
onProfileChange={onProfileChange}
|
onProfileChange={onProfileChange}
|
||||||
onDeleteStoryNpcs={handleDeleteStoryNpcs}
|
onDeleteStoryNpcs={handleDeleteStoryNpcs}
|
||||||
onDeleteLandmarks={handleDeleteLandmarks}
|
onDeleteLandmarks={handleDeleteLandmarks}
|
||||||
createActionLabel={readOnly ? undefined : createLabel}
|
createActionLabel={
|
||||||
|
readOnly || compactAgentResultMode ? undefined : createLabel
|
||||||
|
}
|
||||||
onCreateAction={
|
onCreateAction={
|
||||||
readOnly || !createTarget
|
readOnly || compactAgentResultMode || !createTarget
|
||||||
? undefined
|
? undefined
|
||||||
: () => {
|
: () => {
|
||||||
if (activeTab === 'playable') {
|
if (activeTab === 'playable') {
|
||||||
|
|||||||
@@ -135,12 +135,12 @@ export function CustomWorldCreationHub({
|
|||||||
key={item.workId}
|
key={item.workId}
|
||||||
item={item}
|
item={item}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (item.status === 'draft' && item.sessionId) {
|
if (item.sourceType === 'agent_session' && item.sessionId) {
|
||||||
onResumeDraft(item.sessionId);
|
onResumeDraft(item.sessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.status === 'published' && item.profileId) {
|
if (item.profileId) {
|
||||||
onEnterPublished(item.profileId);
|
onEnterPublished(item.profileId);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ import { useState } from 'react';
|
|||||||
import { beforeEach, expect, test, vi } from 'vitest';
|
import { beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||||
|
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||||
import {
|
import {
|
||||||
createCustomWorldAgentSession,
|
createCustomWorldAgentSession,
|
||||||
executeCustomWorldAgentAction,
|
executeCustomWorldAgentAction,
|
||||||
getCustomWorldAgentOperation,
|
getCustomWorldAgentOperation,
|
||||||
getCustomWorldAgentSession,
|
getCustomWorldAgentSession,
|
||||||
|
listCustomWorldWorks,
|
||||||
streamCustomWorldAgentMessage,
|
streamCustomWorldAgentMessage,
|
||||||
} from '../../services/aiService';
|
} from '../../services/aiService';
|
||||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
|
||||||
import type { AuthUser } from '../../services/authService';
|
import type { AuthUser } from '../../services/authService';
|
||||||
import {
|
import {
|
||||||
clearProfileBrowseHistory,
|
clearProfileBrowseHistory,
|
||||||
@@ -54,12 +55,30 @@ async function clickFirstAsyncButtonByName(
|
|||||||
await user.click(buttons[0]!);
|
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', () => ({
|
vi.mock('../../services/aiService', () => ({
|
||||||
createCustomWorldAgentSession: vi.fn(),
|
createCustomWorldAgentSession: vi.fn(),
|
||||||
executeCustomWorldAgentAction: vi.fn(),
|
executeCustomWorldAgentAction: vi.fn(),
|
||||||
generateCustomWorldProfile: vi.fn(),
|
generateCustomWorldProfile: vi.fn(),
|
||||||
getCustomWorldAgentOperation: vi.fn(),
|
getCustomWorldAgentOperation: vi.fn(),
|
||||||
getCustomWorldAgentSession: vi.fn(),
|
getCustomWorldAgentSession: vi.fn(),
|
||||||
|
listCustomWorldWorks: vi.fn(),
|
||||||
streamCustomWorldAgentMessage: vi.fn(),
|
streamCustomWorldAgentMessage: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -340,6 +359,7 @@ beforeEach(() => {
|
|||||||
vi.mocked(createCustomWorldAgentSession).mockResolvedValue({
|
vi.mocked(createCustomWorldAgentSession).mockResolvedValue({
|
||||||
session: mockSession,
|
session: mockSession,
|
||||||
});
|
});
|
||||||
|
vi.mocked(listCustomWorldWorks).mockResolvedValue([]);
|
||||||
vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({
|
vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({
|
||||||
operation: {
|
operation: {
|
||||||
operationId: 'operation-draft-foundation-1',
|
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 />);
|
render(<TestWrapper />);
|
||||||
|
|
||||||
await clickFirstButtonByName(user, '创作');
|
await openCreationHub(user);
|
||||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
const createButtons = await screen.findAllByRole('button', {
|
||||||
|
name: /新建作品/u,
|
||||||
|
});
|
||||||
|
await user.click(createButtons.at(-1)!);
|
||||||
|
|
||||||
expect(screen.getByText('选择创作类型')).toBeTruthy();
|
expect(screen.getByText('选择创作类型')).toBeTruthy();
|
||||||
|
|
||||||
@@ -393,6 +416,52 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
|
|||||||
).toBeTruthy();
|
).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 () => {
|
test('clicking a public work while logged out routes through requireAuth', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const requireAuth = vi.fn();
|
const requireAuth = vi.fn();
|
||||||
@@ -448,10 +517,7 @@ test('selecting RPG creation while logged out routes through requireAuth', async
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await clickFirstButtonByName(user, '创作');
|
await openNewRpgCreation(user);
|
||||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
|
||||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
|
||||||
|
|
||||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||||
expect(createCustomWorldAgentSession).not.toHaveBeenCalled();
|
expect(createCustomWorldAgentSession).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -461,9 +527,7 @@ test('starting draft generation leaves the agent workspace and shows the generat
|
|||||||
|
|
||||||
render(<TestWrapper />);
|
render(<TestWrapper />);
|
||||||
|
|
||||||
await clickFirstButtonByName(user, '创作');
|
await openNewRpgCreation(user);
|
||||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
|
||||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
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();
|
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();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
|
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
|
||||||
@@ -595,9 +659,7 @@ test('existing draft sessions enter the legacy result layout directly', async ()
|
|||||||
|
|
||||||
render(<TestWrapper />);
|
render(<TestWrapper />);
|
||||||
|
|
||||||
await clickFirstButtonByName(user, '创作');
|
await openNewRpgCreation(user);
|
||||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
|
||||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
|
||||||
|
|
||||||
await waitFor(
|
await waitFor(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -611,13 +673,295 @@ test('existing draft sessions enter the legacy result layout directly', async ()
|
|||||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||||
expect(screen.queryByRole('button', { name: /^锚点/u })).toBeNull();
|
expect(screen.queryByRole('button', { name: /^锚点/u })).toBeNull();
|
||||||
expect(screen.getByText(/基本设定/u)).toBeTruthy();
|
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 }));
|
||||||
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();
|
test('agent draft result back button returns to workspace without redundant sync when session is already latest', async () => {
|
||||||
expect(screen.getByRole('button', { name: /AI生成/u })).toBeTruthy();
|
const user = userEvent.setup();
|
||||||
expect(screen.getByText('技能')).toBeTruthy();
|
|
||||||
|
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 () => {
|
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();
|
const user = userEvent.setup();
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
vi.mocked(listCustomWorldLibrary).mockResolvedValue([
|
const publishedWork = {
|
||||||
{
|
workId: 'published:world-delete-1',
|
||||||
ownerUserId: 'user-1',
|
sourceType: 'published_profile' as const,
|
||||||
profileId: 'world-delete-1',
|
status: 'published' as const,
|
||||||
profile: {
|
title: '潮雾列岛',
|
||||||
id: 'world-delete-1',
|
subtitle: '旧灯塔与失控航路',
|
||||||
name: '潮雾列岛',
|
summary: '用于测试删除流程的作品。',
|
||||||
subtitle: '旧灯塔与失控航路',
|
coverImageSrc: null,
|
||||||
summary: '用于测试删除流程的作品。',
|
coverRenderMode: 'image' as const,
|
||||||
tone: '压抑、潮湿、悬疑',
|
coverCharacterImageSrcs: [],
|
||||||
playerGoal: '查清旧案。',
|
updatedAt: '2026-04-16T12:00:00.000Z',
|
||||||
majorFactions: ['守灯会'],
|
publishedAt: '2026-04-16T12:00:00.000Z',
|
||||||
coreConflicts: ['雾潮正在逼近港口'],
|
stage: null,
|
||||||
playableNpcs: [],
|
stageLabel: '已发布',
|
||||||
storyNpcs: [],
|
playableNpcCount: 0,
|
||||||
landmarks: [],
|
landmarkCount: 0,
|
||||||
} as never,
|
roleVisualReadyCount: 0,
|
||||||
visibility: 'draft',
|
roleAnimationReadyCount: 0,
|
||||||
publishedAt: null,
|
roleAssetSummaryLabel: null,
|
||||||
updatedAt: '2026-04-16T12:00:00.000Z',
|
sessionId: null,
|
||||||
authorDisplayName: '测试玩家',
|
profileId: 'world-delete-1',
|
||||||
worldName: '潮雾列岛',
|
canResume: false,
|
||||||
|
canEnterWorld: true,
|
||||||
|
};
|
||||||
|
const publishedLibraryEntry = {
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
profileId: 'world-delete-1',
|
||||||
|
profile: {
|
||||||
|
id: 'world-delete-1',
|
||||||
|
name: '潮雾列岛',
|
||||||
subtitle: '旧灯塔与失控航路',
|
subtitle: '旧灯塔与失控航路',
|
||||||
summaryText: '用于测试删除流程的作品。',
|
summary: '用于测试删除流程的作品。',
|
||||||
coverImageSrc: null,
|
tone: '压抑、潮湿、悬疑',
|
||||||
themeMode: 'tide',
|
playerGoal: '查清旧案。',
|
||||||
playableNpcCount: 0,
|
majorFactions: ['守灯会'],
|
||||||
landmarkCount: 0,
|
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([]);
|
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
|
||||||
|
|
||||||
render(<TestWrapper withAuth />);
|
render(<TestWrapper withAuth />);
|
||||||
|
|
||||||
await clickFirstButtonByName(user, '创作');
|
await openCreationHub(user);
|
||||||
await clickFirstAsyncButtonByName(user, /潮雾列岛/u);
|
await user.click(screen.getByRole('button', { name: /进入世界/u }));
|
||||||
await user.click(await screen.findByRole('button', { name: '删除作品' }));
|
await user.click(await screen.findByRole('button', { name: '删除作品' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -742,8 +1115,77 @@ test('owned world detail can delete a work and return to the create tab list', a
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByRole('button', { name: '删除作品' })).toBeNull();
|
expect(screen.queryByRole('button', { name: '删除作品' })).toBeNull();
|
||||||
});
|
});
|
||||||
expect(
|
await waitFor(() => {
|
||||||
screen.getAllByText('你还没有保存任何自定义世界,先创建一个草稿开始吧。')
|
expect(screen.getByText('还没有作品')).toBeTruthy();
|
||||||
.length,
|
});
|
||||||
).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,
|
CustomWorldAgentMessage,
|
||||||
CustomWorldAgentOperationRecord,
|
CustomWorldAgentOperationRecord,
|
||||||
CustomWorldAgentSessionSnapshot,
|
CustomWorldAgentSessionSnapshot,
|
||||||
|
CustomWorldWorkSummary,
|
||||||
SendCustomWorldAgentMessageRequest,
|
SendCustomWorldAgentMessageRequest,
|
||||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||||
import type {
|
import type {
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
executeCustomWorldAgentAction,
|
executeCustomWorldAgentAction,
|
||||||
getCustomWorldAgentOperation,
|
getCustomWorldAgentOperation,
|
||||||
getCustomWorldAgentSession,
|
getCustomWorldAgentSession,
|
||||||
|
listCustomWorldWorks,
|
||||||
streamCustomWorldAgentMessage,
|
streamCustomWorldAgentMessage,
|
||||||
} from '../../services/aiService';
|
} from '../../services/aiService';
|
||||||
import { buildCustomWorldProfileFromAgentDraft } from '../../services/customWorldAgentDraftResult';
|
import { buildCustomWorldProfileFromAgentDraft } from '../../services/customWorldAgentDraftResult';
|
||||||
@@ -69,6 +71,7 @@ import {
|
|||||||
} from '../../services/storageService';
|
} from '../../services/storageService';
|
||||||
import { type CustomWorldProfile, type GameState } from '../../types';
|
import { type CustomWorldProfile, type GameState } from '../../types';
|
||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
|
import { CustomWorldCreationHub } from '../custom-world-home/CustomWorldCreationHub';
|
||||||
import { PlatformCreationTypeModal } from './PlatformCreationTypeModal';
|
import { PlatformCreationTypeModal } from './PlatformCreationTypeModal';
|
||||||
import { type PlatformHomeTab, PlatformHomeView } from './PlatformHomeView';
|
import { type PlatformHomeTab, PlatformHomeView } from './PlatformHomeView';
|
||||||
import { PlatformWorldDetailView } from './PlatformWorldDetailView';
|
import { PlatformWorldDetailView } from './PlatformWorldDetailView';
|
||||||
@@ -107,6 +110,10 @@ type CustomWorldGenerationViewSource = 'agent-draft-foundation' | null;
|
|||||||
|
|
||||||
type CustomWorldResultViewSource = 'saved-profile' | 'agent-draft' | null;
|
type CustomWorldResultViewSource = 'saved-profile' | 'agent-draft' | null;
|
||||||
type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
type SyncedAgentDraftResult = {
|
||||||
|
session: CustomWorldAgentSessionSnapshot | null;
|
||||||
|
profile: CustomWorldProfile | null;
|
||||||
|
};
|
||||||
|
|
||||||
type PreGameSelectionFlowProps = {
|
type PreGameSelectionFlowProps = {
|
||||||
selectionStage: SelectionStage;
|
selectionStage: SelectionStage;
|
||||||
@@ -164,6 +171,10 @@ function normalizeAgentBackedProfile(profile: CustomWorldProfile) {
|
|||||||
} satisfies CustomWorldProfile;
|
} satisfies CustomWorldProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stringifyAgentBackedProfile(profile: CustomWorldProfile) {
|
||||||
|
return JSON.stringify(normalizeAgentBackedProfile(profile));
|
||||||
|
}
|
||||||
|
|
||||||
function LazyPanelFallback({ label }: { label: string }) {
|
function LazyPanelFallback({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 items-center justify-center">
|
<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({
|
export function PreGameSelectionFlow({
|
||||||
selectionStage,
|
selectionStage,
|
||||||
setSelectionStage,
|
setSelectionStage,
|
||||||
@@ -191,6 +233,9 @@ export function PreGameSelectionFlow({
|
|||||||
const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState<
|
const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState<
|
||||||
CustomWorldLibraryEntry<CustomWorldProfile>[]
|
CustomWorldLibraryEntry<CustomWorldProfile>[]
|
||||||
>([]);
|
>([]);
|
||||||
|
const [customWorldWorkEntries, setCustomWorldWorkEntries] = useState<
|
||||||
|
CustomWorldWorkSummary[]
|
||||||
|
>([]);
|
||||||
const [publishedGalleryEntries, setPublishedGalleryEntries] = useState<
|
const [publishedGalleryEntries, setPublishedGalleryEntries] = useState<
|
||||||
CustomWorldGalleryCard[]
|
CustomWorldGalleryCard[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -250,6 +295,10 @@ export function PreGameSelectionFlow({
|
|||||||
const customWorldAutoSaveTimeoutRef = useRef<number | null>(null);
|
const customWorldAutoSaveTimeoutRef = useRef<number | null>(null);
|
||||||
const lastAutoSavedProfileSignatureRef = useRef<string | null>(null);
|
const lastAutoSavedProfileSignatureRef = useRef<string | null>(null);
|
||||||
const latestAutoSaveRequestIdRef = useRef(0);
|
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>(
|
const platformTabBootstrapUserIdRef = useRef<string | null | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -318,6 +367,17 @@ export function PreGameSelectionFlow({
|
|||||||
}
|
}
|
||||||
}, [authUi?.user]);
|
}, [authUi?.user]);
|
||||||
|
|
||||||
|
const refreshCustomWorldWorks = useCallback(async () => {
|
||||||
|
if (!authUi?.user) {
|
||||||
|
setCustomWorldWorkEntries([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextItems = await listCustomWorldWorks();
|
||||||
|
setCustomWorldWorkEntries(nextItems);
|
||||||
|
return nextItems;
|
||||||
|
}, [authUi?.user]);
|
||||||
|
|
||||||
const appendBrowseHistoryEntry = useCallback(
|
const appendBrowseHistoryEntry = useCallback(
|
||||||
async (entry: PlatformBrowseHistoryWriteEntry) => {
|
async (entry: PlatformBrowseHistoryWriteEntry) => {
|
||||||
const nextEntries = writePlatformBrowseHistory(authUi?.user, entry);
|
const nextEntries = writePlatformBrowseHistory(authUi?.user, entry);
|
||||||
@@ -380,6 +440,7 @@ export function PreGameSelectionFlow({
|
|||||||
setDashboardError(null);
|
setDashboardError(null);
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
setSavedCustomWorldEntries([]);
|
setSavedCustomWorldEntries([]);
|
||||||
|
setCustomWorldWorkEntries([]);
|
||||||
setSaveEntries([]);
|
setSaveEntries([]);
|
||||||
setProfileDashboard(null);
|
setProfileDashboard(null);
|
||||||
}
|
}
|
||||||
@@ -387,12 +448,14 @@ export function PreGameSelectionFlow({
|
|||||||
try {
|
try {
|
||||||
const [
|
const [
|
||||||
libraryEntriesResult,
|
libraryEntriesResult,
|
||||||
|
workEntriesResult,
|
||||||
galleryEntriesResult,
|
galleryEntriesResult,
|
||||||
dashboardResult,
|
dashboardResult,
|
||||||
historyResult,
|
historyResult,
|
||||||
saveArchivesResult,
|
saveArchivesResult,
|
||||||
] = await Promise.allSettled([
|
] = await Promise.allSettled([
|
||||||
isAuthenticated ? listCustomWorldLibrary() : Promise.resolve([]),
|
isAuthenticated ? listCustomWorldLibrary() : Promise.resolve([]),
|
||||||
|
isAuthenticated ? listCustomWorldWorks() : Promise.resolve([]),
|
||||||
listCustomWorldGallery(),
|
listCustomWorldGallery(),
|
||||||
isAuthenticated ? getProfileDashboard() : Promise.resolve(null),
|
isAuthenticated ? getProfileDashboard() : Promise.resolve(null),
|
||||||
isAuthenticated
|
isAuthenticated
|
||||||
@@ -423,6 +486,12 @@ export function PreGameSelectionFlow({
|
|||||||
setSavedCustomWorldEntries([]);
|
setSavedCustomWorldEntries([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (workEntriesResult.status === 'fulfilled') {
|
||||||
|
setCustomWorldWorkEntries(workEntriesResult.value);
|
||||||
|
} else {
|
||||||
|
setCustomWorldWorkEntries([]);
|
||||||
|
}
|
||||||
|
|
||||||
if (galleryEntriesResult.status === 'fulfilled') {
|
if (galleryEntriesResult.status === 'fulfilled') {
|
||||||
setPublishedGalleryEntries(galleryEntriesResult.value);
|
setPublishedGalleryEntries(galleryEntriesResult.value);
|
||||||
} else {
|
} else {
|
||||||
@@ -431,11 +500,14 @@ export function PreGameSelectionFlow({
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
(isAuthenticated && libraryEntriesResult.status === 'rejected') ||
|
(isAuthenticated && libraryEntriesResult.status === 'rejected') ||
|
||||||
|
(isAuthenticated && workEntriesResult.status === 'rejected') ||
|
||||||
galleryEntriesResult.status === 'rejected'
|
galleryEntriesResult.status === 'rejected'
|
||||||
) {
|
) {
|
||||||
const platformFailure =
|
const platformFailure =
|
||||||
libraryEntriesResult.status === 'rejected'
|
libraryEntriesResult.status === 'rejected'
|
||||||
? libraryEntriesResult.reason
|
? libraryEntriesResult.reason
|
||||||
|
: workEntriesResult.status === 'rejected'
|
||||||
|
? workEntriesResult.reason
|
||||||
: galleryEntriesResult.status === 'rejected'
|
: galleryEntriesResult.status === 'rejected'
|
||||||
? galleryEntriesResult.reason
|
? galleryEntriesResult.reason
|
||||||
: null;
|
: null;
|
||||||
@@ -742,9 +814,14 @@ export function PreGameSelectionFlow({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAgentDraftResultAutoOpenSuppressedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (selectionStage === 'agent-workspace') {
|
if (selectionStage === 'agent-workspace') {
|
||||||
setGeneratedCustomWorldProfile(agentDraftResultProfile);
|
setGeneratedCustomWorldProfile(agentDraftResultProfile);
|
||||||
setCustomWorldResultViewSource('agent-draft');
|
setCustomWorldResultViewSource('agent-draft');
|
||||||
|
isAgentDraftResultAutoOpenSuppressedRef.current = false;
|
||||||
setSelectionStage('custom-world-result');
|
setSelectionStage('custom-world-result');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -755,10 +832,12 @@ export function PreGameSelectionFlow({
|
|||||||
) {
|
) {
|
||||||
setGeneratedCustomWorldProfile(agentDraftResultProfile);
|
setGeneratedCustomWorldProfile(agentDraftResultProfile);
|
||||||
setCustomWorldResultViewSource('agent-draft');
|
setCustomWorldResultViewSource('agent-draft');
|
||||||
|
isAgentDraftResultAutoOpenSuppressedRef.current = false;
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
agentDraftResultProfile,
|
agentDraftResultProfile,
|
||||||
generatedCustomWorldProfile,
|
generatedCustomWorldProfile,
|
||||||
|
isAgentDraftResultAutoOpenSuppressedRef,
|
||||||
selectionStage,
|
selectionStage,
|
||||||
setSelectionStage,
|
setSelectionStage,
|
||||||
shouldAutoOpenAgentDraftResult,
|
shouldAutoOpenAgentDraftResult,
|
||||||
@@ -776,6 +855,8 @@ export function PreGameSelectionFlow({
|
|||||||
const isAgentDraftGenerationView =
|
const isAgentDraftGenerationView =
|
||||||
customWorldGenerationViewSource === 'agent-draft-foundation';
|
customWorldGenerationViewSource === 'agent-draft-foundation';
|
||||||
const isAgentDraftResultView = customWorldResultViewSource === 'agent-draft';
|
const isAgentDraftResultView = customWorldResultViewSource === 'agent-draft';
|
||||||
|
const isAgentDraftResultEditingFrozen =
|
||||||
|
customWorldResultViewSource === 'agent-draft';
|
||||||
const activeGenerationSettingText = agentDraftSettingPreview;
|
const activeGenerationSettingText = agentDraftSettingPreview;
|
||||||
const activeGenerationProgress = agentDraftGenerationProgress;
|
const activeGenerationProgress = agentDraftGenerationProgress;
|
||||||
const isActiveGenerationRunning =
|
const isActiveGenerationRunning =
|
||||||
@@ -822,6 +903,7 @@ export function PreGameSelectionFlow({
|
|||||||
|
|
||||||
setIsCreatingAgentSession(true);
|
setIsCreatingAgentSession(true);
|
||||||
setCreationTypeError(null);
|
setCreationTypeError(null);
|
||||||
|
isAgentDraftResultAutoOpenSuppressedRef.current = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { session } = await createCustomWorldAgentSession(
|
const { session } = await createCustomWorldAgentSession(
|
||||||
@@ -921,6 +1003,7 @@ export function PreGameSelectionFlow({
|
|||||||
const isDraftFoundationAction = payload.action === 'draft_foundation';
|
const isDraftFoundationAction = payload.action === 'draft_foundation';
|
||||||
|
|
||||||
if (isDraftFoundationAction) {
|
if (isDraftFoundationAction) {
|
||||||
|
isAgentDraftResultAutoOpenSuppressedRef.current = false;
|
||||||
setGeneratedCustomWorldProfile(null);
|
setGeneratedCustomWorldProfile(null);
|
||||||
setCustomWorldError(null);
|
setCustomWorldError(null);
|
||||||
setCustomWorldAutoSaveError(null);
|
setCustomWorldAutoSaveError(null);
|
||||||
@@ -980,14 +1063,14 @@ export function PreGameSelectionFlow({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const leaveAgentDraftResult = () => {
|
const leaveAgentDraftResult = () => {
|
||||||
|
isAgentDraftResultAutoOpenSuppressedRef.current = true;
|
||||||
setGeneratedCustomWorldProfile(null);
|
setGeneratedCustomWorldProfile(null);
|
||||||
setCustomWorldError(null);
|
setCustomWorldError(null);
|
||||||
setCustomWorldAutoSaveError(null);
|
setCustomWorldAutoSaveError(null);
|
||||||
setCustomWorldAutoSaveState('idle');
|
setCustomWorldAutoSaveState('idle');
|
||||||
setCustomWorldGenerationViewSource(null);
|
setCustomWorldGenerationViewSource(null);
|
||||||
setCustomWorldResultViewSource(null);
|
setCustomWorldResultViewSource(null);
|
||||||
setPlatformTab('create');
|
setSelectionStage('agent-workspace');
|
||||||
setSelectionStage('platform');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const retryAgentDraftGeneration = () => {
|
const retryAgentDraftGeneration = () => {
|
||||||
@@ -1000,25 +1083,79 @@ export function PreGameSelectionFlow({
|
|||||||
openCreationTypePicker();
|
openCreationTypePicker();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openLibraryDetail = (
|
const openLibraryDetail = useCallback(
|
||||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
|
||||||
) => {
|
if (entry.visibility === 'published') {
|
||||||
if (entry.visibility === 'published') {
|
void appendBrowseHistoryEntry({
|
||||||
void appendBrowseHistoryEntry({
|
ownerUserId: entry.ownerUserId,
|
||||||
ownerUserId: entry.ownerUserId,
|
profileId: entry.profileId,
|
||||||
profileId: entry.profileId,
|
worldName: entry.worldName,
|
||||||
worldName: entry.worldName,
|
subtitle: entry.subtitle,
|
||||||
subtitle: entry.subtitle,
|
summaryText: entry.summaryText,
|
||||||
summaryText: entry.summaryText,
|
coverImageSrc: entry.coverImageSrc,
|
||||||
coverImageSrc: entry.coverImageSrc,
|
themeMode: entry.themeMode,
|
||||||
themeMode: entry.themeMode,
|
authorDisplayName: entry.authorDisplayName,
|
||||||
authorDisplayName: entry.authorDisplayName,
|
});
|
||||||
});
|
}
|
||||||
}
|
setSelectedDetailEntry(entry);
|
||||||
setSelectedDetailEntry(entry);
|
setDetailError(null);
|
||||||
setDetailError(null);
|
setSelectionStage('detail');
|
||||||
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) => {
|
const openGalleryDetail = async (entry: CustomWorldGalleryCard) => {
|
||||||
setSelectionStage('detail');
|
setSelectionStage('detail');
|
||||||
@@ -1083,7 +1220,7 @@ export function PreGameSelectionFlow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedProfile = normalizeAgentBackedProfile(profile);
|
const normalizedProfile = normalizeAgentBackedProfile(profile);
|
||||||
const profileSignature = JSON.stringify(normalizedProfile);
|
const profileSignature = stringifyAgentBackedProfile(normalizedProfile);
|
||||||
const requestId = latestAutoSaveRequestIdRef.current + 1;
|
const requestId = latestAutoSaveRequestIdRef.current + 1;
|
||||||
latestAutoSaveRequestIdRef.current = requestId;
|
latestAutoSaveRequestIdRef.current = requestId;
|
||||||
setCustomWorldAutoSaveState('saving');
|
setCustomWorldAutoSaveState('saving');
|
||||||
@@ -1097,6 +1234,9 @@ export function PreGameSelectionFlow({
|
|||||||
|
|
||||||
lastAutoSavedProfileSignatureRef.current = profileSignature;
|
lastAutoSavedProfileSignatureRef.current = profileSignature;
|
||||||
setSavedCustomWorldEntries(mutation.entries);
|
setSavedCustomWorldEntries(mutation.entries);
|
||||||
|
if (authUi?.user) {
|
||||||
|
void refreshCustomWorldWorks().catch(() => {});
|
||||||
|
}
|
||||||
setSelectedDetailEntry((current) => {
|
setSelectedDetailEntry((current) => {
|
||||||
if (!current || current.profileId === mutation.entry.profileId) {
|
if (!current || current.profileId === mutation.entry.profileId) {
|
||||||
return mutation.entry;
|
return mutation.entry;
|
||||||
@@ -1119,7 +1259,99 @@ export function PreGameSelectionFlow({
|
|||||||
return null;
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -1127,6 +1359,7 @@ export function PreGameSelectionFlow({
|
|||||||
setCustomWorldAutoSaveState('idle');
|
setCustomWorldAutoSaveState('idle');
|
||||||
setCustomWorldAutoSaveError(null);
|
setCustomWorldAutoSaveError(null);
|
||||||
lastAutoSavedProfileSignatureRef.current = null;
|
lastAutoSavedProfileSignatureRef.current = null;
|
||||||
|
latestAgentResultSyncSignatureRef.current = null;
|
||||||
if (customWorldAutoSaveTimeoutRef.current !== null) {
|
if (customWorldAutoSaveTimeoutRef.current !== null) {
|
||||||
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
|
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
|
||||||
customWorldAutoSaveTimeoutRef.current = null;
|
customWorldAutoSaveTimeoutRef.current = null;
|
||||||
@@ -1138,7 +1371,11 @@ export function PreGameSelectionFlow({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSignature = JSON.stringify(generatedCustomWorldProfile);
|
if (isCustomWorldAutoSaveBusyRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSignature = stringifyAgentBackedProfile(generatedCustomWorldProfile);
|
||||||
if (nextSignature === lastAutoSavedProfileSignatureRef.current) {
|
if (nextSignature === lastAutoSavedProfileSignatureRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1150,7 +1387,28 @@ export function PreGameSelectionFlow({
|
|||||||
|
|
||||||
const profileToSave = generatedCustomWorldProfile;
|
const profileToSave = generatedCustomWorldProfile;
|
||||||
customWorldAutoSaveTimeoutRef.current = window.setTimeout(() => {
|
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;
|
customWorldAutoSaveTimeoutRef.current = null;
|
||||||
}, 600);
|
}, 600);
|
||||||
|
|
||||||
@@ -1160,7 +1418,13 @@ export function PreGameSelectionFlow({
|
|||||||
customWorldAutoSaveTimeoutRef.current = null;
|
customWorldAutoSaveTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [generatedCustomWorldProfile, saveGeneratedCustomWorld, selectionStage]);
|
}, [
|
||||||
|
generatedCustomWorldProfile,
|
||||||
|
isAgentDraftResultView,
|
||||||
|
saveGeneratedCustomWorld,
|
||||||
|
selectionStage,
|
||||||
|
syncAgentDraftResultProfile,
|
||||||
|
]);
|
||||||
|
|
||||||
const openSavedCustomWorldEditor = (
|
const openSavedCustomWorldEditor = (
|
||||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||||
@@ -1200,6 +1464,7 @@ export function PreGameSelectionFlow({
|
|||||||
selectedDetailEntry.profileId,
|
selectedDetailEntry.profileId,
|
||||||
);
|
);
|
||||||
setSavedCustomWorldEntries(mutation.entries);
|
setSavedCustomWorldEntries(mutation.entries);
|
||||||
|
await refreshCustomWorldWorks().catch(() => []);
|
||||||
setSelectedDetailEntry(mutation.entry);
|
setSelectedDetailEntry(mutation.entry);
|
||||||
setPublishedGalleryEntries(await listCustomWorldGallery());
|
setPublishedGalleryEntries(await listCustomWorldGallery());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1221,6 +1486,7 @@ export function PreGameSelectionFlow({
|
|||||||
selectedDetailEntry.profileId,
|
selectedDetailEntry.profileId,
|
||||||
);
|
);
|
||||||
setSavedCustomWorldEntries(mutation.entries);
|
setSavedCustomWorldEntries(mutation.entries);
|
||||||
|
await refreshCustomWorldWorks().catch(() => []);
|
||||||
setSelectedDetailEntry(mutation.entry);
|
setSelectedDetailEntry(mutation.entry);
|
||||||
setPublishedGalleryEntries(await listCustomWorldGallery());
|
setPublishedGalleryEntries(await listCustomWorldGallery());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1249,6 +1515,7 @@ export function PreGameSelectionFlow({
|
|||||||
selectedDetailEntry.profileId,
|
selectedDetailEntry.profileId,
|
||||||
);
|
);
|
||||||
setSavedCustomWorldEntries(entries);
|
setSavedCustomWorldEntries(entries);
|
||||||
|
await refreshCustomWorldWorks().catch(() => []);
|
||||||
setSelectedDetailEntry(null);
|
setSelectedDetailEntry(null);
|
||||||
setPlatformTab('create');
|
setPlatformTab('create');
|
||||||
setSelectionStage('platform');
|
setSelectionStage('platform');
|
||||||
@@ -1269,6 +1536,10 @@ export function PreGameSelectionFlow({
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const resultViewError = customWorldAutoSaveError ?? customWorldError;
|
const resultViewError = customWorldAutoSaveError ?? customWorldError;
|
||||||
|
const creationHubItems =
|
||||||
|
customWorldWorkEntries.length > 0
|
||||||
|
? customWorldWorkEntries
|
||||||
|
: buildCreationHubFallbackItems(savedCustomWorldEntries);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1281,47 +1552,106 @@ export function PreGameSelectionFlow({
|
|||||||
exit={{ opacity: 0, y: -12 }}
|
exit={{ opacity: 0, y: -12 }}
|
||||||
className="flex h-full min-h-0 flex-col"
|
className="flex h-full min-h-0 flex-col"
|
||||||
>
|
>
|
||||||
<PlatformHomeView
|
{platformTab === 'create' ? (
|
||||||
activeTab={platformTab}
|
<CustomWorldCreationHub
|
||||||
onTabChange={setPlatformTab}
|
items={creationHubItems}
|
||||||
hasSavedGame={hasSavedGame}
|
loading={isLoadingPlatform}
|
||||||
savedSnapshot={savedSnapshot}
|
error={isLoadingPlatform ? null : (platformError ?? creationTypeError)}
|
||||||
saveEntries={saveEntries}
|
onBack={() => {
|
||||||
saveError={saveError}
|
setPlatformTab('home');
|
||||||
featuredEntries={featuredGalleryEntries}
|
}}
|
||||||
latestEntries={publishedGalleryEntries}
|
onRetry={() => {
|
||||||
myEntries={savedCustomWorldEntries}
|
setPlatformError(null);
|
||||||
historyEntries={historyEntries}
|
void refreshCustomWorldWorks().catch((error) => {
|
||||||
profileDashboard={profileDashboard}
|
setPlatformError(
|
||||||
isLoadingPlatform={isLoadingPlatform}
|
resolveErrorMessage(error, '读取创作作品列表失败。'),
|
||||||
isLoadingDashboard={isLoadingDashboard}
|
);
|
||||||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
});
|
||||||
platformError={
|
}}
|
||||||
isLoadingPlatform ? null : (platformError ?? creationTypeError)
|
onCreateNew={openCreationTypePicker}
|
||||||
}
|
onResumeDraft={(sessionId) => {
|
||||||
dashboardError={isLoadingDashboard ? null : dashboardError}
|
runProtectedAction(() => {
|
||||||
onContinueGame={handleContinueGame}
|
void handleOpenCreationWork({
|
||||||
onResumeSave={(entry) => {
|
workId: `draft:${sessionId}`,
|
||||||
void handleResumeSaveEntry(entry);
|
sourceType: 'agent_session',
|
||||||
}}
|
status: 'draft',
|
||||||
onOpenCreateWorld={openCustomWorldCreator}
|
title: '',
|
||||||
onOpenCreateTypePicker={openCreationTypePicker}
|
subtitle: '',
|
||||||
onOpenGalleryDetail={(entry) => {
|
summary: '',
|
||||||
runProtectedAction(() => {
|
coverImageSrc: null,
|
||||||
void openGalleryDetail(entry);
|
coverRenderMode: 'image',
|
||||||
});
|
coverCharacterImageSrcs: [],
|
||||||
}}
|
updatedAt: new Date().toISOString(),
|
||||||
onOpenLibraryDetail={(entry) => {
|
publishedAt: null,
|
||||||
runProtectedAction(() => {
|
stage: null,
|
||||||
openLibraryDetail(entry);
|
stageLabel: '',
|
||||||
});
|
playableNpcCount: 0,
|
||||||
}}
|
landmarkCount: 0,
|
||||||
onOpenProfileDashboardCard={() => {
|
roleVisualReadyCount: 0,
|
||||||
if (dashboardError) {
|
roleAnimationReadyCount: 0,
|
||||||
void refreshProfileDashboard();
|
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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1501,7 +1831,28 @@ export function PreGameSelectionFlow({
|
|||||||
}}
|
}}
|
||||||
onBack={
|
onBack={
|
||||||
isAgentDraftResultView
|
isAgentDraftResultView
|
||||||
? leaveAgentDraftResult
|
? () => {
|
||||||
|
void (async () => {
|
||||||
|
const currentProfile =
|
||||||
|
generatedCustomWorldProfile ??
|
||||||
|
buildCustomWorldProfileFromAgentDraft(
|
||||||
|
agentSession,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentProfile && activeAgentSessionId) {
|
||||||
|
await syncAgentDraftResultProfile(currentProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
leaveAgentDraftResult();
|
||||||
|
})().catch((error) => {
|
||||||
|
setCustomWorldError(
|
||||||
|
resolveErrorMessage(
|
||||||
|
error,
|
||||||
|
'返回创作前同步草稿失败。',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
: leaveCustomWorldResult
|
: leaveCustomWorldResult
|
||||||
}
|
}
|
||||||
onEditSetting={undefined}
|
onEditSetting={undefined}
|
||||||
@@ -1509,10 +1860,40 @@ export function PreGameSelectionFlow({
|
|||||||
onContinueExpand={undefined}
|
onContinueExpand={undefined}
|
||||||
onEnterWorld={() => {
|
onEnterWorld={() => {
|
||||||
runProtectedAction(() => {
|
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}
|
backLabel={isAgentDraftResultView ? '返回创作' : undefined}
|
||||||
editActionLabel="去Agent调整设定"
|
editActionLabel="去Agent调整设定"
|
||||||
enterWorldActionLabel="进入世界"
|
enterWorldActionLabel="进入世界"
|
||||||
|
|||||||
Reference in New Issue
Block a user