This commit is contained in:
2026-04-21 00:48:17 +08:00
parent 75944b1f1f
commit effe0355bd
19 changed files with 2897 additions and 180 deletions

View File

@@ -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. 一句话结论
这轮工程大清洗的核心,不是“删旧代码看起来更清爽”,而是:
**用一轮有台账、有判定、有阶段、有验收的大清理,把无用历史代码、隐形多链路乱代码和半成品能力从主工程里真正清出去,让项目重新回到单一主链、单一真相源、目录可读、职责清楚的健康状态。**

View File

@@ -2,6 +2,7 @@
## 当前入口
- [ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md](./ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md):面向无用历史代码、隐形多数据链路和半成品实现的一轮工程大清洗执行计划,强调先建台账、再删重收口、最后恢复主工程可读性。
- [CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md](./CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md):当前阶段最值得优先做什么、为什么,以及它和审计/PRD 的对应关系。
- [CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md](./CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md):在不新增前端创作流程的前提下,围绕当前 Agent 创作动线做收口、删重、补通和文档收束的大白话执行规划。
- [EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md](./EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md):基于“前端只做表现、逻辑与数据全部后端化”的工程重构规划。

View 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 的历史职责

View File

@@ -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. 已保存作品的结果页编辑能力不受影响

View 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 结果页只负责预览与收口,不再继续充当旧编辑器。**

View File

@@ -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 主链与已保存作品编辑链仍然可用

View File

