Integrate role asset studio into custom world agent flow

This commit is contained in:
2026-04-14 20:16:41 +08:00
parent 0981d6ee1b
commit bc2999ffb9
118 changed files with 31211 additions and 1232 deletions

View File

@@ -73,8 +73,8 @@ SMS_AUTH_BLOCK_IP_FAILURE_THRESHOLD="10"
SMS_AUTH_BLOCK_PHONE_DURATION_MINUTES="30"
SMS_AUTH_BLOCK_IP_DURATION_MINUTES="30"
# 仅开发环境可选:允许无短信配置时自动走游客账号。
VITE_AUTH_ALLOW_DEV_GUEST="false"
# 仅开发环境:允许本地开发测试自动走游客账号。
VITE_AUTH_ALLOW_DEV_GUEST="true"
# 微信登录配置。
# 当前实现已支持微信登录骨架与 mock 联调;正式联调需补齐开放平台 AppID / AppSecret。

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

8
.idea/Genarrative.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

59
.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,59 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Genarrative.iml" filepath="$PROJECT_DIR$/.idea/Genarrative.iml" />
</modules>
</component>
</project>

6
.idea/prettier.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -14,6 +14,7 @@
- 前端只负责做表现所有的逻辑、数据都放到Express后端进行运算和存储。
- 请默认保持系统的简洁性,能复用、修改、扩展现有系统、页面就不新建新系统新页面。
- 禁止将功能说明描述类的文本默认写入UI界面中。
- prd文档中每个模块的描述要落地设计到可以精准编码到位不能出现需求落地漂移。
## 文档图谱

View File

@@ -0,0 +1,163 @@
# Function 需求完整性核查2026-04-14
## 1. 核查范围
本次核查按当前线程上下文,聚焦 function 体系相关文档与实现,不扩大到整个项目全部 PRD。
本次实际对照了这些文档:
- `docs/audits/FUNCTION_DESIGN_AUDIT_2026-04-03.md`
- `docs/reference/FUNCTION_SCRIPT_CATALOG_2026-04-04.md`
- `docs/experience/ADVENTURE_RUNTIME_DEV_EXPERIENCE.md`
- `docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md`
本次实际核对了这些实现入口:
- `src/data/functionCatalog/**`
- `src/data/stateFunctions.ts`
- `src/data/npcInteractions.ts`
- `src/components/AdventurePanel.tsx`
- `src/hooks/story/choiceActions.ts`
- `src/hooks/story/npcEncounterActions.ts`
- `src/hooks/story/npcInteraction.ts`
- `src/services/runtimeStoryService.ts`
- `server-node/src/modules/story/**`
- `server-node/src/modules/inventory/**`
- `server-node/src/modules/runtime-item/**`
- `server-node/src/modules/quest/**`
## 2. 核查结论
结论先说:
- function 主链路需求已经基本落地,不需要再继续“为了完整而过度迭代”。
- 当前最主要的缺口不是再发明一套新 function 流程,而是把已有链路补齐回归测试,确保后续不会回退。
- 本轮核查后,已把两条仍缺直接测试兜底的核心链路补上。
## 3. 已确认已经落地的需求
### 3.1 function 目录化与分层收口
已实现:
- `state / npc / treasure / flow / panel` 五类 function 已统一收口到 `src/data/functionCatalog/`
- `src/data/functionCatalog/index.ts` 已提供统一导出
- `SERVER_RUNTIME_FUNCTION_IDS` 也已和 catalog 文档映射对齐
判断:
- 这部分需求已完成,不需要继续重做结构。
### 3.2 `story_continue_adventure` 延迟展示链路
已实现:
- `npc_chat``npcEncounterActions.ts` 中先生成聊天正文,再把后续选项写入 `deferredOptions`
- `choiceActions.ts` 在点击 `story_continue_adventure` 时会直接恢复 `deferredOptions`
- `AdventurePanel.tsx` 已按 `functionId` 而不是 `actionText` 识别该特殊按钮
判断:
- 这条主功能链路已经真正存在,不属于“文档写了、实现没跟上”。
### 3.3 modal 型 function 的首次点击分流
已实现:
- `npc_trade`
- `npc_gift`
- `npc_recruit`
这些 function 当前都明确是“首次点击先分流,再在确认后进入真正执行链”。
当前实现路径:
- 首次点击:
- `choiceActions.ts`
- `storyGenerationState.ts`
- `npcInteraction.ts`
- 确认后:
- `trade / gift / quest` 进入 server runtime action
- `recruit` 进入本地招募对白与本地状态提交链
判断:
- 这不是未实现,而是当前架构设计如此。
- 文档里“确认后要继续推进剧情/结算”的要求已经满足。
### 3.4 Task6 function 的服务端化
已实现:
- `inventory_use`
- `equipment_equip`
- `equipment_unequip`
- `forge_craft`
- `forge_dismantle`
- `forge_reforge`
- `npc_trade`
- `npc_gift`
- `npc_quest_accept`
- `npc_quest_turn_in`
- `treasure_secure`
- `treasure_inspect`
- `treasure_leave`
这些 function 已经在前端 `runtimeStoryService.ts`、服务端 `story runtime / inventory / runtime-item / quest` 模块里形成闭环。
判断:
- 这部分不需要再回退到前端本地重写。
## 4. 本轮发现的真实缺口
本轮真正仍未完整落地的,不是功能行为本身,而是下面两条回归保护:
### 4.1 `npc_chat -> story_continue_adventure -> deferredOptions`
问题:
- 文档明确要求这条链路要清楚、可验证。
- 代码已经有,但之前没有直接测试“点击继续冒险后必须展示 deferredOptions”。
本轮已补:
- `src/hooks/story/choiceActions.test.ts`
- 新增 `reveals deferred adventure options when story_continue_adventure is selected`
### 4.2 `AdventurePanel` 对 continue option 的识别方式
问题:
- 文档明确要求不要再靠文案识别 continue option。
- 实现已经改成按 `functionId` 识别,但之前没有组件层测试锁住。
本轮已补:
- `src/components/AdventurePanel.test.tsx`
- 验证 `story_continue_adventure` 即使 actionText 改掉,仍然显示延迟选项提示
- 验证仅 actionText 相同但 functionId 不同,不会误触发提示
## 5. 本轮新增测试
本轮新增:
- `src/components/AdventurePanel.test.tsx`
- `src/hooks/story/choiceActions.test.ts`
- 新增 deferred options 恢复用例
## 6. 验收结论
按当前 function 相关文档要求判断:
- 核心运行时需求:已实现
- 延迟展示链路:已实现
- modal 分流架构:已实现
- Task6 服务端承接:已实现
- 回归测试盲区:本轮已补齐关键缺口
最终建议:
- 这块现在不应该继续过度迭代。
- 后续应以“新增需求再增量补测试”为主,而不是再次重构 function 主链路。
- 除非后续 PRD 明确要求“modal 首次点击也必须立刻生成一段剧情反馈”,否则不建议为了形式统一再强行改写当前分流模型。

View File

@@ -0,0 +1,128 @@
# Function 测试审计2026-04-14
补充更新:
- 本文记录的 2 个 bug 已在同日完成代码修复。
- 对应测试已经从“稳定复现旧行为”切换为“验证修复后行为”。
## 1. 本次新增测试
本轮新增了两组 function 相关测试:
- `src/data/stateFunctions.test.ts`
- 覆盖 state function 的运行时过滤、优先级、选项解析、排序逻辑。
- `src/data/functionCatalog/functionCatalog.test.ts`
- 覆盖 function 文档映射、flow helper、NPC helper modal 初始化逻辑。
这两组测试都直接挂在现有 `vitest` 体系里,没有新建独立测试框架。
## 2. 本次执行结果
本轮实际执行了以下测试:
```bash
npx vitest run src/data/stateFunctions.test.ts src/data/functionCatalog/functionCatalog.test.ts
npx vitest run src/data/npcInteractions.test.ts src/hooks/story/storyGenerationState.test.ts src/services/runtimeStoryService.test.ts
```
执行结果:
- 新增测试:`2` 个文件,`12` 条测试,全部通过。
- 复跑已有 function 相关测试:`3` 个文件,`17` 条测试,全部通过。
- 修复回归测试:`5` 个文件,`30` 条测试,全部通过。
- 编码检查:`1516` 个文件全部通过。
说明:
- 本轮不是“测试全绿就代表没有问题”。
- 最初定位出的 2 个问题,已经在后续修复回合里转成了回归测试。
## 3. 历史 bug 与修复结果
### 3.1 `battle_recover_breath`
- 所在位置:`src/data/stateFunctions.ts`
- 状态:已修复
- 原始问题表现:
-`inBattle = true`,但当前没有存活敌人时,`battle_recover_breath` 仍会留在可执行 function 列表中。
- 直接原因:
- `matchesCategory``recovery` 分类只判断了是否处于战斗态,没有像 `battle` / `escape` 分类那样额外校验 `hasAliveMonsters(context.monsters)`
- 修复方式:
- 已在 `matchesCategory``recovery` 分支中,为战斗恢复类 function 补上 `hasAliveMonsters(context.monsters)` 判断。
- 修复前影响:
- 在“敌人已死但战斗态尚未清理干净”的边界帧里,界面仍可能出现战斗恢复类选项。
- 这会让 function 池和真实战斗状态产生残留错位。
- 当前验证方式:
- `inBattle = true`
- `monsters = [{ hp: 0, ... }]`
- 调用 `getExecutableFunctions(context)`
- 现在返回结果应为空,不再包含 `battle_recover_breath`
- 对应用例:
- `src/data/stateFunctions.test.ts`
- 用例名:`removes battle_recover_breath when combat has no living monsters`
### 3.2 `npc_trade`
- 所在位置:`src/data/functionCatalog/npc/npcTrade.ts`
- 状态:已修复
- 原始问题表现:
- trade modal 初始化时,`selectedPlayerItemId` 直接取 `state.playerInventory[0]?.id`
- 如果玩家背包第一项数量为 `0`modal 默认会选中一件不可出售物品。
- 直接原因:
- `buildNpcTradeModalState` 没有过滤 `quantity <= 0` 的物品,也没有寻找第一个可交易物品。
- 修复方式:
- 已改为优先选择 `quantity > 0` 的可交易物品。
- 同时对 NPC 库存和玩家背包都使用同一条筛选规则,避免默认选中空物品。
- 修复前影响:
- 交易面板第一次打开时,默认状态可能就是不可确认的。
- 用户需要手动切换到第二件物品,才会进入可提交状态。
- 当前验证方式:
- `playerInventory[0].quantity = 0`
- `playerInventory[1].quantity > 0`
- 调用 `buildNpcTradeModalState(...)`
- 现在 `selectedPlayerItemId` 应该自动落到第一件可交易物品
- 对应用例:
- `src/data/functionCatalog/functionCatalog.test.ts`
- 用例名:`prefers the first tradable player item when zero-quantity items exist`
- `src/hooks/story/storyGenerationState.test.ts`
- 用例名:`skips zero-quantity player items when opening the trade modal`
## 4. 本轮已验证通过的 function 能力
以下内容本轮已通过测试验证,没有发现新的明显问题:
- state function runtime 构建:
- `idle_follow_clue` 已正确从运行时候选池移除。
- `idle_explore_forward` 的运行时文案覆盖仍然生效。
- state function 选项行为:
- 高压战斗下 `battle_recover_breath` 会被正确提权。
- 营地场景会正确隐藏 `idle_explore_forward`
- `idle_travel_next_scene` 会强制使用运行时建议 actionText。
- `battle_all_in_crush` 会保留外部传入的自定义 actionText。
- story option 排序仍保持“前 2 个 model 锁定 + 后续按 priority 排序”。
- flow helper
- `story_continue_adventure`
- `camp_travel_home_scene`
- NPC helper
- `npc_preview_talk`
- `npc_gift`
- `npc_recruit`
- 文档映射:
- 当前 `SERVER_RUNTIME_FUNCTION_IDS` 全部都能在 function catalog 文档中找到对应条目。
- 本轮扫描到的 function `source` 路径全部存在,没有出现失效引用。
## 5. 本轮修复动作
- `battle_recover_breath`
- 已补充战斗恢复类 function 的存活敌人校验,避免战斗边界帧残留非法选项。
- `npc_trade`
- 已把 trade modal 默认选中逻辑改为优先寻找可交易物品,不再直接吃数组第一项。
- 回归测试
- 原先记录旧行为的测试已翻转为修复后预期,并额外补了一条 `storyGenerationState` 接入层测试。
## 6. 备注
这次测试资产的意义分两步:
- 第一步先把 bug 稳定复现出来,避免问题只停留在口头描述。
- 第二步在修复后把断言翻转成“正确行为”,让它们正式成为回归测试。

View File

@@ -10,6 +10,8 @@
## 专项审计
- [FUNCTION_DESIGN_AUDIT_2026-04-03.md](./FUNCTION_DESIGN_AUDIT_2026-04-03.md)Function 体系分层、职责边界和当前结构问题。
- [FUNCTION_REQUIREMENT_COMPLETENESS_AUDIT_2026-04-14.md](./FUNCTION_REQUIREMENT_COMPLETENESS_AUDIT_2026-04-14.md)Function 相关文档需求与当前实现对齐核查。
- [FUNCTION_TEST_AUDIT_2026-04-14.md](./FUNCTION_TEST_AUDIT_2026-04-14.md)Function 运行时测试补充、已确认 bug 与当前验证结果。
- [ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md](./ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md):物品生成与 Build 标签系统对 PRD 的落地情况。
- [CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md](./CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md):自定义世界创作工具当前问题、体验断层和优化优先级审计。

View File

@@ -0,0 +1,721 @@
# 自定义世界创作中手填、AI 可改与系统托管的平衡设计
更新时间:`2026-04-12`
## 0. 文档目标
这份文档用于回答一个更具体的问题:
**参考 RPG 专业剧情策划全流程后,在自定义世界创作工具里,哪些设定必须要求创作者手动填写,哪些设定应该由 AI 先生成但允许创作者修改,哪些设定应完全交给系统托管,才能在“尽可能降低门槛”和“尽可能提高作品质量”之间取一个平衡。**
这份文档不再只回答“创作者与 AI 怎么分工”,而是进一步把创作工作台收束成一个更可执行的三层输入结构:
1. 创作者必须手填的高杠杆锚点
2. AI 先生成、创作者可修改的内容草稿层
3. 系统自动编译和运行的托管层
一句话结论:
**让创作者只负责决定作品的灵魂、视角、冲突和关系钩子,让 AI 负责把这些锚点展开成可编辑的剧情草稿,让系统负责把草稿编译成可运行的结构。**
---
## 1. 设计目标
这套平衡设计要同时满足 5 个目标:
1. 低门槛
- 新创作者不需要写长篇设定,也不需要理解底层系统结构。
2. 高辨识度
- 创作者写出来的世界,不应该只是“像一个世界”,而应该保留明显的个人方向。
3. 高可编辑性
- AI 不能一次生成后就不可控,创作者必须能改关键对象、关键关系和关键章节。
4. 高稳定性
- 任务、章节、关系、物件和可见性等运行层结构不能依赖创作者手填专业字段。
5. 可扩展
- 愿意深挖的创作者可以继续补充世界上限,不愿深挖的人也能快速产出质量不错的作品。
---
## 2. 核心原则
## 2.1 创作者手填的必须是“高杠杆决策”,不是“高工作量字段”
应该要求创作者手填的内容,必须同时满足下面两个条件:
1. 会显著决定作品气质和辨识度
2. AI 很难替代判断
例如:
- 世界一句话
- 玩家身份
- 核心冲突
- 关系钩子
- 禁忌边界
而不应该强制手填:
- 全量 NPC
- 全量场景
- 技能列表
- 初始物品
- 章节拆分
- 运行时信号结构
## 2.2 创作者可改层应该承接“专业策划初稿”,而不是“原始底层字段”
AI 生成后允许创作者修改的,不应该是一堆技术型字段,而应该是一批已经成形的内容卡片,例如:
- 关键角色卡
- 势力卡
- 关键地点卡
- 主线章节卡
- 支线种子卡
- 场景章节卡
- 标志性物件卡
也就是说:
**AI 先给创作者一个像策划初稿的东西,而不是给一堆系统字段让创作者自己拼。**
## 2.3 系统托管层必须彻底隐藏专业运行结构
以下这类结构不应该默认要求创作者理解或编辑:
- `ThemePack`
- `WorldStoryGraph`
- `KnowledgeFact`
- `VisibilitySlice`
- `SceneNarrativeDirective`
- `StorySignal`
- `ThreadContract`
- 数值预算
- 稀有度映射
- 掉落和 build 权重
创作者应该编辑的是自然语言与内容卡,而不是运行时图结构。
## 2.4 先少量必填,再逐层展开
最合理的工作流不是“开局填一大页表”,而是:
```text
先填最小必填卡
-> AI 生成世界初稿
-> 创作者修改关键对象
-> 系统继续展开长尾
-> 创作者决定是否进入高级补充
```
## 2.5 默认清爽,深度能力后置
结合当前项目约束,创作工作台默认不要把规则说明、底层字段、专业术语堆到 UI 面板里。
应该做到:
1. 默认只展示最有创作价值的卡片
2. 高级内容折叠到后置面板
3. 大多数系统结构不直接暴露
4. 移动端也能完成最小创作闭环
---
## 3. 最终建议:三层分工
## 3.1 第一层:必须要求创作者手动填写
这一层只保留最影响作品质量的高杠杆锚点,建议默认强制填写 6 张卡。
## 3.2 第二层AI 生成后支持创作者修改
这一层由 AI 根据第一层锚点自动展开成专业剧情策划初稿,创作者可以逐项修改、锁定、局部重生成。
## 3.3 第三层:其余都交给系统
这一层是把前两层编译成可运行游戏结构所需的系统字段、数值和运行时指令,默认不要求创作者处理。
---
## 4. 最低门槛方案:只强制手填 6 张卡
如果目标是尽可能降低门槛,同时又保留作品辨识度,建议只强制创作者填写以下 6 张卡。
## 4.1 卡 1世界一句话与核心幻想
创作者必须手填:
- 世界一句话设定
- 玩家来到这个世界最想体验的感觉
- 这个世界和同类题材相比最不同的一点
原因:
- 这是作品的方向盘
- 这是后续 AI 所有扩写的总锚点
推荐输入形态:
- 一句话文本
- `1~3` 个体验关键词
## 4.2 卡 2玩家身份与开局困境
创作者必须手填:
- 玩家是谁
- 玩家开局最缺什么
- 玩家为什么必须进入这场故事
- 玩家天然站在什么位置上
原因:
- 玩家视角不清,后面所有剧情都会发散
- 这是主线入口、关系入口和任务入口的共同基础
## 4.3 卡 3主题气质与禁忌边界
创作者必须手填:
- 主题关键词
- 情绪基调
- 审美方向
- 禁止出现或尽量避免的内容
原因:
- 这决定世界“是什么味道”
- 这也是避免 AI 跑偏最有效的一层
推荐输入形态:
- 标签选择
- 语气滑条
- 一小段补充说明
## 4.4 卡 4核心冲突
创作者必须手填:
- 当前世界最重要的 `1~3` 个明面冲突
- 至少 `1` 个隐藏问题或暗面危机
- 玩家最先接触的是哪条冲突
原因:
- 没有冲突,世界就只剩设定
- 没有暗面问题,后续剧情就难以产生层次和改判
## 4.5 卡 5关键关系钩子
这里不强制创作者一开始填写完整角色档案,只要求填写更高杠杆的“关系骨架”。
创作者必须手填:
- `2~4` 条关键关系钩子
- 每条钩子至少说明:
- 谁和谁有关
- 关系是债、仇、误解、旧情、利用还是血缘
- 这条关系里压着什么秘密或代价
原因:
- 作品的人味和记忆点主要来自关系张力
- 关系钩子比完整角色长文更容易写,也更高杠杆
## 4.6 卡 6标志性要素与硬规则
创作者必须手填:
- `2~5` 个标志性要素
- 物件
- 怪物
- 制度
- 仪式
- 能力体系
- 社会规则
- 至少 `1~3` 条不能被 AI 擅自改写的硬规则
原因:
- 这决定世界是否有独特手感
- 后续命名、剧情、物件和场景都会反复依赖这些母题
---
## 5. 不建议强制手填,但应该让 AI 生成后支持创作者修改的设定
这一层是平衡“低门槛”和“高质量”的关键。
创作者不需要从零填写这些内容,但 AI 生成后必须能看、能改、能锁定、能局部重生成。
## 5.1 世界外观层
建议 AI 先生成后可改:
- 世界名称
- 副标题
- 世界简介
- 宣传短句
- 主题母题摘要
- 命名风格建议
原因:
- 这些内容影响观感,但不值得强制占用开局填写成本
## 5.2 势力层
建议 AI 先生成后可改:
- `2~6` 个关键势力
- 每个势力的公开目标
- 每个势力的隐藏目标
- 势力间的主要矛盾
- 代表人物
- 势力资源与禁忌
原因:
- 势力很重要,但让新手一开始手写完整势力表太重
- 更合理的做法是让 AI 基于核心冲突先出草稿,再由创作者修正
## 5.3 关键角色层
建议 AI 先生成后可改:
- 关键角色姓名
- 外显身份
- 公众面具
- 当前压力
- 表面目标
- 真实目标
- 背景旧事
- 禁区
- 与玩家关系方向
- 角色个人线阶段
- 背景章节 teaser
原因:
- 创作者已经通过“关系钩子”给出最关键的人物骨架
- AI 负责把钩子展开成可编辑角色卡,创作者再做精修
## 5.4 关键地点层
建议 AI 先生成后可改:
- `4~10` 个关键地点
- 每个地点的功能定位
- 气氛和视觉母题
- 涉及的线程和秘密
- 首次进入时的情绪目标
- 关联角色和标志性载体
原因:
- 地点是世界感的重要来源
- 但新创作者未必能一开始就写出完整地点网络
## 5.5 世界线程层
建议 AI 先生成后可改:
- 明线线程
- 暗线线程
- 旧事伤痕
- 误导信息
- 主要 handoff
- 阶段揭示节奏
原因:
- 线程是专业剧情结构,适合 AI 先搭骨架
- 但创作者必须有权修正哪条线更重要、哪条线该隐藏
## 5.6 主线章节层
建议 AI 先生成后可改:
- 幕结构建议
- 章节标题
- 章节承诺
- 转折设计
- 高潮行动
- 章节 handoff
原因:
- 创作者已经给出了世界目标、冲突和关系
- AI 可以先把它们编成主线章节初稿
- 创作者再选择保留、删减或重排
## 5.7 支线、角色线、阵营线层
建议 AI 先生成后可改:
- 支线种子
- 角色线阶段事件
- 阵营线分歧点
- 私聊或同伴互动节点
- 支线和主线的互文关系
原因:
- 这是最适合 AI 拉开内容宽度的部分
- 也是最需要创作者局部精修的部分
## 5.8 场景章节层
建议 AI 先生成后可改:
- 场景章节标题
- `opening / expansion / turning_point / climax / aftermath`
- 情感锚点 NPC
- 现场压力
- 转折信息
- 局部收束
- 下一跳 handoff
原因:
- 当前项目已经在走“场景 = 章节单元”的方向
- 这层非常适合 AI 编排出第一版,再由创作者补强记忆点
## 5.9 叙事载体层
建议 AI 先生成后可改:
- 标志性物件
- 文书
- 残痕
- 证物
- 场景遗物
- 怪物命名及其故事指向
创作者主要修改:
- 哪些载体最重要
- 哪些载体和哪条线程绑定
- 哪些载体需要更强的个人风格
## 5.10 文案包装层
建议 AI 先生成后可改:
- 角色简介
- 地点短描述
- 势力介绍
- 章节标题候选
- 任务标题与简述
- 物件命名候选
原因:
- 这些内容适合 AI 批量铺量
- 创作者只需要挑、改、锁定,不必从零起草
---
## 6. 其余设定应交给系统托管
以下内容不建议默认暴露给创作者编辑,应由系统根据前两层自动编译和维护。
## 6.1 题材与术语编译层
交给系统:
- `ThemePack`
- 题材词汇表
- 命名模式映射
- 母题标签映射
原因:
- 这是系统为了统一生成风格而维护的内部层
## 6.2 世界图谱运行层
交给系统:
- `WorldStoryGraph`
- `KnowledgeFact`
- 事实 id
- 线程内部关联
- 旧事与角色的细粒度映射
原因:
- 创作者要的是“故事线能对”,不是维护图数据库
## 6.3 可见性和 prompt 裁剪层
交给系统:
- `VisibilitySlice`
- 禁止注入信息列表
- 当前可说信息
- 推测信息
- 越权泄露检查
原因:
- 这层必须稳定、严格、自动化
- 不适合依赖创作者手动维护
## 6.4 运行时导演层
交给系统:
- `SceneNarrativeDirective`
- 节奏推进指令
- 披露预算
- 当前主压力判断
- 当前前景角色和前景载体
原因:
- 这是剧情运行时调度逻辑,不是创作表达层
## 6.5 任务编译层
交给系统:
- `ThreadContract`
- `StorySignal`
- step id
- step 类型映射
- 触发条件编译
- 结算条件编译
说明:
- 创作者可以编辑“任务卡”和“章节卡”
- 但不应默认编辑底层 contract 结构
## 6.6 数值与配置层
交给系统:
- 技能数值
- 初始物品预算
- 稀有度分布
- 掉落权重
- build 标签映射
- 关系数值初始值
- 敌对强度预算
说明:
- 创作者可以给“偏向”
- 系统负责编译成具体数值
## 6.7 QA 与一致性层
交给系统:
- 设定冲突检查
- 同名检查
- 风格漂移检查
- 关系矛盾检查
- 主线与支线弱关联检查
- 未解锁信息泄露检查
- 长尾内容覆盖率检查
原因:
- 这属于高频维护型工作,最适合系统自动做
---
## 7. 按模块划分的最终边界表
| 模块 | 必须手填 | AI 生成后可改 | 系统托管 |
| --- | --- | --- | --- |
| 世界定位 | 世界一句话、核心幻想、差异点 | 世界名称、副标题、简介 | 题材词汇编译 |
| 玩家视角 | 玩家身份、开局困境、初始动机 | 开局剧情摘要、开局目标文案 | 开局状态初始化 |
| 主题边界 | 主题、气质、禁忌、硬边界 | 主题母题摘要、风格建议 | 风格约束编译 |
| 核心冲突 | 明面冲突、隐藏危机 | 线程草稿、旧事伤痕、误导设计 | 世界图谱、事实映射 |
| 关系骨架 | 关键关系钩子 | 关键角色卡、个人线阶段、背景章节 teaser | 关系数值、解锁条件 |
| 标志性要素 | 标志物、怪物、制度、规则 | 标志载体卡、命名候选、衍生变体 | 物件指纹、掉落映射 |
| 势力 | 不强制首轮手填 | 势力卡、代表人物、势力冲突 | 阵营状态映射 |
| 地点 | 不强制首轮手填 | 关键地点卡、场景网络、氛围描述 | 场景连接编译 |
| 主线 | 不强制首轮手写完整主线 | 幕结构、章节卡、高潮与 handoff | 章节状态编译 |
| 支线/角色线 | 不强制首轮手写完整矩阵 | 支线种子、角色线事件、阵营线分歧 | 任务 contract 编译 |
| 场景章节 | 不强制首轮手写全量章节 | 场景章节卡、阶段内容、章节载体 | signal 与导演层 |
| 运行时结构 | 不建议创作者接触 | 不建议默认编辑 | 可见性、导演、信号、编译、QA |
---
## 8. 推荐创作流程
## 8.1 第一步:只填写最小必填集
创作者只需要完成:
1. 世界一句话与核心幻想
2. 玩家身份与开局困境
3. 主题气质与禁忌边界
4. 核心冲突
5. 关键关系钩子
6. 标志性要素与硬规则
这一步应控制在:
- `5~15` 分钟
- `200~800`
- 或更少文字配合标签选择
## 8.2 第二步AI 生成“策划初稿包”
系统根据最小输入,生成一份结构化初稿包,建议至少包括:
1. 世界标题与摘要
2. `3~5` 个关键角色卡
3. `2~4` 个势力卡
4. `4~8` 个关键地点卡
5. `3~5` 条世界线程
6. `3~6` 个场景章节卡
7. 一批支线种子和标志性载体
这里的重点不是一次补满全世界,而是先形成一个像样的内容骨架。
## 8.3 第三步:创作者只精修高价值卡片
建议默认优先让创作者编辑这 4 类卡片:
1. 关键角色
2. 核心冲突与线程
3. 关键地点
4. 主线第一幕或前几个场景章节
这样能以最低编辑成本,最大幅度提升作品质量。
## 8.4 第四步:系统继续展开长尾
在关键卡片被锁定后,再由系统补:
- 长尾 NPC
- 支持性地点
- 次级支线
- 普通物件
- 任务包装
- 文案变体
## 8.5 第五步:创作者按需进入高级模式
高级模式只对愿意深挖的人开放:
- 角色背景章节编辑
- 场景章节细化
- 支线矩阵补完
- 阵营线分歧补强
- 结局变量微调
这一步不是默认主流程。
---
## 9. 哪些内容应该支持“锁定 + 局部重生成”
为了既保证低门槛,又保证创作安全感,第二层内容必须支持锁定和局部重生成。
建议至少支持锁定这些对象:
1. 世界一句话与主题边界
2. 核心冲突
3. 关键角色
4. 关键地点
5. 势力卡
6. 主线章节卡
7. 场景章节卡
8. 标志性载体
建议至少支持这些局部重生成动作:
1. 仅重生成长尾角色
2. 仅重生成长尾地点
3. 仅重生成支线种子
4. 仅重生成物件与残痕
5. 仅重生成某个角色卡
6. 仅重生成某个场景章节
7. 围绕已锁定角色重做主线第一幕
原则是:
**越高价值的锚点越要支持锁定,越低价值的铺量内容越适合重生成。**
---
## 10. 产品实现建议
## 10.1 默认 UI 只展示三段
建议工作台默认只展示:
1. 必填锚点
2. AI 初稿卡片
3. 高级模式入口
不要默认展示底层系统字段。
## 10.2 每张卡只保留自然语言输入
不要强迫创作者在首轮填写:
- tags
- ids
- 数值
- 稀有度
- 信号枚举
- step schema
更合理的做法是:
- 让创作者输入自然语言或选择直觉标签
- 再由系统编译成结构化字段
## 10.3 首轮生成后默认先看“精修建议”
AI 初稿生成后,不应该把创作者直接扔进一个大编辑器。
更好的做法是先给出:
1. 哪些卡片最值得改
2. 哪些内容已经比较稳定
3. 哪些内容仍然偏泛,需要创作者补个性
这样能明显提高创作者的修改效率。
## 10.4 移动端优先只保留高杠杆操作
移动端默认只应该支持:
1. 编辑必填卡
2. 浏览和修改关键角色卡
3. 浏览和修改关键地点卡
4. 锁定 / 重生成
5. 保存和继续创作
长表单和底层结构不要默认放在移动端主流程里。
---
## 11. 最后结论
如果目标是在自定义世界创作中真正平衡“降低门槛”和“提高作品质量”,最好的做法不是让创作者填更多字段,也不是把一切都交给 AI。
更合理的平衡是:
1. 创作者必须手填最小但高杠杆的 6 张卡,掌握世界灵魂。
2. AI 根据这 6 张卡生成一套可编辑的专业剧情初稿,负责把骨架展开成角色、地点、线程、章节和载体。
3. 创作者只精修最有价值的关键对象,锁定真正重要的内容。
4. 其余运行结构、数值、可见性、任务编译和 QA 检查都交给系统托管。
一句话收束:
**创作者负责决定“这个世界为什么值得被创作”AI 负责把它整理成可修改的策划初稿,系统负责把它稳定地跑成一个游戏世界。**

View File

@@ -0,0 +1,724 @@
# 纯 Agent 式对话创作工具与结构化创作方案的对比评估及转型设计
更新时间:`2026-04-12`
## 0. 文档目标
这份文档用于评估两种自定义世界创作形态:
1. 当前方向
- 基于“最小必填锚点 + AI 初稿卡片 + 系统托管层”的结构化创作方案
2. 纯 Agent 式方向
- 以前台对话为唯一主交互,创作者主要通过和 Agent 聊天来完成世界构建、角色塑造、剧情扩展和修改
文档需要回答 3 个问题:
1. 两种方案各自的优缺点是什么?
2. 对当前项目来说,纯 Agent 式是否更优?
3. 如果要转换成纯 Agent 式对话创作工具,应该怎么设计,才能不把质量和可控性一起丢掉?
一句话结论先说:
**纯 Agent 式对话创作工具更适合当“低门槛入口”和“陪创作过程”,但不适合把整个创作系统做成无结构、无锁定、无摘要、无编译层的纯聊天黑箱。**
更稳的方向不是“只有聊天”,而是:
**前台主交互纯 Agent后台继续保留结构化世界模型、锁定机制、局部重生成、编译层和质量护栏。**
---
## 1. 对比对象定义
## 1.1 当前结构化创作方案是什么
当前方案的核心是:
1. 创作者手填最小高杠杆锚点
2. AI 生成一批可编辑的剧情策划初稿卡片
3. 系统把内容编译成运行时结构
它本质上是:
**结构化工作台 + AI 协作生成。**
创作者的主要行为是:
1. 填写关键卡片
2. 修改关键角色、地点、势力、章节等内容卡
3. 锁定重要内容
4. 局部重生成次级内容
## 1.2 纯 Agent 式对话创作工具是什么
纯 Agent 式不是指“系统内部没有结构”,而是指:
**创作者前台几乎不需要面对表单和卡片编辑器,主要通过自然语言对话来完成创作。**
创作者的主要行为变成:
1. 用自然语言描述世界想法
2. 回答 Agent 的追问
3. 让 Agent 生成角色、地点、剧情和章节
4. 通过聊天指令要求修改、锁定、重做、总结和导出
它本质上是:
**对话式创作入口 + Agent 主导的协同编排。**
## 1.3 真正需要比较的不是“聊天 VS 表单”,而是“主交互模式 VS 后台结构”
很多产品会把问题误判成:
- 要么做聊天
- 要么做工作台
更准确的判断应该是:
1. 前台用户主要通过什么方式思考和输入?
2. 后台系统是否仍然有稳定的世界模型和编译层?
3. 创作者是否还能看见摘要、锁定内容和修改范围?
对当前项目来说,真正危险的不是“转成聊天”,而是:
**误把“纯 Agent 式”理解成“完全不需要结构化世界状态”。**
---
## 2. 总体结论
## 2.1 纯 Agent 式的主要优势
纯 Agent 式最大的价值,在于降低开局压力和创作焦虑。
它更擅长:
1. 帮不擅长表单和结构思考的创作者起步
2. 在创作者思路模糊时做追问和陪创作
3. 把“我要做一个世界”变成一次自然聊天
4. 动态决定追问深度,而不是一上来摆很多字段
5. 让创作者感觉自己是在和一个懂 RPG 的剧情搭档共创
## 2.2 纯 Agent 式的主要问题
纯 Agent 式最大的问题,不是能不能生成内容,而是:
**长项目一旦进入中后期,它会天然丢失可控性、可扫描性、可局部编辑性和可审计性。**
它最容易出现这些问题:
1. 聊天很多,但世界状态越来越难总览
2. 角色、地点、势力和章节信息散落在多轮消息里
3. 锁定范围不清,重生成容易误伤已有内容
4. Agent 很容易“替创作者决定太多”
5. 长会话越来越贵,越来越慢,也越来越容易漂移
## 2.3 对当前项目的判断
对当前项目而言:
1. 纯 Agent 式非常适合做创作入口
2. 纯 Agent 式也很适合做关键对象的精修与头脑风暴
3. 纯 Agent 式不适合作为唯一内容管理方式
因此更推荐的方向是:
**Agent-first而不是 Agent-only。**
也就是:
1. 前台以对话为主
2. 后台仍保留结构化世界状态
3. 关键内容仍然可被锁定、摘要、对比、局部重生成和导出
---
## 3. 纯 Agent 式对比当前方案的优缺点
## 3.1 对比表
| 维度 | 当前结构化方案 | 纯 Agent 式方案 | 更优者 |
| --- | --- | --- | --- |
| 首次上手门槛 | 比纯聊天高,需要理解少量卡片与阶段 | 最低,只需开口描述想法 | 纯 Agent |
| 创作陪伴感 | 中等AI 更像工具 | 很强Agent 更像搭档 | 纯 Agent |
| 思路模糊时的引导能力 | 有限,更多靠卡片提示 | 很强,可动态追问和启发 | 纯 Agent |
| 世界整体可扫描性 | 强,卡片和结构更容易总览 | 弱,聊天记录天然碎片化 | 当前方案 |
| 单对象精确编辑 | 强,适合定点修改 | 中等,容易在对话里带出额外变化 | 当前方案 |
| 锁定与局部重生成 | 容易做明确边界 | 容易模糊,需额外设计指令语义 | 当前方案 |
| 长项目稳定性 | 高,适合几十个对象持续维护 | 中等偏弱,越长越依赖摘要和记忆管理 | 当前方案 |
| 内容一致性维护 | 更容易做编译与 QA | 纯聊天很难稳定维护,需要后台隐藏编译 | 当前方案 |
| 移动端输入体验 | 表单负担偏大 | 聊天输入天然更友好 | 纯 Agent |
| 移动端结果总览 | 卡片更好浏览 | 长聊天记录不利于回看 | 当前方案 |
| 专业策划生产效率 | 中后期更高 | 前期更快,中后期容易反复确认 | 当前方案 |
| 新手用户心理压力 | 偏高,容易觉得要“填很多东西” | 低,更像在聊一个想法 | 纯 Agent |
| 实现复杂度 | 已有方向较明确 | 真正做好会更复杂,需要对话层和隐藏结构双系统 | 当前方案 |
| Token / 成本 / 延迟 | 更容易按模块调用 | 长会话上下文更重,成本更高 | 当前方案 |
| 可审计和交接 | 强,更适合多人协作 | 弱,需要额外导出和摘要机制 | 当前方案 |
## 3.2 当前结构化方案的主要优点
当前方案更强的地方在于:
1. 有明确的内容边界
2. 更容易做锁定、重生成和局部修改
3. 更适合中大型世界的长期维护
4. 更适合和后端编译层、任务层、章节层做稳定映射
5. 更容易把专业剧情策划流程映射成可执行数据
它的本质优势是:
**稳定、清楚、可扩展。**
## 3.3 当前结构化方案的主要缺点
当前方案更弱的地方在于:
1. 仍然有“我要开始填工具了”的压力
2. 对不擅长结构化思考的新手不够友好
3. 澄清、启发和陪创作感不够强
4. 很容易从“低门槛工作台”滑向“字段很多的编辑器”
5. 移动端如果处理不好,会有明显表单压迫感
## 3.4 纯 Agent 式方案的主要优点
纯 Agent 式更强的地方在于:
1. 入口极低
2. 更符合普通人“先说想法”的自然习惯
3. 更适合模糊创意逐步收束
4. 更容易把澄清问题变成真实协作
5. 更容易营造“有专业编剧陪你做世界”的体验
它的本质优势是:
**自然、轻松、像在共创。**
## 3.5 纯 Agent 式方案的主要缺点
纯 Agent 式更弱的地方在于:
1. 世界模型隐藏得太深时,创作者会失去整体掌控感
2. 多轮对话后,已确定内容不容易被清晰回看
3. 局部重做和精确编辑边界会变模糊
4. Agent 容易过度代写、过度主导
5. 没有强摘要和锁定机制时,创意很容易漂移
它的本质问题是:
**天然更擅长起步,不天然擅长收口。**
---
## 4. 对当前项目是否值得转成纯 Agent 式的判断
## 4.1 值得转的部分
以下环节非常适合转成纯 Agent 主交互:
1. 首次创作入口
2. 世界灵魂锚点收集
3. 低信息量输入后的澄清与启发
4. 关键角色、关键地点、核心冲突的初稿展开
5. 对单个角色或单个章节做陪创作式精修
因为这些环节的关键问题不是“字段如何摆放”,而是:
**创作者有没有被真正引导出自己想做的世界。**
## 4.2 不值得直接转成纯聊天黑箱的部分
以下环节不适合彻底做成无结构纯聊天:
1. 长项目世界管理
2. 大量角色、地点、支线、章节的总览
3. 锁定与局部重生成
4. 运行时结构编译
5. 质量审计与一致性检查
6. 导出和交付
这些环节需要的是:
**稳定的结构化世界状态,而不是越来越长的聊天记录。**
## 4.3 最合理的判断
如果硬要二选一:
1. 对新手用户和移动端体验,纯 Agent 更有吸引力
2. 对专业生产、长期维护和内容质量,当前结构化方案更稳
所以真正适合当前项目的不是完全替换,而是:
**把当前方案的“结构和护栏”保留,把用户感受到的“入口和协作方式”改成纯 Agent。**
---
## 5. 如果要转换成纯 Agent 式,对什么必须保持不变
纯 Agent 式可以改变前台交互,但不应该改变下面这些底层原则。
## 5.1 内容分层边界不能变
即使转成纯 Agent 式,也仍然要保留这三层:
1. 创作者必须确认的高杠杆锚点
2. AI 生成但允许创作者修改的策划初稿层
3. 系统托管的运行时编译层
变化的只是:
- 这些内容不一定通过卡片表单采集
- 可以通过对话逐步收集和确认
不应该变化的是:
- 谁来决定世界灵魂
- 谁来决定运行时结构
## 5.2 锁定机制不能变
纯 Agent 式必须仍然支持:
1. 锁定世界主题
2. 锁定核心冲突
3. 锁定关键角色
4. 锁定关键地点
5. 锁定主线章节
6. 锁定场景章节
7. 只重做未锁定部分
否则纯 Agent 式会很快变成:
**每次聊一句,世界都在偷偷漂移。**
## 5.3 局部重生成机制不能变
纯 Agent 式里也必须支持:
1. 只重生成长尾 NPC
2. 只重生成次级地点
3. 只重生成某个角色卡
4. 只重生成某个章节
5. 围绕锁定对象重做剩余草稿
如果这点没有做好,对话就会越来越像“整世界覆盖式重写”。
## 5.4 摘要、快照、差异对比不能变
纯 Agent 工具如果没有这些能力,后期一定失控:
1. 当前世界摘要
2. 已锁定内容清单
3. 本轮修改了什么
4. 当前有哪些待确认假设
5. 能否回退到上一版本
所以:
**前台可以纯聊天,后台不能没有版本化世界圣经。**
---
## 6. 转成纯 Agent 式后的产品定义
## 6.1 定义
建议把转型后的工具定义为:
**以 Agent 对话为主交互的 RPG 世界共创工具。**
它不是:
- 单纯聊天框
- 一次性大文本生成器
- 没有状态的陪聊机器人
它应该是:
1. 会主动澄清
2. 会阶段性总结
3. 会把聊天结果沉淀成结构化世界状态
4. 会提醒风险和冲突
5. 会在创作者要求时进行局部重写和定向扩展
## 6.2 正确理解
最重要的一句定义是:
**界面可以纯 Agent数据层绝不能纯会话。**
也就是说:
1. 创作者看到的是对话
2. 系统内部维护的是世界模型、锁定状态、摘要和编译结果
---
## 7. 纯 Agent 式工具的推荐交互模型
## 7.1 阶段 A创作意图收集
Agent 不直接要求用户填表,而是通过 `1~3` 轮自然对话收集最小锚点。
目标是确认:
1. 世界一句话
2. 玩家身份
3. 核心冲突
4. 主题气质
5. 关键关系钩子
6. 标志性要素
这实际上和当前“最小必填 6 张卡”是同一套内容,只是采集方式改成对话。
## 7.2 阶段 BAgent 输出首轮世界底稿
Agent 首轮不应该直接铺满全世界,而应该给出一份简明底稿,例如:
1. 世界标题和摘要
2. 玩家开局定位
3. 核心冲突结构
4. `3~5` 个关键角色
5. `4~6` 个关键地点
6. `2~4` 个势力
7. 主线第一幕简稿
同时必须明确分成 3 类:
1. 已确认内容
2. 建议内容
3. 待确认内容
## 7.3 阶段 C创作者锁定锚点
在纯 Agent 模式里,锁定行为必须被显式支持。
用户可以自然说:
- 这个世界观锁定
- 这个角色保留,不要再改
- 只把第一幕重做一下
- 势力关系别动,重新想地点
系统需要把这些自然语言翻译成正式的锁定状态和重生成范围。
## 7.4 阶段 D按对象逐步精修
Agent 不应该每轮都继续扩全局,而应该支持“单对象工作模式”。
例如:
1. 只精修某个角色
2. 只精修某个地点
3. 只精修某个场景章节
4. 只精修主线第一幕
5. 只精修一条支线
这样可以避免每轮修改都把整个世界重新搅动一次。
## 7.5 阶段 E系统后台自动编译与审计
每一轮重要修改后,系统后台应自动做:
1. 世界图谱更新
2. 可见性边界更新
3. 章节和任务编译
4. 设定冲突检查
5. 弱关联检查
6. 风格一致性检查
这些结果不一定全部展示,但必须被系统持续维护。
## 7.6 阶段 F导出世界圣经和可编辑初稿
纯 Agent 模式的最终产物不能只是一串聊天记录。
至少要能导出:
1. 世界摘要
2. 锁定锚点
3. 关键角色卡
4. 关键地点卡
5. 势力卡
6. 主线章节简稿
7. 支线种子
8. 场景章节草稿
9. 风险与待确认项
---
## 8. 纯 Agent 式工具需要的后台结构
## 8.1 会话层之外必须维护的核心状态
建议后台至少维护下面这些结构:
| 结构 | 作用 |
| --- | --- |
| `creatorIntentProfile` | 当前创作者最初和最新的创作意图 |
| `lockedAnchors` | 已确认不可自动改写的内容 |
| `worldDraftSnapshot` | 当前世界底稿快照 |
| `editableDraftCards` | 角色、地点、势力、章节等可编辑初稿 |
| `pendingClarifications` | 当前还未确认的问题 |
| `changeLog` | 每轮发生了什么变化 |
| `qualityFindings` | 冲突、泄露、弱关联和风格漂移结果 |
## 8.2 每轮对话后的处理流程
建议每次用户发言后走这条后台链:
```text
用户消息
-> 意图识别
-> 判断是在回答问题 / 修改对象 / 请求重生成 / 请求总结 / 请求锁定
-> 更新 creatorIntentProfile 或 worldDraftSnapshot
-> 重新编译相关草稿对象
-> 运行质量检查
-> 生成本轮回复
-> 同步更新摘要、待确认项和 changeLog
```
这条流程说明:
**纯 Agent 的前台体验背后,实际仍然是一个结构化内容状态机。**
---
## 9. 纯 Agent 式前台应该如何设计
## 9.1 主界面以对话为主
主界面可以只有一个核心聊天线程,但不建议只有聊天气泡。
建议保留 3 个轻量辅助区:
1. 顶部固定摘要
- 当前世界名
- 当前阶段
- 当前聚焦对象
2. 锁定内容条
- 展示已锁定的世界观、角色、地点、章节
3. 当前草稿摘要抽屉
- 展示关键角色、关键地点、主线第一幕等的简要卡片
这些区域不是表单编辑器,而是:
**对话模式下帮助用户保持掌控感的最小结构提示。**
## 9.2 支持快捷动作
为了防止用户每次都要自己组织复杂自然语言,建议保留轻量快捷动作:
1. 总结当前设定
2. 锁定当前版本
3. 只重做这一项
4. 展开主线第一幕
5. 增加一个关键角色
6. 给我 3 个更有辨识度的版本
7. 检查是否有设定冲突
这类动作按钮不破坏纯 Agent 主交互,反而能显著降低误解成本。
## 9.3 Agent 的提问规则
Agent 不能像问卷系统,也不能一次追问太多。
建议规则:
1. 一次最多追问 `1~3` 个问题
2. 问题必须是当前最缺的高杠杆信息
3. 每次追问都给默认建议方向
4. 如果创作者不想细答,允许 Agent 先代补一个版本再确认
这样才能保持“像聊天”,而不是“像客服表单”。
## 9.4 Agent 的总结规则
纯 Agent 工具必须高频做阶段性总结。
建议在这些时机自动总结:
1. 首轮世界底稿生成后
2. 锁定任意关键锚点后
3. 完成某个角色精修后
4. 主线第一幕生成后
5. 每累计 `5~8` 轮重要对话后
总结必须包含:
1. 已确认内容
2. 新增内容
3. 待确认内容
4. 潜在风险
---
## 10. 纯 Agent 式下的锁定、重生成与修改语义
## 10.1 锁定语义
建议支持以下语义:
1. 锁定对象
2. 锁定字段
3. 锁定关系
4. 锁定当前版本
例如:
- 锁定这个角色的身份和秘密,但可以重写语气
- 锁定这条冲突,不要再改动它的基本方向
- 锁定第一幕结构,只优化角色高光
## 10.2 重生成语义
建议支持以下语义:
1. 完整替换
2. 保留锚点重做
3. 仅补长尾
4. 给出多个候选版本
例如:
- 保留世界观和角色,重做关键地点
- 保留第一幕结构,给我三个更强的转折版本
- 只补 5 个更有辨识度的路人 NPC
## 10.3 修改语义
Agent 应能识别这些常见修改类型:
1. 收紧风格
2. 增强冲突
3. 提高角色辨识度
4. 减少套路感
5. 让地点更有故事残痕
6. 把支线和主线绑定得更紧
7. 提高队友反应和选择后果
这些应该是内容层意图,而不是要求用户直接碰底层字段。
---
## 11. 纯 Agent 式的主要风险与防护
## 11.1 风险 1对话越长世界越散
防护方式:
1. 周期性强制摘要
2. 关键内容结构化落库
3. 锁定内容固定展示
4. 提供“当前世界圣经”入口
## 11.2 风险 2Agent 过度代写,创作者失去作品归属感
防护方式:
1. 高杠杆锚点必须要求确认
2. 重要改动前先说“我准备改什么”
3. 默认优先给多个候选,而不是直接盖写
4. 允许创作者随时回退到旧版本
## 11.3 风险 3局部修改带出全局漂移
防护方式:
1. 只在目标作用域内修改
2. 修改后自动展示影响范围
3. 对高风险改动要求二次确认
## 11.4 风险 4看起来轻松实际上难以收口
防护方式:
1. 阶段化工作流
2. 每阶段有明确产物
3. 不是无限聊天,而是要进入“底稿确认 -> 精修 -> 导出”
## 11.5 风险 5成本和延迟持续上升
防护方式:
1. 长会话摘要压缩
2. 按对象加载上下文
3. 局部编译,而不是每轮重编整世界
---
## 12. 推荐转型路线
不建议一步到位把当前方案彻底替换成纯聊天。
更稳的路线是分 3 步走。
## 12.1 第一步:先把纯 Agent 做成默认入口
这一阶段:
1. 用户进入后直接和 Agent 聊
2. Agent 帮用户收集最小锚点
3. 系统在后台仍然生成当前方案里的结构化初稿
4. 结果页仍保留为可选工作台
这一阶段的目标是:
**把“起步方式”改成聊天,但不动后端结构主链。**
## 12.2 第二步:让关键对象编辑也支持 Agent 化
这一阶段:
1. 角色、地点、势力、主线第一幕都支持在聊天里精修
2. Agent 支持锁定、重做、总结、对比
3. 工作台逐步退成辅助视图,而不是默认主路径
这一阶段的目标是:
**让大多数高价值修改都可以通过聊天完成。**
## 12.3 第三步:工作台只保留总览和导出
到了这一阶段,前台已经基本纯 Agent 化,但仍建议保留:
1. 世界圣经总览
2. 已锁定对象列表
3. 版本快照
4. 风险与 QA 结果
5. 导出面板
这一阶段的目标不是消灭结构,而是:
**让结构从“编辑入口”退成“掌控和收口工具”。**
---
## 13. 最后结论
纯 Agent 式对话创作工具的最大优势,是把创作入口从“填写工具”变成“和懂创作的人对话”。
它会明显提升:
1. 首次上手意愿
2. 创作陪伴感
3. 模糊想法的收束效率
4. 移动端可用性
但它也天然会削弱:
1. 世界总览
2. 精确编辑
3. 局部重生成边界
4. 长项目稳定性
5. 质量审计与交接能力
因此,对当前项目最合理的方向不是彻底放弃结构化方案,而是把它升级成:
**前台纯 Agent 主交互,后台结构化世界模型持续存在,锁定、摘要、快照、局部重生成和质量护栏全部保留。**
一句话收束:
**可以把“创作入口”彻底 Agent 化,但绝不能把“世界状态管理”也做成纯聊天。**

View File

@@ -5,9 +5,12 @@
## 文档列表
- [CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md](./CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md):自定义世界里创作者输入与 AI 分工边界设计。
- [CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md](./CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md):自定义世界创作里“手填锚点 / AI 可改初稿 / 系统托管层”的平衡设计。
- [CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md](./CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md):纯 Agent 式创作工具与结构化工作台方案的优缺点对比,以及转型设计。
- [CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md](./CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md):把自定义世界从武侠/仙侠模板依赖迁到跨题材通用设定层的优化设计。
- [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。
- [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。
- [RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md](./RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md):专业剧情策划构建 RPG 游戏全剧情的工作流程与交付模板。
- [EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md](./EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md):配装构筑与合成/锻造闭环设计。
- [COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md](./COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md):角色首遇感、关系分层解锁、私聊系统设计。
- [SCENE_CHAPTER_LOOP_AND_FIRST_ENTRY_CHAPTER_QUEST_DESIGN_2026-04-08.md](./SCENE_CHAPTER_LOOP_AND_FIRST_ENTRY_CHAPTER_QUEST_DESIGN_2026-04-08.md):把每个场景收束成章节单元,并在首进场景时开启章节任务的设计稿。
@@ -17,7 +20,10 @@
## 推荐阅读
- 做物品、Build、锻造相关需求时先看前两份。
- 做 RPG 全剧情规划、主支线矩阵、角色线、场景章节与剧情交付模板时,先看新增的全剧情策划流程。
- 做自定义世界创作工作台、创作者输入边界、AI 分工设计时,先看第一份。
- 做“哪些内容必须让创作者手填、哪些适合 AI 先生成再改、哪些必须系统托管”这类分层设计时,优先看新增的输入平衡设计稿。
- 做“是否应该转成纯 Agent 式创作工具、转了之后前后台各该怎么收口”这类产品方向评估时,优先看新增的纯 Agent 对比与转型设计稿。
- 做自定义世界去模板依赖、跨题材泛化、兼容迁移设计时,优先看新增的去模板化优化设计稿。
- 做“模板依赖如何真正变成自定义世界自有设定层”的具体迁移方案时,优先看新增的自有设定层优化方案。
- 做角色关系、同伴互动、对话表现时,先看后两份。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,346 @@
# 方向 13 软件智能化提升奖励材料整理2026-04-14
## 1. 方向判断
### 1.1 与当前项目的匹配点
当前项目与附件 13 的匹配点主要有:
1. 项目本质上是 `游戏软件` 的智能化提升。
2. AI 能力不是外挂聊天而是深入到剧情、世界生成、任务、NPC 关系、运行时内容编排等核心软件功能。
3. 当前项目具有完整软件工程形态:
- 前端应用
- `Express` 后端
- 数据存储
- AI 服务编排
- 编辑器与内容工具链
### 1.2 当前方向的主要风险
附件 13 的门槛并不低,当前必须优先核验以下事项:
1. 项目纳入奖励范围的总投资额是否 `>= 500 万元`
2. 项目是否已经 `竣工并投入运行`
3. 项目建设周期是否 `<= 2 年`
4. 是否具备 `2 项软著``1 项发明专利`
5. 是否已接入 `已备案国产主流大模型`
6. 是否有 `日均 Token >= 500 万`
7. 是否能满足 `5 个不同客户案例``5 万 DAU`
8. 是否能取得 `CNAS/CMA` 机构出具的软件智能化成熟度测评报告
如果以上条件中有 3 项以上暂无依据,建议把方向 13 视为“高潜力但高补件强度”的主申报方向,而不是“马上能提报”的方向。
## 2. 官方要求拆解
### 2.1 申报条件
1. 在北京市登记注册,具有独立法人资格的信息软件企业
2. 项目纳入奖励范围的总投资额超过 `500 万元(含)`
3. 截至申报日项目已竣工并投入运行,竣工时间在 `2025 年 1 月 1 日` 及以后
4. 项目形成的智能软件产品具备自主知识产权,取得不少于 `2 项软著``1 项发明专利`
5. 项目建设周期不超过 `2 年`
### 2.2 绩效要求
项目需同时满足以下条件:
1. 形成智能化软件开发能力
- 代码生成占比 `>= 35%`,或代码行采纳率 `>= 30%`
2. 形成智能化软件产品
- 接入已备案国产主流大模型
- 具备自然语言或多模态交互
- 日均 Token 消耗量不少于 `500 万`
- 软件智能化成熟度至少达到 `部分智能化`
3. 具备行业应用效果
- 面向企业端:至少 `5 个不同客户`
- 面向消费者端:`DAU > 5 万`
## 3. 申报材料总清单
### 3.1 必交材料
1. 《北京市软件智能化提升项目绩效要求》对应证明材料
2. 《智能化提升项目纳入奖励范围的总投资要求》对应专项审计与明细
3. 《智能化提升奖励申报表》
4. 《智能化提升项目实施总结报告》及证明材料
5. 《北京市高精尖产业发展项目资金承诺书》
6. 企业最新版营业执照复印件
7. 其他与项目有关的补充资料
### 3.2 材料与项目事实的对应关系
| 官方材料 | 需要表达什么 | 当前项目可复用内容 | 当前待补件 |
| --- | --- | --- | --- |
| 申报表 | 项目基本信息、投资、创新点、成效、推广性 | `README.md``docs/prd/``docs/technical/` | 企业信息、投资额、建设时间、客户/DAU、资金支持情况 |
| 实施总结报告 | 项目背景、建设方案、关键技术、投资、绩效、效益 | `README.md`、剧情引擎/自定义世界/技术文档 | 立项/结项文件、专项审计、成熟度测评、日志证明 |
| 技术设备与投资明细 | 硬件、软件、材料、研发人员投入 | 可从采购、云资源、模型调用、开发工具里整理 | 发票、合同、付款凭证、记账凭证、审计报告 |
| 知识产权证明 | 项目形成软件的自主知识产权 | 仓库结构、架构、功能模块可支撑匹配说明 | 软著/专利证书本体 |
| 绩效证明 | AI 编码、模型接入、Token、成熟度、应用效果 | 代码仓库、运行日志、接口能力说明 | 第三方编码平台证明、Token 数据、测评报告、客户合同或 DAU |
## 4. 建议的申报项目命名
建议在以下 3 个口径中选一个:
1. `AI 原生剧情引擎与游戏软件智能化提升项目`
2. `面向互动叙事游戏的 AI 原生剧情引擎智能化提升项目`
3. `跨题材 AI 原生叙事游戏软件智能化改造项目`
推荐优先使用:
`AI 原生剧情引擎与游戏软件智能化提升项目`
原因:
- 兼顾 `游戏软件``智能化提升`
- 不把自己限制成单一玩法
- 后续客户案例或平台化能力也更好挂靠
## 5. 申报表字段建议底稿
### 5.1 项目所属领域
建议填写:
- `游戏软件`
### 5.2 项目主要内容1000 字内,可作为首稿)
建议首稿:
> 本项目围绕 AI 原生视觉 RPG 产品开展智能化提升,建设内容覆盖 AI 原生剧情引擎、自定义世界生成、角色关系与任务生成、运行时物品叙事编排、流式交互式对话以及后端持久化与内容工具链等核心模块。项目以“AI 负责叙事表达、本地规则负责状态裁决”为总体技术路线,通过接入大模型能力和本地规则编排机制,完成传统游戏内容生产和运行时交互链路的智能化改造,使游戏软件从固定脚本驱动升级为可结构化生成、可状态约束、可持续演进的 AI 原生软件产品。
### 5.3 项目关键技术和创新点1000 字内,可作为首稿)
建议首稿要点:
1. `剧情引擎结构化`
- 通过 `themePack / storyGraph / narrativeProfile / knowledgeFacts / threadContracts` 等结构,把大模型输出从松散文本升级为可控的剧情引擎语义层。
2. `AI 与本地规则分工`
- AI 负责叙事表达、关系生成、世界扩展,本地规则负责数值、状态、任务推进、背包、招募、持久化等核心逻辑,提升软件稳定性与可验证性。
3. `跨题材自定义世界生成`
- 支持从用户输入的世界锚点出发,生成世界框架、场景、角色、剧情线程和运行时资料,提升内容生产效率与可扩展性。
4. `运行时智能交互`
- 在实际游玩过程中支持流式剧情推进、NPC 对话、关系推进、任务生成和物品叙事编排,实现游戏软件运行态的智能化。
5. `前后端协同架构`
-`Express + PostgreSQL` 为服务端核心,实现运行时 AI 接口、持久化、资产接口、编辑器写盘与开发联调支撑,形成可持续迭代的软件产品工程体系。
### 5.4 项目智能化改造成效1000 字内,建议结构)
建议分 4 段写:
1. `技术重构内容`
- 从静态内容和单轮生成升级为结构化世界生成、角色叙事档案、线程驱动任务和运行时记忆回写。
2. `智能化改造重点`
- 大模型接入、自然语言交互、智能生成剧情、智能生成任务、智能物品叙事、多阶段世界生成。
3. `效益量化评估`
- 内容生产效率提升、生成链路自动化率提升、编辑器与运行时联动效率提升、研发智能辅助能力增强。
4. `生态适配能力和社会经济效益`
- 面向互动叙事、游戏软件、数字内容、IP 衍生、文化科技融合场景具备复制潜力。
### 5.5 项目可推广性1000 字内,建议结构)
建议强调:
1. 可从单款产品复用为 `AI 互动叙事引擎`
2. 可扩展到多题材、多世界观、多角色规模
3. 可服务于游戏、互动内容、数字文化产品
4. 后续具备平台化、SaaS 化或中台化演化空间
## 6. 实施总结报告建议写法
### 6.1 报告结构
按附件 13-5 的模板,建议直接写成以下章节:
1. 企业基本情况介绍
2. 项目建设方案
3. 项目建设情况
4. 相关证明材料
5. 其他需说明事项
### 6.2 可直接落稿的章节框架
#### 一、企业基本情况介绍
建议覆盖:
- 企业基本信息
- 发展阶段
- 核心软件产品
- 近 3 年经营情况或成立以来经营情况
- 当前研发团队与技术方向
#### 二、项目建设方案
`2.1 项目主要内容`
- 从“传统脚本化内容生产和运行时交互效率低、扩展成本高”切入
- 强调项目要解决的核心问题:
- 内容生成成本高
- 叙事扩展难
- 互动体验不连续
- 游戏软件的智能化程度不足
`2.2 项目建设方案`
建议写成 5 个子模块:
1. AI 原生剧情引擎模块
2. 自定义世界生成模块
3. 角色关系与任务智能生成模块
4. 运行时交互与持久化模块
5. 编辑器与资产工具链模块
`2.3 项目关键技术和创新`
建议围绕以下关键词展开:
- 大模型接入与多阶段编排
- 结构化剧情引擎语义层
- AI 叙事与本地规则协同
- 游戏软件运行态智能化
- 自定义世界生成和叙事图谱构建
`2.4 项目预期实现的经济社会效益`
建议从 3 个角度写:
1. 提升游戏软件研发和内容生产效率
2. 形成 AI 原生互动叙事软件产品能力
3. 为数字内容、文化科技融合和互动娱乐软件提供可复制技术路径
#### 三、项目建设情况
`3.1 项目概况`
- 项目建设地点
- 起止时间
- 版本迭代时间点
- 上线 / 投运节点
`3.2 项目建设内容完成情况`
建议按“计划模块 -> 已完成内容 -> 当前状态”写。
`3.3 项目投资完成情况`
- 技术设备费
- 材料费
- 研发人员费用
- 资金到位与使用情况
- 专项审计情况
`3.4 项目绩效完成情况`
必须逐项回应:
1. 代码生成占比 / 采纳率
2. 国产主流大模型接入
3. 日均 Token 量
4. 软件智能化成熟度等级
5. 行业应用效果或 DAU
`3.5 项目其他实施效果`
- 工程效率提升
- 研发流程优化
- 内容产能提升
- 交互质量提升
- 可扩展性提升
## 7. 证据材料采集清单
### 7.1 基础证明
- 营业执照
- 立项决议 / 项目启动文件
- 项目竣工 / 版本上线证明
- 项目建设周期证明
### 7.2 投资与审计证明
- 设备清单
- 投资支出明细表
- 发票
- 付款凭证
- 记账凭证
- 银行流水
- 采购合同
- 专项审计报告
- 研发人员清单、工资、社保材料
### 7.3 技术与知识产权证明
- 软著
- 发明专利
- 知识产权与申报产品对应关系说明
- 架构图
- 关键模块说明
### 7.4 智能化绩效证明
- 第三方辅助编码平台导出的代码生成占比 / 采纳率
- 项目周期内 1 个月辅助编码平台日志
- 大模型接入截图
- 网信办备案截图
- 日均 Token 量后台证明
- 第三方智能化成熟度测评报告
### 7.5 应用效果证明
二选一准备:
1. `B 端路径`
- 5 个不同客户销售合同
- 上线或验收证明
- 非关联关系证明
2. `C 端路径`
- 后台 DAU 日志
- 统计口径说明
- 关键时间段截图
## 8. 当前项目的红黄绿核验表
| 核验项 | 当前判断 | 说明 |
| --- | --- | --- |
| 北京市信息软件企业主体 | `待核验` | 仓库无法证明,需企业基础材料确认 |
| 项目总投资 >= 500 万 | `待核验` | 需按附件 13-3 口径做专项审计测算 |
| 项目竣工并投运 | `待核验` | 需形成明确版本竣工和运行证明 |
| 建设周期 <= 2 年 | `大概率可满足` | 需立项与竣工时间文件确认 |
| 2 项软著或 1 项发明专利 | `待核验` | 当前仓库未见证书信息 |
| 国产主流备案模型接入 | `待核验` | 需提供模型名称、备案号和接入截图 |
| 日均 Token >= 500 万 | `高风险` | 需要真实运行数据支撑 |
| 智能化成熟度测评 | `待补` | 需第三方机构报告 |
| 5 客户或 5 万 DAU | `高风险` | 需选定 B 端或 C 端路径并集中准备 |
## 9. 推荐的实际推进动作
1. 立刻建立 `方向 13 数据底表`
- 建设周期
- 投资金额
- 研发人员
- 模型调用量
- 客户 / DAU
- 软著 / 专利
2. 先做一版 `项目实施总结报告` Word 初稿
- 文字先成型
- 数字和证明后补
3. 同步预约或筛选 `智能化成熟度测评机构`
4. 立刻判断应用效果走哪条路径
- `C 端 DAU`
- `B 端 5 个客户`
5. 如果当前达不到 `5 万 DAU`且已经有引擎化输出可能建议考虑把申报产品口径从“单款游戏”适度提升为“AI 原生剧情引擎及其游戏软件应用”,以增强 B 端案例组织空间。

View File

@@ -0,0 +1,349 @@
# 方向 21 “创赢未来”成长计划材料整理2026-04-14
## 1. 方向判断
### 1.1 与当前项目的匹配点
当前项目与方向 21 的匹配度较高,原因如下:
1. 方向 21 明确支持 `未来信息` 领域。
2. 当前项目更适合归入 `未来信息 -> 通用人工智能`
3. 项目同时具备 `沉浸式互动内容``世界生成``角色智能体化``多轮交互式叙事` 等特征,可作为 `元宇宙` 叙事补充语义,但不建议作为首选归类。
4. 从仓库状态看,项目已具备完整产品原型、明确技术路线和多阶段演进文档,适合做早期高潜企业 / 团队的路演材料。
### 1.2 推荐申报口径
建议主口径:
- `未来信息 -> 通用人工智能`
备选口径:
- `未来信息 -> 元宇宙`
推荐理由:
- 当前项目的核心竞争力在于 AI 原生剧情引擎、结构化世界生成与运行时智能交互,而不是纯 3D 场景或虚拟空间平台。
### 1.3 时间节点
附件 21 明确写明:
- `2026 年 6 月` 路演对应申报截止时间:`2026 年 5 月 15 日`
## 2. 官方材料要求
### 2.1 必交材料
1. “创赢未来”成长计划报名表
2. 承诺书
3. 商业计划书
4. 产品或技术演示材料
5. 其他补充材料
### 2.2 与当前项目的适配判断
| 材料 | 主要看点 | 当前项目优势 | 待补信息 |
| --- | --- | --- | --- |
| 报名表 | 团队、技术、行业归类、融资规划 | 技术方向清晰,产品结构完整 | 企业基本信息、财务、股权、融资、估值 |
| 承诺书 | 基础合规 | 只需走公章和法人签字流程 | 公司主体信息 |
| 商业计划书 | 行业空间、技术壁垒、商业化路径、融资用途 | 文档体系完整,路线图和产品能力明确 | 市场数据、营收、客户、融资规划 |
| 产品/技术演示 | 路演说服力 | 项目演示性很强,适合做 Demo | 需组织脚本、录屏和讲解 |
| 补充材料 | 荣誉、知识产权、合作 | 可补架构、PRD、测试、软著等 | 荣誉、融资、客户证明 |
## 3. 报名表逐项整理建议
### 3.1 一、申报主体基本情况
#### 推荐准备字段
- 申报主体名称
- 统一社会信用代码
- 注册资本
- 注册地址 / 通讯地址
- 企业性质
- 法定代表人
- 申报负责人 / 联系人
- 申报主体人数
- 研发人员人数与占比
#### 企业或创新团队简介建议口径
建议首稿:
> 团队围绕 AI 原生互动叙事软件开展研发,核心方向是构建可支撑跨题材互动内容生成、角色关系演化、运行时剧情推进和自定义世界创建的 AI 原生剧情引擎。当前已完成以视觉 RPG 为主要产品形态的原型验证,形成了前后端一体的软件产品框架,并围绕剧情引擎结构化、世界生成、任务编排、角色关系和运行时状态持久化建立了较完整的技术路线和产品文档体系。
#### 核心团队信息建议准备
每位核心成员建议统一整理以下字段:
- 姓名
- 角色定位
- 负责模块
- 学历 / 过往经历
- 擅长方向
- 与项目的适配性
- 奖项 / 荣誉 / 代表成果
### 3.2 二、核心技术与产品 / 服务
#### 核心技术基础
建议围绕以下 5 点展开:
1. 大模型驱动的剧情生成与世界生成
2. 结构化剧情引擎语义层
3. AI 叙事与本地规则协同架构
4. 自定义世界分阶段生成技术
5. 运行时智能交互与持久化技术
#### 技术 / 产品定位(不超过 300 字)
建议首稿:
> 项目聚焦 AI 原生互动叙事软件,面向游戏、数字内容与沉浸式互动场景,提供从世界构建、角色关系、任务生成到运行时剧情推进的一体化智能引擎能力。产品核心解决传统互动内容生产成本高、扩展慢、剧情可玩性不足的问题,支持在统一规则约束下实现多轮对话、动态剧情、角色状态演化与自定义世界生成。
#### 差异化优势与壁垒(不超过 300 字)
建议首稿:
> 项目的差异化不在于简单接入大模型而在于构建了“AI 负责叙事表达、本地规则负责状态裁决”的分层架构,并将大模型能力进一步沉淀为结构化剧情引擎语义层。相比纯聊天式互动产品,本项目在世界生成、角色关系、任务推进、内容持久化和运行时控制方面具备更强的可控性、可扩展性和软件产品化能力。
#### 知识产权情况
当前建议填写方式:
- 已授权专利:`[待补]`
- 发明专利:`[待补]`
- 软件著作权:`[待补]`
- 其他知识产权:`[待补]`
#### 技术 / 产品成熟度
如果当前还未形成稳定商业化收入,建议优先选择:
- `中试 / 测试`
如果已经存在真实付费客户或稳定运营数据,再考虑:
- `已完成商业化`
#### 商业化进展
建议按真实情况二选一:
1. 如果已有收入
- 填历史收入、当前客户和代表性案例
2. 如果尚在测试阶段
- 建议如实填写:
- 已完成核心原型验证
- 已形成可演示产品
- 正在推进测试验证 / 合作接洽 / 商业化探索
### 3.3 三、核心产业领域
建议勾选:
- `未来信息 -> 通用人工智能`
如需辅助说明,可在备注中补一句:
> 项目兼具沉浸式互动内容和数字世界生成特征,但核心技术驱动仍以通用人工智能能力为主。
### 3.4 四、融资历史与资本结构
需要准备:
- 历史融资时间、轮次、金额、投资方、估值
- 当前股权结构
- 当前估值
- 现有资金储备与可持续运营时间
如果暂无外部融资,建议如实写:
- 历史融资:暂无
- 当前股权结构:创始团队持股 `100%` 或按真实结构填写
- 当前估值:按拟融资口径测算
### 3.5 五、历史财务信息
如果已注册公司,需要补齐:
- `2024`
- `2025`
- 最近一期
指标包括:
- 营业收入
- 利润总额
- 净利润
- 研发投入
- 总资产
- 净资产
- 经营活动现金流净额
### 3.6 六、融资规划与发展战略
#### 未来 12-18 个月资金用途建议分类
建议按以下 5 类填:
1. 产品研发与迭代
2. 核心技术团队扩建
3. 市场推广与业务拓展
4. 基础设施与模型调用投入
5. 补充运营流动资金
#### 关键发展里程碑建议
建议先按以下 5 条拟定:
1. 完成 AI 原生剧情引擎核心能力升级,并形成可对外展示的稳定版本
2. 完成自定义世界生成与角色叙事系统的产品化闭环
3. 完成首批种子用户测试与关键使用数据沉淀
4. 完成核心知识产权申请 / 获取
5. 完成下一轮融资所需的数据验证、客户验证或平台验证
#### 是否接受“拨改投”
建议先与公司决策层确认再填。
如果当前阶段确实需要政府快投和后续基金跟投支持,通常建议倾向:
- `是`
但这属于有实际融资后果的选择,必须由公司确认。
### 3.7 七、财务预测与要素需求
需要形成 `2026-2028` 三年预测:
- 营业收入
- 净利润
- 研发投入
- 团队规模
- 累计知识产权数量
建议同步准备:
- 增长驱动因素
- 风险与应对
- 希望获得的非资金支持
## 4. 商业计划书建议结构
附件 21-3 已给出 8 个章节,建议直接写成 15-18 页 BP
1. 封面
2. 一句话定义项目
3. 行业痛点与机会
4. 产品形态与演示截图
5. 核心技术与架构
6. 差异化与壁垒
7. 当前进展与验证情况
8. 商业化路径
9. 市场空间
10. 团队介绍
11. 融资历史与当前结构
12. 本轮融资需求与用途
13. 未来 12-18 个月里程碑
14. 三年财务预测
15. 政府支持与生态协同需求
## 5. BP 各章节可直接复用的项目内容
### 5.1 公司 / 团队基本情况
可复用来源:
- `README.md`
- `docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md`
- `docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md`
### 5.2 技术研发情况
重点引用:
- `docs/prd/AI_NATIVE_CROSS_GENRE_STORY_ENGINE_PRD_2026-04-06.md`
- `docs/prd/AI_NATIVE_CLASSIC_RPG_EXPERIENCE_BENCHMARK_PRD_2026-04-06.md`
- `docs/prd/AI_NATIVE_NARRATIVE_THREAD_ITEM_AND_WORLD_NPC_PRD_2026-04-06.md`
- `docs/prd/AI_NATIVE_STORY_ENGINE_PHASE1_IMPLEMENTATION_PLAN_2026-04-06.md`
- `docs/prd/AI_NATIVE_STORY_ENGINE_PHASE2_IMPLEMENTATION_PLAN_2026-04-06.md`
### 5.3 产品 / 服务
可突出以下模块:
1. AI 原生剧情引擎
2. 自定义世界生成
3. 角色关系与对话系统
4. 任务生成与推进
5. 运行时智能交互
6. 编辑器与内容工作台
### 5.4 行业及市场
虽然仓库里没有市场数据,但可以先定义分析框架:
1. 游戏与互动内容的 AI 生产工具市场
2. 互动叙事软件市场
3. 数字文化和沉浸式内容市场
4. 可扩展到教育、文旅、IP 互动内容的潜在空间
这一部分需要外部市场数据支持,不能只靠仓库文档。
### 5.5 发展战略与商业规划
建议路线:
1. 先做自有标杆产品验证引擎能力
2. 再沉淀可复用的剧情引擎与世界生成能力
3. 再探索平台化 / 工具化 / B 端合作 / 内容生态输出
## 6. 产品 / 技术演示材料建议
### 6.1 推荐演示结构
建议控制在 `5-8 分钟`
1. 项目定位
2. 进入世界或创建自定义世界
3. 展示 AI 剧情推进
4. 展示 NPC 对话 / 关系 / 任务生成
5. 展示运行时状态与本地规则约束
6. 展示编辑器或后台架构,证明不是单点 Demo
### 6.2 建议准备的演示附件
- 1 个主演示视频
- 1 份产品截图包
- 1 页系统架构图
- 1 页技术路线图
- 1 页未来里程碑图
## 7. 当前项目的材料缺口
| 类别 | 当前状态 | 说明 |
| --- | --- | --- |
| 企业基本信息 | `待补` | 需公司或团队真实资料 |
| 财务数据 | `待补` | 报名表和 BP 都需要 |
| 股权结构与融资历史 | `待补` | 仓库无此信息 |
| 市场验证与客户信息 | `待补` | 需真实商业化数据 |
| 知识产权 | `待核验` | 建议同步整理软著 / 专利 |
| Demo 材料 | `可快速形成` | 项目演示性较强 |
| 技术路线与产品逻辑 | `优势明显` | 仓库文档充足,可直接转写 |
## 8. 推荐的实际推进动作
1. 先把报名表做成一版可填写底稿。
2. 同时做一版 `15 页` 左右的 BP 目录和每页一句话。
3. 与创始团队确认:
- 轮次
- 估值
- 拟融资金额
- 资金用途
- 是否接受拨改投
4. 录制第一版产品演示视频。
5. 把未来 12-18 个月里程碑先量化成数字。

View File

@@ -0,0 +1,184 @@
# 方向 24 北京市中小企业服务券材料整理2026-04-14
## 1. 先说判断
方向 24 不能直接按“和 13、21 一样的项目申报”理解。
它至少有两条完全不同的路径:
1. `路径 A服务机构申报配券产品`
2. `路径 B中小微企业领券用券`
对当前项目来说,必须先明确你想走哪条路径。
## 2. 当前项目与方向 24 的适配结论
### 2.1 如果你想走“服务机构申报配券产品”
当前项目不建议直接以现有形态冲这一路径,原因如下:
1. 当前项目核心是 `AI 原生视觉 RPG / 互动叙事产品`,不是天然面向北京市中小微企业销售的标准化企业服务产品。
2. 附件 24 要求服务机构上年度与北京市中小企业签约合同不少于 `15 份`,营业收入不低于 `800 万元`
3. 还要求产品有明确服务内容、收费标准、知识产权、销售体系和售后服务。
4. 当前批次配券产品征集时间截至 `2026 年 3 月 9 日`,本轮窗口已经结束。
结论:
- `不建议把当前轮 24 作为主申报方向。`
### 2.2 如果你想走“企业领券用券”
这条路径是可行的,但它不是重材料申报,而是:
1. 企业身份认证
2. 查找上线服务产品
3. 下单领券
4. 线下签约
5. 服务交付与留痕
这条路径更适合作为:
-`方向 13` 配套采购第三方大模型、数据治理、智能研发工具、数智转型服务时的降本手段
## 3. 路径 A服务机构申报配券产品
## 3.1 官方硬门槛
服务机构需满足:
1. 依法注册,有固定经营场所,成立 `1 年(含)以上`
2. 拥有开展专业服务所需设备、许可、认证、资质、资格
3.`3 年` 经营、环保、纳税、诚信等方面无严重失信记录
4. 上年度与北京市中小企业签订合同量不少于 `15 份`
5. 营业收入不低于 `800 万元`
6. 产品具有完整知识产权、销售体系及售后服务
7. 产品需有明确服务内容和收费标准
## 3.2 申报材料清单
### 3.2.1 服务机构申请材料
1. 服务机构详细介绍(不少于 `2000` 字)
2. 营业执照
3. 运营所在地证明文件
4. `2025` 年度财务审计报告 / 专审报告 / 财务报表
5. 至少 `15` 份北京市中小企业合同扫描件或等效证明
6. 申请方向相关专业资质证明
7. 其他优势特色证明材料
### 3.2.2 配券服务产品申请材料
每个产品单独成一个文件夹,需提交:
1. 配券产品申请表
2. 产品价格证明
- 报价明细表
-`12` 个月 `5 套` 合同 + 发票 + 转账凭证
3. 知识产权或销售 / 售后体系证明
4. 近两年服务北京地区专精特新、小巨人、上市高成长企业名单(如无可不提供)
## 3.3 当前项目如果一定要做 24需要怎么改口径
如果未来还想走路径 A建议不要再以“游戏项目”名义申报而是重构成面向中小企业的标准化服务产品例如
1. `AIGC 互动内容生成与叙事工作台`
2. `AI 角色对话与剧情内容生产系统`
3. `中小内容团队智能叙事创作平台`
更适合申报的类别:
- `方向 1 大模型应用`
-`方向 2 数智转型系统`
但即便如此,仍需补齐:
1. 北京地区 `15` 份中小企业合同
2. `800 万元` 营收
3. 标准化产品定价体系
4. 售后和交付体系
5. 如果按大模型产品报,还需网信办备案信息
## 3.4 当前路径 A 的建议结论
建议标记为:
- `本轮不主攻`
- `仅保留备查清单`
## 4. 路径 B企业领券用券
## 4.1 这条路径需要做什么
1. 在北京市统一身份认证平台完成企业认证
2. 登录北京通企服版 APP
3. 在服务券专区查找适合的产品
4. 领券并下单
5. 与服务机构线下签约
6. 履约、付款并保留全套材料
## 4.2 对当前项目最有价值的用券方向
如果你们作为企业用券,最值得关注的通常是:
1. `大模型应用`
- 模型部署
- 模型调用
- 大模型精调
- 数据治理
2. `数智转型系统`
- 智能研发工具
- AI 辅助编程系统
- 研发设计 / 经营管理 / 软件系统集成服务
## 4.3 用券侧建议留存的材料
虽然企业侧不一定需要像服务机构一样重申报,但仍建议留痕:
1. 企业认证截图
2. 下单截图
3. 订单编号与下单时间
4. 服务合同
5. 发票
6. 付款凭证
7. 服务交付说明
8. 服务完成确认截图
这些材料后续也可能反过来支持 `方向 13` 的投资和智能化建设证明。
## 5. 当前项目的实际建议
### 5.1 不建议做的事
1. 不建议把当前 AI 游戏项目直接包装成 24 配券产品就立刻申报。
2. 不建议在本轮窗口已过的情况下继续花大量时间准备服务机构征集材料。
### 5.2 建议做的事
1. 如果你们是 `用券企业`,优先研究可采购的 AI 服务产品。
2. 如果你们未来想做 `配券服务机构`,把这份文档当成下一轮准备清单。
3. 现阶段把主要精力放在 `方向 13``方向 21`
## 6. 备查清单
### 6.1 路径 A未来如要做服务机构征集
- 服务机构介绍
- 营业执照
- 场地证明
- 审计报告 / 财务报表
- `15` 份北京中小企业合同
- 资质证明
- 产品报价体系
- `5` 套合同 + 发票 + 转账凭证
- 知识产权 / 销售 / 售后体系
- 高成长客户清单
### 6.2 路径 B当前如要用券
- 企业认证
- 下单记录
- 合同
- 发票
- 付款记录
- 服务交付留痕

View File

@@ -0,0 +1,233 @@
# 北京市方向 13 / 21 / 24 申报总览2026-04-14
## 1. 文档目的
这份文档用于把当前项目与以下 3 个方向的申报要求对齐,并给出统一的材料准备框架:
- 方向 `13`:软件智能化提升奖励
- 方向 `21`:“创赢未来”成长计划
- 方向 `24`:北京市中小企业服务券
本文档基于以下官方附件整理:
- `C:\Users\windows\Downloads\W020260225601546780336.docx`
- `C:\Users\windows\Downloads\W020260225601548312243.doc`
- `C:\Users\windows\Downloads\W020260225601549271520.docx`
## 2. 先说结论
### 2.1 当前项目与三个方向的匹配判断
1. `方向 13` 是当前最值得主攻的申报方向。
- 当前项目本质上是“AI 原生剧情引擎 + 本地规则裁决 + 游戏软件产品”的智能化软件项目,且附件 13 明确包含 `游戏软件`
- 但这一方向材料最重、硬门槛最多,必须优先核验 `总投资 >= 500 万元``项目已竣工投运``知识产权数量``国产模型接入``Token 量``5 个客户案例或 5 万 DAU``智能化成熟度测评` 等要求。
2. `方向 21` 与当前项目方向高度匹配,建议并行准备。
- 当前项目适合归入 `未来信息 -> 通用人工智能``元宇宙` 可作为备选描述维度,不建议作为首选。
- 这一方向更看重技术潜力、团队能力、融资规划和未来产业前景,比较适合当前这种“技术路线清晰、产品原型已成型、后续仍处于快速演化阶段”的项目。
- 附件 21 明确写明 `2026 年 6 月` 路演对应申报截止时间为 `2026 年 5 月 15 日`
3. `方向 24` 需要先区分申报身份,再决定是否投入材料准备。
- 如果你想申报的是 `服务机构配券产品征集`,那当前项目并不天然匹配,因为现有仓库核心是 AI 游戏产品,不是面向北京市中小微企业销售的标准化企业服务产品。
- 如果你想用的是 `中小企业服务券`,那不是一套重申报材料,而是企业认证、下单、签约、付款、留痕流程。
- 附件 24 写明本轮 `配券产品征集` 截止至 `2026 年 3 月 9 日`,按当前日期 `2026 年 4 月 14 日`,这一轮服务机构征集窗口已经结束。
### 2.2 建议的投入顺序
1. 先完成 `方向 13` 的硬条件核验和主材料框架。
2. 并行完成 `方向 21` 的报名表底稿、BP 结构和演示材料提纲。
3.`方向 24` 只做判断和备查,不建议当前把主要时间投入到“配券产品征集”上。
## 3. 当前项目可复用的共用事实
以下内容可以作为 `13``21` 的共用项目底稿基础:
### 3.1 项目定位
- 项目名称:`AI Native Visual RPG`
- 核心定位:以 `AI 叙事 + 本地规则 + 像素演出` 为核心的 AI 原生视觉 RPG 原型
- 产品形态:前端表现层 + `Express` 后端 + `PostgreSQL` 持久化 + AI 接口编排
- 核心能力:
- 世界与角色选择
- AI 剧情推进与流式对话
- 战斗演出、NPC 战斗、切磋
- NPC 交易、送礼、求助、招募
- 宝藏交互
- 同伴跟随与战斗
- 预设编辑器 / NPC 视觉编辑器 / 行为编辑器
- 自动存档与继续游戏
### 3.2 技术架构
- 前端:`React 19` + `TypeScript` + `Vite`
- 后端:`Express` + `TypeScript`
- 数据层:`PostgreSQL`
- AI 能力接入:
- 流式剧情生成
- 自定义世界多阶段生成
- NPC 对话 / 招募 /关系总结
- 任务生成
- 角色视觉与动画资产生成接口
- 工程门禁:
- `npm run lint`
- `npm run check:encoding`
- `npm run check:content`
- `npm run build`
- `vitest`
### 3.3 当前可支撑的创新点
1. `AI 负责叙事表达,本地规则负责状态裁决`
- 把剧情生成与关键规则分离,降低纯模型驱动的不稳定性。
2. `AI 原生剧情引擎`
- 当前文档已经形成 `themePack -> storyGraph -> narrativeProfile -> knowledgeFacts / threadContracts` 的结构化设计。
3. `跨题材自定义世界生成`
- 支持从世界锚点出发生成世界框架、角色、地点、叙事图谱与运行时编译结构。
4. `游戏软件的智能化改造方向明确`
- 不是简单在游戏里接聊天,而是围绕剧情推进、任务、角色关系、物品叙事、世界生成做系统级 AI 注入。
5. `前后端职责边界清晰`
- 遵循“前端只做表现,逻辑和数据放后端”的工程约束,便于形成可持续演进的软件产品能力。
## 4. 仓库内建议重点引用的项目依据
- `README.md`
- `docs/prd/AI_NATIVE_CROSS_GENRE_STORY_ENGINE_PRD_2026-04-06.md`
- `docs/prd/AI_NATIVE_CLASSIC_RPG_EXPERIENCE_BENCHMARK_PRD_2026-04-06.md`
- `docs/prd/AI_NATIVE_NARRATIVE_THREAD_ITEM_AND_WORLD_NPC_PRD_2026-04-06.md`
- `docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_FLOW_OPTIMIZATION_PRD_2026-04-06.md`
- `docs/prd/AI_NATIVE_STORY_ENGINE_PHASE1_IMPLEMENTATION_PLAN_2026-04-06.md`
- `docs/prd/AI_NATIVE_STORY_ENGINE_PHASE2_IMPLEMENTATION_PLAN_2026-04-06.md`
- `docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md`
- `docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md`
- `docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md`
## 5. 三个方向的共用材料包
以下材料建议先做成一个统一总包,再按方向拆分:
### 5.1 企业基础件
- 营业执照扫描件
- 法定代表人信息
- 联系人信息
- 公司简介
- 统一社会信用代码
- 注册地址 / 办公地址 / 经营场所证明
- 企业近 2 年到 3 年财务数据
- 2025 年审计报告 / 专审报告 / 财务报表
### 5.2 项目基础件
- 项目命名口径
- 项目建设周期
- 项目立项文件 / 决策文件
- 项目结项 / 版本竣工 / 上线证明
- 项目建设地点
- 项目实施团队名单
- 项目总投资和构成明细
### 5.3 技术与知识产权件
- 软件著作权证书
- 发明专利证书或申请进展材料
- 产品版本说明
- 系统架构图
- 关键技术说明
- 模型接入说明
- 技术创新点说明
### 5.4 市场与应用件
- 客户合同 / 订单 / 上线验收证明
- 用户数据后台截图
- 日活 / 调用量 / Token 日志
- 合作伙伴名单
- 试点案例
- 演示视频 / 产品截图
### 5.5 融资与团队件
- 核心团队简历
- 股权结构
- 历史融资情况
- 当前估值与资金储备
- 未来 12-18 个月资金需求与用途
- 未来 3 年财务预测
## 6. 建议的材料目录结构
建议在仓库外或公司申报盘中采用以下目录:
```text
申报材料/
├─ 00_共用基础材料/
│ ├─ 00_营业执照与资质/
│ ├─ 01_财务与审计/
│ ├─ 02_团队与融资/
│ ├─ 03_知识产权/
│ ├─ 04_项目立项与结项/
│ ├─ 05_产品截图与演示/
│ └─ 06_客户与应用证明/
├─ 13_软件智能化提升奖励/
│ ├─ 01_申报表/
│ ├─ 02_实施总结报告/
│ ├─ 03_绩效与测评/
│ ├─ 04_投资明细与专项审计/
│ └─ 05_补充证明/
├─ 21_创赢未来成长计划/
│ ├─ 01_报名表/
│ ├─ 02_承诺书/
│ ├─ 03_商业计划书/
│ ├─ 04_产品技术演示/
│ └─ 05_补充证明/
└─ 24_中小企业服务券/
├─ A_配券产品征集_如后续开放/
└─ B_企业用券留痕/
```
## 7. 当前最需要立即核验的硬条件
### 7.1 方向 13
- 是否已经形成一个可定义为“已竣工并投入运行”的软件版本
- 是否能确认项目建设开始时间和竣工时间,且周期不超过 2 年
- 是否有 `2 项软著``1 项发明专利`
- 是否能拿到 `>= 500 万元` 的专项审计口径总投资
- 是否能提供 `第三方辅助编码平台` 的代码生成占比 / 采纳率证明
- 是否已接入 `已备案的国产主流大模型`
- 是否有 `日均 Token >= 500 万`
- 是否能拿到 `智能化成熟度测评报告`
- 是否能满足 `5 个不同客户案例``5 万 DAU`
### 7.2 方向 21
- 当前申报主体是已注册公司还是创新团队
- 是否已有历史融资、估值和股权结构
- 是否已有最小商业化验证
- 未来 12-18 个月的融资金额和用途是否可量化
- 未来 3 年收入、利润、研发投入预测是否能由财务或管理层确认
### 7.3 方向 24
- 如果按 `服务机构配券产品征集` 走,是否满足:
- 上年度与北京市中小企业签约合同不少于 `15 份`
- 营业收入不低于 `800 万元`
- 存在标准化、可售卖、可售后、可定价的企业服务产品
- 如果不满足,是否转为 `企业用券` 路径,而不是继续准备配券申报材料
## 8. 推荐的本周推进顺序
1. 先把 `方向 13` 的硬条件做一次红黄绿核验。
2. 同步准备 `方向 21` 的报名表底稿和 BP 第一版。
3. 只保留 `方向 24` 的判断文档与备查清单,不建议本周作为主线。
4. 所有数字类信息统一建一个 `数据底表`,避免三个方向口径不一致。
## 9. 关联文档
- [BEIJING_DIRECTION13_APPLICATION_MATERIALS_2026-04-14.md](./BEIJING_DIRECTION13_APPLICATION_MATERIALS_2026-04-14.md)
- [BEIJING_DIRECTION21_APPLICATION_MATERIALS_2026-04-14.md](./BEIJING_DIRECTION21_APPLICATION_MATERIALS_2026-04-14.md)
- [BEIJING_DIRECTION24_APPLICATION_MATERIALS_2026-04-14.md](./BEIJING_DIRECTION24_APPLICATION_MATERIALS_2026-04-14.md)

View File

@@ -5,6 +5,10 @@
- [CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md](./CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md):当前阶段最值得优先做什么、为什么,以及它和审计/PRD 的对应关系。
- [EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md](./EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md):基于“前端只做表现、逻辑与数据全部后端化”的工程重构规划。
- [EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md](./EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md):将后端化重构拆成可并行推进、尽量不冲突的任务流与协作顺序。
- [BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md](./BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md):北京市方向 13 / 21 / 24 的统一判断、共用材料框架和准备顺序。
- [BEIJING_DIRECTION13_APPLICATION_MATERIALS_2026-04-14.md](./BEIJING_DIRECTION13_APPLICATION_MATERIALS_2026-04-14.md):方向 13 软件智能化提升奖励的硬门槛、必交材料、底稿建议和证据清单。
- [BEIJING_DIRECTION21_APPLICATION_MATERIALS_2026-04-14.md](./BEIJING_DIRECTION21_APPLICATION_MATERIALS_2026-04-14.md):方向 21 “创赢未来”成长计划的报名表、BP、Demo 和融资规划整理。
- [BEIJING_DIRECTION24_APPLICATION_MATERIALS_2026-04-14.md](./BEIJING_DIRECTION24_APPLICATION_MATERIALS_2026-04-14.md):方向 24 服务机构配券产品与企业用券两条路径的判断和材料备查。
## 使用建议

View File

@@ -0,0 +1,766 @@
# AI 原生 Agent-First 自定义世界创作工具第二阶段技术落地方案
更新时间:`2026-04-13`
## 0. 文档目的
这份文档用于把以下两份文档进一步收束成第二阶段实现方案:
- [AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md](./AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md)
- [AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md](./AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md)
如果说第一阶段的目标是:
**先把创作页面和 Agent 工作区的外壳搭起来**
那么第二阶段的目标就是:
**让 Agent 会话真正开始理解创作者输入,并把自然语言聊天沉淀成结构化创作锚点。**
一句话定义:
**第二阶段先把“收集最小锚点、追问缺口、更新创作意图、同步草稿摘要”这条主链打通,而不是先急着生成完整世界。**
---
## 1. 阶段衔接关系
## 1.1 第一阶段已经完成什么
第二阶段默认建立在第一阶段已经完成的能力之上:
1. 从世界选择页可以进入创作页面
2. 创作页面可以展示草稿和已发布作品
3. 新建作品可以创建 Agent session
4. 可以进入 Agent 工作区
5. 用户发送消息后,服务端会写入 user / assistant 消息
6. session snapshot 和 operation 可以持久化与恢复
## 1.2 第二阶段不再重做什么
以下内容第二阶段不重做:
1. 不重做创作页面整体布局
2. 不重做 session 基础持久化
3. 不重做 operation 轮询主链
4. 不重做 workspace 基础壳层
第二阶段只在第一阶段骨架上继续补:
1. 意图提取
2. 最小锚点判断
3. 澄清问题生成
4. `creatorIntent` 持续更新
5. 创作页面草稿摘要变得更像“作品”
---
## 2. 第二阶段在八阶段中的位置
八阶段拆分如下:
1. 阶段 1创作页面入口、Agent 会话主链与工作区骨架
2. 阶段 2最小锚点收集与澄清流程
3. 阶段 3世界底稿生成与草稿卡编译
4. 阶段 4草稿设定编辑与 AI 新增角色/场景生成
5. 阶段 5角色主图与动作资产工坊接入
6. 阶段 6场景背景图工坊接入
7. 阶段 7长尾内容扩展与自动补齐
8. 阶段 8发布、世界库接入与继续创作恢复
本文件只覆盖:
**阶段 2最小锚点收集与澄清流程**
---
## 3. 第二阶段目标
第二阶段只做 6 件必须一起成立的事:
1. 把用户自然语言输入持续抽取为结构化 `creatorIntent`
2. 明确最小锚点是否齐备
3. 当锚点不足时,生成 `1~3` 个高杠杆澄清问题
4. 在 Agent 工作区中展示“已收集锚点摘要”和“待补充问题”
5.`creatorIntent` 的变化同步反映到创作页面草稿卡片摘要里
6. 当最小锚点齐备时,把 session 阶段推进到 `foundation_review`
一句话目标:
**让第二阶段结束时Agent 不再只是会回话,而是真正开始把“聊天内容”沉淀成“世界锚点”。**
---
## 4. 第二阶段完成定义
第二阶段完成后,必须同时满足以下结果:
1. 用户连续输入多轮自然语言后,`creatorIntent` 会被持续更新。
2. `creatorIntent` 中至少这些字段会真实变化:
- `worldHook`
- `themeKeywords`
- `toneDirectives`
- `playerPremise`
- `openingSituation`
- `coreConflicts`
- `keyCharacters`
- `iconicElements`
- `forbiddenDirectives`
3. 当最小锚点不完整时workspace 右侧会显示待澄清问题。
4. Agent 的回复不再只做“复述”,而会围绕缺失锚点主动追问。
5. 当最小锚点齐备时session 会进入 `foundation_review`,并明确告知“已可以进入下一阶段生成世界底稿”。
6. 创作页面里的草稿作品标题和摘要会随着 `creatorIntent` 更新而变得更准确,不再只是空壳草稿。
7. 第二阶段仍然不要求真正生成世界底稿,也不要求编出角色卡和地点卡。
---
## 5. 范围控制
## 5.1 第二阶段纳入范围
纳入范围的模块:
- `packages/shared/src/contracts/customWorldAgent.ts`
- `src/services/customWorldCreatorIntent.ts`
- `src/services/aiService.ts`
- `src/components/custom-world-home/CustomWorldCreationHub.tsx`
- `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx`
- `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx`
- `server-node/src/services/customWorldWorkSummaryService.ts`
- `server-node/src/services/customWorldAgentSessionStore.ts`
- `server-node/src/services/customWorldAgentOrchestrator.ts`
新增前端模块:
- `src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.tsx`
- `src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx`
新增服务端模块:
- `server-node/src/services/customWorldAgentIntentExtractionService.ts`
- `server-node/src/services/customWorldAgentClarificationService.ts`
## 5.2 第二阶段明确不做
以下内容不放进第二阶段:
1. 不生成世界底稿
2. 不生成 draftCards
3. 不进入角色、地点、势力、章节的实体卡编译
4. 不做锁定逻辑
5. 不做角色资产工坊
6. 不做场景图工坊
7. 不做长尾扩展
8. 不做发布
原因:
**第二阶段的唯一重点,是把 Agent 会话从“能聊”推进到“能收集创作锚点”。**
---
## 6. 第二阶段最小闭环
建议把第二阶段的最小闭环定义为:
```text
创作页面新建作品
-> 进入 Agent 工作区
-> 用户输入世界想法
-> 服务端提取 creatorIntent patch
-> 更新 creatorIntent / anchorPack / pendingClarifications
-> Agent 回复补问或确认
-> 前端展示已收集锚点摘要
-> 用户继续补充
-> 最小锚点齐备
-> session 进入 foundation_review
-> 创作页面草稿摘要同步更新
```
这个闭环里,先只强接两条高价值链路:
1. 对话 -> creatorIntent
2. creatorIntent -> 草稿摘要
---
## 7. 第二阶段产品行为定义
## 7.1 最小锚点定义
第二阶段必须明确判定“最小锚点是否齐备”。
建议统一收束成以下 6 组:
1. 世界一句话与核心幻想
- 对应:`worldHook`
2. 玩家身份与开局困境
- 对应:`playerPremise + openingSituation`
3. 主题气质与禁忌边界
- 对应:`themeKeywords + toneDirectives + forbiddenDirectives`
4. 核心冲突
- 对应:`coreConflicts`
5. 关键关系钩子
- 对应:`keyCharacters`
- 最低要求:至少有 1 个关键人物种子,且带 `relationToPlayer``hiddenHook`
6. 标志性要素
- 对应:`iconicElements`
## 7.2 最小锚点齐备规则
建议使用 deterministic 规则判断:
```ts
type CreatorIntentReadiness = {
isReady: boolean;
completedKeys: string[];
missingKeys: string[];
};
```
### 判定要求
#### `world_hook`
满足任一条件即视为完成:
1. `worldHook.trim().length >= 8`
2. `rawSettingText.trim().length >= 24` 且可提取稳定世界命题
#### `player_premise`
必须:
1. `playerPremise` 非空
2. `openingSituation` 非空
#### `theme_and_tone`
满足:
1. `themeKeywords.length >= 1`
2. `toneDirectives.length >= 1`
`forbiddenDirectives` 可选,但一旦用户明确提到禁忌,必须写入
#### `core_conflict`
满足:
1. `coreConflicts.length >= 1`
#### `relationship_seed`
满足:
1. `keyCharacters.length >= 1`
2. 至少一个条目同时满足:
- `name` 非空
-`relationToPlayer``hiddenHook` 非空
#### `iconic_element`
满足:
1. `iconicElements.length >= 1`
## 7.3 缺口澄清规则
`isReady === false`Agent 必须追问,但必须遵守:
1. 一次最多追问 `3` 个问题
2. 问题必须优先覆盖最高杠杆缺口
3. 问题不能像问卷
4. 每个问题最好给一个方向提示
### 优先级顺序
1. `world_hook`
2. `player_premise`
3. `core_conflict`
4. `theme_and_tone`
5. `relationship_seed`
6. `iconic_element`
## 7.4 阶段推进规则
### 初始
- `collecting_intent`
### 缺口存在
- `clarifying`
### 最小锚点齐备
- `foundation_review`
### 说明
第二阶段进入 `foundation_review` 后,不代表已经生成底稿;
只代表:
**已经具备进入第三阶段生成世界底稿的输入条件。**
## 7.5 Agent 回复行为
第二阶段起assistant 回复必须升级成 3 段结构:
1. 已确认内容
2. 仍缺内容
3. 下一步提问或建议
### 示例结构
```text
我先把目前已经明确的部分收一下:
- ...
现在还缺两块最关键的信息:
- ...
- ...
你可以先告诉我:
1. ...
2. ...
```
禁止:
1. 只说“收到”
2. 一次问超过 3 个问题
3. 明知缺世界核心还继续追问长尾细节
---
## 8. 数据结构落地方案
## 8.1 修改 `CustomWorldCreatorIntent`
继续复用 `src/services/customWorldCreatorIntent.ts` 的现有结构,不新增第二套意图对象。
第二阶段要求:
1. 所有字段都支持增量 patch
2. patch 合并必须是“补充 + 覆盖用户明确改写”
3. 不允许每次新消息都重置整个 intent
## 8.2 新增 `CreatorIntentReadiness`
建议新增到:
- `packages/shared/src/contracts/customWorldAgent.ts`
```ts
export interface CreatorIntentReadiness {
isReady: boolean;
completedKeys: string[];
missingKeys: string[];
}
```
## 8.3 扩展 `pendingClarifications`
当前第一阶段里的澄清结构太轻,第二阶段要扩成:
```ts
export interface CustomWorldPendingClarification {
id: string;
label: string;
question: string;
targetKey:
| 'world_hook'
| 'player_premise'
| 'theme_and_tone'
| 'core_conflict'
| 'relationship_seed'
| 'iconic_element';
priority: number;
answer?: string;
}
```
## 8.4 扩展 `CustomWorldAgentSessionSnapshot`
第二阶段必须新增:
```ts
creatorIntentReadiness: CreatorIntentReadiness;
```
并把:
```ts
pendingClarifications: CustomWorldPendingClarification[];
```
替换掉第一阶段的轻量问题数组。
## 8.5 扩展 `CustomWorldWorkSummary`
为了让创作页面草稿卡更像一个“作品”,第二阶段必须要求草稿摘要从 `creatorIntent` 实时编译。
规则:
### 标题
按优先级取:
1. `creatorIntent.worldHook`
2. `rawSettingText` 截断
3. `未命名草稿`
### 摘要
按优先级取:
1. `buildCustomWorldCreatorIntentDisplayText(intent)`
2. `rawSettingText` 截断
3. 默认空态文案
### 阶段文案
按 session.stage 直接映射。
---
## 9. 服务端实现方案
## 9.1 新增 `customWorldAgentIntentExtractionService.ts`
### 文件
`server-node/src/services/customWorldAgentIntentExtractionService.ts`
### 职责
输入:
1. 当前 `creatorIntent`
2. 最新用户消息
3. 最近若干轮对话摘要
输出:
```ts
type ExtractedCreatorIntentPatch = {
worldHook?: string;
themeKeywords?: string[];
toneDirectives?: string[];
playerPremise?: string;
openingSituation?: string;
coreConflicts?: string[];
keyCharacters?: CreatorCharacterSeed[];
iconicElements?: string[];
forbiddenDirectives?: string[];
};
```
### 推荐实现策略
第一优先:
1. 使用 deterministic 规则提取明显字段
第二优先:
2. 用一个轻量 LLM contract 只提取 patch不生成世界内容
### 第一阶段之外、第二阶段之内的硬要求
这一步的 LLM 输出必须只做“结构提取”,不能夹带世界扩写。
## 9.2 新增 `customWorldAgentClarificationService.ts`
### 文件
`server-node/src/services/customWorldAgentClarificationService.ts`
### 职责
1. 根据 `creatorIntent` 计算 readiness
2. 生成 `pendingClarifications`
3. 按优先级裁剪到 `1~3` 个问题
### 导出函数建议
```ts
evaluateCreatorIntentReadiness(intent)
buildPendingClarifications(intent, readiness)
```
## 9.3 修改 `customWorldAgentOrchestrator.ts`
第二阶段它要从“写消息 + 回固定话术”升级为:
1. 写入 user message
2. 提取 intent patch
3. 合并 intent
4. 更新 anchorPack
5. 计算 readiness
6. 生成 clarifications
7. 生成更结构化的 assistant 回复
8. 更新 snapshot
### 关键顺序
必须严格按下面顺序:
```text
收到用户消息
-> 提取 intent patch
-> merge creatorIntent
-> build anchorPack
-> evaluate readiness
-> build pendingClarifications
-> compose assistant reply
-> 写回 snapshot
```
### 第一阶段兼容要求
如果提取失败:
1. 不允许清空原有 intent
2. assistant 仍要给出可继续的澄清回复
## 9.4 修改 `customWorldWorkSummaryService.ts`
第二阶段它必须从 session 中读取:
1. `creatorIntent`
2. `creatorIntentReadiness`
3. `stage`
并生成更准确的:
1. 草稿标题
2. 草稿摘要
3. 当前阶段标签
---
## 10. 前端实现方案
## 10.1 修改 `CustomWorldAgentWorkspace.tsx`
第二阶段它不再只是空壳工作区,而要新增:
1. 意图摘要区
2. 澄清问题区
### 右侧结构建议
1. `CustomWorldAgentIntentSummaryPanel`
2. `CustomWorldAgentClarificationPanel`
3. `CustomWorldAgentLockBar`
4. `CustomWorldAgentDraftDrawer`
5. `CustomWorldAgentQuickActions`
说明:
第二阶段虽然还没有真正 `draftCards`,但壳层继续保留。
## 10.2 新增 `CustomWorldAgentIntentSummaryPanel.tsx`
### 职责
展示当前已收集的最小锚点摘要。
### 展示项
1. 世界一句话
2. 玩家身份
3. 开局处境
4. 核心冲突
5. 主题气质
6. 标志性要素
### 空态
未收集到时显示:
- `还在收集你的世界锚点`
## 10.3 新增 `CustomWorldAgentClarificationPanel.tsx`
### 职责
展示当前 `pendingClarifications`
### 展示规则
1. 每个问题显示 label + question
2. 最多展示 3 个
3.`isReady === true`,显示:
- `最小锚点已齐备,可以进入下一阶段`
## 10.4 修改 `CustomWorldCreationHub.tsx`
第二阶段必须让草稿卡开始体现“已收集锚点”的变化。
要求:
1. 草稿卡标题和摘要来自统一 `CustomWorldWorkSummary`
2. 列表刷新后可以看到标题变化
3. 草稿从“未命名草稿”变成更接近创作主题的标题
## 10.5 修改 `CustomWorldAgentQuickActions.tsx`
第二阶段只保留轻动作:
1. `总结当前设定`
2. `继续补充锚点`
不允许展示:
1. `生成世界底稿`
2. `发布世界`
因为这属于后续阶段。
---
## 11. 交互时序
## 11.1 用户补充锚点
```text
用户发消息
-> 前端 POST /messages
-> 后端提取 creatorIntent patch
-> 更新 creatorIntent
-> 更新 readiness 和 pendingClarifications
-> 写入 assistant 回复
-> 前端刷新 snapshot
-> 前端刷新意图摘要和澄清问题
```
## 11.2 锚点齐备
```text
用户最后一轮补齐关键锚点
-> 后端 evaluate readiness = true
-> session.stage 切到 foundation_review
-> assistant 回复“已可进入下一阶段”
-> 前端显示完成态
-> 创作页面草稿摘要同步更新
```
---
## 12. 第一阶段到第二阶段的兼容要求
第二阶段必须兼容第一阶段已有数据。
## 12.1 旧 session 兼容
如果存在第一阶段创建的 session没有
1. `creatorIntentReadiness`
2. 新版 `pendingClarifications`
则读取时要自动补 fallback。
## 12.2 旧草稿兼容
如果草稿还没有明确 `worldHook`
1. 继续显示 `未命名草稿`
2. 不允许报错
---
## 13. 落地文件清单
## 13.1 shared
必须修改:
1. `packages/shared/src/contracts/customWorldAgent.ts`
## 13.2 frontend
必须新增:
1. `src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.tsx`
2. `src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx`
必须修改:
1. `src/components/custom-world-home/CustomWorldCreationHub.tsx`
2. `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx`
3. `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx`
4. `src/services/aiService.ts`
5. `src/services/customWorldCreatorIntent.ts`
## 13.3 backend
必须新增:
1. `server-node/src/services/customWorldAgentIntentExtractionService.ts`
2. `server-node/src/services/customWorldAgentClarificationService.ts`
必须修改:
1. `server-node/src/services/customWorldAgentOrchestrator.ts`
2. `server-node/src/services/customWorldWorkSummaryService.ts`
3. `server-node/src/services/customWorldAgentSessionStore.ts`
---
## 14. 测试要求
## 14.1 服务端测试
至少要补:
1. 用户消息能提取到 `creatorIntent` patch
2. patch 合并不会覆盖无关旧字段
3. readiness 能正确判断缺失项
4. clarifications 最多只返回 3 个
5. readiness 达标后 stage 会切到 `foundation_review`
## 14.2 前端测试
至少要补:
1. intent summary panel 能展示已收集锚点
2. clarification panel 能展示待补充问题
3. readiness 达标后显示完成态
4. 创作页面草稿卡能随着摘要变化更新
## 14.3 手工回归
至少走这 4 条:
1. 用户输入一段简单世界想法 -> 被正确提取为 worldHook
2. 用户补充“玩家是谁” -> summary 更新
3. 用户补充核心冲突 -> clarification 继续减少
4. 锚点齐备 -> session 进入 foundation_review
---
## 15. 第二阶段验收标准
做到以下几点,才算第二阶段真正完成:
1. Agent 会话已经可以持续收集并更新 `creatorIntent`
2. 最小锚点不足时,系统会追问真正缺失的高杠杆问题。
3. 最小锚点齐备时session 会进入 `foundation_review`
4. 创作页面中的草稿摘要会明显变得更像一个作品,而不是空壳 session。
5. 第二阶段仍然不生成世界底稿,不越权进入第三阶段。
---
## 16. 一句话结论
第二阶段最重要的不是“让 Agent 写得更长”,而是:
**先让它学会把用户说过的话,稳定地变成创作锚点。**

View File

@@ -0,0 +1,981 @@
# AI 原生 Agent-First 自定义世界创作工具第三阶段技术落地方案
更新时间:`2026-04-14`
## 0. 文档目的
这份文档用于把以下几份文档进一步收束成第三阶段实现方案:
- [AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md](./AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md)
- [AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md](./AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md)
- [AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md](./AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md)
如果说第一阶段的目标是:
**先把创作页面和 Agent 工作区的壳层搭起来**
第二阶段的目标是:
**先把自然语言对话稳定沉淀成结构化创作锚点**
那么第三阶段的目标就是:
**把已经齐备的最小锚点,正式编译成第一版可浏览、可讨论、可继续精修的世界底稿。**
一句话定义:
**第三阶段先让 Agent 不只是“知道你想做什么世界”,而是能真的给出一版像样的世界草稿。**
---
## 1. 阶段衔接关系
## 1.1 第一阶段已经完成什么
第三阶段默认建立在第一阶段已经完成的能力之上:
1. 世界选择页点击“创建自定义世界”后,可以先进入创作页面
2. 创作页面可以展示草稿与已发布作品
3. 新建作品可以创建 Agent session
4. Agent workspace 可进入、可恢复、可轮询 operation
5. 基础 `customWorldAgent` contract 已建立
## 1.2 第二阶段已经完成什么
第三阶段默认建立在第二阶段已经完成的能力之上:
1. 用户自然语言输入可以持续更新 `creatorIntent`
2. 系统可以判断最小锚点是否齐备
3. 缺口存在时会生成澄清问题
4. 锚点齐备时session 会进入 `foundation_review`
5. 创作页面草稿摘要能反映 `creatorIntent`
## 1.3 第三阶段不再重做什么
以下内容第三阶段不重做:
1. 不重做创作页面整体结构
2. 不重做 session 基础持久化
3. 不重做 creatorIntent 提取和 readiness 判断主链
4. 不重做 operation 轮询
第三阶段只在前两阶段骨架上继续补:
1. `draft_foundation` 动作真正可执行
2. 世界底稿生成
3. 草稿卡编译
4. 卡片详情读取
5. 工作区右侧从“空抽屉”升级成“有世界内容”
6. 创作页面草稿卡从“意图摘要”升级为“世界草稿摘要”
---
## 2. 第三阶段在八阶段中的位置
八阶段拆分如下:
1. 阶段 1创作页面入口、Agent 会话主链与工作区骨架
2. 阶段 2最小锚点收集与澄清流程
3. 阶段 3世界底稿生成与草稿卡编译
4. 阶段 4草稿设定编辑与 AI 新增角色/场景生成
5. 阶段 5角色主图与动作资产工坊接入
6. 阶段 6场景背景图工坊接入
7. 阶段 7长尾内容扩展与自动补齐
8. 阶段 8发布、世界库接入与继续创作恢复
本文件只覆盖:
**阶段 3世界底稿生成与草稿卡编译**
---
## 3. 第三阶段目标
第三阶段只做 7 件必须一起成立的事:
1.`creatorIntentReadiness.isReady === true` 时,`draft_foundation` 可以真正执行
2. 系统能根据 `creatorIntent + anchorPack` 生成首轮世界底稿
3. 系统能把首轮世界底稿编译成一组 `draftCards`
4. Agent workspace 能展示并切换这些卡片
5. `GET /cards/:cardId` 可以返回卡片详情
6. Agent 会话会从“收集锚点”切换到“校对世界底稿”
7. 创作页面草稿作品卡能体现“这个草稿已经不只是想法,而是有底稿内容”
一句话目标:
**让第三阶段结束时,用户第一次看到“这个世界已经长出来了”。**
---
## 4. 第三阶段完成定义
第三阶段完成后,必须同时满足以下结果:
1. 用户在 `foundation_review` 阶段点击“整理一版世界底稿”后,会产生真实的底稿生成 operation。
2. operation 完成后session 中会出现:
- 更新后的 `draftProfile`
- 一组非空 `draftCards`
3. `draftCards` 至少覆盖:
- 世界总卡
- 势力卡
- 关键角色卡
- 关键地点卡
- 线程卡
- 主线第一幕卡
4. 用户可以在 workspace 中点击卡片,查看卡片详情。
5. `suggestedActions` 会从“继续补锚点”转为“精修这个角色 / 继续补地点 / 进入下一步”等更像编辑阶段的动作。
6. 创作页面中对应的草稿作品卡,会显示更明确的标题、摘要、阶段标签与对象数量。
7. 第三阶段仍然不要求草稿设定编辑、AI 新增角色/场景生成,也不要求资产工坊接入。
---
## 5. 范围控制
## 5.1 第三阶段纳入范围
纳入范围的模块:
- `packages/shared/src/contracts/customWorldAgent.ts`
- `src/services/customWorld.ts`
- `src/services/customWorldBuilder.ts`
- `src/services/aiService.ts`
- `src/components/custom-world-home/CustomWorldCreationHub.tsx`
- `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx`
- `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx`
- `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx`
- `server-node/src/services/customWorldAgentSessionStore.ts`
- `server-node/src/services/customWorldAgentOrchestrator.ts`
- `server-node/src/services/customWorldWorkSummaryService.ts`
新增前端模块:
- `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx`
- `src/components/custom-world-agent/CustomWorldDraftCardDetailModal.tsx`
新增服务端模块:
- `server-node/src/services/customWorldAgentFoundationDraftService.ts`
- `server-node/src/services/customWorldAgentDraftCompiler.ts`
## 5.2 第三阶段明确不做
以下内容不放进第三阶段:
1. 不做锁定逻辑
2. 不做局部重生成
3. 不做角色主图与动作资产工坊接入
4. 不做场景图工坊接入
5. 不做长尾内容扩展
6. 不做发布
原因:
**第三阶段的唯一重点,是先把“会话里已经收集好的锚点”编成“第一版世界底稿”。**
---
## 6. 第三阶段最小闭环
建议把第三阶段的最小闭环定义为:
```text
第二阶段已收集完最小锚点
-> session 进入 foundation_review
-> 用户点击“整理一版世界底稿”
-> 服务端生成首轮 foundation draft
-> 服务端编译 draftCards
-> workspace 刷新右侧卡片抽屉
-> 用户查看世界卡 / 势力卡 / 角色卡 / 地点卡 / 第一幕卡
-> 创作页面草稿摘要同步升级
```
这个闭环里,先只强接两条高价值链路:
1. `creatorIntent -> foundation draft`
2. `foundation draft -> draftCards`
---
## 7. 第三阶段产品行为定义
## 7.1 进入条件
第三阶段的唯一正式进入条件是:
```ts
session.stage === 'foundation_review'
&& creatorIntentReadiness.isReady === true
```
只有满足这两个条件时,才允许执行:
```ts
draft_foundation
```
## 7.2 首轮世界底稿的最小内容
第三阶段不要求一次生成全世界,但必须生成“像一个作品”的第一版基础结构。
建议最小包含:
1. 世界总卡 `world`
2. 势力卡 `faction`
- `2~4`
3. 关键角色卡 `character`
- `3~5`
4. 关键地点卡 `landmark`
- `4~6`
5. 世界线程卡 `thread`
- `3~5`
6. 主线第一幕卡 `chapter`
- `1`
7. 营地卡 `camp`
- `1` 张,可选但建议有
### 说明
这里的“关键角色 / 地点 / 势力 / 线程”不是后续完整长尾版,而是:
**第一批高价值草稿对象。**
## 7.3 世界底稿生成原则
第三阶段必须遵守:
1. 只围绕 `creatorIntent + anchorPack` 生成
2. 不直接扩到长尾 NPC 与长尾地点
3. 不引入用户从未表达过的核心主题反转
4. 尽量把已有锚点长出来,而不是另起炉灶
### 世界总卡必须回答
1. 这个世界一句话是什么
2. 玩家是谁
3. 眼下最大的冲突是什么
4. 这个世界最吸引人的独特点是什么
### 势力卡必须回答
1. 势力是谁
2. 公开目标是什么
3. 与哪条冲突直接相关
4. 它和玩家、关键角色的关系是什么
### 角色卡必须回答
1. 这个角色表面上是什么
2. 当前压力是什么
3. 他和玩家什么关系
4. 他属于哪条线程
### 地点卡必须回答
1. 这个地方是什么
2. 为什么重要
3. 和谁、哪条线程有关
4. 玩家第一次到这里会感到什么
### 线程卡必须回答
1. 明线还是暗线
2. 这条线的主要冲突是什么
3. 牵涉哪些角色和地点
### 主线第一幕卡必须回答
1. 开幕事件
2. 玩家第一目标
3. 第一批关键角色
4. 第一批关键地点
5. 第一幕结束时玩家会理解到什么
---
## 8. 草稿卡设计
## 8.1 卡片种类
第三阶段实际生成的 `draftCards` 限定为:
```ts
type EnabledPhase3DraftCardKind =
| 'world'
| 'camp'
| 'faction'
| 'character'
| 'landmark'
| 'thread'
| 'chapter';
```
明确不做:
1. `scene_chapter`
2. `carrier`
3. `sidequest_seed`
这些留到后续阶段。
## 8.2 草稿卡状态
第三阶段卡片状态只允许:
1. `suggested`
2. `warning`
第三阶段不做:
1. `confirmed`
2. `locked`
原因:
锁定属于第四阶段。
## 8.3 草稿卡摘要要求
每张卡都必须包含:
1. `title`
2. `subtitle`
3. `summary`
4. `linkedIds`
5. `warningCount`
### world 卡
#### `title`
- 世界名称或世界一句话
#### `subtitle`
- 玩家视角 + 核心冲突短句
#### `summary`
- 世界总摘要
### faction 卡
#### `title`
- 势力名
#### `subtitle`
- 公开目标
#### `summary`
- 势力位置、冲突和代表关系
### character 卡
#### `title`
- 角色名
#### `subtitle`
- 外显身份
#### `summary`
- 当前压力 + 与玩家关系 + 线程位置
### landmark 卡
#### `title`
- 地点名
#### `subtitle`
- 功能定位或情绪定位
#### `summary`
- 地点重要性 + 关联角色 / 线程
### thread 卡
#### `title`
- 线程标题
#### `subtitle`
- 明线 / 暗线
#### `summary`
- 主要冲突和相关对象
### chapter 卡
#### `title`
- 主线第一幕标题
#### `subtitle`
- 开幕目标
#### `summary`
- 第一幕承诺、角色、地点与理解变化
## 8.4 卡片详情要求
第三阶段卡片详情必须支持读取,但不要求直接编辑。
卡片详情统一结构:
```ts
interface CustomWorldDraftCardDetail {
id: string;
kind: CustomWorldDraftCardKind;
title: string;
sections: Array<{
id: string;
label: string;
value: string;
}>;
linkedIds: string[];
locked: false;
warningMessages: string[];
}
```
### 详情 section 最少要求
#### world
1. 世界一句话
2. 玩家是谁
3. 核心冲突
4. 世界气质
#### faction
1. 势力定位
2. 公开目标
3. 冲突关系
#### character
1. 外显身份
2. 当前压力
3. 关系钩子
4. 关联线程
#### landmark
1. 地点定位
2. 场景情绪
3. 关联角色
4. 关联线程
#### thread
1. 线程类型
2. 冲突内容
3. 相关对象
#### chapter
1. 开幕事件
2. 玩家目标
3. 第一批角色
4. 第一批地点
5. 第一幕理解变化
---
## 9. 数据结构落地方案
## 9.1 扩展 `CustomWorldDraftCardSummary`
第三阶段必须把 `draftCards` 从空数组变成稳定可渲染的数据。
如果需要补字段,优先只补:
```ts
kind
title
subtitle
summary
status
linkedIds
warningCount
```
不额外加复杂 UI 字段。
## 9.2 扩展 `draftProfile`
第三阶段的 `draftProfile` 必须第一次真正有内容。
建议结构仍然复用现有 `CustomWorldProfile` 方向,但允许为“草稿版”:
```ts
type CustomWorldFoundationDraftProfile = {
name: string;
subtitle: string;
summary: string;
tone: string;
playerGoal: string;
majorFactions: string[];
coreConflicts: string[];
playableNpcs: CustomWorldPlayableNpc[];
storyNpcs: CustomWorldNpc[];
landmarks: CustomWorldLandmark[];
camp?: CustomWorldCampScene | null;
themePack?: ThemePack | null;
storyGraph?: WorldStoryGraph | null;
};
```
### 第三阶段限制
1. `items` 允许为空
2. `knowledgeFacts / threadContracts` 允许为空
3. `playableNpcs / storyNpcs / landmarks` 只要求第一批关键对象,不要求长尾完整
## 9.3 扩展 `CustomWorldWorkSummary`
第三阶段创作页面卡片应进一步升级:
### 草稿卡显示
1. `playableNpcCount`
2. `landmarkCount`
3. `stageLabel`
4. `summary`
必须来自新的 foundation draft而不只是 intent。
## 9.4 新增 foundation draft operation detail
建议新增:
```ts
interface CustomWorldFoundationDraftResult {
draftProfile: CustomWorldFoundationDraftProfile;
draftCards: CustomWorldDraftCardSummary[];
}
```
供 orchestrator 内部使用,不一定要单独暴露给前端接口。
---
## 10. 服务端实现方案
## 10.1 新增 `customWorldAgentFoundationDraftService.ts`
### 文件
`server-node/src/services/customWorldAgentFoundationDraftService.ts`
### 职责
输入:
1. `creatorIntent`
2. `anchorPack`
输出:
```ts
CustomWorldFoundationDraftResult
```
### 实现原则
优先复用已有能力:
1. `src/services/customWorld.ts`
2. `src/services/customWorldBuilder.ts`
3. 当前自定义世界生成链中已有的 framework / themePack / storyGraph 方向
但第三阶段不能直接照搬旧全量生成链。
### 第三阶段推荐生成顺序
```text
creatorIntent
-> anchorPack
-> foundation framework
-> themePack
-> storyGraph(mini)
-> key character seeds
-> key landmark seeds
-> first-act chapter draft
-> foundation draft profile
```
### 第三阶段数量要求
#### 势力
- 目标:`2~4`
#### 关键角色
- 目标:`3~5`
#### 关键地点
- 目标:`4~6`
#### 线程
- 目标:`3~5`
#### 主线第一幕
- 固定 `1` 张 chapter 卡
## 10.2 新增 `customWorldAgentDraftCompiler.ts`
### 文件
`server-node/src/services/customWorldAgentDraftCompiler.ts`
### 职责
负责把 foundation draft profile 编译成:
1. `draftCards`
2. `card detail`
3. 前端可直接消费的摘要
### 第一优先输出
1. `compileDraftCards(profile)`
2. `getDraftCardDetail(profile, cardId)`
### 规则
1. 前端不自己把 profile 拼成卡片
2. 全部卡片摘要与详情都由后端编译
## 10.3 修改 `customWorldAgentOrchestrator.ts`
第三阶段要把:
```ts
draft_foundation
```
从占位动作变成真实动作。
### 新职责
1. 校验当前 session 是否处于 `foundation_review`
2. 调用 `customWorldAgentFoundationDraftService`
3. 调用 `customWorldAgentDraftCompiler`
4. 更新 `draftProfile`
5. 更新 `draftCards`
6. 更新 `suggestedActions`
7. 写入 assistant summary message
8. 写入 checkpoint
### assistant 回复要求
生成底稿后assistant 回复必须至少包含:
1. 已经整理出的世界总纲
2. 当前第一批关键角色 / 地点 / 势力数量
3. 推荐用户先看哪一张卡
### session 阶段更新
第三阶段完成后session 默认进入:
```ts
object_refining
```
原因:
底稿生成后,用户就进入“精修对象”的前置阶段。
## 10.4 修改 `customWorldWorkSummaryService.ts`
第三阶段它必须优先读取:
1. `draftProfile.name`
2. `draftProfile.summary`
3. `draftProfile.landmarks.length`
4. `draftProfile.playableNpcs.length`
用于创作页面作品卡展示。
如果 `draftProfile` 为空,才回退到第二阶段基于 `creatorIntent` 的摘要。
---
## 11. 前端实现方案
## 11.1 修改 `CustomWorldAgentQuickActions.tsx`
第三阶段必须让:
```ts
draft_foundation
```
成为真实可点击动作。
### 显示条件
仅当:
1. `session.stage === 'foundation_review'`
2. `creatorIntentReadiness.isReady === true`
时显示:
- `整理一版世界底稿`
## 11.2 修改 `CustomWorldAgentDraftDrawer.tsx`
第三阶段起,它不再只是空抽屉。
### 展示要求
1. 按 kind 分组展示
2. world 卡固定置顶
3. chapter 卡单独展示
4. 其余卡片按 kind 分组
### 分组顺序建议
1. `world`
2. `chapter`
3. `thread`
4. `faction`
5. `character`
6. `landmark`
7. `camp`
## 11.3 新增 `CustomWorldAgentDraftDetailPanel.tsx`
### 职责
在第三阶段中detail panel 是第一次真正有内容的右侧详情区。
### props
```ts
{
detail: CustomWorldDraftCardDetail | null;
loading: boolean;
onClose: () => void;
}
```
### 第一版显示
1. 标题
2. kind 标签
3. sections 列表
4. linkedIds 数量提示
5. warningMessages
### 第一版明确不做
1. 不在 detail panel 里直接编辑
2. 不在这里加锁
## 11.4 修改 `CustomWorldAgentWorkspace.tsx`
第三阶段它必须支持:
1. 点击某张 draft card
2. 请求 card detail
3. 在右侧打开 detail panel
### 状态新增
```ts
activeCardId?: string | null;
activeCardDetail?: CustomWorldDraftCardDetail | null;
isCardDetailLoading: boolean;
```
## 11.5 修改 `CustomWorldCreationHub.tsx`
第三阶段创作页面草稿卡要更像一个作品。
要求:
1. 标题优先读 `draftProfile.name`
2. 摘要优先读 `draftProfile.summary`
3. 卡片上显示:
- 可扮演角色数量
- 地点数量
- 当前阶段标签
---
## 12. 接口与交互时序
## 12.1 生成底稿时序
```text
用户点击“整理一版世界底稿”
-> 前端 POST /actions { action: draft_foundation }
-> 服务端创建 operation
-> 服务端生成 foundation draft
-> 服务端编译 draftCards
-> 服务端更新 snapshot
-> 服务端写入 assistant summary
-> operation completed
-> 前端轮询结束
-> 前端刷新 snapshot
```
## 12.2 查看卡片详情时序
```text
用户点击某张草稿卡
-> 前端 GET /cards/:cardId
-> 服务端编译 detail
-> 前端展示 detail panel
```
## 12.3 创作页面同步时序
```text
底稿生成完成
-> 用户返回创作页面
-> 前端 GET /custom-world/works
-> 服务端返回更新后的 work summary
-> 草稿卡显示新的标题、摘要和数量
```
---
## 13. 与第二阶段的兼容要求
## 13.1 只对 ready session 开放
若:
```ts
creatorIntentReadiness.isReady === false
```
则:
- `draft_foundation` 不允许执行
- 前端不显示该快捷动作
## 13.2 兼容旧 session
如果 session 是第二阶段前创建的,且:
1. 没有 `draftProfile`
2. 没有 `draftCards`
则:
- 允许读到空数组和空对象
- 不允许报错
---
## 14. 落地文件清单
## 14.1 shared
必须修改:
1. `packages/shared/src/contracts/customWorldAgent.ts`
## 14.2 frontend
必须新增:
1. `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx`
2. `src/components/custom-world-agent/CustomWorldDraftCardDetailModal.tsx`
必须修改:
1. `src/components/custom-world-home/CustomWorldCreationHub.tsx`
2. `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx`
3. `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx`
4. `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx`
5. `src/services/aiService.ts`
## 14.3 backend
必须新增:
1. `server-node/src/services/customWorldAgentFoundationDraftService.ts`
2. `server-node/src/services/customWorldAgentDraftCompiler.ts`
必须修改:
1. `server-node/src/services/customWorldAgentOrchestrator.ts`
2. `server-node/src/services/customWorldAgentSessionStore.ts`
3. `server-node/src/services/customWorldWorkSummaryService.ts`
4. `server-node/src/routes/customWorldAgent.ts`
---
## 15. 测试要求
## 15.1 服务端测试
至少要补:
1. ready session 能成功执行 `draft_foundation`
2. not-ready session 会拒绝执行 `draft_foundation`
3. foundation draft 生成后 `draftProfile` 非空
4. foundation draft 生成后 `draftCards` 非空
5. `GET /cards/:cardId` 能返回正确详情
## 15.2 前端测试
至少要补:
1. foundation_review 阶段能显示“整理一版世界底稿”
2. draftCards 生效后 drawer 可以正常展示
3. 点击卡片后 detail panel 能正常展示
4. 返回创作页面后草稿卡摘要会更新
## 15.3 手工回归
至少走这 4 条:
1. 用第二阶段已就绪的 session 生成底稿
2. 查看 world / faction / character / landmark / chapter 卡
3. 刷新页面后草稿卡仍在
4. 返回创作页面后草稿摘要明显变化
---
## 16. 第三阶段验收标准
做到以下几点,才算第三阶段真正完成:
1. `draft_foundation` 已经从占位动作变成真实动作。
2. 用户第一次可以在工作区里看到一组真实世界草稿卡。
3. 草稿卡覆盖世界、势力、角色、地点、线程和第一幕。
4. 草稿卡详情可以查看。
5. 创作页面中的草稿卡第一次看起来像一个“世界作品”,而不只是对话 session。
6. 第三阶段仍然不越界去做锁定、资产工坊或发布逻辑。
---
## 17. 一句话结论
第三阶段最重要的不是“继续问更多问题”,而是:
**先把已经收集到的锚点,变成一版真能看的世界底稿。**

View File

@@ -0,0 +1,943 @@
# AI 原生 Agent-First 自定义世界创作工具第四阶段技术落地方案
更新时间:`2026-04-14`
## 0. 文档目的
这份文档用于把以下几份文档进一步收束成第四阶段实现方案:
- [AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md](./AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md)
- [AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md](./AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md)
- [AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md](./AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md)
- [AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md](./AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md)
如果说第三阶段的目标是:
**把已经收集好的创作锚点编译成第一版世界底稿**
那么第四阶段的目标就是:
**让创作者直接修改这版草稿设定,并且继续用 AI 为这版草稿扩出新的角色和场景。**
一句话定义:
**第四阶段把“世界已经长出来”升级成“世界开始可编辑、可继续长新内容”。**
---
## 1. 阶段衔接关系
## 1.1 第三阶段已经完成什么
第四阶段默认建立在第三阶段已经完成的能力之上:
1. session 已进入 `object_refining`
2. `draftProfile` 已经非空
3. `draftCards` 已经非空
4. 工作区可以展示世界、势力、角色、地点、线程和第一幕卡片
5. 用户可以查看卡片详情
6. 创作页面草稿卡已经能显示更像作品的标题、摘要与对象数量
## 1.2 第四阶段不再重做什么
以下内容第四阶段不重做:
1. 不重做最小锚点收集
2. 不重做 foundation draft 生成主链
3. 不重做基础 draftCards 编译
4. 不重做创作页面和工作区壳层
第四阶段只继续补:
1. 草稿设定编辑
2. 编辑后的草稿对象写回
3. 新增角色的 AI 生成
4. 新增场景的 AI 生成
5. 新卡片插入与摘要重编译
6. assistant 变更总结与 checkpoint
---
## 2. 第四阶段在八阶段中的位置
八阶段拆分如下:
1. 阶段 1创作页面入口、Agent 会话主链与工作区骨架
2. 阶段 2最小锚点收集与澄清流程
3. 阶段 3世界底稿生成与草稿卡编译
4. 阶段 4草稿设定编辑与 AI 新增角色/场景生成
5. 阶段 5角色主图与动作资产工坊接入
6. 阶段 6场景背景图工坊接入
7. 阶段 7长尾内容扩展与自动补齐
8. 阶段 8发布、世界库接入与继续创作恢复
本文件只覆盖:
**阶段 4草稿设定编辑与 AI 新增角色/场景生成**
---
## 3. 第四阶段目标
第四阶段只做 7 件必须一起成立的事:
1. 用户可以直接修改 draft card 中的可编辑设定字段
2. 编辑后的内容会稳定写回 `draftProfile`
3. 写回后 `draftCards` 摘要会同步更新
4. 用户可以让 AI 新增角色
5. 用户可以让 AI 新增场景
6. 新生成的角色和场景会插入 `draftProfile` 并出现为新的 `draftCards`
7. 每次编辑或新增后,系统都要写入 assistant 变更摘要和 checkpoint
一句话目标:
**让第四阶段结束时,创作者第一次能像在真正做作品一样修改草稿、继续长出新对象。**
---
## 4. 第四阶段完成定义
第四阶段完成后,必须同时满足以下结果:
1. 用户可以在 world / faction / character / landmark / thread / chapter 卡详情中进入编辑模式。
2. 用户保存编辑后,`draftProfile` 会真实变化,而不是只改前端显示。
3. 保存后,对应 `draftCards` 的标题、副标题、摘要会更新。
4. 用户可以通过 AI 生成 `1~3` 个新角色卡。
5. 用户可以通过 AI 生成 `1~3` 个新场景卡。
6. 新角色卡和新场景卡插入后draft drawer 能立即看到新增对象。
7. 创作页面里的草稿作品卡数量统计会同步增加。
8. 第四阶段仍然不要求做视觉资产工坊、长尾扩展和发布。
---
## 5. 范围控制
## 5.1 第四阶段纳入范围
纳入范围的模块:
- `packages/shared/src/contracts/customWorldAgent.ts`
- `src/components/custom-world-home/CustomWorldCreationHub.tsx`
- `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx`
- `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx`
- `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx`
- `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx`
- `src/services/aiService.ts`
- `server-node/src/services/customWorldAgentSessionStore.ts`
- `server-node/src/services/customWorldAgentOrchestrator.ts`
- `server-node/src/services/customWorldAgentDraftCompiler.ts`
- `server-node/src/services/customWorldWorkSummaryService.ts`
新增前端模块:
- `src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx`
- `src/components/custom-world-agent/CustomWorldGenerateEntityModal.tsx`
新增服务端模块:
- `server-node/src/services/customWorldAgentDraftEditService.ts`
- `server-node/src/services/customWorldAgentEntityGenerationService.ts`
- `server-node/src/services/customWorldAgentChangeSummaryService.ts`
## 5.2 第四阶段明确不做
以下内容不放进第四阶段:
1. 不做锁定
2. 不做局部重生成
3. 不做角色主图与动作资产工坊接入
4. 不做场景图工坊接入
5. 不做长尾内容自动补齐
6. 不做发布
原因:
**第四阶段只解决“这版底稿怎么继续被编辑和扩展”的问题。**
---
## 6. 第四阶段最小闭环
建议把第四阶段的最小闭环定义为:
```text
第三阶段已有 foundation draft
-> 用户打开某张角色卡
-> 修改角色压力与关系描述
-> 保存
-> draftProfile 更新
-> 对应 draftCard 摘要更新
-> 用户点击“新增角色”
-> AI 生成 2 个新角色
-> draftProfile.storyNpcs 增加
-> 新角色卡出现在 drawer
-> 创作页面草稿卡数量同步增加
```
这个闭环里,先只强接两条高价值链路:
1. `draftCard -> draftProfile 编辑`
2. `AI 新增对象 -> draftProfile 扩展`
---
## 7. 第四阶段产品行为定义
## 7.1 可编辑的卡片类型
第四阶段允许直接编辑这些卡片:
1. `world`
2. `faction`
3. `character`
4. `landmark`
5. `thread`
6. `chapter`
7. `camp`
## 7.2 编辑模式规则
第四阶段采用:
**卡片详情内编辑**
不采用:
1. 大型全局表单
2. 多卡同时编辑
3. 独立复杂后台编辑器
### 编辑流程
```text
打开卡片详情
-> 点击“编辑设定”
-> 进入编辑模式
-> 修改字段
-> 点击保存
-> 写回 draftProfile
-> 重编译 draftCards
-> 写入 assistant action_result
```
### 取消规则
用户点击取消后:
1. 丢弃本次未保存草稿
2. 回到只读详情模式
## 7.3 各卡片可编辑字段
为了避免实现漂移,第四阶段明确限定每种卡的可编辑字段。
### `world`
允许编辑:
1. `title`
2. `subtitle`
3. `summary`
4. `playerGoal`
5. `tone`
6. `coreConflicts`
### `faction`
允许编辑:
1. `title`
2. `subtitle`
3. `summary`
4. `publicGoal`
5. `tension`
### `character`
允许编辑:
1. `name`
2. `role`
3. `publicMask`
4. `hiddenHook`
5. `relationToPlayer`
6. `summary`
### `landmark`
允许编辑:
1. `name`
2. `purpose`
3. `mood`
4. `secret`
5. `summary`
### `thread`
允许编辑:
1. `title`
2. `summary`
3. `conflictType`
4. `stakes`
### `chapter`
允许编辑:
1. `title`
2. `summary`
3. `openingEvent`
4. `playerGoal`
5. `understandingShift`
### `camp`
允许编辑:
1. `name`
2. `description`
3. `dangerLevel`
## 7.4 第四阶段不允许编辑的内容
为控制范围,以下内容第四阶段不开放:
1. 技能
2. 初始物品
3. 场景连接网络
4. sceneNpcIds
5. 背景章节分段
6. 视觉资产引用
这些内容留给后续阶段。
## 7.5 AI 新增角色
第四阶段要支持:
```text
新增角色
```
### 目标对象
默认新增:
1. `storyNpcs`
说明:
第四阶段不默认新增 `playableNpcs`,避免过早引入玩家入口角色平衡问题。
### 输入
用户可以提供:
1. 一句话要求
2. 角色数量
3. 可选参考卡片
### 数量限制
每次允许:
1. `1~3` 个角色
### 必须生成的字段
每个新角色至少要带:
1. `id`
2. `name`
3. `role`
4. `publicMask`
5. `hiddenHook`
6. `relationToPlayer`
7. `summary`
### 插入规则
生成后:
1. 写入 `draftProfile.storyNpcs`
2. 重新编译 `character` 类 draftCards
3. 在 drawer 中显示
## 7.6 AI 新增场景
第四阶段要支持:
```text
新增场景
```
### 目标对象
默认新增:
1. `landmarks`
### 输入
用户可以提供:
1. 一句话要求
2. 场景数量
3. 可选参考线程 / 角色 / 势力
### 数量限制
每次允许:
1. `1~3` 个场景
### 必须生成的字段
每个新场景至少要带:
1. `id`
2. `name`
3. `description``summary`
4. `dangerLevel`
5. `purpose`
6. `mood`
### 插入规则
生成后:
1. 写入 `draftProfile.landmarks`
2. 重新编译 `landmark` 类 draftCards
3. 在 drawer 中显示
## 7.7 新增对象的归类规则
为了避免新增对象漂移,第四阶段新增对象必须绑定至少一个已有语义锚点:
### 新角色至少要绑定一个:
1. 线程
2. 势力
3. 玩家关系
### 新场景至少要绑定一个:
1. 线程
2. 角色
3. 势力
如果 AI 生成结果无法绑定任何现有锚点,则:
1. 允许生成
2. 但卡片标记为 `warning`
---
## 8. 数据结构落地方案
## 8.1 扩展 `CustomWorldAgentActionRequest`
第四阶段必须正式启用以下 action
```ts
| {
action: 'update_draft_card';
cardId: string;
sections: Array<{
sectionId: string;
value: string;
}>;
}
| {
action: 'generate_characters';
count: number;
promptText?: string | null;
anchorCardIds?: string[];
}
| {
action: 'generate_landmarks';
count: number;
promptText?: string | null;
anchorCardIds?: string[];
}
```
### 第一版限制
1. `count` 只能是 `1~3`
2. `anchorCardIds` 可选
3. `sections` 不能为空
## 8.2 扩展 `CustomWorldAgentOperationType`
第四阶段新增:
```ts
| 'update_draft_card'
| 'generate_characters'
| 'generate_landmarks'
```
## 8.3 扩展 `CustomWorldDraftCardDetail`
第四阶段开始detail 里需要返回:
```ts
editable: boolean;
editableSectionIds: string[];
```
### 规则
1. 第四阶段所有可编辑卡返回 `editable = true`
2. 非可编辑卡返回 `editable = false`
## 8.4 `draftProfile` 写回规则
第四阶段的所有保存动作都必须最终落到 `draftProfile` 对应对象上。
不得:
1. 只改 `draftCards`
2. 只改前端详情临时数据
顺序必须是:
```text
update draftProfile
-> recompile draftCards
-> return snapshot
```
---
## 9. 服务端实现方案
## 9.1 新增 `customWorldAgentDraftEditService.ts`
### 文件
`server-node/src/services/customWorldAgentDraftEditService.ts`
### 职责
1. 校验 card 是否存在
2. 校验 card 是否可编辑
3. 校验 sectionId 是否允许编辑
4. 把 section patch 写回对应对象
5. 返回新的 `draftProfile`
### 导出函数建议
```ts
updateDraftCardSections(params)
```
### 输入
```ts
type UpdateDraftCardSectionsParams = {
draftProfile: Record<string, unknown>;
cardId: string;
sections: Array<{
sectionId: string;
value: string;
}>;
};
```
## 9.2 新增 `customWorldAgentEntityGenerationService.ts`
### 文件
`server-node/src/services/customWorldAgentEntityGenerationService.ts`
### 职责
1. 生成新增角色
2. 生成新增场景
3. 把新增对象插入 `draftProfile`
### 导出函数建议
```ts
generateAdditionalCharacters(params)
generateAdditionalLandmarks(params)
```
### 输入
必须包含:
1. `creatorIntent`
2. `anchorPack`
3. `draftProfile`
4. `count`
5. `promptText`
6. `anchorCardIds`
### 生成原则
1. 只围绕当前已生成的 foundation draft 扩展
2. 不能重做已有对象
3. 不能直接扩成完整长尾
## 9.3 新增 `customWorldAgentChangeSummaryService.ts`
### 职责
每次编辑或新增对象后,生成一段简短变更摘要。
要求至少包含:
1. 改了什么卡 / 新增了什么对象
2. 影响到哪些对象数量
3. 下一步建议
## 9.4 修改 `customWorldAgentOrchestrator.ts`
第四阶段必须启用以下 action
1. `update_draft_card`
2. `generate_characters`
3. `generate_landmarks`
### `update_draft_card` 流程
```text
收到 update_draft_card
-> 校验 cardId
-> 调用 DraftEditService
-> 更新 draftProfile
-> 调用 DraftCompiler
-> 更新 draftCards
-> 写入 assistant action_result
-> 写入 checkpoint
-> operation completed
```
### `generate_characters` 流程
```text
收到 generate_characters
-> 校验 count
-> 调用 EntityGenerationService
-> 更新 draftProfile.storyNpcs
-> 调用 DraftCompiler
-> 更新 draftCards
-> 写入 assistant action_result
-> 写入 checkpoint
-> operation completed
```
### `generate_landmarks` 流程
```text
收到 generate_landmarks
-> 校验 count
-> 调用 EntityGenerationService
-> 更新 draftProfile.landmarks
-> 调用 DraftCompiler
-> 更新 draftCards
-> 写入 assistant action_result
-> 写入 checkpoint
-> operation completed
```
## 9.5 修改 `customWorldAgentDraftCompiler.ts`
第四阶段它必须继续承担:
1. 根据新的 `draftProfile` 重编译摘要
2. 对新增角色生成新的 character 卡
3. 对新增场景生成新的 landmark 卡
### 第四阶段新增要求
对 detail 详情返回:
1. `editable`
2. `editableSectionIds`
---
## 10. 前端实现方案
## 10.1 修改 `CustomWorldAgentDraftDetailPanel.tsx`
第四阶段它要从只读详情升级成:
1. 只读模式
2. 编辑模式
### 新增动作
1. `编辑设定`
2. `保存`
3. `取消`
4. `新增角色`
5. `新增场景`
### 模式规则
#### 只读模式
显示:
1. sections
2. 编辑设定按钮
#### 编辑模式
`editableSectionIds` 对应的 section 渲染输入框:
1. 短字段用 `input`
2. 长字段用 `textarea`
## 10.2 新增 `CustomWorldDraftEditPanel.tsx`
### 职责
承接 detail panel 的编辑表单。
### props
```ts
{
detail: CustomWorldDraftCardDetail;
onSave: (sections) => void;
onCancel: () => void;
}
```
## 10.3 新增 `CustomWorldGenerateEntityModal.tsx`
### 职责
统一承接:
1. AI 新增角色
2. AI 新增场景
### 模式
```ts
mode: 'character' | 'landmark'
```
### 字段
1. `count`
2. `promptText`
3. 当前参考卡提示
### 第一版限制
1. 不做复杂多选引用 UI
2. `anchorCardIds` 可先默认使用当前焦点卡
## 10.4 修改 `CustomWorldAgentQuickActions.tsx`
第四阶段开始可以根据 session.stage 显示:
1. `新增角色`
2. `新增场景`
说明:
这些只是快捷入口,本质仍然打开 modal 并走 action route。
## 10.5 修改 `CustomWorldAgentWorkspace.tsx`
第四阶段它要新增:
1. `editMode`
2. `showGenerateEntityModal`
3. `generateEntityMode`
同时要支持:
1. 打开详情并进入编辑
2. 保存编辑
3. 打开新增角色 modal
4. 打开新增场景 modal
## 10.6 修改 `CustomWorldCreationHub.tsx`
第四阶段它必须支持作品摘要继续升级:
1. 新增角色后,数量变化
2. 新增场景后,数量变化
3. 编辑世界卡后,标题或摘要变化
---
## 11. 交互时序
## 11.1 编辑草稿设定
```text
用户打开角色卡详情
-> 点击编辑设定
-> 修改字段
-> 点击保存
-> 前端 POST /actions { action: update_draft_card }
-> 服务端更新 draftProfile
-> 服务端重编译 draftCards
-> 服务端写入变更摘要
-> 前端刷新 snapshot
```
## 11.2 AI 新增角色
```text
用户点击新增角色
-> 打开 generate modal
-> 输入数量和补充描述
-> 前端 POST /actions { action: generate_characters }
-> 服务端新增角色
-> 服务端更新 draftProfile.storyNpcs
-> 服务端重编译 draftCards
-> 前端刷新 snapshot
```
## 11.3 AI 新增场景
```text
用户点击新增场景
-> 打开 generate modal
-> 输入数量和补充描述
-> 前端 POST /actions { action: generate_landmarks }
-> 服务端新增场景
-> 服务端更新 draftProfile.landmarks
-> 服务端重编译 draftCards
-> 前端刷新 snapshot
```
---
## 12. 与第三阶段的兼容要求
## 12.1 旧 draftCards 兼容
第三阶段生成的 card detail 如果没有:
```ts
editable
editableSectionIds
```
服务端读取时应自动补:
```ts
editable: true/false
editableSectionIds: []
```
## 12.2 旧 draftProfile 兼容
如果旧 draftProfile 缺少某些新增字段:
1. 编辑时允许 fallback
2. 不允许因为字段缺失直接报错
## 12.3 新增角色/场景 ID 规则
新增对象时,必须使用稳定 id 生成规则,不能临时用数组下标。
---
## 13. 落地文件清单
## 13.1 shared
必须修改:
1. `packages/shared/src/contracts/customWorldAgent.ts`
## 13.2 frontend
必须新增:
1. `src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx`
2. `src/components/custom-world-agent/CustomWorldGenerateEntityModal.tsx`
必须修改:
1. `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx`
2. `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx`
3. `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx`
4. `src/components/custom-world-home/CustomWorldCreationHub.tsx`
5. `src/services/aiService.ts`
## 13.3 backend
必须新增:
1. `server-node/src/services/customWorldAgentDraftEditService.ts`
2. `server-node/src/services/customWorldAgentEntityGenerationService.ts`
3. `server-node/src/services/customWorldAgentChangeSummaryService.ts`
必须修改:
1. `server-node/src/services/customWorldAgentOrchestrator.ts`
2. `server-node/src/services/customWorldAgentDraftCompiler.ts`
3. `server-node/src/services/customWorldAgentSessionStore.ts`
4. `server-node/src/routes/customWorldAgent.ts`
---
## 14. 测试要求
## 14.1 服务端测试
至少要补:
1. `update_draft_card` 能正确写回 draftProfile
2. 写回后 draftCards 摘要会更新
3. `generate_characters` 会新增 `storyNpcs` 并生成新的 character 卡
4. `generate_landmarks` 会新增 `landmarks` 并生成新的 landmark 卡
5. 每次编辑或新增后会写入 checkpoint
## 14.2 前端测试
至少要补:
1. detail panel 可以进入编辑模式
2. 保存编辑会调用 action route
3. 可以打开新增角色 modal
4. 可以打开新增场景 modal
5. 操作完成后 drawer 与创作页面摘要会更新
## 14.3 手工回归
至少走这 5 条:
1. 编辑世界总卡标题和摘要
2. 编辑一张角色卡的压力与关系
3. 新增 2 个角色
4. 新增 2 个场景
5. 返回创作页面确认数量与摘要变化
---
## 15. 第四阶段验收标准
做到以下几点,才算第四阶段真正完成:
1. 用户可以直接修改草稿中的设定。
2. 修改后的内容会真正写回 draftProfile而不是只改前端展示。
3. 用户可以继续用 AI 新增角色和场景。
4. 新增角色和场景会成为新的草稿卡。
5. 创作页面草稿作品卡会同步反映这些变化。
6. 第四阶段仍然不越界去做资产工坊、长尾扩展和发布。
---
## 16. 一句话结论
第四阶段最重要的不是“继续控制已有结果不动”,而是:
**让这版世界草稿开始具备真正的可编辑性和可扩展性。**

View File

@@ -0,0 +1,782 @@
# AI 原生 Agent-First 自定义世界创作工具第五阶段技术落地方案
更新时间:`2026-04-14`
## 0. 文档目的
这份文档用于把以下几份文档进一步收束成第五阶段实现方案:
- [AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md](./AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md)
- [AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md](./AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md)
- [AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md](./AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md)
- [AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md](./AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md)
如果说第四阶段的目标是:
**让草稿世界变得可编辑、可继续长出新角色和新场景**
那么第五阶段的目标就是:
**把草稿里的角色第一次接上正式的主图与核心动作资产工坊。**
一句话定义:
**第五阶段把“角色只是文字卡”升级成“角色开始有可预览、可应用的主形象和动作资产”。**
---
## 1. 阶段衔接关系
## 1.1 第四阶段已经完成什么
第五阶段默认建立在第四阶段已经完成的能力之上:
1. `draftProfile` 已经可编辑
2. 用户可以新增角色
3. `draftCards` 中已经有稳定的 `character`
4. `CustomWorldAgentDraftDetailPanel` 已经存在
5. 用户可以从工作区聚焦到某个具体角色
## 1.2 第五阶段不再重做什么
以下内容第五阶段不重做:
1. 不重做 foundation draft 生成
2. 不重做 draftCards 主链
3. 不重做草稿设定编辑
4. 不重做 AI 新增角色 / 场景
第五阶段只继续补:
1. 角色主图候选生成
2. 角色主图发布
3. 角色核心动作生成
4. 角色核心动作发布
5. 资产状态写回 session 与 draftProfile
6. 角色卡资产状态展示
---
## 2. 第五阶段在八阶段中的位置
八阶段拆分如下:
1. 阶段 1创作页面入口、Agent 会话主链与工作区骨架
2. 阶段 2最小锚点收集与澄清流程
3. 阶段 3世界底稿生成与草稿卡编译
4. 阶段 4草稿设定编辑与 AI 新增角色/场景生成
5. 阶段 5角色主图与动作资产工坊接入
6. 阶段 6场景背景图工坊接入
7. 阶段 7长尾内容扩展与自动补齐
8. 阶段 8发布、世界库接入与继续创作恢复
本文件只覆盖:
**阶段 5角色主图与动作资产工坊接入**
---
## 3. 第五阶段目标
第五阶段只做 7 件必须一起成立的事:
1. 用户可以从角色卡打开资产工坊
2. 用户可以为角色生成主图候选
3. 用户可以选择主图候选并发布为角色主图
4. 用户可以基于已发布主图生成核心动作
5. 用户可以发布核心动作资产
6. 发布成功后,角色对象会写回 `imageSrc / generatedVisualAssetId / generatedAnimationSetId / animationMap`
7. 工作区和创作页面能感知角色资产状态变化
一句话目标:
**让第五阶段结束时,至少部分关键角色已经不只是“设定存在”,而是“能动起来”。**
---
## 4. 第五阶段完成定义
第五阶段完成后,必须同时满足以下结果:
1. 用户从某张 `character` 卡进入详情后,可以点击“角色资产”打开 `CustomWorldRoleAssetStudioModal`
2. 用户可以生成主图候选,并能预览多个候选。
3. 用户选择候选并发布后,对应角色会得到:
- `imageSrc`
- `generatedVisualAssetId`
4. 用户可以基于主图继续生成核心动作草稿。
5. 用户发布动作后,对应角色会得到:
- `generatedAnimationSetId`
- `animationMap`
6. assetCoverage 中对应角色状态会更新。
7. 工作区中的角色卡会显示主图 / 动作状态变化。
8. 第五阶段仍然不要求场景背景图接入,也不要求所有角色都必须完成资产生成。
---
## 5. 范围控制
## 5.1 第五阶段纳入范围
纳入范围的模块:
- `packages/shared/src/contracts/customWorldAgent.ts`
- `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx`
- `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx`
- `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx`
- `src/components/CustomWorldRoleAssetStudioModal.tsx`
- `src/components/asset-studio/characterAssetWorkflowPersistence.ts`
- `src/services/aiService.ts`
- `server-node/src/services/customWorldAgentSessionStore.ts`
- `server-node/src/services/customWorldAgentOrchestrator.ts`
- `server-node/src/services/customWorldAgentDraftCompiler.ts`
- `server-node/src/services/customWorldWorkSummaryService.ts`
- `server-node/src/modules/assets/characterAssetRoutes.ts`
新增服务端模块:
- `server-node/src/services/customWorldAgentAssetBridgeService.ts`
- `server-node/src/services/customWorldAgentRoleAssetStateService.ts`
## 5.2 第五阶段明确不做
以下内容不放进第五阶段:
1. 不做场景背景图工坊接入
2. 不做长尾角色批量自动出图
3. 不做所有角色的自动动作补齐
4. 不做发布时强制所有角色资产齐全
5. 不做视频导入高级编辑链
6. 不做口型 / 对话特写
原因:
**第五阶段只解决“选中的角色如何进入资产工坊并成功把结果写回草稿世界”。**
---
## 6. 第五阶段最小闭环
建议把第五阶段的最小闭环定义为:
```text
第四阶段已有角色卡
-> 用户打开某张角色卡详情
-> 点击“角色资产”
-> 打开角色资产工坊
-> 生成主图候选
-> 选择并发布主图
-> 生成核心动作
-> 发布动作
-> sync_role_assets
-> role card / assetCoverage / 创作页面摘要同步更新
```
这个闭环里,先只强接两条高价值链路:
1. `character card -> asset studio`
2. `asset studio publish -> session sync`
---
## 7. 第五阶段产品行为定义
## 7.1 哪些角色可以进入资产工坊
第五阶段允许以下角色进入资产工坊:
1. `playableNpcs`
2. `storyNpcs`
但默认推荐优先处理:
1. 主线关键角色
2. 可扮演角色
3. 创作者重点想看的角色
## 7.2 入口位置
### 角色卡详情入口
`CustomWorldAgentDraftDetailPanel` 中,当当前卡类型为:
```ts
kind === 'character'
```
显示按钮:
- `角色资产`
### 快捷动作入口
当当前 focus card 为角色卡时,`CustomWorldAgentQuickActions` 可显示:
- `生成角色主图与动作`
说明:
快捷动作与详情按钮最终都打开同一个 modal。
## 7.3 第五阶段支持的资产流程
### 阶段 A主图候选
允许:
1. `text-to-image`
2. `image-to-image`
不做:
1. 批量上传模式
2. 一次性批量生成多个角色主图
### 阶段 B主图发布
用户在候选中选择一个结果后,发布主图。
### 阶段 C核心动作草稿
基于主图生成当前工坊支持的核心动作:
1. `idle`
2. `run`
3. `attack`
4. `hurt`
5. `die`
### 阶段 D动作发布
将动作草稿发布为正式动画资产,并写回角色。
## 7.4 第五阶段不强制的事情
第五阶段明确不强制:
1. 每个角色都必须立刻生成主图
2. 每个角色都必须立刻生成动作
3. 没有资产的角色不能继续文本创作
说明:
这一步是“角色开始接资产”,不是“所有角色必须立刻完工”。
## 7.5 积分消耗提示规则
当前项目已经明确:
**不做预算限制,但高成本生成前必须明确提示积分消耗。**
因此第五阶段必须遵守:
### 主图候选生成前
必须提示:
1. 本次会消耗多少积分
2. 这是候选抽卡,不是最终发布
### 动作草稿生成前
必须提示:
1. 本次会消耗多少积分
2. 这是动作草稿,不是最终发布
### 发布前
发布动作或主图本身不应再次重复收积分,除非现有资产接口明确要求。
---
## 8. 角色资产状态定义
## 8.1 `assetCoverage.roleAssets`
第五阶段必须开始真正使用它。
建议状态:
```ts
type CustomWorldRoleAssetStatus =
| 'missing'
| 'visual_ready'
| 'animations_ready'
| 'complete';
```
### 含义
#### `missing`
角色还没有正式主图
#### `visual_ready`
角色已经有:
1. `imageSrc`
2. `generatedVisualAssetId`
但没有完整动作
#### `animations_ready`
角色已经有:
1. `generatedAnimationSetId`
2. 至少一组核心动作映射
但若需要更严格区分,也允许继续映射到 `complete`
#### `complete`
角色有:
1. 主图
2. 核心动作
### 第五阶段建议判定
为了避免漂移,直接使用:
1. 只有主图:`visual_ready`
2. 主图 + 五组核心动作都齐:`complete`
第五阶段不强制使用 `animations_ready` 作为单独过渡,可选保留。
## 8.2 角色对象写回字段
发布主图成功后,必须写回:
```ts
imageSrc
generatedVisualAssetId
```
发布动作成功后,必须写回:
```ts
generatedAnimationSetId
animationMap
```
### 明确要求
第五阶段不允许只更新 `assetCoverage`,不更新角色对象本身。
---
## 9. 数据结构落地方案
## 9.1 扩展 `CustomWorldAgentActionRequest`
第五阶段正式启用:
```ts
| { action: 'generate_role_assets'; roleIds: string[] }
| {
action: 'sync_role_assets';
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: JsonObject | null;
}
```
### 第五阶段限制
1. `generate_role_assets` 第一版只允许单角色:
- `roleIds.length === 1`
2. 批量角色资产生成留到后续阶段
## 9.2 扩展 `CustomWorldRoleAssetSummary`
第五阶段开始必须真正填:
1. `portraitPath`
2. `generatedVisualAssetId`
3. `generatedAnimationSetId`
4. `status`
5. `missingAnimations`
6. `nextPointCost`
## 9.3 新增角色资产同步结果结构
建议新增:
```ts
type SyncRoleAssetsResult = {
roleId: string;
updatedRole: Record<string, unknown>;
updatedAssetSummary: CustomWorldRoleAssetSummary;
};
```
---
## 10. 服务端实现方案
## 10.1 新增 `customWorldAgentAssetBridgeService.ts`
### 文件
`server-node/src/services/customWorldAgentAssetBridgeService.ts`
### 职责
负责连接:
1. Agent session
2. 现有角色资产路由与持久化能力
### 第一版职责
1. 将角色卡编译成资产工坊启动参数
2. 接收工坊发布结果
3. 转换为 session 可写回的标准结果
### 输入
```ts
buildRoleAssetStudioContext(snapshot, roleId)
applyRoleAssetPublishResult(snapshot, payload)
```
### 说明
它不自己生成图片或动作,仍然复用现有资产链。
## 10.2 新增 `customWorldAgentRoleAssetStateService.ts`
### 文件
`server-node/src/services/customWorldAgentRoleAssetStateService.ts`
### 职责
根据角色对象真实字段,更新:
1. `assetCoverage.roleAssets`
2. `draftCards` 中角色卡的副摘要
3. 创作页面作品卡统计
### 导出函数建议
```ts
rebuildRoleAssetCoverage(draftProfile)
mergeRoleAssetIntoDraftProfile(draftProfile, payload)
```
## 10.3 修改 `customWorldAgentOrchestrator.ts`
第五阶段必须启用:
1. `generate_role_assets`
2. `sync_role_assets`
### `generate_role_assets` 流程
```text
收到 generate_role_assets
-> 校验 roleIds
-> 构建 role asset studio context
-> 返回 operation completed
-> 前端打开资产工坊
```
说明:
这里的 operation 不是生成图片,而是准备进入工坊。
### `sync_role_assets` 流程
```text
收到 sync_role_assets
-> 校验 roleId
-> 写回 draftProfile 中的角色字段
-> 重建 assetCoverage.roleAssets
-> 重新编译角色卡摘要
-> 写入 assistant action_result
-> 写入 checkpoint
-> operation completed
```
## 10.4 修改 `customWorldAgentDraftCompiler.ts`
第五阶段它必须让 `character` 卡摘要带出资产状态。
### 角色卡摘要新增要求
`subtitle``summary` 中追加:
1. `主图已就绪`
2. `动作已就绪`
3. `待生成主图`
但不要让卡片默认变成技术清单。
推荐形式:
- `外显身份 / 主图已就绪`
或:
- `当前压力……(动作待补)`
## 10.5 修改 `customWorldWorkSummaryService.ts`
第五阶段创作页面草稿卡应支持展示:
1. 已有多少角色具备主图
2. 已有多少角色具备动作
第一版如果不想上具体数字,也至少要能在草稿卡上体现:
- `角色资产进行中`
---
## 11. 前端实现方案
## 11.1 修改 `CustomWorldAgentDraftDetailPanel.tsx`
当卡片类型为 `character` 时,新增:
1. `角色资产` 按钮
2. 资产状态 badge
### 状态显示建议
1. `待生成主图`
2. `主图已就绪`
3. `动作已就绪`
## 11.2 修改 `CustomWorldAgentQuickActions.tsx`
当当前 focus 为角色卡时,可显示:
1. `生成角色主图与动作`
点击后:
1. 调用 `generate_role_assets`
2. 成功后打开 `CustomWorldRoleAssetStudioModal`
## 11.3 修改 `CustomWorldAgentWorkspace.tsx`
新增状态:
```ts
activeRoleAssetTargetId?: string | null;
showRoleAssetStudio: boolean;
```
### 打开逻辑
1. 来自 detail panel
2. 来自 quick actions
### 关闭逻辑
关闭后不代表写回成功。
必须等:
1. 工坊发布成功
2. `sync_role_assets` 成功
之后才刷新角色资产状态。
## 11.4 修改 `CustomWorldRoleAssetStudioModal.tsx`
第五阶段不重做这个组件,但必须调整为:
1. 接收来自 Agent 工作区的角色对象
2. 发布成功后,不直接改本地 profile
3. 统一回调:
```ts
onPublishSuccess(payload)
```
### `onPublishSuccess` 最小字段
```ts
{
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
}
```
## 11.5 修改 `CustomWorldCreationHub.tsx`
第五阶段它必须支持草稿作品卡的“资产进度感”。
第一版至少做到:
1. 草稿卡可展示:
- 角色资产进行中
- 或若数量可得,则展示主图 / 动作完成数
不要求这一版做得很重。
---
## 12. 交互时序
## 12.1 打开角色资产工坊
```text
用户点击角色卡
-> 打开 detail panel
-> 点击“角色资产”
-> 前端 POST /actions { action: generate_role_assets }
-> 服务端校验角色
-> 服务端返回可进入工坊
-> 前端打开 CustomWorldRoleAssetStudioModal
```
## 12.2 发布主图
```text
用户在工坊中选择主图候选
-> 发布主图
-> 工坊获得 portraitPath + generatedVisualAssetId
-> 暂不关闭会话
-> 可继续生成动作
```
## 12.3 发布动作并同步
```text
用户发布动作
-> 工坊获得 generatedAnimationSetId + animationMap
-> 前端调用 sync_role_assets
-> 服务端写回 draftProfile.character
-> 服务端重建 assetCoverage.roleAssets
-> 服务端重编译角色卡摘要
-> 前端刷新 snapshot
-> 工坊关闭
```
---
## 13. 与第四阶段的兼容要求
## 13.1 兼容新增角色
第四阶段新增的角色对象只要已经在 `draftProfile.storyNpcs``playableNpcs` 中,均允许进入资产工坊。
不要求:
1. 该角色必须有完整长背景
2. 该角色必须已进入后续发布阶段
## 13.2 兼容无主图角色
如果某角色完全无:
1. `imageSrc`
2. `generatedVisualAssetId`
也允许打开工坊,从零开始生成。
## 13.3 兼容有主图无动作角色
如果角色已经有:
1. `imageSrc`
2. `generatedVisualAssetId`
但没有:
1. `generatedAnimationSetId`
则工坊默认直接进入动作阶段。
---
## 14. 落地文件清单
## 14.1 frontend
必须修改:
1. `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx`
2. `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx`
3. `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx`
4. `src/components/CustomWorldRoleAssetStudioModal.tsx`
5. `src/components/custom-world-home/CustomWorldCreationHub.tsx`
6. `src/services/aiService.ts`
## 14.2 backend
必须新增:
1. `server-node/src/services/customWorldAgentAssetBridgeService.ts`
2. `server-node/src/services/customWorldAgentRoleAssetStateService.ts`
必须修改:
1. `server-node/src/services/customWorldAgentOrchestrator.ts`
2. `server-node/src/services/customWorldAgentDraftCompiler.ts`
3. `server-node/src/services/customWorldAgentSessionStore.ts`
4. `server-node/src/services/customWorldWorkSummaryService.ts`
---
## 15. 测试要求
## 15.1 服务端测试
至少要补:
1. `generate_role_assets` 仅允许单角色
2. `sync_role_assets` 能正确写回角色字段
3. 写回后 `assetCoverage.roleAssets` 状态更新
4. 写回后角色卡摘要更新
5. 写回后 checkpoint 存在
## 15.2 前端测试
至少要补:
1. character 卡详情可显示角色资产入口
2. quick actions 可打开角色资产工坊
3. 工坊发布成功后会触发 `sync_role_assets`
4. snapshot 刷新后角色卡显示新状态
## 15.3 手工回归
至少走这 4 条:
1. 为一个无主图角色生成主图
2. 为该角色继续生成并发布核心动作
3. 返回 workspace 确认角色卡状态变化
4. 返回创作页面确认草稿卡摘要变化
---
## 16. 第五阶段验收标准
做到以下几点,才算第五阶段真正完成:
1. 角色卡已经可以接入并打开角色资产工坊。
2. 主图发布成功后,角色对象会写回 `imageSrc / generatedVisualAssetId`
3. 动作发布成功后,角色对象会写回 `generatedAnimationSetId / animationMap`
4. 角色资产状态会同步反映到 session snapshot 和角色卡摘要。
5. 角色资产接入不会阻塞继续文本创作。
6. 第五阶段仍然不越界去做场景背景图、长尾扩展和发布逻辑。
---
## 17. 一句话结论
第五阶段最重要的不是“让所有角色都立刻变成完整资产”,而是:
**先把草稿世界里的角色,真正接到一条可预览、可发布、可写回的资产工坊链路上。**

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,788 @@
# AI 原生自定义世界创作页面 PRD
更新时间:`2026-04-13`
## 0. 文档目的
这份 PRD 用于定义一个新的、独立的“自定义世界创作页面”。
目标不是继续沿用当前“世界选择页里弹出创建弹窗”的旧流程,而是把“创作入口”和“历史作品管理”正式从世界选择页中抽出来,形成一个专门承接创作行为的页面。
这份 PRD 要解决的核心问题是:
**当用户在世界选择页点击“创建自定义世界”后,不应该立刻被丢进一个弹窗或某个具体工作流,而应该先进入一个专门的创作页面,在这里完成:**
1. 新建作品
2. 继续创作草稿
3. 查看历史已发布作品
4. 从创作页面进入具体 Agent 创作工作区
一句话目标:
**让“创作自定义世界”从一个一次性动作,升级成一个正式的创作入口与作品管理入口。**
---
## 1. 当前问题
## 1.1 当前创建入口过于直接
当前链路是:
```text
世界选择页
-> 点击创建自定义世界
-> 直接打开创建弹窗
```
这会带来几个问题:
1. 入口太窄
- 用户只有“立刻创建”这一个选择,没有“先看看历史作品”“继续草稿”的缓冲页。
2. 创作与管理没有分层
- 新建、继续创作、查看已发布作品都混在世界选择页这个入口层里。
3. 后续 Agent 工作区不好接
- 如果直接从世界选择页跳进 Agent session用户缺少“这是一个创作空间”的过渡。
## 1.2 历史作品入口不完整
当前世界选择页里虽然能展示已保存的自定义世界,但它更像“世界卡片列表”,不是一个真正的创作历史页。
缺口主要有:
1. 草稿和已发布作品没有统一视图
2. 草稿没有正式“继续创作”入口体系
3. 已发布作品只是“世界库内容”,不是“创作成果”
4. 用户看不到自己的创作历史全貌
## 1.3 未来 Agent 工作区缺少前置首页
后续 Agent-first 创作工具一定会有:
1. 草稿 session
2. 已发布世界
3. 继续创作入口
4. 发布后的回看入口
如果没有一个专门的创作页面,这些入口只能继续塞在:
- 世界选择页
- 结果页
- 临时 modal
最终会让流程越来越乱。
---
## 2. 产品目标
这次要做的创作页面,必须同时满足 6 个目标:
1. 把“新建作品”和“管理作品”放到同一入口里
2. 支持同时展示草稿和已发布作品
3. 让用户从世界选择页进入后,先感知到“这里是创作空间”
4. 为后续 Agent 创作工作区提供稳定前置页
5. 保持移动端优先,界面清爽,不堆规则说明
6. 前端只负责展示和跳转,数据聚合与状态归类全部交给后端
---
## 3. 核心结论
新的用户主链应该改成:
```text
世界选择页
-> 点击“创建自定义世界”
-> 进入“创作页面”
-> 用户选择:
- 新建作品
- 继续创作草稿
- 查看已发布作品
-> 再进入具体 Agent 工作区或正式世界
```
一句话:
**创作页面是“创作中心”,不是“又一个创建弹窗”。**
---
## 4. 页面定位
## 4.1 页面名称
建议名称:
`创作页面`
UI 主标题建议:
`自定义世界创作`
## 4.2 页面职责
这个页面只负责 4 件事:
1. 提供“新建作品”入口
2. 列出用户历史草稿
3. 列出用户已发布作品
4. 把用户带到正确的下一步页面
这个页面不负责:
1. 直接进行世界锚点编辑
2. 直接进行 Agent 长对话创作
3. 直接进行结果页细改
4. 直接发布作品
也就是说:
**它是创作首页,不是创作工作区本体。**
---
## 5. 用户流程
## 5.1 世界选择页进入创作页面
当前世界选择页里的“创建自定义世界”按钮行为改为:
```text
点击创建自定义世界
-> 不再打开旧 modal
-> 进入 custom-world-home
```
第一阶段只要求:
1. 进入专门创作页面
2. 能看到新建入口和历史作品
## 5.2 在创作页面新建作品
流程:
```text
创作页面
-> 点击“新建作品”
-> 打开轻量 launcher
-> 创建新的 Agent session
-> 进入 custom-world-agent
```
说明:
- 新建作品依然走 Agent session 主链
- 创作页面不自己生成世界
## 5.3 在创作页面继续草稿
流程:
```text
创作页面
-> 点击草稿卡片的“继续创作”
-> 打开该 session
-> 进入 custom-world-agent
```
## 5.4 在创作页面查看已发布作品
已发布作品卡片支持两种动作:
1. `进入世界`
2. `查看作品`
说明:
- `进入世界` 直接进入当前已发布世界
- `查看作品` 进入只读详情页或结果总览页
第一版如果不单独做只读详情页,可先只保留:
1. `进入世界`
但卡片展示层必须把“它是已发布作品”表达清楚。
---
## 6. 信息架构
## 6.1 页面整体结构
创作页面必须包含 4 个区域:
1. 顶部导航区
2. 新建作品区
3. 历史作品筛选区
4. 作品列表区
## 6.2 顶部导航区
必须展示:
1. 页面标题:`自定义世界创作`
2. 返回按钮:`返回世界选择`
可选展示:
1. 用户作品统计
- 草稿数
- 已发布数
## 6.3 新建作品区
这是页面首屏最高优先级区域。
必须包含:
1. `新建作品` 主按钮
2. 一段极短说明文案
说明文案要求:
1. 只一两句
2. 不要写规则说明
3. 不要写长解释
推荐文案方向:
- `输入一点灵感,开始共创一个新世界。`
## 6.4 历史作品筛选区
建议用 3 个 tab
1. `全部`
2. `草稿`
3. `已发布`
默认:
- `全部`
第一版不强制上搜索框,但如果作品数超过 `8` 个,建议补搜索。
## 6.5 作品列表区
列表区统一展示作品卡片,但卡片要区分两类:
1. 草稿卡片
2. 已发布卡片
默认排序:
-`updatedAt desc`
---
## 7. 作品卡片定义
## 7.1 草稿卡片
草稿卡片必须展示:
1. 标题
2. 草稿状态标识
3. 最近更新时间
4. 当前阶段标签
5. 简短摘要
6. 封面图
7. 主要操作按钮
### 标题规则
按优先级取:
1. `draftProfile.name`
2. `anchorPack.worldSummary`
3. `creatorIntent.worldHook`
4. `未命名草稿`
### 摘要规则
按优先级取:
1. `anchorPack.creatorIntentSummary`
2. `creatorIntent.rawSettingText`
3. 默认摘要占位文本
### 封面图规则
按优先级取:
1. `draftProfile.camp.imageSrc`
2. `draftProfile` 中可解析的营地图
3. 角色主图或默认创作占位图
### 草稿卡片主操作
第一版必须有:
1. `继续创作`
可选:
1. `删除草稿`
2. `复制草稿`
第一版如来不及,可不做删除和复制,但接口结构应预留。
## 7.2 已发布卡片
已发布卡片必须展示:
1. 世界名称
2. 已发布标识
3. 发布时间或更新时间
4. 世界摘要
5. 封面图
6. 主要操作按钮
### 标题规则
直接取:
1. `CustomWorldProfile.name`
### 摘要规则
直接取:
1. `CustomWorldProfile.summary`
### 封面图规则
按优先级取:
1. 营地图
2. 第一可扮演角色立绘
3. 默认已发布作品占位图
### 已发布卡片主操作
第一版必须有:
1. `进入世界`
可选:
1. `查看作品`
2. `基于此作品继续创作`
第一版不强制做“基于已发布作品继续创作”,避免先把发布后再开草稿链带复杂。
---
## 8. 作品摘要数据结构
## 8.1 新增统一作品摘要结构
建议新增:
```ts
type CustomWorldWorkStatus = 'draft' | 'published';
type CustomWorldWorkSource = 'agent_session' | 'published_profile';
interface CustomWorldWorkSummary {
workId: string;
sourceType: CustomWorldWorkSource;
status: CustomWorldWorkStatus;
title: string;
subtitle: string;
summary: string;
coverImageSrc?: string | null;
updatedAt: string;
publishedAt?: string | null;
stage?: string | null;
stageLabel?: string | null;
playableNpcCount: number;
landmarkCount: number;
sessionId?: string | null;
profileId?: string | null;
canResume: boolean;
canEnterWorld: boolean;
}
```
## 8.2 字段解释
### `workId`
统一主键,用于前端列表渲染。
建议格式:
- 草稿:`draft:${sessionId}`
- 已发布:`published:${profileId}`
### `sourceType`
用于区分:
1. Agent 草稿 session
2. 已发布 profile
### `status`
只允许两类:
1. `draft`
2. `published`
### `stage / stageLabel`
仅草稿需要。
已发布作品可为空。
### `canResume`
仅草稿为 `true`
### `canEnterWorld`
仅已发布作品为 `true`
---
## 9. 后端接口设计
## 9.1 新增作品列表接口
必须新增:
`GET /api/runtime/custom-world/works`
这是创作页面的核心接口。
它负责返回:
1. 当前用户的草稿 session 摘要
2. 当前用户的已发布世界摘要
3. 按统一结构聚合后的作品列表
## 9.2 接口返回结构
```ts
interface ListCustomWorldWorksResponse {
items: CustomWorldWorkSummary[];
}
```
### 第一版要求
1. 后端一次性返回全量列表
2. 前端做 tab 过滤
3. 不做服务端分页
原因:
- 当前用户作品量预计不大
- 先把结构做稳,比先做分页更重要
## 9.3 数据来源
### 草稿来源
来自:
- `customWorldAgentSessionStore`
筛选规则:
1. `stage !== published`
2. 未被标记为归档 / 删除
### 已发布来源
来自:
- 当前自定义世界库
即:
- `runtimeRepository.listCustomWorldProfiles(userId)`
## 9.4 聚合服务
建议新增服务:
`server-node/src/services/customWorldWorkSummaryService.ts`
职责:
1. 读取草稿 session
2. 读取已发布 profile
3. 编译成统一的 `CustomWorldWorkSummary[]`
### 明确要求
不允许在 route 里直接拼草稿和已发布数据。
---
## 10. 前端页面设计
## 10.1 页面组件
建议新增页面组件:
`src/components/custom-world-home/CustomWorldCreationHub.tsx`
这是新的创作页面主组件。
## 10.2 子组件建议
建议拆成:
1. `CustomWorldCreationHubHeader.tsx`
2. `CustomWorldCreationStartCard.tsx`
3. `CustomWorldWorkTabs.tsx`
4. `CustomWorldWorkCard.tsx`
5. `CustomWorldWorkList.tsx`
6. `CustomWorldCreationHubEmptyState.tsx`
第一版如果不想拆太多,也允许先做成一个主组件加一个作品卡组件。
## 10.3 页面状态
页面至少要支持:
1. `loading`
2. `ready`
3. `error`
4. `empty`
### `loading`
展示骨架屏,不展示空白页。
### `ready`
正常展示:
1. 新建作品区
2. 筛选 tabs
3. 作品列表
### `error`
展示:
1. 错误文案
2. `重试` 按钮
### `empty`
分两类空态:
1. `全量空态`
- 没有任何草稿,也没有已发布作品
2. `筛选空态`
- 比如只看草稿时为空
## 10.4 页面交互
### 新建作品
点击后:
1. 打开 Agent launcher
2. 创建新 session
3. 成功后跳入 Agent workspace
### 草稿卡片点击
主按钮:
1. `继续创作`
触发:
1. 进入 `custom-world-agent`
2.`sessionId`
### 已发布卡片点击
主按钮:
1. `进入世界`
触发:
1. 读取对应 `profile`
2. 调用当前进入世界流程
---
## 11. 与现有流程的接入方式
## 11.1 修改 `SelectionStage`
建议新增:
```ts
type SelectionStage =
| 'start'
| 'world'
| 'custom-world-home'
| 'custom-world-agent'
| ...
```
## 11.2 世界选择页创建按钮改造
当前:
```text
点击创建自定义世界
-> 打开创建 modal
```
改为:
```text
点击创建自定义世界
-> setSelectionStage('custom-world-home')
```
## 11.3 创作页面与 Agent 工作区关系
关系必须明确:
1. 创作页面
- 管理入口
- 新建入口
- 历史作品页
2. Agent 工作区
- 具体创作编辑页
这两个页面不能混成一个组件。
---
## 12. UI 与交互约束
## 12.1 移动端优先
页面默认以移动端竖屏成立。
要求:
1. 新建作品区位于首屏
2. tabs 横向可滚
3. 作品卡优先单列
4. 不使用桌面化大表格
## 12.2 页面保持清爽
遵守当前项目约束:
1. 不在页面中堆规则说明
2. 不默认展示很多系统字段
3. 不出现“大型表单式管理后台”感
## 12.3 作品卡信息密度
每张卡默认最多展示:
1. 标题
2. 标签
3. 更新时间
4. 一行摘要
5. 一张封面
6. 一个主按钮
不要默认堆:
1. 大量统计字段
2. 多排操作按钮
3. 技术字段
---
## 13. 明确不做什么
本次创作页面 PRD 不做:
1. 不做完整 Agent 工作区
2. 不做世界底稿生成
3. 不做作品删除确认流
4. 不做作品搜索排序高级功能
5. 不做发布世界管理后台
6. 不做已发布作品的二次派生创作
这些内容后续可接,但不属于本次页面 PRD 核心闭环。
---
## 14. 验收标准
做到以下几点,才算这个创作页面真正成立:
1. 世界选择页点击“创建自定义世界”后,进入的是独立创作页面,不再是旧弹窗。
2. 创作页面能同时展示草稿和已发布作品。
3. 草稿作品可以继续创作。
4. 已发布作品可以进入世界。
5. 新建作品入口可以正确创建 Agent session 并跳转到创作工作区。
6. 页面在移动端首屏可用,信息层级清楚。
7. 草稿与已发布作品都通过后端聚合接口返回,前端不自己拼数据来源。
---
## 15. 推荐落地顺序
## 阶段 A先做后端聚合接口
先做:
1. `GET /api/runtime/custom-world/works`
2. `customWorldWorkSummaryService`
验收:
- 能返回统一的作品摘要数组
## 阶段 B再做前端创作页面
先做:
1. `custom-world-home` stage
2. `CustomWorldCreationHub`
3. 作品卡片和 tabs
验收:
- 能看到新建入口和历史作品
## 阶段 C最后接 Agent workspace 跳转
先做:
1. 新建作品 -> 创建 session -> 进入 workspace
2. 草稿 -> 恢复 session
3. 已发布作品 -> 进入世界
验收:
- 三条主路径都通
---
## 16. 一句话结论
“创建自定义世界”不应该继续只是一个弹窗动作,而应该升级成一个正式的创作入口。
这个创作页面的本质价值,不是多做一个页面,而是把:
- 新建作品
- 继续草稿
- 查看已发布作品
这三类本来分散的行为,正式收口到同一个创作中心里。

2313
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,8 @@
"vite": "^6.2.0"
},
"devDependencies": {
"@testing-library/react": "^14.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"@types/react": "^19.2.14",
@@ -64,6 +66,7 @@
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-unused-imports": "^3.2.0",
"globals": "^13.24.0",
"jsdom": "^22.1.0",
"prettier": "^3.3.3",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",

View File

@@ -0,0 +1,418 @@
export type CustomWorldWorkStatus = 'draft' | 'published';
export type CustomWorldWorkSource = 'agent_session' | 'published_profile';
export interface CustomWorldWorkSummary {
workId: string;
sourceType: CustomWorldWorkSource;
status: CustomWorldWorkStatus;
title: string;
subtitle: string;
summary: string;
coverImageSrc?: string | null;
updatedAt: string;
publishedAt?: string | null;
stage?: string | null;
stageLabel?: string | null;
playableNpcCount: number;
landmarkCount: number;
roleVisualReadyCount?: number;
roleAnimationReadyCount?: number;
roleAssetSummaryLabel?: string | null;
sessionId?: string | null;
profileId?: string | null;
canResume: boolean;
canEnterWorld: boolean;
}
export interface CreatorIntentReadiness {
isReady: boolean;
completedKeys: string[];
missingKeys: string[];
}
export interface CustomWorldPendingClarification {
id: string;
label: string;
question: string;
targetKey:
| 'world_hook'
| 'player_premise'
| 'theme_and_tone'
| 'core_conflict'
| 'relationship_seed'
| 'iconic_element';
priority: number;
answer?: string;
}
export type CustomWorldAgentStage =
| 'collecting_intent'
| 'clarifying'
| 'foundation_review'
| 'object_refining'
| 'visual_refining'
| 'long_tail_review'
| 'ready_to_publish'
| 'published'
| 'error';
export type CustomWorldAgentMessageRole = 'user' | 'assistant' | 'system';
export type CustomWorldAgentMessageKind =
| 'chat'
| 'clarification'
| 'summary'
| 'checkpoint'
| 'warning'
| 'action_result';
export interface CustomWorldAgentMessage {
id: string;
role: CustomWorldAgentMessageRole;
kind: CustomWorldAgentMessageKind;
text: string;
createdAt: string;
relatedOperationId?: string | null;
}
export type CustomWorldDraftCardKind =
| 'world'
| 'camp'
| 'faction'
| 'character'
| 'landmark'
| 'thread'
| 'chapter'
| 'scene_chapter'
| 'carrier'
| 'sidequest_seed';
export type CustomWorldDraftCardStatus =
| 'suggested'
| 'confirmed'
| 'locked'
| 'warning';
export interface CustomWorldDraftCardSummary {
id: string;
kind: CustomWorldDraftCardKind;
title: string;
subtitle: string;
summary: string;
status: CustomWorldDraftCardStatus;
linkedIds: string[];
warningCount: number;
assetStatus?: CustomWorldRoleAssetStatus | null;
assetStatusLabel?: string | null;
}
export interface CustomWorldDraftCardDetailSection {
id: string;
label: string;
value: string;
}
export interface CustomWorldFoundationDraftFaction {
id: string;
name: string;
title?: string;
subtitle?: string;
publicGoal: string;
relatedConflict: string;
tension?: string;
playerRelation: string;
summary: string;
}
export interface CustomWorldFoundationDraftCharacter {
id: string;
name: string;
title: string;
role: string;
publicIdentity: string;
publicMask?: string;
currentPressure: string;
hiddenHook?: string;
relationToPlayer: string;
threadIds: string[];
summary: string;
imageSrc?: string | null;
generatedVisualAssetId?: string | null;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
}
export interface CustomWorldFoundationDraftLandmark {
id: string;
name: string;
description?: string;
purpose: string;
mood: string;
importance: string;
secret?: string;
dangerLevel?: string;
characterIds: string[];
threadIds: string[];
summary: string;
}
export interface CustomWorldFoundationDraftThread {
id: string;
title: string;
type: 'main' | 'hidden';
conflictType?: string;
conflict: string;
stakes?: string;
characterIds: string[];
landmarkIds: string[];
summary: string;
}
export interface CustomWorldFoundationDraftChapter {
id: string;
title: string;
openingEvent: string;
playerGoal: string;
characterIds: string[];
landmarkIds: string[];
understandingShift: string;
summary: string;
}
export interface CustomWorldFoundationDraftCamp {
id: string;
name: string;
description: string;
mood: string;
dangerLevel?: string;
summary: string;
}
export interface CustomWorldFoundationDraftProfile {
name: string;
subtitle: string;
summary: string;
tone: string;
playerGoal: string;
majorFactions: string[];
coreConflicts: string[];
playableNpcs: CustomWorldFoundationDraftCharacter[];
storyNpcs: CustomWorldFoundationDraftCharacter[];
landmarks: CustomWorldFoundationDraftLandmark[];
camp?: CustomWorldFoundationDraftCamp | null;
themePack?: Record<string, unknown> | null;
storyGraph?: Record<string, unknown> | null;
factions: CustomWorldFoundationDraftFaction[];
threads: CustomWorldFoundationDraftThread[];
chapters: CustomWorldFoundationDraftChapter[];
worldHook: string;
playerPremise: string;
openingSituation: string;
iconicElements: string[];
sourceAnchorSummary: string;
}
export interface CustomWorldFoundationDraftResult {
draftProfile: CustomWorldFoundationDraftProfile;
draftCards: CustomWorldDraftCardSummary[];
}
export interface CustomWorldDraftCardDetail {
id: string;
kind: CustomWorldDraftCardKind;
title: string;
sections: CustomWorldDraftCardDetailSection[];
linkedIds: string[];
locked: false;
editable: boolean;
editableSectionIds: string[];
warningMessages: string[];
assetStatus?: CustomWorldRoleAssetStatus | null;
assetStatusLabel?: string | null;
}
export interface CustomWorldSuggestedAction {
id: string;
type:
| 'request_summary'
| 'draft_foundation'
| 'refine_focus_target'
| 'lock_current_target'
| 'generate_role_assets'
| 'generate_scene_assets'
| 'expand_long_tail'
| 'publish_world';
label: string;
targetId?: string | null;
}
export type CustomWorldAssetPriorityTier = 'hero' | 'featured' | 'supporting';
export type CustomWorldRoleAssetStatus =
| 'missing'
| 'visual_ready'
| 'animations_ready'
| 'complete';
export interface CustomWorldRoleAssetSummary {
roleId: string;
roleName: string;
roleKind: 'playable' | 'story';
priorityTier: CustomWorldAssetPriorityTier;
portraitPath?: string | null;
generatedVisualAssetId?: string | null;
generatedAnimationSetId?: string | null;
status: CustomWorldRoleAssetStatus;
missingAnimations: string[];
nextPointCost: number;
}
export interface CustomWorldSceneAssetSummary {
sceneId: string;
sceneName: string;
imageSrc?: string | null;
status: 'missing' | 'ready';
nextPointCost: number;
}
export interface CustomWorldAssetCoverageSummary {
roleAssets: CustomWorldRoleAssetSummary[];
sceneAssets: CustomWorldSceneAssetSummary[];
allRoleAssetsReady: boolean;
allSceneAssetsReady: boolean;
}
export interface CustomWorldAgentSessionSnapshot {
sessionId: string;
stage: CustomWorldAgentStage;
focusCardId: string | null;
creatorIntent: Record<string, unknown> | null;
creatorIntentReadiness: CreatorIntentReadiness;
anchorPack: Record<string, unknown> | null;
lockState: Record<string, unknown> | null;
draftProfile: Record<string, unknown> | null;
messages: CustomWorldAgentMessage[];
draftCards: CustomWorldDraftCardSummary[];
pendingClarifications: CustomWorldPendingClarification[];
suggestedActions: CustomWorldSuggestedAction[];
recommendedReplies: string[];
qualityFindings: {
id: string;
severity: 'info' | 'warning' | 'blocker';
code: string;
targetId?: string | null;
message: string;
}[];
assetCoverage: CustomWorldAssetCoverageSummary;
updatedAt: string;
}
export type CustomWorldAgentOperationType =
| 'process_message'
| 'lock_cards'
| 'unlock_cards'
| 'regenerate_scope'
| 'draft_foundation'
| 'update_draft_card'
| 'generate_characters'
| 'generate_landmarks'
| 'generate_role_assets'
| 'sync_role_assets'
| 'generate_scene_assets'
| 'sync_scene_assets'
| 'expand_long_tail'
| 'publish_world'
| 'revert_checkpoint';
export type CustomWorldAgentOperationStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed';
export interface CustomWorldAgentOperationRecord {
operationId: string;
type: CustomWorldAgentOperationType;
status: CustomWorldAgentOperationStatus;
phaseLabel: string;
phaseDetail: string;
progress: number;
error?: string | null;
}
export interface CreateCustomWorldAgentSessionRequest {
seedText?: string;
}
export interface CreateCustomWorldAgentSessionResponse {
session: CustomWorldAgentSessionSnapshot;
}
export interface SendCustomWorldAgentMessageRequest {
clientMessageId: string;
text: string;
focusCardId?: string | null;
selectedCardIds?: string[];
}
export interface SendCustomWorldAgentMessageResponse {
operation: CustomWorldAgentOperationRecord;
}
export type CustomWorldAgentActionRequest =
| { action: 'lock_cards'; cardIds: string[] }
| { action: 'unlock_cards'; cardIds: string[] }
| {
action: 'regenerate_scope';
scope:
| 'focus_card'
| 'long_tail_npcs'
| 'long_tail_landmarks'
| 'sidequest_seeds'
| 'role_assets'
| 'scene_assets';
targetCardId?: string | null;
}
| { action: 'draft_foundation' }
| {
action: 'update_draft_card';
cardId: string;
sections: Array<{
sectionId: string;
value: string;
}>;
}
| {
action: 'generate_characters';
count: number;
promptText?: string | null;
anchorCardIds?: string[];
}
| {
action: 'generate_landmarks';
count: number;
promptText?: string | null;
anchorCardIds?: string[];
}
| { action: 'generate_role_assets'; roleIds: string[] }
| {
action: 'sync_role_assets';
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
}
| { action: 'publish_world' };
export interface CustomWorldAgentActionResponse {
operation: CustomWorldAgentOperationRecord;
}
export interface GetCustomWorldAgentCardDetailResponse {
card: CustomWorldDraftCardDetail;
}
export interface ListCustomWorldWorksResponse {
items: CustomWorldWorkSummary[];
}

View File

@@ -114,10 +114,13 @@ export type CustomWorldSessionSummary = {
sessionId: string;
status: CustomWorldSessionStatus;
questions: CustomWorldQuestion[];
createdAt: string;
updatedAt: string;
};
export type CustomWorldSessionRecord = CustomWorldSessionSummary & {
settingText: string;
creatorIntent?: JsonObject | null;
generationMode: CustomWorldGenerationMode;
result?: JsonObject;
lastError?: string;

File diff suppressed because it is too large Load Diff

View File

@@ -61,6 +61,15 @@ function validateCredentials(username: string, password: string) {
}
}
function isUniqueViolationError(error: unknown) {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === '23505'
);
}
function buildMaskedPhoneDisplay(phoneNumber: string) {
const normalizedPhone = normalizeMainlandChinaPhoneNumber(phoneNumber);
return normalizedPhone.maskedNationalNumber;
@@ -935,13 +944,21 @@ export async function entryWithPassword(
validateCredentials(username, password);
let user = await context.userRepository.findByUsername(username);
let shouldVerifyExistingPassword = Boolean(user);
if (!user) {
const passwordHash = await hashPassword(password);
user = await context.userRepository.create(username, passwordHash);
} else {
const isValid = await verifyPassword(user.passwordHash, password);
if (!isValid) {
throw unauthorized('用户名或密码错误');
try {
user = await context.userRepository.create(username, passwordHash);
shouldVerifyExistingPassword = false;
} catch (error) {
if (!isUniqueViolationError(error)) {
throw error;
}
user = await context.userRepository.findByUsername(username);
shouldVerifyExistingPassword = true;
if (!user) {
throw error;
}
}
}
@@ -949,6 +966,13 @@ export async function entryWithPassword(
throw new Error('failed to resolve user after auth entry');
}
if (shouldVerifyExistingPassword) {
const isValid = await verifyPassword(user.passwordHash, password);
if (!isValid) {
throw unauthorized('用户名或密码错误');
}
}
await writeAuthAuditLog(context, {
userId: user.id,
eventType: 'password_login',

View File

@@ -2,15 +2,17 @@ import type { Logger } from 'pino';
import type { AppConfig } from './config.js';
import type { AppDatabase } from './db.js';
import { AuthIdentityRepository } from './repositories/authIdentityRepository.js';
import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js';
import { AuthIdentityRepository } from './repositories/authIdentityRepository.js';
import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js';
import { RuntimeRepository } from './repositories/runtimeRepository.js';
import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js';
import { UserRepository } from './repositories/userRepository.js';
import { UserSessionRepository } from './repositories/userSessionRepository.js';
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js';
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
import { UpstreamLlmClient } from './services/llmClient.js';
import type { SmsVerificationService } from './services/smsVerificationService.js';
import type { WechatAuthService } from './services/wechatAuthService.js';
@@ -29,6 +31,8 @@ export type AppContext = {
runtimeRepository: RuntimeRepository;
llmClient: UpstreamLlmClient;
customWorldSessions: CustomWorldSessionStore;
customWorldAgentSessions: CustomWorldAgentSessionStore;
customWorldAgentOrchestrator: CustomWorldAgentOrchestrator;
smsVerificationService: SmsVerificationService;
wechatAuthService: WechatAuthService;
wechatAuthStates: WechatAuthStateStore;

View File

@@ -108,6 +108,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
'20260409_006_auth_audit_logs',
'20260409_007_sms_auth_events',
'20260409_008_auth_risk_blocks',
'20260413_009_custom_world_sessions',
],
);
@@ -123,6 +124,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
'auth_risk_blocks',
'sms_auth_events',
'user_sessions',
'custom_world_sessions',
'save_snapshots',
'runtime_settings',
'custom_world_profiles'
@@ -137,6 +139,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
'auth_identities',
'auth_risk_blocks',
'custom_world_profiles',
'custom_world_sessions',
'runtime_settings',
'save_snapshots',
'schema_migrations',

View File

@@ -189,4 +189,21 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
ON auth_risk_blocks (scope_type, scope_key, expires_at DESC)`,
],
},
{
id: '20260413_009_custom_world_sessions',
name: 'custom world sessions',
statements: [
`CREATE TABLE IF NOT EXISTS custom_world_sessions (
user_id TEXT NOT NULL,
session_id TEXT NOT NULL,
payload_json JSONB NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, session_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS custom_world_sessions_user_updated_idx
ON custom_world_sessions (user_id, updated_at DESC)`,
],
},
];

View File

@@ -1,462 +1,490 @@
import type {
CustomWorldGenerationMode,
CustomWorldGenerationProgress,
GenerateCustomWorldProfileInput,
} from '../../../../packages/shared/src/contracts/runtime.js';
import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js';
import {
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
validateGeneratedCustomWorldProfile,
} from '../../../../src/services/customWorld.js';
import { buildExpandedCustomWorldProfile } from '../../../../src/services/customWorldBuilder.js';
import {
buildCustomWorldAnchorPackFromIntent,
buildCustomWorldCreatorIntentGenerationText,
deriveCustomWorldLockStateFromIntent,
hasMeaningfulCustomWorldCreatorIntent,
normalizeCustomWorldCreatorIntent,
} from '../../../../src/services/customWorldCreatorIntent.js';
import type {
CustomWorldCreatorIntent,
CustomWorldProfile,
} from '../../../../src/types.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
type GeneratedProfile = Record<string, unknown>;
const PLAYABLE_ROLE_TEMPLATES = [
{ title: '断桥行者', role: '游历剑客', style: '快剑追击', tags: ['快剑', '突进', '追击'] },
{ title: '听风客', role: '远行弓手', style: '远射游击', tags: ['远射', '游击', '风行'] },
{ title: '守夜人', role: '前列护卫', style: '守御护体', tags: ['守御', '护体', '先锋'] },
{ title: '观火者', role: '术式使', style: '法修过载', tags: ['法修', '过载', '法力'] },
{ title: '逐潮者', role: '浪客拳师', style: '重击压制', tags: ['重击', '爆发', '压制'] },
const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
你会收到一段本应为单个 JSON 对象的文本。
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
不要输出 Markdown、代码块、解释、注释或额外文字。
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = 180000;
const FAST_CUSTOM_WORLD_PLAYABLE_COUNT = 3;
const FAST_CUSTOM_WORLD_STORY_COUNT = 8;
const FAST_CUSTOM_WORLD_LANDMARK_COUNT = 4;
const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
{
id: 'prepare',
label: '整理设定',
detail: '整理创作者输入,准备模型推理上下文。',
total: 1,
weight: 1,
},
{
id: 'llm-profile',
label: '大模型推理',
detail: '正在请求模型生成世界档案、角色群像与场景网络。',
total: 1,
weight: 8,
},
{
id: 'normalize',
label: '系统编译',
detail: '正在把模型结果归一成运行时可用结构。',
total: 1,
weight: 2,
},
{
id: 'finalize',
label: '归档世界',
detail: '整理最终世界档案并做完整性校验。',
total: 1,
weight: 1,
},
] as const;
const STORY_ROLE_TEMPLATES = [
{ role: '沿街商贩', danger: 'low', tags: ['交易', '情报'] },
{ role: '巡路探子', danger: 'medium', tags: ['巡守', '警觉'] },
{ role: '旧案见证人', danger: 'medium', tags: ['旧案', '隐情'] },
{ role: '守桥武人', danger: 'high', tags: ['守御', '敌意'] },
{ role: '异变潜伏者', danger: 'high', tags: ['异变', '威胁'] },
] as const;
type CustomWorldGenerationStageId =
(typeof CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS)[number]['id'];
const LANDMARK_TEMPLATES = [
'断桥口',
'旧市桥廊',
'潮痕渡口',
'灰塔前庭',
'沉钟小巷',
'碑下荒庭',
'雾潮栈道',
'封灯码头',
'裂潮前哨',
'残照高台',
] as const;
class CustomWorldGenerationAbortedError extends Error {
constructor(message = '世界生成已中断。') {
super(message);
this.name = 'CustomWorldGenerationAbortedError';
}
}
function nowMs() {
return Date.now();
}
function inferWorldType(settingText: string) {
return /||||||/u.test(settingText)
? 'XIANXIA'
: 'WUXIA';
function throwIfCustomWorldGenerationAborted(signal?: AbortSignal) {
if (!signal?.aborted) {
return;
}
throw signal.reason instanceof Error
? signal.reason
: new CustomWorldGenerationAbortedError();
}
function seedText(input: GenerateCustomWorldProfileInput) {
return input.settingText.trim().replace(/\s+/g, ' ');
function isCustomWorldGenerationAbortLikeError(error: unknown) {
return (
error instanceof CustomWorldGenerationAbortedError ||
(typeof DOMException !== 'undefined' &&
error instanceof DOMException &&
error.name === 'AbortError') ||
(error instanceof Error && error.name === 'AbortError')
);
}
function slugify(value: string) {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '');
function sanitizeJsonLikeText(text: string) {
const trimmed = text.trim();
if (!trimmed) {
return '';
}
return normalized || 'entry';
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu);
const unfenced = fencedMatch?.[1]?.trim() || trimmed;
const firstBrace = unfenced.indexOf('{');
const lastBrace = unfenced.lastIndexOf('}');
const extracted =
firstBrace >= 0 && lastBrace > firstBrace
? unfenced.slice(firstBrace, lastBrace + 1)
: unfenced;
return extracted
.replace(/^\uFEFF/u, '')
.replace(/[\u201C\u201D]/gu, '"')
.replace(/[\u2018\u2019]/gu, "'")
.replace(/\u00A0/gu, ' ')
.replace(/,\s*([}\]])/gu, '$1')
.trim();
}
function buildAttributeSchema(worldType: 'WUXIA' | 'XIANXIA') {
function resolveCustomWorldGenerationInput(
input: GenerateCustomWorldProfileInput,
): {
settingText: string;
generationSeedText: string;
creatorIntent: CustomWorldCreatorIntent | null;
generationMode: CustomWorldGenerationMode;
} {
const settingText = input.settingText.trim();
const creatorIntent = normalizeCustomWorldCreatorIntent(input.creatorIntent);
const generationSeedText =
creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent)
? buildCustomWorldCreatorIntentGenerationText(creatorIntent)
: settingText;
return {
id: `schema:${worldType.toLowerCase()}:default`,
worldId: `world:${worldType.toLowerCase()}`,
schemaVersion: 1,
generatedFrom: {
worldType,
worldName: worldType === 'XIANXIA' ? '云海异境' : '裂潮边城',
settingSummary: worldType === 'XIANXIA' ? '灵潮翻涌的高空异境' : '旧桥与边城交错的裂潮地界',
tone: worldType === 'XIANXIA' ? '高危、空灵、失衡' : '冷峻、紧绷、边境余震',
conflictCore: '旧秩序与新威胁正在同时逼近',
},
schemaName: worldType === 'XIANXIA' ? '灵潮六轴' : '边城六轴',
slots: [
{
slotId: 'axis_a',
name: '锋势',
definition: '临战时的主动压迫与破面能力',
positiveSignals: ['先手', '破势'],
negativeSignals: ['迟疑', '退缩'],
combatUseText: '决定压制与追击能力',
socialUseText: '决定发起对峙的胆气',
explorationUseText: '决定冒险前推的强度',
},
{
slotId: 'axis_b',
name: '守意',
definition: '承压、稳住阵脚与保全同伴的能力',
positiveSignals: ['护持', '稳守'],
negativeSignals: ['失衡', '溃散'],
combatUseText: '决定承伤与稳场',
socialUseText: '决定是否可靠',
explorationUseText: '决定穿越危险区的稳定性',
},
{
slotId: 'axis_c',
name: '灵运',
definition: '资源调度、法力回转与术式适配能力',
positiveSignals: ['回转', '灵感'],
negativeSignals: ['枯竭', '滞涩'],
combatUseText: '决定灵力和术式运转',
socialUseText: '决定理解复杂信息的能力',
explorationUseText: '决定破解机关与异象',
},
{
slotId: 'axis_d',
name: '机变',
definition: '借势应变、换位与局势判断能力',
positiveSignals: ['借势', '换位'],
negativeSignals: ['僵硬', '迟钝'],
combatUseText: '决定机动与变招',
socialUseText: '决定读懂弦外之音',
explorationUseText: '决定追踪与绕险',
},
{
slotId: 'axis_e',
name: '因缘',
definition: '人与人之间的牵连、信任与旧债张力',
positiveSignals: ['信任', '牵连'],
negativeSignals: ['隔阂', '背离'],
combatUseText: '决定协同与互援',
socialUseText: '决定关系推进',
explorationUseText: '决定是否能得到帮助',
},
{
slotId: 'axis_f',
name: '秘痕',
definition: '旧案、禁忌与隐秘线索的承载程度',
positiveSignals: ['旧痕', '秘线'],
negativeSignals: ['空白', '浅表'],
combatUseText: '决定异象与特殊效果',
socialUseText: '决定话题深度',
explorationUseText: '决定发现隐藏真相的能力',
},
],
settingText,
generationSeedText: generationSeedText.trim() || settingText,
creatorIntent,
generationMode: input.generationMode === 'fast' ? 'fast' : 'full',
};
}
function buildBackstoryReveal(name: string) {
function getCustomWorldGenerationTargets(
generationMode: CustomWorldGenerationMode,
) {
if (generationMode === 'fast') {
return {
playableCount: FAST_CUSTOM_WORLD_PLAYABLE_COUNT,
storyCount: FAST_CUSTOM_WORLD_STORY_COUNT,
landmarkCount: FAST_CUSTOM_WORLD_LANDMARK_COUNT,
generationStatus: 'key_only' as const,
};
}
return {
publicSummary: `${name}在表面上只露出一层足以自保的说辞。`,
privateChatUnlockAffinity: 60,
chapters: [
{
id: `${slugify(name)}-surface`,
title: '表层来意',
affinityRequired: 15,
teaser: `${name}对你仍留着一层试探。`,
content: `${name}先承认自己并非偶然出现在这里,而是被同一场异动推到了前线。`,
contextSnippet: `${name}的真正来意还没有完全摊开。`,
},
{
id: `${slugify(name)}-scar`,
title: '旧事裂痕',
affinityRequired: 30,
teaser: `${name}提到过一次不愿重说的旧伤。`,
content: `${name}曾在上一轮风暴里失去过重要的人,因此对类似局势格外警觉。`,
contextSnippet: `${name}和旧案之间存在未平的裂痕。`,
},
{
id: `${slugify(name)}-hidden`,
title: '隐藏执念',
affinityRequired: 60,
teaser: `${name}其实一直在盯着更深一层的线索。`,
content: `${name}真正想追索的不是眼前纷乱本身,而是它背后那只一直没露面的手。`,
contextSnippet: `${name}的行动始终绕着一条更深的暗线。`,
},
{
id: `${slugify(name)}-final`,
title: '最终底牌',
affinityRequired: 90,
teaser: `${name}手里一直留着最后一道底牌。`,
content: `${name}早就为最坏结局准备了最后的应对,但不到绝境绝不会轻易亮出。`,
contextSnippet: `${name}仍保留着能改写局面的最后筹码。`,
},
],
playableCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
storyCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
landmarkCount: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
generationStatus: 'complete' as const,
};
}
function buildSkills(name: string) {
return [
{
id: `${slugify(name)}-skill-1`,
name: `${name}起手`,
summary: '先用短促动作压住眼前节奏。',
style: '起手压制',
},
{
id: `${slugify(name)}-skill-2`,
name: `${name}变招`,
summary: '在试探后迅速换位改势。',
style: '机动周旋',
},
{
id: `${slugify(name)}-skill-3`,
name: `${name}底牌`,
summary: '在局势逼紧时打出保留手段。',
style: '爆发终结',
},
];
}
function buildInitialItems(name: string) {
return [
{
id: `${slugify(name)}-item-1`,
name: `${name}常备武具`,
category: '武器',
quantity: 1,
rarity: 'rare',
description: '随身不离手的主战物件。',
tags: ['战斗', '随身'],
},
{
id: `${slugify(name)}-item-2`,
name: `${name}补给包`,
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '为了久战和撤离准备的基础补给。',
tags: ['补给', '行动'],
},
{
id: `${slugify(name)}-item-3`,
name: `${name}私人物件`,
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '不愿轻易交出的旧信物。',
tags: ['信物', '线索'],
},
];
}
function buildPlayableNpcs(seed: string) {
return PLAYABLE_ROLE_TEMPLATES.map((template, index) => {
const name = `${seed.slice(0, 2) || '裂潮'}${['岚', '砺', '遥', '烛', '澜'][index]}`;
return {
id: `playable-npc-${index + 1}`,
name,
title: template.title,
role: template.role,
description: `${name}习惯先观察再出手,对局势变化反应极快。`,
backstory: `${name}长期在风暴边缘活动,对眼前这场失衡局势并不陌生。`,
personality: '谨慎、沉稳、保留余地',
motivation: '想先查清是谁把局势推到这一步。',
combatStyle: template.style,
initialAffinity: 18 + index * 4,
relationshipHooks: ['共同求生', '交换情报'],
tags: [...template.tags],
backstoryReveal: buildBackstoryReveal(name),
skills: buildSkills(name),
initialItems: buildInitialItems(name),
templateCharacterId: ['sword-princess', 'archer-hero', 'girl-hero', 'punch-hero', 'fighter-4'][index],
};
});
}
function buildStoryNpcs(seed: string) {
return Array.from({ length: 25 }, (_, index) => {
const template = STORY_ROLE_TEMPLATES[index % STORY_ROLE_TEMPLATES.length]!;
const name = `${seed.slice(0, 2) || '裂潮'}${['青', '玄', '沉', '洛', '霁'][index % 5]}${index + 1}`;
return {
id: `story-npc-${index + 1}`,
name,
title: `${index + 1}位见证者`,
role: template.role,
description: `${name}始终在观察这场异动会把谁先逼到台前。`,
backstory: `${name}和这片地界的旧事牵连很深,只是还没有把来历说透。`,
personality: '警觉、克制、善于藏话',
motivation: '想确认这轮动荡背后真正的引线。',
combatStyle: template.danger === 'high' ? '先压后断' : '先试后动',
initialAffinity: template.danger === 'high' ? -12 : 6 + (index % 3) * 6,
relationshipHooks: ['旧案牵连', '局势试探'],
tags: [...template.tags],
backstoryReveal: buildBackstoryReveal(name),
skills: buildSkills(name),
initialItems: buildInitialItems(name),
};
});
}
function buildLandmarks(seed: string, storyNpcIds: string[]) {
return LANDMARK_TEMPLATES.map((baseName, index, all) => {
const name = `${seed.slice(0, 2) || '裂潮'}${baseName}`;
return {
id: `landmark-${index + 1}`,
name,
description: `${name}附近同时压着旧痕、异动与尚未收束的危险。`,
dangerLevel: index < 3 ? 'medium' : index < 7 ? 'high' : 'extreme',
sceneNpcIds: [
storyNpcIds[index % storyNpcIds.length],
storyNpcIds[(index + 7) % storyNpcIds.length],
storyNpcIds[(index + 13) % storyNpcIds.length],
],
connections: [
{
targetLandmarkId: `landmark-${((index + 1) % all.length) + 1}`,
relativePosition: 'forward',
summary: '沿着当前道路继续前推就能抵达。',
},
{
targetLandmarkId: `landmark-${((index + all.length - 1) % all.length) + 1}`,
relativePosition: 'back',
summary: '沿原路回撤可以折返到上一处节点。',
},
],
};
});
}
function buildProgress(
phaseId: string,
phaseLabel: string,
phaseDetail: string,
overallProgress: number,
activeStepIndex: number,
startedAt: number,
): CustomWorldGenerationProgress {
const steps = [
{ id: 'framework', label: '世界框架', detail: '整理世界基础骨架。', status: overallProgress >= 0.25 ? 'completed' : phaseId === 'framework' ? 'active' : 'pending', completed: overallProgress >= 0.25 ? 1 : 0, total: 1 },
{ id: 'roles', label: '角色群像', detail: '生成可玩与场景角色。', status: overallProgress >= 0.6 ? 'completed' : phaseId === 'roles' ? 'active' : 'pending', completed: overallProgress >= 0.6 ? 1 : 0, total: 1 },
{ id: 'landmarks', label: '场景网络', detail: '生成地标与连接关系。', status: overallProgress >= 0.85 ? 'completed' : phaseId === 'landmarks' ? 'active' : 'pending', completed: overallProgress >= 0.85 ? 1 : 0, total: 1 },
{ id: 'finalize', label: '最终归档', detail: '整理最终世界资料。', status: overallProgress >= 1 ? 'completed' : phaseId === 'finalize' ? 'active' : 'pending', completed: overallProgress >= 1 ? 1 : 0, total: 1 },
] as CustomWorldGenerationProgress['steps'];
return {
phaseId,
phaseLabel,
phaseDetail,
overallProgress,
completedWeight: Math.round(overallProgress * 100),
totalWeight: 100,
elapsedMs: nowMs() - startedAt,
estimatedRemainingMs: overallProgress >= 1 ? 0 : Math.max(1000, Math.round((1 - overallProgress) * 4000)),
activeStepIndex,
steps,
};
}
function inferMajorFactions(seed: string) {
return [
`${seed.slice(0, 2) || '裂潮'}守桥司`,
`${seed.slice(0, 2) || '裂潮'}旧案会`,
`${seed.slice(0, 2) || '裂潮'}商旅盟`,
];
}
function inferCoreConflicts(seedText: string) {
const core = seedText.slice(0, 24) || '旧秩序与新威胁的失衡';
return [
`围绕“${core}”的旧秩序正在松动。`,
'各方都在争夺谁来解释眼前的异变。',
'真正推动局势的人始终没有完全现身。',
];
}
function buildDeterministicProfile(input: GenerateCustomWorldProfileInput) {
const setting = seedText(input);
const worldType = inferWorldType(setting);
const seed = setting.replace(/\s+/g, '').slice(0, 6) || (worldType === 'XIANXIA' ? '云潮' : '裂潮');
const playableNpcs = buildPlayableNpcs(seed);
const storyNpcs = buildStoryNpcs(seed);
const landmarks = buildLandmarks(
seed,
storyNpcs.map((npc) => npc.id),
function createCustomWorldGenerationReporter(
onProgress?: (progress: CustomWorldGenerationProgress) => void,
) {
const startedAt = nowMs();
const completedByStage = Object.fromEntries(
CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((stage) => [stage.id, 0]),
) as Record<CustomWorldGenerationStageId, number>;
const totalWeight = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce(
(sum, stage) => sum + stage.weight,
0,
);
const emit = (
stageId: CustomWorldGenerationStageId,
options: Partial<{
completed: number;
phaseDetail: string;
}> = {},
) => {
const stage = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.find(
(item) => item.id === stageId,
);
if (!stage) {
return;
}
if (typeof options.completed === 'number') {
completedByStage[stageId] = Math.max(
0,
Math.min(stage.total, options.completed),
);
}
const steps = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((item) => {
const completed = Math.max(
0,
Math.min(item.total, completedByStage[item.id]),
);
return {
id: item.id,
label: item.label,
detail: item.detail,
completed,
total: item.total,
status:
completed >= item.total
? 'completed'
: item.id === stageId
? 'active'
: 'pending',
} satisfies CustomWorldGenerationProgress['steps'][number];
});
const completedWeight = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce(
(sum, item) =>
sum + (completedByStage[item.id] / item.total || 0) * item.weight,
0,
);
const progressFraction = totalWeight > 0 ? completedWeight / totalWeight : 0;
const elapsedMs = Math.max(0, nowMs() - startedAt);
const estimatedRemainingMs =
progressFraction > 0 && progressFraction < 1
? Math.max(0, Math.round(elapsedMs / progressFraction - elapsedMs))
: progressFraction >= 1
? 0
: null;
onProgress?.({
phaseId: stage.id,
phaseLabel: stage.label,
phaseDetail: options.phaseDetail ?? stage.detail,
overallProgress: Math.max(
0,
Math.min(100, Math.round(progressFraction * 100)),
),
completedWeight,
totalWeight,
elapsedMs,
estimatedRemainingMs,
activeStepIndex: CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.findIndex(
(item) => item.id === stage.id,
),
steps,
});
};
return {
id: `custom-world-${Date.now().toString(36)}-${slugify(seed)}`,
settingText: setting,
name: worldType === 'XIANXIA' ? `${seed}灵境` : `${seed}边城`,
subtitle: '前路未明',
summary: `这个世界围绕“${setting.slice(0, 28)}”展开,旧秩序与新威胁正在同时逼近。`,
tone: worldType === 'XIANXIA' ? '空灵、危险、失衡' : '冷峻、紧绷、边境余震',
playerGoal: '查清眼前局势的关键矛盾,并守住仍值得相信的人与事',
templateWorldType: worldType,
compatibilityTemplateWorldType: worldType,
majorFactions: inferMajorFactions(seed),
coreConflicts: inferCoreConflicts(setting),
attributeSchema: buildAttributeSchema(worldType),
playableNpcs,
storyNpcs,
items: [],
camp: {
name: worldType === 'XIANXIA' ? `${seed}归云舍` : `${seed}归桥居`,
description: '这是玩家开局时暂时安身、整理情报与调整队伍的位置。',
dangerLevel: 'low',
begin(stageId: CustomWorldGenerationStageId, phaseDetail?: string) {
emit(stageId, {
completed: completedByStage[stageId],
phaseDetail,
});
},
landmarks,
themePack: null,
storyGraph: null,
knowledgeFacts: [],
threadContracts: [],
creatorIntent: input.creatorIntent ?? null,
anchorPack: null,
lockState: null,
ownedSettingLayers: null,
generationMode: input.generationMode ?? 'full',
generationStatus: input.generationMode === 'fast' ? 'key_only' : 'complete',
scenarioPackId: null,
campaignPackId: null,
} satisfies GeneratedProfile;
complete(stageId: CustomWorldGenerationStageId, phaseDetail?: string) {
const stage = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.find(
(item) => item.id === stageId,
);
if (!stage) {
return;
}
emit(stageId, {
completed: stage.total,
phaseDetail,
});
},
};
}
function buildCustomWorldProfilePrompt(params: {
settingText: string;
generationSeedText: string;
creatorIntent: CustomWorldCreatorIntent | null;
generationMode: CustomWorldGenerationMode;
}) {
const targets = getCustomWorldGenerationTargets(params.generationMode);
const creatorIntentText =
params.creatorIntent && hasMeaningfulCustomWorldCreatorIntent(params.creatorIntent)
? buildCustomWorldCreatorIntentGenerationText(params.creatorIntent)
: '';
return [
'请根据创作者输入,生成一个可直接进入游戏的自定义世界档案 JSON。',
'必须严格输出单个 JSON 对象,不要 Markdown不要解释。',
'',
`生成模式:${params.generationMode}`,
`可扮演角色数量:${targets.playableCount}`,
`场景角色数量:${targets.storyCount}`,
`关键场景数量:${targets.landmarkCount}`,
'',
'创作者输入:',
params.generationSeedText,
creatorIntentText ? `\n结构化创作锚点\n${creatorIntentText}` : '',
'',
'输出 JSON 字段要求:',
'- name, subtitle, summary, tone, playerGoal, templateWorldType',
'- majorFactions: string[]coreConflicts: string[]',
'- camp: { name, description, dangerLevel }',
'- playableNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
'- storyNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
'- landmarks: 每项包含 name,description,dangerLevel,sceneNpcNames,connections',
'- connections 每项包含 targetLandmarkName, relativePosition, summarytargetLandmarkName 必须指向本次输出的其他场景名',
'',
'约束:',
'- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。',
'- 角色名字、势力名、场景名必须互相区分,避免重复。',
'- sceneNpcNames 只能引用 storyNpcs 中已经输出的 name。',
'- templateWorldType 只能是 WUXIA 或 XIANXIA。',
'- dangerLevel 使用 low、medium、high、extreme 之一。',
'- relativePosition 使用 forward、back、left、right、up、down、inside、outside 之一。',
'- 不要预生成物品档案items 如需输出,必须为空数组。',
].filter(Boolean).join('\n');
}
function buildCustomWorldProfileRepairPrompt(responseText: string) {
return [
'请修复下面的自定义世界 JSON。',
'只输出能被 JSON.parse 直接解析的单个 JSON 对象,不要解释。',
responseText,
].join('\n\n');
}
async function parseCustomWorldJsonStage(params: {
llmClient: UpstreamLlmClient;
responseText: string;
signal?: AbortSignal;
}) {
throwIfCustomWorldGenerationAborted(params.signal);
try {
return parseJsonResponseText(params.responseText);
} catch {
const sanitized = sanitizeJsonLikeText(params.responseText);
if (sanitized && sanitized !== params.responseText.trim()) {
try {
return parseJsonResponseText(sanitized);
} catch {
// Fall through to model-assisted repair.
}
}
const repairedText = await params.llmClient.requestMessageContent({
systemPrompt: CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT,
userPrompt: buildCustomWorldProfileRepairPrompt(params.responseText),
signal: params.signal,
timeoutMs: 90000,
debugLabel: 'custom-world-profile-json-repair',
});
throwIfCustomWorldGenerationAborted(params.signal);
return parseJsonResponseText(sanitizeJsonLikeText(repairedText) || repairedText);
}
}
async function requestCustomWorldProfileJson(params: {
llmClient: UpstreamLlmClient;
userPrompt: string;
signal?: AbortSignal;
}) {
const responseText = await params.llmClient.requestMessageContent({
systemPrompt: CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT,
userPrompt: params.userPrompt,
signal: params.signal,
timeoutMs: CUSTOM_WORLD_REQUEST_TIMEOUT_MS,
debugLabel: 'custom-world-profile',
});
if (!responseText.trim()) {
throw new Error('自定义世界生成失败:模型没有返回有效内容。');
}
return parseCustomWorldJsonStage({
llmClient: params.llmClient,
responseText,
signal: params.signal,
});
}
function attachRuntimeGenerationMetadata(params: {
profile: CustomWorldProfile;
settingText: string;
creatorIntent: CustomWorldCreatorIntent | null;
generationMode: CustomWorldGenerationMode;
}) {
const targets = getCustomWorldGenerationTargets(params.generationMode);
return {
...params.profile,
settingText: params.settingText || params.profile.settingText,
creatorIntent: params.creatorIntent,
anchorPack:
params.profile.anchorPack ??
buildCustomWorldAnchorPackFromIntent(params.creatorIntent),
lockState:
params.profile.lockState ??
deriveCustomWorldLockStateFromIntent(params.creatorIntent),
generationMode: params.generationMode,
generationStatus: targets.generationStatus,
items: [],
} satisfies CustomWorldProfile;
}
export async function generateCustomWorldProfileFromOrchestrator(
llmClient: UpstreamLlmClient,
input: GenerateCustomWorldProfileInput,
options: {
onProgress?: (progress: CustomWorldGenerationProgress) => void;
signal?: AbortSignal;
} = {},
) {
if (options.signal?.aborted) {
throw new Error('世界生成已中断。');
const {
settingText,
generationSeedText,
creatorIntent,
generationMode,
} = resolveCustomWorldGenerationInput(input);
const reporter = createCustomWorldGenerationReporter(options.onProgress);
try {
throwIfCustomWorldGenerationAborted(options.signal);
reporter.begin('prepare', '正在整理创作者输入与结构化锚点。');
const userPrompt = buildCustomWorldProfilePrompt({
settingText,
generationSeedText,
creatorIntent,
generationMode,
});
reporter.complete('prepare', '设定上下文已整理,开始请求大模型推理。');
reporter.begin('llm-profile', '正在请求模型生成世界档案、角色群像与场景网络。');
const rawProfile = await requestCustomWorldProfileJson({
llmClient,
userPrompt,
signal: options.signal,
});
reporter.complete('llm-profile', '模型已返回世界档案,开始系统归一与运行时编译。');
reporter.begin('normalize', '正在把模型 JSON 归一为可运行的自定义世界结构。');
const expandedProfile = buildExpandedCustomWorldProfile(
{
...(rawProfile as GeneratedProfile),
settingText,
creatorIntent,
generationMode,
generationStatus: getCustomWorldGenerationTargets(generationMode)
.generationStatus,
},
generationSeedText,
);
const profile = attachRuntimeGenerationMetadata({
profile: expandedProfile,
settingText,
creatorIntent,
generationMode,
});
reporter.complete('normalize', '模型结果已完成运行时结构编译。');
reporter.begin('finalize', '正在做最终完整性校验。');
if (generationMode === 'full') {
validateGeneratedCustomWorldProfile(profile);
}
reporter.complete('finalize', `世界“${profile.name}”已完成归档。`);
return profile as unknown as GeneratedProfile;
} catch (error) {
if (isCustomWorldGenerationAbortLikeError(error) || options.signal?.aborted) {
throw error instanceof Error
? error
: new CustomWorldGenerationAbortedError();
}
if (error instanceof SyntaxError) {
throw new Error(
'自定义世界生成失败:模型返回了非严格 JSON且自动修复仍未成功请稍后重试。',
);
}
throw error;
}
const startedAt = nowMs();
options.onProgress?.(
buildProgress(
'framework',
'世界框架',
'正在整理世界基础设定与主矛盾。',
0.2,
0,
startedAt,
),
);
options.onProgress?.(
buildProgress(
'roles',
'角色群像',
'正在生成可扮演角色与场景角色骨架。',
0.55,
1,
startedAt,
),
);
options.onProgress?.(
buildProgress(
'landmarks',
'场景网络',
'正在生成地标与场景连接关系。',
0.82,
2,
startedAt,
),
);
const profile = buildDeterministicProfile(input);
options.onProgress?.(
buildProgress(
'finalize',
'最终归档',
`世界“${String(profile.name)}”已完成归档。`,
1,
3,
startedAt,
),
);
return profile;
}

View File

@@ -5,12 +5,15 @@ import type {
CharacterChatSuggestionsRequest,
} from '../../../../packages/shared/src/contracts/story.js';
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js';
import { CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT } from './chatPromptBuilders.js';
import { SYSTEM_PROMPT } from './storyPromptBuilders.js';
import {
generateCharacterChatSuggestionsFromOrchestrator,
} from './chatOrchestrator.js';
import { CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT } from './chatPromptBuilders.js';
import {
generateCustomWorldProfileFromOrchestrator,
} from './customWorldOrchestrator.js';
import { generateInitialStoryFromOrchestrator } from './storyOrchestrator.js';
import { SYSTEM_PROMPT } from './storyPromptBuilders.js';
type TestStoryContext = Parameters<typeof generateInitialStoryFromOrchestrator>[4];
type TestStoryOption = Awaited<
@@ -191,3 +194,105 @@ test('chat orchestrator builds character suggestion prompts on the server side',
assert.match(capturedPrompts[0]?.userPrompt ?? '', //u);
assert.match(capturedPrompts[0]?.userPrompt ?? '', new RegExp(payload.targetCharacter.name, 'u'));
});
test('custom world orchestrator requests LLM content before compiling the profile', async () => {
const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = [];
const storyNpcNames = Array.from(
{ length: 8 },
(_, index) => `潮灯见证者${index + 1}`,
);
const llmClient = {
requestMessageContent: async ({
systemPrompt,
userPrompt,
}: {
systemPrompt: string;
userPrompt: string;
}) => {
capturedPrompts.push({ systemPrompt, userPrompt });
return JSON.stringify({
name: '潮灯列岛',
subtitle: '雾潮之下',
summary: '旧灯塔、潮雾与沉船盟约纠缠出的列岛冒险。',
tone: '潮湿、悬疑、克制',
playerGoal: '查明潮雾为何吞掉守灯人的名字',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '沉船商盟', '潮雾祭司'],
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
camp: {
name: '旧灯塔下层',
description: '潮水退去时才露出的临时据点。',
dangerLevel: 'low',
},
playableNpcs: Array.from({ length: 3 }, (_, index) => ({
name: `守灯旅人${index + 1}`,
title: `${index + 1}盏灯`,
role: '守灯同行者',
description: '在潮雾边缘辨认灯火与人声。',
backstory: '曾经守过一座被除名的灯塔。',
personality: '谨慎、沉静、记仇',
motivation: '找回被潮雾吞掉的名字。',
combatStyle: '短刃牵制后借灯火逼退敌人。',
initialAffinity: 18,
relationshipHooks: ['守灯', '旧名'],
tags: ['潮雾', '灯塔'],
})),
storyNpcs: storyNpcNames.map((name, index) => ({
name,
title: `${index + 1}位见证者`,
role: '潮雾见证者',
description: '知道一段被潮水洗掉的航线传闻。',
backstory: '在沉船夜里听见过不该出现的钟声。',
personality: '警觉、克制',
motivation: '确认下一次潮雾会带走谁。',
combatStyle: '先试探再撤入雾中。',
initialAffinity: 6,
relationshipHooks: ['沉船夜', '钟声'],
tags: ['潮雾', '线索'],
})),
landmarks: Array.from({ length: 4 }, (_, index) => ({
name: `潮灯地标${index + 1}`,
description: '潮雾会在这里折回,留下盐痕和旧灯影。',
dangerLevel: index === 0 ? 'medium' : 'high',
sceneNpcNames: storyNpcNames.slice(index, index + 3),
connections: [
{
targetLandmarkName: `潮灯地标${(index + 1) % 4 + 1}`,
relativePosition: 'forward',
summary: '沿潮痕继续前行即可抵达下一处灯影。',
},
],
})),
items: [],
});
},
} as const;
const progressEvents: Array<{ phaseId: string; overallProgress: number }> = [];
const profile = await generateCustomWorldProfileFromOrchestrator(
llmClient as never,
{
settingText: '一个被潮雾与失落列岛切碎的边境世界。',
generationMode: 'fast',
},
{
onProgress: (progress) => {
progressEvents.push({
phaseId: progress.phaseId,
overallProgress: progress.overallProgress,
});
},
},
);
assert.equal(capturedPrompts.length, 1);
assert.match(capturedPrompts[0]?.systemPrompt ?? '', /JSON /u);
assert.match(capturedPrompts[0]?.userPrompt ?? '', /fast/u);
assert.match(capturedPrompts[0]?.userPrompt ?? '', //u);
assert.equal(profile.name, '潮灯列岛');
assert.equal(profile.generationMode, 'fast');
assert.equal(profile.generationStatus, 'key_only');
assert.equal((profile.playableNpcs as unknown[]).length, 3);
assert.ok(progressEvents.some((event) => event.phaseId === 'llm-profile'));
assert.equal(progressEvents.at(-1)?.overallProgress, 100);
});

View File

@@ -1,14 +1,15 @@
import type { QueryResultRow } from 'pg';
import {
DEFAULT_MUSIC_VOLUME,
SAVE_SNAPSHOT_VERSION,
} from '../../../packages/shared/src/contracts/runtime.js';
import type {
CustomWorldProfileRecord,
RuntimeSettings,
SavedGameSnapshot,
} from '../../../packages/shared/src/contracts/runtime.js';
import {
type CustomWorldSessionRecord,
DEFAULT_MUSIC_VOLUME,
SAVE_SNAPSHOT_VERSION,
} from '../../../packages/shared/src/contracts/runtime.js';
import type { AppDatabase } from '../db.js';
const MAX_CUSTOM_WORLD_PROFILES = 12;
@@ -29,6 +30,13 @@ type SettingsRow = QueryResultRow & {
type ProfileRow = QueryResultRow & {
payload: CustomWorldProfileRecord;
updatedAt: string;
};
type SessionRow = QueryResultRow & {
payload: CustomWorldSessionRecord;
createdAt: string;
updatedAt: string;
};
export type RuntimeRepositoryPort = {
@@ -53,6 +61,16 @@ export type RuntimeRepositoryPort = {
userId: string,
profileId: string,
): Promise<CustomWorldProfileRecord[]>;
listCustomWorldSessions(userId: string): Promise<CustomWorldSessionRecord[]>;
getCustomWorldSession(
userId: string,
sessionId: string,
): Promise<CustomWorldSessionRecord | null>;
upsertCustomWorldSession(
userId: string,
sessionId: string,
session: CustomWorldSessionRecord,
): Promise<CustomWorldSessionRecord>;
};
export class RuntimeRepository implements RuntimeRepositoryPort {
@@ -175,7 +193,8 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
async listCustomWorldProfiles(userId: string) {
const result = await this.db.query<ProfileRow>(
`SELECT payload_json AS payload
`SELECT payload_json AS payload,
updated_at AS "updatedAt"
FROM custom_world_profiles
WHERE user_id = $1
ORDER BY updated_at DESC
@@ -183,7 +202,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
[userId, MAX_CUSTOM_WORLD_PROFILES],
);
return result.rows.map((row: ProfileRow) => row.payload);
return result.rows.map((row: ProfileRow) => ({
...row.payload,
updatedAt: row.updatedAt,
}));
}
async upsertCustomWorldProfile(
@@ -217,4 +239,75 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
return this.listCustomWorldProfiles(userId);
}
async listCustomWorldSessions(userId: string) {
const result = await this.db.query<SessionRow>(
`SELECT payload_json AS payload,
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM custom_world_sessions
WHERE user_id = $1
ORDER BY updated_at DESC`,
[userId],
);
return result.rows.map((row) => ({
...row.payload,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
}
async getCustomWorldSession(userId: string, sessionId: string) {
const result = await this.db.query<SessionRow>(
`SELECT payload_json AS payload,
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM custom_world_sessions
WHERE user_id = $1 AND session_id = $2`,
[userId, sessionId],
);
const row = result.rows[0];
if (!row) {
return null;
}
return {
...row.payload,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
async upsertCustomWorldSession(
userId: string,
sessionId: string,
session: CustomWorldSessionRecord,
) {
const payload = {
...session,
sessionId,
} satisfies CustomWorldSessionRecord;
await this.db.query(
`INSERT INTO custom_world_sessions (
user_id,
session_id,
payload_json,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, session_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at`,
[userId, sessionId, payload, session.createdAt, session.updatedAt],
);
return {
...payload,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
};
}
}

View File

@@ -0,0 +1,221 @@
import { Router } from 'express';
import { z } from 'zod';
import type {
CreateCustomWorldAgentSessionRequest,
CustomWorldAgentActionRequest,
SendCustomWorldAgentMessageRequest,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { AppContext } from '../context.js';
import { badRequest, notFound } from '../errors.js';
import { asyncHandler, prepareApiResponse, sendApiResponse } from '../http.js';
import { routeMeta } from '../middleware/routeMeta.js';
const createSessionSchema = z.object({
seedText: z.string().trim().optional().default(''),
});
const sendMessageSchema = z.object({
clientMessageId: z.string().trim().min(1),
text: z.string().trim().min(1),
focusCardId: z.string().trim().nullable().optional().default(null),
selectedCardIds: z.array(z.string().trim().min(1)).optional().default([]),
});
const actionSchema = z.discriminatedUnion('action', [
z.object({
action: z.literal('draft_foundation'),
}),
z.object({
action: z.literal('update_draft_card'),
cardId: z.string().trim().min(1),
sections: z
.array(
z.object({
sectionId: z.string().trim().min(1),
value: z.string(),
}),
)
.min(1),
}),
z.object({
action: z.literal('generate_characters'),
count: z.number().int().min(1).max(3),
promptText: z.string().trim().nullable().optional().default(null),
anchorCardIds: z.array(z.string().trim().min(1)).optional().default([]),
}),
z.object({
action: z.literal('generate_landmarks'),
count: z.number().int().min(1).max(3),
promptText: z.string().trim().nullable().optional().default(null),
anchorCardIds: z.array(z.string().trim().min(1)).optional().default([]),
}),
z.object({
action: z.literal('generate_role_assets'),
roleIds: z.array(z.string().trim().min(1)).min(1),
}),
z.object({
action: z.literal('sync_role_assets'),
roleId: z.string().trim().min(1),
portraitPath: z.string().trim().min(1),
generatedVisualAssetId: z.string().trim().min(1),
generatedAnimationSetId: z.string().trim().nullable().optional(),
animationMap: z.record(z.string(), z.unknown()).nullable().optional(),
}),
z.object({
action: z.literal('publish_world'),
}),
]);
function readParam(param: string | string[] | undefined) {
return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || '';
}
export function createCustomWorldAgentRoutes(context: AppContext) {
const router = Router();
router.post(
'/sessions',
routeMeta({ operation: 'runtime.customWorldAgent.createSession' }),
asyncHandler(async (request, response) => {
const payload = createSessionSchema.parse(
request.body,
) as CreateCustomWorldAgentSessionRequest;
sendApiResponse(response, {
session: await context.customWorldAgentOrchestrator.createSession(
request.userId!,
payload,
),
});
}),
);
router.get(
'/sessions/:sessionId',
routeMeta({ operation: 'runtime.customWorldAgent.getSession' }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
const session = await context.customWorldAgentOrchestrator.getSessionSnapshot(
request.userId!,
sessionId,
);
if (!session) {
throw notFound('custom world agent session not found');
}
sendApiResponse(response, session);
}),
);
router.post(
'/sessions/:sessionId/messages',
routeMeta({ operation: 'runtime.customWorldAgent.sendMessage' }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
const payload = sendMessageSchema.parse(
request.body,
) as SendCustomWorldAgentMessageRequest;
sendApiResponse(
response,
await context.customWorldAgentOrchestrator.submitMessage(
request.userId!,
sessionId,
payload,
),
);
}),
);
router.post(
'/sessions/:sessionId/actions',
routeMeta({ operation: 'runtime.customWorldAgent.executeAction' }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
const payload = actionSchema.parse(
request.body,
) as CustomWorldAgentActionRequest;
sendApiResponse(
response,
await context.customWorldAgentOrchestrator.executeAction(
request.userId!,
sessionId,
payload,
),
);
}),
);
router.get(
'/sessions/:sessionId/operations/:operationId',
routeMeta({ operation: 'runtime.customWorldAgent.getOperation' }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
const operationId = readParam(request.params.operationId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
if (!operationId) {
throw badRequest('operationId is required');
}
const operation = await context.customWorldAgentOrchestrator.getOperation(
request.userId!,
sessionId,
operationId,
);
if (!operation) {
throw notFound('custom world agent operation not found');
}
prepareApiResponse(request, response, {
statusCode: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
});
response.end(JSON.stringify({ operation }));
}),
);
router.get(
'/sessions/:sessionId/cards/:cardId',
routeMeta({ operation: 'runtime.customWorldAgent.getCardDetail' }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
const cardId = readParam(request.params.cardId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
if (!cardId) {
throw badRequest('cardId is required');
}
const card = await context.customWorldAgentOrchestrator.getCardDetail(
request.userId!,
sessionId,
cardId,
);
if (!card) {
throw notFound('custom world agent card not found');
}
sendApiResponse(response, {
card,
});
}),
);
return router;
}

View File

@@ -1,6 +1,7 @@
import { Router } from 'express';
import { z } from 'zod';
import type { ListCustomWorldWorksResponse } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type {
AnswerCustomWorldSessionQuestionRequest,
CreateCustomWorldSessionRequest,
@@ -27,6 +28,8 @@ import {
prepareEventStreamResponse,
sendApiResponse,
} from '../http.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { routeMeta } from '../middleware/routeMeta.js';
import {
generateCharacterChatSuggestionsFromOrchestrator,
generateCharacterChatSummaryFromOrchestrator,
@@ -34,8 +37,6 @@ import {
streamNpcChatDialogueFromOrchestrator,
streamNpcRecruitDialogueFromOrchestrator,
} from '../modules/ai/chatOrchestrator.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { routeMeta } from '../middleware/routeMeta.js';
import {
hydrateSavedSnapshot,
normalizeSavedSnapshotPayload,
@@ -48,6 +49,9 @@ import {
npcRecruitDialogueRequestSchema,
} from '../services/chatService.js';
import { generateCustomWorldProfile } from '../services/customWorldGenerationService.js';
import {
listCustomWorldWorkSummaries,
} from '../services/customWorldWorkSummaryService.js';
import { generateQuestForNpcEncounter } from '../services/questService.js';
import { generateRuntimeItemIntents } from '../services/runtimeItemService.js';
import {
@@ -59,6 +63,7 @@ import {
generateHighQualityNextStory,
parseStoryRequest,
} from '../services/storyService.js';
import { createCustomWorldAgentRoutes } from './customWorldAgent.js';
const jsonObjectSchema = z.record(z.string(), z.unknown());
@@ -109,6 +114,10 @@ export function createRuntimeRoutes(context: AppContext) {
const requireAuth = requireJwtAuth(context.config, context.userRepository);
router.use(requireAuth);
router.use(
'/runtime/custom-world/agent',
createCustomWorldAgentRoutes(context),
);
router.post(
'/llm/chat/completions',
@@ -198,6 +207,19 @@ export function createRuntimeRoutes(context: AppContext) {
}),
);
router.get(
'/runtime/custom-world/works',
routeMeta({ operation: 'runtime.customWorldWorks.list' }),
asyncHandler(async (request, response) => {
sendApiResponse<ListCustomWorldWorksResponse>(response, {
items: await listCustomWorldWorkSummaries(request.userId!, {
runtimeRepository: context.runtimeRepository,
customWorldAgentSessions: context.customWorldAgentSessions,
}),
});
}),
);
router.get(
'/runtime/custom-world-library',
routeMeta({ operation: 'runtime.customWorldLibrary.list' }),
@@ -356,7 +378,7 @@ export function createRuntimeRoutes(context: AppContext) {
) as CreateCustomWorldSessionRequest;
sendApiResponse(
response,
context.customWorldSessions.create(
await context.customWorldSessions.create(
request.userId!,
payload.settingText,
payload.creatorIntent,
@@ -370,7 +392,7 @@ export function createRuntimeRoutes(context: AppContext) {
'/runtime/custom-world/sessions/:sessionId',
routeMeta({ operation: 'runtime.customWorldSession.get' }),
asyncHandler(async (request, response) => {
const session = context.customWorldSessions.get(
const session = await context.customWorldSessions.get(
request.userId!,
readParam(request.params.sessionId),
);
@@ -388,7 +410,7 @@ export function createRuntimeRoutes(context: AppContext) {
const payload = customWorldAnswerSchema.parse(
request.body,
) as AnswerCustomWorldSessionQuestionRequest;
const session = context.customWorldSessions.answer(
const session = await context.customWorldSessions.answer(
request.userId!,
readParam(request.params.sessionId),
payload.questionId,
@@ -405,7 +427,7 @@ export function createRuntimeRoutes(context: AppContext) {
'/runtime/custom-world/sessions/:sessionId/generate/stream',
routeMeta({ operation: 'runtime.customWorldSession.generateStream' }),
asyncHandler(async (request, response) => {
const session = context.customWorldSessions.get(
const session = await context.customWorldSessions.get(
request.userId!,
readParam(request.params.sessionId),
);
@@ -426,7 +448,7 @@ export function createRuntimeRoutes(context: AppContext) {
};
writeEvent('progress', { phase: 'preparing', progress: 10 });
context.customWorldSessions.updateStatus(
await context.customWorldSessions.updateStatus(
request.userId!,
readParam(request.params.sessionId),
'generating',
@@ -443,7 +465,7 @@ export function createRuntimeRoutes(context: AppContext) {
);
},
});
context.customWorldSessions.setResult(
await context.customWorldSessions.setResult(
request.userId!,
readParam(request.params.sessionId),
profile,
@@ -456,7 +478,7 @@ export function createRuntimeRoutes(context: AppContext) {
error instanceof Error
? error.message
: 'custom world generation failed';
context.customWorldSessions.updateStatus(
await context.customWorldSessions.updateStatus(
request.userId!,
readParam(request.params.sessionId),
'generation_error',

View File

@@ -5,16 +5,18 @@ import { type AppConfig, loadConfig } from './config.js';
import type { AppContext } from './context.js';
import { createDatabase } from './db.js';
import { createLogger } from './logging.js';
import { AuthIdentityRepository } from './repositories/authIdentityRepository.js';
import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js';
import { AuthIdentityRepository } from './repositories/authIdentityRepository.js';
import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js';
import { RuntimeRepository } from './repositories/runtimeRepository.js';
import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js';
import { UserRepository } from './repositories/userRepository.js';
import { UserSessionRepository } from './repositories/userSessionRepository.js';
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js';
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
import { UpstreamLlmClient } from './services/llmClient.js';
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
import { createSmsVerificationService } from './services/smsVerificationService.js';
import { createWechatAuthService } from './services/wechatAuthService.js';
import { WechatAuthStateStore } from './services/wechatAuthStateStore.js';
@@ -77,6 +79,10 @@ function describeDatabase(databaseUrl: string) {
export async function createAppContext(config: AppConfig = loadConfig()) {
const logger = createLogger(config);
const db = await createDatabase(config);
const runtimeRepository = new RuntimeRepository(db);
const customWorldAgentSessions = new CustomWorldAgentSessionStore(
runtimeRepository,
);
const context: AppContext = {
config,
logger,
@@ -87,9 +93,16 @@ export async function createAppContext(config: AppConfig = loadConfig()) {
authRiskBlockRepository: new AuthRiskBlockRepository(db),
smsAuthEventRepository: new SmsAuthEventRepository(db),
userSessionRepository: new UserSessionRepository(db),
runtimeRepository: new RuntimeRepository(db),
runtimeRepository,
llmClient: new UpstreamLlmClient(config, logger),
customWorldSessions: new CustomWorldSessionStore(),
customWorldSessions: new CustomWorldSessionStore(runtimeRepository),
customWorldAgentSessions,
customWorldAgentOrchestrator: new CustomWorldAgentOrchestrator(
customWorldAgentSessions,
config.llm.apiKey.trim()
? new UpstreamLlmClient(config, logger)
: null,
),
smsVerificationService: createSmsVerificationService(config, logger),
wechatAuthService: createWechatAuthService(config, logger),
wechatAuthStates: new WechatAuthStateStore(),

View File

@@ -0,0 +1,99 @@
import type { CustomWorldRoleAssetSummary } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import {
getRoleAssetSummaryById,
mergeRoleAssetIntoDraftProfile,
} from './customWorldAgentRoleAssetStateService.js';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
)
: [];
}
type SyncRoleAssetsPayload = {
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
};
export type SyncRoleAssetsResult = {
roleId: string;
updatedRole: Record<string, unknown>;
updatedAssetSummary: CustomWorldRoleAssetSummary;
draftProfile: Record<string, unknown>;
};
export class CustomWorldAgentAssetBridgeService {
buildRoleAssetStudioContext(snapshot: unknown, roleId: string) {
const profile = toRecord(snapshot);
if (!profile) {
throw new Error('当前世界草稿为空,无法打开角色资产工坊。');
}
const playableRole = toRecordArray(profile.playableNpcs).find(
(item) => toText(item.id) === roleId,
);
const storyRole = toRecordArray(profile.storyNpcs).find(
(item) => toText(item.id) === roleId,
);
const role = playableRole ?? storyRole;
if (!role) {
throw new Error('未找到目标角色,无法进入角色资产工坊。');
}
const assetSummary = getRoleAssetSummaryById(profile, roleId);
if (!assetSummary) {
throw new Error('未找到目标角色的资产摘要。');
}
return {
roleId,
roleName: toText(role.name) || assetSummary.roleName,
roleKind: playableRole ? ('playable' as const) : ('story' as const),
startFrom:
assetSummary.status === 'missing' ? ('visual' as const) : ('animation' as const),
assetSummary,
};
}
applyRoleAssetPublishResult(
snapshot: unknown,
payload: SyncRoleAssetsPayload,
): SyncRoleAssetsResult {
const profile = toRecord(snapshot);
if (!profile) {
throw new Error('当前世界草稿为空,无法同步角色资产。');
}
const { draftProfile, updatedRole } = mergeRoleAssetIntoDraftProfile(
profile,
payload,
);
const assetSummary = getRoleAssetSummaryById(draftProfile, payload.roleId);
if (!assetSummary) {
throw new Error('角色资产同步后未能生成新的资产摘要。');
}
return {
roleId: payload.roleId,
updatedRole,
updatedAssetSummary: assetSummary,
draftProfile,
};
}
}

View File

@@ -0,0 +1,91 @@
import {
getWorldFoundationCardId,
normalizeFoundationDraftProfile,
} from './customWorldAgentDraftCompiler.js';
type BuildDraftChangeSummaryParams =
| {
action: 'update_draft_card';
cardId: string;
changedLabels: string[];
draftProfile: Record<string, unknown>;
}
| {
action: 'generate_characters';
names: string[];
draftProfile: Record<string, unknown>;
}
| {
action: 'generate_landmarks';
names: string[];
draftProfile: Record<string, unknown>;
};
function resolveTotalCharacterCount(
profile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
) {
return [...new Set([...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id))]
.length;
}
function resolveCardTitle(
draftProfile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
cardId: string,
) {
if (cardId === getWorldFoundationCardId()) {
return draftProfile.name;
}
return (
draftProfile.factions.find((entry) => entry.id === cardId)?.title ||
draftProfile.factions.find((entry) => entry.id === cardId)?.name ||
[...draftProfile.playableNpcs, ...draftProfile.storyNpcs].find(
(entry) => entry.id === cardId,
)?.name ||
draftProfile.landmarks.find((entry) => entry.id === cardId)?.name ||
draftProfile.threads.find((entry) => entry.id === cardId)?.title ||
draftProfile.chapters.find((entry) => entry.id === cardId)?.title ||
(draftProfile.camp?.id === cardId ? draftProfile.camp.name : '') ||
'当前卡片'
);
}
export class CustomWorldAgentChangeSummaryService {
buildSummary(params: BuildDraftChangeSummaryParams) {
const draftProfile = normalizeFoundationDraftProfile(params.draftProfile);
if (!draftProfile) {
return '这次改动已经写回草稿。';
}
const characterCount = resolveTotalCharacterCount(draftProfile);
const landmarkCount = draftProfile.landmarks.length;
if (params.action === 'update_draft_card') {
const title = resolveCardTitle(draftProfile, params.cardId);
const changedLabelText =
params.changedLabels.length > 0
? params.changedLabels.slice(0, 4).join('、')
: '核心字段';
return [
`已更新「${title}」的 ${changedLabelText}`,
`当前底稿里共有 ${characterCount} 个角色、${landmarkCount} 个地点。`,
'下一步建议顺着这张卡直接检查它牵动的线程或地点。',
].join('\n');
}
if (params.action === 'generate_characters') {
return [
`已补出 ${params.names.length} 个新角色:${params.names.join('、')}`,
`当前底稿里共有 ${characterCount} 个角色、${landmarkCount} 个地点。`,
'下一步建议先点开新角色卡,把玩家关系和关联线程收紧一轮。',
].join('\n');
}
return [
`已补出 ${params.names.length} 个新地点:${params.names.join('、')}`,
`当前底稿里共有 ${characterCount} 个角色、${landmarkCount} 个地点。`,
'下一步建议先点开新地点卡,把线程挂钩和场景气质收紧一轮。',
].join('\n');
}
}

View File

@@ -0,0 +1,161 @@
import type {
CreatorIntentReadiness,
CustomWorldPendingClarification,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { CustomWorldAgentStage } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { CustomWorldCreatorIntentRecord } from './customWorldAgentIntentExtractionService.js';
type CreatorIntentReadinessKey =
| 'world_hook'
| 'player_premise'
| 'theme_and_tone'
| 'core_conflict'
| 'relationship_seed'
| 'iconic_element';
const CLARIFICATION_DEFINITIONS: Array<{
targetKey: CreatorIntentReadinessKey;
priority: number;
label: string;
question: string;
}> = [
{
targetKey: 'world_hook',
priority: 1,
label: '世界一句话',
question: '先用一句话收住这个世界最独特的核心幻想,我会据此继续往下补。',
},
{
targetKey: 'player_premise',
priority: 2,
label: '玩家身份与开局',
question:
'玩家是谁,故事开场时卡在什么处境里?你可以把身份和开局困境一起告诉我。',
},
{
targetKey: 'core_conflict',
priority: 3,
label: '核心冲突',
question:
'现在推动这个世界往前走的主要冲突是什么?最好是能立刻形成剧情压力的那种。',
},
{
targetKey: 'theme_and_tone',
priority: 4,
label: '主题气质',
question:
'它整体更偏什么主题和气质?比如冷峻、压迫、浪漫、潮湿,也可以顺手告诉我不要什么。',
},
{
targetKey: 'relationship_seed',
priority: 5,
label: '关键关系钩子',
question:
'给我一个关键人物种子就行,他和玩家是什么关系,或者他藏着什么暗线?',
},
{
targetKey: 'iconic_element',
priority: 6,
label: '标志性要素',
question: '这个世界至少给我 1 个一眼能认出来的标志性元素、机制或意象。',
},
];
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
export function evaluateCreatorIntentReadiness(
intent: CustomWorldCreatorIntentRecord | null | undefined,
): CreatorIntentReadiness {
const completedKeys: CreatorIntentReadinessKey[] = [];
const missingKeys: CreatorIntentReadinessKey[] = [];
const relationshipReady =
intent?.keyCharacters.some(
(entry) =>
Boolean(toText(entry.name)) &&
Boolean(toText(entry.relationToPlayer) || toText(entry.hiddenHook)),
) ?? false;
const keyChecks: Array<{
key: CreatorIntentReadinessKey;
ready: boolean;
}> = [
{
key: 'world_hook',
ready:
(intent?.worldHook.trim().length ?? 0) >= 8 ||
(intent?.rawSettingText.trim().length ?? 0) >= 24,
},
{
key: 'player_premise',
ready: Boolean(
intent?.playerPremise.trim() && intent?.openingSituation.trim(),
),
},
{
key: 'theme_and_tone',
ready:
(intent?.themeKeywords.length ?? 0) >= 1 &&
(intent?.toneDirectives.length ?? 0) >= 1,
},
{
key: 'core_conflict',
ready: (intent?.coreConflicts.length ?? 0) >= 1,
},
{
key: 'relationship_seed',
ready: (intent?.keyCharacters.length ?? 0) >= 1 && relationshipReady,
},
{
key: 'iconic_element',
ready: (intent?.iconicElements.length ?? 0) >= 1,
},
];
keyChecks.forEach((entry) => {
if (entry.ready) {
completedKeys.push(entry.key);
return;
}
missingKeys.push(entry.key);
});
return {
isReady: missingKeys.length === 0,
completedKeys,
missingKeys,
};
}
export function buildPendingClarifications(
intent: CustomWorldCreatorIntentRecord | null | undefined,
readiness = evaluateCreatorIntentReadiness(intent),
) {
return CLARIFICATION_DEFINITIONS.filter((entry) =>
readiness.missingKeys.includes(entry.targetKey),
)
.sort((left, right) => left.priority - right.priority)
.slice(0, 1)
.map(
(entry): CustomWorldPendingClarification => ({
id: entry.targetKey,
label: entry.label,
question: entry.question,
targetKey: entry.targetKey,
priority: entry.priority,
}),
);
}
export function resolveCreatorIntentStage(params: {
hasUserInput: boolean;
readiness: CreatorIntentReadiness;
}): CustomWorldAgentStage {
if (params.readiness.isReady) {
return 'foundation_review';
}
return params.hasUserInput ? 'clarifying' : 'collecting_intent';
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,322 @@
import { badRequest, notFound } from '../errors.js';
import {
getWorldFoundationCardId,
normalizeFoundationDraftProfile,
} from './customWorldAgentDraftCompiler.js';
type DraftSectionPatch = {
sectionId: string;
value: string;
};
export type UpdateDraftCardSectionsParams = {
draftProfile: Record<string, unknown>;
cardId: string;
sections: DraftSectionPatch[];
};
const EDITABLE_SECTION_IDS = {
world: new Set(['title', 'subtitle', 'summary', 'playerGoal', 'tone', 'coreConflicts']),
faction: new Set(['title', 'subtitle', 'summary', 'publicGoal', 'tension']),
character: new Set(['name', 'role', 'publicMask', 'hiddenHook', 'relationToPlayer', 'summary']),
landmark: new Set(['name', 'purpose', 'mood', 'secret', 'summary']),
thread: new Set(['title', 'summary', 'conflictType', 'stakes']),
chapter: new Set(['title', 'summary', 'openingEvent', 'playerGoal', 'understandingShift']),
camp: new Set(['name', 'description', 'dangerLevel']),
} as const;
function normalizePatches(sections: DraftSectionPatch[]) {
const normalized = sections
.map((section) => ({
sectionId: section.sectionId.trim(),
value: section.value.trim(),
}))
.filter((section) => section.sectionId);
if (normalized.length === 0) {
throw badRequest('update_draft_card requires at least one section patch');
}
const deduped = new Map<string, string>();
normalized.forEach((section) => {
deduped.set(section.sectionId, section.value);
});
return [...deduped.entries()].map(([sectionId, value]) => ({
sectionId,
value,
}));
}
function parseStringList(value: string) {
return [...new Set(value.split(/[\n;]+/u).map((item) => item.trim()).filter(Boolean))];
}
function resolveThreadType(value: string) {
if (value.includes('暗') || value.toLowerCase() === 'hidden') {
return 'hidden' as const;
}
return 'main' as const;
}
export function updateDraftCardSections(params: UpdateDraftCardSectionsParams) {
const draftProfile = normalizeFoundationDraftProfile(params.draftProfile);
if (!draftProfile) {
throw badRequest('draftProfile is empty');
}
const patches = normalizePatches(params.sections);
const worldCardId = getWorldFoundationCardId();
if (params.cardId === worldCardId) {
patches.forEach(({ sectionId, value }) => {
if (!EDITABLE_SECTION_IDS.world.has(sectionId as never)) {
throw badRequest(`section ${sectionId} is not editable for world`);
}
if (sectionId === 'title') {
draftProfile.name = value;
return;
}
if (sectionId === 'subtitle') {
draftProfile.subtitle = value;
return;
}
if (sectionId === 'summary') {
draftProfile.summary = value;
return;
}
if (sectionId === 'playerGoal') {
draftProfile.playerGoal = value;
return;
}
if (sectionId === 'tone') {
draftProfile.tone = value;
return;
}
if (sectionId === 'coreConflicts') {
draftProfile.coreConflicts = parseStringList(value);
}
});
return draftProfile as unknown as Record<string, unknown>;
}
const faction = draftProfile.factions.find((entry) => entry.id === params.cardId);
if (faction) {
patches.forEach(({ sectionId, value }) => {
if (!EDITABLE_SECTION_IDS.faction.has(sectionId as never)) {
throw badRequest(`section ${sectionId} is not editable for faction`);
}
if (sectionId === 'title') {
faction.name = value;
faction.title = value;
return;
}
if (sectionId === 'subtitle') {
faction.subtitle = value;
return;
}
if (sectionId === 'summary') {
faction.summary = value;
return;
}
if (sectionId === 'publicGoal') {
faction.publicGoal = value;
return;
}
if (sectionId === 'tension') {
faction.tension = value;
faction.relatedConflict = value;
}
});
return draftProfile as unknown as Record<string, unknown>;
}
const character = [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].find(
(entry) => entry.id === params.cardId,
);
if (character) {
patches.forEach(({ sectionId, value }) => {
if (!EDITABLE_SECTION_IDS.character.has(sectionId as never)) {
throw badRequest(`section ${sectionId} is not editable for character`);
}
if (sectionId === 'name') {
character.name = value;
return;
}
if (sectionId === 'role') {
character.role = value;
character.title = value;
return;
}
if (sectionId === 'publicMask') {
character.publicMask = value;
character.publicIdentity = value;
return;
}
if (sectionId === 'hiddenHook') {
character.hiddenHook = value;
character.currentPressure = value;
return;
}
if (sectionId === 'relationToPlayer') {
character.relationToPlayer = value;
return;
}
if (sectionId === 'summary') {
character.summary = value;
}
});
return draftProfile as unknown as Record<string, unknown>;
}
const landmark = draftProfile.landmarks.find((entry) => entry.id === params.cardId);
if (landmark) {
patches.forEach(({ sectionId, value }) => {
if (!EDITABLE_SECTION_IDS.landmark.has(sectionId as never)) {
throw badRequest(`section ${sectionId} is not editable for landmark`);
}
if (sectionId === 'name') {
landmark.name = value;
return;
}
if (sectionId === 'purpose') {
landmark.purpose = value;
return;
}
if (sectionId === 'mood') {
landmark.mood = value;
return;
}
if (sectionId === 'secret') {
landmark.secret = value;
landmark.importance = value;
return;
}
if (sectionId === 'summary') {
landmark.summary = value;
}
});
return draftProfile as unknown as Record<string, unknown>;
}
const thread = draftProfile.threads.find((entry) => entry.id === params.cardId);
if (thread) {
patches.forEach(({ sectionId, value }) => {
if (!EDITABLE_SECTION_IDS.thread.has(sectionId as never)) {
throw badRequest(`section ${sectionId} is not editable for thread`);
}
if (sectionId === 'title') {
thread.title = value;
return;
}
if (sectionId === 'summary') {
thread.summary = value;
return;
}
if (sectionId === 'conflictType') {
thread.conflictType = value;
thread.type = resolveThreadType(value);
return;
}
if (sectionId === 'stakes') {
thread.stakes = value;
thread.conflict = value;
}
});
return draftProfile as unknown as Record<string, unknown>;
}
const chapter = draftProfile.chapters.find((entry) => entry.id === params.cardId);
if (chapter) {
patches.forEach(({ sectionId, value }) => {
if (!EDITABLE_SECTION_IDS.chapter.has(sectionId as never)) {
throw badRequest(`section ${sectionId} is not editable for chapter`);
}
if (sectionId === 'title') {
chapter.title = value;
return;
}
if (sectionId === 'summary') {
chapter.summary = value;
return;
}
if (sectionId === 'openingEvent') {
chapter.openingEvent = value;
return;
}
if (sectionId === 'playerGoal') {
chapter.playerGoal = value;
return;
}
if (sectionId === 'understandingShift') {
chapter.understandingShift = value;
}
});
return draftProfile as unknown as Record<string, unknown>;
}
if (draftProfile.camp?.id === params.cardId) {
patches.forEach(({ sectionId, value }) => {
if (!EDITABLE_SECTION_IDS.camp.has(sectionId as never)) {
throw badRequest(`section ${sectionId} is not editable for camp`);
}
if (sectionId === 'name') {
draftProfile.camp!.name = value;
return;
}
if (sectionId === 'description') {
draftProfile.camp!.description = value;
return;
}
if (sectionId === 'dangerLevel') {
draftProfile.camp!.dangerLevel = value;
draftProfile.camp!.mood = value;
}
});
return draftProfile as unknown as Record<string, unknown>;
}
throw notFound('draft card not found');
}

View File

@@ -0,0 +1,651 @@
import type {
CustomWorldFoundationDraftCharacter,
CustomWorldFoundationDraftLandmark,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import { badRequest } from '../errors.js';
import {
getWorldFoundationCardId,
normalizeFoundationDraftProfile,
} from './customWorldAgentDraftCompiler.js';
import type { UpstreamLlmClient } from './llmClient.js';
type GenerateEntitiesParams = {
creatorIntent: unknown;
anchorPack: unknown;
draftProfile: Record<string, unknown>;
count: number;
promptText?: string | null;
anchorCardIds?: string[];
llmClient?: UpstreamLlmClient | null;
};
const CHARACTER_SURNAME_POOL = [
'沈',
'顾',
'裴',
'闻',
'纪',
'苏',
'岑',
'陆',
'白',
'商',
'温',
'严',
'黎',
'季',
] as const;
const CHARACTER_GIVEN_POOL = [
'砺',
'岚',
'澄',
'栖',
'弦',
'朔',
'遥',
'霁',
'衡',
'铃',
'潮',
'燧',
'宁',
'鸢',
] as const;
const CHARACTER_ROLE_POOL = [
'线人',
'调停者',
'巡查官',
'记录员',
'司钥人',
'护送者',
] as const;
const LANDMARK_PREFIX_POOL = [
'盐火',
'潮碑',
'雾湾',
'沉钟',
'旧航',
'灰塔',
'回潮',
'断潮',
] as const;
const LANDMARK_SUFFIX_POOL = [
'观测台',
'栈桥',
'档案楼',
'前哨站',
'藏书库',
'工坊',
'集市',
'驿站',
] as const;
const DANGER_LEVEL_POOL = ['中', '中高', '高'] as const;
type AnchorContext = {
anchorLabels: string[];
threadIds: string[];
characterIds: string[];
landmarkIds: string[];
factionNames: string[];
};
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function clampText(value: string, maxLength: number) {
const normalized = value.replace(/\s+/gu, ' ').trim();
if (!normalized) {
return '';
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function slugify(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-')
.replace(/^-+|-+$/gu, '');
return normalized || 'entry';
}
function createStableId(prefix: string, label: string, index: number) {
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
}
function ensureCount(count: number) {
const normalized = Number.isFinite(count) ? Math.round(count) : 0;
if (normalized < 1 || normalized > 3) {
throw badRequest('count must be between 1 and 3');
}
return normalized;
}
function getAllCharacters(
profile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
) {
return [...profile.playableNpcs, ...profile.storyNpcs];
}
function dedupeStrings(values: string[]) {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
}
function extractJsonPayload(content: string) {
const fencedMatch = content.match(/```(?:json)?\s*([\s\S]+?)\s*```/u);
if (fencedMatch?.[1]) {
return fencedMatch[1].trim();
}
const arrayStart = content.indexOf('[');
const arrayEnd = content.lastIndexOf(']');
if (arrayStart >= 0 && arrayEnd > arrayStart) {
return content.slice(arrayStart, arrayEnd + 1);
}
return content.trim();
}
function buildAnchorContext(
profile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
anchorCardIds: string[],
): AnchorContext {
const worldCardId = getWorldFoundationCardId();
const labels: string[] = [];
const threadIds: string[] = [];
const characterIds: string[] = [];
const landmarkIds: string[] = [];
const factionNames: string[] = [];
const characters = getAllCharacters(profile);
anchorCardIds.forEach((cardId) => {
if (cardId === worldCardId) {
labels.push(profile.name);
if (profile.threads[0]) {
threadIds.push(profile.threads[0].id);
}
return;
}
const faction = profile.factions.find((entry) => entry.id === cardId);
if (faction) {
labels.push(faction.title || faction.name);
factionNames.push(faction.title || faction.name);
profile.threads
.filter(
(thread) =>
thread.summary.includes(faction.name) ||
thread.conflict.includes(faction.name) ||
thread.conflict.includes(faction.relatedConflict),
)
.slice(0, 2)
.forEach((thread) => {
threadIds.push(thread.id);
});
return;
}
const character = characters.find((entry) => entry.id === cardId);
if (character) {
labels.push(character.name);
characterIds.push(character.id);
threadIds.push(...character.threadIds);
return;
}
const landmark = profile.landmarks.find((entry) => entry.id === cardId);
if (landmark) {
labels.push(landmark.name);
landmarkIds.push(landmark.id);
characterIds.push(...landmark.characterIds);
threadIds.push(...landmark.threadIds);
return;
}
const thread = profile.threads.find((entry) => entry.id === cardId);
if (thread) {
labels.push(thread.title);
threadIds.push(thread.id);
characterIds.push(...thread.characterIds);
landmarkIds.push(...thread.landmarkIds);
return;
}
const chapter = profile.chapters.find((entry) => entry.id === cardId);
if (chapter) {
labels.push(chapter.title);
characterIds.push(...chapter.characterIds);
landmarkIds.push(...chapter.landmarkIds);
return;
}
if (profile.camp?.id === cardId) {
labels.push(profile.camp.name);
landmarkIds.push(...profile.landmarks.slice(0, 2).map((entry) => entry.id));
}
});
if (labels.length === 0) {
labels.push(profile.name);
}
if (threadIds.length === 0 && profile.threads[0]) {
threadIds.push(profile.threads[0].id);
}
if (characterIds.length === 0 && characters[0]) {
characterIds.push(characters[0].id);
}
return {
anchorLabels: dedupeStrings(labels),
threadIds: dedupeStrings(threadIds).slice(0, 3),
characterIds: dedupeStrings(characterIds).slice(0, 4),
landmarkIds: dedupeStrings(landmarkIds).slice(0, 4),
factionNames: dedupeStrings(factionNames).slice(0, 3),
};
}
function buildUniqueCharacterName(existingNames: Set<string>, startIndex: number) {
for (let attempt = 0; attempt < 120; attempt += 1) {
const index = startIndex + attempt;
const surname =
CHARACTER_SURNAME_POOL[index % CHARACTER_SURNAME_POOL.length];
const firstName =
CHARACTER_GIVEN_POOL[
Math.floor(index / CHARACTER_SURNAME_POOL.length) %
CHARACTER_GIVEN_POOL.length
];
const secondName =
CHARACTER_GIVEN_POOL[
(index + 5) % CHARACTER_GIVEN_POOL.length
];
const candidate = `${surname}${firstName}${secondName}`;
if (!existingNames.has(candidate)) {
existingNames.add(candidate);
return candidate;
}
}
const fallback = `新角色${existingNames.size + 1}`;
existingNames.add(fallback);
return fallback;
}
function buildUniqueLandmarkName(existingNames: Set<string>, startIndex: number) {
for (let attempt = 0; attempt < 120; attempt += 1) {
const index = startIndex + attempt;
const candidate = `${LANDMARK_PREFIX_POOL[index % LANDMARK_PREFIX_POOL.length]}${
LANDMARK_SUFFIX_POOL[
Math.floor(index / LANDMARK_PREFIX_POOL.length) %
LANDMARK_SUFFIX_POOL.length
]
}`;
if (!existingNames.has(candidate)) {
existingNames.add(candidate);
return candidate;
}
}
const fallback = `新地点${existingNames.size + 1}`;
existingNames.add(fallback);
return fallback;
}
function buildPromptSeed(promptText?: string | null) {
return clampText(promptText || '', 28);
}
function buildAnchorSummary(anchorContext: AnchorContext) {
return anchorContext.anchorLabels[0] || '当前底稿';
}
function buildCharacterFallback(
profile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
anchorContext: AnchorContext,
promptSeed: string,
index: number,
existingNames: Set<string>,
): CustomWorldFoundationDraftCharacter {
const name = buildUniqueCharacterName(existingNames, getAllCharacters(profile).length + index);
const role = CHARACTER_ROLE_POOL[
(getAllCharacters(profile).length + index) % CHARACTER_ROLE_POOL.length
];
const anchorSummary = buildAnchorSummary(anchorContext);
const publicMask = clampText(
[
`表面上以${role}身份靠近${anchorSummary}`,
promptSeed ? `对外总把话题往“${promptSeed}”上带` : '',
]
.filter(Boolean)
.join(''),
72,
);
const hiddenHook = clampText(
[
`暗中握着和${anchorSummary}有关的旧线索`,
anchorContext.factionNames[0]
? `并持续替${anchorContext.factionNames[0]}观察局势变化`
: '一直在等一个足以翻盘的时机',
].join(''),
72,
);
const relationToPlayer = clampText(
anchorContext.characterIds[0]
? `会先借熟人网络试探玩家愿不愿意卷入${anchorSummary}`
: `会先试探玩家是否愿意站到${anchorSummary}这一侧。`,
72,
);
const summary = clampText(
`${publicMask}${hiddenHook}${relationToPlayer}`,
140,
);
return {
id: createStableId('character', name, getAllCharacters(profile).length + index),
name,
title: role,
role,
publicIdentity: publicMask,
publicMask,
currentPressure: hiddenHook,
hiddenHook,
relationToPlayer,
threadIds: anchorContext.threadIds.slice(0, 2),
summary,
};
}
function buildLandmarkFallback(
profile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
anchorContext: AnchorContext,
promptSeed: string,
index: number,
existingNames: Set<string>,
): CustomWorldFoundationDraftLandmark {
const name = buildUniqueLandmarkName(existingNames, profile.landmarks.length + index);
const anchorSummary = buildAnchorSummary(anchorContext);
const purpose = clampText(
promptSeed
? `承接“${promptSeed}”这条补充要求的关键场景`
: `承接${anchorSummary}这条线的关键场景`,
72,
);
const mood = clampText(
buildPromptSeed(profile.tone) || '压迫、克制、带着未明感',
28,
);
const dangerLevel =
DANGER_LEVEL_POOL[(profile.landmarks.length + index) % DANGER_LEVEL_POOL.length];
const secret = clampText(
anchorContext.characterIds[0]
? `埋着与现有角色有关的旧痕和反转线索`
: `埋着足以改写${anchorSummary}解释权的旧线索`,
72,
);
const summary = clampText(
`${purpose},整体气质${mood}${secret}`,
140,
);
return {
id: createStableId('landmark', name, profile.landmarks.length + index),
name,
description: summary,
purpose,
mood,
importance: secret,
secret,
dangerLevel,
characterIds: anchorContext.characterIds.slice(0, 3),
threadIds: anchorContext.threadIds.slice(0, 2),
summary,
};
}
async function requestCharacterSuggestionsFromLlm(params: {
llmClient: UpstreamLlmClient;
profile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>;
anchorContext: AnchorContext;
count: number;
promptSeed: string;
creatorIntent: unknown;
anchorPack: unknown;
}) {
const anchorSummary = buildAnchorSummary(params.anchorContext);
const creatorIntentSummary =
toText(toRecord(params.anchorPack)?.creatorIntentSummary) ||
toText(toRecord(params.creatorIntent)?.worldHook) ||
params.profile.summary;
const content = await params.llmClient.requestMessageContent({
systemPrompt:
'你负责为当前游戏世界底稿补 1 到 3 个新角色。只能输出 JSON 数组,不要输出任何额外说明。',
userPrompt: [
`当前世界:${params.profile.name}`,
`世界摘要:${params.profile.summary}`,
`创作意图摘要:${creatorIntentSummary}`,
`参考锚点:${anchorSummary}`,
`已有角色:${getAllCharacters(params.profile)
.slice(0, 10)
.map((entry) => entry.name)
.join('、') || '暂无'}`,
`数量:${params.count}`,
`补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`,
'返回 JSON 数组。每个对象字段只允许包含name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。',
'threadIds 必须优先引用现有线程 id。',
].join('\n'),
timeoutMs: 45000,
debugLabel: 'custom-world-agent-generate-characters',
});
const parsed = JSON.parse(extractJsonPayload(content)) as Array<Record<string, unknown>>;
return Array.isArray(parsed) ? parsed : [];
}
async function requestLandmarkSuggestionsFromLlm(params: {
llmClient: UpstreamLlmClient;
profile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>;
anchorContext: AnchorContext;
count: number;
promptSeed: string;
creatorIntent: unknown;
anchorPack: unknown;
}) {
const anchorSummary = buildAnchorSummary(params.anchorContext);
const creatorIntentSummary =
toText(toRecord(params.anchorPack)?.creatorIntentSummary) ||
toText(toRecord(params.creatorIntent)?.worldHook) ||
params.profile.summary;
const content = await params.llmClient.requestMessageContent({
systemPrompt:
'你负责为当前游戏世界底稿补 1 到 3 个新地点。只能输出 JSON 数组,不要输出任何额外说明。',
userPrompt: [
`当前世界:${params.profile.name}`,
`世界摘要:${params.profile.summary}`,
`创作意图摘要:${creatorIntentSummary}`,
`参考锚点:${anchorSummary}`,
`已有地点:${params.profile.landmarks
.slice(0, 10)
.map((entry) => entry.name)
.join('、') || '暂无'}`,
`数量:${params.count}`,
`补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`,
'返回 JSON 数组。每个对象字段只允许包含name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds。',
'threadIds / characterIds 必须优先引用现有对象 id。',
].join('\n'),
timeoutMs: 45000,
debugLabel: 'custom-world-agent-generate-landmarks',
});
const parsed = JSON.parse(extractJsonPayload(content)) as Array<Record<string, unknown>>;
return Array.isArray(parsed) ? parsed : [];
}
export class CustomWorldAgentEntityGenerationService {
constructor(private readonly llmClient: UpstreamLlmClient | null = null) {}
async generateAdditionalCharacters(params: GenerateEntitiesParams) {
const draftProfile = normalizeFoundationDraftProfile(params.draftProfile);
if (!draftProfile) {
throw badRequest('draftProfile is empty');
}
const count = ensureCount(params.count);
const promptSeed = buildPromptSeed(params.promptText);
const anchorContext = buildAnchorContext(draftProfile, params.anchorCardIds ?? []);
const existingNames = new Set(
getAllCharacters(draftProfile).map((entry) => entry.name),
);
let llmDrafts: Array<Record<string, unknown>> = [];
if (this.llmClient) {
try {
llmDrafts = await requestCharacterSuggestionsFromLlm({
llmClient: this.llmClient,
profile: draftProfile,
anchorContext,
count,
promptSeed,
creatorIntent: params.creatorIntent,
anchorPack: params.anchorPack,
});
} catch {
llmDrafts = [];
}
}
const generatedCharacters = Array.from({ length: count }, (_, index) => {
const fallback = buildCharacterFallback(
draftProfile,
anchorContext,
promptSeed,
index,
existingNames,
);
const llmDraft = toRecord(llmDrafts[index]);
if (!llmDraft) {
return fallback;
}
const name = toText(llmDraft.name) || fallback.name;
return {
...fallback,
id: createStableId('character', name, getAllCharacters(draftProfile).length + index),
name,
title: toText(llmDraft.role) || fallback.title,
role: toText(llmDraft.role) || fallback.role,
publicIdentity: toText(llmDraft.publicMask) || fallback.publicIdentity,
publicMask: toText(llmDraft.publicMask) || fallback.publicMask,
currentPressure: toText(llmDraft.hiddenHook) || fallback.currentPressure,
hiddenHook: toText(llmDraft.hiddenHook) || fallback.hiddenHook,
relationToPlayer:
toText(llmDraft.relationToPlayer) || fallback.relationToPlayer,
threadIds:
Array.isArray(llmDraft.threadIds) && llmDraft.threadIds.length > 0
? dedupeStrings(llmDraft.threadIds.map((entry) => toText(entry))).slice(0, 2)
: fallback.threadIds,
summary: toText(llmDraft.summary) || fallback.summary,
} satisfies CustomWorldFoundationDraftCharacter;
});
draftProfile.storyNpcs = [...draftProfile.storyNpcs, ...generatedCharacters];
return {
draftProfile: draftProfile as unknown as Record<string, unknown>,
generatedCharacters,
};
}
async generateAdditionalLandmarks(params: GenerateEntitiesParams) {
const draftProfile = normalizeFoundationDraftProfile(params.draftProfile);
if (!draftProfile) {
throw badRequest('draftProfile is empty');
}
const count = ensureCount(params.count);
const promptSeed = buildPromptSeed(params.promptText);
const anchorContext = buildAnchorContext(draftProfile, params.anchorCardIds ?? []);
const existingNames = new Set(draftProfile.landmarks.map((entry) => entry.name));
let llmDrafts: Array<Record<string, unknown>> = [];
if (this.llmClient) {
try {
llmDrafts = await requestLandmarkSuggestionsFromLlm({
llmClient: this.llmClient,
profile: draftProfile,
anchorContext,
count,
promptSeed,
creatorIntent: params.creatorIntent,
anchorPack: params.anchorPack,
});
} catch {
llmDrafts = [];
}
}
const generatedLandmarks = Array.from({ length: count }, (_, index) => {
const fallback = buildLandmarkFallback(
draftProfile,
anchorContext,
promptSeed,
index,
existingNames,
);
const llmDraft = toRecord(llmDrafts[index]);
if (!llmDraft) {
return fallback;
}
const name = toText(llmDraft.name) || fallback.name;
return {
...fallback,
id: createStableId('landmark', name, draftProfile.landmarks.length + index),
name,
description: toText(llmDraft.description) || toText(llmDraft.summary) || fallback.description,
purpose: toText(llmDraft.purpose) || fallback.purpose,
mood: toText(llmDraft.mood) || fallback.mood,
importance: toText(llmDraft.secret) || fallback.importance,
secret: toText(llmDraft.secret) || fallback.secret,
dangerLevel: toText(llmDraft.dangerLevel) || fallback.dangerLevel,
characterIds:
Array.isArray(llmDraft.characterIds) && llmDraft.characterIds.length > 0
? dedupeStrings(llmDraft.characterIds.map((entry) => toText(entry))).slice(0, 3)
: fallback.characterIds,
threadIds:
Array.isArray(llmDraft.threadIds) && llmDraft.threadIds.length > 0
? dedupeStrings(llmDraft.threadIds.map((entry) => toText(entry))).slice(0, 2)
: fallback.threadIds,
summary: toText(llmDraft.summary) || fallback.summary,
} satisfies CustomWorldFoundationDraftLandmark;
});
draftProfile.landmarks = [...draftProfile.landmarks, ...generatedLandmarks];
return {
draftProfile: draftProfile as unknown as Record<string, unknown>,
generatedLandmarks,
};
}
}

View File

@@ -0,0 +1,821 @@
import type {
CustomWorldFoundationDraftCamp,
CustomWorldFoundationDraftCharacter,
CustomWorldFoundationDraftFaction,
CustomWorldFoundationDraftLandmark,
CustomWorldFoundationDraftProfile,
CustomWorldFoundationDraftThread,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import {
buildDraftSummaryFromIntent,
normalizeCreatorIntentRecord,
type CreatorCharacterSeedRecord,
type CustomWorldCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function clampText(value: string, maxLength: number) {
const normalized = value.replace(/\s+/gu, ' ').trim();
if (!normalized) {
return '';
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function slugify(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-')
.replace(/^-+|-+$/gu, '');
return normalized || 'entry';
}
function createId(prefix: string, label: string, index: number) {
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
}
function dedupeStrings(items: string[], maxCount = 8) {
return [...new Set(items.map((item) => item.trim()).filter(Boolean))].slice(
0,
maxCount,
);
}
function sanitizeEntityName(value: string) {
return value
.replace(/^(||||||||||)/u, '')
.replace(/[,]/gu, '')
.trim();
}
function buildCompactLabel(text: string, fallback: string, maxLength = 14) {
const normalized = sanitizeEntityName(text)
.replace(/^(||||||)/u, '')
.trim();
return clampText(normalized || fallback, maxLength) || fallback;
}
function splitSentences(text: string) {
return text
.split(/[\n]/u)
.map((entry) => entry.trim())
.filter(Boolean);
}
function extractConflictSides(conflict: string) {
const relationMatch = conflict.match(
/([A-Za-z0-9\u4e00-\u9fa5·-]{2,12}?)(?:||)([A-Za-z0-9\u4e00-\u9fa5·-]{2,12}?)(?:||||||)/u,
);
if (relationMatch?.[1] && relationMatch?.[2]) {
return [relationMatch[1].trim(), relationMatch[2].trim()];
}
return [...conflict.matchAll(/([A-Za-z0-9\u4e00-\u9fa5·-]{2,12}(?:|||||||||||||殿|||||))/gu)]
.map((entry) => entry[1]?.trim() || '')
.filter(Boolean)
.slice(0, 3);
}
function extractConflictTarget(conflict: string) {
const matched = conflict.match(
/(?:|||||)([^]{2,20})/u,
);
return clampText(toText(matched?.[1]), 18);
}
function extractPlaceLikePhrase(text: string) {
const patterns = [
/([^]{2,18}?(?:||||||||||||殿|||||||||||||||))(?:||||||)?/u,
/([^]{2,18}?(?:||||||||||||殿|||||||||||||||))(?:|||)?/u,
];
for (const pattern of patterns) {
const matched = text.match(pattern);
const candidate = sanitizeEntityName(toText(matched?.[1]));
if (candidate) {
return clampText(candidate, 16);
}
}
return '';
}
function looksLikePlaceName(value: string) {
return /(||||||||||||殿||||||||||||||||||)/u.test(
value,
);
}
function convertElementToLandmarkName(element: string) {
const normalized = sanitizeEntityName(element);
if (!normalized) {
return '';
}
if (looksLikePlaceName(normalized)) {
return clampText(normalized, 16);
}
if (normalized.endsWith('钟声')) {
return clampText(normalized.replace(/$/u, ''), 16);
}
if (normalized.endsWith('盟约') || normalized.endsWith('残片')) {
return clampText(`${normalized}档库`, 16);
}
if (normalized.endsWith('火')) {
return clampText(`${normalized}哨点`, 16);
}
return clampText(`${normalized}回响区`, 16);
}
function buildWorldName(intent: CustomWorldCreatorIntentRecord) {
const worldHook = sanitizeEntityName(intent.worldHook || intent.rawSettingText);
const namedMatch = worldHook.match(
/([A-Za-z0-9\u4e00-\u9fa5·-]{2,16}(?:||||||||||||||))/u,
);
return (
clampText(namedMatch?.[1] || worldHook || intent.iconicElements[0] || '', 18) ||
'未命名世界底稿'
);
}
function buildTone(intent: CustomWorldCreatorIntentRecord) {
return (
dedupeStrings(
[...intent.themeKeywords, ...intent.toneDirectives, ...intent.iconicElements],
8,
).join('、') || '紧绷、未明、带着继续展开的空间'
);
}
function buildPlayerGoal(params: {
playerPremise: string;
openingSituation: string;
coreConflict: string;
}) {
const conflictTarget = extractConflictTarget(params.coreConflict);
const location = extractPlaceLikePhrase(params.openingSituation);
const lead = location
? `先在${location}站稳`
: params.openingSituation
? `先扛过“${buildCompactLabel(params.openingSituation, '开局风暴', 12)}`
: '先稳住眼前的局势';
const tail = conflictTarget
? `,再查清谁在主导“${conflictTarget}`
: params.coreConflict
? `,再判断自己在“${buildCompactLabel(params.coreConflict, '核心冲突', 12)}”里的站位`
: '';
return clampText(`${lead}${tail}`, 60);
}
function buildFactions(params: {
intent: CustomWorldCreatorIntentRecord;
coreConflicts: string[];
playerPremise: string;
iconicElements: string[];
}): CustomWorldFoundationDraftFaction[] {
const explicitFactions = params.intent.keyFactions.map((entry) => ({
name: sanitizeEntityName(entry.name),
publicGoal: clampText(entry.publicGoal, 28),
relatedConflict:
clampText(entry.tension, 48) || params.coreConflicts[0] || '局势正在升温',
playerRelation: '玩家很难绕开它的影响',
}));
const conflictSideNames = params.coreConflicts.flatMap((entry) =>
extractConflictSides(entry),
);
const fallbackPrefixes = dedupeStrings(
[
...params.iconicElements.map((entry) => buildCompactLabel(entry, '', 6)),
buildCompactLabel(params.intent.worldHook, '', 6),
],
4,
).filter(Boolean);
const fallbackNames = [
fallbackPrefixes[0] ? `${fallbackPrefixes[0]}守望会` : '',
fallbackPrefixes[1] ? `${fallbackPrefixes[1]}商盟` : '',
'旧约议庭',
'灰区中间人',
].filter(Boolean);
const names = dedupeStrings(
[
...explicitFactions.map((entry) => entry.name),
...conflictSideNames,
...fallbackNames,
],
4,
).slice(0, 3);
return names.map((name, index) => {
const explicit = explicitFactions.find((entry) => entry.name === name);
const relatedConflict =
explicit?.relatedConflict ||
params.coreConflicts.find((entry) => entry.includes(name)) ||
params.coreConflicts[index % Math.max(1, params.coreConflicts.length)] ||
'局势仍在快速失衡';
const conflictTarget = extractConflictTarget(relatedConflict);
const publicGoal =
explicit?.publicGoal ||
clampText(
conflictTarget
? `拿下${conflictTarget}的主动解释权`
: '在变局里先一步拿到主动权',
28,
);
const playerRelation =
explicit?.playerRelation ||
clampText(
index === 0
? '它会把玩家当成必须争取的关键变量'
: index === 1
? '它迟早会逼玩家在立场上做选择'
: '它可能提供入口,也可能直接加码风险',
36,
);
return {
id: createId('faction', name, index),
name,
publicGoal,
relatedConflict,
playerRelation,
summary: clampText(
`${name}正在围绕“${buildCompactLabel(relatedConflict, '核心冲突', 16)}”抢先手,公开目标是${publicGoal},并且${playerRelation}`,
140,
),
};
});
}
function buildBaseThreads(params: {
intent: CustomWorldCreatorIntentRecord;
coreConflicts: string[];
playerPremise: string;
openingSituation: string;
iconicElements: string[];
}): CustomWorldFoundationDraftThread[] {
const firstConflict = params.coreConflicts[0] || '旧秩序与新力量正在拉扯世界走向';
const hiddenSeed =
params.intent.keyCharacters.find((entry) => entry.hiddenHook.trim())?.hiddenHook ||
params.iconicElements[0] ||
'表面冲突背后还有更深的一层';
const relationshipSeed =
params.intent.keyCharacters.find((entry) => entry.relationToPlayer.trim())
?.relationToPlayer ||
params.playerPremise ||
params.openingSituation;
const extraSeed = params.coreConflicts[1] || params.iconicElements[1] || '';
const seeds = [
{
title: buildCompactLabel(firstConflict, '主线推进', 16),
type: 'main' as const,
conflict: firstConflict,
summary: clampText(`明线围绕“${firstConflict}”推进,玩家一入局就会被迫参与。`, 90),
},
{
title: buildCompactLabel(hiddenSeed, '暗线回潮', 16),
type: 'hidden' as const,
conflict: hiddenSeed,
summary: clampText(`暗线真正牵动的不是表面立场,而是“${buildCompactLabel(hiddenSeed, '暗线真相', 18)}”。`, 90),
},
{
title: buildCompactLabel(relationshipSeed, '关系裂口', 16),
type: 'main' as const,
conflict: relationshipSeed,
summary: clampText(`玩家身边的关系与身份会决定这条线最先从哪里裂开。`, 90),
},
...(extraSeed
? [
{
title: buildCompactLabel(extraSeed, '余波扩散', 16),
type: 'hidden' as const,
conflict: extraSeed,
summary: clampText(`这条线负责把世界里更深的余波慢慢带出来。`, 90),
},
]
: []),
];
return seeds.slice(0, 4).map((entry, index) => ({
id: createId('thread', entry.title, index),
title: entry.title,
type: entry.type,
conflict: clampText(entry.conflict, 72),
characterIds: [],
landmarkIds: [],
summary: entry.summary,
}));
}
function buildPlayerProxyCharacter(
intent: CustomWorldCreatorIntentRecord,
threads: CustomWorldFoundationDraftThread[],
coreConflict: string,
): CustomWorldFoundationDraftCharacter | null {
const playerPremise = sanitizeEntityName(intent.playerPremise);
if (!playerPremise) {
return null;
}
const mainThreadId = threads[0]?.id ?? null;
const relationThreadId = threads[2]?.id ?? threads[1]?.id ?? null;
const name = buildCompactLabel(playerPremise, '玩家前线身份', 10);
return {
id: createId('character', name, 0),
name,
title: '玩家前线身份',
role: playerPremise,
publicIdentity: playerPremise,
currentPressure:
clampText(intent.openingSituation || coreConflict, 48) ||
'必须先扛过眼前的局势压力',
relationToPlayer: '这是玩家当前最贴近世界的切入口',
threadIds: [mainThreadId, relationThreadId].filter(
(entry): entry is string => Boolean(entry),
),
summary: clampText(
`${playerPremise}被直接推到台前,眼下压力是“${buildCompactLabel(intent.openingSituation || coreConflict, '开局压力', 18)}”。`,
120,
),
};
}
function buildCharacterFromSeed(params: {
seed: CreatorCharacterSeedRecord;
index: number;
threads: CustomWorldFoundationDraftThread[];
coreConflict: string;
}): CustomWorldFoundationDraftCharacter {
const hiddenThreadId = params.threads.find((entry) => entry.type === 'hidden')?.id;
const mainThreadId = params.threads[0]?.id ?? null;
const relationThreadId = params.threads[2]?.id ?? hiddenThreadId ?? null;
return {
id: params.seed.id || createId('character', params.seed.name || params.seed.role, params.index),
name:
sanitizeEntityName(params.seed.name) ||
buildCompactLabel(params.seed.role || params.seed.relationToPlayer, '关键角色', 10),
title: clampText(params.seed.role || '关键人物', 18) || '关键人物',
role: clampText(params.seed.role || '关键人物', 28) || '关键人物',
publicIdentity:
clampText(params.seed.publicMask || params.seed.role || '站在当前局势前台的人', 36) ||
'站在当前局势前台的人',
currentPressure:
clampText(params.seed.hiddenHook || params.coreConflict, 48) ||
'正在被当前局势不断加压',
relationToPlayer:
clampText(params.seed.relationToPlayer || '会直接改变玩家的第一步选择', 36) ||
'会直接改变玩家的第一步选择',
threadIds: dedupeStrings(
[
params.seed.hiddenHook ? hiddenThreadId ?? '' : '',
params.seed.relationToPlayer ? relationThreadId ?? '' : '',
mainThreadId ?? '',
],
3,
),
summary: clampText(
`${params.seed.publicMask || params.seed.role || '表面上像是立场前台的人'};当前压力是${params.seed.hiddenHook || '必须在明暗两条线上同时做选择'};与玩家关系是${params.seed.relationToPlayer || '会直接左右玩家的站位'}`,
130,
),
};
}
function buildGeneratedCharacters(params: {
existingNames: string[];
factions: CustomWorldFoundationDraftFaction[];
threads: CustomWorldFoundationDraftThread[];
iconicElements: string[];
coreConflict: string;
}): CustomWorldFoundationDraftCharacter[] {
const suffixes = ['联络人', '记录官', '引路人', '修补匠', '代言人'];
const generated: CustomWorldFoundationDraftCharacter[] = [];
const mainThreadId = params.threads[0]?.id ?? null;
const hiddenThreadId = params.threads.find((entry) => entry.type === 'hidden')?.id;
const relationThreadId = params.threads[2]?.id ?? mainThreadId;
params.factions.forEach((faction, index) => {
const prefix =
buildCompactLabel(faction.name.replace(/(||||||||)$/u, ''), '', 6) ||
buildCompactLabel(params.iconicElements[index] || '', '关键', 6);
const name = `${prefix}${suffixes[index % suffixes.length]}`;
if (params.existingNames.includes(name)) {
return;
}
generated.push({
id: createId('character', name, generated.length + 1),
name,
title: '关键阵营接口人',
role: `${faction.name}在前台推动局势的人`,
publicIdentity: `${faction.name}的前台接口人`,
currentPressure: faction.relatedConflict || params.coreConflict,
relationToPlayer:
index === 0 ? '会主动把玩家拉进局势中心' : '对玩家既有利用价值也有试探意图',
threadIds: dedupeStrings(
[mainThreadId ?? '', index % 2 === 0 ? relationThreadId ?? '' : hiddenThreadId ?? ''],
3,
),
summary: clampText(
`${name}代表${faction.name}在前台出手,眼下压力直指“${buildCompactLabel(faction.relatedConflict || params.coreConflict, '局势升级', 18)}”,同时会主动试探玩家的站位。`,
130,
),
});
});
return generated;
}
function buildCharacters(params: {
intent: CustomWorldCreatorIntentRecord;
factions: CustomWorldFoundationDraftFaction[];
threads: CustomWorldFoundationDraftThread[];
coreConflicts: string[];
iconicElements: string[];
}) {
const firstConflict = params.coreConflicts[0] || '旧秩序与新力量正在拉扯世界走向';
const characters: CustomWorldFoundationDraftCharacter[] = [];
const playerProxy = buildPlayerProxyCharacter(
params.intent,
params.threads,
firstConflict,
);
if (playerProxy) {
characters.push(playerProxy);
}
params.intent.keyCharacters.forEach((seed, index) => {
characters.push(
buildCharacterFromSeed({
seed,
index: index + 1,
threads: params.threads,
coreConflict: firstConflict,
}),
);
});
const generated = buildGeneratedCharacters({
existingNames: characters.map((entry) => entry.name),
factions: params.factions,
threads: params.threads,
iconicElements: params.iconicElements,
coreConflict: firstConflict,
});
generated.forEach((entry) => {
if (characters.some((item) => item.name === entry.name)) {
return;
}
characters.push(entry);
});
return dedupeStrings(characters.map((entry) => entry.name), 5).map(
(name) => characters.find((entry) => entry.name === name)!,
);
}
function buildCamp(params: {
openingSituation: string;
worldHook: string;
iconicElements: string[];
}): CustomWorldFoundationDraftCamp {
const openingPlace = extractPlaceLikePhrase(params.openingSituation);
const prefix =
openingPlace ||
buildCompactLabel(params.iconicElements[0] || params.worldHook, '归返', 6);
const name = looksLikePlaceName(prefix) ? `${prefix}守望舍` : `${prefix}前哨`;
return {
id: 'camp-home',
name: clampText(name, 16),
description: clampText(
openingPlace
? `贴着${openingPlace}搭起来的临时落脚处,玩家还能在这里喘口气和整理线索。`
: '玩家暂时还能整顿情报、换口气并决定下一步站位的落脚处。',
72,
),
mood: '克制、紧绷,但还有一点能重新收住局势的余地',
summary: clampText(
`${clampText(name, 12)}不是安全区,而是玩家在风暴边缘还能勉强站稳的一块地方。`,
88,
),
};
}
function buildLandmarks(params: {
intent: CustomWorldCreatorIntentRecord;
camp: CustomWorldFoundationDraftCamp;
factions: CustomWorldFoundationDraftFaction[];
characters: CustomWorldFoundationDraftCharacter[];
threads: CustomWorldFoundationDraftThread[];
coreConflicts: string[];
iconicElements: string[];
openingSituation: string;
}): CustomWorldFoundationDraftLandmark[] {
const explicit = params.intent.keyLandmarks.map((entry) => ({
name: clampText(sanitizeEntityName(entry.name), 16),
purpose: clampText(entry.purpose, 24) || '承接关键剧情推进',
mood: clampText(entry.mood, 24) || '带着明显的情绪指向',
importance:
clampText(entry.secret, 36) || '和当前主线冲突直接勾连的关键地点',
}));
const openingPlace = extractPlaceLikePhrase(params.openingSituation);
const conflictTarget = extractConflictTarget(params.coreConflicts[0] || '');
const derivedNames = dedupeStrings(
[
...explicit.map((entry) => entry.name),
openingPlace,
...params.iconicElements.map((entry) => convertElementToLandmarkName(entry)),
conflictTarget
? looksLikePlaceName(conflictTarget)
? conflictTarget
: `${conflictTarget}争议带`
: '',
`${buildCompactLabel(params.factions[0]?.name || params.camp.name, '前线', 8)}前场`,
'旧档案库',
'灰雾渡口',
],
6,
).slice(0, 5);
return derivedNames.map((name, index) => {
const explicitEntry = explicit.find((entry) => entry.name === name);
const threadIds = dedupeStrings(
[
params.threads[index % Math.max(1, params.threads.length)]?.id ?? '',
params.threads[(index + 1) % Math.max(1, params.threads.length)]?.id ?? '',
],
3,
);
const characterIds = dedupeStrings(
[
params.characters[index % Math.max(1, params.characters.length)]?.id ?? '',
params.characters[(index + 1) % Math.max(1, params.characters.length)]?.id ?? '',
],
3,
);
return {
id: createId('landmark', name, index),
name,
purpose:
explicitEntry?.purpose ||
clampText(
index === 0
? '玩家最先被推到局势前台的位置'
: index === 1
? '不同立场开始交锋和试探的地方'
: '把世界气质、冲突和人物同时挂住的关键地标',
28,
),
mood:
explicitEntry?.mood ||
clampText(
index === 0
? '第一眼就能感到风暴逼近'
: index === 1
? '压迫里带着可探索的缝隙'
: '既有吸引力,也有明显风险感',
24,
),
importance:
explicitEntry?.importance ||
clampText(
`${name}和“${buildCompactLabel(params.coreConflicts[0] || params.threads[0]?.title || '主线推进', '主线', 16)}”直接勾连,玩家第一次抵达时就会意识到它不只是背景。`,
60,
),
characterIds,
threadIds,
summary: clampText(
`${name}承担${explicitEntry?.purpose || '主线推进'},会把${characterIds.length > 0 ? '关键人物' : '局势压力'}直接挂到玩家面前。`,
120,
),
};
});
}
function finalizeThreads(params: {
threads: CustomWorldFoundationDraftThread[];
characters: CustomWorldFoundationDraftCharacter[];
landmarks: CustomWorldFoundationDraftLandmark[];
}) {
return params.threads.map((thread) => {
const characterIds = params.characters
.filter((entry) => entry.threadIds.includes(thread.id))
.map((entry) => entry.id)
.slice(0, 4);
const landmarkIds = params.landmarks
.filter((entry) => entry.threadIds.includes(thread.id))
.map((entry) => entry.id)
.slice(0, 4);
return {
...thread,
characterIds,
landmarkIds,
summary: clampText(
`${thread.type === 'hidden' ? '暗线' : '明线'}围绕“${buildCompactLabel(thread.conflict, thread.title, 18)}”推进,相关对象包括${[
characterIds.length > 0 ? `${characterIds.length} 名关键角色` : '',
landmarkIds.length > 0 ? `${landmarkIds.length} 个关键地点` : '',
]
.filter(Boolean)
.join('、') || '当前第一批底稿对象'}`,
120,
),
};
});
}
function buildChapter(params: {
worldName: string;
openingSituation: string;
playerGoal: string;
characters: CustomWorldFoundationDraftCharacter[];
landmarks: CustomWorldFoundationDraftLandmark[];
threads: CustomWorldFoundationDraftThread[];
}) {
const openingEvent =
clampText(params.openingSituation, 60) ||
`玩家被迫卷入“${buildCompactLabel(params.threads[0]?.conflict || '', '主线冲突', 18)}”。`;
const characterIds = params.characters.slice(0, 3).map((entry) => entry.id);
const landmarkIds = params.landmarks.slice(0, 3).map((entry) => entry.id);
const hiddenThread = params.threads.find((entry) => entry.type === 'hidden');
return {
id: 'chapter-first-act',
title: clampText(`第一幕:${buildCompactLabel(params.worldName, '世界开幕', 12)}`, 18),
openingEvent,
playerGoal: params.playerGoal,
characterIds,
landmarkIds,
understandingShift: clampText(
hiddenThread
? `第一幕结束时,玩家会意识到“${buildCompactLabel(hiddenThread.conflict, hiddenThread.title, 18)}”并不是背景噪音,而是会反过来改写主线走向。`
: '第一幕结束时,玩家会意识到这场冲突远不止表面那一层。',
72,
),
summary: clampText(
`${openingEvent} 玩家第一步要做的不是立刻解决一切,而是先在${params.landmarks[0]?.name || '关键地点'}站稳,并看清${params.characters[0]?.name || '关键角色'}等人分别在推什么。`,
140,
),
};
}
export class CustomWorldAgentFoundationDraftService {
generate(params: {
creatorIntent: unknown;
anchorPack: unknown;
}): CustomWorldFoundationDraftProfile {
const intent = normalizeCreatorIntentRecord(params.creatorIntent) ?? {
sourceMode: 'freeform' as const,
rawSettingText: '',
worldHook: '',
themeKeywords: [],
toneDirectives: [],
playerPremise: '',
openingSituation: '',
coreConflicts: [],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: [],
forbiddenDirectives: [],
};
const anchorPack = toRecord(params.anchorPack);
const worldHook =
clampText(intent.worldHook || intent.rawSettingText, 72) ||
'一个仍在失衡边缘不断扩张的世界';
const playerPremise =
clampText(intent.playerPremise, 72) || '玩家是一名被卷进局势中心的行动者';
const openingSituation =
clampText(intent.openingSituation, 72) ||
'故事开局时,玩家已经站在必须立刻选边的位置上';
const coreConflicts =
dedupeStrings(intent.coreConflicts, 4).length > 0
? dedupeStrings(intent.coreConflicts, 4)
: ['旧秩序与新力量正在争夺这个世界的解释权'];
const iconicElements = dedupeStrings(intent.iconicElements, 6);
const tone = buildTone(intent);
const worldName = buildWorldName(intent);
const playerGoal = buildPlayerGoal({
playerPremise,
openingSituation,
coreConflict: coreConflicts[0] || '',
});
const factions = buildFactions({
intent,
coreConflicts,
playerPremise,
iconicElements,
});
const baseThreads = buildBaseThreads({
intent,
coreConflicts,
playerPremise,
openingSituation,
iconicElements,
});
const characters = buildCharacters({
intent,
factions,
threads: baseThreads,
coreConflicts,
iconicElements,
}).slice(0, 5);
const camp = buildCamp({
openingSituation,
worldHook,
iconicElements,
});
const landmarks = buildLandmarks({
intent,
camp,
factions,
characters,
threads: baseThreads,
coreConflicts,
iconicElements,
openingSituation,
}).slice(0, 6);
const threads = finalizeThreads({
threads: baseThreads.slice(0, 4),
characters,
landmarks,
});
const chapter = buildChapter({
worldName,
openingSituation,
playerGoal,
characters,
landmarks,
threads,
});
const uniquePoint =
iconicElements.length > 0
? `最抓人的记忆点是${iconicElements.slice(0, 2).join('、')}`
: '这个世界的吸引力来自它正在失衡中的人和秩序';
const summary = clampText(
`${worldHook} 玩家会以“${playerPremise}”切入这个世界,眼下最直接的冲突是“${coreConflicts[0]}”。${uniquePoint}`,
180,
);
return {
name: worldName,
subtitle:
clampText(
[buildCompactLabel(playerPremise, '玩家视角', 12), buildCompactLabel(coreConflicts[0] || '', '核心冲突', 16)]
.filter(Boolean)
.join(' · '),
40,
) || '第一版世界底稿',
summary,
tone,
playerGoal,
majorFactions: factions.map((entry) => entry.name),
coreConflicts,
playableNpcs: characters,
storyNpcs: [],
landmarks,
camp,
themePack: null,
storyGraph: null,
factions,
threads,
chapters: [chapter],
worldHook,
playerPremise,
openingSituation,
iconicElements,
sourceAnchorSummary:
toText(anchorPack?.creatorIntentSummary) ||
buildDraftSummaryFromIntent(intent) ||
summary,
};
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,351 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import {
buildPendingClarifications,
evaluateCreatorIntentReadiness,
} from './customWorldAgentClarificationService.js';
import {
extractCreatorIntentPatch,
mergeCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
const sessionsByUser = new Map<
string,
Map<string, CustomWorldSessionRecord>
>();
const profilesByUser = new Map<string, Record<string, unknown>[]>();
const getSessionBucket = (userId: string) => {
const existing = sessionsByUser.get(userId);
if (existing) {
return existing;
}
const nextBucket = new Map<string, CustomWorldSessionRecord>();
sessionsByUser.set(userId, nextBucket);
return nextBucket;
};
return {
async getSnapshot(_userId) {
return null;
},
async putSnapshot(_userId, _payload) {
throw new Error('not implemented');
},
async deleteSnapshot(_userId) {
return undefined;
},
async getSettings() {
return {
musicVolume: 0.42,
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles(userId) {
return [...(profilesByUser.get(userId) ?? [])];
},
async upsertCustomWorldProfile(userId, profileId, profile) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
current.unshift({
...profile,
id: profileId,
});
profilesByUser.set(userId, current);
return current;
},
async deleteCustomWorldProfile(userId, profileId) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
profilesByUser.set(userId, current);
return current;
},
async listCustomWorldSessions(userId) {
return [...getSessionBucket(userId).values()];
},
async getCustomWorldSession(userId, sessionId) {
return getSessionBucket(userId).get(sessionId) ?? null;
},
async upsertCustomWorldSession(userId, sessionId, session) {
getSessionBucket(userId).set(
sessionId,
JSON.parse(JSON.stringify(session)),
);
return JSON.parse(JSON.stringify(session));
},
};
}
async function waitForOperation(
orchestrator: CustomWorldAgentOrchestrator,
userId: string,
sessionId: string,
operationId: string,
) {
for (let attempt = 0; attempt < 40; attempt += 1) {
const operation = await orchestrator.getOperation(
userId,
sessionId,
operationId,
);
if (operation?.status === 'completed' || operation?.status === 'failed') {
return operation;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error('operation did not finish in time');
}
test('phase2 extractor can pull multiple creator intent anchors from natural language', () => {
const patch = extractCreatorIntentPatch({
currentIntent: null,
latestUserMessage:
'玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。标志性元素是潮雾钟声、盐火灯塔。',
});
assert.match(patch.playerPremise ?? '', //u);
assert.match(patch.openingSituation ?? '', //u);
assert.ok(patch.themeKeywords?.some((entry) => /|/u.test(entry)));
assert.ok(patch.toneDirectives?.some((entry) => /|/u.test(entry)));
assert.ok(patch.coreConflicts?.[0]?.includes('争夺航道解释权'));
assert.deepEqual(patch.iconicElements, ['潮雾钟声', '盐火灯塔']);
});
test('phase2 extractor marks explicit rewrite fields for merge replacement', () => {
const patch = extractCreatorIntentPatch({
currentIntent: {
sourceMode: 'freeform',
rawSettingText: '',
worldHook: '一个被潮雾切开的列岛世界。',
themeKeywords: ['海岛'],
toneDirectives: ['冷峻'],
playerPremise: '',
openingSituation: '',
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: [],
forbiddenDirectives: [],
},
latestUserMessage:
'主题改成宫廷悬疑,核心冲突改为王庭继承人与旧灯塔盟约对抗。',
});
assert.ok(patch.replaceFields?.includes('themeKeywords'));
assert.ok(patch.replaceFields?.includes('coreConflicts'));
assert.ok(patch.themeKeywords?.some((entry) => /|/u.test(entry)));
assert.ok(patch.coreConflicts?.some((entry) => //u.test(entry)));
});
test('phase2 clarification service only keeps the top highest leverage gap', () => {
const readiness = evaluateCreatorIntentReadiness({
sourceMode: 'freeform',
rawSettingText: '',
worldHook: '',
themeKeywords: [],
toneDirectives: [],
playerPremise: '',
openingSituation: '',
coreConflicts: [],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: [],
forbiddenDirectives: [],
});
const clarifications = buildPendingClarifications(null, readiness);
assert.equal(clarifications.length, 1);
assert.equal(clarifications[0]?.targetKey, 'world_hook');
});
test('phase2 orchestrator advances session to foundation_review when minimal anchors are complete', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase2-ready';
const createdSession = await orchestrator.createSession(userId, {
seedText: '一个被潮雾切开的列岛世界。',
});
assert.equal(createdSession.stage, 'clarifying');
assert.match(
String(
(createdSession.creatorIntent as Record<string, unknown>)?.worldHook ??
'',
),
//u,
);
const message1 = await orchestrator.submitMessage(
userId,
createdSession.sessionId,
{
clientMessageId: 'client-1',
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
focusCardId: null,
selectedCardIds: [],
},
);
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message1.operation.operationId,
);
const message2 = await orchestrator.submitMessage(
userId,
createdSession.sessionId,
{
clientMessageId: 'client-2',
text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。',
focusCardId: null,
selectedCardIds: [],
},
);
const operation = await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message2.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(
userId,
createdSession.sessionId,
);
assert.equal(operation?.status, 'completed');
assert.equal(snapshot?.stage, 'foundation_review');
assert.equal(snapshot?.creatorIntentReadiness.isReady, true);
assert.deepEqual(snapshot?.pendingClarifications, []);
assert.match(
String(
(snapshot?.creatorIntent as Record<string, unknown>)?.worldHook ?? '',
),
//u,
);
assert.ok(
snapshot?.messages.some(
(message) =>
message.role === 'assistant' &&
message.text.includes('最小锚点已经齐备'),
),
);
});
test('phase2 work summaries compile draft title and summary from creator intent', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase2-summary';
const createdSession = await orchestrator.createSession(userId, {
seedText: '一个被潮雾切开的列岛世界。',
});
const update = await orchestrator.submitMessage(
userId,
createdSession.sessionId,
{
clientMessageId: 'client-summary',
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。核心冲突是守灯会与沉船商盟争夺航道解释权。',
focusCardId: null,
selectedCardIds: [],
},
);
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
update.operation.operationId,
);
const items = await listCustomWorldWorkSummaries(userId, {
runtimeRepository,
customWorldAgentSessions: sessionStore,
});
const draft = items.find(
(item) => item.sessionId === createdSession.sessionId,
);
assert.ok(draft);
assert.match(draft?.title ?? '', //u);
assert.match(draft?.summary ?? '', //u);
assert.match(draft?.summary ?? '', //u);
});
test('phase2 merge keeps existing anchors while applying new patch', () => {
const merged = mergeCreatorIntentRecord(
{
sourceMode: 'freeform',
rawSettingText: '一个被潮雾切开的列岛世界。',
worldHook: '一个被潮雾切开的列岛世界。',
themeKeywords: [],
toneDirectives: [],
playerPremise: '玩家是失职返乡的守灯人。',
openingSituation: '',
coreConflicts: [],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: [],
forbiddenDirectives: [],
},
{
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
toneDirectives: ['冷峻'],
},
);
assert.equal(merged.playerPremise, '玩家是失职返乡的守灯人。');
assert.equal(merged.worldHook, '一个被潮雾切开的列岛世界。');
assert.deepEqual(merged.coreConflicts, ['守灯会与沉船商盟争夺航道解释权']);
assert.deepEqual(merged.toneDirectives, ['冷峻']);
});
test('phase2 merge replaces explicit rewrite arrays instead of appending them', () => {
const merged = mergeCreatorIntentRecord(
{
sourceMode: 'freeform',
rawSettingText: '',
worldHook: '一个被潮雾切开的列岛世界。',
themeKeywords: ['海岛', '旧案'],
toneDirectives: ['冷峻'],
playerPremise: '',
openingSituation: '',
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: [],
forbiddenDirectives: [],
},
{
themeKeywords: ['宫廷', '悬疑'],
coreConflicts: ['王庭继承人与旧灯塔盟约对抗'],
replaceFields: ['themeKeywords', 'coreConflicts'],
},
);
assert.deepEqual(merged.themeKeywords, ['宫廷', '悬疑']);
assert.deepEqual(merged.coreConflicts, ['王庭继承人与旧灯塔盟约对抗']);
assert.deepEqual(merged.toneDirectives, ['冷峻']);
});

View File

@@ -0,0 +1,259 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
const sessionsByUser = new Map<
string,
Map<string, CustomWorldSessionRecord>
>();
const profilesByUser = new Map<string, Record<string, unknown>[]>();
const getSessionBucket = (userId: string) => {
const existing = sessionsByUser.get(userId);
if (existing) {
return existing;
}
const nextBucket = new Map<string, CustomWorldSessionRecord>();
sessionsByUser.set(userId, nextBucket);
return nextBucket;
};
return {
async getSnapshot(_userId) {
return null;
},
async putSnapshot(_userId, _payload) {
throw new Error('not implemented');
},
async deleteSnapshot(_userId) {
return undefined;
},
async getSettings() {
return {
musicVolume: 0.42,
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles(userId) {
return [...(profilesByUser.get(userId) ?? [])];
},
async upsertCustomWorldProfile(userId, profileId, profile) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
current.unshift({
...profile,
id: profileId,
});
profilesByUser.set(userId, current);
return current;
},
async deleteCustomWorldProfile(userId, profileId) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
profilesByUser.set(userId, current);
return current;
},
async listCustomWorldSessions(userId) {
return [...getSessionBucket(userId).values()];
},
async getCustomWorldSession(userId, sessionId) {
return getSessionBucket(userId).get(sessionId) ?? null;
},
async upsertCustomWorldSession(userId, sessionId, session) {
getSessionBucket(userId).set(
sessionId,
JSON.parse(JSON.stringify(session)),
);
return JSON.parse(JSON.stringify(session));
},
};
}
async function waitForOperation(
orchestrator: CustomWorldAgentOrchestrator,
userId: string,
sessionId: string,
operationId: string,
) {
for (let attempt = 0; attempt < 50; attempt += 1) {
const operation = await orchestrator.getOperation(
userId,
sessionId,
operationId,
);
if (operation?.status === 'completed' || operation?.status === 'failed') {
return operation;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error('operation did not finish in time');
}
async function createReadySession(
orchestrator: CustomWorldAgentOrchestrator,
userId: string,
) {
const createdSession = await orchestrator.createSession(userId, {
seedText: '一个被潮雾切开的列岛世界。',
});
const message1 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
clientMessageId: 'phase3-ready-1',
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
focusCardId: null,
selectedCardIds: [],
});
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message1.operation.operationId,
);
const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
clientMessageId: 'phase3-ready-2',
text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。',
focusCardId: null,
selectedCardIds: [],
});
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message2.operation.operationId,
);
const readySession = await orchestrator.getSessionSnapshot(
userId,
createdSession.sessionId,
);
assert.equal(readySession?.stage, 'foundation_review');
assert.equal(readySession?.creatorIntentReadiness.isReady, true);
return readySession!;
}
test('phase3 ready session can execute draft_foundation and expose card detail', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase3-draft';
const readySession = await createReadySession(orchestrator, userId);
const response = await orchestrator.executeAction(
userId,
readySession.sessionId,
{
action: 'draft_foundation',
},
);
const operation = await waitForOperation(
orchestrator,
userId,
readySession.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, readySession.sessionId);
assert.equal(operation?.status, 'completed');
assert.equal(snapshot?.stage, 'object_refining');
assert.ok(snapshot?.draftCards.length);
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'world'));
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'faction'));
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'character'));
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'landmark'));
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'thread'));
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'chapter'));
assert.equal(
typeof (snapshot?.draftProfile as Record<string, unknown>)?.name,
'string',
);
assert.ok(
snapshot?.messages.some(
(message) =>
message.role === 'assistant' &&
message.text.includes('第一版世界底稿整理出来了'),
),
);
const worldCard = snapshot?.draftCards.find((card) => card.kind === 'world');
assert.ok(worldCard);
const detail = await orchestrator.getCardDetail(
userId,
readySession.sessionId,
worldCard!.id,
);
assert.ok(detail);
assert.equal(detail?.kind, 'world');
assert.ok(detail?.sections.length);
assert.ok(detail?.sections.some((section) => section.label === '世界一句话'));
});
test('phase3 draft_foundation rejects not-ready session', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase3-not-ready';
const createdSession = await orchestrator.createSession(userId, {
seedText: '一个被潮雾切开的列岛世界。',
});
await assert.rejects(
() =>
orchestrator.executeAction(userId, createdSession.sessionId, {
action: 'draft_foundation',
}),
/ready session|foundation_review/u,
);
});
test('phase3 work summaries prefer compiled foundation draft fields', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase3-summary';
const readySession = await createReadySession(orchestrator, userId);
const response = await orchestrator.executeAction(
userId,
readySession.sessionId,
{
action: 'draft_foundation',
},
);
await waitForOperation(
orchestrator,
userId,
readySession.sessionId,
response.operation.operationId,
);
const items = await listCustomWorldWorkSummaries(userId, {
runtimeRepository,
customWorldAgentSessions: sessionStore,
});
const draft = items.find((item) => item.sessionId === readySession.sessionId);
assert.ok(draft);
assert.ok((draft?.playableNpcCount ?? 0) >= 3);
assert.ok((draft?.landmarkCount ?? 0) >= 4);
assert.match(draft?.summary ?? '', /||/u);
assert.match(draft?.subtitle ?? '', /||/u);
});

View File

@@ -0,0 +1,311 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
const sessionsByUser = new Map<
string,
Map<string, CustomWorldSessionRecord>
>();
const profilesByUser = new Map<string, Record<string, unknown>[]>();
const getSessionBucket = (userId: string) => {
const existing = sessionsByUser.get(userId);
if (existing) {
return existing;
}
const nextBucket = new Map<string, CustomWorldSessionRecord>();
sessionsByUser.set(userId, nextBucket);
return nextBucket;
};
return {
async getSnapshot(_userId) {
return null;
},
async putSnapshot(_userId, _payload) {
throw new Error('not implemented');
},
async deleteSnapshot(_userId) {
return undefined;
},
async getSettings() {
return {
musicVolume: 0.42,
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles(userId) {
return [...(profilesByUser.get(userId) ?? [])];
},
async upsertCustomWorldProfile(userId, profileId, profile) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
current.unshift({
...profile,
id: profileId,
});
profilesByUser.set(userId, current);
return current;
},
async deleteCustomWorldProfile(userId, profileId) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
profilesByUser.set(userId, current);
return current;
},
async listCustomWorldSessions(userId) {
return [...getSessionBucket(userId).values()];
},
async getCustomWorldSession(userId, sessionId) {
return getSessionBucket(userId).get(sessionId) ?? null;
},
async upsertCustomWorldSession(userId, sessionId, session) {
getSessionBucket(userId).set(
sessionId,
JSON.parse(JSON.stringify(session)),
);
return JSON.parse(JSON.stringify(session));
},
};
}
async function waitForOperation(
orchestrator: CustomWorldAgentOrchestrator,
userId: string,
sessionId: string,
operationId: string,
) {
for (let attempt = 0; attempt < 60; attempt += 1) {
const operation = await orchestrator.getOperation(
userId,
sessionId,
operationId,
);
if (operation?.status === 'completed' || operation?.status === 'failed') {
return operation;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error('operation did not finish in time');
}
async function createObjectRefiningSession(
orchestrator: CustomWorldAgentOrchestrator,
userId: string,
) {
const createdSession = await orchestrator.createSession(userId, {
seedText: '一个被潮雾切开的列岛世界。',
});
const message1 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
clientMessageId: 'phase4-ready-1',
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
focusCardId: null,
selectedCardIds: [],
});
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message1.operation.operationId,
);
const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
clientMessageId: 'phase4-ready-2',
text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。',
focusCardId: null,
selectedCardIds: [],
});
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message2.operation.operationId,
);
const foundationOperation = await orchestrator.executeAction(
userId,
createdSession.sessionId,
{
action: 'draft_foundation',
},
);
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
foundationOperation.operation.operationId,
);
return (await orchestrator.getSessionSnapshot(
userId,
createdSession.sessionId,
))!;
}
test('phase4 update_draft_card writes back draft profile and recompiles summaries', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase4-edit';
const session = await createObjectRefiningSession(orchestrator, userId);
const characterCard = session.draftCards.find((card) => card.kind === 'character');
assert.ok(characterCard);
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'update_draft_card',
cardId: characterCard!.id,
sections: [
{
sectionId: 'publicMask',
value: '表面上仍是守灯会里最懂旧航道的人。',
},
{
sectionId: 'relationToPlayer',
value: '和玩家共享一段无法轻易翻篇的旧灯塔往事。',
},
{
sectionId: 'summary',
value: '他像旧友,也像最早知道航道秘密的人。',
},
],
});
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 editedCharacter = [...(profile?.playableNpcs ?? []), ...(profile?.storyNpcs ?? [])].find(
(entry) => entry.id === characterCard!.id,
);
const editedCard = snapshot?.draftCards.find((card) => card.id === characterCard!.id);
assert.equal(operation?.status, 'completed');
assert.equal(
editedCharacter?.publicMask,
'表面上仍是守灯会里最懂旧航道的人。',
);
assert.equal(
editedCharacter?.relationToPlayer,
'和玩家共享一段无法轻易翻篇的旧灯塔往事。',
);
assert.equal(editedCard?.summary, '他像旧友,也像最早知道航道秘密的人。');
assert.ok(
snapshot?.messages.some(
(message) =>
message.kind === 'action_result' && message.text.includes('已更新'),
),
);
});
test('phase4 generate_characters appends story npcs and updates work summary counts', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase4-characters';
const session = await createObjectRefiningSession(orchestrator, userId);
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!;
const baselineCharacterCount = [
...new Set(
[...baselineProfile.playableNpcs, ...baselineProfile.storyNpcs].map(
(entry) => entry.id,
),
),
].length;
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'generate_characters',
count: 2,
promptText: '补两位更贴近旧航道线的边缘角色。',
anchorCardIds: [session.draftCards.find((card) => card.kind === 'thread')!.id],
});
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 nextCharacterCount = [
...new Set(
[...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id),
),
].length;
const workItems = await listCustomWorldWorkSummaries(userId, {
runtimeRepository,
customWorldAgentSessions: sessionStore,
});
const draftItem = workItems.find((item) => item.sessionId === session.sessionId);
assert.equal(operation?.status, 'completed');
assert.ok(profile.storyNpcs.length >= 2);
assert.ok(nextCharacterCount >= baselineCharacterCount + 2);
assert.ok(snapshot?.draftCards.filter((card) => card.kind === 'character').length);
assert.ok(snapshot?.focusCardId);
assert.ok(
snapshot?.messages.some(
(message) =>
message.kind === 'action_result' && message.text.includes('新角色'),
),
);
assert.ok((draftItem?.playableNpcCount ?? 0) >= baselineCharacterCount + 2);
});
test('phase4 generate_landmarks appends new landmark cards and checkpoints', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase4-landmarks';
const session = await createObjectRefiningSession(orchestrator, userId);
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!;
const baselineLandmarkCount = baselineProfile.landmarks.length;
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'generate_landmarks',
count: 2,
promptText: '补两个适合藏旧航道秘密的地点。',
anchorCardIds: [session.draftCards.find((card) => card.kind === 'character')!.id],
});
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 latestSessionRecord = await sessionStore.get(userId, session.sessionId);
assert.equal(operation?.status, 'completed');
assert.ok(profile.landmarks.length >= baselineLandmarkCount + 2);
assert.ok(
snapshot?.draftCards.filter((card) => card.kind === 'landmark').length,
);
assert.ok(
snapshot?.messages.some(
(message) =>
message.kind === 'action_result' && message.text.includes('新地点'),
),
);
assert.ok((latestSessionRecord?.checkpoints.length ?? 0) >= 2);
});

View File

@@ -0,0 +1,276 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
const sessionsByUser = new Map<
string,
Map<string, CustomWorldSessionRecord>
>();
const profilesByUser = new Map<string, Record<string, unknown>[]>();
const getSessionBucket = (userId: string) => {
const existing = sessionsByUser.get(userId);
if (existing) {
return existing;
}
const nextBucket = new Map<string, CustomWorldSessionRecord>();
sessionsByUser.set(userId, nextBucket);
return nextBucket;
};
return {
async getSnapshot() {
return null;
},
async putSnapshot(_userId, payload) {
return payload;
},
async deleteSnapshot() {
return undefined;
},
async getSettings() {
return {
musicVolume: 0.42,
};
},
async putSettings(_userId, settings) {
return settings;
},
async listCustomWorldProfiles(userId) {
return [...(profilesByUser.get(userId) ?? [])];
},
async upsertCustomWorldProfile(userId, profileId, profile) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
current.unshift({
...profile,
id: profileId,
});
profilesByUser.set(userId, current);
return current;
},
async deleteCustomWorldProfile(userId, profileId) {
const current = [...(profilesByUser.get(userId) ?? [])].filter(
(item) => String(item.id ?? '') !== profileId,
);
profilesByUser.set(userId, current);
return current;
},
async listCustomWorldSessions(userId) {
return [...getSessionBucket(userId).values()];
},
async getCustomWorldSession(userId, sessionId) {
return getSessionBucket(userId).get(sessionId) ?? null;
},
async upsertCustomWorldSession(userId, sessionId, session) {
getSessionBucket(userId).set(
sessionId,
JSON.parse(JSON.stringify(session)),
);
return JSON.parse(JSON.stringify(session));
},
};
}
async function waitForOperation(
orchestrator: CustomWorldAgentOrchestrator,
userId: string,
sessionId: string,
operationId: string,
) {
for (let attempt = 0; attempt < 60; attempt += 1) {
const operation = await orchestrator.getOperation(
userId,
sessionId,
operationId,
);
if (operation?.status === 'completed' || operation?.status === 'failed') {
return operation;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error('operation did not finish in time');
}
async function createObjectRefiningSession(
orchestrator: CustomWorldAgentOrchestrator,
userId: string,
) {
const createdSession = await orchestrator.createSession(userId, {
seedText: '一个被潮雾切开的列岛世界。',
});
const message1 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
clientMessageId: 'phase5-ready-1',
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
focusCardId: null,
selectedCardIds: [],
});
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message1.operation.operationId,
);
const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
clientMessageId: 'phase5-ready-2',
text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。',
focusCardId: null,
selectedCardIds: [],
});
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
message2.operation.operationId,
);
const foundationOperation = await orchestrator.executeAction(
userId,
createdSession.sessionId,
{
action: 'draft_foundation',
},
);
await waitForOperation(
orchestrator,
userId,
createdSession.sessionId,
foundationOperation.operation.operationId,
);
return (await orchestrator.getSessionSnapshot(
userId,
createdSession.sessionId,
))!;
}
test('phase5 generate_role_assets only allows a single role and moves session into visual_refining', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase5-generate-role-assets';
const session = await createObjectRefiningSession(orchestrator, userId);
const characterIds = session.draftCards
.filter((card) => card.kind === 'character')
.map((card) => card.id);
await assert.rejects(
orchestrator.executeAction(userId, session.sessionId, {
action: 'generate_role_assets',
roleIds: characterIds.slice(0, 2),
}),
);
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'generate_role_assets',
roleIds: [characterIds[0]!],
});
const operation = await waitForOperation(
orchestrator,
userId,
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
assert.equal(operation?.status, 'completed');
assert.equal(snapshot?.stage, 'visual_refining');
assert.equal(snapshot?.focusCardId, characterIds[0]);
assert.ok(
snapshot?.messages.some(
(message) =>
message.kind === 'action_result' &&
message.text.includes('角色资产工坊'),
),
);
});
test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
const userId = 'user-phase5-sync-role-assets';
const session = await createObjectRefiningSession(orchestrator, userId);
const characterCard = session.draftCards.find((card) => card.kind === 'character');
assert.ok(characterCard);
const prepareResponse = await orchestrator.executeAction(
userId,
session.sessionId,
{
action: 'generate_role_assets',
roleIds: [characterCard!.id],
},
);
await waitForOperation(
orchestrator,
userId,
session.sessionId,
prepareResponse.operation.operationId,
);
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'sync_role_assets',
roleId: characterCard!.id,
portraitPath: '/generated/characters/shenli-portrait.png',
generatedVisualAssetId: 'visual-shenli-1',
generatedAnimationSetId: 'animation-set-shenli-1',
animationMap: {
idle: { basePath: '/generated/characters/shenli/idle' },
run: { basePath: '/generated/characters/shenli/run' },
attack: { basePath: '/generated/characters/shenli/attack' },
hurt: { basePath: '/generated/characters/shenli/hurt' },
die: { basePath: '/generated/characters/shenli/die' },
},
});
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 syncedRole = [...(profile?.playableNpcs ?? []), ...(profile?.storyNpcs ?? [])].find(
(entry) => entry.id === characterCard!.id,
);
const syncedCard = snapshot?.draftCards.find((card) => card.id === characterCard!.id);
const syncedAssetSummary = snapshot?.assetCoverage.roleAssets.find(
(entry) => entry.roleId === characterCard!.id,
);
const latestRecord = await sessionStore.get(userId, session.sessionId);
assert.equal(operation?.status, 'completed');
assert.equal(syncedRole?.imageSrc, '/generated/characters/shenli-portrait.png');
assert.equal(syncedRole?.generatedVisualAssetId, 'visual-shenli-1');
assert.equal(syncedRole?.generatedAnimationSetId, 'animation-set-shenli-1');
assert.equal(
(syncedRole?.animationMap as Record<string, { basePath?: string }> | null)?.idle
?.basePath,
'/generated/characters/shenli/idle',
);
assert.equal(syncedAssetSummary?.status, 'complete');
assert.equal(syncedCard?.assetStatusLabel, '动作已就绪');
assert.ok(syncedCard?.subtitle.includes('动作已就绪'));
assert.ok(
snapshot?.messages.some(
(message) =>
message.kind === 'action_result' && message.text.includes('动作已就绪'),
),
);
assert.ok((latestRecord?.checkpoints.length ?? 0) >= 2);
});

View File

@@ -0,0 +1,295 @@
import type {
CustomWorldAssetCoverageSummary,
CustomWorldAssetPriorityTier,
CustomWorldRoleAssetStatus,
CustomWorldRoleAssetSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
const CORE_ROLE_ANIMATION_KEYS = [
'idle',
'run',
'attack',
'hurt',
'die',
] as const;
type DraftRoleRecord = {
id: string;
name: string;
threadIds: string[];
imageSrc?: string | null;
generatedVisualAssetId?: string | null;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
};
type DraftRoleKind = 'playable' | 'story';
type MergeRoleAssetIntoDraftProfilePayload = {
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
};
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
)
: [];
}
function toStringArray(value: unknown) {
return Array.isArray(value)
? value
.map((item) => toText(item))
.filter(Boolean)
.slice(0, 12)
: [];
}
function toAnimationMap(value: unknown) {
return toRecord(value);
}
function hasAnimationSlot(
animationMap: Record<string, unknown> | null | undefined,
slot: string,
) {
const entry = toRecord(animationMap?.[slot]);
if (!entry) {
return false;
}
return Boolean(toText(entry.basePath) || toText(entry.spriteSheetPath));
}
function resolvePriorityTier(
role: DraftRoleRecord,
roleKind: DraftRoleKind,
): CustomWorldAssetPriorityTier {
if (roleKind === 'playable') {
return 'hero';
}
return role.threadIds.length > 0 ? 'featured' : 'supporting';
}
function resolveNextPointCost(
status: CustomWorldRoleAssetStatus,
priorityTier: CustomWorldAssetPriorityTier,
) {
if (status === 'complete') {
return 0;
}
if (status === 'missing') {
return priorityTier === 'supporting' ? 12 : 20;
}
return priorityTier === 'supporting' ? 36 : 60;
}
function collectDraftRoles(profileInput: unknown) {
const profile = toRecord(profileInput);
if (!profile) {
return [] as Array<{ role: DraftRoleRecord; roleKind: DraftRoleKind }>;
}
const normalizeRole = (
item: Record<string, unknown>,
): DraftRoleRecord | null => {
const id = toText(item.id);
const name = toText(item.name);
if (!id || !name) {
return null;
}
return {
id,
name,
threadIds: toStringArray(item.threadIds),
imageSrc: toText(item.imageSrc) || null,
generatedVisualAssetId: toText(item.generatedVisualAssetId) || null,
generatedAnimationSetId: toText(item.generatedAnimationSetId) || null,
animationMap: toAnimationMap(item.animationMap),
};
};
return [
...toRecordArray(profile.playableNpcs)
.map((item) => {
const role = normalizeRole(item);
return role ? { role, roleKind: 'playable' as const } : null;
})
.filter(
(
item,
): item is {
role: DraftRoleRecord;
roleKind: DraftRoleKind;
} => Boolean(item),
),
...toRecordArray(profile.storyNpcs)
.map((item) => {
const role = normalizeRole(item);
return role ? { role, roleKind: 'story' as const } : null;
})
.filter(
(
item,
): item is {
role: DraftRoleRecord;
roleKind: DraftRoleKind;
} => Boolean(item),
),
];
}
export function resolveRoleAssetStatusLabel(status: CustomWorldRoleAssetStatus) {
if (status === 'complete') {
return '动作已就绪';
}
if (status === 'animations_ready') {
return '动作补齐中';
}
if (status === 'visual_ready') {
return '主图已就绪';
}
return '待生成主图';
}
export function buildRoleAssetSummary(params: {
role: DraftRoleRecord;
roleKind: DraftRoleKind;
}): CustomWorldRoleAssetSummary {
const { role, roleKind } = params;
const priorityTier = resolvePriorityTier(role, roleKind);
const missingAnimations = CORE_ROLE_ANIMATION_KEYS.filter(
(slot) => !hasAnimationSlot(role.animationMap, slot),
);
const hasPortrait =
Boolean(role.imageSrc) && Boolean(role.generatedVisualAssetId);
const hasAnimationSet = Boolean(role.generatedAnimationSetId);
const status: CustomWorldRoleAssetStatus = !hasPortrait
? 'missing'
: missingAnimations.length === 0
? 'complete'
: hasAnimationSet
? 'animations_ready'
: 'visual_ready';
return {
roleId: role.id,
roleName: role.name,
roleKind,
priorityTier,
portraitPath: role.imageSrc ?? null,
generatedVisualAssetId: role.generatedVisualAssetId ?? null,
generatedAnimationSetId: role.generatedAnimationSetId ?? null,
status,
missingAnimations,
nextPointCost: resolveNextPointCost(status, priorityTier),
};
}
export function getRoleAssetSummaryById(
draftProfile: unknown,
roleId: string,
) {
const roleEntry = collectDraftRoles(draftProfile).find(
(entry) => entry.role.id === roleId,
);
if (!roleEntry) {
return null;
}
return buildRoleAssetSummary(roleEntry);
}
export function rebuildRoleAssetCoverage(
draftProfile: unknown,
): CustomWorldAssetCoverageSummary {
const roleAssets = collectDraftRoles(draftProfile).map((entry) =>
buildRoleAssetSummary(entry),
);
return {
roleAssets,
sceneAssets: [],
allRoleAssetsReady:
roleAssets.length > 0 &&
roleAssets.every((entry) => entry.status === 'complete'),
allSceneAssetsReady: false,
};
}
export function mergeRoleAssetIntoDraftProfile(
draftProfileInput: Record<string, unknown>,
payload: MergeRoleAssetIntoDraftProfilePayload,
) {
const nextDraftProfile = {
...draftProfileInput,
};
let updatedRole: Record<string, unknown> | null = null;
const updateRoleList = (field: 'playableNpcs' | 'storyNpcs') => {
const currentList = toRecordArray(nextDraftProfile[field]);
let touched = false;
const nextList = currentList.map((item) => {
if (toText(item.id) !== payload.roleId) {
return item;
}
touched = true;
updatedRole = {
...item,
imageSrc: payload.portraitPath,
generatedVisualAssetId: payload.generatedVisualAssetId,
};
if (payload.generatedAnimationSetId !== undefined) {
updatedRole.generatedAnimationSetId = payload.generatedAnimationSetId;
}
if (payload.animationMap !== undefined) {
updatedRole.animationMap = payload.animationMap;
}
return updatedRole;
});
if (touched) {
nextDraftProfile[field] = nextList;
}
return touched;
};
const touched =
updateRoleList('playableNpcs') || updateRoleList('storyNpcs');
if (!touched || !updatedRole) {
throw new Error('目标角色不存在,无法同步角色资产。');
}
return {
draftProfile: nextDraftProfile,
updatedRole,
};
}

View File

@@ -0,0 +1,711 @@
import crypto from 'node:crypto';
import type {
CustomWorldAssetCoverageSummary,
CreatorIntentReadiness,
CustomWorldAgentMessage,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
CustomWorldAgentStage,
CustomWorldDraftCardSummary,
CustomWorldPendingClarification,
CustomWorldSuggestedAction,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { CustomWorldSessionRecord as LegacyCustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import {
buildPendingClarifications,
evaluateCreatorIntentReadiness,
resolveCreatorIntentStage,
} from './customWorldAgentClarificationService.js';
import {
buildAnchorPackFromIntent,
buildDraftSummaryFromIntent,
buildDraftTitleFromIntent,
createEmptyCreatorIntentRecord,
extractCreatorIntentPatch,
mergeCreatorIntentRecord,
normalizeCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
import { rebuildRoleAssetCoverage } from './customWorldAgentRoleAssetStateService.js';
export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX =
'custom-world-agent-session-';
export type CustomWorldAgentSessionRecord = {
sessionId: string;
userId: string;
seedText: string;
stage: CustomWorldAgentStage;
focusCardId: string | null;
creatorIntent: Record<string, unknown> | null;
creatorIntentReadiness: CreatorIntentReadiness;
anchorPack: Record<string, unknown> | null;
lockState: Record<string, unknown> | null;
draftProfile: Record<string, unknown> | null;
messages: CustomWorldAgentMessage[];
draftCards: CustomWorldDraftCardSummary[];
pendingClarifications: CustomWorldPendingClarification[];
suggestedActions: CustomWorldSuggestedAction[];
recommendedReplies: string[];
qualityFindings: Array<{
id: string;
severity: 'info' | 'warning' | 'blocker';
code: string;
targetId?: string | null;
message: string;
}>;
assetCoverage: CustomWorldAssetCoverageSummary;
operations: CustomWorldAgentOperationRecord[];
checkpoints: Array<{
checkpointId: string;
createdAt: string;
label: string;
}>;
createdAt: string;
updatedAt: string;
};
type CreateSessionInput = {
seedText?: string;
welcomeMessage: string;
pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications'];
creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent'];
creatorIntentReadiness?: CreatorIntentReadiness;
anchorPack?: CustomWorldAgentSessionRecord['anchorPack'];
draftProfile?: CustomWorldAgentSessionRecord['draftProfile'];
stage?: CustomWorldAgentStage;
suggestedActions: CustomWorldSuggestedAction[];
recommendedReplies?: string[];
};
function cloneRecord<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function isStage(value: unknown): value is CustomWorldAgentStage {
return (
value === 'collecting_intent' ||
value === 'clarifying' ||
value === 'foundation_review' ||
value === 'object_refining' ||
value === 'visual_refining' ||
value === 'long_tail_review' ||
value === 'ready_to_publish' ||
value === 'published' ||
value === 'error'
);
}
function isAgentSessionRecord(
value: unknown,
): value is CustomWorldAgentSessionRecord {
const record = toRecord(value);
if (!record) {
return false;
}
return (
typeof record.sessionId === 'string' &&
record.sessionId.startsWith(CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX) &&
typeof record.userId === 'string' &&
isStage(record.stage) &&
Array.isArray(record.messages) &&
Array.isArray(record.operations) &&
typeof record.createdAt === 'string' &&
typeof record.updatedAt === 'string'
);
}
function isCreatorIntentReadiness(
value: unknown,
): value is CreatorIntentReadiness {
const record = toRecord(value);
if (!record) {
return false;
}
return (
typeof record.isReady === 'boolean' &&
Array.isArray(record.completedKeys) &&
Array.isArray(record.missingKeys)
);
}
function mapLegacyClarificationTargetKey(id: string) {
if (id === 'world_hook') return 'world_hook';
if (id === 'player_premise') return 'player_premise';
if (id === 'theme_and_tone' || id === 'tone_boundary') {
return 'theme_and_tone';
}
if (id === 'core_conflict') return 'core_conflict';
if (id === 'relationship_seed' || id === 'relationship_hook') {
return 'relationship_seed';
}
if (id === 'iconic_element' || id === 'iconic_elements') {
return 'iconic_element';
}
return null;
}
function hasUserInput(record: CustomWorldAgentSessionRecord) {
return (
Boolean(record.seedText.trim()) ||
record.messages.some(
(message) => message.role === 'user' && message.text.trim(),
)
);
}
function buildCompatibleCreatorIntent(record: CustomWorldAgentSessionRecord) {
const existingIntent =
normalizeCreatorIntentRecord(record.creatorIntent) ??
createEmptyCreatorIntentRecord('freeform');
if (!record.seedText.trim()) {
return existingIntent;
}
const seedPatch = extractCreatorIntentPatch({
currentIntent: existingIntent,
latestUserMessage: record.seedText,
});
return mergeCreatorIntentRecord(existingIntent, seedPatch);
}
function buildCompatibleReadiness(record: CustomWorldAgentSessionRecord) {
if (
isCreatorIntentReadiness(
(record as Record<string, unknown>).creatorIntentReadiness,
)
) {
return record.creatorIntentReadiness;
}
return evaluateCreatorIntentReadiness(
normalizeCreatorIntentRecord(record.creatorIntent),
);
}
function buildCompatiblePendingClarifications(
record: CustomWorldAgentSessionRecord,
) {
const normalizedIntent = normalizeCreatorIntentRecord(record.creatorIntent);
const readiness = buildCompatibleReadiness(record);
const legacyClarifications = Array.isArray(record.pendingClarifications)
? record.pendingClarifications
: [];
const nextClarifications = legacyClarifications
.map((entry, index) => {
const targetKey = mapLegacyClarificationTargetKey(entry.id);
if (!targetKey) {
return null;
}
return {
id: entry.id || targetKey,
label: entry.label || '待补充问题',
question: entry.question || '',
targetKey,
priority:
typeof entry.priority === 'number' ? entry.priority : index + 1,
answer: entry.answer,
} satisfies CustomWorldPendingClarification;
})
.filter((entry): entry is CustomWorldPendingClarification =>
Boolean(entry?.question),
)
.slice(0, 3);
if (nextClarifications.length > 0) {
return nextClarifications;
}
return buildPendingClarifications(normalizedIntent, readiness);
}
function buildCompatibleDraftProfile(
record: CustomWorldAgentSessionRecord,
creatorIntent: ReturnType<typeof buildCompatibleCreatorIntent>,
) {
const existingDraftProfile = toRecord(record.draftProfile);
const hasFoundationContent = Boolean(
existingDraftProfile &&
(typeof existingDraftProfile.name === 'string' ||
Array.isArray(existingDraftProfile.playableNpcs) ||
Array.isArray(existingDraftProfile.landmarks) ||
Array.isArray(existingDraftProfile.factions) ||
Array.isArray(existingDraftProfile.threads) ||
Array.isArray(existingDraftProfile.chapters)),
);
if (hasFoundationContent) {
return {
...existingDraftProfile,
name:
toText(existingDraftProfile?.name) ||
toText(existingDraftProfile?.title) ||
buildDraftTitleFromIntent(creatorIntent),
summary:
toText(existingDraftProfile?.summary) ||
buildDraftSummaryFromIntent(creatorIntent),
};
}
return {
...(existingDraftProfile ?? {}),
title:
toText(existingDraftProfile?.title) || buildDraftTitleFromIntent(creatorIntent),
summary:
toText(existingDraftProfile?.summary) ||
buildDraftSummaryFromIntent(creatorIntent),
};
}
function buildCompatibleSuggestedActions(params: {
record: CustomWorldAgentSessionRecord;
stage: CustomWorldAgentStage;
readiness: CreatorIntentReadiness;
draftProfile: Record<string, unknown>;
}) {
if (params.record.suggestedActions.length > 0) {
return params.record.suggestedActions;
}
const actions: CustomWorldSuggestedAction[] = [
{
id: 'request_summary',
type: 'request_summary',
label:
params.stage === 'object_refining' || params.stage === 'visual_refining'
? '总结当前世界底稿'
: '总结当前设定',
},
];
const playableNpcs = Array.isArray(params.draftProfile.playableNpcs)
? params.draftProfile.playableNpcs
: [];
const storyNpcs = Array.isArray(params.draftProfile.storyNpcs)
? params.draftProfile.storyNpcs
: [];
const landmarks = Array.isArray(params.draftProfile.landmarks)
? params.draftProfile.landmarks
: [];
if (params.stage === 'foundation_review' && params.readiness.isReady) {
actions.push({
id: 'draft_foundation',
type: 'draft_foundation',
label: '整理一版世界底稿',
});
return actions;
}
if (params.stage === 'object_refining' || params.stage === 'visual_refining') {
const firstCharacter = toRecord([...playableNpcs, ...storyNpcs][0]);
const firstLandmark = toRecord(landmarks[0]);
actions.push({
id: 'refine_world',
type: 'refine_focus_target',
label: '先看世界总卡',
targetId: 'world-foundation',
});
if (firstCharacter) {
actions.push({
id: `refine-character-${toText(firstCharacter.id) || 'seed'}`,
type: 'refine_focus_target',
label: `精修角色:${toText(firstCharacter.name) || '关键角色'}`,
targetId: toText(firstCharacter.id) || null,
});
}
if (firstLandmark) {
actions.push({
id: `refine-landmark-${toText(firstLandmark.id) || 'seed'}`,
type: 'refine_focus_target',
label: `继续补地点:${toText(firstLandmark.name) || '关键地点'}`,
targetId: toText(firstLandmark.id) || null,
});
}
}
return actions;
}
function normalizeRecommendedReplies(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => toText(item))
.filter(Boolean)
.slice(0, 3);
}
function buildCompatibleAssetCoverage(
record: CustomWorldAgentSessionRecord,
draftProfile: Record<string, unknown>,
) {
const derivedCoverage = rebuildRoleAssetCoverage(draftProfile);
const existingCoverage = toRecord(record.assetCoverage);
const sceneAssets = Array.isArray(existingCoverage?.sceneAssets)
? existingCoverage.sceneAssets
: [];
const allSceneAssetsReady =
typeof existingCoverage?.allSceneAssetsReady === 'boolean'
? existingCoverage.allSceneAssetsReady
: false;
return {
...derivedCoverage,
sceneAssets,
allSceneAssetsReady,
} satisfies CustomWorldAssetCoverageSummary;
}
function applyCompatibility(record: CustomWorldAgentSessionRecord) {
const creatorIntent = buildCompatibleCreatorIntent(record);
const creatorIntentReadiness = evaluateCreatorIntentReadiness(creatorIntent);
const stage =
record.stage === 'collecting_intent' ||
record.stage === 'clarifying' ||
record.stage === 'foundation_review'
? resolveCreatorIntentStage({
hasUserInput: hasUserInput(record),
readiness: creatorIntentReadiness,
})
: record.stage;
const pendingClarifications = buildCompatiblePendingClarifications({
...record,
creatorIntent,
creatorIntentReadiness,
});
const draftProfile = buildCompatibleDraftProfile(record, creatorIntent);
return {
...record,
stage,
creatorIntent,
creatorIntentReadiness,
anchorPack:
record.anchorPack && Object.keys(record.anchorPack).length > 0
? record.anchorPack
: buildAnchorPackFromIntent(creatorIntent, {
completedKeys: creatorIntentReadiness.completedKeys,
missingKeys: creatorIntentReadiness.missingKeys,
}),
draftProfile,
pendingClarifications,
suggestedActions: buildCompatibleSuggestedActions({
record,
stage,
readiness: creatorIntentReadiness,
draftProfile,
}),
assetCoverage: buildCompatibleAssetCoverage(record, draftProfile),
recommendedReplies: normalizeRecommendedReplies(
(record as Record<string, unknown>).recommendedReplies,
),
} satisfies CustomWorldAgentSessionRecord;
}
function toSnapshot(
record: CustomWorldAgentSessionRecord,
): CustomWorldAgentSessionSnapshot {
return {
sessionId: record.sessionId,
stage: record.stage,
focusCardId: record.focusCardId,
creatorIntent: cloneRecord(record.creatorIntent),
creatorIntentReadiness: cloneRecord(record.creatorIntentReadiness),
anchorPack: cloneRecord(record.anchorPack),
lockState: cloneRecord(record.lockState),
draftProfile: cloneRecord(record.draftProfile),
messages: cloneRecord(record.messages),
draftCards: cloneRecord(record.draftCards),
pendingClarifications: cloneRecord(record.pendingClarifications),
suggestedActions: cloneRecord(record.suggestedActions),
recommendedReplies: cloneRecord(record.recommendedReplies),
qualityFindings: cloneRecord(record.qualityFindings),
assetCoverage: cloneRecord(record.assetCoverage),
updatedAt: record.updatedAt,
};
}
export class CustomWorldAgentSessionStore {
constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {}
private async persist(record: CustomWorldAgentSessionRecord) {
await this.runtimeRepository.upsertCustomWorldSession(
record.userId,
record.sessionId,
record as unknown as LegacyCustomWorldSessionRecord,
);
return cloneRecord(record);
}
private async mutate(
userId: string,
sessionId: string,
mutateFn: (record: CustomWorldAgentSessionRecord) => void,
) {
const current = await this.get(userId, sessionId);
if (!current) {
return null;
}
const nextRecord = cloneRecord(current);
mutateFn(nextRecord);
nextRecord.updatedAt = new Date().toISOString();
return this.persist(nextRecord);
}
async create(userId: string, input: CreateSessionInput) {
const sessionId = `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}${crypto.randomBytes(16).toString('hex')}`;
const now = new Date().toISOString();
const welcomeMessage: CustomWorldAgentMessage = {
id: `message-${crypto.randomBytes(8).toString('hex')}`,
role: 'assistant',
kind: 'chat',
text: input.welcomeMessage,
createdAt: now,
relatedOperationId: null,
};
const record: CustomWorldAgentSessionRecord = {
sessionId,
userId,
seedText: input.seedText?.trim() ?? '',
stage: input.stage ?? 'collecting_intent',
focusCardId: null,
creatorIntent: cloneRecord(input.creatorIntent ?? {}),
creatorIntentReadiness: input.creatorIntentReadiness ?? {
isReady: false,
completedKeys: [],
missingKeys: [],
},
anchorPack: cloneRecord(input.anchorPack ?? {}),
lockState: {},
draftProfile: cloneRecord(input.draftProfile ?? {}),
messages: [welcomeMessage],
draftCards: [],
pendingClarifications: cloneRecord(input.pendingClarifications),
suggestedActions: cloneRecord(input.suggestedActions),
recommendedReplies: cloneRecord(input.recommendedReplies ?? []),
qualityFindings: [],
assetCoverage: rebuildRoleAssetCoverage(input.draftProfile ?? {}),
operations: [],
checkpoints: [],
createdAt: now,
updatedAt: now,
};
const compatibleRecord = applyCompatibility(record);
await this.persist(compatibleRecord);
return cloneRecord(compatibleRecord);
}
async list(userId: string) {
const records =
await this.runtimeRepository.listCustomWorldSessions(userId);
return records
.filter((record) => isAgentSessionRecord(record))
.map((record) => cloneRecord(applyCompatibility(record)))
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
async get(userId: string, sessionId: string) {
if (!sessionId.trim()) {
return null;
}
const record = await this.runtimeRepository.getCustomWorldSession(
userId,
sessionId,
);
if (!isAgentSessionRecord(record)) {
return null;
}
return cloneRecord(applyCompatibility(record));
}
async getSnapshot(userId: string, sessionId: string) {
const record = await this.get(userId, sessionId);
return record ? toSnapshot(record) : null;
}
async appendMessage(
userId: string,
sessionId: string,
message: CustomWorldAgentMessage,
) {
return this.mutate(userId, sessionId, (record) => {
record.messages.push(cloneRecord(message));
});
}
async replaceDerivedState(
userId: string,
sessionId: string,
patch: Partial<
Pick<
CustomWorldAgentSessionRecord,
| 'stage'
| 'creatorIntent'
| 'creatorIntentReadiness'
| 'anchorPack'
| 'lockState'
| 'draftProfile'
| 'pendingClarifications'
| 'suggestedActions'
| 'recommendedReplies'
| 'draftCards'
| 'qualityFindings'
| 'focusCardId'
| 'assetCoverage'
>
>,
) {
return this.mutate(userId, sessionId, (record) => {
if (patch.stage) {
record.stage = patch.stage;
}
if (patch.focusCardId !== undefined) {
record.focusCardId = patch.focusCardId;
}
if (patch.creatorIntent !== undefined) {
record.creatorIntent = cloneRecord(patch.creatorIntent);
}
if (patch.creatorIntentReadiness !== undefined) {
record.creatorIntentReadiness = cloneRecord(
patch.creatorIntentReadiness,
);
}
if (patch.anchorPack !== undefined) {
record.anchorPack = cloneRecord(patch.anchorPack);
}
if (patch.lockState !== undefined) {
record.lockState = cloneRecord(patch.lockState);
}
if (patch.draftProfile !== undefined) {
record.draftProfile = cloneRecord(patch.draftProfile);
}
if (patch.pendingClarifications !== undefined) {
record.pendingClarifications = cloneRecord(patch.pendingClarifications);
}
if (patch.suggestedActions !== undefined) {
record.suggestedActions = cloneRecord(patch.suggestedActions);
}
if (patch.recommendedReplies !== undefined) {
record.recommendedReplies = cloneRecord(patch.recommendedReplies);
}
if (patch.draftCards !== undefined) {
record.draftCards = cloneRecord(patch.draftCards);
}
if (patch.qualityFindings !== undefined) {
record.qualityFindings = cloneRecord(patch.qualityFindings);
}
if (patch.assetCoverage !== undefined) {
record.assetCoverage = cloneRecord(patch.assetCoverage);
}
});
}
async createOperation(
userId: string,
sessionId: string,
operation: CustomWorldAgentOperationRecord,
) {
return this.mutate(userId, sessionId, (record) => {
record.operations.push(cloneRecord(operation));
});
}
async getOperation(userId: string, sessionId: string, operationId: string) {
const record = await this.get(userId, sessionId);
if (!record) {
return null;
}
const operation = record.operations.find(
(item) => item.operationId === operationId,
);
return operation ? cloneRecord(operation) : null;
}
async updateOperation(
userId: string,
sessionId: string,
operationId: string,
patch: Partial<CustomWorldAgentOperationRecord>,
) {
return this.mutate(userId, sessionId, (record) => {
const operation = record.operations.find(
(item) => item.operationId === operationId,
);
if (!operation) {
return;
}
if (patch.type) {
operation.type = patch.type;
}
if (patch.status) {
operation.status = patch.status;
}
if (patch.phaseLabel) {
operation.phaseLabel = patch.phaseLabel;
}
if (patch.phaseDetail) {
operation.phaseDetail = patch.phaseDetail;
}
if (typeof patch.progress === 'number') {
operation.progress = patch.progress;
}
if (patch.error !== undefined) {
operation.error = patch.error;
}
});
}
async appendCheckpoint(
userId: string,
sessionId: string,
input: {
checkpointId?: string;
label: string;
},
) {
return this.mutate(userId, sessionId, (record) => {
record.checkpoints.push({
checkpointId:
input.checkpointId ||
`checkpoint-${crypto.randomBytes(8).toString('hex')}`,
createdAt: new Date().toISOString(),
label: input.label,
});
});
}
async listDraftCards(userId: string, sessionId: string) {
const record = await this.get(userId, sessionId);
return record ? cloneRecord(record.draftCards) : null;
}
}

View File

@@ -1,13 +1,13 @@
import type { AppContext } from '../context.js';
import {
type CustomWorldGenerationProgress,
type GenerateCustomWorldProfileInput,
generateCustomWorldProfileFromOrchestrator,
type GenerateCustomWorldProfileInput,
} from '../modules/ai/customWorldOrchestrator.js';
import type { AppContext } from '../context.js';
import type { CustomWorldSession } from './customWorldSessionStore.js';
export async function generateCustomWorldProfile(
_context: AppContext,
context: AppContext,
session: CustomWorldSession,
options: {
onProgress?: (progress: CustomWorldGenerationProgress) => void;
@@ -20,10 +20,14 @@ export async function generateCustomWorldProfile(
generationMode: session.generationMode,
} satisfies GenerateCustomWorldProfileInput;
const profile = await generateCustomWorldProfileFromOrchestrator(input, {
onProgress: options.onProgress,
signal: options.signal,
});
const profile = await generateCustomWorldProfileFromOrchestrator(
context.llmClient,
input,
{
onProgress: options.onProgress,
signal: options.signal,
},
);
return JSON.parse(JSON.stringify(profile)) as Record<string, unknown>;
}

View File

@@ -4,12 +4,13 @@ import type { JsonObject } from '../../../packages/shared/src/contracts/common.j
import type {
CustomWorldGenerationMode,
CustomWorldQuestion,
CustomWorldSessionRecord,
CustomWorldSessionStatus,
} from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
export type CustomWorldSession = {
sessionId: string;
userId: string;
status: CustomWorldSessionStatus;
settingText: string;
creatorIntent: JsonObject | null;
@@ -25,6 +26,36 @@ function cloneSession(session: CustomWorldSession) {
return JSON.parse(JSON.stringify(session)) as CustomWorldSession;
}
function toSessionRecord(session: CustomWorldSession): CustomWorldSessionRecord {
return {
sessionId: session.sessionId,
status: session.status,
settingText: session.settingText,
creatorIntent: session.creatorIntent,
generationMode: session.generationMode,
questions: session.questions,
result: session.result,
lastError: session.lastError,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
};
}
function toSession(record: CustomWorldSessionRecord) {
return cloneSession({
sessionId: record.sessionId,
status: record.status,
settingText: record.settingText,
creatorIntent: record.creatorIntent ?? null,
generationMode: record.generationMode,
questions: record.questions,
result: record.result,
lastError: record.lastError,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
});
}
function hasPendingQuestion(questions: CustomWorldQuestion[]) {
return questions.some((question) => !question.answer?.trim());
}
@@ -79,9 +110,11 @@ function buildClarificationQuestions(
}
export class CustomWorldSessionStore {
private readonly sessions = new Map<string, Map<string, CustomWorldSession>>();
constructor(
private readonly runtimeRepository: RuntimeRepositoryPort,
) {}
create(
async create(
userId: string,
settingText: string,
creatorIntent: JsonObject | null,
@@ -91,7 +124,6 @@ export class CustomWorldSessionStore {
const now = new Date().toISOString();
const session: CustomWorldSession = {
sessionId,
userId,
status: 'ready_to_generate',
settingText,
creatorIntent,
@@ -105,19 +137,34 @@ export class CustomWorldSessionStore {
session.status = 'clarifying';
}
const userSessions = this.sessions.get(userId) ?? new Map<string, CustomWorldSession>();
userSessions.set(sessionId, session);
this.sessions.set(userId, userSessions);
await this.runtimeRepository.upsertCustomWorldSession(
userId,
sessionId,
toSessionRecord(session),
);
return cloneSession(session);
}
get(userId: string, sessionId: string) {
const session = this.sessions.get(userId)?.get(sessionId);
return session ? cloneSession(session) : null;
async list(userId: string) {
const sessions = await this.runtimeRepository.listCustomWorldSessions(userId);
return sessions.map((session) => toSession(session));
}
answer(userId: string, sessionId: string, questionId: string, answer: string) {
const session = this.sessions.get(userId)?.get(sessionId);
async get(userId: string, sessionId: string) {
const session = await this.runtimeRepository.getCustomWorldSession(
userId,
sessionId,
);
return session ? toSession(session) : null;
}
async answer(
userId: string,
sessionId: string,
questionId: string,
answer: string,
) {
const session = await this.get(userId, sessionId);
if (!session) {
return null;
}
@@ -132,16 +179,21 @@ export class CustomWorldSessionStore {
? 'clarifying'
: 'ready_to_generate';
session.updatedAt = new Date().toISOString();
await this.runtimeRepository.upsertCustomWorldSession(
userId,
sessionId,
toSessionRecord(session),
);
return cloneSession(session);
}
updateStatus(
async updateStatus(
userId: string,
sessionId: string,
status: CustomWorldSessionStatus,
lastError = '',
) {
const session = this.sessions.get(userId)?.get(sessionId);
const session = await this.get(userId, sessionId);
if (!session) {
return null;
}
@@ -149,11 +201,16 @@ export class CustomWorldSessionStore {
session.status = status;
session.lastError = lastError || undefined;
session.updatedAt = new Date().toISOString();
await this.runtimeRepository.upsertCustomWorldSession(
userId,
sessionId,
toSessionRecord(session),
);
return cloneSession(session);
}
setResult(userId: string, sessionId: string, result: JsonObject) {
const session = this.sessions.get(userId)?.get(sessionId);
async setResult(userId: string, sessionId: string, result: JsonObject) {
const session = await this.get(userId, sessionId);
if (!session) {
return null;
}
@@ -162,6 +219,11 @@ export class CustomWorldSessionStore {
session.lastError = undefined;
session.result = JSON.parse(JSON.stringify(result)) as JsonObject;
session.updatedAt = new Date().toISOString();
await this.runtimeRepository.upsertCustomWorldSession(
userId,
sessionId,
toSessionRecord(session),
);
return cloneSession(session);
}
}

View File

@@ -0,0 +1,233 @@
import type {
CustomWorldAgentStage,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import {
buildDraftSummaryFromIntent,
buildDraftTitleFromIntent,
normalizeCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
import {
rebuildRoleAssetCoverage,
resolveRoleAssetStatusLabel,
} from './customWorldAgentRoleAssetStateService.js';
import type {
CustomWorldAgentSessionRecord,
CustomWorldAgentSessionStore,
} from './customWorldAgentSessionStore.js';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter((item) => item && typeof item === 'object')
: [];
}
function truncateText(value: string, maxLength: number) {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function formatDraftStageLabel(stage: CustomWorldAgentStage) {
if (stage === 'collecting_intent') return '收集世界锚点';
if (stage === 'clarifying') return '补齐关键锚点';
if (stage === 'foundation_review') return '准备整理底稿';
if (stage === 'object_refining') return '精修对象';
if (stage === 'visual_refining') return '视觉工坊';
if (stage === 'long_tail_review') return '扩展长尾';
if (stage === 'ready_to_publish') return '准备发布';
if (stage === 'published') return '已发布';
return '发生错误';
}
function resolveDraftTitle(session: CustomWorldAgentSessionRecord) {
const intent = normalizeCreatorIntentRecord(session.creatorIntent);
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
return (
draftProfile?.name ||
buildDraftTitleFromIntent(intent) ||
toText(session.draftProfile?.title) ||
truncateText(session.seedText, 18) ||
'未命名草稿'
);
}
function resolveDraftSummary(session: CustomWorldAgentSessionRecord) {
const intent = normalizeCreatorIntentRecord(session.creatorIntent);
const compiledSummary = buildDraftSummaryFromIntent(intent);
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
return (
draftProfile?.summary ||
compiledSummary ||
toText(session.draftProfile?.summary) ||
truncateText(session.seedText, 72) ||
'还在收集你的世界锚点。'
);
}
function resolveDraftCounts(session: CustomWorldAgentSessionRecord) {
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
if (draftProfile) {
return {
playableNpcCount: [
...new Set(
[...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map(
(entry) => entry.id,
),
),
].length,
landmarkCount: draftProfile.landmarks.length,
};
}
const playableNpcCount = session.draftCards.filter(
(card) => card.kind === 'character',
).length;
const landmarkCount = session.draftCards.filter(
(card) => card.kind === 'landmark' || card.kind === 'camp',
).length;
return {
playableNpcCount,
landmarkCount,
};
}
function resolveDraftRoleAssetProgress(session: CustomWorldAgentSessionRecord) {
const coverage = rebuildRoleAssetCoverage(session.draftProfile);
const roleVisualReadyCount = coverage.roleAssets.filter(
(entry) => entry.status !== 'missing',
).length;
const roleAnimationReadyCount = coverage.roleAssets.filter(
(entry) => entry.status === 'complete',
).length;
const leadRole = coverage.roleAssets[0];
return {
roleVisualReadyCount,
roleAnimationReadyCount,
roleAssetSummaryLabel: leadRole
? `${leadRole.roleName} · ${resolveRoleAssetStatusLabel(leadRole.status)}`
: coverage.roleAssets.length > 0
? '角色资产进行中'
: null,
};
}
function resolvePublishedCover(profile: Record<string, unknown>) {
const camp = toRecord(profile.camp);
const playableNpcs = toRecordArray(profile.playableNpcs);
const leadNpc = toRecord(playableNpcs[0]);
return toText(camp?.imageSrc) || toText(leadNpc?.imageSrc) || null;
}
export async function listCustomWorldWorkSummaries(
userId: string,
dependencies: {
runtimeRepository: RuntimeRepositoryPort;
customWorldAgentSessions: CustomWorldAgentSessionStore;
},
) {
const [profiles, sessions] = await Promise.all([
dependencies.runtimeRepository.listCustomWorldProfiles(userId),
dependencies.customWorldAgentSessions.list(userId),
]);
const draftItems: CustomWorldWorkSummary[] = sessions.map((session) => {
const counts = resolveDraftCounts(session);
const roleAssetProgress = resolveDraftRoleAssetProgress(session);
return {
workId: `draft:${session.sessionId}`,
sourceType: 'agent_session',
status: 'draft',
title: resolveDraftTitle(session),
subtitle:
normalizeFoundationDraftProfile(session.draftProfile)?.subtitle ||
formatDraftStageLabel(session.stage),
summary: resolveDraftSummary(session),
coverImageSrc: null,
updatedAt: session.updatedAt,
publishedAt: null,
stage: session.stage,
stageLabel: formatDraftStageLabel(session.stage),
playableNpcCount: counts.playableNpcCount,
landmarkCount: counts.landmarkCount,
roleVisualReadyCount: roleAssetProgress.roleVisualReadyCount,
roleAnimationReadyCount: roleAssetProgress.roleAnimationReadyCount,
roleAssetSummaryLabel: roleAssetProgress.roleAssetSummaryLabel,
sessionId: session.sessionId,
profileId: null,
canResume: true,
canEnterWorld: false,
};
});
const publishedItems: CustomWorldWorkSummary[] = profiles.map((profile) => {
const profileRecord = profile as CustomWorldProfileRecord &
Record<string, unknown>;
const playableNpcs = toRecordArray(profileRecord.playableNpcs);
const landmarks = toRecordArray(profileRecord.landmarks);
const updatedAt =
toText(profileRecord.updatedAt) || new Date().toISOString();
const roleVisualReadyCount = playableNpcs.filter(
(entry) =>
Boolean(toText(entry.imageSrc)) &&
Boolean(toText(entry.generatedVisualAssetId)),
).length;
const roleAnimationReadyCount = playableNpcs.filter(
(entry) => Boolean(toText(entry.generatedAnimationSetId)),
).length;
return {
workId: `published:${toText(profileRecord.id) || updatedAt}`,
sourceType: 'published_profile',
status: 'published',
title: toText(profileRecord.name) || '未命名世界',
subtitle: toText(profileRecord.subtitle) || '已发布作品',
summary:
toText(profileRecord.summary) || '这个世界已经可以直接进入体验。',
coverImageSrc: resolvePublishedCover(profileRecord),
updatedAt,
publishedAt: toText(profileRecord.publishedAt) || updatedAt,
stage: 'published',
stageLabel: '已发布',
playableNpcCount: playableNpcs.length,
landmarkCount: landmarks.length,
roleVisualReadyCount,
roleAnimationReadyCount,
roleAssetSummaryLabel:
roleAnimationReadyCount > 0
? `动作已就绪 ${roleAnimationReadyCount}`
: roleVisualReadyCount > 0
? `主图已就绪 ${roleVisualReadyCount}`
: null,
sessionId: null,
profileId: toText(profileRecord.id) || null,
canResume: false,
canEnterWorld: true,
};
});
return [...draftItems, ...publishedItems].sort((left, right) =>
right.updatedAt.localeCompare(left.updatedAt),
);
}

View File

@@ -0,0 +1,131 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { AdventurePanel } from './AdventurePanel';
import { AnimationState, type Character, type StoryMoment, type StoryOption, WorldType } from '../types';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试主角',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 8,
spirit: 9,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
} as Character;
}
function createOption(functionId: string, actionText: string): StoryOption {
return {
functionId,
actionText,
text: actionText,
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
};
}
function renderPanel(currentStory: StoryMoment, displayedOptions: StoryOption[]) {
return renderToStaticMarkup(
<AdventurePanel
aiError={null}
currentStory={currentStory}
isLoading={false}
displayedOptions={displayedOptions}
hideOptions={false}
canRefreshOptions={false}
onRefreshOptions={() => undefined}
onChoice={() => undefined}
onOpenCharacter={() => undefined}
onOpenInventory={() => undefined}
playerCharacter={createCharacter()}
worldType={WorldType.WUXIA}
quests={[]}
questUi={{
acknowledgeQuestCompletion: () => undefined,
claimQuestReward: () => null,
}}
goalStack={{
northStarGoal: null,
activeGoal: null,
immediateStepGoal: null,
supportGoals: [],
}}
goalPulse={null}
onDismissGoalPulse={() => undefined}
battleRewardUi={{
reward: null,
dismiss: () => undefined,
}}
playerHp={100}
playerMaxHp={100}
playerMana={20}
playerMaxMana={20}
playerSkillCooldowns={{}}
inBattle={false}
currentNpcBattleMode={null}
statistics={{
playTimeMs: 0,
hostileNpcsDefeated: 0,
questsAccepted: 0,
questsCompleted: 0,
questsTurnedIn: 0,
itemsUsed: 0,
scenesTraveled: 0,
currentSceneName: '竹林古道',
playerCurrency: 0,
inventoryItemCount: 0,
inventoryStackCount: 0,
activeCompanionCount: 0,
rosterCompanionCount: 0,
}}
musicVolume={0.6}
onMusicVolumeChange={() => undefined}
onSaveAndExit={() => undefined}
/>,
);
}
test('adventure panel recognizes story_continue_adventure by function id instead of action text', () => {
const continueOption = createOption('story_continue_adventure', '查看后续');
const currentStory: StoryMoment = {
text: '你们交换完这一轮判断。',
options: [continueOption],
deferredOptions: [createOption('idle_explore_forward', '继续向前探索')],
};
const html = renderPanel(currentStory, [continueOption]);
expect(html).toContain('剧情推理完成,继续后显示新的冒险选项');
});
test('adventure panel does not show deferred hint for non-continue options with the same text', () => {
const misleadingOption = createOption('npc_chat', '查看后续');
const currentStory: StoryMoment = {
text: '你们交换完这一轮判断。',
options: [misleadingOption],
deferredOptions: [createOption('idle_explore_forward', '继续向前探索')],
};
const html = renderPanel(currentStory, [misleadingOption]);
expect(html).not.toContain('剧情推理完成,继续后显示新的冒险选项');
});

View File

@@ -14,8 +14,8 @@ interface CustomWorldResultViewProps {
progressLabel: string;
error: string | null;
onBack: () => void;
onEditSetting: () => void;
onRegenerate: () => void;
onEditSetting?: () => void;
onRegenerate?: () => void;
onContinueExpand?: () => void;
onSave: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
@@ -122,7 +122,7 @@ export function CustomWorldResultView({
const createTarget = useMemo(() => getCreateTargetByTab(activeTab), [activeTab]);
const createLabel = useMemo(() => getCreateLabelByTab(activeTab), [activeTab]);
const onRegenerate = () => {
if (isGenerating) return;
if (isGenerating || !triggerRegenerate) return;
const confirmed = window.confirm(
`确认重新生成“${profile.name}”吗?\n\n重新生成会重新生成当前世界中的所有信息包括你修改和新增的所有内容。`,
@@ -198,8 +198,12 @@ export function CustomWorldResultView({
</div>
) : null}
<div className="flex items-center justify-end gap-3">
<SmallButton onClick={onEditSetting}></SmallButton>
<SmallButton onClick={onRegenerate} tone="sky"></SmallButton>
{onEditSetting ? (
<SmallButton onClick={onEditSetting}></SmallButton>
) : null}
{triggerRegenerate ? (
<SmallButton onClick={onRegenerate} tone="sky"></SmallButton>
) : null}
{profile.generationStatus === 'key_only' && onContinueExpand ? (
<SmallButton onClick={onContinueExpand} tone="sky" disabled={isGenerating}>

View File

@@ -4,13 +4,17 @@ import {
ImagePlus,
RefreshCcw,
} from 'lucide-react';
import { type ChangeEvent, type ReactNode, useMemo, useState } from 'react';
import {
type ChangeEvent,
type ReactNode,
useEffect,
useMemo,
useState,
} from 'react';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import {
AnimationState,
type CustomWorldNpc,
type CustomWorldPlayableNpc,
} from '../types';
import { CharacterAnimator } from './CharacterAnimator';
import {
@@ -29,7 +33,23 @@ import {
publishCharacterVisualAsset,
} from './asset-studio/characterAssetWorkflowPersistence';
type EditableCustomWorldRole = CustomWorldPlayableNpc | CustomWorldNpc;
type EditableCustomWorldRole = {
id: string;
name: string;
title: string;
role: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
templateCharacterId?: string;
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown>;
};
type CustomWorldAiActionConfig = {
animation: AnimationState;
@@ -298,7 +318,7 @@ function buildRoleCharacterBrief(
role.personality ? `角色性格:${role.personality}` : '',
role.motivation ? `角色动机:${role.motivation}` : '',
role.combatStyle ? `战斗风格:${role.combatStyle}` : '',
role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '',
role.tags && role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '',
templateLabel ? `参考模板:${templateLabel}` : '',
]
.filter(Boolean)
@@ -319,13 +339,35 @@ export function CustomWorldRoleAssetStudioModal({
role,
roleKind,
onApply,
onPublishSuccess,
onClose,
syncBusy = false,
visualPointCost = 20,
animationPointCost = 60,
priorityTier = roleKind === 'playable' ? 'hero' : 'featured',
}: {
role: EditableCustomWorldRole;
roleKind: 'playable' | 'story';
onApply: (nextRole: EditableCustomWorldRole) => void;
onApply?: (nextRole: EditableCustomWorldRole) => void;
onPublishSuccess?: (
payload: {
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
},
options?: {
closeAfterSync?: boolean;
},
) => void;
onClose: () => void;
syncBusy?: boolean;
visualPointCost?: number;
animationPointCost?: number;
priorityTier?: 'hero' | 'featured' | 'supporting';
}) {
const [workingRole, setWorkingRole] = useState<EditableCustomWorldRole>(role);
const [sourceMode, setSourceMode] =
useState<Exclude<CharacterVisualSourceMode, 'upload'>>(
role.imageSrc ? 'image-to-image' : 'text-to-image',
@@ -351,42 +393,66 @@ export function CustomWorldRoleAssetStudioModal({
const [isGeneratingAnimations, setIsGeneratingAnimations] = useState(false);
const [isApplyingAnimations, setIsApplyingAnimations] = useState(false);
useEffect(() => {
setWorkingRole(role);
}, [role]);
const selectedTemplate =
roleKind === 'playable' && 'templateCharacterId' in role && role.templateCharacterId
roleKind === 'playable' && workingRole.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === role.templateCharacterId,
(character) => character.id === workingRole.templateCharacterId,
) ?? null
: null;
const characterBriefText = useMemo(
() =>
buildRoleCharacterBrief(
role,
workingRole,
selectedTemplate
? `${selectedTemplate.name} / ${selectedTemplate.title}`
: undefined,
),
[role, selectedTemplate],
[workingRole, selectedTemplate],
);
const effectiveReferenceImages =
referenceImageDataUrls.length > 0
? referenceImageDataUrls
: role.imageSrc
? [role.imageSrc]
: workingRole.imageSrc
? [workingRole.imageSrc]
: [];
const selectedVisualDraft =
visualDrafts.find((draft) => draft.id === selectedVisualDraftId) ?? null;
const previewImageSrc =
selectedVisualDraft?.imageSrc ??
role.imageSrc ??
workingRole.imageSrc ??
selectedTemplate?.portrait ??
'';
const selectedActionConfig =
CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ??
CORE_ACTIONS[0];
const appliedActionCount = CORE_ACTIONS.filter(
(item) => role.animationMap?.[item.animation]?.basePath,
(item) =>
Boolean(
(workingRole.animationMap as Record<string, { basePath?: string }> | null)
?.[item.animation]?.basePath,
),
).length;
const visualCandidateCount = priorityTier === 'supporting' ? 1 : 2;
const confirmPointSpend = (params: {
kindLabel: string;
points: number;
description: string;
}) => {
if (typeof window === 'undefined' || typeof window.confirm !== 'function') {
return true;
}
return window.confirm(
`${params.kindLabel}预计消耗 ${params.points} 积分。\n${params.description}`,
);
};
const handleReferenceImageUpload = async (
event: ChangeEvent<HTMLInputElement>,
) => {
@@ -406,6 +472,16 @@ export function CustomWorldRoleAssetStudioModal({
};
const handleGenerateVisuals = async () => {
if (
!confirmPointSpend({
kindLabel: '主图候选生成',
points: visualPointCost,
description: '这次是主图候选抽卡,不是最终发布。',
})
) {
return;
}
setIsGeneratingVisuals(true);
setVisualStatus(null);
@@ -418,12 +494,12 @@ export function CustomWorldRoleAssetStudioModal({
}
const result = await generateCharacterVisualCandidates({
characterId: role.id,
characterId: workingRole.id,
sourceMode,
promptText: visualPromptText,
characterBriefText,
referenceImageDataUrls: effectiveReferenceImages,
candidateCount: 3,
candidateCount: visualCandidateCount,
imageModel: 'wan2.7-image-pro',
size: '1024*1536',
});
@@ -450,7 +526,7 @@ export function CustomWorldRoleAssetStudioModal({
try {
const result = await publishCharacterVisualAsset({
characterId: role.id,
characterId: workingRole.id,
sourceMode,
promptText: visualPromptText,
selectedPreviewSource: selectedVisualDraft.imageSrc,
@@ -460,13 +536,25 @@ export function CustomWorldRoleAssetStudioModal({
updateCharacterOverride: false,
});
onApply(
mergeRole(role, {
imageSrc: result.portraitPath,
const nextRole = mergeRole(workingRole, {
imageSrc: result.portraitPath,
generatedVisualAssetId: result.assetId,
generatedAnimationSetId: undefined,
animationMap: undefined,
});
setWorkingRole(nextRole);
onApply?.(nextRole);
onPublishSuccess?.(
{
roleId: workingRole.id,
portraitPath: result.portraitPath,
generatedVisualAssetId: result.assetId,
generatedAnimationSetId: undefined,
animationMap: undefined,
}),
generatedAnimationSetId: null,
animationMap: null,
},
{
closeAfterSync: false,
},
);
setDraftAnimations({});
setAnimationStatus(null);
@@ -481,18 +569,18 @@ export function CustomWorldRoleAssetStudioModal({
};
const generateActionClip = async (config: CustomWorldAiActionConfig) => {
if (!role.imageSrc || !role.generatedVisualAssetId) {
if (!workingRole.imageSrc || !workingRole.generatedVisualAssetId) {
throw new Error('请先应用主图,再生成动作。');
}
const result = await generateCharacterAnimationDraft({
characterId: role.id,
characterId: workingRole.id,
strategy: 'image-to-video',
animation: config.animation,
promptText: animationPromptText,
characterBriefText,
actionTemplateId: config.templateId,
visualSource: role.imageSrc,
visualSource: workingRole.imageSrc,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
frameCount: config.frameCount,
@@ -525,6 +613,16 @@ export function CustomWorldRoleAssetStudioModal({
return;
}
if (
!confirmPointSpend({
kindLabel: '动作草稿生成',
points: animationPointCost,
description: '这次是动作草稿试片,不是最终发布。',
})
) {
return;
}
setIsGeneratingAnimations(true);
setAnimationStatus(null);
@@ -545,6 +643,16 @@ export function CustomWorldRoleAssetStudioModal({
};
const handleGenerateAllAnimations = async () => {
if (
!confirmPointSpend({
kindLabel: '核心动作生成',
points: animationPointCost,
description: '这次会生成核心动作草稿,发布前仍可继续调整。',
})
) {
return;
}
setIsGeneratingAnimations(true);
setAnimationStatus(null);
@@ -570,7 +678,7 @@ export function CustomWorldRoleAssetStudioModal({
};
const handleApplyAnimations = async () => {
if (!role.generatedVisualAssetId) {
if (!workingRole.generatedVisualAssetId) {
setAnimationStatus('请先应用主图,再应用动作。');
return;
}
@@ -601,22 +709,37 @@ export function CustomWorldRoleAssetStudioModal({
]),
);
const result = await publishCharacterAnimationAssets({
characterId: role.id,
visualAssetId: role.generatedVisualAssetId,
characterId: workingRole.id,
visualAssetId: workingRole.generatedVisualAssetId,
animations: payload,
updateCharacterOverride: false,
});
onApply(
mergeRole(role, {
const nextRole = mergeRole(workingRole, {
generatedAnimationSetId: result.animationSetId,
animationMap: {
...(role.animationMap ?? {}),
...((workingRole.animationMap ?? {}) as Record<string, unknown>),
...(result.animationMap as NonNullable<
EditableCustomWorldRole['animationMap']
>),
},
}),
});
setWorkingRole(nextRole);
onApply?.(nextRole);
onPublishSuccess?.(
{
roleId: workingRole.id,
portraitPath: workingRole.imageSrc ?? previewImageSrc,
generatedVisualAssetId: workingRole.generatedVisualAssetId ?? '',
generatedAnimationSetId: result.animationSetId,
animationMap: (nextRole.animationMap ?? null) as Record<
string,
unknown
> | null,
},
{
closeAfterSync: true,
},
);
setAnimationStatus('核心动作已应用到当前角色。');
} catch (error) {
@@ -637,7 +760,8 @@ export function CustomWorldRoleAssetStudioModal({
isGeneratingVisuals ||
isApplyingVisual ||
isGeneratingAnimations ||
isApplyingAnimations
isApplyingAnimations ||
syncBusy
}
>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.05fr)_minmax(22rem,0.95fr)]">
@@ -695,18 +819,21 @@ export function CustomWorldRoleAssetStudioModal({
</div>
</Field>
<div className="flex flex-wrap gap-3">
<StatusBadge tone="amber">
{visualPointCost}
</StatusBadge>
<ActionButton
icon={<ImagePlus className="h-4 w-4" />}
label={isGeneratingVisuals ? '生成中...' : '生成主图候选'}
onClick={() => void handleGenerateVisuals()}
disabled={isGeneratingVisuals}
disabled={isGeneratingVisuals || syncBusy}
tone="sky"
/>
<ActionButton
icon={<CheckCircle2 className="h-4 w-4" />}
label={isApplyingVisual ? '应用中...' : '应用主图'}
onClick={() => void handleApplyVisual()}
disabled={isApplyingVisual || !selectedVisualDraft}
disabled={isApplyingVisual || !selectedVisualDraft || syncBusy}
tone="green"
/>
</div>
@@ -723,7 +850,7 @@ export function CustomWorldRoleAssetStudioModal({
{previewImageSrc ? (
<img
src={previewImageSrc}
alt={role.name}
alt={workingRole.name}
className="max-h-[28rem] w-full object-contain"
/>
) : selectedTemplate ? (
@@ -811,7 +938,9 @@ export function CustomWorldRoleAssetStudioModal({
: `生成${selectedActionConfig?.label ?? '当前'}动作`
}
onClick={() => void handleGenerateSingleAnimation()}
disabled={isGeneratingAnimations || !role.imageSrc}
disabled={
isGeneratingAnimations || !workingRole.imageSrc || syncBusy
}
tone="sky"
/>
<ActionButton
@@ -820,16 +949,21 @@ export function CustomWorldRoleAssetStudioModal({
isGeneratingAnimations ? '生成中...' : '生成核心动作'
}
onClick={() => void handleGenerateAllAnimations()}
disabled={isGeneratingAnimations || !role.imageSrc}
disabled={
isGeneratingAnimations || !workingRole.imageSrc || syncBusy
}
/>
<ActionButton
icon={<CheckCircle2 className="h-4 w-4" />}
label={isApplyingAnimations ? '应用中...' : '应用动作'}
onClick={() => void handleApplyAnimations()}
disabled={isApplyingAnimations}
disabled={isApplyingAnimations || syncBusy}
tone="green"
/>
</div>
<div className="text-xs text-zinc-500">
稿 {animationPointCost}
</div>
{animationStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{animationStatus}
@@ -843,7 +977,7 @@ export function CustomWorldRoleAssetStudioModal({
<StatusBadge tone="amber">
{appliedActionCount}/{CORE_ACTIONS.length}
</StatusBadge>
{role.generatedVisualAssetId ? (
{workingRole.generatedVisualAssetId ? (
<StatusBadge tone="green"></StatusBadge>
) : (
<StatusBadge tone="zinc"></StatusBadge>
@@ -853,7 +987,12 @@ export function CustomWorldRoleAssetStudioModal({
{CORE_ACTIONS.map((item) => {
const hasDraft = Boolean(draftAnimations[item.animation]);
const isApplied = Boolean(
role.animationMap?.[item.animation]?.basePath,
(
workingRole.animationMap as Record<
string,
{ basePath?: string }
> | null
)?.[item.animation]?.basePath,
);
return (
<div
@@ -896,17 +1035,22 @@ export function CustomWorldRoleAssetStudioModal({
state={selectedAnimation}
character={{
...selectedTemplate,
id: role.id,
name: role.name,
title: role.title,
portrait: role.imageSrc || selectedTemplate.portrait,
generatedVisualAssetId: role.generatedVisualAssetId,
id: workingRole.id,
name: workingRole.name,
title: workingRole.title,
portrait:
workingRole.imageSrc || selectedTemplate.portrait,
generatedVisualAssetId:
workingRole.generatedVisualAssetId ?? undefined,
generatedAnimationSetId:
role.generatedAnimationSetId,
animationMap: role.animationMap
workingRole.generatedAnimationSetId ?? undefined,
animationMap: workingRole.animationMap
? {
...(selectedTemplate.animationMap ?? {}),
...role.animationMap,
...(workingRole.animationMap as Record<
string,
unknown
>),
}
: selectedTemplate.animationMap,
}}
@@ -916,7 +1060,7 @@ export function CustomWorldRoleAssetStudioModal({
) : previewImageSrc ? (
<img
src={previewImageSrc}
alt={role.name}
alt={workingRole.name}
className="max-h-[16rem] w-full object-contain"
/>
) : (
@@ -934,14 +1078,20 @@ export function CustomWorldRoleAssetStudioModal({
<div className="space-y-5">
<Section title="当前角色档案">
<div className="rounded-3xl border border-white/10 bg-black/20 p-4">
<div className="text-lg font-semibold text-white">{role.name}</div>
<div className="text-lg font-semibold text-white">
{workingRole.name}
</div>
<div className="mt-1 text-sm text-zinc-400">
{role.title} / {role.role}
{workingRole.title} / {workingRole.role}
</div>
<div className="mt-4 space-y-3 text-sm leading-7 text-zinc-300">
{role.description ? <div>{role.description}</div> : null}
{role.combatStyle ? <div>{role.combatStyle}</div> : null}
{role.tags.length > 0 ? <div>{role.tags.join('、')}</div> : null}
{workingRole.description ? <div>{workingRole.description}</div> : null}
{workingRole.combatStyle ? (
<div>{workingRole.combatStyle}</div>
) : null}
{workingRole.tags && workingRole.tags.length > 0 ? (
<div>{workingRole.tags.join('、')}</div>
) : null}
</div>
</div>
<Field label="自动提示词依据">
@@ -959,7 +1109,7 @@ export function CustomWorldRoleAssetStudioModal({
<div className="grid gap-3">
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<div className="text-sm text-zinc-200"></div>
{role.generatedVisualAssetId ? (
{workingRole.generatedVisualAssetId ? (
<StatusBadge tone="green"></StatusBadge>
) : (
<StatusBadge tone="zinc"></StatusBadge>
@@ -976,7 +1126,11 @@ export function CustomWorldRoleAssetStudioModal({
)}
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-zinc-500">
{priorityTier === 'hero'
? '主角级'
: priorityTier === 'featured'
? '重点角色'
: '支撑角色'}
</div>
</div>
</Section>

View File

@@ -47,7 +47,7 @@ type AuthStatus =
const allowDevGuestAutoAuth =
import.meta.env.DEV &&
import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST === 'true';
import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST !== 'false';
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking');

View File

@@ -0,0 +1,46 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldAgentClarificationPanel } from './CustomWorldAgentClarificationPanel';
test('clarification panel shows pending questions and ready state', () => {
const pendingHtml = renderToStaticMarkup(
<CustomWorldAgentClarificationPanel
readiness={{
isReady: false,
completedKeys: ['world_hook'],
missingKeys: ['player_premise', 'core_conflict'],
}}
pendingClarifications={[
{
id: 'player_premise',
label: '玩家身份与开局',
question: '玩家是谁,故事开场时卡在什么处境里?',
targetKey: 'player_premise',
priority: 2,
},
]}
/>,
);
const readyHtml = renderToStaticMarkup(
<CustomWorldAgentClarificationPanel
readiness={{
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
}}
pendingClarifications={[]}
/>,
);
expect(pendingHtml).toContain('待补充问题');
expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里');
expect(readyHtml).toContain('最小锚点已齐备,可以进入下一阶段');
});

View File

@@ -0,0 +1,64 @@
import type {
CreatorIntentReadiness,
CustomWorldPendingClarification,
} from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentClarificationPanelProps = {
pendingClarifications: CustomWorldPendingClarification[];
readiness: CreatorIntentReadiness;
};
export function CustomWorldAgentClarificationPanel({
pendingClarifications,
readiness,
}: CustomWorldAgentClarificationPanelProps) {
if (readiness.isReady) {
return (
<section className="rounded-[1.5rem] border border-emerald-300/18 bg-emerald-500/8 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.2em] text-emerald-100/80">
</div>
<div className="mt-2 text-lg font-semibold text-white">
</div>
</section>
);
}
return (
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-2 text-lg font-semibold text-white">
1 3
</div>
</div>
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
{pendingClarifications.length}
</span>
</div>
<div className="mt-4 space-y-2">
{pendingClarifications.slice(0, 3).map((item, index) => (
<div
key={item.id}
className="rounded-[1.15rem] border border-white/8 bg-white/5 px-3 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-white">
{index + 1}. {item.label}
</div>
<div className="text-[11px] text-zinc-500">P{item.priority}</div>
</div>
<div className="mt-2 text-sm leading-6 text-zinc-300">
{item.question}
</div>
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,102 @@
import type { RefObject } from 'react';
import { useState } from 'react';
import type { SendCustomWorldAgentMessageRequest } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentComposerProps = {
disabled: boolean;
onSubmit: (payload: SendCustomWorldAgentMessageRequest) => void;
textareaRef?: RefObject<HTMLTextAreaElement | null>;
onSummaryClick?: () => void;
onAutoCompleteClick?: () => void;
showAutoComplete?: boolean;
};
function createClientMessageId() {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID();
}
return `client-message-${Date.now()}`;
}
export function CustomWorldAgentComposer({
disabled,
onSubmit,
textareaRef,
onSummaryClick,
onAutoCompleteClick,
showAutoComplete = true,
}: CustomWorldAgentComposerProps) {
const [text, setText] = useState('');
const submit = () => {
const nextText = text.trim();
if (!nextText || disabled) {
return;
}
onSubmit({
clientMessageId: createClientMessageId(),
text: nextText,
focusCardId: null,
selectedCardIds: [],
});
setText('');
};
return (
<div className="rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={onSummaryClick}
disabled={disabled}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
{showAutoComplete ? (
<button
type="button"
onClick={onAutoCompleteClick}
disabled={disabled}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
) : null}
</div>
<textarea
ref={textareaRef}
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submit();
}
}}
rows={2}
disabled={disabled}
placeholder="输入消息"
className="w-full resize-none rounded-[1.35rem] border border-white/10 bg-black/30 px-4 py-2.5 text-sm leading-6 text-white outline-none transition focus:border-emerald-300/35 disabled:cursor-not-allowed disabled:opacity-60"
/>
<div className="flex justify-end">
<button
type="button"
onClick={submit}
disabled={disabled || !text.trim()}
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { expect, test } from 'vitest';
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
import { CustomWorldGenerateEntityModal } from './CustomWorldGenerateEntityModal';
const CHARACTER_DETAIL: CustomWorldDraftCardDetail = {
id: 'character-1',
kind: 'character',
title: '沈砺',
sections: [
{
id: 'name',
label: '角色名',
value: '沈砺',
},
{
id: 'publicMask',
label: '外显身份',
value: '守灯会里最熟悉旧航道的人。',
},
{
id: 'summary',
label: '角色摘要',
value: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
linkedIds: ['thread-1'],
locked: false,
editable: true,
editableSectionIds: ['name', 'publicMask', 'summary'],
warningMessages: [],
assetStatus: 'missing',
assetStatusLabel: '待生成主图',
};
function DetailInteractionHarness() {
const [editMode, setEditMode] = useState(false);
const [generateMode, setGenerateMode] = useState<'character' | 'landmark' | null>(
null,
);
const [savedPayload, setSavedPayload] = useState<string>('');
return (
<>
<CustomWorldAgentDraftDetailPanel
detail={CHARACTER_DETAIL}
loading={false}
editMode={editMode}
onClose={() => {}}
onStartEdit={() => {
setEditMode(true);
}}
onCancelEdit={() => {
setEditMode(false);
}}
onSave={(sections) => {
setSavedPayload(JSON.stringify(sections));
setEditMode(false);
}}
onGenerateCharacter={() => {
setGenerateMode('character');
}}
onGenerateLandmark={() => {
setGenerateMode('landmark');
}}
onOpenRoleAssetStudio={() => {}}
/>
<CustomWorldGenerateEntityModal
open={generateMode !== null}
mode={generateMode ?? 'character'}
anchorCardTitle={CHARACTER_DETAIL.title}
onClose={() => {
setGenerateMode(null);
}}
onSubmit={() => {
setGenerateMode(null);
}}
/>
<div data-testid="saved-payload">{savedPayload}</div>
</>
);
}
test('draft detail panel supports edit save and opening generate modals', async () => {
const user = userEvent.setup();
render(<DetailInteractionHarness />);
await user.click(screen.getByRole('button', { name: '编辑设定' }));
const summaryInput = screen.getByLabelText('角色摘要');
await user.clear(summaryInput);
await user.type(summaryInput, '他像旧友,也像最早知道航道秘密的人。');
await user.click(screen.getByRole('button', { name: '保存' }));
expect(screen.getByTestId('saved-payload').textContent).toContain(
'他像旧友,也像最早知道航道秘密的人。',
);
await user.click(screen.getByRole('button', { name: '新增角色' }));
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
expect(screen.getByText('当前参考卡')).toBeTruthy();
const closeButtons = screen.getAllByRole('button', { name: '关闭' });
await user.click(closeButtons[closeButtons.length - 1]!);
expect(screen.getByRole('button', { name: '角色资产' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '新增场景' }));
expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
});

View File

@@ -0,0 +1,45 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
test('draft detail panel renders sections and warnings', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentDraftDetailPanel
detail={{
id: 'thread-1',
kind: 'thread',
title: '谁掌握航道解释权',
sections: [
{
id: 'thread-type',
label: '线程类型',
value: '明线',
},
{
id: 'thread-conflict',
label: '冲突内容',
value: '守灯会与沉船商盟正在争夺航道解释权。',
},
],
linkedIds: ['character-1', 'landmark-1'],
locked: false,
editable: true,
editableSectionIds: ['title', 'summary', 'conflictType', 'stakes'],
warningMessages: ['这条线还缺少更明确的地点挂点。'],
}}
loading={false}
onClose={() => {}}
onStartEdit={() => {}}
onGenerateCharacter={() => {}}
onGenerateLandmark={() => {}}
/>,
);
expect(html).toContain('谁掌握航道解释权');
expect(html).toContain('线程类型');
expect(html).toContain('守灯会与沉船商盟');
expect(html).toContain('继续精修');
expect(html).toContain('编辑设定');
expect(html).toContain('新增角色');
});

View File

@@ -0,0 +1,204 @@
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldDraftEditPanel } from './CustomWorldDraftEditPanel';
type CustomWorldAgentDraftDetailPanelProps = {
detail: CustomWorldDraftCardDetail | null;
loading: boolean;
busy?: boolean;
editMode?: boolean;
onClose: () => void;
onStartEdit?: () => void;
onCancelEdit?: () => void;
onSave?: (
sections: Array<{
sectionId: string;
value: string;
}>,
) => void;
onGenerateCharacter?: () => void;
onGenerateLandmark?: () => void;
onOpenRoleAssetStudio?: () => void;
};
function resolveKindLabel(kind: CustomWorldDraftCardDetail['kind']) {
if (kind === 'world') return '世界总卡';
if (kind === 'camp') return '营地';
if (kind === 'faction') return '势力';
if (kind === 'character') return '角色';
if (kind === 'landmark') return '地点';
if (kind === 'thread') return '线程';
if (kind === 'chapter') return '第一幕';
return '草稿卡';
}
function ActionButton(props: {
label: string;
onClick?: () => void;
disabled?: boolean;
tone?: 'default' | 'sky';
}) {
const { label, onClick, disabled = false, tone = 'default' } = props;
if (!onClick) {
return null;
}
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-full border px-3 py-1.5 text-[11px] transition ${
tone === 'sky'
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
} disabled:cursor-not-allowed disabled:opacity-45`}
>
{label}
</button>
);
}
export function CustomWorldAgentDraftDetailPanel({
detail,
loading,
busy = false,
editMode = false,
onClose,
onStartEdit,
onCancelEdit,
onSave,
onGenerateCharacter,
onGenerateLandmark,
onOpenRoleAssetStudio,
}: CustomWorldAgentDraftDetailPanelProps) {
return (
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-2 text-lg font-semibold text-white">
{loading ? '正在读取' : detail?.title || '选择一张草稿卡'}
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300 transition hover:text-white"
>
</button>
</div>
{loading ? (
<div className="mt-4 rounded-[1.15rem] border border-white/8 bg-white/5 px-4 py-5 text-sm leading-7 text-zinc-300">
</div>
) : detail ? (
<div className="mt-4 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
{resolveKindLabel(detail.kind)}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
{detail.linkedIds.length}
</span>
{detail.editable ? (
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
</span>
) : null}
{detail.kind === 'character' && detail.assetStatusLabel ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
{detail.assetStatusLabel}
</span>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{!editMode && detail.editable ? (
<ActionButton
label="编辑设定"
onClick={onStartEdit}
disabled={busy}
/>
) : null}
{!editMode && detail.kind === 'character' ? (
<ActionButton
label="角色资产"
onClick={onOpenRoleAssetStudio}
disabled={busy}
tone="sky"
/>
) : null}
{!editMode ? (
<>
<ActionButton
label="新增角色"
onClick={onGenerateCharacter}
disabled={busy}
tone="sky"
/>
<ActionButton
label="新增场景"
onClick={onGenerateLandmark}
disabled={busy}
tone="sky"
/>
</>
) : null}
</div>
{editMode && onSave && onCancelEdit ? (
<CustomWorldDraftEditPanel
detail={detail}
disabled={busy}
onSave={onSave}
onCancel={onCancelEdit}
/>
) : (
<div className="space-y-2">
{detail.sections.map((section) => (
<div
key={section.id}
className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3"
>
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
{section.label}
</div>
<div className="mt-2 whitespace-pre-wrap text-sm leading-7 text-zinc-100">
{section.value}
</div>
</div>
))}
</div>
)}
{detail.warningMessages.length > 0 ? (
<div className="rounded-[1.15rem] border border-amber-300/20 bg-amber-500/10 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.18em] text-amber-100">
</div>
<div className="mt-3 space-y-2">
{detail.warningMessages.map((message, index) => (
<div
key={`${detail.id}-warning-${index}`}
className="text-sm leading-7 text-amber-50"
>
{message}
</div>
))}
</div>
</div>
) : null}
</div>
) : (
<div className="mt-4 rounded-[1.15rem] border border-dashed border-white/10 bg-black/22 px-4 py-5 text-sm leading-7 text-zinc-400">
稿稿
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,115 @@
import type { CustomWorldDraftCardSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentDraftDrawerProps = {
draftCards: CustomWorldDraftCardSummary[];
activeCardId?: string | null;
onSelectCard: (cardId: string) => void;
};
const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [
'world',
'chapter',
'thread',
'faction',
'character',
'landmark',
'camp',
];
function resolveGroupLabel(kind: CustomWorldDraftCardSummary['kind']) {
if (kind === 'world') return '世界总卡';
if (kind === 'chapter') return '第一幕';
if (kind === 'thread') return '世界线程';
if (kind === 'faction') return '势力';
if (kind === 'character') return '关键角色';
if (kind === 'landmark') return '关键地点';
if (kind === 'camp') return '营地';
return '草稿卡';
}
export function CustomWorldAgentDraftDrawer({
draftCards,
activeCardId,
onSelectCard,
}: CustomWorldAgentDraftDrawerProps) {
const groupedCards = DRAWER_KIND_ORDER.map((kind) => ({
kind,
items: draftCards.filter((card) => card.kind === kind),
})).filter((group) => group.items.length > 0);
return (
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
稿
</div>
{groupedCards.length > 0 ? (
<div className="mt-3 space-y-4">
{groupedCards.map((group) => (
<section key={group.kind}>
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] tracking-[0.18em] text-zinc-400">
{resolveGroupLabel(group.kind)}
</div>
<div className="text-[11px] text-zinc-500">
{group.items.length}
</div>
</div>
<div className="mt-2 space-y-2">
{group.items.map((card) => {
const isActive = activeCardId === card.id;
return (
<button
key={card.id}
type="button"
onClick={() => onSelectCard(card.id)}
className={`w-full rounded-[1.2rem] border px-3 py-3 text-left transition ${
isActive
? 'border-sky-300/30 bg-sky-500/10'
: 'border-white/8 bg-white/5 hover:border-white/14'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-white">
{card.title}
</div>
{card.warningCount > 0 ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
{card.warningCount}
</span>
) : null}
</div>
<div className="mt-1 text-[11px] text-zinc-400">
{card.subtitle}
</div>
<div className="mt-2 text-sm leading-6 text-zinc-300">
{card.summary}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-200">
{card.linkedIds.length}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-200">
{card.status === 'warning' ? '待精修' : '建议稿'}
</span>
{card.kind === 'character' && card.assetStatusLabel ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
{card.assetStatusLabel}
</span>
) : null}
</div>
</button>
);
})}
</div>
</section>
))}
</div>
) : (
<div className="mt-3 text-sm leading-7 text-zinc-400">
稿
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,18 @@
type CustomWorldAgentHeaderProps = {
onBack: () => void;
};
export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps) {
return (
<div className="flex items-center justify-between gap-3 rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
<button
type="button"
onClick={onBack}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:text-white"
>
</button>
<div className="text-sm font-semibold text-white">Agent</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldAgentIntentSummaryPanel } from './CustomWorldAgentIntentSummaryPanel';
test('intent summary panel shows collected custom world anchors', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentIntentSummaryPanel
creatorIntent={{
sourceMode: 'freeform',
rawSettingText: '',
worldHook: '一个被潮雾切开的列岛世界。',
themeKeywords: ['海岛'],
toneDirectives: ['冷峻'],
playerPremise: '玩家是失职返乡的守灯人。',
openingSituation: '开局站在即将熄灭的旧灯塔上。',
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
keyCharacters: [],
iconicElements: ['潮雾钟声'],
}}
readiness={{
isReady: false,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'iconic_element',
],
missingKeys: ['relationship_seed'],
}}
/>,
);
expect(html).toContain('已收集锚点');
expect(html).toContain('世界一句话');
expect(html).toContain('一个被潮雾切开的列岛世界');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
expect(html).toContain('5/6');
});

View File

@@ -0,0 +1,99 @@
import type { CreatorIntentReadiness } from '../../../packages/shared/src/contracts/customWorldAgent';
import {
evaluateCustomWorldCreatorIntentReadiness,
hasMeaningfulCustomWorldCreatorIntent,
normalizeCustomWorldCreatorIntent,
} from '../../services/customWorldCreatorIntent';
type CustomWorldAgentIntentSummaryPanelProps = {
creatorIntent: Record<string, unknown> | null;
readiness: CreatorIntentReadiness;
};
export function CustomWorldAgentIntentSummaryPanel({
creatorIntent,
readiness,
}: CustomWorldAgentIntentSummaryPanelProps) {
const intent = normalizeCustomWorldCreatorIntent(creatorIntent);
const resolvedReadiness =
readiness ?? evaluateCustomWorldCreatorIntentReadiness(intent);
const items = [
{
label: '世界一句话',
value: intent?.worldHook || '',
ready: resolvedReadiness.completedKeys.includes('world_hook'),
},
{
label: '玩家身份',
value: intent?.playerPremise || '',
ready: Boolean(intent?.playerPremise),
},
{
label: '开局处境',
value: intent?.openingSituation || '',
ready: Boolean(intent?.openingSituation),
},
{
label: '核心冲突',
value: intent?.coreConflicts.join('、') || '',
ready: resolvedReadiness.completedKeys.includes('core_conflict'),
},
{
label: '主题气质',
value:
[...(intent?.themeKeywords ?? []), ...(intent?.toneDirectives ?? [])]
.filter(Boolean)
.join('、') || '',
ready: resolvedReadiness.completedKeys.includes('theme_and_tone'),
},
{
label: '标志性要素',
value: intent?.iconicElements.join('、') || '',
ready: resolvedReadiness.completedKeys.includes('iconic_element'),
},
];
return (
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-2 text-lg font-semibold text-white">
{resolvedReadiness.isReady ? '创作输入已齐备' : '继续收世界骨架'}
</div>
</div>
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
{resolvedReadiness.completedKeys.length}/6
</span>
</div>
{hasMeaningfulCustomWorldCreatorIntent(intent) ? (
<div className="mt-4 grid gap-2 sm:grid-cols-2">
{items.map((item) => (
<div
key={item.label}
className={`rounded-[1.15rem] border px-3 py-3 ${
item.ready
? 'border-emerald-300/18 bg-emerald-500/8'
: 'border-white/8 bg-white/5'
}`}
>
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
{item.label}
</div>
<div className="mt-2 text-sm leading-6 text-zinc-100">
{item.value || '待补充'}
</div>
</div>
))}
</div>
) : (
<div className="mt-4 rounded-[1.15rem] border border-dashed border-white/10 bg-black/22 px-4 py-5 text-sm text-zinc-400">
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,90 @@
import { X } from 'lucide-react';
type CustomWorldAgentLauncherModalProps = {
isOpen: boolean;
seedText: string;
isBusy: boolean;
error: string | null;
onClose: () => void;
onSeedTextChange: (value: string) => void;
onConfirm: () => void;
};
export function CustomWorldAgentLauncherModal({
isOpen,
seedText,
isBusy,
error,
onClose,
onSeedTextChange,
onConfirm,
}: CustomWorldAgentLauncherModalProps) {
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm">
<div className="flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] border border-white/10 bg-[#11161f] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="text-base font-semibold text-white">
Agent
</div>
<div className="mt-1 text-xs text-zinc-400">
</div>
</div>
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<label className="block">
<div className="mb-2 text-sm font-medium text-zinc-200">
Seed Text
</div>
<textarea
value={seedText}
onChange={(event) => onSeedTextChange(event.target.value)}
rows={7}
placeholder="例:一个被潮雾切碎的列岛世界,灯塔守望者、沉船秘术与旧盟约残片正在重新苏醒。"
className="w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
/>
</label>
{error ? (
<div className="mt-4 rounded-3xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
<button
type="button"
onClick={onConfirm}
disabled={isBusy}
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-45"
>
{isBusy ? '处理中...' : '开始共创'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
type CustomWorldAgentLockBarProps = {
lockState: Record<string, unknown> | null;
};
function readLockedItems(lockState: Record<string, unknown> | null) {
if (!lockState) {
return [];
}
return Object.entries(lockState)
.flatMap(([key, value]) =>
Array.isArray(value)
? value.map((item) => `${key}:${String(item)}`)
: typeof value === 'string' && value.trim()
? [`${key}:${value.trim()}`]
: [],
)
.slice(0, 8);
}
export function CustomWorldAgentLockBar({
lockState,
}: CustomWorldAgentLockBarProps) {
const lockedItems = readLockedItems(lockState);
return (
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
{lockedItems.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{lockedItems.map((item) => (
<span
key={item}
className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100"
>
{item}
</span>
))}
</div>
) : (
<div className="mt-3 text-sm leading-7 text-zinc-400">
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react';
import type { CustomWorldAgentOperationRecord } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentOperationBannerProps = {
operation: CustomWorldAgentOperationRecord | null;
};
export function CustomWorldAgentOperationBanner({
operation,
}: CustomWorldAgentOperationBannerProps) {
const [visibleOperation, setVisibleOperation] =
useState<CustomWorldAgentOperationRecord | null>(operation);
useEffect(() => {
setVisibleOperation(operation);
if (operation?.status !== 'completed') {
return;
}
const timeoutId = window.setTimeout(() => {
setVisibleOperation((current) =>
current?.operationId === operation.operationId ? null : current,
);
}, 1200);
return () => window.clearTimeout(timeoutId);
}, [operation]);
if (!visibleOperation) {
return null;
}
const isFailed = visibleOperation.status === 'failed';
const isRunning =
visibleOperation.status === 'running' || visibleOperation.status === 'queued';
return (
<div
className={`rounded-[1.4rem] border px-4 py-4 ${
isFailed
? 'border-rose-400/20 bg-[#111318]/95'
: isRunning
? 'border-emerald-300/20 bg-[#111318]/95'
: 'border-emerald-300/20 bg-[#111318]/95'
}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
{visibleOperation.phaseLabel}
</div>
<div className="text-xs text-zinc-300">
{Math.max(0, Math.min(100, Math.round(visibleOperation.progress)))}%
</div>
</div>
{visibleOperation.error ? (
<div className="mt-2 text-sm text-zinc-200">
{visibleOperation.error}
</div>
) : null}
<div className="mt-3 h-2 overflow-hidden rounded-full bg-white/10">
<div
className={`h-full rounded-full transition-[width] duration-300 ${
isFailed ? 'bg-rose-300' : 'bg-emerald-300'
}`}
style={{
width: `${Math.max(8, Math.min(100, visibleOperation.progress))}%`,
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import type { CustomWorldSuggestedAction } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentQuickActionsProps = {
suggestedActions: CustomWorldSuggestedAction[];
disabled: boolean;
canDraftFoundation: boolean;
showEntityActions?: boolean;
onRequestSummary: () => void;
onDraftFoundation: () => void;
onGenerateCharacter?: () => void;
onGenerateLandmark?: () => void;
onGenerateRoleAssets?: () => void;
showRoleAssetAction?: boolean;
onFocusSuggestedAction: (action?: CustomWorldSuggestedAction) => void;
};
function QuickActionButton(props: {
label: string;
onClick: () => void;
disabled: boolean;
tone?: 'default' | 'sky' | 'amber';
}) {
const { label, onClick, disabled, tone = 'default' } = props;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-[1.1rem] border px-4 py-3 text-left text-sm transition disabled:cursor-not-allowed disabled:opacity-45 ${
tone === 'amber'
? 'border-amber-300/20 bg-amber-500/10 text-amber-100 hover:text-white'
: tone === 'sky'
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
: 'border-white/10 bg-black/20 text-zinc-200 hover:text-white'
}`}
>
{label}
</button>
);
}
export function CustomWorldAgentQuickActions({
suggestedActions,
disabled,
canDraftFoundation,
showEntityActions = false,
onRequestSummary,
onDraftFoundation,
onGenerateCharacter,
onGenerateLandmark,
onGenerateRoleAssets,
showRoleAssetAction = false,
onFocusSuggestedAction,
}: CustomWorldAgentQuickActionsProps) {
const summaryAction = suggestedActions.find(
(action) => action.type === 'request_summary',
);
const draftAction = suggestedActions.find(
(action) => action.type === 'draft_foundation',
);
const refinementActions = suggestedActions.filter(
(action) =>
action.type !== 'request_summary' && action.type !== 'draft_foundation',
);
return (
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-3 flex flex-col gap-2">
<QuickActionButton
label={summaryAction?.label ?? '总结当前设定'}
onClick={onRequestSummary}
disabled={disabled}
tone="sky"
/>
{draftAction && canDraftFoundation ? (
<QuickActionButton
label={draftAction.label}
onClick={onDraftFoundation}
disabled={disabled}
tone="amber"
/>
) : null}
{showEntityActions && onGenerateCharacter ? (
<QuickActionButton
label="新增角色"
onClick={onGenerateCharacter}
disabled={disabled}
/>
) : null}
{showEntityActions && onGenerateLandmark ? (
<QuickActionButton
label="新增场景"
onClick={onGenerateLandmark}
disabled={disabled}
/>
) : null}
{showRoleAssetAction && onGenerateRoleAssets ? (
<QuickActionButton
label="生成角色主图与动作"
onClick={onGenerateRoleAssets}
disabled={disabled}
tone="amber"
/>
) : null}
{refinementActions.length > 0 ? (
refinementActions.slice(0, 2).map((action) => (
<QuickActionButton
key={action.id}
label={action.label}
onClick={() => onFocusSuggestedAction(action)}
disabled={disabled}
/>
))
) : !draftAction || !canDraftFoundation ? (
<QuickActionButton
label={showEntityActions ? '继续精修当前草稿' : '继续补充锚点'}
onClick={() => onFocusSuggestedAction()}
disabled={disabled}
/>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentSummaryPanelProps = {
session: CustomWorldAgentSessionSnapshot;
};
function readSummaryText(
draftProfile: Record<string, unknown> | null,
fallback: string,
) {
const title =
typeof draftProfile?.title === 'string' ? draftProfile.title.trim() : '';
const summary =
typeof draftProfile?.summary === 'string'
? draftProfile.summary.trim()
: '';
return {
title: title || '世界摘要待整理',
summary: summary || fallback,
};
}
export function CustomWorldAgentSummaryPanel({
session,
}: CustomWorldAgentSummaryPanelProps) {
const pendingCount = session.pendingClarifications.length;
const { title, summary } = readSummaryText(
session.draftProfile,
'第一阶段先收住世界锚点,后续阶段再把这里整理成更完整的世界底稿摘要。',
);
return (
<div className="rounded-[1.75rem] border border-white/10 bg-black/20 px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-2 text-lg font-semibold text-white">
{title}
</div>
</div>
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
{session.messages.length}
</span>
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
{pendingCount}
</span>
</div>
</div>
<div className="mt-3 text-sm leading-7 text-zinc-300">
{summary}
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useEffect, useRef } from 'react';
import type { CustomWorldAgentMessage } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentThreadProps = {
messages: CustomWorldAgentMessage[];
recommendedReplies?: string[];
onRecommendedReply?: (text: string) => void;
};
function formatMessageTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
export function CustomWorldAgentThread({
messages,
recommendedReplies = [],
onRecommendedReply,
}: CustomWorldAgentThreadProps) {
const bottomRef = useRef<HTMLDivElement | null>(null);
const lastAssistantMessageId = [...messages]
.reverse()
.find((message) => message.role === 'assistant')?.id;
useEffect(() => {
bottomRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end',
});
}, [messages]);
return (
<div className="flex min-h-[20rem] flex-1 flex-col overflow-y-auto rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
{messages.length === 0 ? (
<div className="m-auto text-sm text-zinc-400">
</div>
) : (
<div className="space-y-3">
{messages.map((message) => {
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
return (
<div
key={message.id}
className={`flex ${
isUser ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${
isUser
? 'border border-white/10 bg-white/10 text-zinc-50'
: isSystem
? 'border border-amber-300/16 bg-amber-500/10 text-amber-50'
: 'border border-white/10 bg-white/6 text-zinc-100'
}`}
>
<div className="whitespace-pre-wrap">{message.text}</div>
<div className="mt-2 text-[11px] text-zinc-400">
{formatMessageTime(message.createdAt)}
</div>
{!isUser &&
message.id === lastAssistantMessageId &&
recommendedReplies.length > 0 ? (
<div className="mt-3 flex flex-col gap-2">
{recommendedReplies.slice(0, 3).map((reply) => (
<button
key={reply}
type="button"
onClick={() => onRecommendedReply?.(reply)}
className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-left text-xs leading-5 text-zinc-200 transition hover:border-emerald-300/25 hover:text-white"
>
{reply}
</button>
))}
</div>
) : null}
</div>
</div>
);
})}
<div ref={bottomRef} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,471 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import type {
CustomWorldAgentSessionSnapshot,
CustomWorldDraftCardDetail,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import { getCustomWorldAgentCardDetail } from '../../services/aiService';
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
vi.mock('../../services/aiService', () => ({
getCustomWorldAgentCardDetail: vi.fn(),
}));
vi.mock('../CustomWorldRoleAssetStudioModal', () => ({
CustomWorldRoleAssetStudioModal: ({
role,
onPublishSuccess,
}: {
role: { name: string };
onPublishSuccess?: (
payload: {
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
},
options?: { closeAfterSync?: boolean },
) => void;
}) => (
<div>
<div>{role.name}</div>
<button
type="button"
onClick={() =>
onPublishSuccess?.(
{
roleId: 'character-1',
portraitPath: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
animationMap: {
idle: { basePath: '/generated/character-1/idle' },
run: { basePath: '/generated/character-1/run' },
attack: { basePath: '/generated/character-1/attack' },
hurt: { basePath: '/generated/character-1/hurt' },
die: { basePath: '/generated/character-1/die' },
},
},
{
closeAfterSync: true,
},
)
}
>
</button>
</div>
),
}));
const detailById: Record<string, CustomWorldDraftCardDetail> = {
'world-foundation': {
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
sections: [
{
id: 'title',
label: '标题',
value: '潮雾列岛',
},
{
id: 'summary',
label: '摘要',
value: '这是第一版世界底稿。',
},
],
linkedIds: ['thread-1', 'character-1'],
locked: false,
editable: true,
editableSectionIds: ['title', 'summary'],
warningMessages: [],
},
'character-1': {
id: 'character-1',
kind: 'character',
title: '沈砺',
sections: [
{
id: 'name',
label: '角色名',
value: '沈砺',
},
{
id: 'summary',
label: '角色摘要',
value: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
linkedIds: ['thread-1'],
locked: false,
editable: true,
editableSectionIds: ['name', 'summary'],
warningMessages: [],
assetStatus: 'missing',
assetStatusLabel: '待生成主图',
},
'character-2': {
id: 'character-2',
kind: 'character',
title: '顾潮音',
sections: [
{
id: 'name',
label: '角色名',
value: '顾潮音',
},
{
id: 'summary',
label: '角色摘要',
value: '她总像比所有人更早知道海雾会往哪一侧压下来。',
},
],
linkedIds: ['thread-1'],
locked: false,
editable: true,
editableSectionIds: ['name', 'summary'],
warningMessages: [],
assetStatus: 'missing',
assetStatusLabel: '待生成主图',
},
};
const baseSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'custom-world-agent-session-1',
stage: 'object_refining',
focusCardId: 'world-foundation',
creatorIntent: {},
creatorIntentReadiness: {
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
},
anchorPack: {},
lockState: {},
draftProfile: {
name: '潮雾列岛',
storyNpcs: [
{
id: 'character-1',
name: '沈砺',
title: '守灯会旧友',
role: '航道向导',
publicMask: '守灯会里最熟悉旧航道的人。',
hiddenHook: '暗地里正在为沉船商盟引路。',
relationToPlayer: '旧友兼宿敌',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
},
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'summary',
text: '当前底稿已经可以继续精修。',
createdAt: new Date().toISOString(),
relatedOperationId: null,
},
],
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与航道争夺',
summary: '世界总卡已经生成。',
status: 'warning',
linkedIds: ['thread-1', 'character-1'],
warningCount: 1,
},
{
id: 'character-1',
kind: 'character',
title: '沈砺',
subtitle: '守灯会旧友',
summary: '他最了解旧航道,也最可能先背叛。',
status: 'suggested',
linkedIds: ['thread-1'],
warningCount: 0,
},
],
pendingClarifications: [],
suggestedActions: [
{
id: 'request-summary',
type: 'request_summary',
label: '总结当前世界底稿',
targetId: null,
},
],
recommendedReplies: ['现在开始生成草稿', '先总结一下当前设定', '我还想再补充一点'],
qualityFindings: [],
assetCoverage: {
roleAssets: [
{
roleId: 'character-1',
roleName: '沈砺',
roleKind: 'story',
priorityTier: 'featured',
portraitPath: null,
generatedVisualAssetId: null,
generatedAnimationSetId: null,
status: 'missing',
missingAnimations: ['idle', 'run', 'attack', 'hurt', 'die'],
nextPointCost: 20,
},
],
sceneAssets: [],
allRoleAssetsReady: false,
allSceneAssetsReady: false,
},
updatedAt: '2026-04-14T10:00:00.000Z',
};
beforeEach(() => {
vi.mocked(getCustomWorldAgentCardDetail).mockImplementation(
async (_sessionId, cardId): Promise<CustomWorldDraftCardDetail> =>
detailById[cardId] ?? detailById['world-foundation']!,
);
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
});
test('workspace loads detail, saves edits, opens generate actions, and reflects updated drawer cards', async () => {
const user = userEvent.setup();
const onExecuteAction = vi.fn();
const { rerender } = render(
<CustomWorldAgentWorkspace
session={baseSession}
activeOperation={null}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
await waitFor(() => {
expect(getCustomWorldAgentCardDetail).toHaveBeenCalledWith(
baseSession.sessionId,
'world-foundation',
);
});
await user.click(screen.getByRole('button', { name: '编辑设定' }));
const summaryInput = screen.getByLabelText('摘要');
await user.clear(summaryInput);
await user.type(summaryInput, '这是更新后的世界摘要。');
await user.click(screen.getByRole('button', { name: '保存' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'update_draft_card',
cardId: 'world-foundation',
sections: [
{
sectionId: 'title',
value: '潮雾列岛',
},
{
sectionId: 'summary',
value: '这是更新后的世界摘要。',
},
],
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(getCustomWorldAgentCardDetail).toHaveBeenLastCalledWith(
baseSession.sessionId,
'character-1',
);
});
const [generateCharacterButton] = screen.getAllByRole('button', { name: '新增角色' });
await user.click(generateCharacterButton!);
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '生成角色' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_characters',
count: 2,
promptText: null,
anchorCardIds: ['character-1'],
});
const [generateLandmarkButton] = screen.getAllByRole('button', { name: '新增场景' });
await user.click(generateLandmarkButton!);
expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '生成场景' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_landmarks',
count: 2,
promptText: null,
anchorCardIds: ['character-1'],
});
const [openRoleAssetsButton] = screen.getAllByRole('button', {
name: '角色资产',
});
await user.click(openRoleAssetsButton!);
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_role_assets',
roleIds: ['character-1'],
});
rerender(
<CustomWorldAgentWorkspace
session={{
...baseSession,
stage: 'visual_refining',
draftCards: [
...baseSession.draftCards,
{
id: 'character-2',
kind: 'character',
title: '顾潮音',
subtitle: '回潮记录员',
summary: '她会把每一次海雾异常都记到连自己都不愿复看的本子里。',
status: 'suggested',
linkedIds: ['thread-1'],
warningCount: 0,
},
],
updatedAt: '2026-04-14T10:05:00.000Z',
}}
activeOperation={{
operationId: 'operation-role-assets',
type: 'generate_role_assets',
status: 'completed',
phaseLabel: '角色资产工坊已就绪',
phaseDetail: '可以开始生成角色主图与动作。',
progress: 100,
error: null,
}}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
await waitFor(() => {
expect(screen.getByText('顾潮音')).toBeTruthy();
});
expect(screen.getByText('角色资产工坊:沈砺')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '模拟同步角色资产' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'sync_role_assets',
roleId: 'character-1',
portraitPath: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
animationMap: {
idle: { basePath: '/generated/character-1/idle' },
run: { basePath: '/generated/character-1/run' },
attack: { basePath: '/generated/character-1/attack' },
hurt: { basePath: '/generated/character-1/hurt' },
die: { basePath: '/generated/character-1/die' },
},
});
rerender(
<CustomWorldAgentWorkspace
session={{
...baseSession,
stage: 'visual_refining',
draftCards: [
{
...baseSession.draftCards[0]!,
},
{
...baseSession.draftCards[1]!,
subtitle: '守灯会旧友 / 动作已就绪',
assetStatus: 'complete',
assetStatusLabel: '动作已就绪',
},
],
assetCoverage: {
roleAssets: [
{
roleId: 'character-1',
roleName: '沈砺',
roleKind: 'story',
priorityTier: 'featured',
portraitPath: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
status: 'complete',
missingAnimations: [],
nextPointCost: 0,
},
],
sceneAssets: [],
allRoleAssetsReady: true,
allSceneAssetsReady: false,
},
draftProfile: {
...baseSession.draftProfile,
storyNpcs: [
{
id: 'character-1',
name: '沈砺',
title: '守灯会旧友',
role: '航道向导',
publicMask: '守灯会里最熟悉旧航道的人。',
hiddenHook: '暗地里正在为沉船商盟引路。',
relationToPlayer: '旧友兼宿敌',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
imageSrc: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
animationMap: {
idle: { basePath: '/generated/character-1/idle' },
run: { basePath: '/generated/character-1/run' },
attack: { basePath: '/generated/character-1/attack' },
hurt: { basePath: '/generated/character-1/hurt' },
die: { basePath: '/generated/character-1/die' },
},
},
],
},
}}
activeOperation={{
operationId: 'operation-sync-role-assets',
type: 'sync_role_assets',
status: 'completed',
phaseLabel: '角色资产已同步',
phaseDetail: '角色资产已经写回草稿。',
progress: 100,
error: null,
}}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
await waitFor(() => {
expect(screen.getAllByText('动作已就绪').length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,96 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
test('custom world agent workspace renders progress labels, action button and recommended replies', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentWorkspace
session={{
sessionId: 'custom-world-agent-session-1',
stage: 'object_refining',
focusCardId: 'world-foundation',
creatorIntent: {},
creatorIntentReadiness: {
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
},
anchorPack: {},
lockState: {},
draftProfile: {
name: '潮雾列岛',
},
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'summary',
text: '欢迎。当前底稿已经可以继续精修。',
createdAt: new Date().toISOString(),
relatedOperationId: null,
},
],
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与航道争夺',
summary: '世界总卡已经生成。',
status: 'warning',
linkedIds: ['thread-1', 'character-1'],
warningCount: 1,
},
{
id: 'character-1',
kind: 'character',
title: '沈砺',
subtitle: '守灯会旧友',
summary: '他最了解旧航道,也最可能先背叛。',
status: 'suggested',
linkedIds: ['thread-1'],
warningCount: 0,
},
],
pendingClarifications: [],
suggestedActions: [
{
id: 'request-summary',
type: 'request_summary',
label: '总结当前世界底稿',
targetId: null,
},
],
recommendedReplies: ['现在开始生成草稿', '先总结一下当前设定'],
qualityFindings: [],
assetCoverage: {
roleAssets: [],
sceneAssets: [],
allRoleAssetsReady: false,
allSceneAssetsReady: false,
},
updatedAt: new Date().toISOString(),
}}
activeOperation={null}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
/>,
);
expect(html).toContain('首轮草稿会先确认这 6 项信息');
expect(html).toContain('世界核心');
expect(html).toContain('玩家开局');
expect(html).toContain('现在开始生成草稿');
expect(html).toContain('开始生成草稿');
expect(html).toContain('欢迎。当前底稿已经可以继续精修。');
});

View File

@@ -0,0 +1,702 @@
import { useEffect, useState } from 'react';
import type {
CustomWorldAgentActionRequest,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
CustomWorldDraftCardDetail,
SendCustomWorldAgentMessageRequest,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldRoleAssetStudioModal } from '../CustomWorldRoleAssetStudioModal';
import { getCustomWorldAgentCardDetail } from '../../services/aiService';
import { CustomWorldAgentComposer } from './CustomWorldAgentComposer';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
import { CustomWorldAgentDraftDrawer } from './CustomWorldAgentDraftDrawer';
import { CustomWorldAgentHeader } from './CustomWorldAgentHeader';
import { CustomWorldAgentOperationBanner } from './CustomWorldAgentOperationBanner';
import { CustomWorldAgentQuickActions } from './CustomWorldAgentQuickActions';
import { CustomWorldAgentThread } from './CustomWorldAgentThread';
import { CustomWorldDraftCardDetailModal } from './CustomWorldDraftCardDetailModal';
import { CustomWorldGenerateEntityModal } from './CustomWorldGenerateEntityModal';
type WorkspaceRoleAssetTarget = {
id: string;
name: string;
title: string;
role: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown>;
};
type CustomWorldAgentWorkspaceProps = {
session: CustomWorldAgentSessionSnapshot | null;
activeOperation: CustomWorldAgentOperationRecord | null;
onBack: () => void;
onRefresh: () => void;
onSubmitMessage: (payload: SendCustomWorldAgentMessageRequest) => void;
onExecuteAction: (payload: CustomWorldAgentActionRequest) => void;
};
const TOTAL_READINESS_STEPS = 6;
const READINESS_ITEMS = [
{ key: 'world_hook', label: '世界核心' },
{ key: 'player_premise', label: '玩家开局' },
{ key: 'theme_and_tone', label: '主题气质' },
{ key: 'core_conflict', label: '核心冲突' },
{ key: 'relationship_seed', label: '关键关系' },
{ key: 'iconic_element', label: '标志元素' },
] as const;
function createClientMessageId() {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID();
}
return `client-message-${Date.now()}`;
}
function resolveInitialCardId(session: CustomWorldAgentSessionSnapshot | null) {
if (!session || session.draftCards.length === 0) {
return null;
}
return (
session.focusCardId ||
session.draftCards.find((card) => card.kind === 'world')?.id ||
session.draftCards[0]?.id ||
null
);
}
function buildRecommendedReplies(session: CustomWorldAgentSessionSnapshot) {
return session.recommendedReplies;
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
)
: [];
}
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function resolveRoleAssetTarget(
session: CustomWorldAgentSessionSnapshot | null,
roleId: string | null,
) {
if (!session || !roleId) {
return null;
}
const draftProfile = toRecord(session.draftProfile);
if (!draftProfile) {
return null;
}
const playableRole = toRecordArray(draftProfile.playableNpcs).find(
(item) => toText(item.id) === roleId,
);
const storyRole = toRecordArray(draftProfile.storyNpcs).find(
(item) => toText(item.id) === roleId,
);
const role = playableRole ?? storyRole;
if (!role) {
return null;
}
const assetSummary =
session.assetCoverage.roleAssets.find((entry) => entry.roleId === roleId) ??
null;
return {
role: {
id: roleId,
name: toText(role.name) || '未命名角色',
title: toText(role.title) || toText(role.role) || '关键角色',
role: toText(role.role) || toText(role.title) || '关键角色',
description: toText(role.summary),
backstory: toText(role.hiddenHook) || undefined,
personality: toText(role.publicMask) || undefined,
motivation: toText(role.relationToPlayer) || undefined,
combatStyle: toText(role.role) || undefined,
tags: Array.isArray(role.threadIds)
? role.threadIds
.map((item) => toText(item))
.filter(Boolean)
.slice(0, 4)
: [],
imageSrc: toText(role.imageSrc) || undefined,
generatedVisualAssetId: toText(role.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(role.generatedAnimationSetId) || undefined,
animationMap: toRecord(role.animationMap) ?? undefined,
} satisfies WorkspaceRoleAssetTarget,
roleKind: playableRole ? ('playable' as const) : ('story' as const),
assetSummary,
};
}
function CustomWorldAgentReadinessBar(props: {
completedKeys: string[];
isReady: boolean;
busy: boolean;
onStartDraft: () => void;
}) {
const { completedKeys, isReady, busy, onStartDraft } = props;
const completedKeySet = new Set(completedKeys);
const completedCount = READINESS_ITEMS.filter((item) =>
completedKeySet.has(item.key),
).length;
return (
<div className="rounded-[1.35rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold tracking-[0.12em] text-zinc-300">
稿 6
</div>
</div>
<div className="text-xs text-zinc-400">
{Math.min(completedCount, TOTAL_READINESS_STEPS)}/
{TOTAL_READINESS_STEPS}
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="grid min-w-0 flex-1 grid-cols-3 gap-2 sm:grid-cols-6">
{READINESS_ITEMS.map((item) => (
<div
key={item.key}
className={`rounded-2xl border px-2.5 py-2 text-center text-[11px] ${
completedKeySet.has(item.key)
? 'border-emerald-300/25 bg-emerald-500/10 text-emerald-100'
: 'border-white/10 bg-black/18 text-zinc-500'
}`}
>
{item.label}
</div>
))}
</div>
<div className="flex items-center justify-between gap-3 sm:justify-end">
{isReady ? (
<button
type="button"
onClick={onStartDraft}
disabled={busy}
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
{busy ? '生成中' : '开始生成草稿'}
</button>
) : null}
</div>
</div>
</div>
);
}
export function CustomWorldAgentWorkspace({
session,
activeOperation,
onBack,
onSubmitMessage,
onExecuteAction,
}: CustomWorldAgentWorkspaceProps) {
const [selectedCardId, setSelectedCardId] = useState<string | null>(() =>
resolveInitialCardId(session),
);
const [detail, setDetail] = useState<CustomWorldDraftCardDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [autoCompleteConfirmOpen, setAutoCompleteConfirmOpen] = useState(false);
const [generateEntityMode, setGenerateEntityMode] = useState<
'character' | 'landmark' | null
>(null);
const [requestedRoleAssetTargetId, setRequestedRoleAssetTargetId] = useState<
string | null
>(null);
const [activeRoleAssetTargetId, setActiveRoleAssetTargetId] = useState<
string | null
>(null);
const [showRoleAssetStudio, setShowRoleAssetStudio] = useState(false);
const [closeRoleAssetStudioAfterSync, setCloseRoleAssetStudioAfterSync] =
useState(false);
useEffect(() => {
if (!session) {
setSelectedCardId(null);
return;
}
const availableCardIds = new Set(session.draftCards.map((card) => card.id));
if (session.focusCardId && availableCardIds.has(session.focusCardId)) {
setSelectedCardId(session.focusCardId);
return;
}
setSelectedCardId((current) => {
if (current && availableCardIds.has(current)) {
return current;
}
return resolveInitialCardId(session);
});
}, [session]);
useEffect(() => {
setEditMode(false);
}, [detail?.id]);
useEffect(() => {
if (!requestedRoleAssetTargetId || !activeOperation) {
return;
}
if (activeOperation.type !== 'generate_role_assets') {
return;
}
if (activeOperation.status === 'completed') {
setActiveRoleAssetTargetId(requestedRoleAssetTargetId);
setShowRoleAssetStudio(true);
setRequestedRoleAssetTargetId(null);
setDetailModalOpen(false);
return;
}
if (activeOperation.status === 'failed') {
setRequestedRoleAssetTargetId(null);
}
}, [activeOperation, requestedRoleAssetTargetId]);
useEffect(() => {
if (!activeOperation || activeOperation.type !== 'sync_role_assets') {
return;
}
if (activeOperation.status === 'completed') {
if (closeRoleAssetStudioAfterSync) {
setShowRoleAssetStudio(false);
}
setCloseRoleAssetStudioAfterSync(false);
return;
}
if (activeOperation.status === 'failed') {
setCloseRoleAssetStudioAfterSync(false);
}
}, [activeOperation, closeRoleAssetStudioAfterSync]);
useEffect(() => {
if (!session?.sessionId || !selectedCardId) {
setDetail(null);
setDetailLoading(false);
return;
}
let cancelled = false;
setDetailLoading(true);
void getCustomWorldAgentCardDetail(session.sessionId, selectedCardId)
.then((nextDetail) => {
if (cancelled) {
return;
}
setDetail(nextDetail);
})
.catch(() => {
if (cancelled) {
return;
}
setDetail(null);
})
.finally(() => {
if (!cancelled) {
setDetailLoading(false);
}
});
return () => {
cancelled = true;
};
}, [selectedCardId, session?.sessionId, session?.updatedAt]);
if (!session) {
return (
<div className="mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] border border-white/10 bg-black/20 px-6 py-8 text-center text-sm text-zinc-400">
</div>
);
}
const isBusy =
activeOperation?.status === 'queued' || activeOperation?.status === 'running';
const canStartDraft =
session.creatorIntentReadiness.isReady &&
session.stage === 'foundation_review';
const showAutoCompleteButton =
!session.creatorIntentReadiness.isReady &&
session.creatorIntentReadiness.completedKeys.includes('world_hook');
const showDraftWorkspace =
(session.stage === 'object_refining' || session.stage === 'visual_refining') &&
session.draftCards.length > 0;
const selectedCard = session.draftCards.find((card) => card.id === selectedCardId) ?? null;
const recommendedReplies = buildRecommendedReplies(session);
const selectedRoleAssetContext = resolveRoleAssetTarget(
session,
activeRoleAssetTargetId,
);
const openRoleAssetStudio = (roleId: string | null) => {
if (!roleId) {
return;
}
setRequestedRoleAssetTargetId(roleId);
onExecuteAction({
action: 'generate_role_assets',
roleIds: [roleId],
});
};
const submitTextMessage = (text: string) => {
onSubmitMessage({
clientMessageId: createClientMessageId(),
text,
focusCardId: selectedCardId,
selectedCardIds: selectedCardId ? [selectedCardId] : [],
});
};
const submitSummaryRequest = () => {
submitTextMessage(
showDraftWorkspace
? '帮我总结当前世界底稿,并指出下一步最值得精修的卡片。'
: '帮我总结当前设定,并指出下一步最值得补的世界锚点。',
);
};
const submitAutoCompleteRequest = () => {
submitTextMessage(
session.creatorIntentReadiness.isReady
? '基于当前设定,帮我自动补强还可以更清晰的细节。'
: '请根据当前信息自动补全还缺的设定,并给我一版默认方案。',
);
setAutoCompleteConfirmOpen(false);
};
const handleRecommendedReply = (reply: string) => {
if (canStartDraft && reply.includes('生成草稿')) {
onExecuteAction({
action: 'draft_foundation',
});
return;
}
submitTextMessage(reply);
};
const openGenerateModal = (mode: 'character' | 'landmark') => {
setGenerateEntityMode(mode);
};
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-[1500px] flex-col gap-3">
<CustomWorldAgentHeader onBack={onBack} />
<CustomWorldAgentReadinessBar
completedKeys={session.creatorIntentReadiness.completedKeys}
isReady={canStartDraft}
busy={isBusy}
onStartDraft={() => {
onExecuteAction({
action: 'draft_foundation',
});
}}
/>
<CustomWorldAgentOperationBanner operation={activeOperation} />
{showDraftWorkspace ? (
<div className="grid min-h-0 flex-1 gap-3 xl:grid-cols-[18rem_minmax(0,1fr)_24rem]">
<div className="flex min-h-0 flex-col gap-3 xl:overflow-hidden">
<CustomWorldAgentQuickActions
suggestedActions={session.suggestedActions}
disabled={isBusy}
canDraftFoundation={canStartDraft}
showEntityActions
showRoleAssetAction={selectedCard?.kind === 'character'}
onRequestSummary={submitSummaryRequest}
onDraftFoundation={() => {
onExecuteAction({
action: 'draft_foundation',
});
}}
onGenerateCharacter={() => {
openGenerateModal('character');
}}
onGenerateLandmark={() => {
openGenerateModal('landmark');
}}
onGenerateRoleAssets={() => {
openRoleAssetStudio(selectedCardId);
}}
onFocusSuggestedAction={(action) => {
if (action?.targetId) {
setSelectedCardId(action.targetId);
setDetailModalOpen(true);
return;
}
if (session.draftCards[0]) {
setSelectedCardId(session.draftCards[0].id);
setDetailModalOpen(true);
}
}}
/>
<div className="xl:min-h-0 xl:overflow-y-auto">
<CustomWorldAgentDraftDrawer
draftCards={session.draftCards}
activeCardId={selectedCardId}
onSelectCard={(cardId) => {
setSelectedCardId(cardId);
setDetailModalOpen(true);
}}
/>
</div>
</div>
<div className="hidden min-h-0 xl:block xl:overflow-y-auto">
<CustomWorldAgentDraftDetailPanel
detail={detail}
loading={detailLoading}
busy={isBusy}
editMode={editMode}
onClose={() => {
setSelectedCardId(null);
setDetailModalOpen(false);
setEditMode(false);
}}
onStartEdit={() => {
setEditMode(true);
}}
onCancelEdit={() => {
setEditMode(false);
}}
onSave={(sections) => {
if (!detail) {
return;
}
setEditMode(false);
onExecuteAction({
action: 'update_draft_card',
cardId: detail.id,
sections,
});
}}
onGenerateCharacter={() => {
openGenerateModal('character');
}}
onGenerateLandmark={() => {
openGenerateModal('landmark');
}}
onOpenRoleAssetStudio={() => {
openRoleAssetStudio(detail?.id ?? selectedCardId);
}}
/>
</div>
<div className="flex min-h-0 flex-col gap-3">
<div className="h-[18rem] min-h-[18rem] xl:min-h-0 xl:flex-1">
<CustomWorldAgentThread
messages={session.messages}
recommendedReplies={recommendedReplies}
onRecommendedReply={handleRecommendedReply}
/>
</div>
<CustomWorldAgentComposer
disabled={isBusy}
onSubmit={onSubmitMessage}
onSummaryClick={submitSummaryRequest}
onAutoCompleteClick={() => setAutoCompleteConfirmOpen(true)}
showAutoComplete={showAutoCompleteButton}
/>
</div>
</div>
) : (
<>
<CustomWorldAgentThread
messages={session.messages}
recommendedReplies={recommendedReplies}
onRecommendedReply={handleRecommendedReply}
/>
<CustomWorldAgentComposer
disabled={isBusy}
onSubmit={onSubmitMessage}
onSummaryClick={submitSummaryRequest}
onAutoCompleteClick={() => setAutoCompleteConfirmOpen(true)}
showAutoComplete={showAutoCompleteButton}
/>
</>
)}
{autoCompleteConfirmOpen ? (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div className="w-full max-w-md rounded-[1.5rem] border border-white/10 bg-[#111318] px-5 py-5 shadow-[0_20px_60px_rgba(0,0,0,0.45)]">
<div className="text-base font-semibold text-white">
</div>
<div className="mt-3 text-sm leading-7 text-zinc-300">
</div>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={() => setAutoCompleteConfirmOpen(false)}
className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-200 transition hover:text-white"
>
</button>
<button
type="button"
onClick={submitAutoCompleteRequest}
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-4 py-2 text-sm text-emerald-100 transition hover:text-white"
>
</button>
</div>
</div>
</div>
) : null}
<CustomWorldDraftCardDetailModal
open={detailModalOpen}
detail={detail}
loading={detailLoading}
busy={isBusy}
editMode={editMode}
onClose={() => {
setDetailModalOpen(false);
setEditMode(false);
}}
onStartEdit={() => {
setEditMode(true);
}}
onCancelEdit={() => {
setEditMode(false);
}}
onSave={(sections) => {
if (!detail) {
return;
}
setEditMode(false);
setDetailModalOpen(false);
onExecuteAction({
action: 'update_draft_card',
cardId: detail.id,
sections,
});
}}
onGenerateCharacter={() => {
setDetailModalOpen(false);
openGenerateModal('character');
}}
onGenerateLandmark={() => {
setDetailModalOpen(false);
openGenerateModal('landmark');
}}
onOpenRoleAssetStudio={() => {
setDetailModalOpen(false);
openRoleAssetStudio(detail?.id ?? selectedCardId);
}}
/>
<CustomWorldGenerateEntityModal
open={generateEntityMode !== null}
mode={generateEntityMode ?? 'character'}
anchorCardTitle={selectedCard?.title ?? detail?.title ?? null}
disabled={isBusy}
onClose={() => {
setGenerateEntityMode(null);
}}
onSubmit={({ count, promptText }) => {
if (!generateEntityMode) {
return;
}
onExecuteAction({
action:
generateEntityMode === 'character'
? 'generate_characters'
: 'generate_landmarks',
count,
promptText: promptText || null,
anchorCardIds: selectedCardId ? [selectedCardId] : [],
});
setGenerateEntityMode(null);
}}
/>
{showRoleAssetStudio && selectedRoleAssetContext ? (
<CustomWorldRoleAssetStudioModal
role={selectedRoleAssetContext.role}
roleKind={selectedRoleAssetContext.roleKind}
priorityTier={
selectedRoleAssetContext.assetSummary?.priorityTier ??
(selectedRoleAssetContext.roleKind === 'playable'
? 'hero'
: 'featured')
}
visualPointCost={
selectedRoleAssetContext.assetSummary?.status === 'missing'
? selectedRoleAssetContext.assetSummary?.nextPointCost ?? 20
: 20
}
animationPointCost={
selectedRoleAssetContext.assetSummary?.status === 'missing'
? 60
: selectedRoleAssetContext.assetSummary?.nextPointCost ?? 60
}
syncBusy={
activeOperation?.type === 'sync_role_assets' &&
(activeOperation.status === 'queued' ||
activeOperation.status === 'running')
}
onPublishSuccess={(payload, options) => {
setCloseRoleAssetStudioAfterSync(Boolean(options?.closeAfterSync));
onExecuteAction({
action: 'sync_role_assets',
...payload,
});
}}
onClose={() => {
setShowRoleAssetStudio(false);
setCloseRoleAssetStudioAfterSync(false);
}}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,67 @@
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
type CustomWorldDraftCardDetailModalProps = {
open: boolean;
detail: CustomWorldDraftCardDetail | null;
loading: boolean;
busy?: boolean;
editMode?: boolean;
onClose: () => void;
onStartEdit?: () => void;
onCancelEdit?: () => void;
onSave?: (
sections: Array<{
sectionId: string;
value: string;
}>,
) => void;
onGenerateCharacter?: () => void;
onGenerateLandmark?: () => void;
onOpenRoleAssetStudio?: () => void;
};
export function CustomWorldDraftCardDetailModal({
open,
detail,
loading,
busy = false,
editMode = false,
onClose,
onStartEdit,
onCancelEdit,
onSave,
onGenerateCharacter,
onGenerateLandmark,
onOpenRoleAssetStudio,
}: CustomWorldDraftCardDetailModalProps) {
if (!open) {
return null;
}
return (
<div className="fixed inset-0 z-[95] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm xl:hidden">
<button
type="button"
aria-label="关闭卡片详情"
onClick={onClose}
className="absolute inset-0 cursor-default"
/>
<div className="relative z-10 max-h-[85vh] w-full max-w-2xl overflow-y-auto">
<CustomWorldAgentDraftDetailPanel
detail={detail}
loading={loading}
busy={busy}
editMode={editMode}
onClose={onClose}
onStartEdit={onStartEdit}
onCancelEdit={onCancelEdit}
onSave={onSave}
onGenerateCharacter={onGenerateCharacter}
onGenerateLandmark={onGenerateLandmark}
onOpenRoleAssetStudio={onOpenRoleAssetStudio}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
test('draft detail panel renders editable form in edit mode', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentDraftDetailPanel
detail={{
id: 'character-1',
kind: 'character',
title: '沈砺',
sections: [
{
id: 'name',
label: '角色名',
value: '沈砺',
},
{
id: 'publicMask',
label: '外显身份',
value: '守灯会里最熟悉旧航道的人。',
},
{
id: 'summary',
label: '角色摘要',
value: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
linkedIds: ['thread-1'],
locked: false,
editable: true,
editableSectionIds: ['name', 'publicMask', 'summary'],
warningMessages: [],
}}
loading={false}
editMode
onClose={() => {}}
onCancelEdit={() => {}}
onSave={() => {}}
/>,
);
expect(html).toContain('保存');
expect(html).toContain('取消');
expect(html).toContain('角色名');
expect(html).toContain('textarea');
});

View File

@@ -0,0 +1,136 @@
import { useEffect, useMemo, useState } from 'react';
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldDraftEditPanelProps = {
detail: CustomWorldDraftCardDetail;
disabled?: boolean;
onSave: (
sections: Array<{
sectionId: string;
value: string;
}>,
) => void;
onCancel: () => void;
};
function shouldUseTextarea(sectionId: string, value: string) {
return (
value.length > 28 ||
value.includes('\n') ||
sectionId === 'summary' ||
sectionId === 'tone' ||
sectionId === 'coreConflicts' ||
sectionId === 'hiddenHook' ||
sectionId === 'secret' ||
sectionId === 'stakes' ||
sectionId === 'openingEvent' ||
sectionId === 'understandingShift' ||
sectionId === 'description'
);
}
export function CustomWorldDraftEditPanel({
detail,
disabled = false,
onSave,
onCancel,
}: CustomWorldDraftEditPanelProps) {
const editableSections = useMemo(
() =>
detail.sections.filter((section) =>
detail.editableSectionIds.includes(section.id),
),
[detail],
);
const [draftValues, setDraftValues] = useState<Record<string, string>>(() =>
Object.fromEntries(
editableSections.map((section) => [section.id, section.value]),
),
);
useEffect(() => {
setDraftValues(
Object.fromEntries(editableSections.map((section) => [section.id, section.value])),
);
}, [editableSections]);
if (editableSections.length === 0) {
return null;
}
return (
<div className="space-y-3">
{editableSections.map((section) => {
const value = draftValues[section.id] ?? '';
const multiline = shouldUseTextarea(section.id, value);
return (
<label
key={section.id}
className="block rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3"
>
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
{section.label}
</div>
{multiline ? (
<textarea
value={value}
onChange={(event) => {
const nextValue = event.target.value;
setDraftValues((current) => ({
...current,
[section.id]: nextValue,
}));
}}
rows={4}
disabled={disabled}
className="mt-2 w-full resize-none rounded-[0.9rem] border border-white/10 bg-black/26 px-3 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
) : (
<input
type="text"
value={value}
onChange={(event) => {
const nextValue = event.target.value;
setDraftValues((current) => ({
...current,
[section.id]: nextValue,
}));
}}
disabled={disabled}
className="mt-2 h-11 w-full rounded-[0.9rem] border border-white/10 bg-black/26 px-3 text-sm text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
)}
</label>
);
})}
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={onCancel}
disabled={disabled}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
<button
type="button"
onClick={() => {
onSave(
editableSections.map((section) => ({
sectionId: section.id,
value: draftValues[section.id] ?? '',
})),
);
}}
disabled={disabled}
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { useEffect, useState } from 'react';
type CustomWorldGenerateEntityModalProps = {
open: boolean;
mode: 'character' | 'landmark';
anchorCardTitle?: string | null;
disabled?: boolean;
onClose: () => void;
onSubmit: (payload: {
count: number;
promptText: string;
}) => void;
};
export function CustomWorldGenerateEntityModal({
open,
mode,
anchorCardTitle,
disabled = false,
onClose,
onSubmit,
}: CustomWorldGenerateEntityModalProps) {
const [count, setCount] = useState(2);
const [promptText, setPromptText] = useState('');
useEffect(() => {
if (!open) {
return;
}
setCount(2);
setPromptText('');
}, [open, mode]);
if (!open) {
return null;
}
const title = mode === 'character' ? '新增角色' : '新增场景';
const submitLabel = mode === 'character' ? '生成角色' : '生成场景';
return (
<div className="fixed inset-0 z-[96] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm sm:items-center">
<button
type="button"
aria-label="关闭新增弹窗"
onClick={onClose}
className="absolute inset-0 cursor-default"
/>
<div className="relative z-10 w-full max-w-xl rounded-[1.8rem] border border-white/10 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(8,10,14,0.96))] px-4 py-4 shadow-[0_18px_60px_rgba(0,0,0,0.35)] sm:px-5 sm:py-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
AI
</div>
<div className="mt-2 text-lg font-semibold text-white">{title}</div>
</div>
<button
type="button"
onClick={onClose}
disabled={disabled}
className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
</div>
<div className="mt-4 space-y-4">
{anchorCardTitle ? (
<div className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
</div>
<div className="mt-2 text-sm text-zinc-100">{anchorCardTitle}</div>
</div>
) : null}
<div className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-400"></div>
<div className="mt-3 flex gap-2">
{[1, 2, 3].map((value) => (
<button
key={value}
type="button"
onClick={() => setCount(value)}
disabled={disabled}
className={`rounded-full border px-4 py-2 text-sm transition ${
count === value
? 'border-sky-300/20 bg-sky-500/10 text-sky-100'
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
} disabled:cursor-not-allowed disabled:opacity-45`}
>
{value}
</button>
))}
</div>
</div>
<label className="block rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
</div>
<textarea
value={promptText}
onChange={(event) => setPromptText(event.target.value)}
rows={5}
disabled={disabled}
className="mt-2 w-full resize-none rounded-[0.9rem] border border-white/10 bg-black/26 px-3 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
</div>
<div className="mt-4 flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={disabled}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
<button
type="button"
onClick={() => {
onSubmit({
count,
promptText: promptText.trim(),
});
}}
disabled={disabled}
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
{submitLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
const baseDraftItem: CustomWorldWorkSummary = {
workId: 'draft:session-1',
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '补齐关键锚点',
summary: '玩家是失职返乡的守灯人。',
coverImageSrc: null,
updatedAt: new Date('2026-04-14T10:00:00.000Z').toISOString(),
publishedAt: null,
stage: 'object_refining',
stageLabel: '精修对象',
playableNpcCount: 3,
landmarkCount: 4,
sessionId: 'session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
};
test('creation hub reflects updated draft title summary and counts after rerender', () => {
const { rerender } = render(
<CustomWorldCreationHub
items={[baseDraftItem]}
loading={false}
error={null}
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
expect(screen.getByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
expect(screen.getByText('角色 3')).toBeTruthy();
expect(screen.getByText('地点 4')).toBeTruthy();
rerender(
<CustomWorldCreationHub
items={[
{
...baseDraftItem,
title: '潮雾列岛·回潮版',
summary: '世界总卡和角色网已经继续长出了新的支线。',
playableNpcCount: 5,
landmarkCount: 6,
updatedAt: new Date('2026-04-14T10:10:00.000Z').toISOString(),
},
]}
loading={false}
error={null}
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
expect(screen.getByText('潮雾列岛·回潮版')).toBeTruthy();
expect(screen.getByText('世界总卡和角色网已经继续长出了新的支线。')).toBeTruthy();
expect(screen.getByText('角色 5')).toBeTruthy();
expect(screen.getByText('地点 6')).toBeTruthy();
});

View File

@@ -0,0 +1,44 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
test('creation hub draft card renders compiled work summary fields', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[
{
workId: 'draft:session-1',
sourceType: 'agent_session',
status: 'draft',
title: '一个被潮雾切开的列岛世界',
subtitle: '补齐关键锚点',
summary:
'玩家是失职返乡的守灯人 · 核心冲突:守灯会与沉船商盟争夺航道解释权',
coverImageSrc: null,
updatedAt: new Date('2026-04-13T12:00:00.000Z').toISOString(),
publishedAt: null,
stage: 'clarifying',
stageLabel: '补齐关键锚点',
playableNpcCount: 0,
landmarkCount: 0,
sessionId: 'session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]}
loading={false}
error={null}
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
expect(html).toContain('一个被潮雾切开的列岛世界');
expect(html).toContain('玩家是失职返乡的守灯人');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
});

View File

@@ -0,0 +1,154 @@
import { useMemo, useState } from 'react';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
import {
type CustomWorldWorkFilter,
CustomWorldWorkTabs,
} from './CustomWorldWorkTabs';
type CustomWorldCreationHubProps = {
items: CustomWorldWorkSummary[];
loading: boolean;
error: string | null;
onBack: () => void;
onRetry: () => void;
onCreateNew: () => void;
onResumeDraft: (sessionId: string) => void;
onEnterPublished: (profileId: string) => void;
};
function EmptyState({ title }: { title: string }) {
return (
<div className="flex min-h-[16rem] flex-col items-center justify-center rounded-[1.8rem] border border-white/8 bg-white/5 px-6 py-8 text-center">
<div className="text-lg font-semibold text-white">{title}</div>
</div>
);
}
export function CustomWorldCreationHub({
items,
loading,
error,
onBack,
onRetry,
onCreateNew,
onResumeDraft,
onEnterPublished,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
const draftCount = items.filter((item) => item.status === 'draft').length;
const publishedCount = items.filter(
(item) => item.status === 'published',
).length;
const filteredItems = useMemo(
() =>
items.filter((item) =>
activeFilter === 'all' ? true : item.status === activeFilter,
),
[activeFilter, items],
);
return (
<div
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<div className="sticky top-0 z-20 -mx-3 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(10,12,18,0.88),rgba(10,12,18,0))] px-3 pb-4 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-5 sm:pt-0">
<div className="flex items-start justify-between gap-3">
<div>
<button
type="button"
onClick={onBack}
className="rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white"
>
</button>
<div className="mt-4 text-[1.8rem] font-black leading-tight text-white sm:text-[2.3rem]">
</div>
</div>
<div className="hidden shrink-0 gap-2 sm:flex">
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
稿 {draftCount}
</span>
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
{publishedCount}
</span>
</div>
</div>
</div>
<div className="space-y-4">
<CustomWorldCreationStartCard onCreateNew={onCreateNew} />
<CustomWorldWorkTabs
activeFilter={activeFilter}
draftCount={draftCount}
publishedCount={publishedCount}
onChange={setActiveFilter}
/>
{error ? (
<div className="rounded-3xl border border-rose-400/18 bg-rose-500/10 px-4 py-4 text-sm leading-7 text-rose-100">
<div>{error}</div>
<button
type="button"
onClick={onRetry}
className="mt-3 rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white"
>
</button>
</div>
) : null}
{loading ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={`skeleton-${index}`}
className="min-h-[12rem] rounded-[1.8rem] border border-white/8 bg-white/5 p-5"
>
<div className="h-4 w-20 rounded-full bg-white/10" />
<div className="mt-6 h-8 w-36 rounded-full bg-white/10" />
<div className="mt-4 h-4 w-full rounded-full bg-white/10" />
<div className="mt-2 h-4 w-4/5 rounded-full bg-white/10" />
<div className="mt-8 flex gap-2">
<div className="h-7 w-20 rounded-full bg-white/10" />
<div className="h-7 w-20 rounded-full bg-white/10" />
</div>
</div>
))}
</div>
) : filteredItems.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredItems.map((item) => (
<CustomWorldWorkCard
key={item.workId}
item={item}
onClick={() => {
if (item.status === 'draft' && item.sessionId) {
onResumeDraft(item.sessionId);
return;
}
if (item.status === 'published' && item.profileId) {
onEnterPublished(item.profileId);
}
}}
/>
))}
</div>
) : items.length === 0 ? (
<EmptyState title="还没有作品" />
) : (
<EmptyState title="当前筛选下没有内容" />
)}
</div>
</div>
);
}
export type { CustomWorldWorkFilter };

View File

@@ -0,0 +1,146 @@
import { X } from 'lucide-react';
import type { CustomWorldQuestion } from '../../../packages/shared/src/contracts/runtime';
type CustomWorldCreationLauncherModalProps = {
isOpen: boolean;
mode: 'create' | 'resume';
seedText: string;
seedTextLocked: boolean;
questions: CustomWorldQuestion[];
answers: Record<string, string>;
isBusy: boolean;
error: string | null;
lastError?: string | null;
primaryLabel: string;
onClose: () => void;
onSeedTextChange: (value: string) => void;
onAnswerChange: (questionId: string, value: string) => void;
onPrimaryAction: () => void;
};
export function CustomWorldCreationLauncherModal({
isOpen,
mode,
seedText,
seedTextLocked,
questions,
answers,
isBusy,
error,
lastError = null,
primaryLabel,
onClose,
onSeedTextChange,
onAnswerChange,
onPrimaryAction,
}: CustomWorldCreationLauncherModalProps) {
if (!isOpen) {
return null;
}
const unansweredQuestions = questions.filter((question) => !question.answer?.trim());
return (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm">
<div className="flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] border border-white/10 bg-[#11161f] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="text-base font-semibold text-white">
{mode === 'create' ? '新建作品' : '继续创作'}
</div>
<div className="mt-1 text-xs text-zinc-400">
</div>
</div>
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="space-y-4">
<label className="block">
<div className="mb-2 text-sm font-medium text-zinc-200">
</div>
<textarea
value={seedText}
onChange={(event) => onSeedTextChange(event.target.value)}
rows={seedTextLocked ? 4 : 6}
readOnly={seedTextLocked}
placeholder="例:一个被潮雾切碎的列岛世界,灯塔守望者、沉船秘术与旧盟约残片正在重新苏醒。"
className={`w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 ${
seedTextLocked ? 'cursor-not-allowed opacity-75' : ''
}`}
/>
</label>
{unansweredQuestions.length > 0 ? (
<div className="space-y-3">
<div className="rounded-3xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-zinc-300">
</div>
{unansweredQuestions.map((question) => (
<label key={question.id} className="block">
<div className="mb-2 text-sm font-medium text-zinc-200">
{question.label}
</div>
<div className="mb-2 text-xs leading-6 text-zinc-400">
{question.question}
</div>
<textarea
value={answers[question.id] ?? question.answer ?? ''}
onChange={(event) =>
onAnswerChange(question.id, event.target.value)
}
rows={3}
placeholder="补充一句就可以。"
className="w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
/>
</label>
))}
</div>
) : null}
{lastError ? (
<div className="rounded-3xl border border-amber-400/25 bg-amber-500/10 px-4 py-3 text-sm leading-6 text-amber-100">
{lastError}
</div>
) : null}
{error ? (
<div className="rounded-3xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
<button
type="button"
onClick={onPrimaryAction}
disabled={isBusy}
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-45"
>
{isBusy ? '处理中...' : primaryLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
type CustomWorldCreationStartCardProps = {
onCreateNew: () => void;
};
export function CustomWorldCreationStartCard({
onCreateNew,
}: CustomWorldCreationStartCardProps) {
return (
<div
className="pixel-nine-slice pixel-panel overflow-hidden"
style={getNineSliceStyle(UI_CHROME.storyPanel, {
paddingX: 18,
paddingY: 16,
})}
>
<div className="relative overflow-hidden rounded-[1.75rem] border border-white/8 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.18),transparent_36%),linear-gradient(180deg,rgba(8,10,14,0.28),rgba(8,10,14,0.82))] px-5 py-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="text-2xl font-black text-white sm:text-3xl">
</div>
</div>
<button
type="button"
onClick={onCreateNew}
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 18,
paddingY: 11,
})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
function formatUpdatedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '最近更新';
}
return new Intl.DateTimeFormat('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
type CustomWorldWorkCardProps = {
item: CustomWorldWorkSummary;
onClick: () => void;
};
export function CustomWorldWorkCard({
item,
onClick,
}: CustomWorldWorkCardProps) {
const isDraft = item.status === 'draft';
const hasFoundationDraft =
item.playableNpcCount > 0 || item.landmarkCount > 0;
const roleCountLabel = isDraft ? '角色' : '可扮演角色';
return (
<div
className="pixel-nine-slice pixel-panel relative overflow-hidden"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 15,
})}
>
{item.coverImageSrc ? (
<img
src={item.coverImageSrc}
alt={item.title}
className="absolute inset-0 h-full w-full object-cover opacity-20"
style={{ imageRendering: 'pixelated' }}
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.12),rgba(8,10,14,0.82))]" />
<div className="relative z-10 flex h-full min-h-[12rem] flex-col">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap gap-2">
<span
className={`rounded-full border px-3 py-1 text-[10px] tracking-[0.18em] ${
isDraft
? 'border-amber-300/20 bg-amber-500/10 text-amber-100'
: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
}`}
>
{isDraft ? '草稿' : '已发布'}
</span>
{item.stageLabel ? (
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-200">
{item.stageLabel}
</span>
) : null}
</div>
<div className="text-[11px] text-zinc-400">
{formatUpdatedAt(item.updatedAt)}
</div>
</div>
<div className="mt-4">
<div className="text-2xl font-black text-white">
{item.title}
</div>
<div className="mt-1 text-xs tracking-[0.12em] text-zinc-400">
{item.subtitle}
</div>
<div className="mt-3 line-clamp-3 text-sm leading-7 text-zinc-200/90">
{item.summary}
</div>
</div>
<div className="mt-auto flex items-center justify-between gap-3 pt-4">
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100">
{roleCountLabel} {item.playableNpcCount}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100">
{item.landmarkCount}
</span>
{item.roleVisualReadyCount ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] text-amber-100">
{item.roleVisualReadyCount}
</span>
) : null}
{item.roleAnimationReadyCount ? (
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
{item.roleAnimationReadyCount}
</span>
) : null}
{item.roleAssetSummaryLabel ? (
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-200">
{item.roleAssetSummaryLabel}
</span>
) : null}
</div>
<button
type="button"
onClick={onClick}
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white"
>
{isDraft ? (hasFoundationDraft ? '继续精修' : '继续创作') : '进入世界'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
export type CustomWorldWorkFilter = 'all' | 'draft' | 'published';
const FILTER_OPTIONS: Array<{
id: CustomWorldWorkFilter;
label: string;
}> = [
{ id: 'all', label: '全部' },
{ id: 'draft', label: '草稿' },
{ id: 'published', label: '已发布' },
];
type CustomWorldWorkTabsProps = {
activeFilter: CustomWorldWorkFilter;
draftCount: number;
publishedCount: number;
onChange: (filter: CustomWorldWorkFilter) => void;
};
export function CustomWorldWorkTabs({
activeFilter,
draftCount,
publishedCount,
onChange,
}: CustomWorldWorkTabsProps) {
return (
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{FILTER_OPTIONS.map((option) => {
const count =
option.id === 'draft'
? draftCount
: option.id === 'published'
? publishedCount
: draftCount + publishedCount;
return (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={`shrink-0 rounded-full border px-4 py-2 text-sm transition ${
activeFilter === option.id
? 'border-sky-300/20 bg-sky-500/10 text-sky-100'
: 'border-white/10 bg-black/18 text-zinc-300 hover:text-white'
}`}
>
{option.label} {count}
</button>
);
})}
</div>
);
}

View File

@@ -111,6 +111,9 @@ export function GameCanvasEntityLayer({
monsterAnchorMeters,
playerX,
}: GameCanvasEntityLayerProps) {
const shouldRenderPeacefulEncounter =
Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
return (
<>
{companions.map(companion => {
@@ -327,8 +330,12 @@ export function GameCanvasEntityLayer({
);
})}
{encounter &&
{shouldRenderPeacefulEncounter &&
(() => {
if (!encounter) {
return null;
}
const isCampCompanionEncounter =
encounter.specialBehavior === 'initial_companion'
|| encounter.specialBehavior === 'camp_companion';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
import { existsSync } from 'node:fs';
import { SERVER_RUNTIME_FUNCTION_IDS } from '../../../packages/shared/src/contracts/story';
import { describe, expect, it } from 'vitest';
import {
ALL_FUNCTION_DOCUMENTATION,
buildCampTravelHomeOption,
buildContinueAdventureOption,
buildNpcGiftModalState,
buildNpcPreviewTalkOption,
buildNpcRecruitModalState,
buildNpcTradeModalState,
CONTINUE_ADVENTURE_FUNCTION,
getFunctionDocumentationById,
isNpcPreviewTalkOption,
NPC_PREVIEW_TALK_FUNCTION,
shouldNpcRecruitOpenModal,
} from './index';
import type { Encounter, GameState, InventoryItem } from '../../types';
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
return {
id: 'npc-trader',
kind: 'npc',
npcName: '梁伯',
npcDescription: '沿路摆摊的商人。',
npcAvatar: '梁',
context: '商贩',
...overrides,
};
}
function createInventoryItem(
id: string,
name: string,
overrides: Partial<InventoryItem> = {},
): InventoryItem {
return {
id,
name,
description: `${name} 的测试描述`,
quantity: 1,
category: 'misc',
rarity: 'common',
tags: [],
value: 1,
...overrides,
};
}
function createModalState(overrides: Partial<GameState> = {}): GameState {
return {
playerInventory: [
createInventoryItem('player-potion', '疗伤药'),
createInventoryItem('player-charm', '护符'),
],
companions: [
{
npcId: 'npc-ally-1',
characterId: 'ally-1',
name: '阿青',
role: '同伴',
joinedAtAffinity: 12,
},
],
...overrides,
} as GameState;
}
describe('functionCatalog', () => {
it('keeps function documentation ids unique and source files resolvable', () => {
const documentationIds = ALL_FUNCTION_DOCUMENTATION.map((entry) => entry.id);
expect(new Set(documentationIds).size).toBe(documentationIds.length);
ALL_FUNCTION_DOCUMENTATION.forEach((entry) => {
expect(existsSync(entry.source), `${entry.id} -> ${entry.source}`).toBe(
true,
);
expect(getFunctionDocumentationById(entry.id)).toEqual(entry);
});
});
it('covers every server runtime function id with documentation metadata', () => {
SERVER_RUNTIME_FUNCTION_IDS.forEach((functionId) => {
expect(getFunctionDocumentationById(functionId)).not.toBeNull();
});
});
it('builds flow helper options with the expected function ids', () => {
const continueOption = buildContinueAdventureOption();
const campTravelOption = buildCampTravelHomeOption('竹林古道');
expect(continueOption.functionId).toBe(CONTINUE_ADVENTURE_FUNCTION.id);
expect(continueOption.priority).toBe(99);
expect(campTravelOption.functionId).toBe('camp_travel_home_scene');
expect(campTravelOption.actionText).toBe('前往 竹林古道');
expect(campTravelOption.detailText).toBe('离开营地,前往 竹林古道。');
});
it('builds npc preview talk options from the current encounter', () => {
const option = buildNpcPreviewTalkOption(createEncounter());
expect(option.functionId).toBe(NPC_PREVIEW_TALK_FUNCTION.id);
expect(option.actionText).toBe('与 梁伯 交谈');
expect(isNpcPreviewTalkOption(option)).toBe(true);
});
it('builds modal helper state for trade, gift and recruit flows', () => {
const state = createModalState();
const encounter = createEncounter();
const tradeModal = buildNpcTradeModalState(
state,
encounter,
'先看看货',
[
createInventoryItem('npc-herb', '止血草'),
createInventoryItem('npc-ore', '陨铁碎片'),
],
);
const giftModal = buildNpcGiftModalState(
state,
encounter,
'送你一样东西',
'player-charm',
);
const recruitModal = buildNpcRecruitModalState(
state,
encounter,
'谈谈同行的事',
);
expect(tradeModal.selectedNpcItemId).toBe('npc-herb');
expect(tradeModal.selectedPlayerItemId).toBe('player-potion');
expect(giftModal.selectedItemId).toBe('player-charm');
expect(recruitModal.selectedReleaseNpcId).toBe('npc-ally-1');
expect(shouldNpcRecruitOpenModal(2, 2)).toBe(true);
expect(shouldNpcRecruitOpenModal(1, 2)).toBe(false);
});
it('prefers the first tradable player item when zero-quantity items exist', () => {
const encounter = createEncounter();
const tradeModal = buildNpcTradeModalState(
createModalState({
playerInventory: [
createInventoryItem('empty-slot', '空槽位', { quantity: 0 }),
createInventoryItem('usable-item', '可售草药', { quantity: 2 }),
],
}),
encounter,
'交易',
[createInventoryItem('npc-herb', '止血草')],
);
expect(tradeModal.selectedPlayerItemId).toBe('usable-item');
});
});

View File

@@ -21,13 +21,18 @@ export function buildNpcTradeModalState(
actionText: string,
npcInventory: InventoryItem[],
): TradeModalState {
const selectedNpcItemId =
npcInventory.find((item) => item.quantity > 0)?.id ?? null;
const selectedPlayerItemId =
state.playerInventory.find((item) => item.quantity > 0)?.id ?? null;
return {
encounter,
actionText,
introText: buildNpcTradeModalIntroText(encounter),
mode: 'buy',
selectedNpcItemId: npcInventory[0]?.id ?? null,
selectedPlayerItemId: state.playerInventory[0]?.id ?? null,
selectedNpcItemId,
selectedPlayerItemId,
selectedQuantity: 1,
};
}

Some files were not shown because too many files have changed in this diff Show More