@@ -9,6 +9,10 @@
- [CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md](./CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md):世界草稿生成失败后等待页误显示为“卡在编译草稿卡”的根因拆解、主链与增强链路边界,以及本次修复策略。
- [CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md](./CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md):世界草稿里“资产已生成但结果页看不到”的根因拆解,包含角色主形象展示、分幕背景露出和 fallback 资源格式修复。
- [CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md](./CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md)Phase4 新增角色/地点后草稿作品卡数量统计与测试断言的语义对齐说明。
- [AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md):阶段一保持结果页深度编辑能力不变,同时把结果页完整世界快照同步回 Agent session 主链的方案说明。
- [AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md):阶段二把平台创作入口统一到聚合作品列表,并收紧 Agent 结果页的新增入口职责边界。
- [AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md):阶段三继续降级旧 pipeline让创作中心只认 Agent 草稿与已发布作品,并把 Agent 结果页冻结为预览收口层。
- [CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md](./CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md):对照当前优化计划核查四阶段完成度,并明确这轮只允许物理删除旧 `custom-world/sessions` 世界生成链,不误伤 Agent 主链与已保存作品兼容编辑链。
- [TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md](./TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md):把外部仓库 TXT 模式完整迁入当前项目的冻结边界、模块映射、分阶段计划与验收清单。
- [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。
- [EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md](./EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md)Express 后端当前 contract 冻结版本、热点文件编辑规则与集成窗口清单。

View File

@@ -428,6 +428,7 @@ export type CustomWorldAgentOperationType =
| 'regenerate_scope'
| 'draft_foundation'
| 'update_draft_card'
| 'sync_result_profile'
| 'generate_characters'
| 'generate_landmarks'
| 'generate_role_assets'
@@ -497,6 +498,10 @@ export type CustomWorldAgentActionRequest =
value: string;
}>;
}
| {
action: 'sync_result_profile';
profile: Record<string, unknown>;
}
| {
action: 'generate_characters';
count: number;

View File

@@ -2503,6 +2503,34 @@ test('custom world works endpoint returns draft sessions and published worlds to
assert.equal(publishResponse.status, 200);
const publishMutationResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/world-published/publish`,
withBearer(entry.token, {
method: 'POST',
}),
);
assert.equal(publishMutationResponse.status, 200);
const draftOnlyResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/world-draft-only`,
withBearer(entry.token, {
method: 'PUT',
body: JSON.stringify({
profile: {
id: 'world-draft-only',
name: '旧兼容草稿',
subtitle: '仍保留在作品库,但不再进入创作中心',
summary: '这个条目用来验证阶段三不会继续把 library draft 当主草稿展示。',
playableNpcs: [{ id: 'hero-draft', name: '旧草稿角色' }],
landmarks: [{ id: 'port-draft', name: '旧草稿地点' }],
},
}),
}),
);
assert.equal(draftOnlyResponse.status, 200);
const worksResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/works`,
{
@@ -2542,6 +2570,10 @@ test('custom world works endpoint returns draft sessions and published worlds to
item.canEnterWorld === true,
),
);
assert.equal(
worksPayload.items.some((item) => item.profileId === 'world-draft-only'),
false,
);
});
});
@@ -3038,6 +3070,117 @@ test('custom world agent update_draft_card action updates draft profile and card
);
});
test('custom world agent sync_result_profile action writes result snapshot back over http', async () => {
await withTestServer(
'custom-world-agent-sync-result-profile-http',
async ({ baseUrl, context }) => {
installTestCustomWorldAgentSingleTurnLlm(context);
const entry = await authEntry(
baseUrl,
'cw_agent_sync_result',
'secret123',
);
const session = await createObjectRefiningCustomWorldAgentSession({
baseUrl,
token: entry.token,
});
const actionResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`,
withBearer(entry.token, {
method: 'POST',
body: JSON.stringify({
action: 'sync_result_profile',
profile: {
id: `agent-draft-${session.sessionId}`,
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·结果页回写版',
subtitle: '旧灯塔与失控航路',
summary: '结果页里的最新世界概述已经回写到当前草稿。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船夜与假航灯背后的操盘链。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试',
generatedFrom: {
worldType: 'CUSTOM',
worldName: '潮雾列岛·结果页回写版',
settingSummary: '测试',
tone: '测试',
conflictCore: '测试',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
},
}),
}),
);
const actionPayload = (await actionResponse.json()) as {
operation: {
operationId: string;
status: string;
};
};
assert.equal(actionResponse.status, 200);
assert.equal(actionPayload.operation.status, 'queued');
await waitForCustomWorldAgentOperation({
baseUrl,
token: entry.token,
sessionId: session.sessionId,
operationId: actionPayload.operation.operationId,
expectedStatus: 'completed',
});
const sessionResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`,
{
headers: {
Authorization: `Bearer ${entry.token}`,
},
},
);
const sessionPayload = (await sessionResponse.json()) as {
draftProfile: {
name?: string;
summary?: string;
legacyResultProfile?: {
name?: string;
playerGoal?: string;
};
} | null;
};
assert.equal(sessionResponse.status, 200);
assert.equal(sessionPayload.draftProfile?.name, '潮雾列岛·结果页回写版');
assert.equal(
sessionPayload.draftProfile?.summary,
'结果页里的最新世界概述已经回写到当前草稿。',
);
assert.equal(
sessionPayload.draftProfile?.legacyResultProfile?.name,
'潮雾列岛·结果页回写版',
);
assert.equal(
sessionPayload.draftProfile?.legacyResultProfile?.playerGoal,
'查清沉船夜与假航灯背后的操盘链。',
);
},
);
});
test('custom world agent generate_characters action appends character cards over http', async () => {
await withTestServer(
'custom-world-agent-phase4-generate-characters-http',

View File

@@ -39,6 +39,10 @@ const actionSchema = z.discriminatedUnion('action', [
)
.min(1),
}),
z.object({
action: z.literal('sync_result_profile'),
profile: z.record(z.string(), z.unknown()),
}),
z.object({
action: z.literal('generate_characters'),
count: z.number().int().min(1).max(3),

View 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);
});

View File

@@ -16,6 +16,8 @@ import type {
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import { badRequest, notFound } from '../errors.js';
import { prepareEventStreamResponse } from '../http.js';
import { normalizeCustomWorldProfile } from '../modules/custom-world/runtimeProfile.js';
import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js';
import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js';
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js';
@@ -150,6 +152,8 @@ function buildOperation(type: CustomWorldAgentOperationRecord['type']) {
? '正在把已确认设定编成第一版世界底稿。'
: type === 'update_draft_card'
? '正在把这次设定改动写回草稿。'
: type === 'sync_result_profile'
? '正在把结果页里的世界快照同步回当前草稿。'
: type === 'generate_characters'
? '正在围绕当前底稿补出新角色。'
: type === 'generate_landmarks'
@@ -194,6 +198,27 @@ function buildRoleAssetSyncResultText(params: {
return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}`;
}
function syncResultProfileIntoDraftProfile(params: {
currentDraftProfile: Record<string, unknown> | null | undefined;
resultProfile: CustomWorldProfile;
}) {
const currentDraftProfile = params.currentDraftProfile ?? {};
const resultProfile = params.resultProfile;
return {
// 阶段一只回写基础摘要和完整 legacy 快照,避免把结果页的运行时结构反向拆回 foundation draft。
...currentDraftProfile,
name: resultProfile.name,
subtitle: resultProfile.subtitle,
summary: resultProfile.summary,
tone: resultProfile.tone,
playerGoal: resultProfile.playerGoal,
majorFactions: resultProfile.majorFactions,
coreConflicts: resultProfile.coreConflicts,
legacyResultProfile: resultProfile as unknown as Record<string, unknown>,
} satisfies Record<string, unknown>;
}
function buildQuestionLines(
pendingClarifications: CustomWorldPendingClarification[],
) {
@@ -548,6 +573,7 @@ export class CustomWorldAgentOrchestrator {
if (
payload.action === 'update_draft_card' ||
payload.action === 'sync_result_profile' ||
payload.action === 'generate_characters' ||
payload.action === 'generate_landmarks' ||
payload.action === 'generate_role_assets' ||
@@ -595,6 +621,32 @@ export class CustomWorldAgentOrchestrator {
};
}
if (payload.action === 'sync_result_profile') {
const normalizedProfile = normalizeCustomWorldProfile(
payload.profile,
'',
);
if (!normalizedProfile) {
throw badRequest('sync_result_profile requires a valid profile');
}
const operation = buildOperation('sync_result_profile');
await this.sessionStore.createOperation(userId, sessionId, operation);
void this.processSyncResultProfileOperation({
userId,
sessionId,
operationId: operation.operationId,
payload: {
...payload,
profile: normalizedProfile as unknown as Record<string, unknown>,
},
});
return {
operation,
};
}
if (payload.action === 'generate_characters') {
if (payload.count < 1 || payload.count > 3) {
throw badRequest('generate_characters count must be between 1 and 3');
@@ -1113,6 +1165,97 @@ export class CustomWorldAgentOrchestrator {
}
}
private async processSyncResultProfileOperation(params: {
userId: string;
sessionId: string;
operationId: string;
payload: Extract<
CustomWorldAgentActionRequest,
{ action: 'sync_result_profile' }
>;
}) {
const { userId, sessionId, operationId, payload } = params;
try {
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'running',
phaseLabel: '同步结果页快照',
phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。',
progress: 36,
});
const latestSession = (await this.sessionStore.get(
userId,
sessionId,
)) as CustomWorldAgentSessionRecord | null;
if (!latestSession) {
throw new Error('custom world agent session not found');
}
const resultProfile = payload.profile as unknown as CustomWorldProfile;
const nextDraftProfile = syncResultProfileIntoDraftProfile({
currentDraftProfile: latestSession.draftProfile,
resultProfile,
});
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
phaseLabel: '重编译草稿摘要',
phaseDetail: '正在刷新草稿卡摘要、资产覆盖和结果页恢复快照。',
progress: 72,
});
const nextDraftCards = this.draftCompiler.compileDraftCards(nextDraftProfile);
const assetCoverage = rebuildRoleAssetCoverage(nextDraftProfile);
const nextStage =
latestSession.stage === 'visual_refining'
? ('visual_refining' as const)
: ('object_refining' as const);
const nextSuggestedActions = buildSuggestedActions({
stage: nextStage,
isReady: true,
draftProfile: nextDraftProfile,
draftCards: nextDraftCards,
});
await this.sessionStore.replaceDerivedState(userId, sessionId, {
stage: nextStage,
draftProfile: nextDraftProfile,
draftCards: nextDraftCards,
assetCoverage,
suggestedActions: nextSuggestedActions,
recommendedReplies: [],
});
await this.sessionStore.appendCheckpoint(userId, sessionId, {
label: '同步结果页编辑',
});
await this.sessionStore.appendMessage(
userId,
sessionId,
buildActionResultMessage({
relatedOperationId: operationId,
text: '结果页里的最新世界结构已经同步回当前草稿。',
}),
);
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'completed',
phaseLabel: '结果页快照已同步',
phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。',
progress: 100,
error: null,
});
} catch (error) {
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'failed',
phaseLabel: '结果页同步失败',
phaseDetail: '这一轮结果页编辑没有成功写回当前草稿。',
progress: 100,
error:
error instanceof Error ? error.message : 'sync result profile failed',
});
}
}
private async processGenerateCharactersOperation(params: {
userId: string;
sessionId: string;

View File

@@ -227,6 +227,200 @@ test('phase4 update_draft_card writes back draft profile and recompiles summarie
);
});
test('phase4 sync_result_profile writes result-page snapshot back into session draft chain', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-sync-result-profile';
const session = await createObjectRefiningSession(orchestrator, userId);
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'sync_result_profile',
profile: {
id: `agent-draft-${session.sessionId}`,
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·结果页精修版',
subtitle: '旧灯塔与失控航路',
summary: '结果页已经把世界概述继续往沉船夜暗线收紧。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船夜与假航灯的真正操盘者。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试',
generatedFrom: {
worldType: 'CUSTOM',
worldName: '潮雾列岛·结果页精修版',
settingSummary: '测试',
tone: '测试',
conflictCore: '测试',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
},
});
const operation = await waitForOperation(
orchestrator,
userId,
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile);
const draftRecord = snapshot?.draftProfile as Record<string, unknown> | null;
const legacyResultProfile = draftRecord?.legacyResultProfile as
| Record<string, unknown>
| undefined;
assert.equal(operation?.status, 'completed');
assert.equal(profile?.name, '潮雾列岛·结果页精修版');
assert.equal(
profile?.summary,
'结果页已经把世界概述继续往沉船夜暗线收紧。',
);
assert.equal(legacyResultProfile?.name, '潮雾列岛·结果页精修版');
assert.equal(
legacyResultProfile?.playerGoal,
'查清沉船夜与假航灯的真正操盘者。',
);
assert.ok(
snapshot?.messages.some(
(message) =>
message.kind === 'action_result' &&
message.text.includes('结果页里的最新世界结构已经同步回当前草稿'),
),
);
});
test('phase4 sync_result_profile keeps existing foundation structure while updating summary snapshot', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-sync-result-profile-structure';
const session = await createObjectRefiningSession(orchestrator, userId);
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile);
const baselinePlayableName = baselineProfile?.playableNpcs[0]?.name;
const baselineStoryName = baselineProfile?.storyNpcs[0]?.name;
const baselineLandmarkName = baselineProfile?.landmarks[0]?.name;
assert.ok(baselinePlayableName);
assert.ok(baselineStoryName);
assert.ok(baselineLandmarkName);
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'sync_result_profile',
profile: {
id: `agent-draft-${session.sessionId}`,
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·结果页精修版',
subtitle: '旧灯塔与失控航路',
summary: '结果页已经把世界概述继续往沉船夜暗线收紧。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船夜与假航灯的真正操盘者。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试',
generatedFrom: {
worldType: 'CUSTOM',
worldName: '潮雾列岛·结果页精修版',
settingSummary: '测试',
tone: '测试',
conflictCore: '测试',
},
slots: [],
},
playableNpcs: [
{
id: 'playable-runtime-only',
name: '结果页临时角色',
title: '运行时角色',
role: '测试角色',
description: '不应该直接覆盖 foundation draft。',
backstory: '仅用于验证 sync 边界。',
personality: '谨慎',
motivation: '验证同步边界',
combatStyle: '观察',
initialAffinity: 0,
relationshipHooks: [],
tags: [],
},
],
storyNpcs: [
{
id: 'story-runtime-only',
name: '结果页临时场景角色',
title: '运行时场景角色',
role: '测试角色',
description: '不应该直接覆盖 foundation draft。',
backstory: '仅用于验证 sync 边界。',
personality: '克制',
motivation: '验证同步边界',
combatStyle: '观察',
initialAffinity: 0,
relationshipHooks: [],
tags: [],
},
],
items: [],
landmarks: [
{
id: 'landmark-runtime-only',
name: '结果页临时地点',
description: '不应该直接覆盖 foundation draft。',
dangerLevel: '低',
sceneNpcIds: [],
connections: [],
},
],
generationMode: 'full',
generationStatus: 'complete',
},
});
const operation = await waitForOperation(
orchestrator,
userId,
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile);
const draftRecord = snapshot?.draftProfile as Record<string, unknown> | null;
const legacyResultProfile = draftRecord?.legacyResultProfile as
| Record<string, unknown>
| undefined;
assert.equal(operation?.status, 'completed');
assert.equal(profile?.name, '潮雾列岛·结果页精修版');
assert.equal(profile?.playableNpcs[0]?.name, baselinePlayableName);
assert.equal(profile?.storyNpcs[0]?.name, baselineStoryName);
assert.equal(profile?.landmarks[0]?.name, baselineLandmarkName);
assert.equal(legacyResultProfile?.name, '潮雾列岛·结果页精修版');
assert.equal(
(legacyResultProfile?.playableNpcs as Array<{ name?: string }> | undefined)?.[0]
?.name,
'结果页临时角色',
);
});
test('phase4 generate_characters appends story npcs and updates work summary counts', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
@@ -323,3 +517,33 @@ test('phase4 generate_landmarks appends new landmark cards and checkpoints', asy
);
assert.ok((latestSessionRecord?.checkpoints.length ?? 0) >= 2);
});
test('phase4 work summaries exclude library draft entries after phase3 downgrade', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-work-summary-phase3';
const session = await createObjectRefiningSession(orchestrator, userId);
await runtimeRepository.upsertCustomWorldProfile(userId, 'library-draft-1', {
id: 'library-draft-1',
name: '旧兼容草稿',
subtitle: '仍保留在作品库',
summary: '不应该继续出现在创作中心 works 聚合里。',
playableNpcs: [],
landmarks: [],
});
const workItems = await listCustomWorldWorkSummaries(userId, {
runtimeRepository,
customWorldAgentSessions: sessionStore,
});
assert.ok(workItems.some((item) => item.sessionId === session.sessionId));
assert.equal(
workItems.some((item) => item.profileId === 'library-draft-1'),
false,
);
});

View File

@@ -171,6 +171,12 @@ function isLibraryEntry(
);
}
function isPublishedLibraryEntry(
value: unknown,
): value is CustomWorldLibraryEntry<CustomWorldProfileRecord> {
return isLibraryEntry(value) && value.visibility === 'published';
}
export async function listCustomWorldWorkSummaries(
userId: string,
dependencies: {
@@ -216,8 +222,10 @@ export async function listCustomWorldWorkSummaries(
};
});
const publishedItems: CustomWorldWorkSummary[] = profiles.map((profile) => {
const libraryEntry = isLibraryEntry(profile) ? profile : null;
const publishedItems: CustomWorldWorkSummary[] = profiles
.filter((profile) => isPublishedLibraryEntry(profile))
.map((profile) => {
const libraryEntry = profile;
const profileRecord = (
libraryEntry?.profile ?? profile
) as CustomWorldProfileRecord & Record<string, unknown>;
@@ -237,59 +245,55 @@ export async function listCustomWorldWorkSummaries(
(entry) => Boolean(toText(entry.generatedAnimationSetId)),
).length;
return {
workId: `published:${toText(profileRecord.id) || updatedAt}`,
sourceType: 'published_profile',
status: 'published',
title:
(libraryEntry ? toText(libraryEntry.worldName) : '') ||
toText(profileRecord.name) ||
'未命名世界',
subtitle:
(libraryEntry ? toText(libraryEntry.subtitle) : '') ||
toText(profileRecord.subtitle) ||
'已保存作品',
summary:
(libraryEntry ? toText(libraryEntry.summaryText) : '') ||
toText(profileRecord.summary) ||
'这个世界已经可以直接进入体验。',
coverImageSrc:
(libraryEntry ? libraryEntry.coverImageSrc : null) ||
coverPresentation.imageSrc,
coverRenderMode: coverPresentation.renderMode,
coverCharacterImageSrcs: coverPresentation.characterImageSrcs,
updatedAt,
publishedAt:
(libraryEntry ? toText(libraryEntry.publishedAt) : '') ||
toText(profileRecord.publishedAt) ||
return {
workId: `published:${toText(profileRecord.id) || updatedAt}`,
sourceType: 'published_profile',
status: 'published',
title:
toText(libraryEntry.worldName) ||
toText(profileRecord.name) ||
'未命名世界',
subtitle:
toText(libraryEntry.subtitle) ||
toText(profileRecord.subtitle) ||
'已保存作品',
summary:
toText(libraryEntry.summaryText) ||
toText(profileRecord.summary) ||
'这个世界已经可以直接进入体验。',
coverImageSrc: libraryEntry.coverImageSrc || coverPresentation.imageSrc,
coverRenderMode: coverPresentation.renderMode,
coverCharacterImageSrcs: coverPresentation.characterImageSrcs,
updatedAt,
stage: 'published',
stageLabel: '已发布',
playableNpcCount:
(libraryEntry?.playableNpcCount ?? 0) > 0
? libraryEntry!.playableNpcCount
: playableNpcs.length,
landmarkCount:
(libraryEntry?.landmarkCount ?? 0) > 0
? libraryEntry!.landmarkCount
: landmarks.length,
roleVisualReadyCount,
roleAnimationReadyCount,
roleAssetSummaryLabel:
roleAnimationReadyCount > 0
? `动作已就绪 ${roleAnimationReadyCount}`
: roleVisualReadyCount > 0
? `主图已就绪 ${roleVisualReadyCount}`
: null,
sessionId: null,
profileId:
(libraryEntry ? toText(libraryEntry.profileId) : '') ||
toText(profileRecord.id) ||
null,
canResume: false,
canEnterWorld: true,
};
});
publishedAt:
toText(libraryEntry.publishedAt) ||
toText(profileRecord.publishedAt) ||
updatedAt,
stage: 'published',
stageLabel: '已发布',
playableNpcCount:
libraryEntry.playableNpcCount > 0
? libraryEntry.playableNpcCount
: playableNpcs.length,
landmarkCount:
libraryEntry.landmarkCount > 0
? libraryEntry.landmarkCount
: landmarks.length,
roleVisualReadyCount,
roleAnimationReadyCount,
roleAssetSummaryLabel:
roleAnimationReadyCount > 0
? `动作已就绪 ${roleAnimationReadyCount}`
: roleVisualReadyCount > 0
? `主图已就绪 ${roleVisualReadyCount}`
: null,
sessionId: null,
profileId:
toText(libraryEntry.profileId) || toText(profileRecord.id) || null,
canResume: false,
canEnterWorld: true,
};
});
return [...draftItems, ...publishedItems].sort((left, right) =>
right.updatedAt.localeCompare(left.updatedAt),

View File

@@ -398,3 +398,30 @@ test('landmark tab uses first act image as scene card preview and keeps chapter
'/generated-custom-world-scenes/scene-act-1.png',
);
});
test('readOnly result view hides edit and create actions for agent preview mode', async () => {
const user = userEvent.setup();
render(
<CustomWorldResultView
profile={baseProfile}
previewCharacters={[]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={() => {}}
readOnly
compactAgentResultMode
/>,
);
expect(screen.queryByRole('button', { name: /^$/u })).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
expect(screen.queryByRole('button', { name: '新增可扮演角色' })).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
expect(screen.queryByRole('button', { name: //u })).toBeNull();
});

View File

@@ -40,6 +40,7 @@ interface CustomWorldResultViewProps {
regenerateActionLabel?: string;
enterWorldActionLabel?: string;
autoSaveState?: 'idle' | 'saving' | 'saved' | 'error';
compactAgentResultMode?: boolean;
}
type EntityGenerationKind = 'playable' | 'story' | 'landmark';
@@ -372,6 +373,7 @@ export function CustomWorldResultView({
regenerateActionLabel = '重新生成',
enterWorldActionLabel = '进入世界',
autoSaveState = 'idle',
compactAgentResultMode = false,
}: CustomWorldResultViewProps) {
const [editorTarget, setEditorTarget] =
useState<CustomWorldEditorTarget | null>(null);
@@ -609,9 +611,11 @@ export function CustomWorldResultView({
onProfileChange={onProfileChange}
onDeleteStoryNpcs={handleDeleteStoryNpcs}
onDeleteLandmarks={handleDeleteLandmarks}
createActionLabel={readOnly ? undefined : createLabel}
createActionLabel={
readOnly || compactAgentResultMode ? undefined : createLabel
}
onCreateAction={
readOnly || !createTarget
readOnly || compactAgentResultMode || !createTarget
? undefined
: () => {
if (activeTab === 'playable') {

View File

@@ -135,12 +135,12 @@ export function CustomWorldCreationHub({
key={item.workId}
item={item}
onClick={() => {
if (item.status === 'draft' && item.sessionId) {
if (item.sourceType === 'agent_session' && item.sessionId) {
onResumeDraft(item.sessionId);
return;
}
if (item.status === 'published' && item.profileId) {
if (item.profileId) {
onEnterPublished(item.profileId);
}
}}

View File

@@ -6,14 +6,15 @@ import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
createCustomWorldAgentSession,
executeCustomWorldAgentAction,
getCustomWorldAgentOperation,
getCustomWorldAgentSession,
listCustomWorldWorks,
streamCustomWorldAgentMessage,
} from '../../services/aiService';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService';
import {
clearProfileBrowseHistory,
@@ -54,12 +55,30 @@ async function clickFirstAsyncButtonByName(
await user.click(buttons[0]!);
}
async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '创作');
expect(await screen.findByText('创作中心')).toBeTruthy();
}
async function openNewRpgCreation(
user: ReturnType<typeof userEvent.setup>,
) {
await openCreationHub(user);
const createButtons = await screen.findAllByRole('button', {
name: //u,
});
await user.click(createButtons.at(-1)!);
expect(screen.getByText('选择创作类型')).toBeTruthy();
await user.click(screen.getByRole('button', { name: / RPG/u }));
}
vi.mock('../../services/aiService', () => ({
createCustomWorldAgentSession: vi.fn(),
executeCustomWorldAgentAction: vi.fn(),
generateCustomWorldProfile: vi.fn(),
getCustomWorldAgentOperation: vi.fn(),
getCustomWorldAgentSession: vi.fn(),
listCustomWorldWorks: vi.fn(),
streamCustomWorldAgentMessage: vi.fn(),
}));
@@ -340,6 +359,7 @@ beforeEach(() => {
vi.mocked(createCustomWorldAgentSession).mockResolvedValue({
session: mockSession,
});
vi.mocked(listCustomWorldWorks).mockResolvedValue([]);
vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({
operation: {
operationId: 'operation-draft-foundation-1',
@@ -369,8 +389,11 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
render(<TestWrapper />);
await clickFirstButtonByName(user, '创作');
await clickFirstButtonByName(user, //u);
await openCreationHub(user);
const createButtons = await screen.findAllByRole('button', {
name: //u,
});
await user.click(createButtons.at(-1)!);
expect(screen.getByText('选择创作类型')).toBeTruthy();
@@ -393,6 +416,52 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
).toBeTruthy();
});
test('create tab uses unified creation hub and can resume an agent draft', async () => {
const user = userEvent.setup();
vi.mocked(listCustomWorldWorks).mockResolvedValue([
{
workId: 'draft:custom-world-agent-session-1',
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '精修对象',
summary: '玩家是失职返乡的守灯人。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: null,
stage: 'object_refining',
stageLabel: '精修对象',
playableNpcCount: 3,
landmarkCount: 4,
roleVisualReadyCount: 1,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: '沈砺 · 主图已生成',
sessionId: 'custom-world-agent-session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]);
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(
screen.getByRole('button', { name: //u }),
).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
await user.click(screen.getByRole('button', { name: //u }));
expect(
await screen.findByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
});
test('clicking a public work while logged out routes through requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
@@ -448,10 +517,7 @@ test('selecting RPG creation while logged out routes through requireAuth', async
/>,
);
await clickFirstButtonByName(user, '创作');
await clickFirstButtonByName(user, //u);
await user.click(screen.getByRole('button', { name: / RPG/u }));
await openNewRpgCreation(user);
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(createCustomWorldAgentSession).not.toHaveBeenCalled();
});
@@ -461,9 +527,7 @@ test('starting draft generation leaves the agent workspace and shows the generat
render(<TestWrapper />);
await clickFirstButtonByName(user, '创作');
await clickFirstButtonByName(user, //u);
await user.click(screen.getByRole('button', { name: / RPG/u }));
await openNewRpgCreation(user);
expect(
await screen.findByText('Agent工作区custom-world-agent-session-1'),
@@ -490,7 +554,7 @@ test('starting draft generation leaves the agent workspace and shows the generat
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
});
test('existing draft sessions enter the legacy result layout directly', async () => {
test('existing draft sessions enter the agent preview layout without opening legacy editor', async () => {
const user = userEvent.setup();
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
@@ -595,9 +659,7 @@ test('existing draft sessions enter the legacy result layout directly', async ()
render(<TestWrapper />);
await clickFirstButtonByName(user, '创作');
await clickFirstButtonByName(user, //u);
await user.click(screen.getByRole('button', { name: / RPG/u }));
await openNewRpgCreation(user);
await waitFor(
async () => {
@@ -611,13 +673,295 @@ test('existing draft sessions enter the legacy result layout directly', async ()
expect(screen.queryByText(/Agent/u)).toBeNull();
expect(screen.queryByRole('button', { name: /^/u })).toBeNull();
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
expect(screen.queryByRole('button', { name: /AI/u })).toBeNull();
expect(screen.queryByText('技能')).toBeNull();
});
expect(await screen.findByText(//u)).toBeTruthy();
expect(screen.getByRole('button', { name: /AI/u })).toBeTruthy();
expect(screen.getByText('技能')).toBeTruthy();
test('agent draft result back button returns to workspace without redundant sync when session is already latest', async () => {
const user = userEvent.setup();
vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({
operation: {
operationId: 'operation-sync-result-profile-1',
type: 'sync_result_profile',
status: 'queued',
phaseLabel: '同步结果页快照',
phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。',
progress: 24,
error: null,
},
});
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
operationId: 'operation-sync-result-profile-1',
type: 'sync_result_profile',
status: 'completed',
phaseLabel: '结果页快照已同步',
phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。',
progress: 100,
error: null,
});
const resultSession = {
...mockSession,
stage: 'object_refining' as const,
creatorIntent: {
sourceMode: 'card',
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
themeKeywords: ['海雾', '旧航路'],
toneDirectives: ['压抑', '悬疑'],
openingSituation: '首夜就有陌生船只闯入禁航区。',
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: ['会移动的海雾'],
forbiddenDirectives: [],
rawSettingText: '',
},
draftProfile: {
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
publicIdentity: '最熟悉旧航路的人。',
publicMask: '看上去像可靠旧友。',
currentPressure: '他必须在两股势力间站队。',
hiddenHook: '暗中替沉船商盟引路。',
relationToPlayer: '旧友兼潜在背叛者',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
publicIdentity: '负责夜间巡灯与封锁。',
publicMask: '对外一直冷静克制。',
currentPressure: '她知道更多禁航区真相。',
hiddenHook: '曾亲眼见过失控海雾吞船。',
relationToPlayer: '最早愿意交换线索的人',
threadIds: ['thread-1'],
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
},
],
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
purpose: '观察雾潮与往来船只',
mood: '潮湿、压抑、风声不止',
importance: '开局核心场景',
characterIds: ['story-1'],
threadIds: ['thread-1'],
summary: '旧灯塔是整片群岛最先看见异动的地方。',
},
],
factions: [],
threads: [],
chapters: [],
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
openingSituation: '首夜就有陌生船只闯入禁航区。',
iconicElements: ['会移动的海雾'],
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
legacyResultProfile: {
id: 'agent-draft-custom-world-agent-session-1',
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·同步后',
subtitle: '旧灯塔与失控航路',
summary: '同步后的结果页快照已经回写到 session。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
},
},
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
status: 'warning',
linkedIds: ['playable-1', 'story-1', 'landmark-1'],
warningCount: 0,
},
],
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(resultSession);
render(<TestWrapper />);
await openNewRpgCreation(user);
await waitFor(
async () => {
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
},
{ timeout: 2500 },
);
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(
screen.getByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
});
expect(
vi.mocked(executeCustomWorldAgentAction).mock.calls.some(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
),
).toBe(false);
expect(screen.queryByText('世界档案')).toBeNull();
});
test('agent draft result auto-save persists the latest profile rebuilt from synced session', async () => {
const user = userEvent.setup();
vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({
operation: {
operationId: 'operation-sync-result-profile-2',
type: 'sync_result_profile',
status: 'queued',
phaseLabel: '同步结果页快照',
phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。',
progress: 24,
error: null,
},
});
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
operationId: 'operation-sync-result-profile-2',
type: 'sync_result_profile',
status: 'completed',
phaseLabel: '结果页快照已同步',
phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。',
progress: 100,
error: null,
});
const syncedSession = {
...mockSession,
stage: 'object_refining' as const,
creatorIntent: {
sourceMode: 'card',
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
themeKeywords: ['海雾', '旧航路'],
toneDirectives: ['压抑', '悬疑'],
openingSituation: '首夜就有陌生船只闯入禁航区。',
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: ['会移动的海雾'],
forbiddenDirectives: [],
rawSettingText: '',
},
draftProfile: {
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [],
storyNpcs: [],
landmarks: [],
factions: [],
threads: [],
chapters: [],
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
openingSituation: '首夜就有陌生船只闯入禁航区。',
iconicElements: ['会移动的海雾'],
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
legacyResultProfile: {
id: 'agent-draft-custom-world-agent-session-1',
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·session最新版',
subtitle: '旧灯塔与失控航路',
summary: '作品库应该保存这份同步后的最新快照。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
},
},
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
status: 'warning',
linkedIds: [],
warningCount: 0,
},
],
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(syncedSession);
render(<TestWrapper />);
await openNewRpgCreation(user);
await waitFor(
async () => {
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(screen.getByText('已自动保存')).toBeTruthy();
},
{ timeout: 2500 },
);
await waitFor(() => {
expect(upsertCustomWorldProfile).toHaveBeenCalled();
});
const latestSavedProfile = vi.mocked(upsertCustomWorldProfile).mock.calls.at(-1)?.[0];
expect(latestSavedProfile?.name).toBe('潮雾列岛·session最新版');
expect(latestSavedProfile?.summary).toBe(
'作品库应该保存这份同步后的最新快照。',
);
});
test('authenticated users with save archives default into the saves tab', async () => {
@@ -697,42 +1041,71 @@ test('owned world detail can delete a work and return to the create tab list', a
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
vi.mocked(listCustomWorldLibrary).mockResolvedValue([
{
ownerUserId: 'user-1',
profileId: 'world-delete-1',
profile: {
id: 'world-delete-1',
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '用于测试删除流程的作品。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清旧案。',
majorFactions: ['守灯会'],
coreConflicts: ['雾潮正在逼近港口'],
playableNpcs: [],
storyNpcs: [],
landmarks: [],
} as never,
visibility: 'draft',
publishedAt: null,
updatedAt: '2026-04-16T12:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: '潮雾列岛',
const publishedWork = {
workId: 'published:world-delete-1',
sourceType: 'published_profile' as const,
status: 'published' as const,
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '用于测试删除流程的作品。',
coverImageSrc: null,
coverRenderMode: 'image' as const,
coverCharacterImageSrcs: [],
updatedAt: '2026-04-16T12:00:00.000Z',
publishedAt: '2026-04-16T12:00:00.000Z',
stage: null,
stageLabel: '已发布',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: null,
profileId: 'world-delete-1',
canResume: false,
canEnterWorld: true,
};
const publishedLibraryEntry = {
ownerUserId: 'user-1',
profileId: 'world-delete-1',
profile: {
id: 'world-delete-1',
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '用于测试删除流程的作品。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 0,
landmarkCount: 0,
},
]);
summary: '用于测试删除流程的作品。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清旧案。',
majorFactions: ['守灯会'],
coreConflicts: ['雾潮正在逼近港口'],
playableNpcs: [],
storyNpcs: [],
landmarks: [],
} as never,
visibility: 'published' as const,
publishedAt: '2026-04-16T12:00:00.000Z',
updatedAt: '2026-04-16T12:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '用于测试删除流程的作品。',
coverImageSrc: null,
themeMode: 'tide' as const,
playableNpcCount: 0,
landmarkCount: 0,
};
vi.mocked(listCustomWorldWorks)
.mockResolvedValueOnce([publishedWork])
.mockResolvedValue([]);
vi.mocked(listCustomWorldLibrary)
.mockResolvedValueOnce([publishedLibraryEntry])
.mockResolvedValue([]);
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
render(<TestWrapper withAuth />);
await clickFirstButtonByName(user, '创作');
await clickFirstAsyncButtonByName(user, //u);
await openCreationHub(user);
await user.click(screen.getByRole('button', { name: //u }));
await user.click(await screen.findByRole('button', { name: '删除作品' }));
await waitFor(() => {
@@ -742,8 +1115,77 @@ test('owned world detail can delete a work and return to the create tab list', a
await waitFor(() => {
expect(screen.queryByRole('button', { name: '删除作品' })).toBeNull();
});
expect(
screen.getAllByText('还没有保存任何自定义世界,先创建一个草稿开始吧。')
.length,
).toBeTruthy();
await waitFor(() => {
expect(screen.getByText('还没有作品')).toBeTruthy();
});
});
test('creation hub published work enters existing detail view', async () => {
const user = userEvent.setup();
vi.mocked(listCustomWorldWorks).mockResolvedValue([
{
workId: 'published:world-public-1',
sourceType: 'published_profile',
status: 'published',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '已经发布的群岛世界作品。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: '2026-04-20T10:00:00.000Z',
stage: null,
stageLabel: '已发布',
playableNpcCount: 3,
landmarkCount: 4,
roleVisualReadyCount: 1,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: null,
profileId: 'world-public-1',
canResume: false,
canEnterWorld: true,
},
]);
vi.mocked(listCustomWorldLibrary).mockResolvedValue([
{
ownerUserId: 'user-1',
profileId: 'world-public-1',
profile: {
id: 'world-public-1',
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '已经发布的群岛世界作品。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清群岛旧案。',
majorFactions: ['守灯会'],
coreConflicts: ['假航灯正在扰乱航线'],
playableNpcs: [],
storyNpcs: [],
landmarks: [],
} as never,
visibility: 'published',
publishedAt: '2026-04-20T10:00:00.000Z',
updatedAt: '2026-04-20T10:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '已经发布的群岛世界作品。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
},
]);
render(<TestWrapper withAuth />);
await openCreationHub(user);
await user.click(screen.getByRole('button', { name: //u }));
expect(await screen.findByText('世界信息')).toBeTruthy();
expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy();
expect(screen.getByText('已发布')).toBeTruthy();
});

View File

@@ -14,6 +14,7 @@ import type {
CustomWorldAgentMessage,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
SendCustomWorldAgentMessageRequest,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
@@ -29,6 +30,7 @@ import {
executeCustomWorldAgentAction,
getCustomWorldAgentOperation,
getCustomWorldAgentSession,
listCustomWorldWorks,
streamCustomWorldAgentMessage,
} from '../../services/aiService';
import { buildCustomWorldProfileFromAgentDraft } from '../../services/customWorldAgentDraftResult';
@@ -69,6 +71,7 @@ import {
} from '../../services/storageService';
import { type CustomWorldProfile, type GameState } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { CustomWorldCreationHub } from '../custom-world-home/CustomWorldCreationHub';
import { PlatformCreationTypeModal } from './PlatformCreationTypeModal';
import { type PlatformHomeTab, PlatformHomeView } from './PlatformHomeView';
import { PlatformWorldDetailView } from './PlatformWorldDetailView';
@@ -107,6 +110,10 @@ type CustomWorldGenerationViewSource = 'agent-draft-foundation' | null;
type CustomWorldResultViewSource = 'saved-profile' | 'agent-draft' | null;
type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type SyncedAgentDraftResult = {
session: CustomWorldAgentSessionSnapshot | null;
profile: CustomWorldProfile | null;
};
type PreGameSelectionFlowProps = {
selectionStage: SelectionStage;
@@ -164,6 +171,10 @@ function normalizeAgentBackedProfile(profile: CustomWorldProfile) {
} satisfies CustomWorldProfile;
}
function stringifyAgentBackedProfile(profile: CustomWorldProfile) {
return JSON.stringify(normalizeAgentBackedProfile(profile));
}
function LazyPanelFallback({ label }: { label: string }) {
return (
<div className="flex h-full min-h-0 items-center justify-center">
@@ -174,6 +185,37 @@ function LazyPanelFallback({ label }: { label: string }) {
);
}
function buildCreationHubFallbackItems(
entries: CustomWorldLibraryEntry<CustomWorldProfile>[],
): CustomWorldWorkSummary[] {
return entries
.filter((entry) => entry.visibility === 'published')
.map((entry) => ({
workId: `fallback:${entry.profileId}`,
sourceType: 'published_profile',
status: 'published',
title: entry.worldName,
subtitle: entry.subtitle || '已发布作品',
summary: entry.summaryText || '继续补完这个世界的设定与游玩入口。',
coverImageSrc: entry.coverImageSrc,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: entry.updatedAt,
publishedAt: entry.publishedAt,
stage: null,
stageLabel: '已发布',
playableNpcCount: entry.playableNpcCount,
landmarkCount: entry.landmarkCount,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: null,
profileId: entry.profileId,
canResume: false,
canEnterWorld: true,
}));
}
export function PreGameSelectionFlow({
selectionStage,
setSelectionStage,
@@ -191,6 +233,9 @@ export function PreGameSelectionFlow({
const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState<
CustomWorldLibraryEntry<CustomWorldProfile>[]
>([]);
const [customWorldWorkEntries, setCustomWorldWorkEntries] = useState<
CustomWorldWorkSummary[]
>([]);
const [publishedGalleryEntries, setPublishedGalleryEntries] = useState<
CustomWorldGalleryCard[]
>([]);
@@ -250,6 +295,10 @@ export function PreGameSelectionFlow({
const customWorldAutoSaveTimeoutRef = useRef<number | null>(null);
const lastAutoSavedProfileSignatureRef = useRef<string | null>(null);
const latestAutoSaveRequestIdRef = useRef(0);
const latestAgentResultSyncSignatureRef = useRef<string | null>(null);
// 用户手动返回工作区后,先抑制自动重开结果页,避免刚退出又被 session 快照顶回去。
const isAgentDraftResultAutoOpenSuppressedRef = useRef(false);
const isCustomWorldAutoSaveBusyRef = useRef(false);
const platformTabBootstrapUserIdRef = useRef<string | null | undefined>(
undefined,
);
@@ -318,6 +367,17 @@ export function PreGameSelectionFlow({
}
}, [authUi?.user]);
const refreshCustomWorldWorks = useCallback(async () => {
if (!authUi?.user) {
setCustomWorldWorkEntries([]);
return [];
}
const nextItems = await listCustomWorldWorks();
setCustomWorldWorkEntries(nextItems);
return nextItems;
}, [authUi?.user]);
const appendBrowseHistoryEntry = useCallback(
async (entry: PlatformBrowseHistoryWriteEntry) => {
const nextEntries = writePlatformBrowseHistory(authUi?.user, entry);
@@ -380,6 +440,7 @@ export function PreGameSelectionFlow({
setDashboardError(null);
if (!isAuthenticated) {
setSavedCustomWorldEntries([]);
setCustomWorldWorkEntries([]);
setSaveEntries([]);
setProfileDashboard(null);
}
@@ -387,12 +448,14 @@ export function PreGameSelectionFlow({
try {
const [
libraryEntriesResult,
workEntriesResult,
galleryEntriesResult,
dashboardResult,
historyResult,
saveArchivesResult,
] = await Promise.allSettled([
isAuthenticated ? listCustomWorldLibrary() : Promise.resolve([]),
isAuthenticated ? listCustomWorldWorks() : Promise.resolve([]),
listCustomWorldGallery(),
isAuthenticated ? getProfileDashboard() : Promise.resolve(null),
isAuthenticated
@@ -423,6 +486,12 @@ export function PreGameSelectionFlow({
setSavedCustomWorldEntries([]);
}
if (workEntriesResult.status === 'fulfilled') {
setCustomWorldWorkEntries(workEntriesResult.value);
} else {
setCustomWorldWorkEntries([]);
}
if (galleryEntriesResult.status === 'fulfilled') {
setPublishedGalleryEntries(galleryEntriesResult.value);
} else {
@@ -431,11 +500,14 @@ export function PreGameSelectionFlow({
if (
(isAuthenticated && libraryEntriesResult.status === 'rejected') ||
(isAuthenticated && workEntriesResult.status === 'rejected') ||
galleryEntriesResult.status === 'rejected'
) {
const platformFailure =
libraryEntriesResult.status === 'rejected'
? libraryEntriesResult.reason
: workEntriesResult.status === 'rejected'
? workEntriesResult.reason
: galleryEntriesResult.status === 'rejected'
? galleryEntriesResult.reason
: null;
@@ -742,9 +814,14 @@ export function PreGameSelectionFlow({
return;
}
if (isAgentDraftResultAutoOpenSuppressedRef.current) {
return;
}
if (selectionStage === 'agent-workspace') {
setGeneratedCustomWorldProfile(agentDraftResultProfile);
setCustomWorldResultViewSource('agent-draft');
isAgentDraftResultAutoOpenSuppressedRef.current = false;
setSelectionStage('custom-world-result');
return;
}
@@ -755,10 +832,12 @@ export function PreGameSelectionFlow({
) {
setGeneratedCustomWorldProfile(agentDraftResultProfile);
setCustomWorldResultViewSource('agent-draft');
isAgentDraftResultAutoOpenSuppressedRef.current = false;
}
}, [
agentDraftResultProfile,
generatedCustomWorldProfile,
isAgentDraftResultAutoOpenSuppressedRef,
selectionStage,
setSelectionStage,
shouldAutoOpenAgentDraftResult,
@@ -776,6 +855,8 @@ export function PreGameSelectionFlow({
const isAgentDraftGenerationView =
customWorldGenerationViewSource === 'agent-draft-foundation';
const isAgentDraftResultView = customWorldResultViewSource === 'agent-draft';
const isAgentDraftResultEditingFrozen =
customWorldResultViewSource === 'agent-draft';
const activeGenerationSettingText = agentDraftSettingPreview;
const activeGenerationProgress = agentDraftGenerationProgress;
const isActiveGenerationRunning =
@@ -822,6 +903,7 @@ export function PreGameSelectionFlow({
setIsCreatingAgentSession(true);
setCreationTypeError(null);
isAgentDraftResultAutoOpenSuppressedRef.current = false;
try {
const { session } = await createCustomWorldAgentSession(
@@ -921,6 +1003,7 @@ export function PreGameSelectionFlow({
const isDraftFoundationAction = payload.action === 'draft_foundation';
if (isDraftFoundationAction) {
isAgentDraftResultAutoOpenSuppressedRef.current = false;
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
@@ -980,14 +1063,14 @@ export function PreGameSelectionFlow({
};
const leaveAgentDraftResult = () => {
isAgentDraftResultAutoOpenSuppressedRef.current = true;
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
setPlatformTab('create');
setSelectionStage('platform');
setSelectionStage('agent-workspace');
};
const retryAgentDraftGeneration = () => {
@@ -1000,25 +1083,79 @@ export function PreGameSelectionFlow({
openCreationTypePicker();
};
const openLibraryDetail = (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => {
if (entry.visibility === 'published') {
void appendBrowseHistoryEntry({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
authorDisplayName: entry.authorDisplayName,
});
}
setSelectedDetailEntry(entry);
setDetailError(null);
setSelectionStage('detail');
};
const openLibraryDetail = useCallback(
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
if (entry.visibility === 'published') {
void appendBrowseHistoryEntry({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
authorDisplayName: entry.authorDisplayName,
});
}
setSelectedDetailEntry(entry);
setDetailError(null);
setSelectionStage('detail');
},
[appendBrowseHistoryEntry, setSelectionStage],
);
const handleOpenCreationWork = useCallback(
async (work: CustomWorldWorkSummary) => {
if (work.status === 'draft' && work.sessionId) {
// 阶段二要求草稿优先回到 Agent 工作区,而不是再次自动顶回结果页。
isAgentDraftResultAutoOpenSuppressedRef.current = true;
persistAgentUiState(work.sessionId, null);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
setPlatformTab('create');
setSelectionStage('agent-workspace');
return;
}
if (!work.profileId) {
return;
}
try {
let matchedEntry = savedCustomWorldEntries.find(
(entry) => entry.profileId === work.profileId,
);
if (!matchedEntry && authUi?.user) {
const latestLibraryEntries = await listCustomWorldLibrary();
setSavedCustomWorldEntries(latestLibraryEntries);
matchedEntry = latestLibraryEntries.find(
(entry) => entry.profileId === work.profileId,
);
}
if (matchedEntry) {
openLibraryDetail(matchedEntry);
return;
}
setPlatformError('未找到对应作品,请刷新后重试。');
} catch (error) {
setPlatformError(resolveErrorMessage(error, '读取作品详情失败。'));
}
},
[
authUi?.user,
openLibraryDetail,
persistAgentUiState,
savedCustomWorldEntries,
setSelectionStage,
],
);
const openGalleryDetail = async (entry: CustomWorldGalleryCard) => {
setSelectionStage('detail');
@@ -1083,7 +1220,7 @@ export function PreGameSelectionFlow({
}
const normalizedProfile = normalizeAgentBackedProfile(profile);
const profileSignature = JSON.stringify(normalizedProfile);
const profileSignature = stringifyAgentBackedProfile(normalizedProfile);
const requestId = latestAutoSaveRequestIdRef.current + 1;
latestAutoSaveRequestIdRef.current = requestId;
setCustomWorldAutoSaveState('saving');
@@ -1097,6 +1234,9 @@ export function PreGameSelectionFlow({
lastAutoSavedProfileSignatureRef.current = profileSignature;
setSavedCustomWorldEntries(mutation.entries);
if (authUi?.user) {
void refreshCustomWorldWorks().catch(() => {});
}
setSelectedDetailEntry((current) => {
if (!current || current.profileId === mutation.entry.profileId) {
return mutation.entry;
@@ -1119,7 +1259,99 @@ export function PreGameSelectionFlow({
return null;
}
},
[generatedCustomWorldProfile],
[authUi?.user, generatedCustomWorldProfile, refreshCustomWorldWorks],
);
const syncAgentDraftResultProfile = useCallback(
async (profile: CustomWorldProfile) => {
if (!activeAgentSessionId) {
return {
session: null,
profile: null,
} satisfies SyncedAgentDraftResult;
}
const normalizedProfile = normalizeAgentBackedProfile(profile);
const profileSignature = stringifyAgentBackedProfile(normalizedProfile);
const latestSessionProfileSignature =
agentSession && buildCustomWorldProfileFromAgentDraft(agentSession)
? stringifyAgentBackedProfile(
buildCustomWorldProfileFromAgentDraft(agentSession)!,
)
: '';
if (latestSessionProfileSignature === profileSignature) {
latestAgentResultSyncSignatureRef.current = profileSignature;
return {
session: agentSession,
profile: normalizeAgentBackedProfile(
buildCustomWorldProfileFromAgentDraft(agentSession) ?? profile,
),
} satisfies SyncedAgentDraftResult;
}
if (latestAgentResultSyncSignatureRef.current === profileSignature) {
return {
session: agentSession,
profile: normalizeAgentBackedProfile(
buildCustomWorldProfileFromAgentDraft(agentSession) ?? profile,
),
} satisfies SyncedAgentDraftResult;
}
const { operation } = await executeCustomWorldAgentAction(
activeAgentSessionId,
{
action: 'sync_result_profile',
profile: normalizedProfile as unknown as Record<string, unknown>,
},
);
setAgentOperation(operation);
persistAgentUiState(activeAgentSessionId, operation.operationId);
for (let attempt = 0; attempt < 60; attempt += 1) {
const latestOperation = await getCustomWorldAgentOperation(
activeAgentSessionId,
operation.operationId,
);
setAgentOperation(latestOperation);
if (latestOperation.status === 'failed') {
throw new Error(
latestOperation.error ||
latestOperation.phaseDetail ||
'同步结果页世界快照失败。',
);
}
if (latestOperation.status === 'completed') {
persistAgentUiState(activeAgentSessionId, null);
const latestSession = await syncAgentSessionSnapshot(
activeAgentSessionId,
);
// 同步完成后统一从最新 session 重编译结果,保证结果页、作品库和进入世界吃同一份快照。
const latestProfile = normalizeAgentBackedProfile(
buildCustomWorldProfileFromAgentDraft(latestSession) ?? profile,
);
if (latestProfile) {
setGeneratedCustomWorldProfile(latestProfile);
}
latestAgentResultSyncSignatureRef.current = profileSignature;
return {
session: latestSession,
profile: latestProfile,
} satisfies SyncedAgentDraftResult;
}
await new Promise((resolve) => window.setTimeout(resolve, 200));
}
throw new Error('同步结果页世界快照超时。');
},
[
activeAgentSessionId,
agentSession,
persistAgentUiState,
syncAgentSessionSnapshot,
],
);
useEffect(() => {
@@ -1127,6 +1359,7 @@ export function PreGameSelectionFlow({
setCustomWorldAutoSaveState('idle');
setCustomWorldAutoSaveError(null);
lastAutoSavedProfileSignatureRef.current = null;
latestAgentResultSyncSignatureRef.current = null;
if (customWorldAutoSaveTimeoutRef.current !== null) {
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
customWorldAutoSaveTimeoutRef.current = null;
@@ -1138,7 +1371,11 @@ export function PreGameSelectionFlow({
return;
}
const nextSignature = JSON.stringify(generatedCustomWorldProfile);
if (isCustomWorldAutoSaveBusyRef.current) {
return;
}
const nextSignature = stringifyAgentBackedProfile(generatedCustomWorldProfile);
if (nextSignature === lastAutoSavedProfileSignatureRef.current) {
return;
}
@@ -1150,7 +1387,28 @@ export function PreGameSelectionFlow({
const profileToSave = generatedCustomWorldProfile;
customWorldAutoSaveTimeoutRef.current = window.setTimeout(() => {
void saveGeneratedCustomWorld(profileToSave);
void (async () => {
isCustomWorldAutoSaveBusyRef.current = true;
try {
let latestProfileToSave = normalizeAgentBackedProfile(profileToSave);
if (isAgentDraftResultView) {
const syncedResult =
await syncAgentDraftResultProfile(profileToSave);
// 作品库自动保存优先落同步后 session 重编译出的结果,避免继续保存旧的前端内存态。
latestProfileToSave = normalizeAgentBackedProfile(
syncedResult.profile ?? profileToSave,
);
}
await saveGeneratedCustomWorld(latestProfileToSave);
} catch (error) {
setCustomWorldAutoSaveState('error');
setCustomWorldAutoSaveError(
resolveErrorMessage(error, '保存自定义世界失败。'),
);
} finally {
isCustomWorldAutoSaveBusyRef.current = false;
}
})();
customWorldAutoSaveTimeoutRef.current = null;
}, 600);
@@ -1160,7 +1418,13 @@ export function PreGameSelectionFlow({
customWorldAutoSaveTimeoutRef.current = null;
}
};
}, [generatedCustomWorldProfile, saveGeneratedCustomWorld, selectionStage]);
}, [
generatedCustomWorldProfile,
isAgentDraftResultView,
saveGeneratedCustomWorld,
selectionStage,
syncAgentDraftResultProfile,
]);
const openSavedCustomWorldEditor = (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
@@ -1200,6 +1464,7 @@ export function PreGameSelectionFlow({
selectedDetailEntry.profileId,
);
setSavedCustomWorldEntries(mutation.entries);
await refreshCustomWorldWorks().catch(() => []);
setSelectedDetailEntry(mutation.entry);
setPublishedGalleryEntries(await listCustomWorldGallery());
} catch (error) {
@@ -1221,6 +1486,7 @@ export function PreGameSelectionFlow({
selectedDetailEntry.profileId,
);
setSavedCustomWorldEntries(mutation.entries);
await refreshCustomWorldWorks().catch(() => []);
setSelectedDetailEntry(mutation.entry);
setPublishedGalleryEntries(await listCustomWorldGallery());
} catch (error) {
@@ -1249,6 +1515,7 @@ export function PreGameSelectionFlow({
selectedDetailEntry.profileId,
);
setSavedCustomWorldEntries(entries);
await refreshCustomWorldWorks().catch(() => []);
setSelectedDetailEntry(null);
setPlatformTab('create');
setSelectionStage('platform');
@@ -1269,6 +1536,10 @@ export function PreGameSelectionFlow({
),
);
const resultViewError = customWorldAutoSaveError ?? customWorldError;
const creationHubItems =
customWorldWorkEntries.length > 0
? customWorldWorkEntries
: buildCreationHubFallbackItems(savedCustomWorldEntries);
return (
<>
@@ -1281,47 +1552,106 @@ export function PreGameSelectionFlow({
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<PlatformHomeView
activeTab={platformTab}
onTabChange={setPlatformTab}
hasSavedGame={hasSavedGame}
savedSnapshot={savedSnapshot}
saveEntries={saveEntries}
saveError={saveError}
featuredEntries={featuredGalleryEntries}
latestEntries={publishedGalleryEntries}
myEntries={savedCustomWorldEntries}
historyEntries={historyEntries}
profileDashboard={profileDashboard}
isLoadingPlatform={isLoadingPlatform}
isLoadingDashboard={isLoadingDashboard}
isResumingSaveWorldKey={isResumingSaveWorldKey}
platformError={
isLoadingPlatform ? null : (platformError ?? creationTypeError)
}
dashboardError={isLoadingDashboard ? null : dashboardError}
onContinueGame={handleContinueGame}
onResumeSave={(entry) => {
void handleResumeSaveEntry(entry);
}}
onOpenCreateWorld={openCustomWorldCreator}
onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => {
runProtectedAction(() => {
void openGalleryDetail(entry);
});
}}
onOpenLibraryDetail={(entry) => {
runProtectedAction(() => {
openLibraryDetail(entry);
});
}}
onOpenProfileDashboardCard={() => {
if (dashboardError) {
void refreshProfileDashboard();
{platformTab === 'create' ? (
<CustomWorldCreationHub
items={creationHubItems}
loading={isLoadingPlatform}
error={isLoadingPlatform ? null : (platformError ?? creationTypeError)}
onBack={() => {
setPlatformTab('home');
}}
onRetry={() => {
setPlatformError(null);
void refreshCustomWorldWorks().catch((error) => {
setPlatformError(
resolveErrorMessage(error, '读取创作作品列表失败。'),
);
});
}}
onCreateNew={openCreationTypePicker}
onResumeDraft={(sessionId) => {
runProtectedAction(() => {
void handleOpenCreationWork({
workId: `draft:${sessionId}`,
sourceType: 'agent_session',
status: 'draft',
title: '',
subtitle: '',
summary: '',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: new Date().toISOString(),
publishedAt: null,
stage: null,
stageLabel: '',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId,
profileId: null,
canResume: true,
canEnterWorld: false,
});
});
}}
onEnterPublished={(profileId) => {
runProtectedAction(() => {
const matchedWork = creationHubItems.find(
(entry) => entry.profileId === profileId,
);
if (!matchedWork) {
return;
}
void handleOpenCreationWork(matchedWork);
});
}}
/>
) : (
<PlatformHomeView
activeTab={platformTab}
onTabChange={setPlatformTab}
hasSavedGame={hasSavedGame}
savedSnapshot={savedSnapshot}
saveEntries={saveEntries}
saveError={saveError}
featuredEntries={featuredGalleryEntries}
latestEntries={publishedGalleryEntries}
myEntries={savedCustomWorldEntries}
historyEntries={historyEntries}
profileDashboard={profileDashboard}
isLoadingPlatform={isLoadingPlatform}
isLoadingDashboard={isLoadingDashboard}
isResumingSaveWorldKey={isResumingSaveWorldKey}
platformError={
isLoadingPlatform ? null : (platformError ?? creationTypeError)
}
}}
/>
dashboardError={isLoadingDashboard ? null : dashboardError}
onContinueGame={handleContinueGame}
onResumeSave={(entry) => {
void handleResumeSaveEntry(entry);
}}
onOpenCreateWorld={openCustomWorldCreator}
onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => {
runProtectedAction(() => {
void openGalleryDetail(entry);
});
}}
onOpenLibraryDetail={(entry) => {
runProtectedAction(() => {
openLibraryDetail(entry);
});
}}
onOpenProfileDashboardCard={() => {
if (dashboardError) {
void refreshProfileDashboard();
}
}}
/>
)}
</motion.div>
)}
@@ -1501,7 +1831,28 @@ export function PreGameSelectionFlow({
}}
onBack={
isAgentDraftResultView
? leaveAgentDraftResult
? () => {
void (async () => {
const currentProfile =
generatedCustomWorldProfile ??
buildCustomWorldProfileFromAgentDraft(
agentSession,
);
if (currentProfile && activeAgentSessionId) {
await syncAgentDraftResultProfile(currentProfile);
}
leaveAgentDraftResult();
})().catch((error) => {
setCustomWorldError(
resolveErrorMessage(
error,
'返回创作前同步草稿失败。',
),
);
});
}
: leaveCustomWorldResult
}
onEditSetting={undefined}
@@ -1509,10 +1860,40 @@ export function PreGameSelectionFlow({
onContinueExpand={undefined}
onEnterWorld={() => {
runProtectedAction(() => {
handleCustomWorldSelect(generatedCustomWorldProfile);
void (async () => {
if (!isAgentDraftResultView || !activeAgentSessionId) {
handleCustomWorldSelect(generatedCustomWorldProfile);
return;
}
const currentProfile =
generatedCustomWorldProfile ??
buildCustomWorldProfileFromAgentDraft(agentSession);
if (!currentProfile) {
return;
}
const latestResult = await syncAgentDraftResultProfile(
currentProfile,
);
const latestProfile = normalizeAgentBackedProfile(
buildCustomWorldProfileFromAgentDraft(
latestResult.session ?? agentSession,
) ??
latestResult.profile ??
currentProfile,
);
setGeneratedCustomWorldProfile(latestProfile);
handleCustomWorldSelect(latestProfile);
})().catch((error) => {
setCustomWorldError(
resolveErrorMessage(error, '进入世界前同步草稿失败。'),
);
});
});
}}
readOnly={false}
readOnly={isAgentDraftResultEditingFrozen}
compactAgentResultMode={isAgentDraftResultView}
backLabel={isAgentDraftResultView ? '返回创作' : undefined}
editActionLabel="去Agent调整设定"
enterWorldActionLabel="进入世界"