diff --git a/.env.example b/.env.example index 64f49925..bc09527a 100644 --- a/.env.example +++ b/.env.example @@ -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。 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..f6906f2e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/Genarrative.iml b/.idea/Genarrative.iml new file mode 100644 index 00000000..c956989b --- /dev/null +++ b/.idea/Genarrative.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 00000000..932f7d1b --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 00000000..79ee123c --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..03d9549e --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..315bbf8a --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml new file mode 100644 index 00000000..b0c1c68f --- /dev/null +++ b/.idea/prettier.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 2674bb44..b4380165 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ - 前端只负责做表现,所有的逻辑、数据都放到Express后端进行运算和存储。 - 请默认保持系统的简洁性,能复用、修改、扩展现有系统、页面就不新建新系统新页面。 - 禁止将功能说明描述类的文本默认写入UI界面中。 +- prd文档中每个模块的描述要落地设计到可以精准编码到位,不能出现需求落地漂移。 ## 文档图谱 diff --git a/docs/audits/FUNCTION_REQUIREMENT_COMPLETENESS_AUDIT_2026-04-14.md b/docs/audits/FUNCTION_REQUIREMENT_COMPLETENESS_AUDIT_2026-04-14.md new file mode 100644 index 00000000..61f1cb76 --- /dev/null +++ b/docs/audits/FUNCTION_REQUIREMENT_COMPLETENESS_AUDIT_2026-04-14.md @@ -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 首次点击也必须立刻生成一段剧情反馈”,否则不建议为了形式统一再强行改写当前分流模型。 diff --git a/docs/audits/FUNCTION_TEST_AUDIT_2026-04-14.md b/docs/audits/FUNCTION_TEST_AUDIT_2026-04-14.md new file mode 100644 index 00000000..9a08e01d --- /dev/null +++ b/docs/audits/FUNCTION_TEST_AUDIT_2026-04-14.md @@ -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 稳定复现出来,避免问题只停留在口头描述。 +- 第二步在修复后把断言翻转成“正确行为”,让它们正式成为回归测试。 diff --git a/docs/audits/README.md b/docs/audits/README.md index 0673170b..3bc09ac9 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -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):自定义世界创作工具当前问题、体验断层和优化优先级审计。 diff --git a/docs/design/CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md b/docs/design/CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md new file mode 100644 index 00000000..4ba249a7 --- /dev/null +++ b/docs/design/CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md @@ -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 负责把它整理成可修改的策划初稿,系统负责把它稳定地跑成一个游戏世界。** diff --git a/docs/design/CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md b/docs/design/CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md new file mode 100644 index 00000000..126d7639 --- /dev/null +++ b/docs/design/CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md @@ -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 阶段 B:Agent 输出首轮世界底稿 + +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 风险 2:Agent 过度代写,创作者失去作品归属感 + +防护方式: + +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 化,但绝不能把“世界状态管理”也做成纯聊天。** diff --git a/docs/design/README.md b/docs/design/README.md index 2898ca71..c3106211 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -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 对比与转型设计稿。 - 做自定义世界去模板依赖、跨题材泛化、兼容迁移设计时,优先看新增的去模板化优化设计稿。 - 做“模板依赖如何真正变成自定义世界自有设定层”的具体迁移方案时,优先看新增的自有设定层优化方案。 - 做角色关系、同伴互动、对话表现时,先看后两份。 diff --git a/docs/design/RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md b/docs/design/RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md new file mode 100644 index 00000000..a51dd93f --- /dev/null +++ b/docs/design/RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md @@ -0,0 +1,1026 @@ +# RPG 游戏全剧情专业策划工作流程 + +更新时间:`2026-04-12` + +## 0. 文档目的 + +这份文档用于整理一套专业剧情策划在 RPG 项目中构建“游戏内所有剧情”的完整工作流程。 + +这里的“所有剧情”不只指主线对白,而是包括: + +1. 世界观与主题母题 +2. 主线剧情 +3. 支线剧情 +4. 队友 / 同伴 / 关键 NPC 个人线 +5. 阵营线 +6. 场景章节 +7. 任务合约与阶段推进 +8. 物件、文书、残痕、传闻等叙事载体 +9. 玩家选择、后果与长期回响 +10. 运行时 AI 生成所需的提示词上下文与可见性边界 + +这份流程的目标不是让策划一次写完所有文本,而是建立一套可生产、可扩展、可验收、可接入当前项目 AI 原生剧情引擎的剧情生产方法。 + +一句话目标: + +**先把世界、角色、线程、章节、任务、载体、选择和回响的骨架搭稳,再让文本、AI 生成和演出表现长在同一套结构上。** + +--- + +## 1. 核心原则 + +## 1.1 剧情策划不是写故事,而是设计可运行的叙事系统 + +专业 RPG 剧情策划要交付的不是一篇小说,而是一套可以被玩家行动驱动的剧情结构。 + +因此每一段剧情都要能回答: + +1. 这段剧情服务哪条世界线程? +2. 玩家在这里能做什么? +3. 哪些信息此刻能被玩家知道? +4. 哪些角色会因为玩家行为改变立场? +5. 这段剧情结束后,世界、角色、物件或任务状态发生了什么变化? +6. 这些变化后续在哪里回响? + +如果一段剧情只能阅读,不能推进状态,也不能产生回响,它就更像背景文案,不是完整的 RPG 剧情单元。 + +## 1.2 世界先于角色,角色先于对白,状态先于文案 + +推荐顺序是: + +```text +世界主题 +-> 世界线程 +-> 阵营与角色立场 +-> 信息可见性 +-> 章节与任务结构 +-> 场景、物件、对话、演出文本 +``` + +不要从对白或桥段开始反推世界观。这样容易出现角色很会说话,但世界没有共同冲突、主线没有抓手、支线和物件互不相干的问题。 + +## 1.3 主线、支线、角色线和物件线必须讲同一个世界 + +好的 RPG 剧情不是“主线一套,支线一套,物品描述又一套”。 + +所有内容都应该能挂回同一张世界故事图谱: + +1. 主线负责推进世界核心冲突。 +2. 支线负责展示核心冲突在普通人、地点、组织中的局部后果。 +3. 角色线负责把世界冲突压到个人选择与关系代价上。 +4. 物件和残痕负责把旧事、证据、误导和主题意象分散到玩家探索中。 +5. 阵营线负责让玩家看到不同价值立场之间的冲突。 + +## 1.4 AI 负责表达,本地规则负责边界 + +结合当前项目方向,剧情策划要默认把内容拆成两部分: + +1. AI 可生成部分 + - 对话措辞 + - 场景氛围 + - 行动结果描述 + - 任务引导文本 + - 物件叙事包装 + - 角色语气与情绪细节 + +2. 本地规则必须掌控部分 + - 剧情阶段 + - 玩家已知信息 + - 任务状态 + - 选择裁决 + - 关系变化 + - 奖励发放 + - 后续解锁 + - 长期记忆写回 + +原则是: + +**模型可以写得更生动,但不能临场决定世界状态。** + +## 1.5 有限分歧,强反馈,不追求无限分支 + +RPG 剧情最容易失控的地方是分支爆炸。 + +更稳定的做法是: + +1. 分歧数量有限 +2. 每个分歧都有明确价值立场 +3. 反馈足够强 +4. 后续能通过关系、可见信息、任务口径、奖励和 NPC 反应回响 + +玩家需要感到“我的选择被世界记住了”,不一定需要每个选择都开一条完全不同的剧情线。 + +--- + +## 2. 全流程总览 + +专业剧情策划构建 RPG 全剧情,建议按 12 个阶段推进: + +```text +阶段 0:确认产品目标与剧情边界 +-> 阶段 1:建立叙事支柱与玩家幻想 +-> 阶段 2:搭建世界故事图谱 +-> 阶段 3:设计阵营、角色与关系压力 +-> 阶段 4:拆主线结构与章节节奏 +-> 阶段 5:建立支线、角色线、阵营线矩阵 +-> 阶段 6:把每个场景收束成章节单元 +-> 阶段 7:设计任务合约、信号与推进条件 +-> 阶段 8:设计叙事载体与线索网络 +-> 阶段 9:设计选择后果与回响记忆 +-> 阶段 10:整理 AI 生成上下文与可见性边界 +-> 阶段 11:进入制作、验收、复盘与迭代 +``` + +这 12 个阶段不要求完全瀑布式执行,但不建议跳过前 4 个阶段直接写大量场景文本。 + +--- + +## 3. 阶段 0:确认产品目标与剧情边界 + +## 3.1 目标 + +在写剧情前,先确定项目到底需要哪种 RPG 体验。 + +剧情策划要先和产品、系统、战斗、关卡、美术、技术对齐: + +1. 游戏是偏经典单机 RPG、Roguelike RPG、开放叙事、剧情冒险,还是轻量跑团? +2. 玩家体验核心是情感羁绊、探索解谜、队友反应、战斗成长、阵营选择,还是世界观沉浸? +3. 剧情内容是一次性长线体验,还是可重复生成和长期扩展? +4. AI 参与生成到什么程度? +5. 哪些剧情状态必须由后端持久化? + +## 3.2 关键产出 + +| 产出 | 用途 | +| --- | --- | +| 剧情目标一句话 | 让所有人知道本作剧情体验追求什么 | +| 参考体验拆解 | 明确借鉴的是方法,不是复制桥段 | +| 内容范围表 | 约束主线、支线、角色线、随机事件规模 | +| 禁止事项 | 明确不做什么,避免范围膨胀 | +| 验收口径 | 后续判断剧情是否达标 | + +## 3.3 推荐模板 + +```text +项目剧情目标: + +玩家幻想: + +核心情绪: + +主要参考: + +本作不做: + +首个可验证剧情闭环: + +剧情成功的判断标准: +``` + +--- + +## 4. 阶段 1:建立叙事支柱与玩家幻想 + +## 4.1 目标 + +叙事支柱是整个剧情系统的方向盘。 + +建议控制在 3 到 5 条,例如: + +1. 角色羁绊 +2. 世界旧史 +3. 选择代价 +4. 路线试炼 +5. 物件残痕 + +每条支柱都要能落到玩法和内容,而不是只停留在口号。 + +## 4.2 工作方法 + +先写清楚这 4 个问题: + +1. 玩家为什么会进入这个世界? +2. 玩家为什么愿意继续探索? +3. 玩家会因为什么角色或事件产生情感投入? +4. 玩家每 10 分钟能感到什么剧情反馈? + +## 4.3 关键产出 + +| 产出 | 内容 | +| --- | --- | +| 叙事支柱表 | 每条支柱的体验目标、内容落点和系统落点 | +| 玩家幻想说明 | 玩家扮演谁、追求什么、害怕失去什么 | +| 情绪曲线 | 游戏前中后期的情绪节奏 | +| 主题母题 | 世界反复出现的意象、代价、冲突形式 | + +## 4.4 验收标准 + +做到以下几点才算完成: + +1. 每条叙事支柱都能对应至少一种系统承载方式。 +2. 主线、角色线、物件线都能共享同一组主题母题。 +3. 团队能用同一句话解释本作剧情体验,而不是各说各的。 + +--- + +## 5. 阶段 2:搭建世界故事图谱 + +## 5.1 目标 + +世界故事图谱用于回答: + +1. 这个世界正在发生什么大问题? +2. 哪些问题是玩家一开始知道的? +3. 哪些问题是暗线? +4. 哪些旧事留下了残痕? +5. 哪些角色、地点、物件共同指向同一条线? + +这一步是后续主线、支线、角色、场景、物件全部能互相印证的基础。 + +## 5.2 图谱最小结构 + +推荐至少包含 4 类内容: + +| 类型 | 说明 | +| --- | --- | +| 明线线程 | 玩家早期就能感知的冲突和目标 | +| 暗线线程 | 真实原因、隐藏组织、旧案、背叛或世界真相 | +| 旧事伤痕 | 过去事件在当下留下的地点、人物、物件痕迹 | +| 主题母题 | 反复出现的意象、禁忌、制度、称谓、代价 | + +## 5.3 设计步骤 + +1. 先写世界现状,不写古代编年史。 +2. 再写现状背后的核心矛盾。 +3. 再写至少 3 条明线线程。 +4. 再写至少 2 条暗线线程。 +5. 再把每条线程挂到角色、地点、物件和阵营。 +6. 最后补哪些信息一开始可见,哪些必须延后揭示。 + +## 5.4 推荐模板 + +```text +世界名称: + +核心主题: + +世界现状: + +明线线程: +- id: +- 标题: +- 表面冲突: +- 玩家早期看到的证据: +- 涉及角色: +- 涉及地点: +- 涉及物件: + +暗线线程: +- id: +- 标题: +- 隐藏真相: +- 误导信息: +- 解锁条件: +- 最终影响: + +旧事伤痕: +- id: +- 过去事件: +- 当下残痕: +- 相关角色: +- 可被玩家发现的载体: +``` + +## 5.5 常见错误 + +1. 只写设定百科,没有当前冲突。 +2. 每个角色都有背景,但没有共同线程。 +3. 暗线太早暴露,导致后续剧情只剩执行流程。 +4. 地点和物件只是装饰,没有承担证据或回响。 + +--- + +## 6. 阶段 3:设计阵营、角色与关系压力 + +## 6.1 目标 + +RPG 角色不能只是设定卡,而要成为世界冲突中的活节点。 + +每个重点角色都要回答: + +1. 他在世界明线里是什么位置? +2. 他和暗线有什么关系? +3. 他现在承受什么压力? +4. 他对玩家的默认态度是什么? +5. 他不愿意说什么? +6. 玩家什么行为会改变他? + +## 6.2 角色分层 + +建议把角色分为 5 层: + +| 层级 | 职责 | +| --- | --- | +| 主角 / 玩家化身 | 承载玩家幻想、成长路径、核心选择 | +| 同伴 / 队友 | 承载情感关系、立场反应、个人线 | +| 关键 NPC | 承载主线线索、阵营压力、章节推进 | +| 阵营代表 | 承载价值冲突、资源控制、任务来源 | +| 场景 NPC | 承载局部生活感、误导、支线入口、收束口风 | + +## 6.3 角色叙事档案 + +每个重点角色建议整理成以下结构: + +```text +角色名: + +外显身份: + +公众面具: + +当前压力: + +表面目标: + +真实目标: + +隐藏关系: + +已付代价: + +禁区: + +默认对玩家态度: + +可触发反应关键词: + +关联明线线程: + +关联暗线线程: + +个人线阶段: + +可解锁背景章节: +``` + +## 6.4 低关系角色的写法 + +低关系角色不能只是冷淡。 + +更好的低关系状态应该具备: + +1. 有压力 +2. 有保留 +3. 有错位说辞 +4. 有观察和试探 +5. 有后续可解锁的暗线钩子 + +也就是说: + +**低关系降低的是披露深度,不是剧情密度。** + +## 6.5 阵营设计 + +每个阵营至少要有: + +1. 公开目标 +2. 隐藏目标 +3. 资源控制点 +4. 对玩家的利用价值 +5. 与其他阵营的矛盾 +6. 内部裂缝 +7. 代表角色 +8. 玩家可改变的状态 + +阵营线要避免只变成声望商店。阵营应该提供不同价值立场,让玩家在任务选择里感到代价。 + +--- + +## 7. 阶段 4:拆主线结构与章节节奏 + +## 7.1 目标 + +主线不是连续剧情文本,而是玩家穿过世界核心冲突的结构化路径。 + +主线要同时做到: + +1. 每一章有清晰目标。 +2. 每一章有新的理解变化。 +3. 每一章有可执行任务。 +4. 每一章有角色或世界状态变化。 +5. 每一章结尾能交给下一章。 + +## 7.2 推荐结构 + +主线可以按 5 层拆: + +```text +全局主题 +-> 幕 +-> 章节 +-> 场景章节 +-> 任务 step / 玩家行动 +``` + +## 7.3 幕结构建议 + +| 幕 | 作用 | +| --- | --- | +| 第一幕:进入世界 | 建立玩家身份、核心异常、第一批角色和明线目标 | +| 第二幕:扩展冲突 | 展开阵营、地点、支线和角色压力 | +| 第三幕:理解改判 | 让玩家发现前期判断不完整或被误导 | +| 第四幕:代价选择 | 让玩家做有关系与世界后果的关键选择 | +| 第五幕:收束回响 | 回收主线、角色线、旧物、场景残痕和选择后果 | + +不一定每个项目都需要完整五幕,但每个主线版本都要明确当前处在哪种叙事功能。 + +## 7.4 单章结构 + +每章都要具备: + +1. 章节承诺 +2. 章节压力 +3. 主要角色 +4. 主要地点 +5. 主要任务 +6. 关键信息 +7. 转折点 +8. 高潮行动 +9. 收束结果 +10. 下一章 handoff + +## 7.5 主线章节模板 + +```text +章节 id: + +章节标题: + +所属幕: + +章节承诺: + +玩家进入条件: + +开章事件: + +主要目标: + +关键 NPC: + +关键地点: + +关键载体: + +中段压力: + +转折信息: + +高潮行动: + +玩家选择: + +结算状态: + +角色关系变化: + +世界状态变化: + +下一章 handoff: +``` + +--- + +## 8. 阶段 5:建立支线、角色线、阵营线矩阵 + +## 8.1 目标 + +支线、角色线和阵营线不是主线之外的填充内容,而是主线主题在不同尺度上的展开。 + +它们要分别承担: + +1. 支线:展示世界冲突对普通人、地点、职业、怪物、制度的影响。 +2. 角色线:展示某个角色如何被世界冲突改变。 +3. 阵营线:展示不同价值立场如何争夺资源和解释权。 + +## 8.2 支线设计原则 + +每条支线都要至少满足 3 个条件: + +1. 与一条世界线程有关。 +2. 有局部人物或地点变化。 +3. 结尾能留下一个回响。 + +不推荐做纯跑腿支线。如果必须做轻量任务,也要让它承担信息、关系、物件或地点状态中的至少一个作用。 + +## 8.3 角色线设计原则 + +角色线不等于给角色写长背景。 + +角色线应该按阶段展开: + +| 阶段 | 作用 | +| --- | --- | +| 首遇 | 建立外显身份、压力、错位 | +| 试探 | 玩家开始触碰禁区,但信息仍不完整 | +| 信任 | 角色透露真实目标或旧伤的一部分 | +| 冲突 | 玩家选择与角色立场发生碰撞 | +| 高光 | 角色做出关键行动或牺牲 | +| 余波 | 角色口风、关系、能力或结局发生变化 | + +## 8.4 阵营线设计原则 + +阵营线要重点设计: + +1. 玩家为什么会被阵营需要? +2. 阵营给玩家什么资源? +3. 阵营要求玩家付出什么代价? +4. 阵营内部有什么分歧? +5. 玩家是否能改变阵营走向? +6. 退出、背叛、结盟会带来什么后果? + +## 8.5 剧情矩阵模板 + +| 类型 | id | 标题 | 关联线程 | 主要角色 | 主要地点 | 解锁条件 | 关键选择 | 结算回响 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 主线 | | | | | | | | | +| 支线 | | | | | | | | | +| 角色线 | | | | | | | | | +| 阵营线 | | | | | | | | | + +--- + +## 9. 阶段 6:把每个场景收束成章节单元 + +## 9.1 目标 + +结合当前项目方向,推荐默认把每个可到达场景视为一个场景章节。 + +这意味着玩家进入一个新场景时,不只是换地图,而是进入一段局部剧情闭环。 + +每个场景章节都必须回答: + +1. 玩家刚进入时,这里正在发生什么? +2. 这个场景给玩家什么压力? +3. 玩家会在这里遇到什么改判? +4. 玩家离开前,这里有什么局部收束? +5. 本章留下什么可回响的人、物、线索或关系变化? + +## 9.2 五阶段结构 + +当前项目已有章节阶段可直接承接场景章节: + +| 阶段 | 体验语义 | 策划职责 | +| --- | --- | --- | +| `opening` | 起:开章立题 | 抛出异常、角色 lead、首个目标 | +| `expansion` | 承:压力展开 | 推出阻碍、调查、战斗、关系张力 | +| `turning_point` | 转:理解改判 | 给新事实、反证、误导破裂或立场变化 | +| `climax` | 合:正面收束 | 让玩家完成核心行动或关键选择 | +| `aftermath` | 余波:结果沉淀 | 写回 chronicle、关系、奖励和下一跳 | + +## 9.3 场景章节必须包含的九件套 + +每个场景章节建议至少有: + +1. 章节承诺 +2. 情感锚点 NPC +3. 现场压力 +4. 路线或空间推进 +5. 至少一个叙事载体 +6. 至少一个玩家选择或处理方式差异 +7. 转折信息 +8. 局部收束 +9. 余波回响 + +不要求每一项都做很重,但不能让场景只剩“看描述、接任务、交任务”。 + +## 9.4 场景章节卡模板 + +```text +场景 id: + +场景名称: + +章节标题: + +章节类型: +情感章 / 抉择章 / 试炼章 / 调查章 / 阵营章 / 过渡章 + +章节承诺: + +opening: +- 开章 NPC / 残痕: +- 玩家第一目标: + +expansion: +- 主要压力: +- 任务 step: +- 场景阻碍: + +turning_point: +- 新事实: +- 误导或反证: +- 关系变化: + +climax: +- 核心行动: +- 玩家选择: +- 任务结算: + +aftermath: +- chronicle 写回: +- 关系回响: +- 物件 / 奖励回响: +- 下一场景 handoff: +``` + +--- + +## 10. 阶段 7:设计任务合约、信号与推进条件 + +## 10.1 目标 + +任务是剧情在玩家前台的动作面。 + +专业剧情策划不能只写“玩家去调查真相”,而要把它拆成: + +1. 任务意图 +2. 任务合约 +3. 推进信号 +4. 完成条件 +5. 失败或替代处理 +6. 奖励与回响 + +## 10.2 任务设计结构 + +| 层级 | 说明 | +| --- | --- | +| 意图 | 这条任务想推动什么剧情问题 | +| 合约 | 玩家需要完成哪些可追踪步骤 | +| 信号 | 哪些行动会推进任务状态 | +| 裁决 | 玩家不同做法如何结算 | +| 回响 | 任务结果写回哪里 | + +## 10.3 常见信号类型 + +1. 到达场景 +2. 与 NPC 对话 +3. 击败敌对实体 +4. 获得物件 +5. 检查残痕 +6. 交付物品 +7. 关系变化 +8. 阵营选择 +9. 触发私聊 +10. 解锁可见信息 + +## 10.4 任务合约模板 + +```text +任务 id: + +任务标题: + +任务类型: +主线 / 支线 / 角色线 / 阵营线 / 场景章节任务 + +关联章节: + +关联世界线程: + +任务意图: + +发布者: + +参与角色: + +前置条件: + +任务 steps: +- step id: +- step 类型: +- 玩家目标: +- 推进信号: +- 完成条件: +- 可替代解法: + +关键选择: + +奖励: + +关系变化: + +可见信息变化: + +chronicle 写回: + +后续 handoff: +``` + +## 10.5 设计要求 + +每个任务至少要清楚: + +1. 为什么这个任务现在发生? +2. 玩家为什么需要参与? +3. 任务完成后谁的状态变了? +4. 是否解锁新信息? +5. 是否影响后续 NPC 口风或任务入口? + +--- + +## 11. 阶段 8:设计叙事载体与线索网络 + +## 11.1 目标 + +RPG 的故事不应该只靠 NPC 对话讲完。 + +叙事载体包括: + +1. 装备 +2. 道具 +3. 文书 +4. 信件 +5. 地标 +6. 尸体 +7. 遗物 +8. 建筑残痕 +9. 怪物命名 +10. 传闻 +11. 阵营标记 +12. 任务奖励 + +这些载体要像证人一样共同讲故事。 + +## 11.2 每个重点载体必须回答 + +1. 它是谁留下的? +2. 它见证过什么? +3. 它为什么现在出现在这里? +4. 它指向哪条明线或暗线? +5. 谁看到它会产生反应? +6. 它是否能改变玩家理解? + +## 11.3 叙事载体模板 + +```text +载体 id: + +载体类型: + +显示名称: + +表面功能: + +可见线索: + +见证痕: + +未完成问题: + +当前出现理由: + +关联线程: + +关联角色: + +关联地点: + +触发反应: + +后续回响: +``` + +## 11.4 线索网络设计 + +一个重要真相不要只放在单个 NPC 口中。 + +推荐至少用 3 类载体共同证明: + +1. 一个人说了什么 +2. 一个地点留下了什么 +3. 一个物件证明或反驳了什么 + +这样玩家会感到自己是在拼出真相,而不是被系统直接告知答案。 + +--- + +## 12. 阶段 9:设计选择后果与回响记忆 + +## 12.1 目标 + +选择后果不是只改变结局文本,而是让玩家行为在后续剧情中持续被看见。 + +可回响的位置包括: + +1. NPC 口风 +2. 队友认可或反对 +3. 任务入口变化 +4. 可见信息变化 +5. 阵营态度变化 +6. 商店、奖励、援助变化 +7. 地点状态变化 +8. 物件描述变化 +9. 私聊内容变化 +10. 结局变量 + +## 12.2 选择设计原则 + +好的选择不是“善恶二选一”,而是价值冲突。 + +每个关键选择建议至少具备: + +1. 两种合理动机 +2. 至少一个关系影响 +3. 至少一个世界状态影响 +4. 至少一个短期反馈 +5. 至少一个长期回响 + +## 12.3 后果表模板 + +| 选择 id | 玩家行为 | 价值立场 | 即时反馈 | 关系变化 | 信息变化 | 世界状态变化 | 后续回响 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| | | | | | | | | + +## 12.4 记忆分层 + +建议把剧情记忆拆成: + +| 记忆类型 | 说明 | +| --- | --- | +| 事件记忆 | 玩家做过什么事 | +| 关系记忆 | 哪些角色因此改变看法 | +| 线索记忆 | 玩家知道了哪些事实 | +| 误解记忆 | 玩家或角色当前误以为什么 | +| 真相记忆 | 已经被稳定揭示的事实 | +| 载体记忆 | 哪些物件、残痕、文书被发现 | + +## 12.5 验收标准 + +关键选择至少要能在后续 2 到 3 个地方被看见,才算真正产生回响。 + +--- + +## 13. 阶段 10:整理 AI 生成上下文与可见性边界 + +## 13.1 目标 + +在 AI 原生剧情项目里,剧情策划不能只交付成品文本,还要交付“AI 可以知道什么、不能知道什么、应该怎么表达”的上下文包。 + +## 13.2 可见性分层 + +至少要区分: + +| 类型 | 说明 | +| --- | --- | +| 世界事实 | 真实存在的设定 | +| 玩家已知事实 | 玩家已经通过游戏获得的信息 | +| 角色已知事实 | 当前 NPC 自己知道的信息 | +| 角色愿意说的信息 | 当前关系和场景下可以说的内容 | +| 模型可见信息 | 本轮 prompt 真正允许注入的信息 | +| 禁止注入信息 | 未解锁、未发现、会剧透的内容 | + +## 13.3 AI 上下文包模板 + +```text +本轮场景: + +当前章节阶段: + +当前任务 step: + +当前情境压力: + +玩家已知事实: + +当前 NPC 公开面: + +当前 NPC 可说信息: + +当前 NPC 不可说信息: + +允许推进的线索: + +禁止提前揭示: + +推荐情绪节奏: + +输出目标: +``` + +## 13.4 策划注意事项 + +1. 未解锁角色背景不能进入 prompt。 +2. 暗线真相不能因为模型“写得顺”而提前暴露。 +3. 角色知道某事,不代表当前愿意说。 +4. 玩家拿到线索,不代表系统要直接确认真相。 +5. AI 输出应服务当前章节阶段,不要每轮都试图讲完整世界观。 + +--- + +## 14. 阶段 11:制作、验收、复盘与迭代 + +## 14.1 推荐制作顺序 + +正式生产内容时,建议按这个顺序推进: + +1. 先做世界故事图谱和叙事支柱。 +2. 再做主线第一幕和核心角色档案。 +3. 再做 1 到 2 个完整场景章节作为 vertical slice。 +4. 再接任务合约、载体、选择后果和回响。 +5. 验证闭环成立后,再扩展更多场景和支线。 + +不要一开始就横向铺很多场景和 NPC。这样最容易堆出大量互不相干的剧情素材。 + +## 14.2 单剧情单元验收清单 + +每个剧情单元上线前至少检查: + +1. 是否挂到世界线程? +2. 是否有明确玩家目标? +3. 是否有阶段推进? +4. 是否有可见性边界? +5. 是否有角色压力? +6. 是否有至少一个叙事载体或证据? +7. 是否有结算状态? +8. 是否有后续回响? +9. 是否能被任务或章节系统追踪? +10. 是否能进入 chronicle 或等价长期记忆? + +## 14.3 全局剧情验收清单 + +整个 RPG 剧情框架至少要满足: + +1. 玩家能说出世界核心冲突是什么。 +2. 玩家能记住 3 到 5 个重点角色的压力、秘密或关系变化。 +3. 主线、支线、角色线、物件描述之间能互相印证。 +4. 关键选择不只是改一句文本,而会影响关系、信息或世界状态。 +5. 每个主要场景都有局部章节闭环。 +6. 暗线不会在早期 prompt 或 UI 中提前泄露。 +7. 重要剧情结果能在后续任务、对话、载体或结局中回响。 + +--- + +## 15. 剧情策划交付物清单 + +建议项目中把剧情策划交付物拆成下面这些文档或数据表: + +| 交付物 | 作用 | +| --- | --- | +| 剧情目标一页纸 | 对齐体验目标和范围 | +| 世界故事图谱 | 管理明线、暗线、旧事、母题 | +| 角色叙事档案 | 管理角色压力、秘密、关系、禁区 | +| 阵营设计表 | 管理组织目标、资源和价值冲突 | +| 主线章节表 | 管理幕、章节、转折、收束 | +| 支线矩阵 | 管理支线与世界线程的关系 | +| 角色线矩阵 | 管理同伴或重点 NPC 的阶段成长 | +| 场景章节卡 | 管理每个场景的 opening 到 aftermath | +| 任务合约表 | 管理任务 step、信号、结算和奖励 | +| 叙事载体表 | 管理物件、文书、残痕、证据和反应 | +| 选择后果表 | 管理玩家行为的短期反馈和长期回响 | +| AI 上下文边界表 | 管理 prompt 可见性和禁止泄露内容 | +| 剧情验收清单 | 管理上线前质量标准 | + +--- + +## 16. 与当前项目文档的衔接 + +这份工作流程可以作为总入口,与以下文档配合使用: + +1. `docs/prd/AI_NATIVE_CROSS_GENRE_STORY_ENGINE_PRD_2026-04-06.md` + - 用于理解跨题材剧情引擎的底层语法。 + +2. `docs/prd/AI_NATIVE_CLASSIC_RPG_EXPERIENCE_BENCHMARK_PRD_2026-04-06.md` + - 用于对齐经典 RPG 体验目标。 + +3. `docs/prd/AI_NATIVE_STORY_ENGINE_PHASE1_IMPLEMENTATION_PLAN_2026-04-06.md` + - 用于把世界图谱、角色档案、可见性和载体编译接到代码实现。 + +4. `docs/design/SCENE_CHAPTER_LOOP_AND_FIRST_ENTRY_CHAPTER_QUEST_DESIGN_2026-04-08.md` + - 用于把每个场景落成章节闭环和章节任务。 + +5. `docs/design/SCENE_CHAPTER_BENCHMARK_GAP_AND_AI_NATIVE_EXPERIENCE_SUPPLEMENT_2026-04-08.md` + - 用于补足单场景章节的人物、抉择、试炼、余波体验。 + +6. `docs/design/COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md` + - 用于处理同伴首遇、关系解锁、私聊与背景章节可见性。 + +7. `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md` + - 用于理解项目里叙事、状态、演出、工具链路的协作边界。 + +--- + +## 17. 最后结论 + +专业 RPG 剧情策划的核心工作,不是把剧情文本写得更长,而是把所有剧情拆成可运行、可追踪、可回响的叙事结构。 + +一套健康的 RPG 全剧情流程应该让每段内容都能回到同一组问题: + +1. 它属于哪条世界线程? +2. 它让玩家做了什么? +3. 它让玩家知道了什么? +4. 它改变了谁? +5. 它留下了什么? +6. 它之后在哪里回响? + +只要这六个问题长期成立,主线、支线、角色线、阵营线、场景章节、物件线索和 AI 生成文本就不会散掉,而会一起组成一个能持续生长的 RPG 剧情系统。 diff --git a/docs/planning/BEIJING_DIRECTION13_APPLICATION_MATERIALS_2026-04-14.md b/docs/planning/BEIJING_DIRECTION13_APPLICATION_MATERIALS_2026-04-14.md new file mode 100644 index 00000000..1f2630d3 --- /dev/null +++ b/docs/planning/BEIJING_DIRECTION13_APPLICATION_MATERIALS_2026-04-14.md @@ -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 端案例组织空间。 diff --git a/docs/planning/BEIJING_DIRECTION21_APPLICATION_MATERIALS_2026-04-14.md b/docs/planning/BEIJING_DIRECTION21_APPLICATION_MATERIALS_2026-04-14.md new file mode 100644 index 00000000..6c7090f4 --- /dev/null +++ b/docs/planning/BEIJING_DIRECTION21_APPLICATION_MATERIALS_2026-04-14.md @@ -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 个月里程碑先量化成数字。 diff --git a/docs/planning/BEIJING_DIRECTION24_APPLICATION_MATERIALS_2026-04-14.md b/docs/planning/BEIJING_DIRECTION24_APPLICATION_MATERIALS_2026-04-14.md new file mode 100644 index 00000000..9c566bfe --- /dev/null +++ b/docs/planning/BEIJING_DIRECTION24_APPLICATION_MATERIALS_2026-04-14.md @@ -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:当前如要用券 + +- 企业认证 +- 下单记录 +- 合同 +- 发票 +- 付款记录 +- 服务交付留痕 diff --git a/docs/planning/BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md b/docs/planning/BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md new file mode 100644 index 00000000..afc846ed --- /dev/null +++ b/docs/planning/BEIJING_POLICY_APPLICATION_OVERVIEW_13_21_24_2026-04-14.md @@ -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) diff --git a/docs/planning/README.md b/docs/planning/README.md index 0d0a5ab2..71e6b2ca 100644 --- a/docs/planning/README.md +++ b/docs/planning/README.md @@ -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 服务机构配券产品与企业用券两条路径的判断和材料备查。 ## 使用建议 diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md new file mode 100644 index 00000000..7f73a297 --- /dev/null +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md @@ -0,0 +1,1164 @@ +# AI 原生 Agent-First 自定义世界创作工具第一阶段技术落地方案 + +更新时间:`2026-04-13` + +## 0. 文档目的 + +这份文档用于把以下两份 PRD 收束成可直接开工的第一阶段实现方案: + +- [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_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md](./AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md) + +这一阶段不追求把 Agent 创作工具一次做完,而是先打牢最关键的地基: + +**先把创作页面入口、Agent 会话主链、工作区壳层、消息持久化、快照读取、操作轮询和基础恢复能力做完整。** + +一句话定义: + +**第一阶段先让“创作页面 + Agent 工作区”这条基础主链成立,而不是先急着让 Agent 生成完整世界。** + +--- + +## 1. 八阶段拆分 + +为了后续按阶段独立实现,先把整套 Agent 创作工具拆成 8 个阶段: + +1. 阶段 1:创作页面入口、Agent 会话主链与工作区骨架 +2. 阶段 2:最小锚点收集与澄清流程 +3. 阶段 3:世界底稿生成与草稿卡编译 +4. 阶段 4:草稿设定编辑与 AI 新增角色/场景生成 +5. 阶段 5:角色主图与动作资产工坊接入 +6. 阶段 6:场景背景图工坊接入 +7. 阶段 7:长尾内容扩展与自动补齐 +8. 阶段 8:发布、世界库接入与继续创作恢复 + +本文件只覆盖: + +**阶段 1:创作页面入口、Agent 会话主链与工作区骨架** + +--- + +## 2. 第一阶段目标 + +第一阶段只做 5 件必须一起成立的事: + +1. 建立新的作品摘要结构与 Agent session 共享 contract +2. 打通服务端的作品列表、session 创建、读取、发消息、轮询 operation 主链 +3. 新建前端创作页面与 Agent workspace 壳层,并能进入/恢复会话 +4. 让作品列表、消息、快照、操作状态都能持久化并恢复 +5. 保证现有世界选择流程能先进入创作页面,再从创作页面进入 Agent 工作区,不破坏旧世界选择与进入世界逻辑 + +一句话目标: + +**先把“可进入、可切换、可恢复”的创作页面与 Agent 工作区搭起来。** + +--- + +## 3. 第一阶段完成定义 + +第一阶段完成后,必须同时满足以下结果: + +1. 用户从世界选择页点击“创建自定义世界”后,不再进入旧的纯 textarea 创建流程,而是先进入新的创作页面。 +2. 创作页面可以展示“新建作品”“草稿作品”“已发布作品”三类入口。 +3. 服务端可以返回统一的作品摘要列表,并区分草稿与已发布作品。 +4. 用户从创作页面点击“新建作品”后,服务端可以创建 `custom-world-agent session`,并返回初始 snapshot。 +5. 前端可以读取 snapshot,展示聊天线程、顶部摘要、锁定条、草稿抽屉、操作状态区的空壳。 +6. 用户发送一条消息后,服务端会创建 operation,写入消息,并在 operation 完成后产出新的 assistant 消息。 +7. 页面刷新后,可以通过 sessionId 恢复同一个 Agent 工作区;返回创作页面后可以再次看到该草稿。 +8. 所有作品摘要、snapshot、messages、operations 都由服务端持久化,前端不再自己拼装“真实状态”。 +9. 第一阶段暂时不要求真正生成世界底稿,也不要求角色/场景资产工坊接入,但相关占位接口和结构必须预留。 + +--- + +## 4. 范围控制 + +## 4.1 第一阶段纳入范围 + +纳入范围的模块: + +- `packages/shared/src/contracts/customWorldAgent.ts` +- `packages/shared/src/contracts/runtime.ts` +- `src/types/customWorld.ts` +- `src/services/aiService.ts` +- `src/components/game-shell/PreGameSelectionFlow.tsx` +- `src/components/SelectionCustomizationModals.tsx` +- `server-node/src/routes/runtimeRoutes.ts` +- `server-node/src/services/customWorldWorkSummaryService.ts` +- `server-node/src/routes/customWorldAgent.ts` +- `server-node/src/services/customWorldAgentSessionStore.ts` +- `server-node/src/services/customWorldAgentOrchestrator.ts` + +新增前端模块: + +- `src/components/custom-world-home/CustomWorldCreationHub.tsx` +- `src/components/custom-world-home/CustomWorldWorkCard.tsx` +- `src/components/custom-world-home/CustomWorldWorkTabs.tsx` +- `src/components/custom-world-home/CustomWorldCreationStartCard.tsx` +- `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` +- `src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx` +- `src/components/custom-world-agent/CustomWorldAgentHeader.tsx` +- `src/components/custom-world-agent/CustomWorldAgentThread.tsx` +- `src/components/custom-world-agent/CustomWorldAgentComposer.tsx` +- `src/components/custom-world-agent/CustomWorldAgentLockBar.tsx` +- `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx` +- `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx` +- `src/components/custom-world-agent/CustomWorldAgentOperationBanner.tsx` + +新增服务端模块: + +- `server-node/src/services/customWorldWorkSummaryService.ts` +- `server-node/src/services/customWorldAgentSessionStore.ts` +- `server-node/src/services/customWorldAgentOrchestrator.ts` + +## 4.2 第一阶段明确不做 + +以下内容不放进第一阶段: + +1. 不做真实世界底稿生成 +2. 不做关键角色卡、关键地点卡、主线第一幕卡的实际编译 +3. 不做草稿设定编辑与 AI 新增角色/场景生成 +4. 不做角色主图与动作资产工坊接入 +5. 不做场景背景图工坊接入 +6. 不做发布到世界库 +7. 不做质量护栏结果展示 + +原因很简单: + +**第一阶段的任务是先把工作区和会话总线打通,不是先把所有内容能力塞进去。** + +--- + +## 5. 第一阶段最小闭环 + +建议把第一阶段的最小闭环定义为: + +```text +世界选择页点击创建 +-> 进入创作页面 +-> 拉取作品摘要列表 +-> 点击新建作品 +-> 创建 Agent session +-> 进入 Agent workspace +-> 拉取 session snapshot +-> 用户发送消息 +-> 服务端创建 operation +-> operation 完成后生成 assistant 回复 +-> 前端轮询 operation +-> 刷新 snapshot +-> 刷新后仍能恢复该会话 +``` + +这个闭环里,先只强接三条高价值链路: + +1. 创作入口生命周期 +2. 会话生命周期 +3. 前后端状态同步 + +原因: + +- 这是后面所有生成、锁定、发布、资产工坊的共同基础 +- 如果第一阶段连“创作入口”和“会话工作区”都没成立,后面所有 Agent 能力都会漂 + +--- + +## 6. 第一阶段产品行为定义 + +## 6.1 用户入口 + +入口仍然在: + +- `世界选择页` + +点击“创建自定义世界”后,新的默认行为变成: + +1. 进入 `custom-world-home` +2. 拉取作品摘要列表 +3. 展示“新建作品 / 草稿 / 已发布” +4. 用户点击“新建作品” +5. 打开 `CustomWorldAgentLauncherModal` +6. 用户可选输入一段 seed text +7. 点击“开始和 Agent 共创” +8. 前端请求创建新的 Agent session +9. 成功后进入 `custom-world-agent` 阶段 + +第一阶段要求: + +1. 旧的 `CustomWorldCreatorModal` 可以保留,但不再作为默认创建路径 +2. 创作页面必须成为新默认入口 + +## 6.2 创作页面的最小行为 + +创作页面第一阶段至少要做到: + +1. 展示顶部标题与返回按钮 +2. 展示新建作品区 +3. 展示作品 tab:`全部 / 草稿 / 已发布` +4. 展示统一作品卡片列表 + +### 作品列表最小规则 + +1. 草稿卡片主按钮:`继续创作` +2. 已发布卡片主按钮:`进入世界` +3. 列表按 `updatedAt desc` 排序 +4. 没有作品时展示空态 + +### 第一阶段允许的简化 + +1. 草稿摘要可以先只展示标题、状态、更新时间 +2. 已发布作品可以先只展示名称、摘要、封面和进入按钮 +3. 不强制做搜索 +4. 不强制做删除和复制草稿 + +## 6.3 初始会话行为 + +创建 session 后,服务端必须写入: + +1. 一条 `assistant` 欢迎消息 +2. 一个空的 `creatorIntent` +3. 一个空的 `anchorPack` +4. 一个空的 `lockState` +5. 一个空的 `draftCards` +6. 一个空的 `assetCoverage` +7. 当前阶段 `collecting_intent` + +## 6.4 用户发消息后的最小行为 + +第一阶段不做真正的意图分类和世界编译。 + +但用户发消息后,服务端必须至少做到: + +1. 把这条消息写进 `messages` +2. 创建一个 `operation` +3. operation 完成后,写入一条 assistant 消息 +4. assistant 消息必须: + - 复述当前收到的信息 + - 给出下一步引导 + - 不能只是“收到” + +也就是说: + +**第一阶段的 assistant 可以先是轻量规则回复,但不能是空壳假对话。** + +## 6.5 页面刷新后的恢复行为 + +如果用户在 `custom-world-agent` 阶段刷新页面,前端必须能: + +1. 保留当前 `sessionId` +2. 重新请求该 session snapshot +3. 恢复聊天记录、阶段、操作状态和辅助区 + +第一阶段允许的存储位置: + +1. URL query +2. `sessionStorage` +3. 内存状态配合服务端恢复 + +推荐: + +- 使用 URL query 挂 `sessionId` + +## 6.6 返回创作页面后的恢复行为 + +当用户从 Agent 工作区返回创作页面时,前端必须能: + +1. 再次请求作品摘要列表 +2. 在草稿列表里看到刚刚的 session +3. 继续点击“继续创作”恢复会话 + +--- + +## 7. 数据结构落地方案 + +## 7.1 新增 `packages/shared/src/contracts/customWorldAgent.ts` + +这一阶段必须把 Agent 相关 contract 从 `runtime.ts` 拆出去,避免继续膨胀旧 runtime contract。 + +第一阶段只要求以下最小结构: + +```ts +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; + sessionId?: string | null; + profileId?: string | null; + canResume: boolean; + canEnterWorld: boolean; +} + +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; +} + +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 interface CustomWorldAssetCoverageSummary { + roleAssets: []; + sceneAssets: []; + allRoleAssetsReady: boolean; + allSceneAssetsReady: boolean; +} + +export interface CustomWorldAgentSessionSnapshot { + sessionId: string; + stage: CustomWorldAgentStage; + focusCardId: string | null; + creatorIntent: Record | null; + anchorPack: Record | null; + lockState: Record | null; + draftProfile: Record | null; + messages: CustomWorldAgentMessage[]; + draftCards: CustomWorldDraftCardSummary[]; + pendingClarifications: { + id: string; + label: string; + question: string; + answer?: string; + }[]; + suggestedActions: CustomWorldSuggestedAction[]; + 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' + | '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: 'publish_world' }; + +export interface CustomWorldAgentActionResponse { + operation: CustomWorldAgentOperationRecord; +} + +export interface ListCustomWorldWorksResponse { + items: CustomWorldWorkSummary[]; +} +``` + +### 第一阶段约束 + +1. `roleAssets` 与 `sceneAssets` 先允许为空数组 +2. `qualityFindings` 先允许为空数组 +3. `draftCards` 先允许为空数组 +4. 但这些字段必须存在,不能后面再补 + +## 7.2 修改 `src/services/aiService.ts` + +第一阶段新增以下客户端函数: + +```ts +listCustomWorldWorks() +createCustomWorldAgentSession(payload) +getCustomWorldAgentSession(sessionId) +sendCustomWorldAgentMessage(sessionId, payload) +executeCustomWorldAgentAction(sessionId, payload) +getCustomWorldAgentOperation(sessionId, operationId) +getCustomWorldAgentCardDetail(sessionId, cardId) +``` + +### 明确要求 + +1. 这些函数放在 `aiService.ts`,不再散落到组件内部直接 `fetch` +2. 沿用当前 `requestJson / fetchWithApiAuth` 体系 +3. 错误消息必须走统一 `parseApiErrorMessage` + +## 7.3 修改 `src/types/customWorld.ts` + +第一阶段不新增复杂业务字段,只补和恢复流程相关的 UI 本地状态类型即可。 + +推荐新增: + +```ts +export type CustomWorldAgentUiState = { + activeSessionId?: string | null; + activeOperationId?: string | null; +}; +``` + +说明: + +- 真实业务状态仍然来自服务端 snapshot + +## 7.4 作品列表接口要求 + +第一阶段必须新增: + +`GET /api/runtime/custom-world/works` + +返回: + +```ts +ListCustomWorldWorksResponse +``` + +作用: + +1. 为创作页面提供统一作品列表 +2. 返回当前用户所有草稿与已发布作品摘要 + +--- + +## 8. 服务端实现方案 + +## 8.1 新增 `customWorldAgentSessionStore.ts` + +### 文件 + +`server-node/src/services/customWorldAgentSessionStore.ts` + +### 第一阶段职责 + +1. 创建 session +2. 读取 session +3. 写入 messages +4. 写入 operation +5. 更新 snapshot +6. 支持 checkpoint 占位 + +### 第一阶段数据结构 + +```ts +type CustomWorldAgentSessionRecord = { + sessionId: string; + userId: string; + stage: CustomWorldAgentStage; + focusCardId: string | null; + creatorIntent: Record | null; + anchorPack: Record | null; + lockState: Record | null; + draftProfile: Record | null; + messages: CustomWorldAgentMessage[]; + draftCards: CustomWorldDraftCardSummary[]; + pendingClarifications: Array<{ + id: string; + label: string; + question: string; + answer?: string; + }>; + suggestedActions: CustomWorldSuggestedAction[]; + qualityFindings: Array<{ + id: string; + severity: 'info' | 'warning' | 'blocker'; + code: string; + targetId?: string | null; + message: string; + }>; + assetCoverage: { + roleAssets: []; + sceneAssets: []; + allRoleAssetsReady: false; + allSceneAssetsReady: false; + }; + operations: CustomWorldAgentOperationRecord[]; + checkpoints: Array<{ + checkpointId: string; + createdAt: string; + label: string; + }>; + createdAt: string; + updatedAt: string; +}; +``` + +### 第一阶段硬要求 + +1. 不允许只存内存 +2. 需支持进程重启后恢复 +3. 如果当前仓库短期内没有现成数据库表,就先用 `runtimeRepository` 同级别的本地持久化方案 +4. 所有写操作都必须更新时间戳 + +## 8.2 新增 `customWorldWorkSummaryService.ts` + +### 文件 + +`server-node/src/services/customWorldWorkSummaryService.ts` + +### 第一阶段职责 + +只做统一作品摘要聚合: + +1. 读取当前用户的草稿 session +2. 读取当前用户的已发布 `CustomWorldProfile` +3. 编译成统一 `CustomWorldWorkSummary[]` + +### 第一阶段规则 + +1. 草稿来源于 `customWorldAgentSessionStore` +2. 已发布作品来源于 `runtimeRepository.listCustomWorldProfiles(userId)` +3. 默认按 `updatedAt desc` 排序 +4. 草稿状态固定为 `draft` +5. 已发布状态固定为 `published` + +### 第一阶段允许的简化 + +1. 草稿摘要允许只从 session 基础字段推导 +2. 作品封面允许优先为空 +3. 作品统计字段允许先用简单计数,不做复杂推断 + +## 8.3 新增 `customWorldAgentOrchestrator.ts` + +### 文件 + +`server-node/src/services/customWorldAgentOrchestrator.ts` + +### 第一阶段职责 + +只做最小编排: + +1. `createSession(userId, seedText?)` +2. `getSessionSnapshot(userId, sessionId)` +3. `submitMessage(userId, sessionId, payload)` +4. `executeAction(userId, sessionId, payload)` +5. `getOperation(userId, sessionId, operationId)` +6. `getCardDetail(userId, sessionId, cardId)` + +### 第一阶段消息处理规则 + +第一阶段不做复杂 LLM 编排,先走 deterministic assistant reply: + +#### 用户首条消息 + +assistant 回复应包含: + +1. 对 seedText / 用户消息的简要复述 +2. 当前仍缺哪些世界锚点 +3. 建议创作者下一步回答什么 + +#### 用户后续消息 + +assistant 回复应包含: + +1. 已收到的新增信息摘要 +2. 还未明确的锚点 +3. 推荐下一步 + +### 第一阶段 operation 规则 + +用户每发送一条消息,都要创建一条 `process_message` operation: + +1. 初始状态:`queued` +2. 进入处理:`running` +3. 回复写入成功后:`completed` +4. 失败时:`failed` + +### 第一阶段 progress 规则 + +`process_message` operation 的 progress 固定三段: + +1. `10` + - 已接收消息 +2. `60` + - 正在更新会话状态 +3. `100` + - 回复已完成 + +不允许第一阶段先搞复杂 streaming 进度。 + +## 8.4 路由层 + +### 文件 + +`server-node/src/routes/customWorldAgent.ts` + +以及: + +`server-node/src/routes/runtimeRoutes.ts` + +### 第一阶段接口 + +#### 1. 获取作品列表 + +`GET /api/runtime/custom-world/works` + +用途: + +1. 为创作页面提供统一作品列表 +2. 返回草稿与已发布作品摘要 + +#### 2. 创建 session + +`POST /api/runtime/custom-world/agent/sessions` + +#### 3. 获取 snapshot + +`GET /api/runtime/custom-world/agent/sessions/:sessionId` + +#### 4. 发消息 + +`POST /api/runtime/custom-world/agent/sessions/:sessionId/messages` + +#### 5. 执行动作 + +`POST /api/runtime/custom-world/agent/sessions/:sessionId/actions` + +#### 6. 查询 operation + +`GET /api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId` + +#### 7. 获取 card detail + +`GET /api/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId` + +### 第一阶段动作限制 + +第一阶段 `actions` 只允许接受: + +1. `draft_foundation` +2. `publish_world` + +但它们先返回: + +- `400 not implemented in phase1` + +原因: + +- 接口先占位,避免第二阶段再改前后端协议 + +### 路由校验要求 + +1. 所有 body 必须走 schema 校验 +2. sessionId 为空直接 `400` +3. 读取不存在的 session 返回 `404` +4. 读取不存在的 operation 返回 `404` +5. `GET /works` 必须要求登录用户,不允许返回全局作品列表 + +--- + +## 9. 前端实现方案 + +## 9.1 修改 `PreGameSelectionFlow.tsx` + +### 第一阶段目标 + +让它能先进入创作页面,再从创作页面进入 Agent 工作区。 + +### 必改项 + +1. `SelectionStage` 新增: + +```ts +| 'custom-world-home' +| 'custom-world-agent' +``` + +2. `openCustomWorldCreator()` 改成 `setSelectionStage('custom-world-home')` +3. 新增创作页面渲染分支 +4. 创建 session 成功后,进入 `custom-world-agent` +5. 新增 `activeCustomWorldAgentSessionId` 本地状态 +6. 新增 `activeCustomWorldAgentOperationId` 本地状态 + +### 第一阶段禁止行为 + +1. 不允许创建 session 后直接去生成世界 +2. 不允许创建 session 后直接写入世界库 + +## 9.2 新增 `CustomWorldCreationHub.tsx` + +### 职责 + +作为创作页面主组件,负责: + +1. 展示新建作品入口 +2. 展示草稿与已发布作品列表 +3. 把用户带到正确的下一步页面 + +### props + +```ts +{ + items: CustomWorldWorkSummary[]; + loading: boolean; + error: string | null; + onBack: () => void; + onRetry: () => void; + onCreateNew: () => void; + onResumeDraft: (sessionId: string) => void; + onEnterPublished: (profileId: string) => void; +} +``` + +### 第一阶段硬要求 + +1. 默认展示 `全部 / 草稿 / 已发布` 三个 tab +2. 新建作品区位于首屏顶部 +3. 草稿卡主按钮为 `继续创作` +4. 已发布卡主按钮为 `进入世界` + +## 9.3 新增 `CustomWorldAgentLauncherModal.tsx` + +### 职责 + +只做: + +1. 输入一段 seed text +2. 点击“开始共创” + +### 字段 + +只保留: + +1. `seedText` textarea +2. `开始共创` 按钮 +3. `取消` 按钮 + +不加任何模式、卡片、生成参数。 + +## 9.4 新增 `CustomWorldAgentWorkspace.tsx` + +### 职责 + +作为 Agent 创作工具第一阶段的主壳层。 + +### props + +```ts +{ + session: CustomWorldAgentSessionSnapshot | null; + activeOperation: CustomWorldAgentOperationRecord | null; + onBack: () => void; + onRefresh: () => void; + onSubmitMessage: (payload: SendCustomWorldAgentMessageRequest) => void; +} +``` + +### 布局 + +必须包含: + +1. 顶部 header +2. 左侧主线程 +3. 右侧辅助区 +4. 底部 composer + +### 第一阶段右侧辅助区内容 + +1. 锁定条 + - 空态文案:`暂未锁定内容` + +2. 草稿抽屉 + - 空态文案:`世界草稿将在后续阶段出现` + +3. 快捷动作 + - 第一阶段只展示: + - `总结当前设定` + - `刷新会话` + +## 9.5 新增 `CustomWorldAgentHeader.tsx` + +### 第一阶段展示字段 + +1. 标题:`自定义世界共创` +2. 当前阶段中文 +3. sessionId 的短显示 +4. 返回按钮 + +### 当前阶段映射 + +第一阶段只需要稳定显示: + +- `collecting_intent` + - `收集世界锚点` + +## 9.6 新增 `CustomWorldAgentThread.tsx` + +### 渲染规则 + +1. `user` 气泡靠右 +2. `assistant` 气泡靠左 +3. `system` 使用更弱样式 + +### 第一阶段硬要求 + +1. 必须支持长消息自动换行 +2. 必须支持消息时间显示 +3. 刷新后消息顺序不变 + +## 9.7 新增 `CustomWorldAgentComposer.tsx` + +### 功能 + +1. 文本输入 +2. 发送按钮 +3. active operation 期间禁用 + +### 第一阶段限制 + +1. 不做多行快捷 slash command +2. 不做附件 +3. 不做选卡发送 + +但函数签名仍保留: + +```ts +onSubmit({ + clientMessageId, + text, + focusCardId: null, + selectedCardIds: [], +}) +``` + +## 9.8 新增 `CustomWorldAgentOperationBanner.tsx` + +### 第一阶段展示 + +1. `phaseLabel` +2. `phaseDetail` +3. 进度条 +4. 错误文案 + +### 展示规则 + +1. operation 为空时不显示 +2. operation `running` 时显示蓝色状态 +3. operation `failed` 时显示红色状态 +4. operation `completed` 时自动淡出 + +--- + +## 10. 第一阶段接口与交互时序 + +## 10.1 进入创作页面时序 + +```text +世界选择页点击创建 +-> 前端进入 custom-world-home +-> 前端 GET /custom-world/works +-> 服务端返回作品摘要列表 +-> 前端渲染创作页面 +``` + +## 10.2 创建会话时序 + +```text +用户在创作页面点击新建作品 +-> 打开 launcher +-> 用户点击开始共创 +-> 前端 POST /agent/sessions +-> 服务端创建 session +-> 服务端写入 assistant welcome message +-> 返回 session snapshot +-> 前端进入 workspace +``` + +## 10.3 发送消息时序 + +```text +用户发送消息 +-> 前端 POST /messages +-> 服务端创建 operation(queued) +-> 服务端写入 user message +-> 服务端更新 operation(running) +-> 服务端生成 assistant reply +-> 服务端写入 assistant message +-> 服务端更新 operation(completed) +-> 前端轮询 operation +-> 前端刷新 snapshot +``` + +## 10.4 刷新恢复时序 + +```text +页面刷新 +-> 前端从 URL / sessionStorage 读取 sessionId +-> 前端 GET /sessions/:sessionId +-> 服务端返回 snapshot +-> 前端恢复 workspace +``` + +## 10.5 返回创作页面时序 + +```text +用户从 workspace 返回 +-> 前端进入 custom-world-home +-> 前端 GET /custom-world/works +-> 服务端返回作品摘要列表 +-> 前端看到刚刚的草稿卡 +``` + +--- + +## 11. 第一阶段占位行为 + +第一阶段虽然不实现后续能力,但必须预留占位。 + +## 11.1 draft_foundation 占位 + +当前返回: + +- `400` +- message: `draft_foundation is not available in phase1` + +## 11.2 publish_world 占位 + +当前返回: + +- `400` +- message: `publish_world is not available in phase1` + +## 11.3 card detail 占位 + +若 `draftCards` 为空: + +- `GET /cards/:cardId` 返回 `404` + +这是允许的。 + +--- + +## 12. 落地文件清单 + +## 12.1 shared + +必须新增: + +1. `packages/shared/src/contracts/customWorldAgent.ts` + +必须修改: + +1. `packages/shared/src/contracts/runtime.ts` +2. `src/services/aiService.ts` + +## 12.2 frontend + +必须新增: + +1. `src/components/custom-world-home/CustomWorldCreationHub.tsx` +2. `src/components/custom-world-home/CustomWorldWorkCard.tsx` +3. `src/components/custom-world-home/CustomWorldWorkTabs.tsx` +4. `src/components/custom-world-home/CustomWorldCreationStartCard.tsx` +5. `src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx` +6. `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` +7. `src/components/custom-world-agent/CustomWorldAgentHeader.tsx` +8. `src/components/custom-world-agent/CustomWorldAgentThread.tsx` +9. `src/components/custom-world-agent/CustomWorldAgentComposer.tsx` +10. `src/components/custom-world-agent/CustomWorldAgentLockBar.tsx` +11. `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx` +12. `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx` +13. `src/components/custom-world-agent/CustomWorldAgentOperationBanner.tsx` + +必须修改: + +1. `src/components/game-shell/PreGameSelectionFlow.tsx` +2. `src/components/SelectionCustomizationModals.tsx` + +## 12.3 backend + +必须新增: + +1. `server-node/src/routes/customWorldAgent.ts` +2. `server-node/src/services/customWorldAgentSessionStore.ts` +3. `server-node/src/services/customWorldAgentOrchestrator.ts` +4. `server-node/src/services/customWorldWorkSummaryService.ts` + +必须修改: + +1. `server-node/src/routes/runtimeRoutes.ts` + - 挂载作品摘要接口 + +--- + +## 13. 测试要求 + +## 13.1 服务端测试 + +至少要补: + +1. 作品列表接口能同时返回草稿和已发布作品摘要 +2. 创建 session 后 snapshot 字段完整 +3. 新 session 能出现在作品列表草稿区 +4. 发送消息后会新增 user + assistant 两条消息 +5. operation 状态能从 `queued -> running -> completed` +6. 错误情况下 operation 会变成 `failed` +7. 读取不存在的 session 返回 `404` + +## 13.2 前端测试 + +至少要补: + +1. `PreGameSelectionFlow` 能进入 `custom-world-home` +2. `CustomWorldCreationHub` 能展示新建入口、tab 和作品卡空态 +3. 从创作页面新建作品后能进入 `custom-world-agent` +4. workspace 在空 draftCards 下正常显示空态 +5. 发送消息时 composer 会 disabled +6. operation 完成后会刷新线程 + +## 13.3 手工回归 + +至少走这 4 条: + +1. 世界选择页 -> 创作页面 +2. 创作页面 -> 新建作品 -> 进入工作区 +3. 连续发送 3 轮消息 -> 线程正常增长 +4. 刷新页面 -> 会话恢复 +5. 返回创作页面 -> 草稿卡出现 -> 再次进入会话 + +--- + +## 14. 第一阶段验收标准 + +做到以下几点,才算第一阶段真正完成: + +1. 从世界选择页可以稳定进入新的创作页面。 +2. 创作页面可以展示新建作品入口、草稿作品和已发布作品。 +3. 从创作页面可以创建新的 Agent 会话并进入工作区。 +4. Agent 会话由服务端持久化,刷新后可以恢复。 +5. 用户每发送一条消息,都会有真实 operation 和 assistant 回复。 +6. 前端只消费作品摘要、session snapshot 和 operation,不自己拼真实状态。 +7. 第一阶段不需要世界底稿生成,也不会误把旧生成流程塞回新工作区。 + +--- + +## 15. 一句话结论 + +第一阶段最重要的不是“让 Agent 多聪明”,而是: + +**先让创作页面和 Agent 工作区成为一条真的、稳定的、可恢复的创作入口主链。** diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md new file mode 100644 index 00000000..b1ae0d71 --- /dev/null +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md @@ -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 写得更长”,而是: + +**先让它学会把用户说过的话,稳定地变成创作锚点。** diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md new file mode 100644 index 00000000..87f40b23 --- /dev/null +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md @@ -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. 一句话结论 + +第三阶段最重要的不是“继续问更多问题”,而是: + +**先把已经收集到的锚点,变成一版真能看的世界底稿。** diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md new file mode 100644 index 00000000..cca30769 --- /dev/null +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md @@ -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; + 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. 一句话结论 + +第四阶段最重要的不是“继续控制已有结果不动”,而是: + +**让这版世界草稿开始具备真正的可编辑性和可扩展性。** diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md new file mode 100644 index 00000000..50d32193 --- /dev/null +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md @@ -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; + 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 | 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. 一句话结论 + +第五阶段最重要的不是“让所有角色都立刻变成完整资产”,而是: + +**先把草稿世界里的角色,真正接到一条可预览、可发布、可写回的资产工坊链路上。** diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md new file mode 100644 index 00000000..cfa80262 --- /dev/null +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md @@ -0,0 +1,2021 @@ +# AI 原生 Agent-First 自定义世界创作工具 PRD + +更新时间:`2026-04-12` + +## 0. 文档目的 + +这份 PRD 用于把以下几份分析文档收束成一份可直接指导编码落地的新创作工具产品需求文档: + +- `docs/design/CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md` +- `docs/design/CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md` +- `docs/design/CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md` +- `docs/design/RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md` +- `docs/audits/CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md` + +本次目标不是在现有自定义世界工具上继续叠加几个输入框或几个按钮,而是把它升级成: + +**以前台 Agent 对话为主交互、后台结构化世界模型为真实状态、支持锁定、局部重生成、摘要、质量护栏和发布导出的新一代自定义世界创作工具。** + +这份 PRD 必须满足一个硬要求: + +**每个模块都要设计到能直接落进现有仓库结构,不能停留在概念级描述,不能把需求解释空间留给后续实现。** + +--- + +## 1. 产品定义 + +## 1.1 产品名 + +建议命名: + +`Agent-First 自定义世界创作工具` + +## 1.2 一句话定义 + +让创作者通过与一个懂 RPG 剧情策划方法的 Agent 对话,逐步完成世界锚点收集、关键对象塑造、剧情骨架搭建和长尾内容展开;同时由 Express 后端持续维护结构化世界状态、锁定边界、局部重生成和质量检查。 + +## 1.3 目标用户 + +目标用户分三类: + +1. 轻创作者 + - 有世界灵感,但不擅长结构化填表 + +2. 中度创作者 + - 愿意精修角色、地点、主线第一幕,但不想维护大量底层字段 + +3. 重度创作者 + - 需要局部重生成、锁定、版本化和导出世界圣经 + +## 1.4 产品成功标准 + +这个新工具上线后,必须同时满足: + +1. 新用户可以只靠对话在 `5~15` 分钟内产出一个可继续扩展的世界底稿。 +2. Agent 收集的高杠杆信息,能稳定沉淀到结构化状态,而不是散落在聊天记录里。 +3. 关键角色、关键地点、主线第一幕、场景章节等内容可以被局部重做,不误伤已锁定内容。 +4. 所有真实逻辑、数据合并、锁定裁决、质量检查都在 Express 后端完成,前端只负责展示和输入。 +5. 结果产物最终仍可稳定编译成现有 `CustomWorldProfile`,进入当前游戏流程。 +6. 发布前,所有角色都具备主形象与基础动作资产,所有场景与营地都具备正式背景图资产。 + +## 1.5 不做什么 + +这次 PRD 明确不做: + +1. 不把整套系统做成纯聊天黑箱。 +2. 不让前端继续承担锁定合并、重生成裁决、结构编译等核心逻辑。 +3. 不要求创作者直接编辑 `ThemePack / WorldStoryGraph / VisibilitySlice / ThreadContract` 等运行时结构。 +4. 不把长项目世界管理完全交给一条无限增长的聊天记录。 +5. 不再保留“生成完直接回世界列表并自动保存”的旧流程。 +6. 不允许角色主图、角色动作、场景背景图继续停留在临时候选状态后直接发布世界。 + +--- + +## 2. 产品总原则 + +## 2.1 Agent-first,不是 Agent-only + +前台主交互改成 Agent 对话,但后台必须保留结构化世界模型、锁定状态、检查结果和导出数据。 + +## 2.2 服务端是真实状态源 + +所有内容状态、锁定状态、对话解释、重生成范围、质量检查、导出与发布逻辑都必须在 Express 后端完成。 + +前端不允许再做: + +1. 锁定内容合并 +2. 名称匹配式保留内容 +3. 局部重生成范围裁决 +4. 结构化草稿编译 + +## 2.3 创作过程分阶段,不无限闲聊 + +整个工具必须有明确阶段,而不是无穷对话: + +1. 收集锚点 +2. 生成底稿 +3. 锁定关键内容 +4. 精修对象 +5. 展开长尾 +6. 发布 / 导出 + +## 2.4 重要改动必须可回看、可锁定、可回退 + +每轮重要操作后,系统必须能回答: + +1. 改了什么 +2. 哪些内容被锁定了 +3. 哪些内容仍然是建议稿 +4. 当前还有什么风险 +5. 能否回退到上一个 checkpoint + +## 2.5 视觉资产必须工坊化,不走纯聊天黑箱 + +角色主形象、角色动作、场景背景图都属于重资产对象。 + +这类对象必须遵守: + +1. Agent 负责决定什么时候生成、围绕什么设定生成、生成哪些对象。 +2. 真正的生成、预览、筛选和发布必须进入专门资产工坊。 +3. 生成结果必须资产化,再写回结构化世界状态。 +4. 发布世界前必须完成视觉覆盖检查。 +5. 视觉资产必须分阶段抽卡与分层投入;实际成本通过积分结算与显式提示管理。 + +也就是说: + +**视觉资产是 Agent 主链里的必经子流程,但不适合只靠聊天气泡完成。** + +## 2.6 UI 默认清爽 + +默认 UI 不展示底层系统字段,不展示规则说明文案,不把所有高级编辑项一次铺开。 + +--- + +## 3. 与现有代码基线的关系 + +## 3.1 必须复用的现有能力 + +本次 PRD 必须复用以下现有基础: + +1. `src/services/customWorldCreatorIntent.ts` + - 已有创作者意图、锚点包、锁定状态的基础结构 + +2. `src/types/customWorld.ts` + - 已有 `creatorIntent / anchorPack / lockState / generationMode / generationStatus` + +3. `src/services/aiService.ts` + - 已有自定义世界 session 与生成 API 客户端 + +4. `server-node/src/services/customWorldSessionStore.ts` + - 已有澄清问题与 session 的基础概念 + +5. `server-node/src/services/customWorldGenerationService.ts` + - 已有分阶段生成骨架 + +6. `src/components/game-shell/PreGameSelectionFlow.tsx` + - 已有世界创建流程入口 + +7. `src/components/CustomWorldResultView.tsx` + - 已有结果页壳层 + +8. `src/components/CustomWorldRoleAssetStudioModal.tsx` + - 已有角色主图与核心动作资产工坊原型 + +9. `src/services/ai.ts` + - 已有 `generateCustomWorldSceneImage(...)` 场景图生成入口 + +10. `server-node/src/modules/assets/characterAssetRoutes.ts` + - 已有角色主图发布、角色动作发布、动作模板等资产路由 + +11. `server-node/src/routes/runtimeRoutes.ts` + - 已有 `/custom-world/scene-image` 场景背景图生成路由 + +## 3.2 必须替换或重构的现有行为 + +下面这些行为必须被替换: + +1. `PreGameSelectionFlow.tsx` 中生成完成后自动保存并返回世界列表 +2. `PreGameSelectionFlow.tsx` 中前端 `mergeLockedProfileContent(...)` 名称匹配式内容保留 +3. `aiService.ts` 中对 session 澄清问题的静默自动兜底回答 +4. `SelectionCustomizationModals.tsx` 中只有大 textarea 的创建入口 +5. `CustomWorldResultView.tsx` 中“整世界覆盖式重新生成”确认逻辑 +6. 角色与场景图片生成能力分散在编辑器内部、未进入 Agent 会话主链的现状 + +--- + +## 4. 最终产品形态 + +## 4.1 前台形态 + +新工具的前台必须是: + +1. 一个主聊天线程 +2. 一个顶部世界摘要区 +3. 一个锁定内容条 +4. 一个草稿对象抽屉 +5. 一个快捷动作区 +6. 一个操作进度 / 错误提示区 + +其中: + +- 主聊天线程是主交互 +- 其余都是帮助用户保持掌控感的辅助结构 + +## 4.2 后台形态 + +后台必须持续维护: + +1. 创作者意图 +2. 锁定状态 +3. 世界底稿快照 +4. 可编辑草稿对象列表 +5. 当前阶段 +6. 待澄清项 +7. 发布校验结果 +8. checkpoint 历史 +9. 已发布世界版本 + +## 4.3 结果产物 + +最终必须输出两类产物: + +1. 创作工作产物 + - 世界圣经摘要 + - 关键角色卡 + - 关键地点卡 + - 势力卡 + - 主线第一幕与章节草稿 + - 支线种子 + - 场景章节草稿 + - 角色视觉资产清单 + - 场景背景资产清单 + +2. 游戏可运行产物 + - `CustomWorldProfile` + - 角色主形象资产 + - 角色基础动作资产 + - 场景背景图资产 + +--- + +## 5. 用户主流程 + +## 5.1 流程总览 + +```text +世界选择页 +-> 打开 Agent 创作入口 +-> Agent 收集最小锚点 +-> Agent 输出首轮世界底稿 +-> 创作者锁定/修改关键内容 +-> Agent 局部生成关键角色/地点/主线第一幕 +-> 进入角色与场景资产工坊,生成主形象 / 动作 / 背景图 +-> Agent 扩展长尾内容 +-> 创作者发布世界 +-> 保存到世界库并进入世界 +``` + +## 5.2 具体流程 + +### 步骤 1:创建 Agent 会话 + +触发: + +- 世界选择页点击“创建自定义世界” + +服务端行为: + +1. 创建新的 Agent 会话 +2. 初始化空的 `CustomWorldCreatorIntent` +3. 写入一条欢迎 assistant message +4. 设置阶段为 `collecting_intent` + +前端行为: + +1. 进入 `custom-world-agent` 阶段 +2. 渲染聊天工作区 + +### 步骤 2:收集最小锚点 + +目标: + +- 确认最小必填 6 张卡对应的信息 + +Agent 必须确认: + +1. 世界一句话与核心幻想 +2. 玩家身份与开局困境 +3. 主题气质与禁忌边界 +4. 核心冲突 +5. 关键关系钩子 +6. 标志性要素与硬规则 + +约束: + +1. 一次最多追问 3 个问题 +2. 未拿到关键锚点前,不允许直接进入长尾展开 + +### 步骤 3:生成首轮世界底稿 + +触发: + +- 最小锚点齐备,且用户发送“可以开始生成 / 先给我底稿 / 你先整理一版” + +服务端行为: + +1. 编译 `creatorIntent` +2. 生成 `anchorPack` +3. 生成世界基础摘要 +4. 生成第一批关键角色卡、关键地点卡、势力卡、线程卡和主线第一幕草稿 +5. 写入 checkpoint + +前端行为: + +1. 显示操作进度 +2. 操作完成后,将 assistant summary 插入聊天流 +3. 刷新顶部摘要、草稿抽屉和建议动作 + +### 步骤 4:锁定与精修关键对象 + +触发: + +- 用户通过自然语言或快捷动作要求: + - 锁定角色 + - 修改地点 + - 重写主线第一幕 + - 增强某个冲突 + +服务端行为: + +1. 解析目标对象 +2. 识别操作类型 +3. 只修改目标范围 +4. 更新结构化草稿 +5. 重新运行局部质量检查 +6. 写入 changeLog 和 checkpoint + +### 步骤 5:生成角色主形象与动作资产 + +触发: + +- 用户发送“给这些角色生成形象和动作” +- 或点击快捷动作“生成角色资产” +- 或在某个角色卡上点击“打开资产工坊” + +范围: + +1. 关键角色优先 +2. 所有可扮演角色必须生成 +3. 所有场景角色最终也必须生成 + +分阶段规则: + +1. 不允许直接对角色一次性生成完整高成本资产包。 +2. 必须先走低成本主图候选抽卡,再做主图确认。 +3. 主图确认前,不允许生成完整动作集。 +4. 动作必须先做试片,再决定是否生成完整核心动作集。 + +服务端行为: + +1. 解析目标角色集合 +2. 根据角色卡编译视觉提示词与动作提示词 +3. 按角色优先级确定默认抽卡策略和模型策略 +4. 先生成低成本主图候选 +5. 主图确认后再发布正式主图 +6. 先生成动作试片 +7. 动作试片确认后再生成完整核心动作集 +8. 主图发布后写回 `imageSrc + generatedVisualAssetId` +9. 动作发布后写回 `generatedAnimationSetId + animationMap` +10. 更新 `assetCoverage.roleAssets` +11. 写入 changeLog 和 checkpoint + +前端行为: + +1. 从 draft detail panel 打开 `CustomWorldRoleAssetStudioModal` +2. 继续复用现有主图候选、动作草稿、发布链 +3. 明确展示当前角色的抽卡轮次、本次积分消耗和当前阶段 +4. 发布成功后调用 Agent action `sync_role_assets` +5. 同步刷新角色卡资产状态 + +### 步骤 6:生成场景与营地背景图资产 + +触发: + +- 用户发送“给这些场景出背景图” +- 或点击快捷动作“生成场景背景” +- 或在地点卡 / 营地卡上点击“生成背景图” + +范围: + +1. 所有 `landmark` +2. `camp` + +分阶段规则: + +1. 重要场景允许多候选抽卡后确认。 +2. 次级场景默认只走一次低成本候选。 +3. 场景背景图未确认前,不阻塞世界草稿继续扩展;但阻塞发布。 + +服务端行为: + +1. 解析目标场景集合 +2. 按场景优先级确定默认候选策略 +3. 复用现有 `generateCustomWorldSceneImage(...)` 能力 +4. 生成结果写回 `imageSrc` +5. 同步写回 `generatedSceneAssetId / generatedScenePrompt / generatedSceneModel` +6. 更新 `assetCoverage.sceneAssets` +7. 写入 changeLog 和 checkpoint + +前端行为: + +1. 从 draft detail panel 打开 `CustomWorldSceneAssetStudioModal` +2. 支持附加 prompt、参考图和重新生成 +3. 应用结果后调用 Agent action `sync_scene_assets` +4. 明确展示当前场景的候选轮次、本次积分消耗和是否需要人工确认 + +说明: + +草稿阶段允许继续使用 `customWorldVisuals.ts` 的默认图池做预览; +但进入发布前检查时,营地与所有 `landmark` 都必须拥有正式生成资产。 + +### 步骤 7:扩展长尾内容 + +触发: + +- 用户发送“继续补全世界 / 扩展长尾 / 增加路人和次级地点” +- 或点击快捷动作“继续补全世界” + +服务端行为: + +1. 基于当前 `creatorIntent + anchorPack + lockState + draftSnapshot` +2. 生成长尾 NPC、次级地点、支线种子、普通载体和场景连接 +3. 不得覆盖锁定内容 +4. 生成完成后更新 `CustomWorldProfile` 的长尾部分 +5. 长尾角色完成后进入自动视觉资产补齐队列 +6. 长尾地点完成后进入自动背景图补齐队列 + +### 步骤 8:发布 + +触发: + +- 用户点击“发布世界” + +服务端行为: + +1. 发布时自动执行内部发布校验 +2. 若存在 blocker 级问题,禁止发布 +3. 若只有 warning,允许发布但必须展示告知 +4. 发布时把当前快照编译成最终 `CustomWorldProfile` +5. 持久化到世界库 +6. 冻结本次角色与场景资产引用 + +--- + +## 5.3 当前阶段视觉资产覆盖定义 + +## 5.4 视觉抽卡与积分消耗策略 + +当前项目必须默认承认一个现实: + +**角色主形象和动作通常需要多次抽卡才能获得满意结果,而且这条链路成本明显高于普通文本生成。** + +因此视觉资产不能按“想重抽就无限重抽”的方式设计,而必须采用: + +1. 对象分级 +2. 分阶段抽卡 +3. 低成本预览先行 +4. 正式高清发布后置 +5. 每次高成本操作前明确提示积分消耗 + +### 角色优先级分层 + +服务端必须为每个角色自动计算 `assetPriorityTier`: + +```ts +type CustomWorldAssetPriorityTier = 'hero' | 'featured' | 'supporting'; +``` + +判定规则: + +1. `hero` + - 所有 `playableNpcs` + +2. `featured` + - 被锁定的 `storyNpcs` + - 主线第一幕直接关联的 `storyNpcs` + - 势力代表角色 + +3. `supporting` + - 其余 `storyNpcs` + +### 场景优先级分层 + +服务端必须为场景自动计算 `scenePriorityTier`: + +```ts +type CustomWorldScenePriorityTier = 'key' | 'supporting'; +``` + +判定规则: + +1. `key` + - `camp` + - 被锁定的 `landmark` + - 主线第一幕直接关联的 `landmark` + +2. `supporting` + - 其余 `landmark` + +### 角色主图抽卡策略 + +#### `hero` + +1. 先生成 `2` 张低成本候选 +2. 每次重抽仍为 `2` 张低成本候选 +3. 用户确认候选后,再生成 `1` 次正式主图发布结果 +4. 每次重抽前都要展示本次积分消耗 + +#### `featured` + +1. 先生成 `2` 张低成本候选 +2. 用户确认候选后,再生成 `1` 次正式主图发布结果 +3. 每次重抽前都要展示本次积分消耗 + +#### `supporting` + +1. 默认只生成 `1` 张低成本候选 +2. 支持继续重抽,但默认不主动推荐多轮人工抽卡 +3. 通过基础 QA 后直接进入正式主图发布 +4. 不要求每个 supporting 角色都人工逐个确认 + +### 动作抽卡策略 + +角色动作不能一开始就把完整核心动作集全部抽出来。 + +必须采用两段式: + +#### 阶段 A:动作试片 + +每个角色先只生成: + +1. `idle` +2. `attack` + +用途: + +1. 检查角色一致性是否稳定 +2. 检查动作风格是否匹配 +3. 检查武器、衣摆和轮廓是否容易漂移 + +#### 阶段 B:完整核心动作集 + +只有当动作试片确认通过后,才允许生成: + +1. `run` +2. `hurt` +3. `die` + +加上已确认的 `idle / attack`,组成当前阶段完整核心动作集。 + +### 场景图抽卡策略 + +#### `key` 场景 + +1. 每次生成 `2` 张候选 +2. 候选确认后,再记为正式场景图资产 +3. 每次重抽前都要展示本次积分消耗 + +#### `supporting` 场景 + +1. 每次只生成 `1` 张候选 +2. 支持继续重抽,但默认不主动推荐多轮人工抽卡 +3. 通过基础 QA 后直接进入正式场景图资产 + +### 积分提示与确认规则 + +服务端必须记录: + +1. 当前对象已经生成了多少轮候选 +2. 本轮属于低成本预览还是正式发布 +3. 本轮预计消耗多少积分 + +当用户触发高成本操作时: + +1. 不允许直接发起模型调用 +2. 必须返回 warning +3. 必须要求一次显式确认 +4. 确认文案中必须展示“本次将消耗多少积分” + +### 必须落盘的抽卡状态 + +角色和场景资产摘要中必须带: + +1. `priorityTier` +2. `draftRound` +3. `manualApprovalRequired` +4. `approved` +5. `nextPointCost` + +这些字段不能只存在前端本地状态。 + +## 角色主形象 + +发布前,每个角色必须满足: + +1. `imageSrc` 非空 +2. `generatedVisualAssetId` 非空 + +适用范围: + +1. `playableNpcs` +2. `storyNpcs` + +## 角色动作 + +当前阶段严格按现有 `CustomWorldRoleAssetStudioModal.tsx` 的核心动作集执行。 + +发布前,每个角色至少需要以下动作槽位可用: + +1. `idle` +2. `run` +3. `attack` +4. `hurt` +5. `die` + +判定方式: + +1. `generatedAnimationSetId` 非空 +2. `animationMap` 中以上 5 个槽位都存在有效映射 + +说明: + +这次 PRD 不要求把更完整的动作系统一次扩到所有跳跃、冲刺、攀爬槽位。 +如果后续要把当前资产工坊升级到更完整动作集,应在角色资产工坊专项 PRD 中继续扩写。 + +## 场景背景图 + +发布前,以下对象必须满足: + +1. `camp.imageSrc` 非空,且 `camp.generatedSceneAssetId` 非空 +2. 每个 `landmark.imageSrc` 非空,且 `landmark.generatedSceneAssetId` 非空 + +草稿阶段允许暂时继续使用默认图池做预览,不作为发布通过标准。 + +--- + +## 6. 会话状态机 + +## 6.1 会话阶段枚举 + +必须新增: + +```ts +type CustomWorldAgentStage = + | 'collecting_intent' + | 'clarifying' + | 'foundation_review' + | 'object_refining' + | 'visual_refining' + | 'long_tail_review' + | 'ready_to_publish' + | 'published' + | 'error'; +``` + +## 6.2 状态迁移规则 + +| 当前阶段 | 触发 | 下一阶段 | +| --- | --- | --- | +| `collecting_intent` | 最小锚点不足,Agent 追问 | `clarifying` | +| `clarifying` | 用户补齐锚点 | `foundation_review` | +| `collecting_intent` | 用户信息已足够并请求底稿 | `foundation_review` | +| `foundation_review` | 用户精修关键对象 | `object_refining` | +| `object_refining` | 用户请求生成角色或场景资产 | `visual_refining` | +| `visual_refining` | 关键角色与场景资产进入可用状态 | `long_tail_review` | +| `object_refining` | 用户明确跳过人工精修并走自动补齐 | `long_tail_review` | +| `long_tail_review` | 用户请求发布 | `ready_to_publish` | +| `ready_to_publish` | 发布成功 | `published` | +| 任意阶段 | 发生不可恢复错误 | `error` | + +## 6.3 阶段显示规则 + +前端顶部摘要区必须展示当前阶段中文标签: + +| 阶段 | 展示文案 | +| --- | --- | +| `collecting_intent` | 收集世界锚点 | +| `clarifying` | 补充关键设定 | +| `foundation_review` | 校对世界底稿 | +| `object_refining` | 精修关键对象 | +| `visual_refining` | 生成视觉资产 | +| `long_tail_review` | 补全长尾内容 | +| `ready_to_publish` | 准备发布 | +| `published` | 已发布 | +| `error` | 处理异常 | + +--- + +## 7. 共享 contract 设计 + +## 7.1 新增 contract 文件 + +必须新增: + +`packages/shared/src/contracts/customWorldAgent.ts` + +不允许继续把所有 Agent 相关结构都塞回 `runtime.ts`。 + +## 7.2 核心类型 + +### 1. 消息结构 + +```ts +type CustomWorldAgentMessageRole = 'user' | 'assistant' | 'system'; + +type CustomWorldAgentMessageKind = + | 'chat' + | 'clarification' + | 'summary' + | 'checkpoint' + | 'warning' + | 'action_result'; + +interface CustomWorldAgentMessage { + id: string; + role: CustomWorldAgentMessageRole; + kind: CustomWorldAgentMessageKind; + text: string; + createdAt: string; + relatedOperationId?: string | null; +} +``` + +### 2. 草稿卡片摘要 + +```ts +type CustomWorldDraftCardKind = + | 'world' + | 'camp' + | 'faction' + | 'character' + | 'landmark' + | 'thread' + | 'chapter' + | 'scene_chapter' + | 'carrier' + | 'sidequest_seed'; + +type CustomWorldDraftCardStatus = + | 'suggested' + | 'confirmed' + | 'locked' + | 'warning'; + +interface CustomWorldDraftCardSummary { + id: string; + kind: CustomWorldDraftCardKind; + title: string; + subtitle: string; + summary: string; + status: CustomWorldDraftCardStatus; + linkedIds: string[]; + warningCount: number; +} +``` + +### 3. 草稿卡片详情 + +```ts +interface CustomWorldDraftCardDetail { + id: string; + kind: CustomWorldDraftCardKind; + title: string; + sections: Array<{ + id: string; + label: string; + value: string; + }>; + linkedIds: string[]; + locked: boolean; + warningMessages: string[]; +} +``` + +### 4. 视觉覆盖摘要 + +```ts +type CustomWorldRoleAssetStatus = + | 'missing' + | 'visual_ready' + | 'animations_ready' + | 'complete'; + +type CustomWorldSceneAssetStatus = 'missing' | 'ready'; + +interface CustomWorldRoleAssetSummary { + roleId: string; + roleKind: 'playable' | 'story'; + name: string; + priorityTier: 'hero' | 'featured' | 'supporting'; + portraitPath?: string | null; + generatedVisualAssetId?: string | null; + generatedAnimationSetId?: string | null; + status: CustomWorldRoleAssetStatus; + missingAnimations: string[]; + visualDraftRound: number; + animationDraftRound: number; + motionPreviewApproved: boolean; + manualApprovalRequired: boolean; + approved: boolean; + nextPointCost: number | null; +} + +interface CustomWorldSceneAssetSummary { + sceneId: string; + sceneKind: 'camp' | 'landmark'; + name: string; + priorityTier: 'key' | 'supporting'; + imageSrc?: string | null; + generatedSceneAssetId?: string | null; + status: CustomWorldSceneAssetStatus; + draftRound: number; + manualApprovalRequired: boolean; + approved: boolean; + nextPointCost: number | null; +} + +interface CustomWorldAssetCoverageSummary { + roleAssets: CustomWorldRoleAssetSummary[]; + sceneAssets: CustomWorldSceneAssetSummary[]; + allRoleAssetsReady: boolean; + allSceneAssetsReady: boolean; +} +``` + +### 5. 质量问题 + +```ts +type CustomWorldQualitySeverity = 'info' | 'warning' | 'blocker'; + +type CustomWorldQualityCode = + | 'MISSING_WORLD_HOOK' + | 'MISSING_PLAYER_PREMISE' + | 'WEAK_CORE_CONFLICT' + | 'DUPLICATE_CHARACTER' + | 'DUPLICATE_LANDMARK' + | 'THREAD_DRIFT' + | 'SCENE_CHAPTER_UNBOUND' + | 'STYLE_DRIFT' + | 'LOCK_CONFLICT' + | 'LEAK_RISK' + | 'MISSING_ROLE_VISUAL' + | 'MISSING_ROLE_ANIMATIONS' + | 'MISSING_SCENE_IMAGE'; + +interface CustomWorldQualityFinding { + id: string; + severity: CustomWorldQualitySeverity; + code: CustomWorldQualityCode; + targetId?: string | null; + message: string; +} +``` + +### 6. 建议动作 + +```ts +type CustomWorldSuggestedActionType = + | 'request_summary' + | 'draft_foundation' + | 'refine_focus_target' + | 'lock_current_target' + | 'generate_role_assets' + | 'generate_scene_assets' + | 'expand_long_tail' + | 'publish_world'; + +interface CustomWorldSuggestedAction { + id: string; + type: CustomWorldSuggestedActionType; + label: string; + targetId?: string | null; +} +``` + +### 7. 会话快照 + +```ts +interface CustomWorldAgentSessionSnapshot { + sessionId: string; + stage: CustomWorldAgentStage; + focusCardId: string | null; + creatorIntent: JsonObject | null; + anchorPack: JsonObject | null; + lockState: JsonObject | null; + draftProfile: JsonObject | null; + messages: CustomWorldAgentMessage[]; + draftCards: CustomWorldDraftCardSummary[]; + pendingClarifications: CustomWorldQuestion[]; + suggestedActions: CustomWorldSuggestedAction[]; + qualityFindings: CustomWorldQualityFinding[]; + assetCoverage: CustomWorldAssetCoverageSummary; + updatedAt: string; +} +``` + +### 8. 操作记录 + +```ts +type CustomWorldAgentOperationType = + | 'process_message' + | 'lock_cards' + | 'unlock_cards' + | 'regenerate_scope' + | 'draft_foundation' + | 'generate_role_assets' + | 'sync_role_assets' + | 'generate_scene_assets' + | 'sync_scene_assets' + | 'expand_long_tail' + | 'publish_world' + | 'revert_checkpoint'; + +type CustomWorldAgentOperationStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed'; + +interface CustomWorldAgentOperationRecord { + operationId: string; + type: CustomWorldAgentOperationType; + status: CustomWorldAgentOperationStatus; + phaseLabel: string; + phaseDetail: string; + progress: number; + error?: string | null; +} +``` + +## 7.3 请求与响应结构 + +### 创建会话 + +```ts +interface CreateCustomWorldAgentSessionRequest { + seedText?: string; +} +``` + +响应: + +```ts +interface CreateCustomWorldAgentSessionResponse { + session: CustomWorldAgentSessionSnapshot; +} +``` + +### 发送消息 + +```ts +interface SendCustomWorldAgentMessageRequest { + clientMessageId: string; + text: string; + focusCardId?: string | null; + selectedCardIds?: string[]; +} +``` + +响应: + +```ts +interface SendCustomWorldAgentMessageResponse { + operation: CustomWorldAgentOperationRecord; +} +``` + +### 执行动作 + +```ts +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'; targetCardId?: string | null } + | { action: 'draft_foundation' } + | { action: 'generate_role_assets'; roleIds: string[] } + | { + action: 'sync_role_assets'; + roleId: string; + portraitPath: string; + generatedVisualAssetId: string; + generatedAnimationSetId?: string | null; + animationMap?: JsonObject | null; + } + | { action: 'generate_scene_assets'; sceneIds: string[] } + | { + action: 'sync_scene_assets'; + sceneId: string; + sceneKind: 'camp' | 'landmark'; + imageSrc: string; + generatedSceneAssetId: string; + generatedScenePrompt?: string | null; + generatedSceneModel?: string | null; + } + | { action: 'expand_long_tail' } + | { action: 'publish_world' } + | { action: 'revert_checkpoint'; checkpointId: string }; +``` + +响应: + +```ts +interface CustomWorldAgentActionResponse { + operation: CustomWorldAgentOperationRecord; +} +``` + +## 7.4 `src/types/customWorld.ts` 的必改字段 + +### 角色对象 + +角色对象继续复用当前已有字段,不再新增第二套视觉字段: + +```ts +imageSrc?: string; +generatedVisualAssetId?: string; +generatedAnimationSetId?: string; +animationMap?: Partial>; +``` + +### 营地与场景对象 + +`CustomWorldCampScene` 与 `CustomWorldLandmark` 必须新增正式场景图资产字段: + +```ts +generatedSceneAssetId?: string; +generatedScenePrompt?: string; +generatedSceneModel?: string; +``` + +说明: + +1. `imageSrc` 继续作为运行时读取字段 +2. `generatedSceneAssetId` 用于发布检查和资产追踪 +3. `generatedScenePrompt / generatedSceneModel` 用于复盘和重生成 + +--- + +## 8. 服务端模块设计 + +## 8.1 总原则 + +Agent 工具的核心逻辑全部在 Express 后端。 + +必须新增以下服务端模块。 + +额外约束: + +角色资产与场景图生成可以继续复用现有资产路由和场景图服务,但 orchestrator 必须统一管理这些结果何时进入 session snapshot。 + +## 8.2 路由层 + +### 文件 + +`server-node/src/routes/customWorldAgent.ts` + +### 职责 + +只负责: + +1. 参数校验 +2. 调用 service +3. 返回 snapshot / operation + +不得负责: + +1. 意图分类 +2. 内容合并 +3. 锁定裁决 +4. 重生成范围判断 + +## 8.3 Session Store + +### 文件 + +`server-node/src/services/customWorldAgentSessionStore.ts` + +### 职责 + +1. 持久化 Agent 会话 +2. 持久化 messages +3. 持久化 snapshot +4. 持久化 operations +5. 持久化 checkpoints + +### 硬要求 + +1. 不允许只存在内存里 +2. 每轮操作完成后必须落盘 +3. 会话支持恢复 +4. `assetCoverage` 也必须持久化 + +## 8.4 Orchestrator + +### 文件 + +`server-node/src/services/customWorldAgentOrchestrator.ts` + +### 职责 + +作为唯一总线,负责: + +1. 处理用户消息 +2. 调用意图分类器 +3. 调用目标解析器 +4. 调用 mutation service +5. 调用 generation / compile service +6. 调用 quality service +7. 组装 assistant reply +8. 更新 session snapshot + +### 禁止 + +不允许让路由直接跨服务拼流程。 + +## 8.5 Intent Resolver + +### 文件 + +`server-node/src/services/customWorldAgentIntentResolver.ts` + +### 职责 + +把用户消息解析成标准操作意图: + +```ts +type ResolvedAgentIntent = + | 'answer_clarification' + | 'draft_foundation' + | 'refine_card' + | 'lock_cards' + | 'unlock_cards' + | 'regenerate_scope' + | 'generate_role_assets' + | 'sync_role_assets' + | 'generate_scene_assets' + | 'sync_scene_assets' + | 'expand_long_tail' + | 'request_summary' + | 'publish_world'; +``` + +### 规则 + +1. 优先使用前端显式动作 +2. 其次使用 deterministic 规则 +3. 最后才允许 LLM 辅助分类 + +## 8.6 Target Resolver + +### 文件 + +`server-node/src/services/customWorldAgentTargetResolver.ts` + +### 职责 + +把用户消息中的目标对象绑定到卡片 id。 + +### 目标解析优先级 + +1. 前端显式传入的 `selectedCardIds` +2. 当前 `focusCardId` +3. 与现有卡片标题完全匹配 +4. 与现有卡片别名唯一匹配 + +### 硬规则 + +若目标解析结果: + +1. 为 0 个 +2. 或多于 1 个且无法唯一确认 + +则: + +- 不允许写入 +- 必须转成 clarification reply + +## 8.7 Mutation Service + +### 文件 + +`server-node/src/services/customWorldAgentMutationService.ts` + +### 职责 + +把标准操作意图变成结构化状态修改。 + +### 输入 + +1. 当前 session snapshot +2. resolved intent +3. resolved target ids +4. 用户原始文本 + +### 输出 + +1. 更新后的 `creatorIntent` +2. 更新后的 `lockState` +3. 更新后的 `draftProfile` +4. 更新后的 `draftCards` +5. 更新后的 `assetCoverage` + +### 硬规则 + +1. 锁定内容不允许被覆盖 +2. 不允许静默全局重写 +3. 每次 mutation 必须记录 `changeLog` +4. 角色和场景资产写回时,必须同时更新覆盖摘要 + +## 8.8 Draft Compiler + +### 文件 + +`server-node/src/services/customWorldAgentDraftCompiler.ts` + +### 职责 + +负责把: + +1. `creatorIntent` +2. `anchorPack` +3. `draftProfile` + +编译成: + +1. `CustomWorldDraftCardSummary[]` +2. `CustomWorldDraftCardDetail` +3. 适合前端展示的摘要信息 +4. 角色与场景资产覆盖摘要 + +### 说明 + +前端不自己拼卡片,卡片由后端统一编译。 + +## 8.9 Generation Bridge + +### 文件 + +直接复用并扩展: + +- `server-node/src/services/customWorldGenerationService.ts` + +必要时新增: + +- `server-node/src/services/customWorldAgentGenerationService.ts` + +### 职责 + +提供以下精确能力: + +1. `draftFoundation(...)` +2. `refineCard(...)` +3. `expandLongTail(...)` +4. `publishProfile(...)` + +### 硬要求 + +所有生成都必须支持传入: + +1. `creatorIntent` +2. `anchorPack` +3. `lockState` +4. `scope` + +## 8.10 Quality Service + +### 文件 + +`server-node/src/services/customWorldAgentQualityService.ts` + +### 职责 + +输出 `CustomWorldQualityFinding[]` + +### 检查项目 + +必须至少包含: + +1. 世界 hook 缺失 +2. 玩家身份缺失 +3. 核心冲突过弱 +4. 关键角色重复 +5. 关键地点重复 +6. 线程漂移 +7. 场景章节未绑定线程或角色 +8. 风格漂移 +9. 锁定冲突 +10. 泄露风险 +11. 角色主形象缺失 +12. 角色基础动作缺失 +13. 场景背景图缺失 + +## 8.11 Asset Bridge Service + +### 文件 + +`server-node/src/services/customWorldAgentAssetBridgeService.ts` + +### 职责 + +作为 Agent session 与现有资产技术链之间的桥。 + +### 必须复用 + +1. `server-node/src/modules/assets/characterAssetRoutes.ts` 里现有的角色主图发布 / 动作发布逻辑 +2. `server-node/src/services/sceneImageService.ts` +3. `src/components/CustomWorldRoleAssetStudioModal.tsx` 已使用的资产接口语义 + +### 必须重构 + +当前 `characterAssetRoutes.ts` 中与生成 / 发布相关的核心文件写入逻辑,必须抽出到独立 service,供: + +1. 资产路由直接调用 +2. Agent orchestrator 间接调用 + +### 输出 + +1. 角色主图发布结果 +2. 角色动作发布结果 +3. 场景图生成结果 +4. 可写回 session 的标准 asset payload +5. 角色 / 场景优先级与抽卡策略 + +## 8.12 Reply Composer + +### 文件 + +`server-node/src/services/customWorldAgentReplyComposer.ts` + +### 职责 + +为每轮 assistant 回复输出统一结构。 + +### 回复必须包含 + +1. 一段自然语言回应 +2. 本轮已确认或修改了什么 +3. 当前待确认项 +4. 建议下一步动作 + +### 禁止 + +不允许只返回闲聊式长文本,不给结构性结论。 + +--- + +## 9. 前端模块设计 + +## 9.1 入口流程 + +### 文件 + +`src/components/game-shell/PreGameSelectionFlow.tsx` + +### 必改项 + +1. 新增 `SelectionStage = 'custom-world-agent'` +2. 点击“创建自定义世界”后,不再直接打开大 textarea modal +3. 改为创建 Agent session 并进入 workspace +4. 发布成功前,不允许自动写入世界库 + +### 必删逻辑 + +1. 前端 `mergeLockedProfileContent(...)` +2. 自动保存并返回世界列表的当前行为 + +## 9.2 Agent 启动弹层 + +### 新文件 + +`src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx` + +### 职责 + +只负责: + +1. 输入一段可选 seed text +2. 点击“开始和 Agent 共创” + +### 不做 + +不承载完整创作表单,不承载生成逻辑。 + +## 9.3 Agent Workspace + +### 新文件 + +`src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` + +### 职责 + +是整个 Agent 创作工具的主壳层。 + +### 输入 + +1. `sessionSnapshot` +2. `activeOperation` + +### 子区块 + +1. `CustomWorldAgentHeader` +2. `CustomWorldAgentThread` +3. `CustomWorldAgentComposer` +4. `CustomWorldAgentLockBar` +5. `CustomWorldAgentDraftDrawer` +6. `CustomWorldAgentDraftDetailPanel` +7. `CustomWorldAgentQuickActions` +8. `CustomWorldAgentOperationBanner` + +### 不允许 + +不允许在这里做任何业务合并或对象变异。 + +## 9.4 Thread + +### 新文件 + +`src/components/custom-world-agent/CustomWorldAgentThread.tsx` + +### 职责 + +只渲染 messages 列表。 + +### 渲染规则 + +1. `assistant` 显示正常对话气泡 +2. `system summary` 显示强调样式 +3. `warning` 显示风险样式 +4. checkpoint message 可点击查看差异摘要 + +## 9.5 Composer + +### 新文件 + +`src/components/custom-world-agent/CustomWorldAgentComposer.tsx` + +### 职责 + +只负责发送用户输入。 + +### 输出事件 + +1. `onSubmit(text, focusCardId, selectedCardIds)` +2. `onQuickAction(action)` + +### 规则 + +发送时若有 active operation running,输入框置 disabled。 + +## 9.6 Header + +### 新文件 + +`src/components/custom-world-agent/CustomWorldAgentHeader.tsx` + +### 职责 + +展示: + +1. 世界名 +2. 当前阶段 +3. 当前 focus card +4. 草稿是否已发布 + +## 9.7 Lock Bar + +### 新文件 + +`src/components/custom-world-agent/CustomWorldAgentLockBar.tsx` + +### 职责 + +展示已锁定对象的 pill 列表。 + +### 行为 + +点击 pill 时: + +- 触发 focus 到对应卡片 + +## 9.8 Draft Drawer + +### 新文件 + +`src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx` + +### 职责 + +展示 `draftCards` 列表。 + +### 交互 + +1. 点击卡片 => 设为 focus card +2. 支持勾选卡片供快捷动作使用 +3. 不在这里直接编辑底层字段 + +## 9.9 Quick Actions + +### 新文件 + +`src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx` + +### 职责 + +渲染服务端返回的 `suggestedActions` + +### 行为 + +点击动作后,发起 `/actions` 请求,不在前端解释动作语义。 + +## 9.10 Operation Banner + +### 新文件 + +`src/components/custom-world-agent/CustomWorldAgentOperationBanner.tsx` + +### 职责 + +显示当前 operation 的: + +1. `phaseLabel` +2. `phaseDetail` +3. progress 百分比 +4. 错误状态 + +## 9.11 结果页 + +### 文件 + +`src/components/CustomWorldResultView.tsx` + +### 改造要求 + +在新方案里,它不再是主编辑器,而是: + +1. 发布前的最终预览 +2. 发布后的世界圣经总览 + +### 明确要求 + +1. 不再承担整世界重生成主入口 +2. 不再承担核心 Agent 对话主流程 +3. 仅保留“查看 / 导出 / 发布确认 / 进入世界” + +## 9.12 角色资产工坊 + +### 文件 + +`src/components/CustomWorldRoleAssetStudioModal.tsx` + +### 处理方式 + +这不是废弃模块,而是要接入 Agent workspace。 + +### 新职责 + +1. 接收 Agent 传入的当前角色卡 +2. 继续复用现有主图候选、动作候选、发布链 +3. 必须先走“低成本主图候选 -> 主图确认 -> 动作试片 -> 完整核心动作”四段式流程 +4. 明确展示当前角色的抽卡轮次、本次积分消耗和是否需要人工确认 +5. 发布成功后,不直接改本地 profile +6. 而是调用 Agent session action `sync_role_assets` + +### 硬规则 + +1. 关闭 modal 不等于写回成功 +2. 只有发布成功且 session 同步成功,角色卡资产状态才算更新 +3. 若本次操作属于高成本生成,必须先弹出积分消耗确认 + +## 9.13 场景背景图工坊 + +### 新文件 + +`src/components/custom-world-agent/CustomWorldSceneAssetStudioModal.tsx` + +### 来源 + +从当前 `CustomWorldEntityEditorModal.tsx` 中抽离 `SceneImageGenerationModal` + +### 职责 + +1. 预览当前场景图 +2. 输入附加 prompt 和参考图 +3. 调用现有 `generateCustomWorldSceneImage(...)` +4. 明确展示当前场景的候选轮次、本次积分消耗和是否需要人工确认 +5. 应用结果后调用 Agent session action `sync_scene_assets` + +### 范围 + +1. 支持 `camp` +2. 支持 `landmark` + +## 9.14 Agent Draft Detail Panel + +### 新文件 + +`src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx` + +### 职责 + +当 focus card 是: + +1. `character` +2. `landmark` +3. `camp` + +时,展示: + +1. 卡片详情 +2. 当前视觉资产状态 +3. 打开资产工坊按钮 +4. 锁定 / 重生快捷动作 + +### 说明 + +纯 Agent 是主交互,但视觉资产必须通过 detail panel 打开专门工坊。 + +--- + +## 10. API 设计 + +## 10.1 路由前缀 + +统一使用: + +`/api/runtime/custom-world/agent` + +同时复用现有: + +1. `/api/assets/...` +2. `/api/runtime/custom-world/scene-image` + +## 10.2 新增接口 + +### 1. 创建 session + +`POST /api/runtime/custom-world/agent/sessions` + +用途: + +- 创建新 Agent 创作会话 + +### 2. 获取 session snapshot + +`GET /api/runtime/custom-world/agent/sessions/:sessionId` + +用途: + +- 恢复会话 +- 获取最新 snapshot + +### 3. 发送消息 + +`POST /api/runtime/custom-world/agent/sessions/:sessionId/messages` + +用途: + +- 用户发送自然语言消息 +- 服务端创建 operation + +### 4. 执行动作 + +`POST /api/runtime/custom-world/agent/sessions/:sessionId/actions` + +用途: + +- 执行显式锁定、重生成、发布等操作 + +### 5. 查询 operation + +`GET /api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId` + +用途: + +- 轮询当前操作状态 + +### 6. 获取 card detail + +`GET /api/runtime/custom-world/agent/sessions/:sessionId/cards/:cardId` + +用途: + +- 打开某张卡片详情 + +## 10.3 复用的资产接口 + +以下现有接口保持可用,不重命名: + +1. `POST /api/assets/character-visual/generate` +2. `POST /api/assets/character-visual/publish` +3. `GET /api/assets/character-visual/jobs/:taskId` +4. `POST /api/assets/character-animation/generate` +5. `POST /api/assets/character-animation/publish` +6. `GET /api/assets/character-animation/jobs/:taskId` +7. `GET /api/assets/character-animation/templates` +8. `POST /api/assets/character-animation/import-video` +9. 场景图生成 `POST /api/runtime/custom-world/scene-image` + +约束: + +1. 前端资产工坊可以直接调用这些接口获取候选和发布结果 +2. 但真正把结果写回世界草稿,必须再调用 Agent action route + +## 10.4 前端轮询规则 + +前端发送 message 或 action 后: + +1. 立即拿到 `operationId` +2. 每 `500ms` 轮询一次 operation +3. operation `completed` 后立即刷新 snapshot +4. operation `failed` 后展示错误,不自动重试 + +--- + +## 11. Agent 行为规则 + +## 11.1 提问规则 + +1. 一次最多问 `3` 个问题 +2. 问题必须是当前阶段最缺的高杠杆信息 +3. 没有必要不追问长尾细节 +4. 如果用户拒绝细答,允许 Agent 提供默认提案供确认 + +## 11.2 总结规则 + +在下面时机必须自动插入 `summary` 类型 message: + +1. 首轮底稿生成后 +2. 锁定任意关键对象后 +3. 完成关键角色或关键地点精修后 +4. 长尾扩展完成后 +5. 发布完成后 + +## 11.3 修改规则 + +Agent 处理修改时必须遵守: + +1. 未解析清楚目标,不得写入 +2. 锁定内容不得被覆盖 +3. 局部修改不得静默扩散到无关对象 +4. 影响其他对象时,必须在回复里说明 + +## 11.4 生成规则 + +Agent 生成时必须遵守: + +1. 先锚点,后底稿 +2. 先关键对象,后长尾 +3. 先确认关键对象,再展开全局 +4. 长尾展开必须围绕当前锁定锚点 +5. 视觉资产先走低成本候选,不得默认直接走正式高成本生成 +6. 动作资产先走试片,不得默认一次生成完整动作集 +7. 遇到高成本生成时,必须先返回 warning 和积分确认请求 + +--- + +## 12. 锁定与重生成规则 + +## 12.1 锁定规则 + +锁定必须作用于卡片 id,不作用于名称字符串。 + +锁定后: + +1. 不允许 AI 覆盖卡片核心字段 +2. 允许 AI 在不触碰核心字段的情况下补充描述 +3. 若用户显式要求改写锁定对象,系统必须先提示“将解除锁定并重写” + +## 12.2 重生成范围 + +必须支持以下范围: + +1. `focus_card` +2. `long_tail_npcs` +3. `long_tail_landmarks` +4. `sidequest_seeds` +5. `role_assets` +6. `scene_assets` + +V1 明确不支持: + +1. 整个世界静默覆盖式重生成 + +## 12.3 冲突处理 + +若用户请求的重生成范围包含锁定对象: + +1. 默认拒绝执行 +2. assistant 回复解释冲突原因 +3. 提供两个动作: + - 保留锁定,缩小范围 + - 解除锁定后继续 + +--- + +## 13. 质量护栏 + +## 13.1 blocker 定义 + +下列问题属于 blocker,禁止发布: + +1. 缺失世界 hook +2. 缺失玩家身份 +3. 缺失核心冲突 +4. 发布时主线第一幕不存在 +5. 场景章节完全未绑定线程或角色 +6. 锁定对象与当前 draft 发生不可自动修复冲突 +7. 任意角色缺失 `generatedVisualAssetId` +8. 任意角色缺失 `generatedAnimationSetId` +9. 营地缺失正式背景图资产 +10. 任意 `landmark` 缺失正式背景图资产 + +## 13.2 warning 定义 + +下列问题属于 warning,允许发布但需展示: + +1. 风格漂移 +2. 角色重复感过强 +3. 地点区分度不足 +4. 支线和主线绑定过弱 +5. 载体不足 +6. 长尾覆盖不均 +7. 角色动作虽齐全但表现差异不足 +8. 场景背景图与地点描述匹配度偏弱 +9. 某些 `hero / featured / key` 对象尚未人工确认,但已接近发布 + +## 13.3 检查时机 + +内部发布校验必须发生在: + +1. 每次 foundation draft 完成后 +2. 每次 expand long tail 完成后 +3. 每次 publish 前 +4. 每次角色资产批量写回后 +5. 每次场景图批量写回后 + +--- + +## 14. 发布与存储规则 + +## 14.1 自动保存规则 + +Agent 会话每次 operation 完成后自动保存 session snapshot。 + +## 14.2 发布规则 + +只有用户显式执行 `publish_world` 时,才允许: + +1. 编译最终 `CustomWorldProfile` +2. 写入世界库 +3. 冻结角色主图、动作和场景图的当前 asset 引用 + +## 14.3 旧流程废弃 + +明确废弃: + +1. 生成成功后立即 upsert 世界库 +2. 生成成功后直接返回世界列表 +3. 使用默认图池和模板头像直接发布世界 + +## 14.4 世界库展示规则 + +世界选择页显示的已保存世界,仅展示已发布版本。 + +未发布的 Agent 会话不直接进入世界列表卡片,而通过“继续创作”入口恢复。 + +--- + +## 15. 编码落地清单 + +## 15.1 shared + +必须新增或修改: + +1. `packages/shared/src/contracts/customWorldAgent.ts` +2. `packages/shared/src/contracts/runtime.ts` + - 只保留 legacy custom world session,不扩写 Agent 专属结构 +3. `src/types/customWorld.ts` + - 为 `camp / landmark` 增加正式场景图资产字段 + +## 15.2 frontend + +必须新增或修改: + +1. `src/components/game-shell/PreGameSelectionFlow.tsx` +2. `src/components/SelectionCustomizationModals.tsx` +3. `src/services/aiService.ts` +4. `src/services/ai.ts` + - 继续承接场景图生成客户端 +5. `src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx` +6. `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` +7. `src/components/custom-world-agent/CustomWorldAgentHeader.tsx` +8. `src/components/custom-world-agent/CustomWorldAgentThread.tsx` +9. `src/components/custom-world-agent/CustomWorldAgentComposer.tsx` +10. `src/components/custom-world-agent/CustomWorldAgentLockBar.tsx` +11. `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx` +12. `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx` +13. `src/components/custom-world-agent/CustomWorldAgentOperationBanner.tsx` +14. `src/components/CustomWorldResultView.tsx` +15. `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx` +16. `src/components/custom-world-agent/CustomWorldSceneAssetStudioModal.tsx` +17. `src/components/CustomWorldRoleAssetStudioModal.tsx` + - 改成 Agent 可调用版 +18. `src/components/asset-studio/characterAssetWorkflowPersistence.ts` + - 继续复用现有资产接口客户端 + +## 15.3 backend + +必须新增或修改: + +1. `server-node/src/routes/customWorldAgent.ts` +2. `server-node/src/services/customWorldAgentSessionStore.ts` +3. `server-node/src/services/customWorldAgentOrchestrator.ts` +4. `server-node/src/services/customWorldAgentIntentResolver.ts` +5. `server-node/src/services/customWorldAgentTargetResolver.ts` +6. `server-node/src/services/customWorldAgentMutationService.ts` +7. `server-node/src/services/customWorldAgentDraftCompiler.ts` +8. `server-node/src/services/customWorldAgentQualityService.ts` +9. `server-node/src/services/customWorldAgentReplyComposer.ts` +10. `server-node/src/services/customWorldGenerationService.ts` +11. 路由注册文件中挂载新 route +12. `server-node/src/services/customWorldAgentAssetBridgeService.ts` +13. `server-node/src/modules/assets/characterAssetRoutes.ts` + - 抽出可复用 service +14. `server-node/src/services/sceneImageService.ts` + - 增加可被 Agent bridge 直接调用的服务能力 + +--- + +## 16. 逐阶段实施顺序 + +## 阶段 A:先打通 shared contract 与 session 主链 + +完成后标准: + +1. 可以创建 session +2. 可以发 message +3. 可以轮询 operation +4. 可以获取 snapshot +5. snapshot 带有空的 `assetCoverage` 结构 + +## 阶段 B:接入 Agent workspace 前台 + +完成后标准: + +1. 可以进入 `custom-world-agent` 页面 +2. 可以看到聊天线程、摘要、草稿抽屉 +3. 可以发送消息并拿到 assistant 回复 +4. 可以从卡片详情打开角色资产工坊或场景图工坊入口 + +## 阶段 C:打通 foundation draft + +完成后标准: + +1. Agent 能收集最小锚点 +2. Agent 能生成首轮底稿 +3. 草稿卡可以展示关键角色、地点、势力和第一幕 +4. 草稿卡开始展示角色 / 场景资产缺口 + +## 阶段 D:打通草稿设定编辑与 AI 新增角色/场景生成 + +完成后标准: + +1. 锁定对象可生效 +2. 只重做目标范围 +3. 前端不再做内容合并 +4. 角色与场景资产写回也只通过 session action 生效 + +## 阶段 E:打通视觉资产子链 + +完成后标准: + +1. 可从 Agent workspace 打开角色资产工坊 +2. 角色主图发布后能同步回 session +3. 角色动作发布后能同步回 session +4. 可从 Agent workspace 打开场景图工坊 +5. 营地和 `landmark` 背景图可同步回 session +6. 角色与场景都带有明确的抽卡轮次、积分提示和确认状态 + +## 阶段 F:打通长尾扩展与发布 + +完成后标准: + +1. 可继续补全长尾内容 +2. 可发布到世界库并进入世界 +3. 发布前内部校验能阻止缺图缺动作的世界通过 + +--- + +## 17. 验收标准 + +做到以下几点,才算这次 PRD 落地成功: + +1. 用户不需要先填一页表单,就能开始构建世界。 +2. Agent 在前台看起来像对话搭档,但后台每轮对话都能沉淀成结构化状态。 +3. 关键角色、关键地点、主线第一幕都能被精确锁定和局部重写。 +4. 长尾扩展不会覆盖已锁定内容。 +5. 前端不再承担合并锁定内容和生成裁决逻辑。 +6. 生成后的未发布世界不会直接污染正式世界库。 +7. 发布前有明确的内部校验和 blocker 规则。 +8. 最终发布产物仍可稳定进入现有游戏流程。 +9. 所有角色的 `generatedVisualAssetId / generatedAnimationSetId` 在发布时齐全。 +10. 营地与所有场景的正式背景图在发布时齐全。 +11. 角色与场景视觉资产会先走低成本候选流程,高成本生成前必须显式确认积分消耗。 + +--- + +## 18. 一句话结论 + +这次新创作工具的正确方向,不是把现有工作台换成一个更大的聊天框,而是: + +**让 Agent 成为创作者的主交互入口,让 Express 后端成为真正的世界状态管理者,让锁定、局部重生成、摘要、质量护栏和发布链把整个创作过程牢牢收住。** diff --git a/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md b/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md new file mode 100644 index 00000000..45b66479 --- /dev/null +++ b/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md @@ -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. 一句话结论 + +“创建自定义世界”不应该继续只是一个弹窗动作,而应该升级成一个正式的创作入口。 + +这个创作页面的本质价值,不是多做一个页面,而是把: + +- 新建作品 +- 继续草稿 +- 查看已发布作品 + +这三类本来分散的行为,正式收口到同一个创作中心里。 diff --git a/package-lock.json b/package-lock.json index 465ba2ab..9c92d3da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,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", @@ -33,6 +35,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", @@ -243,6 +246,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -292,6 +304,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "aix" @@ -307,6 +320,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -322,6 +336,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -337,6 +352,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "android" @@ -352,6 +368,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -367,6 +384,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -382,6 +400,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -397,6 +416,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -412,6 +432,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -427,6 +448,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -442,6 +464,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "linux" @@ -457,6 +480,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -472,6 +496,7 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" @@ -487,6 +512,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -502,6 +528,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -517,6 +544,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -532,6 +560,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -547,6 +576,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -562,6 +592,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -577,6 +608,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -592,6 +624,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -607,6 +640,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openharmony" @@ -622,6 +656,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" @@ -637,6 +672,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -652,6 +688,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -667,6 +704,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -868,6 +906,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -880,6 +919,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -892,6 +932,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -904,6 +945,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -916,6 +958,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -928,6 +971,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -940,6 +984,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -952,6 +997,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -964,6 +1010,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -976,6 +1023,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -988,6 +1036,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1000,6 +1049,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1012,6 +1062,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1024,6 +1075,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1036,6 +1088,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1048,6 +1101,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1060,6 +1114,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1072,6 +1127,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1084,6 +1140,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1096,6 +1153,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -1108,6 +1166,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openharmony" @@ -1120,6 +1179,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1132,6 +1192,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1144,6 +1205,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1156,6 +1218,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1409,6 +1472,112 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, + "node_modules/@testing-library/react": { + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", + "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^9.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@testing-library/react/node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1483,7 +1652,8 @@ "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true }, "node_modules/@types/express": { "version": "4.17.25", @@ -1531,7 +1701,7 @@ "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "devOptional": true, + "dev": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1972,6 +2142,13 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2017,6 +2194,18 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -2063,6 +2252,31 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -2086,6 +2300,12 @@ "node": "*" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, "node_modules/autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -2122,6 +2342,21 @@ "postcss": "^8.1.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2246,6 +2481,24 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2365,6 +2618,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2428,12 +2693,38 @@ "node": ">= 8" } }, + "node_modules/cssstyle": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", + "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", + "dev": true, + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "dev": true }, + "node_modules/data-urls": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz", + "integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==", + "dev": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2450,6 +2741,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true + }, "node_modules/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -2462,12 +2759,87 @@ "node": ">=6" } }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2526,6 +2898,25 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true + }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -2580,6 +2971,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2596,6 +2999,26 @@ "node": ">= 0.4" } }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2607,11 +3030,26 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -3013,6 +3451,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "engines": { "node": ">=12.0.0" }, @@ -3115,6 +3554,37 @@ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3180,6 +3650,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -3197,6 +3668,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3253,7 +3733,7 @@ "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "devOptional": true, + "dev": true, "dependencies": { "resolve-pkg-maps": "^1.0.0" }, @@ -3351,6 +3831,18 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3360,6 +3852,18 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3371,6 +3875,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -3382,6 +3901,18 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3401,6 +3932,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -3462,6 +4020,20 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3470,6 +4042,98 @@ "node": ">= 0.10" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3491,6 +4155,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3500,6 +4176,22 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -3509,6 +4201,124 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3540,6 +4350,48 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", + "integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==", + "dev": true, + "dependencies": { + "abab": "^2.0.6", + "cssstyle": "^3.0.0", + "data-urls": "^4.0.0", + "decimal.js": "^10.4.3", + "domexception": "^4.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.4", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.1", + "ws": "^8.13.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3897,6 +4749,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4078,6 +4939,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -4110,6 +4972,12 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==" }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -4121,6 +4989,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4200,6 +5113,18 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4273,6 +5198,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "engines": { "node": ">=12" }, @@ -4297,10 +5223,20 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -4392,6 +5328,18 @@ "node": ">= 0.10" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4415,6 +5363,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4490,6 +5444,32 @@ "node": ">=0.10.0" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4503,7 +5483,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "devOptional": true, + "dev": true, "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } @@ -4538,6 +5518,7 @@ "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -4577,6 +5558,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -4619,11 +5606,40 @@ } ] }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4687,6 +5703,38 @@ "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -4824,6 +5872,19 @@ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -4872,6 +5933,12 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -4905,6 +5972,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" @@ -4954,6 +6022,33 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -4975,7 +6070,7 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "devOptional": true, + "dev": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -5058,7 +6153,16 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true + "dev": true + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } }, "node_modules/unpipe": { "version": "1.0.0", @@ -5106,6 +6210,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -5126,6 +6240,7 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -5685,6 +6800,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "aix" @@ -5700,6 +6816,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -5715,6 +6832,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -5730,6 +6848,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "android" @@ -5745,6 +6864,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -5760,6 +6880,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -5775,6 +6896,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -5790,6 +6912,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -5805,6 +6928,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5820,6 +6944,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5835,6 +6960,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5850,6 +6976,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5865,6 +6992,7 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5880,6 +7008,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5895,6 +7024,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5910,6 +7040,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5925,6 +7056,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -5940,6 +7072,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -5955,6 +7088,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -5970,6 +7104,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -5985,6 +7120,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -6000,6 +7136,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openharmony" @@ -6015,6 +7152,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" @@ -6030,6 +7168,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -6045,6 +7184,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -6060,6 +7200,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -6072,6 +7213,7 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -6645,6 +7787,74 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", + "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "dev": true, + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6660,6 +7870,64 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -6691,6 +7959,42 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6848,6 +8152,12 @@ "@babel/helper-plugin-utils": "^7.27.1" } }, + "@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true + }, "@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -6885,156 +8195,182 @@ "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "dev": true, "optional": true }, "@esbuild/android-arm": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "dev": true, "optional": true }, "@esbuild/android-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "dev": true, "optional": true }, "@esbuild/android-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "dev": true, "optional": true }, "@esbuild/darwin-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "dev": true, "optional": true }, "@esbuild/darwin-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "dev": true, "optional": true }, "@esbuild/freebsd-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "dev": true, "optional": true }, "@esbuild/linux-arm": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "dev": true, "optional": true }, "@esbuild/linux-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "dev": true, "optional": true }, "@esbuild/linux-ia32": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "dev": true, "optional": true }, "@esbuild/linux-loong64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "dev": true, "optional": true }, "@esbuild/linux-mips64el": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "dev": true, "optional": true }, "@esbuild/linux-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "dev": true, "optional": true }, "@esbuild/linux-riscv64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "dev": true, "optional": true }, "@esbuild/linux-s390x": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "dev": true, "optional": true }, "@esbuild/linux-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "dev": true, "optional": true }, "@esbuild/netbsd-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "dev": true, "optional": true }, "@esbuild/netbsd-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "dev": true, "optional": true }, "@esbuild/openbsd-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "dev": true, "optional": true }, "@esbuild/openbsd-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "dev": true, "optional": true }, "@esbuild/openharmony-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "dev": true, "optional": true }, "@esbuild/sunos-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "dev": true, "optional": true }, "@esbuild/win32-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "dev": true, "optional": true }, "@esbuild/win32-ia32": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "dev": true, "optional": true }, "@esbuild/win32-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "dev": true, "optional": true }, "@eslint-community/eslint-utils": { @@ -7179,150 +8515,175 @@ "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "dev": true, "optional": true }, "@rollup/rollup-android-arm64": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "dev": true, "optional": true }, "@rollup/rollup-darwin-arm64": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "dev": true, "optional": true }, "@rollup/rollup-darwin-x64": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "dev": true, "optional": true }, "@rollup/rollup-freebsd-arm64": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "dev": true, "optional": true }, "@rollup/rollup-freebsd-x64": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "dev": true, "optional": true }, "@rollup/rollup-linux-arm-gnueabihf": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "dev": true, "optional": true }, "@rollup/rollup-linux-arm-musleabihf": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-gnu": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-musl": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "dev": true, "optional": true }, "@rollup/rollup-linux-loong64-gnu": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "dev": true, "optional": true }, "@rollup/rollup-linux-loong64-musl": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "dev": true, "optional": true }, "@rollup/rollup-linux-ppc64-gnu": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "dev": true, "optional": true }, "@rollup/rollup-linux-ppc64-musl": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "dev": true, "optional": true }, "@rollup/rollup-linux-riscv64-gnu": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "dev": true, "optional": true }, "@rollup/rollup-linux-riscv64-musl": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "dev": true, "optional": true }, "@rollup/rollup-linux-s390x-gnu": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "dev": true, "optional": true }, "@rollup/rollup-linux-x64-gnu": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "dev": true, "optional": true }, "@rollup/rollup-linux-x64-musl": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "dev": true, "optional": true }, "@rollup/rollup-openbsd-x64": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "dev": true, "optional": true }, "@rollup/rollup-openharmony-arm64": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "dev": true, "optional": true }, "@rollup/rollup-win32-arm64-msvc": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "dev": true, "optional": true }, "@rollup/rollup-win32-ia32-msvc": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "dev": true, "optional": true }, "@rollup/rollup-win32-x64-gnu": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "dev": true, "optional": true }, "@rollup/rollup-win32-x64-msvc": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "dev": true, "optional": true }, "@sinclair/typebox": { @@ -7454,6 +8815,84 @@ "tailwindcss": "4.2.2" } }, + "@testing-library/dom": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + } + }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + } + } + }, + "@testing-library/react": { + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", + "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^9.0.0", + "@types/react-dom": "^18.0.0" + }, + "dependencies": { + "@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true + } + } + }, + "@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true + }, + "@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true + }, + "@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true + }, "@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -7511,8 +8950,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.6.tgz", "integrity": "sha512-m8lERkkQj+uek18hXOZuec3W/fCRTrU4hrnXjH3qhHy96ytuPaPiWGgu7sJb7tZxZonO75vYAjCvpe/e4VUwRw==", - "dev": true, - "requires": {} + "dev": true }, "@types/connect": { "version": "3.4.38", @@ -7526,7 +8964,8 @@ "@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true }, "@types/express": { "version": "4.17.25", @@ -7574,7 +9013,7 @@ "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "devOptional": true, + "dev": true, "requires": { "undici-types": "~6.21.0" } @@ -7604,8 +9043,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "requires": {} + "dev": true }, "@types/semver": { "version": "7.7.1", @@ -7877,6 +9315,12 @@ "pretty-format": "^29.5.0" } }, + "abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "dev": true + }, "accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -7896,8 +9340,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} + "dev": true }, "acorn-walk": { "version": "8.3.5", @@ -7908,6 +9351,15 @@ "acorn": "^8.11.0" } }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "requires": { + "debug": "4" + } + }, "ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -7941,6 +9393,25 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "requires": { + "deep-equal": "^2.0.5" + } + }, + "array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "requires": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + } + }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -7958,6 +9429,12 @@ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", "dev": true }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, "autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -7971,6 +9448,15 @@ "postcss-value-parser": "^4.2.0" } }, + "available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "requires": { + "possible-typed-array-names": "^1.0.0" + } + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -8058,6 +9544,18 @@ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true }, + "call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + } + }, "call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -8136,6 +9634,15 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -8187,12 +9694,32 @@ "which": "^2.0.1" } }, + "cssstyle": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", + "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", + "dev": true, + "requires": { + "rrweb-cssom": "^0.6.0" + } + }, "csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "dev": true }, + "data-urls": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz", + "integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==", + "dev": true, + "requires": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.0" + } + }, "debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -8201,6 +9728,12 @@ "ms": "^2.1.3" } }, + "decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true + }, "deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -8210,12 +9743,66 @@ "type-detect": "^4.0.0" } }, + "deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "requires": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + } + }, "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "requires": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + } + }, + "define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "requires": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true + }, "depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -8255,6 +9842,21 @@ "esutils": "^2.0.2" } }, + "dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true + }, + "domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "dev": true, + "requires": { + "webidl-conversions": "^7.0.0" + } + }, "dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -8294,6 +9896,12 @@ "tapable": "^2.3.0" } }, + "entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true + }, "es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -8304,6 +9912,23 @@ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" }, + "es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + } + }, "es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -8312,11 +9937,23 @@ "es-errors": "^1.3.0" } }, + "es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + } + }, "esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", - "devOptional": true, + "dev": true, "requires": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", @@ -8412,29 +10049,25 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-react-hooks": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-react-refresh": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.6.tgz", "integrity": "sha512-NjGXdm7zgcKRkKMua34qVO9doI7VOxZ6ancSvBELJSSoX97jyndXcSoa8XBh69JoB31dNz3EEzlMcizZl7LaMA==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-simple-import-sort": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-10.0.0.tgz", "integrity": "sha512-AeTvO9UCMSNzIHRkg8S6c3RPy5YEwKWSQPx3DYghLedo2ZQxowPFLGDN1AZ2evfg6r6mjBSZSLxLFsWSu3acsw==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-unused-imports": { "version": "3.2.0", @@ -8621,7 +10254,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "requires": {} + "dev": true }, "file-entry-cache": { "version": "6.0.1", @@ -8697,6 +10330,28 @@ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, + "for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "requires": { + "is-callable": "^1.2.7" + } + }, + "form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + } + }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -8733,6 +10388,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "optional": true }, "function-bind": { @@ -8740,6 +10396,12 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, + "functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true + }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -8781,7 +10443,7 @@ "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "devOptional": true, + "dev": true, "requires": { "resolve-pkg-maps": "^1.0.0" } @@ -8848,17 +10510,41 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true + }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "requires": { + "es-define-property": "^1.0.0" + } + }, "has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" }, + "has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "requires": { + "has-symbols": "^1.0.3" + } + }, "hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -8867,6 +10553,15 @@ "function-bind": "^1.1.2" } }, + "html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "requires": { + "whatwg-encoding": "^2.0.0" + } + }, "http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -8879,6 +10574,27 @@ "toidentifier": "~1.0.1" } }, + "http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "requires": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + } + }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -8924,11 +10640,78 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + } + }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" }, + "is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + } + }, + "is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + } + }, + "is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "requires": { + "has-bigints": "^1.0.2" + } + }, + "is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "requires": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + } + }, + "is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true + }, + "is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + } + }, "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -8944,18 +10727,110 @@ "is-extglob": "^2.1.1" } }, + "is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true + }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true }, + "is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "requires": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + } + }, "is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true }, + "is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, + "is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + } + }, + "is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true + }, + "is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "requires": { + "call-bound": "^1.0.3" + } + }, + "is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "requires": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + } + }, + "is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + } + }, + "is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true + }, + "is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "requires": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + } + }, + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -8981,6 +10856,37 @@ "argparse": "^2.0.1" } }, + "jsdom": { + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", + "integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==", + "dev": true, + "requires": { + "abab": "^2.0.6", + "cssstyle": "^3.0.0", + "data-urls": "^4.0.0", + "decimal.js": "^10.4.3", + "domexception": "^4.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.4", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.1", + "ws": "^8.13.0", + "xml-name-validator": "^4.0.0" + } + }, "jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -9154,8 +11060,13 @@ "lucide-react": { "version": "0.546.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.546.0.tgz", - "integrity": "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ==", - "requires": {} + "integrity": "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ==" + }, + "lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true }, "magic-string": { "version": "0.30.21", @@ -9286,7 +11197,8 @@ "nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==" + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true }, "natural-compare": { "version": "1.4.0", @@ -9304,11 +11216,47 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==" }, + "nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true + }, "object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" }, + "object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "requires": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + } + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + } + }, "on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -9367,6 +11315,15 @@ "callsites": "^3.0.0" } }, + "parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "requires": { + "entities": "^6.0.0" + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -9421,7 +11378,8 @@ "picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true }, "pkg-types": { "version": "1.3.1", @@ -9442,10 +11400,17 @@ } } }, + "possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true + }, "postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, "requires": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9498,6 +11463,15 @@ "ipaddr.js": "1.9.1" } }, + "psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "requires": { + "punycode": "^2.3.1" + } + }, "punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9512,6 +11486,12 @@ "side-channel": "^1.1.0" } }, + "querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9558,6 +11538,26 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==" }, + "regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + } + }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, "resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -9568,7 +11568,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "devOptional": true + "dev": true }, "reusify": { "version": "1.1.0", @@ -9589,6 +11589,7 @@ "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, "requires": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", @@ -9619,6 +11620,12 @@ "fsevents": "~2.3.2" } }, + "rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true + }, "run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -9633,11 +11640,31 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" }, + "safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + } + }, "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "requires": { + "xmlchars": "^2.2.0" + } + }, "scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -9696,6 +11723,32 @@ "send": "~0.19.1" } }, + "set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "requires": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + } + }, + "set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "requires": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + } + }, "setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -9794,6 +11847,16 @@ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true }, + "stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + } + }, "strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -9827,6 +11890,12 @@ "has-flag": "^4.0.0" } }, + "symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -9853,6 +11922,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "requires": { "fdir": "^6.5.0", "picomatch": "^4.0.3" @@ -9884,12 +11954,32 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, + "tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "requires": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + } + }, + "tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dev": true, + "requires": { + "punycode": "^2.3.0" + } + }, "ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", - "dev": true, - "requires": {} + "dev": true }, "tslib": { "version": "2.8.1", @@ -9900,7 +11990,7 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "devOptional": true, + "dev": true, "requires": { "esbuild": "~0.27.0", "fsevents": "~2.3.3", @@ -9953,7 +12043,13 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true + "dev": true + }, + "universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true }, "unpipe": { "version": "1.0.0", @@ -9978,6 +12074,16 @@ "punycode": "^2.1.0" } }, + "url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "requires": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -9992,6 +12098,7 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, "requires": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10006,162 +12113,189 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "dev": true, "optional": true }, "@esbuild/android-arm": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "dev": true, "optional": true }, "@esbuild/android-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "dev": true, "optional": true }, "@esbuild/android-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "dev": true, "optional": true }, "@esbuild/darwin-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "dev": true, "optional": true }, "@esbuild/darwin-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "dev": true, "optional": true }, "@esbuild/freebsd-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "dev": true, "optional": true }, "@esbuild/linux-arm": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "dev": true, "optional": true }, "@esbuild/linux-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "dev": true, "optional": true }, "@esbuild/linux-ia32": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "dev": true, "optional": true }, "@esbuild/linux-loong64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "dev": true, "optional": true }, "@esbuild/linux-mips64el": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "dev": true, "optional": true }, "@esbuild/linux-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "dev": true, "optional": true }, "@esbuild/linux-riscv64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "dev": true, "optional": true }, "@esbuild/linux-s390x": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "dev": true, "optional": true }, "@esbuild/linux-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "dev": true, "optional": true }, "@esbuild/netbsd-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "dev": true, "optional": true }, "@esbuild/netbsd-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "dev": true, "optional": true }, "@esbuild/openbsd-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "dev": true, "optional": true }, "@esbuild/openbsd-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "dev": true, "optional": true }, "@esbuild/openharmony-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "dev": true, "optional": true }, "@esbuild/sunos-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "dev": true, "optional": true }, "@esbuild/win32-arm64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "dev": true, "optional": true }, "@esbuild/win32-ia32": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "dev": true, "optional": true }, "@esbuild/win32-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "dev": true, "optional": true }, "esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, "requires": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", @@ -10653,6 +12787,57 @@ } } }, + "w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "requires": { + "xml-name-validator": "^4.0.0" + } + }, + "webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true + }, + "whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "requires": { + "iconv-lite": "0.6.3" + }, + "dependencies": { + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, + "whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true + }, + "whatwg-url": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", + "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "dev": true, + "requires": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + } + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10662,6 +12847,46 @@ "isexe": "^2.0.0" } }, + "which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "requires": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + } + }, + "which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "requires": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + } + }, + "which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + } + }, "why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -10684,6 +12909,24 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true + }, + "xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true + }, + "xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index f7b3c678..7c272576 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/shared/src/contracts/customWorldAgent.ts b/packages/shared/src/contracts/customWorldAgent.ts new file mode 100644 index 00000000..a2a8029d --- /dev/null +++ b/packages/shared/src/contracts/customWorldAgent.ts @@ -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 | 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 | null; + storyGraph?: Record | 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 | null; + creatorIntentReadiness: CreatorIntentReadiness; + anchorPack: Record | null; + lockState: Record | null; + draftProfile: Record | 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 | null; + } + | { action: 'publish_world' }; + +export interface CustomWorldAgentActionResponse { + operation: CustomWorldAgentOperationRecord; +} + +export interface GetCustomWorldAgentCardDetailResponse { + card: CustomWorldDraftCardDetail; +} + +export interface ListCustomWorldWorksResponse { + items: CustomWorldWorkSummary[]; +} diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 6ab314be..e37f6283 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -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; diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index 296dae11..476d0f4d 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -15,7 +15,10 @@ import { createAppContext } from './server.ts'; import { httpRequest, type TestRequestInit } from './testHttp.ts'; type TestConfigOverrides = Partial< - Omit + Omit< + AppConfig, + 'llm' | 'dashScope' | 'smsAuth' | 'wechatAuth' | 'authSession' + > > & { llm?: Partial; dashScope?: Partial; @@ -24,6 +27,8 @@ type TestConfigOverrides = Partial< authSession?: Partial; }; +type TestAppContext = Awaited>; + function createTestConfig( testName: string, overrides: TestConfigOverrides = {}, @@ -141,7 +146,7 @@ function createTestConfig( async function withTestServer( testName: string, - run: (options: { baseUrl: string }) => Promise, + run: (options: { baseUrl: string; context: TestAppContext }) => Promise, overrides: TestConfigOverrides = {}, ) { const context = await createAppContext(createTestConfig(testName, overrides)); @@ -154,6 +159,7 @@ async function withTestServer( const address = server.address() as AddressInfo; return await run({ baseUrl: `http://127.0.0.1:${address.port}`, + context, }); } finally { await new Promise((resolve, reject) => { @@ -250,15 +256,188 @@ async function phoneLogin(baseUrl: string, phone: string, code = '123456') { }; } -function parseRedirectHash(location: string) { - const url = new URL(location, 'http://127.0.0.1'); - return new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : url.hash); +async function waitForCustomWorldAgentOperation(params: { + baseUrl: string; + token: string; + sessionId: string; + operationId: string; + expectedStatus: 'completed' | 'failed'; +}) { + let operationText = ''; + + for (let attempt = 0; attempt < 24; attempt += 1) { + const operationResponse = await httpRequest( + `${params.baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(params.sessionId)}/operations/${encodeURIComponent(params.operationId)}`, + { + headers: { + Authorization: `Bearer ${params.token}`, + }, + }, + ); + assert.equal(operationResponse.status, 200); + operationText = await operationResponse.text(); + + if (new RegExp(`"status":"${params.expectedStatus}"`, 'u').test(operationText)) { + return operationText; + } + + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + throw new Error(`operation did not reach ${params.expectedStatus}`); } -async function startWechatMockFlow( - baseUrl: string, - redirectPath = '/', -) { +async function createReadyCustomWorldAgentSession(params: { + baseUrl: string; + token: string; +}) { + const createResponse = await httpRequest( + `${params.baseUrl}/api/runtime/custom-world/agent/sessions`, + withBearer(params.token, { + method: 'POST', + body: JSON.stringify({ + seedText: '一个被潮雾切开的列岛世界。', + }), + }), + ); + const created = (await createResponse.json()) as { + session: { + sessionId: string; + }; + }; + + assert.equal(createResponse.status, 200); + + const messagePayloads = [ + { + clientMessageId: 'phase3-app-1', + text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。', + }, + { + clientMessageId: 'phase3-app-2', + text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。', + }, + ]; + + for (const payload of messagePayloads) { + const messageResponse = await httpRequest( + `${params.baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`, + withBearer(params.token, { + method: 'POST', + body: JSON.stringify({ + ...payload, + focusCardId: null, + selectedCardIds: [], + }), + }), + ); + const messageData = (await messageResponse.json()) as { + operation: { + operationId: string; + }; + }; + + assert.equal(messageResponse.status, 200); + await waitForCustomWorldAgentOperation({ + baseUrl: params.baseUrl, + token: params.token, + sessionId: created.session.sessionId, + operationId: messageData.operation.operationId, + expectedStatus: 'completed', + }); + } + + const sessionResponse = await httpRequest( + `${params.baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}`, + { + headers: { + Authorization: `Bearer ${params.token}`, + }, + }, + ); + const session = (await sessionResponse.json()) as { + sessionId: string; + stage: string; + creatorIntentReadiness: { + isReady: boolean; + }; + }; + + assert.equal(sessionResponse.status, 200); + assert.equal(session.stage, 'foundation_review'); + assert.equal(session.creatorIntentReadiness.isReady, true); + + return session; +} + +async function createObjectRefiningCustomWorldAgentSession(params: { + baseUrl: string; + token: string; +}) { + const readySession = await createReadyCustomWorldAgentSession(params); + + const actionResponse = await httpRequest( + `${params.baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/actions`, + withBearer(params.token, { + method: 'POST', + body: JSON.stringify({ + action: 'draft_foundation', + }), + }), + ); + const actionPayload = (await actionResponse.json()) as { + operation: { + operationId: string; + status: string; + }; + }; + + assert.equal(actionResponse.status, 200); + assert.equal(actionPayload.operation.status, 'queued'); + + await waitForCustomWorldAgentOperation({ + baseUrl: params.baseUrl, + token: params.token, + sessionId: readySession.sessionId, + operationId: actionPayload.operation.operationId, + expectedStatus: 'completed', + }); + + const sessionResponse = await httpRequest( + `${params.baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}`, + { + headers: { + Authorization: `Bearer ${params.token}`, + }, + }, + ); + const session = (await sessionResponse.json()) as { + sessionId: string; + stage: string; + draftCards: Array<{ + id: string; + kind: string; + title: string; + summary: string; + }>; + draftProfile: Record | null; + messages: Array<{ kind: string; text: string }>; + }; + + assert.equal(sessionResponse.status, 200); + assert.equal(session.stage, 'object_refining'); + + return session; +} + +function parseRedirectHash(location: string) { + const url = new URL(location, 'http://127.0.0.1'); + return new URLSearchParams( + url.hash.startsWith('#') ? url.hash.slice(1) : url.hash, + ); +} + +async function startWechatMockFlow(baseUrl: string, redirectPath = '/') { const startResponse = await httpRequest( `${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent(redirectPath)}`, ); @@ -387,6 +566,47 @@ test('auth entry auto-registers, me works, logout invalidates old token', async }); }); +test('auth entry tolerates concurrent creation of the same local account', async () => { + await withTestServer('auth-entry-race', async ({ baseUrl }) => { + const body = JSON.stringify({ + username: 'guest_race_local', + password: 'secret123', + }); + const responses = await Promise.all([ + httpRequest(`${baseUrl}/api/auth/entry`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }), + httpRequest(`${baseUrl}/api/auth/entry`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }), + ]); + const payloads = await Promise.all( + responses.map((response) => + response.json<{ + token: string; + user: { + id: string; + username: string; + }; + }>(), + ), + ); + + assert.deepEqual( + responses.map((response) => response.status), + [200, 200], + ); + assert.ok(payloads[0].token); + assert.ok(payloads[1].token); + assert.equal(payloads[0].user.username, 'guest_race_local'); + assert.equal(payloads[1].user.id, payloads[0].user.id); + }); +}); + test('login options expose enabled methods without authentication', async () => { await withTestServer('auth-login-options', async ({ baseUrl }) => { const response = await httpRequest(`${baseUrl}/api/auth/login-options`); @@ -459,7 +679,10 @@ test('wechat start uses oauth authorize inside wechat browser', async () => { `${authorizationUrl.origin}${authorizationUrl.pathname}`, 'https://open.weixin.qq.com/connect/oauth2/authorize', ); - assert.equal(authorizationUrl.searchParams.get('scope'), 'snsapi_userinfo'); + assert.equal( + authorizationUrl.searchParams.get('scope'), + 'snsapi_userinfo', + ); assert.equal(authorizationUrl.hash, '#wechat_redirect'); }, { @@ -607,14 +830,17 @@ test('captcha challenge is required after repeated verification failures', async assert.equal(response.status, 401); } - const sendCodeResponse = await httpRequest(`${baseUrl}/api/auth/phone/send-code`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: '13600136000', - scene: 'login', - }), - }); + const sendCodeResponse = await httpRequest( + `${baseUrl}/api/auth/phone/send-code`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone: '13600136000', + scene: 'login', + }), + }, + ); const sendCodePayload = (await sendCodeResponse.json()) as { error: { code: string; @@ -655,14 +881,17 @@ test('phone number enters temporary protection after repeated failed verificatio assert.equal(response.status, 401); } - const blockedResponse = await httpRequest(`${baseUrl}/api/auth/phone/send-code`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: '13800138000', - scene: 'login', - }), - }); + const blockedResponse = await httpRequest( + `${baseUrl}/api/auth/phone/send-code`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone: '13800138000', + scene: 'login', + }), + }, + ); const blockedPayload = (await blockedResponse.json()) as { error: { code: string; @@ -706,14 +935,17 @@ test('ip enters temporary protection after repeated failed verifications across assert.equal(response.status, 401); } - const blockedResponse = await httpRequest(`${baseUrl}/api/auth/phone/send-code`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: '13700137000', - scene: 'login', - }), - }); + const blockedResponse = await httpRequest( + `${baseUrl}/api/auth/phone/send-code`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phone: '13700137000', + scene: 'login', + }), + }, + ); const blockedPayload = (await blockedResponse.json()) as { error: { code: string; @@ -742,12 +974,15 @@ test('risk block endpoint returns active phone protection for the signed-in acco assert.equal(response.status, 401); } - const blocksResponse = await httpRequest(`${baseUrl}/api/auth/risk-blocks`, { - headers: { - Authorization: `Bearer ${entry.token}`, - Cookie: entry.refreshCookie || '', + const blocksResponse = await httpRequest( + `${baseUrl}/api/auth/risk-blocks`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + Cookie: entry.refreshCookie || '', + }, }, - }); + ); const blocksPayload = (await blocksResponse.json()) as { blocks: Array<{ scopeType: string; @@ -756,7 +991,9 @@ test('risk block endpoint returns active phone protection for the signed-in acco }; assert.equal(blocksResponse.status, 200); - assert.ok(blocksPayload.blocks.some((block) => block.scopeType === 'phone')); + assert.ok( + blocksPayload.blocks.some((block) => block.scopeType === 'phone'), + ); assert.ok((blocksPayload.blocks[0]?.remainingSeconds ?? 0) > 0); }); }); @@ -794,12 +1031,15 @@ test('risk block lift endpoint clears current phone protection', async () => { assert.equal(liftResponse.status, 200); assert.equal(liftPayload.ok, true); - const blocksResponse = await httpRequest(`${baseUrl}/api/auth/risk-blocks`, { - headers: { - Authorization: `Bearer ${entry.token}`, - Cookie: entry.refreshCookie || '', + const blocksResponse = await httpRequest( + `${baseUrl}/api/auth/risk-blocks`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + Cookie: entry.refreshCookie || '', + }, }, - }); + ); const blocksPayload = (await blocksResponse.json()) as { blocks: Array<{ scopeType: string; @@ -1081,8 +1321,16 @@ test('session list returns current active browser sessions for the user', async test('session revoke removes a remote device but keeps the current session alive', async () => { await withTestServer('session-revoke', async ({ baseUrl }) => { - const firstEntry = await authEntry(baseUrl, 'hero_session_revoke', 'secret123'); - const secondEntry = await authEntry(baseUrl, 'hero_session_revoke', 'secret123'); + const firstEntry = await authEntry( + baseUrl, + 'hero_session_revoke', + 'secret123', + ); + const secondEntry = await authEntry( + baseUrl, + 'hero_session_revoke', + 'secret123', + ); const sessionsResponse = await httpRequest(`${baseUrl}/api/auth/sessions`, { headers: { @@ -1096,7 +1344,9 @@ test('session revoke removes a remote device but keeps the current session alive isCurrent: boolean; }>; }; - const remoteSession = sessionsPayload.sessions.find((session) => !session.isCurrent); + const remoteSession = sessionsPayload.sessions.find( + (session) => !session.isCurrent, + ); assert.ok(remoteSession); const revokeResponse = await httpRequest( @@ -1115,12 +1365,15 @@ test('session revoke removes a remote device but keeps the current session alive assert.equal(revokeResponse.status, 200); assert.equal(revokePayload.ok, true); - const remoteRefreshResponse = await httpRequest(`${baseUrl}/api/auth/refresh`, { - method: 'POST', - headers: { - Cookie: firstEntry.refreshCookie || '', + const remoteRefreshResponse = await httpRequest( + `${baseUrl}/api/auth/refresh`, + { + method: 'POST', + headers: { + Cookie: firstEntry.refreshCookie || '', + }, }, - }); + ); assert.equal(remoteRefreshResponse.status, 401); const currentMeResponse = await httpRequest(`${baseUrl}/api/auth/me`, { @@ -1304,12 +1557,15 @@ test('logout-all revokes all refresh sessions and invalidates existing access to }); assert.equal(meBResponse.status, 401); - const refreshAfterLogoutAll = await httpRequest(`${baseUrl}/api/auth/refresh`, { - method: 'POST', - headers: { - Cookie: entryB.refreshCookie, + const refreshAfterLogoutAll = await httpRequest( + `${baseUrl}/api/auth/refresh`, + { + method: 'POST', + headers: { + Cookie: entryB.refreshCookie, + }, }, - }); + ); assert.equal(refreshAfterLogoutAll.status, 401); }); }); @@ -1571,6 +1827,771 @@ test('runtime persistence is isolated by user', async () => { }); }); +test('custom world works endpoint returns draft sessions and published worlds together', async () => { + await withTestServer('custom-world-works', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'cw_works', 'secret123'); + + const createSessionResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + seedText: '一个被潮雾切开的列岛世界。', + }), + }), + ); + const createdSession = (await createSessionResponse.json()) as { + session: { + sessionId: string; + stage: string; + }; + }; + + assert.equal(createSessionResponse.status, 200); + assert.equal(createdSession.session.stage, 'clarifying'); + + const publishResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/world-published`, + withBearer(entry.token, { + method: 'PUT', + body: JSON.stringify({ + profile: { + id: 'world-published', + name: '雾潮列岛', + subtitle: '已发布作品', + summary: '灯塔、沉船秘术与旧盟约在雾潮里重新苏醒。', + playableNpcs: [{ id: 'hero', name: '沈灯' }], + landmarks: [{ id: 'port', name: '潮港' }], + }, + }), + }), + ); + + assert.equal(publishResponse.status, 200); + + const worksResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/works`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + const worksPayload = (await worksResponse.json()) as { + items: Array<{ + status: string; + title: string; + sessionId?: string | null; + profileId?: string | null; + canResume: boolean; + canEnterWorld: boolean; + }>; + }; + + assert.equal(worksResponse.status, 200); + assert.ok( + worksPayload.items.some( + (item) => + item.status === 'draft' && + item.sessionId === createdSession.session.sessionId && + item.canResume === true && + item.canEnterWorld === false, + ), + ); + assert.ok( + worksPayload.items.some( + (item) => + item.status === 'published' && + item.profileId === 'world-published' && + item.title === '雾潮列岛' && + item.canResume === false && + item.canEnterWorld === true, + ), + ); + }); +}); + +test('custom world agent session accepts messages and exposes completed operations', async () => { + await withTestServer('custom-world-agent-messages', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'cw_agent', 'secret123'); + + const createResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + seedText: '一个围绕灯塔与沉船秘术的边境世界。', + }), + }), + ); + const created = (await createResponse.json()) as { + session: { + sessionId: string; + messages: Array<{ role: string }>; + }; + }; + + assert.equal(createResponse.status, 200); + assert.equal(created.session.messages[0]?.role, 'assistant'); + + const messageResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + clientMessageId: 'client-1', + text: '玩家是一个被迫回到故乡灯塔的失职守望者。', + focusCardId: null, + selectedCardIds: [], + }), + }), + ); + const messagePayload = (await messageResponse.json()) as { + operation: { + operationId: string; + status: string; + progress: number; + }; + }; + + assert.equal(messageResponse.status, 200); + assert.equal(messagePayload.operation.status, 'queued'); + assert.equal(messagePayload.operation.progress, 10); + + let operationText = ''; + + for (let attempt = 0; attempt < 20; attempt += 1) { + const operationResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/operations/${encodeURIComponent(messagePayload.operation.operationId)}`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + assert.equal(operationResponse.status, 200); + operationText = await operationResponse.text(); + + if (/"status":"completed"/u.test(operationText)) { + break; + } + + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + assert.match(operationText, /"status":"completed"/u); + assert.match(operationText, /"progress":100/u); + + const sessionResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + const sessionPayload = (await sessionResponse.json()) as { + stage: string; + creatorIntent: { + playerPremise?: string | null; + } | null; + messages: Array<{ role: string; text: string }>; + pendingClarifications: Array<{ question: string }>; + }; + + assert.equal(sessionResponse.status, 200); + assert.equal(sessionPayload.stage, 'clarifying'); + assert.ok( + sessionPayload.messages.some((message) => message.role === 'user'), + ); + assert.ok( + sessionPayload.messages.some((message) => message.role === 'assistant'), + ); + assert.match( + sessionPayload.creatorIntent?.playerPremise ?? '', + /玩家|守望者/u, + ); + assert.ok(sessionPayload.pendingClarifications.length > 0); + }); +}); + +test('custom world agent missing session returns 404', async () => { + await withTestServer( + 'custom-world-agent-missing-session', + async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'cw_agent_missing', 'secret123'); + + const response = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/unknown-session`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + const payload = (await response.json()) as { + error: { + code: string; + }; + }; + + assert.equal(response.status, 404); + assert.equal(payload.error.code, 'NOT_FOUND'); + }, + ); +}); + +test('custom world agent operation can fail and expose failed status', async () => { + await withTestServer( + 'custom-world-agent-failed-operation', + async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'cw_agent_fail', 'secret123'); + + const createResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + seedText: '一个潮雾列岛世界。', + }), + }), + ); + const created = (await createResponse.json()) as { + session: { + sessionId: string; + }; + }; + + assert.equal(createResponse.status, 200); + + const messageResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + clientMessageId: 'client-fail', + text: '__phase1_force_fail__', + focusCardId: null, + selectedCardIds: [], + }), + }), + ); + const messagePayload = (await messageResponse.json()) as { + operation: { + operationId: string; + }; + }; + + assert.equal(messageResponse.status, 200); + + let operationText = ''; + + for (let attempt = 0; attempt < 20; attempt += 1) { + const operationResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/operations/${encodeURIComponent(messagePayload.operation.operationId)}`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + assert.equal(operationResponse.status, 200); + operationText = await operationResponse.text(); + + if (/"status":"failed"/u.test(operationText)) { + break; + } + + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + assert.match(operationText, /"status":"failed"/u); + assert.match(operationText, /forced failure/u); + }, + ); +}); + +test('custom world agent draft_foundation action generates draft cards and card detail over http', async () => { + await withTestServer( + 'custom-world-agent-phase3-http', + async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'cw_agent_phase3', 'secret123'); + const readySession = await createReadyCustomWorldAgentSession({ + baseUrl, + token: entry.token, + }); + + const actionResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/actions`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + action: 'draft_foundation', + }), + }), + ); + const actionPayload = (await actionResponse.json()) as { + operation: { + operationId: string; + status: string; + }; + }; + + assert.equal(actionResponse.status, 200); + assert.equal(actionPayload.operation.status, 'queued'); + + await waitForCustomWorldAgentOperation({ + baseUrl, + token: entry.token, + sessionId: readySession.sessionId, + operationId: actionPayload.operation.operationId, + expectedStatus: 'completed', + }); + + const sessionResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + const sessionPayload = (await sessionResponse.json()) as { + stage: string; + draftProfile: { + name?: string; + summary?: string; + } | null; + draftCards: Array<{ + id: string; + kind: string; + }>; + }; + + assert.equal(sessionResponse.status, 200); + assert.equal(sessionPayload.stage, 'object_refining'); + assert.ok(sessionPayload.draftProfile?.name); + assert.ok(sessionPayload.draftCards.length > 0); + + const worldCard = sessionPayload.draftCards.find( + (card) => card.kind === 'world', + ); + assert.ok(worldCard); + + const cardDetailResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/cards/${encodeURIComponent(worldCard!.id)}`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + const cardDetailPayload = (await cardDetailResponse.json()) as { + card: { + kind: string; + sections: Array<{ + label: string; + value: string; + }>; + }; + }; + + assert.equal(cardDetailResponse.status, 200); + assert.equal(cardDetailPayload.card.kind, 'world'); + assert.ok( + cardDetailPayload.card.sections.some( + (section) => + section.label === '世界一句话' && section.value.length > 0, + ), + ); + }, + ); +}); + +test('custom world agent draft_foundation action rejects not-ready sessions over http', async () => { + await withTestServer( + 'custom-world-agent-phase3-http-not-ready', + async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'cw_agent_p3_nr', 'secret123'); + + const createResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + seedText: '一个被潮雾切开的列岛世界。', + }), + }), + ); + const created = (await createResponse.json()) as { + session: { + sessionId: string; + }; + }; + + assert.equal(createResponse.status, 200); + + const actionResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/actions`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + action: 'draft_foundation', + }), + }), + ); + const actionPayload = (await actionResponse.json()) as { + error: { + code: string; + message: string; + }; + }; + + assert.equal(actionResponse.status, 400); + assert.equal(actionPayload.error.code, 'BAD_REQUEST'); + assert.match(actionPayload.error.message, /foundation_review|ready/u); + }, + ); +}); + +test('custom world agent update_draft_card action updates draft profile and cards over http', async () => { + await withTestServer( + 'custom-world-agent-phase4-update-http', + async ({ baseUrl, context }) => { + const entry = await authEntry(baseUrl, 'cw_agent_phase4_update', 'secret123'); + const session = await createObjectRefiningCustomWorldAgentSession({ + baseUrl, + token: entry.token, + }); + const worldCard = session.draftCards.find((card) => card.kind === 'world'); + + assert.ok(worldCard); + + const actionResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + action: 'update_draft_card', + cardId: worldCard!.id, + sections: [ + { + sectionId: 'title', + value: '潮雾列岛·回潮版', + }, + { + sectionId: 'summary', + value: '世界总卡和主要对象已经继续往回潮暗线收紧。', + }, + ], + }), + }), + ); + const actionPayload = (await actionResponse.json()) as { + operation: { + operationId: string; + status: string; + }; + }; + + assert.equal(actionResponse.status, 200); + assert.equal(actionPayload.operation.status, 'queued'); + + await waitForCustomWorldAgentOperation({ + baseUrl, + token: entry.token, + sessionId: session.sessionId, + operationId: actionPayload.operation.operationId, + expectedStatus: 'completed', + }); + + const sessionResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + const sessionPayload = (await sessionResponse.json()) as { + draftProfile: { + name?: string; + summary?: string; + } | null; + draftCards: Array<{ + id: string; + kind: string; + title: string; + summary: string; + }>; + messages: Array<{ + kind: string; + text: string; + }>; + }; + + assert.equal(sessionResponse.status, 200); + assert.equal(sessionPayload.draftProfile?.name, '潮雾列岛·回潮版'); + assert.equal( + sessionPayload.draftProfile?.summary, + '世界总卡和主要对象已经继续往回潮暗线收紧。', + ); + assert.ok( + sessionPayload.draftCards.some( + (card) => + card.id === worldCard!.id && + card.title === '潮雾列岛·回潮版' && + card.summary === '世界总卡和主要对象已经继续往回潮暗线收紧。', + ), + ); + assert.ok( + sessionPayload.messages.some( + (message) => + message.kind === 'action_result' && message.text.includes('已更新'), + ), + ); + + const cardDetailResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/cards/${encodeURIComponent(worldCard!.id)}`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + const cardDetailPayload = (await cardDetailResponse.json()) as { + card: { + sections: Array<{ + label: string; + value: string; + }>; + }; + }; + + assert.equal(cardDetailResponse.status, 200); + assert.ok( + cardDetailPayload.card.sections.some( + (section) => section.label === '标题' && section.value === '潮雾列岛·回潮版', + ), + ); + + const sessionRecord = await context.customWorldAgentSessions.get( + entry.user.id, + session.sessionId, + ); + assert.ok( + sessionRecord?.checkpoints.some((checkpoint) => + checkpoint.label.includes('编辑'), + ), + ); + }, + ); +}); + +test('custom world agent generate_characters action appends character cards over http', async () => { + await withTestServer( + 'custom-world-agent-phase4-generate-characters-http', + async ({ baseUrl, context }) => { + const entry = await authEntry( + baseUrl, + 'cw_agent_p4_ch', + 'secret123', + ); + const session = await createObjectRefiningCustomWorldAgentSession({ + baseUrl, + token: entry.token, + }); + const baselineCharacterCount = session.draftCards.filter( + (card) => card.kind === 'character', + ).length; + const anchorCardId = + session.draftCards.find((card) => card.kind === 'character')?.id ?? + session.draftCards.find((card) => card.kind === 'thread')?.id; + + assert.ok(anchorCardId); + + const actionResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + action: 'generate_characters', + count: 2, + promptText: '补两位更贴近旧航道线的边缘角色。', + anchorCardIds: [anchorCardId], + }), + }), + ); + const actionPayload = (await actionResponse.json()) as { + operation: { + operationId: string; + status: string; + }; + }; + + assert.equal(actionResponse.status, 200); + assert.equal(actionPayload.operation.status, 'queued'); + + await waitForCustomWorldAgentOperation({ + baseUrl, + token: entry.token, + sessionId: session.sessionId, + operationId: actionPayload.operation.operationId, + expectedStatus: 'completed', + }); + + const sessionResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + const sessionPayload = (await sessionResponse.json()) as { + focusCardId: string | null; + draftProfile: { + storyNpcs?: Array<{ id: string }>; + } | null; + draftCards: Array<{ + kind: string; + title: string; + }>; + messages: Array<{ + kind: string; + text: string; + }>; + }; + + assert.equal(sessionResponse.status, 200); + assert.ok((sessionPayload.draftProfile?.storyNpcs?.length ?? 0) >= 2); + assert.ok( + sessionPayload.draftCards.filter((card) => card.kind === 'character').length >= + baselineCharacterCount + 2, + ); + assert.ok(sessionPayload.focusCardId); + assert.ok( + sessionPayload.messages.some( + (message) => + message.kind === 'action_result' && message.text.includes('新角色'), + ), + ); + + const sessionRecord = await context.customWorldAgentSessions.get( + entry.user.id, + session.sessionId, + ); + assert.ok( + sessionRecord?.checkpoints.some((checkpoint) => + checkpoint.label.includes('新增角色'), + ), + ); + }, + ); +}); + +test('custom world agent generate_landmarks action appends landmark cards over http', async () => { + await withTestServer( + 'custom-world-agent-phase4-generate-landmarks-http', + async ({ baseUrl, context }) => { + const entry = await authEntry( + baseUrl, + 'cw_agent_p4_lm', + 'secret123', + ); + const session = await createObjectRefiningCustomWorldAgentSession({ + baseUrl, + token: entry.token, + }); + const baselineLandmarkCount = session.draftCards.filter( + (card) => card.kind === 'landmark', + ).length; + const anchorCardId = + session.draftCards.find((card) => card.kind === 'character')?.id ?? + session.draftCards.find((card) => card.kind === 'thread')?.id; + + assert.ok(anchorCardId); + + const actionResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + action: 'generate_landmarks', + count: 2, + promptText: '补两个适合藏旧航道秘密的地点。', + anchorCardIds: [anchorCardId], + }), + }), + ); + const actionPayload = (await actionResponse.json()) as { + operation: { + operationId: string; + status: string; + }; + }; + + assert.equal(actionResponse.status, 200); + assert.equal(actionPayload.operation.status, 'queued'); + + await waitForCustomWorldAgentOperation({ + baseUrl, + token: entry.token, + sessionId: session.sessionId, + operationId: actionPayload.operation.operationId, + expectedStatus: 'completed', + }); + + const sessionResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + const sessionPayload = (await sessionResponse.json()) as { + focusCardId: string | null; + draftProfile: { + landmarks?: Array<{ id: string }>; + } | null; + draftCards: Array<{ + kind: string; + title: string; + }>; + messages: Array<{ + kind: string; + text: string; + }>; + }; + + assert.equal(sessionResponse.status, 200); + assert.ok((sessionPayload.draftProfile?.landmarks?.length ?? 0) >= 6); + assert.ok( + sessionPayload.draftCards.filter((card) => card.kind === 'landmark').length >= + baselineLandmarkCount + 2, + ); + assert.ok(sessionPayload.focusCardId); + assert.ok( + sessionPayload.messages.some( + (message) => + message.kind === 'action_result' && message.text.includes('新地点'), + ), + ); + + const sessionRecord = await context.customWorldAgentSessions.get( + entry.user.id, + session.sessionId, + ); + assert.ok( + sessionRecord?.checkpoints.some((checkpoint) => + checkpoint.label.includes('新增地点'), + ), + ); + }, + ); +}); + test('runtime snapshot persistence accepts null currentStory payloads', async () => { await withTestServer('persistence-null-story', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'player_null_story', 'secret123'); @@ -1596,11 +2617,14 @@ test('runtime snapshot persistence accepts null currentStory payloads', async () assert.equal(saveResponse.status, 200); assert.equal(savePayload.currentStory, null); - const loadResponse = await httpRequest(`${baseUrl}/api/runtime/save/snapshot`, { - headers: { - Authorization: `Bearer ${entry.token}`, + const loadResponse = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, }, - }); + ); const loadPayload = (await loadResponse.json()) as { currentStory: null; }; @@ -1612,7 +2636,11 @@ test('runtime snapshot persistence accepts null currentStory payloads', async () test('runtime snapshot persistence returns hydrated snapshots for frontend restore flows', async () => { await withTestServer('persistence-hydrated-snapshot', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'player_hydrated_snapshot', 'secret123'); + const entry = await authEntry( + baseUrl, + 'player_hydrated_snapshot', + 'secret123', + ); const saveResponse = await httpRequest( `${baseUrl}/api/runtime/save/snapshot`, @@ -1677,15 +2705,27 @@ test('runtime snapshot persistence returns hydrated snapshots for frontend resto ); assert.equal(savePayload.gameState.playerMaxHp, 208); assert.equal(savePayload.gameState.playerMaxMana, 1009); - assert.equal(savePayload.gameState.playerEquipment.weapon?.id, 'starter:hero:weapon'); - assert.equal(savePayload.gameState.playerEquipment.armor?.id, 'starter:hero:armor'); - assert.equal(savePayload.gameState.playerEquipment.relic?.id, 'starter:hero:relic'); + assert.equal( + savePayload.gameState.playerEquipment.weapon?.id, + 'starter:hero:weapon', + ); + assert.equal( + savePayload.gameState.playerEquipment.armor?.id, + 'starter:hero:armor', + ); + assert.equal( + savePayload.gameState.playerEquipment.relic?.id, + 'starter:hero:relic', + ); - const loadResponse = await httpRequest(`${baseUrl}/api/runtime/save/snapshot`, { - headers: { - Authorization: `Bearer ${entry.token}`, + const loadResponse = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, }, - }); + ); const loadPayload = (await loadResponse.json()) as typeof savePayload; assert.equal(loadResponse.status, 200); @@ -1695,15 +2735,28 @@ test('runtime snapshot persistence returns hydrated snapshots for frontend resto loadPayload.gameState.storyEngineMemory.saveMigrationManifest?.version, 'story-engine-v5', ); - assert.equal(loadPayload.gameState.playerEquipment.weapon?.id, 'starter:hero:weapon'); - assert.equal(loadPayload.gameState.playerEquipment.armor?.id, 'starter:hero:armor'); - assert.equal(loadPayload.gameState.playerEquipment.relic?.id, 'starter:hero:relic'); + assert.equal( + loadPayload.gameState.playerEquipment.weapon?.id, + 'starter:hero:weapon', + ); + assert.equal( + loadPayload.gameState.playerEquipment.armor?.id, + 'starter:hero:armor', + ); + assert.equal( + loadPayload.gameState.playerEquipment.relic?.id, + 'starter:hero:relic', + ); }); }); test('runtime snapshot persistence returns hydrated snapshots for frontend restore flows', async () => { await withTestServer('persistence-hydrated-snapshot', async ({ baseUrl }) => { - const entry = await authEntry(baseUrl, 'player_hydrated_story', 'secret123'); + const entry = await authEntry( + baseUrl, + 'player_hydrated_story', + 'secret123', + ); const saveResponse = await httpRequest( `${baseUrl}/api/runtime/save/snapshot`, @@ -1797,7 +2850,10 @@ test('runtime snapshot persistence returns hydrated snapshots for frontend resto assert.equal(savePayload.bottomTab, 'adventure'); assert.equal(savePayload.currentStory.streaming, false); assert.equal(savePayload.gameState.runtimeActionVersion, 0); - assert.deepEqual(savePayload.gameState.storyEngineMemory.activeThreadIds, []); + assert.deepEqual( + savePayload.gameState.storyEngineMemory.activeThreadIds, + [], + ); assert.equal( savePayload.gameState.storyEngineMemory.saveMigrationManifest?.version, 'story-engine-v5', @@ -1809,11 +2865,14 @@ test('runtime snapshot persistence returns hydrated snapshots for frontend resto assert.equal(savePayload.gameState.playerMana, 90); assert.equal(savePayload.gameState.playerMaxMana, 95); - const loadResponse = await httpRequest(`${baseUrl}/api/runtime/save/snapshot`, { - headers: { - Authorization: `Bearer ${entry.token}`, + const loadResponse = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, }, - }); + ); const loadPayload = (await loadResponse.json()) as { bottomTab: string; currentStory: { diff --git a/server-node/src/auth/authService.ts b/server-node/src/auth/authService.ts index 92d500c6..a1637088 100644 --- a/server-node/src/auth/authService.ts +++ b/server-node/src/auth/authService.ts @@ -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', diff --git a/server-node/src/context.ts b/server-node/src/context.ts index 3e3641d4..8f195f8e 100644 --- a/server-node/src/context.ts +++ b/server-node/src/context.ts @@ -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; diff --git a/server-node/src/db.test.ts b/server-node/src/db.test.ts index 303f8671..5ef6a2c5 100644 --- a/server-node/src/db.test.ts +++ b/server-node/src/db.test.ts @@ -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', diff --git a/server-node/src/db/migrations.ts b/server-node/src/db/migrations.ts index fa47f22d..8f899352 100644 --- a/server-node/src/db/migrations.ts +++ b/server-node/src/db/migrations.ts @@ -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)`, + ], + }, ]; diff --git a/server-node/src/modules/ai/customWorldOrchestrator.ts b/server-node/src/modules/ai/customWorldOrchestrator.ts index 90ad1696..0426ef1a 100644 --- a/server-node/src/modules/ai/customWorldOrchestrator.ts +++ b/server-node/src/modules/ai/customWorldOrchestrator.ts @@ -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; -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; + 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, summary,targetLandmarkName 必须指向本次输出的其他场景名', + '', + '约束:', + '- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。', + '- 角色名字、势力名、场景名必须互相区分,避免重复。', + '- 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; } diff --git a/server-node/src/modules/ai/orchestrator.test.ts b/server-node/src/modules/ai/orchestrator.test.ts index ff194a5a..bd31340c 100644 --- a/server-node/src/modules/ai/orchestrator.test.ts +++ b/server-node/src/modules/ai/orchestrator.test.ts @@ -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[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); +}); diff --git a/server-node/src/repositories/runtimeRepository.ts b/server-node/src/repositories/runtimeRepository.ts index ec0e6fb4..e1136b81 100644 --- a/server-node/src/repositories/runtimeRepository.ts +++ b/server-node/src/repositories/runtimeRepository.ts @@ -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; + listCustomWorldSessions(userId: string): Promise; + getCustomWorldSession( + userId: string, + sessionId: string, + ): Promise; + upsertCustomWorldSession( + userId: string, + sessionId: string, + session: CustomWorldSessionRecord, + ): Promise; }; export class RuntimeRepository implements RuntimeRepositoryPort { @@ -175,7 +193,8 @@ export class RuntimeRepository implements RuntimeRepositoryPort { async listCustomWorldProfiles(userId: string) { const result = await this.db.query( - `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( + `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( + `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, + }; + } } diff --git a/server-node/src/routes/customWorldAgent.ts b/server-node/src/routes/customWorldAgent.ts new file mode 100644 index 00000000..d7f74b41 --- /dev/null +++ b/server-node/src/routes/customWorldAgent.ts @@ -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; +} diff --git a/server-node/src/routes/runtimeRoutes.ts b/server-node/src/routes/runtimeRoutes.ts index 71bf17bc..bb634d69 100644 --- a/server-node/src/routes/runtimeRoutes.ts +++ b/server-node/src/routes/runtimeRoutes.ts @@ -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(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', diff --git a/server-node/src/server.ts b/server-node/src/server.ts index 73a98f72..fce3a7f7 100644 --- a/server-node/src/server.ts +++ b/server-node/src/server.ts @@ -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(), diff --git a/server-node/src/services/customWorldAgentAssetBridgeService.ts b/server-node/src/services/customWorldAgentAssetBridgeService.ts new file mode 100644 index 00000000..21de5ab5 --- /dev/null +++ b/server-node/src/services/customWorldAgentAssetBridgeService.ts @@ -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) + : null; +} + +function toRecordArray(value: unknown) { + return Array.isArray(value) + ? value.filter( + (item): item is Record => + Boolean(item) && typeof item === 'object' && !Array.isArray(item), + ) + : []; +} + +type SyncRoleAssetsPayload = { + roleId: string; + portraitPath: string; + generatedVisualAssetId: string; + generatedAnimationSetId?: string | null; + animationMap?: Record | null; +}; + +export type SyncRoleAssetsResult = { + roleId: string; + updatedRole: Record; + updatedAssetSummary: CustomWorldRoleAssetSummary; + draftProfile: Record; +}; + +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, + }; + } +} diff --git a/server-node/src/services/customWorldAgentChangeSummaryService.ts b/server-node/src/services/customWorldAgentChangeSummaryService.ts new file mode 100644 index 00000000..2ac6c56e --- /dev/null +++ b/server-node/src/services/customWorldAgentChangeSummaryService.ts @@ -0,0 +1,91 @@ +import { + getWorldFoundationCardId, + normalizeFoundationDraftProfile, +} from './customWorldAgentDraftCompiler.js'; + +type BuildDraftChangeSummaryParams = + | { + action: 'update_draft_card'; + cardId: string; + changedLabels: string[]; + draftProfile: Record; + } + | { + action: 'generate_characters'; + names: string[]; + draftProfile: Record; + } + | { + action: 'generate_landmarks'; + names: string[]; + draftProfile: Record; + }; + +function resolveTotalCharacterCount( + profile: NonNullable>, +) { + return [...new Set([...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id))] + .length; +} + +function resolveCardTitle( + draftProfile: NonNullable>, + 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'); + } +} diff --git a/server-node/src/services/customWorldAgentClarificationService.ts b/server-node/src/services/customWorldAgentClarificationService.ts new file mode 100644 index 00000000..6d0c8b71 --- /dev/null +++ b/server-node/src/services/customWorldAgentClarificationService.ts @@ -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'; +} diff --git a/server-node/src/services/customWorldAgentDraftCompiler.ts b/server-node/src/services/customWorldAgentDraftCompiler.ts new file mode 100644 index 00000000..733994b7 --- /dev/null +++ b/server-node/src/services/customWorldAgentDraftCompiler.ts @@ -0,0 +1,1041 @@ +import type { + CustomWorldDraftCardDetail, + CustomWorldDraftCardDetailSection, + CustomWorldDraftCardKind, + CustomWorldDraftCardSummary, + CustomWorldRoleAssetStatus, + CustomWorldFoundationDraftCamp, + CustomWorldFoundationDraftChapter, + CustomWorldFoundationDraftCharacter, + CustomWorldFoundationDraftFaction, + CustomWorldFoundationDraftLandmark, + CustomWorldFoundationDraftProfile, + CustomWorldFoundationDraftThread, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import { + buildRoleAssetSummary, + resolveRoleAssetStatusLabel, +} from './customWorldAgentRoleAssetStateService.js'; + +const WORLD_CARD_ID = 'world-foundation'; + +const EDITABLE_WORLD_SECTION_IDS = [ + 'title', + 'subtitle', + 'summary', + 'playerGoal', + 'tone', + 'coreConflicts', +] as const; + +const EDITABLE_FACTION_SECTION_IDS = [ + 'title', + 'subtitle', + 'summary', + 'publicGoal', + 'tension', +] as const; + +const EDITABLE_CHARACTER_SECTION_IDS = [ + 'name', + 'role', + 'publicMask', + 'hiddenHook', + 'relationToPlayer', + 'summary', +] as const; + +const EDITABLE_LANDMARK_SECTION_IDS = [ + 'name', + 'purpose', + 'mood', + 'secret', + 'summary', +] as const; + +const EDITABLE_THREAD_SECTION_IDS = [ + 'title', + 'summary', + 'conflictType', + 'stakes', +] as const; + +const EDITABLE_CHAPTER_SECTION_IDS = [ + 'title', + 'summary', + 'openingEvent', + 'playerGoal', + 'understandingShift', +] as const; + +const EDITABLE_CAMP_SECTION_IDS = [ + 'name', + 'description', + 'dangerLevel', +] as const; + +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) + : null; +} + +function toRecordArray(value: unknown) { + return Array.isArray(value) + ? value.filter((item) => item && typeof item === 'object') + : []; +} + +function toStringArray(value: unknown, maxCount = 8) { + if (!Array.isArray(value)) { + return []; + } + + return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice( + 0, + maxCount, + ); +} + +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 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 dedupeById(items: T[]) { + const seen = new Set(); + return items.filter((item) => { + const key = item.id.trim(); + if (!key || seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); +} + +function resolveEditableSectionIds(kind: CustomWorldDraftCardKind) { + if (kind === 'world') return [...EDITABLE_WORLD_SECTION_IDS]; + if (kind === 'faction') return [...EDITABLE_FACTION_SECTION_IDS]; + if (kind === 'character') return [...EDITABLE_CHARACTER_SECTION_IDS]; + if (kind === 'landmark') return [...EDITABLE_LANDMARK_SECTION_IDS]; + if (kind === 'thread') return [...EDITABLE_THREAD_SECTION_IDS]; + if (kind === 'chapter') return [...EDITABLE_CHAPTER_SECTION_IDS]; + if (kind === 'camp') return [...EDITABLE_CAMP_SECTION_IDS]; + return []; +} + +function normalizeFaction( + value: unknown, + index: number, +): CustomWorldFoundationDraftFaction | null { + const record = toRecord(value); + if (!record) { + return null; + } + + const title = toText(record.title) || toText(record.name); + const subtitle = toText(record.subtitle); + const publicGoal = toText(record.publicGoal); + const tension = toText(record.tension) || toText(record.relatedConflict); + const playerRelation = toText(record.playerRelation); + const summary = toText(record.summary); + + if (!title && !publicGoal && !tension && !summary) { + return null; + } + + return { + id: toText(record.id) || createId('faction', title || publicGoal, index), + name: title || `关键势力 ${index + 1}`, + title: title || `关键势力 ${index + 1}`, + subtitle: + subtitle || + clampText( + [publicGoal || '关键势力', tension || '当前张力仍在升级'] + .filter(Boolean) + .join(' · '), + 40, + ), + publicGoal: publicGoal || '稳住自己在当前局势中的位置', + relatedConflict: tension || '局势仍在快速失衡', + tension: tension || '局势仍在快速失衡', + playerRelation: playerRelation || '玩家迟早要和它发生直接关系', + summary: + summary || + clampText( + [ + publicGoal || '正在抢夺当前局势的主动权', + tension || '和主线冲突直接相连', + playerRelation || '会逼玩家选边', + ].join(';'), + 120, + ), + }; +} + +function normalizeCharacter( + value: unknown, + index: number, +): CustomWorldFoundationDraftCharacter | null { + const record = toRecord(value); + if (!record) { + return null; + } + + const name = toText(record.name); + const title = toText(record.title); + const role = toText(record.role); + const publicMask = toText(record.publicMask) || toText(record.publicIdentity); + const hiddenHook = toText(record.hiddenHook) || toText(record.currentPressure); + const relationToPlayer = toText(record.relationToPlayer); + const summary = toText(record.summary); + + if (!name && !title && !role && !summary) { + return null; + } + + return { + id: toText(record.id) || createId('character', name || title || role, index), + name: name || `关键角色 ${index + 1}`, + title: title || role || '关键角色', + role: role || title || '关键角色', + publicIdentity: publicMask || title || role || '正在局势前台行动的人', + publicMask: publicMask || title || role || '正在局势前台行动的人', + currentPressure: hiddenHook || '必须立刻回应眼前的局势压力', + hiddenHook: hiddenHook || '必须立刻回应眼前的局势压力', + relationToPlayer: relationToPlayer || '和玩家存在尚待精修的关系钩子', + threadIds: toStringArray(record.threadIds, 6), + summary: + summary || + clampText( + [ + publicMask || title || role || '处在局势前台', + hiddenHook || '眼下压力仍在加码', + relationToPlayer || '与玩家关系待细化', + ].join(';'), + 120, + ), + imageSrc: toText(record.imageSrc) || null, + generatedVisualAssetId: toText(record.generatedVisualAssetId) || null, + generatedAnimationSetId: toText(record.generatedAnimationSetId) || null, + animationMap: toRecord(record.animationMap), + }; +} + +function normalizeLandmark( + value: unknown, + index: number, +): CustomWorldFoundationDraftLandmark | null { + const record = toRecord(value); + if (!record) { + return null; + } + + const name = toText(record.name); + const description = toText(record.description); + const purpose = toText(record.purpose); + const mood = toText(record.mood); + const secret = toText(record.secret) || toText(record.importance); + const dangerLevel = toText(record.dangerLevel); + const summary = toText(record.summary); + + if (!name && !purpose && !mood && !secret && !summary) { + return null; + } + + return { + id: toText(record.id) || createId('landmark', name || purpose, index), + name: name || `关键地点 ${index + 1}`, + description: + description || + clampText( + [purpose || '承接关键冲突', mood || '整体情绪仍在发酵'] + .filter(Boolean) + .join(';'), + 96, + ), + purpose: purpose || '承接主线推进的关键地点', + mood: mood || '带着明显张力与未明感', + importance: secret || '玩家第一次抵达就会意识到它不只是背景', + secret: secret || '玩家第一次抵达就会意识到它不只是背景', + dangerLevel: dangerLevel || '中', + characterIds: toStringArray(record.characterIds, 8), + threadIds: toStringArray(record.threadIds, 8), + summary: + summary || + clampText( + [ + purpose || '承担关键戏剧功能', + secret || '和当前冲突直接相连', + mood || '会立刻形成情绪印象', + ].join(';'), + 120, + ), + }; +} + +function normalizeThread( + value: unknown, + index: number, +): CustomWorldFoundationDraftThread | null { + const record = toRecord(value); + if (!record) { + return null; + } + + const title = toText(record.title); + const conflictTypeText = toText(record.conflictType); + const type = + record.type === 'hidden' || + conflictTypeText.includes('暗') || + conflictTypeText.toLowerCase() === 'hidden' + ? 'hidden' + : 'main'; + const stakes = toText(record.stakes) || toText(record.conflict); + const summary = toText(record.summary); + + if (!title && !stakes && !summary) { + return null; + } + + return { + id: toText(record.id) || createId('thread', title || stakes, index), + title: title || `世界线程 ${index + 1}`, + type, + conflictType: + conflictTypeText || (type === 'hidden' ? '暗线' : '明线'), + conflict: stakes || '这条线仍在等待进一步精修', + stakes: stakes || '这条线仍在等待进一步精修', + characterIds: toStringArray(record.characterIds, 8), + landmarkIds: toStringArray(record.landmarkIds, 8), + summary: + summary || + clampText( + [ + type === 'hidden' ? '暗线' : '明线', + stakes || '主要冲突待细化', + ].join(':'), + 120, + ), + }; +} + +function normalizeChapter( + value: unknown, + index: number, +): CustomWorldFoundationDraftChapter | null { + const record = toRecord(value); + if (!record) { + return null; + } + + const title = toText(record.title); + const openingEvent = toText(record.openingEvent); + const playerGoal = toText(record.playerGoal); + const understandingShift = toText(record.understandingShift); + const summary = toText(record.summary); + + if (!title && !openingEvent && !playerGoal && !summary) { + return null; + } + + return { + id: toText(record.id) || createId('chapter', title || openingEvent, index), + title: title || '第一幕', + openingEvent: openingEvent || '局势在开幕时突然失控', + playerGoal: playerGoal || '先稳住开局并找到下一步目标', + characterIds: toStringArray(record.characterIds, 8), + landmarkIds: toStringArray(record.landmarkIds, 8), + understandingShift: + understandingShift || '玩家会意识到这场冲突远不止表面那一层', + summary: + summary || + clampText( + [ + openingEvent || '开幕事件已逼近', + playerGoal || '玩家需要尽快立住脚跟', + understandingShift || '第一幕会改写玩家对世界的理解', + ].join(';'), + 140, + ), + }; +} + +function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null { + const record = toRecord(value); + if (!record) { + return null; + } + + const name = toText(record.name); + const description = toText(record.description); + const dangerLevel = toText(record.dangerLevel) || toText(record.mood); + const summary = toText(record.summary); + + if (!name && !description && !summary) { + return null; + } + + return { + id: toText(record.id) || 'camp-home', + name: name || '临时落脚处', + description: description || '玩家暂时还能整顿情报和喘口气的地方', + mood: dangerLevel || '克制、紧绷,但还能暂时收拢局势', + dangerLevel: dangerLevel || '克制、紧绷,但还能暂时收拢局势', + summary: + summary || + clampText( + [ + description || '这是玩家当前最稳的回气点', + dangerLevel || '它承担落脚与整理线索的功能', + ].join(';'), + 120, + ), + }; +} + +export function normalizeFoundationDraftProfile( + value: unknown, +): CustomWorldFoundationDraftProfile | null { + const record = toRecord(value); + if (!record) { + return null; + } + + const name = toText(record.name) || toText(record.title); + const summary = toText(record.summary); + const playableNpcs = dedupeById( + toRecordArray(record.playableNpcs) + .map((item, index) => normalizeCharacter(item, index)) + .filter((item): item is CustomWorldFoundationDraftCharacter => + Boolean(item), + ), + ); + const storyNpcs = dedupeById( + toRecordArray(record.storyNpcs) + .map((item, index) => normalizeCharacter(item, index)) + .filter((item): item is CustomWorldFoundationDraftCharacter => + Boolean(item), + ), + ); + const landmarks = dedupeById( + toRecordArray(record.landmarks) + .map((item, index) => normalizeLandmark(item, index)) + .filter((item): item is CustomWorldFoundationDraftLandmark => + Boolean(item), + ), + ); + const factions = dedupeById( + toRecordArray(record.factions) + .map((item, index) => normalizeFaction(item, index)) + .filter((item): item is CustomWorldFoundationDraftFaction => + Boolean(item), + ), + ); + const threads = dedupeById( + toRecordArray(record.threads) + .map((item, index) => normalizeThread(item, index)) + .filter((item): item is CustomWorldFoundationDraftThread => + Boolean(item), + ), + ); + const chapters = dedupeById( + toRecordArray(record.chapters) + .map((item, index) => normalizeChapter(item, index)) + .filter((item): item is CustomWorldFoundationDraftChapter => + Boolean(item), + ), + ); + const camp = normalizeCamp(record.camp); + const hasStructuredFoundationContent = + playableNpcs.length > 0 || + storyNpcs.length > 0 || + landmarks.length > 0 || + factions.length > 0 || + threads.length > 0 || + chapters.length > 0 || + Boolean(camp); + + if (!hasStructuredFoundationContent) { + return null; + } + + const mergedCharacters = dedupeById([...playableNpcs, ...storyNpcs]); + const coreConflicts = toStringArray(record.coreConflicts, 6); + + return { + name: name || '未命名世界底稿', + subtitle: + toText(record.subtitle) || + clampText( + [toText(record.playerPremise), coreConflicts[0] ?? '核心冲突仍在整理'] + .filter(Boolean) + .join(' · '), + 40, + ) || + '第一版世界底稿', + summary: + summary || + clampText( + [ + toText(record.worldHook), + toText(record.playerPremise), + coreConflicts[0] ?? '', + ] + .filter(Boolean) + .join(' '), + 160, + ) || + '第一版世界底稿已经整理完成。', + tone: toText(record.tone) || '整体气质仍可继续精修', + playerGoal: toText(record.playerGoal) || '先站稳开局,再判断下一步', + majorFactions: + toStringArray(record.majorFactions, 6).length > 0 + ? toStringArray(record.majorFactions, 6) + : factions.map((entry) => entry.name), + coreConflicts, + playableNpcs: + playableNpcs.length > 0 + ? playableNpcs + : mergedCharacters.slice(0, Math.max(3, mergedCharacters.length)), + storyNpcs: + storyNpcs.length > 0 + ? storyNpcs + : mergedCharacters.filter( + (entry) => !playableNpcs.some((npc) => npc.id === entry.id), + ), + landmarks, + camp, + themePack: toRecord(record.themePack), + storyGraph: toRecord(record.storyGraph), + factions, + threads, + chapters, + worldHook: toText(record.worldHook) || name || summary, + playerPremise: toText(record.playerPremise), + openingSituation: toText(record.openingSituation), + iconicElements: toStringArray(record.iconicElements, 8), + sourceAnchorSummary: toText(record.sourceAnchorSummary) || summary, + }; +} + +function buildSection( + id: string, + label: string, + value: string, +): CustomWorldDraftCardDetailSection { + return { + id, + label, + value: value.trim() || '待继续精修', + }; +} + +function resolveThreadTypeLabel(type: CustomWorldFoundationDraftThread['type']) { + return type === 'hidden' ? '暗线' : '明线'; +} + +function buildWorldWarnings(profile: CustomWorldFoundationDraftProfile) { + const warnings: string[] = []; + const totalCharacters = dedupeById([ + ...profile.playableNpcs, + ...profile.storyNpcs, + ]).length; + if (profile.iconicElements.length === 0) { + warnings.push('标志性要素还偏少,后续可以补 1 到 2 个记忆点。'); + } + if (totalCharacters < 3) { + warnings.push('关键角色数量还偏少,建议继续补角色关系网。'); + } + if (profile.landmarks.length < 4) { + warnings.push('关键地点仍然偏少,第一版游历路径还不够饱满。'); + } + return warnings; +} + +function buildFactionWarnings(faction: CustomWorldFoundationDraftFaction) { + const warnings: string[] = []; + if (!faction.playerRelation.trim()) { + warnings.push('这个势力和玩家的关系仍可更具体。'); + } + if (!faction.relatedConflict.trim()) { + warnings.push('这个势力还缺少更明确的冲突挂钩。'); + } + return warnings; +} + +function buildCharacterWarnings(character: CustomWorldFoundationDraftCharacter) { + const warnings: string[] = []; + if (!character.relationToPlayer.trim()) { + warnings.push('和玩家的关系钩子还不够明确。'); + } + if (character.threadIds.length === 0) { + warnings.push('这个角色尚未绑定到明确线程。'); + } + return warnings; +} + +function buildLandmarkWarnings(landmark: CustomWorldFoundationDraftLandmark) { + const warnings: string[] = []; + if (landmark.characterIds.length === 0) { + warnings.push('这个地点还没有挂住足够明确的角色。'); + } + if (landmark.threadIds.length === 0) { + warnings.push('这个地点还缺少更清楚的线程挂钩。'); + } + return warnings; +} + +function buildThreadWarnings(thread: CustomWorldFoundationDraftThread) { + const warnings: string[] = []; + if (thread.characterIds.length === 0) { + warnings.push('这条线还缺少更明确的角色挂点。'); + } + if (thread.landmarkIds.length === 0) { + warnings.push('这条线还缺少更明确的地点挂点。'); + } + return warnings; +} + +function buildChapterWarnings(chapter: CustomWorldFoundationDraftChapter) { + const warnings: string[] = []; + if (chapter.characterIds.length < 2) { + warnings.push('第一幕涉及的关键角色还偏少。'); + } + if (chapter.landmarkIds.length < 2) { + warnings.push('第一幕涉及的关键地点还偏少。'); + } + return warnings; +} + +function buildCampWarnings() { + return [] as string[]; +} + +function buildCharacterAssetHeadline(character: CustomWorldFoundationDraftCharacter) { + const assetSummary = buildRoleAssetSummary({ + role: { + id: character.id, + name: character.name, + threadIds: character.threadIds, + imageSrc: character.imageSrc, + generatedVisualAssetId: character.generatedVisualAssetId, + generatedAnimationSetId: character.generatedAnimationSetId, + animationMap: character.animationMap, + }, + roleKind: 'story', + }); + + return { + status: assetSummary.status, + label: resolveRoleAssetStatusLabel(assetSummary.status), + }; +} + +type CompiledCard = { + summary: CustomWorldDraftCardSummary; + detail: CustomWorldDraftCardDetail; +}; + +export class CustomWorldAgentDraftCompiler { + compileDraftCards(profileInput: unknown) { + return this.compile(profileInput).map((entry) => entry.summary); + } + + getDraftCardDetail(profileInput: unknown, cardId: string) { + return ( + this.compile(profileInput).find((entry) => entry.summary.id === cardId) + ?.detail ?? null + ); + } + + private compile(profileInput: unknown): CompiledCard[] { + const profile = normalizeFoundationDraftProfile(profileInput); + if (!profile) { + return []; + } + + const characters = dedupeById([ + ...profile.playableNpcs, + ...profile.storyNpcs, + ]); + const characterById = new Map(characters.map((entry) => [entry.id, entry])); + const landmarkById = new Map(profile.landmarks.map((entry) => [entry.id, entry])); + const threadById = new Map(profile.threads.map((entry) => [entry.id, entry])); + + const resolveCharacterNames = (ids: string[]) => + ids + .map((id) => characterById.get(id)?.name) + .filter((entry): entry is string => Boolean(entry)) + .join('、'); + const resolveLandmarkNames = (ids: string[]) => + ids + .map((id) => landmarkById.get(id)?.name) + .filter((entry): entry is string => Boolean(entry)) + .join('、'); + const resolveThreadTitles = (ids: string[]) => + ids + .map((id) => threadById.get(id)?.title) + .filter((entry): entry is string => Boolean(entry)) + .join('、'); + + const cards: CompiledCard[] = []; + + const pushCard = (params: { + id: string; + kind: CustomWorldDraftCardKind; + title: string; + subtitle: string; + summary: string; + linkedIds: string[]; + sections: CustomWorldDraftCardDetailSection[]; + editableSectionIds?: string[]; + warningMessages: string[]; + assetStatus?: CustomWorldRoleAssetStatus | null; + assetStatusLabel?: string | null; + }) => { + const warningMessages = [...new Set(params.warningMessages.filter(Boolean))]; + const editableSectionIds = params.editableSectionIds ?? []; + cards.push({ + summary: { + id: params.id, + kind: params.kind, + title: params.title, + subtitle: params.subtitle, + summary: clampText(params.summary, 180), + status: warningMessages.length > 0 ? 'warning' : 'suggested', + linkedIds: [...new Set(params.linkedIds.filter(Boolean))], + warningCount: warningMessages.length, + assetStatus: params.assetStatus ?? null, + assetStatusLabel: params.assetStatusLabel ?? null, + }, + detail: { + id: params.id, + kind: params.kind, + title: params.title, + sections: params.sections, + linkedIds: [...new Set(params.linkedIds.filter(Boolean))], + locked: false, + editable: editableSectionIds.length > 0, + editableSectionIds, + warningMessages, + assetStatus: params.assetStatus ?? null, + assetStatusLabel: params.assetStatusLabel ?? null, + }, + }); + }; + + const worldWarnings = buildWorldWarnings(profile); + pushCard({ + id: WORLD_CARD_ID, + kind: 'world', + title: profile.name, + subtitle: + clampText( + [profile.playerPremise, profile.coreConflicts[0] ?? '核心冲突待继续精修'] + .filter(Boolean) + .join(' · '), + 40, + ) || profile.subtitle, + summary: profile.summary, + linkedIds: [ + ...(profile.camp ? [profile.camp.id] : []), + ...profile.factions.map((entry) => entry.id), + ...characters.map((entry) => entry.id), + ...profile.landmarks.map((entry) => entry.id), + ...profile.threads.map((entry) => entry.id), + ...profile.chapters.map((entry) => entry.id), + ].slice(0, 12), + sections: [ + buildSection('title', '标题', profile.name), + buildSection('subtitle', '副标题', profile.subtitle), + buildSection('summary', '摘要', profile.summary), + buildSection('playerGoal', '玩家目标', profile.playerGoal), + buildSection( + 'tone', + '世界气质', + [profile.tone, profile.iconicElements.join('、')] + .filter(Boolean) + .join(' / '), + ), + buildSection( + 'coreConflicts', + '核心冲突', + profile.coreConflicts.join(';') || profile.summary, + ), + buildSection('worldHook', '世界一句话', profile.worldHook || profile.summary), + buildSection( + 'playerPremise', + '玩家是谁', + profile.playerPremise, + ), + ], + editableSectionIds: resolveEditableSectionIds('world'), + warningMessages: worldWarnings, + }); + + if (profile.camp) { + const campWarnings = buildCampWarnings(); + pushCard({ + id: profile.camp.id, + kind: 'camp', + title: profile.camp.name, + subtitle: clampText(profile.camp.mood || '开局落脚处', 28), + summary: profile.camp.summary, + linkedIds: [ + ...profile.landmarks.slice(0, 2).map((entry) => entry.id), + ...characters.slice(0, 2).map((entry) => entry.id), + ...profile.chapters.slice(0, 1).map((entry) => entry.id), + ], + sections: [ + buildSection('name', '营地名称', profile.camp.name), + buildSection('description', '当前定位', profile.camp.description), + buildSection( + 'dangerLevel', + '危险等级', + profile.camp.dangerLevel || profile.camp.mood, + ), + buildSection( + 'linkedObjects', + '关联对象', + [ + resolveLandmarkNames( + profile.landmarks.slice(0, 2).map((entry) => entry.id), + ), + resolveCharacterNames( + characters.slice(0, 2).map((entry) => entry.id), + ), + ] + .filter(Boolean) + .join(';'), + ), + ], + editableSectionIds: resolveEditableSectionIds('camp'), + warningMessages: campWarnings, + }); + } + + profile.threads.forEach((thread) => { + const warnings = buildThreadWarnings(thread); + pushCard({ + id: thread.id, + kind: 'thread', + title: thread.title, + subtitle: resolveThreadTypeLabel(thread.type), + summary: thread.summary, + linkedIds: [...thread.characterIds, ...thread.landmarkIds], + sections: [ + buildSection('title', '线程标题', thread.title), + buildSection('summary', '线程摘要', thread.summary), + buildSection( + 'conflictType', + '冲突类型', + thread.conflictType || resolveThreadTypeLabel(thread.type), + ), + buildSection('stakes', '冲突内容', thread.stakes || thread.conflict), + buildSection( + 'relatedObjects', + '相关对象', + [ + resolveCharacterNames(thread.characterIds), + resolveLandmarkNames(thread.landmarkIds), + ] + .filter(Boolean) + .join(';'), + ), + ], + editableSectionIds: resolveEditableSectionIds('thread'), + warningMessages: warnings, + }); + }); + + profile.factions.forEach((faction) => { + const warnings = buildFactionWarnings(faction); + const linkedThreadIds = profile.threads + .filter( + (thread) => + thread.conflict.includes(faction.name) || + thread.conflict.includes(faction.relatedConflict) || + thread.summary.includes(faction.name), + ) + .map((entry) => entry.id) + .slice(0, 3); + pushCard({ + id: faction.id, + kind: 'faction', + title: faction.title || faction.name, + subtitle: clampText(faction.subtitle || faction.publicGoal, 28), + summary: faction.summary, + linkedIds: linkedThreadIds, + sections: [ + buildSection('title', '势力标题', faction.title || faction.name), + buildSection( + 'subtitle', + '副标题', + faction.subtitle || clampText(faction.publicGoal, 40), + ), + buildSection('summary', '势力摘要', faction.summary), + buildSection('publicGoal', '公开目标', faction.publicGoal), + buildSection('tension', '当前张力', faction.tension || faction.relatedConflict), + buildSection('playerRelation', '玩家关系', faction.playerRelation), + ], + editableSectionIds: resolveEditableSectionIds('faction'), + warningMessages: warnings, + }); + }); + + characters.forEach((character) => { + const warnings = buildCharacterWarnings(character); + const assetHeadline = buildCharacterAssetHeadline(character); + const linkedLandmarks = profile.landmarks + .filter((landmark) => landmark.characterIds.includes(character.id)) + .map((entry) => entry.id) + .slice(0, 3); + pushCard({ + id: character.id, + kind: 'character', + title: character.name, + subtitle: [ + clampText(character.publicMask || character.publicIdentity, 18), + assetHeadline.label, + ] + .filter(Boolean) + .join(' / '), + summary: + assetHeadline.status === 'complete' + ? clampText(`${character.summary}(核心动作已就绪)`, 180) + : assetHeadline.status === 'visual_ready' + ? clampText(`${character.summary}(主图已就绪)`, 180) + : assetHeadline.status === 'animations_ready' + ? clampText(`${character.summary}(动作补齐中)`, 180) + : clampText(`${character.summary}(待生成主图)`, 180), + linkedIds: [...character.threadIds, ...linkedLandmarks].slice(0, 6), + sections: [ + buildSection('name', '角色名', character.name), + buildSection('role', '角色定位', character.role || character.title), + buildSection( + 'publicMask', + '外显身份', + character.publicMask || character.publicIdentity, + ), + buildSection( + 'hiddenHook', + '隐藏钩子', + character.hiddenHook || character.currentPressure, + ), + buildSection('relationToPlayer', '玩家关系', character.relationToPlayer), + buildSection('summary', '角色摘要', character.summary), + buildSection( + 'threadIds', + '关联线程', + resolveThreadTitles(character.threadIds), + ), + ], + editableSectionIds: resolveEditableSectionIds('character'), + warningMessages: warnings, + assetStatus: assetHeadline.status, + assetStatusLabel: assetHeadline.label, + }); + }); + + profile.landmarks.forEach((landmark) => { + const warnings = buildLandmarkWarnings(landmark); + pushCard({ + id: landmark.id, + kind: 'landmark', + title: landmark.name, + subtitle: clampText(landmark.purpose || landmark.mood, 28), + summary: landmark.summary, + linkedIds: [...landmark.characterIds, ...landmark.threadIds].slice(0, 8), + sections: [ + buildSection('name', '地点名', landmark.name), + buildSection('purpose', '地点定位', landmark.purpose), + buildSection('mood', '场景情绪', landmark.mood), + buildSection('secret', '隐藏秘密', landmark.secret || landmark.importance), + buildSection('summary', '地点摘要', landmark.summary), + buildSection( + 'characterIds', + '关联角色', + resolveCharacterNames(landmark.characterIds), + ), + buildSection( + 'threadIds', + '关联线程', + resolveThreadTitles(landmark.threadIds), + ), + ], + editableSectionIds: resolveEditableSectionIds('landmark'), + warningMessages: warnings, + }); + }); + + profile.chapters.forEach((chapter) => { + const warnings = buildChapterWarnings(chapter); + pushCard({ + id: chapter.id, + kind: 'chapter', + title: chapter.title, + subtitle: clampText(chapter.playerGoal, 28), + summary: chapter.summary, + linkedIds: [...chapter.characterIds, ...chapter.landmarkIds].slice(0, 10), + sections: [ + buildSection('title', '章节标题', chapter.title), + buildSection('summary', '章节摘要', chapter.summary), + buildSection('openingEvent', '开幕事件', chapter.openingEvent), + buildSection('playerGoal', '玩家目标', chapter.playerGoal), + buildSection( + 'characterIds', + '第一批角色', + resolveCharacterNames(chapter.characterIds), + ), + buildSection( + 'landmarkIds', + '第一批地点', + resolveLandmarkNames(chapter.landmarkIds), + ), + buildSection( + 'understandingShift', + '第一幕理解变化', + chapter.understandingShift, + ), + ], + editableSectionIds: resolveEditableSectionIds('chapter'), + warningMessages: warnings, + }); + }); + + return cards; + } +} + +export function getWorldFoundationCardId() { + return WORLD_CARD_ID; +} diff --git a/server-node/src/services/customWorldAgentDraftEditService.ts b/server-node/src/services/customWorldAgentDraftEditService.ts new file mode 100644 index 00000000..f01dd7a5 --- /dev/null +++ b/server-node/src/services/customWorldAgentDraftEditService.ts @@ -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; + 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(); + 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; + } + + 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; + } + + 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; + } + + 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; + } + + 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; + } + + 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; + } + + 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; + } + + throw notFound('draft card not found'); +} diff --git a/server-node/src/services/customWorldAgentEntityGenerationService.ts b/server-node/src/services/customWorldAgentEntityGenerationService.ts new file mode 100644 index 00000000..999fd3af --- /dev/null +++ b/server-node/src/services/customWorldAgentEntityGenerationService.ts @@ -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; + 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) + : 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>, +) { + 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>, + 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, 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, 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>, + anchorContext: AnchorContext, + promptSeed: string, + index: number, + existingNames: Set, +): 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>, + anchorContext: AnchorContext, + promptSeed: string, + index: number, + existingNames: Set, +): 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>; + 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>; + return Array.isArray(parsed) ? parsed : []; +} + +async function requestLandmarkSuggestionsFromLlm(params: { + llmClient: UpstreamLlmClient; + profile: NonNullable>; + 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>; + 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> = []; + 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, + 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> = []; + 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, + generatedLandmarks, + }; + } +} diff --git a/server-node/src/services/customWorldAgentFoundationDraftService.ts b/server-node/src/services/customWorldAgentFoundationDraftService.ts new file mode 100644 index 00000000..79662b71 --- /dev/null +++ b/server-node/src/services/customWorldAgentFoundationDraftService.ts @@ -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) + : 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, + }; + } +} diff --git a/server-node/src/services/customWorldAgentIntentExtractionService.ts b/server-node/src/services/customWorldAgentIntentExtractionService.ts new file mode 100644 index 00000000..53ed0116 --- /dev/null +++ b/server-node/src/services/customWorldAgentIntentExtractionService.ts @@ -0,0 +1,1128 @@ +type CustomWorldCreatorInputMode = 'freeform' | 'card'; + +export interface CreatorFactionSeedRecord { + id: string; + name: string; + publicGoal: string; + tension: string; + notes: string; + locked?: boolean; +} + +export interface CreatorCharacterSeedRecord { + id: string; + name: string; + role: string; + publicMask: string; + hiddenHook: string; + relationToPlayer: string; + notes: string; + locked?: boolean; +} + +export interface CreatorLandmarkSeedRecord { + id: string; + name: string; + purpose: string; + mood: string; + secret: string; + locked?: boolean; +} + +export interface CustomWorldCreatorIntentRecord { + sourceMode: CustomWorldCreatorInputMode; + rawSettingText: string; + worldHook: string; + themeKeywords: string[]; + toneDirectives: string[]; + playerPremise: string; + openingSituation: string; + coreConflicts: string[]; + keyFactions: CreatorFactionSeedRecord[]; + keyCharacters: CreatorCharacterSeedRecord[]; + keyLandmarks: CreatorLandmarkSeedRecord[]; + iconicElements: string[]; + forbiddenDirectives: string[]; +} + +export type ExtractedCreatorIntentPatch = Partial< + Pick< + CustomWorldCreatorIntentRecord, + | 'rawSettingText' + | 'worldHook' + | 'themeKeywords' + | 'toneDirectives' + | 'playerPremise' + | 'openingSituation' + | 'coreConflicts' + | 'keyFactions' + | 'keyCharacters' + | 'keyLandmarks' + | 'iconicElements' + | 'forbiddenDirectives' + > +> & { + replaceFields?: Array< + | 'rawSettingText' + | 'worldHook' + | 'themeKeywords' + | 'toneDirectives' + | 'playerPremise' + | 'openingSituation' + | 'coreConflicts' + | 'keyFactions' + | 'keyCharacters' + | 'keyLandmarks' + | 'iconicElements' + | 'forbiddenDirectives' + >; +}; + +const THEME_LEXICON = [ + '武侠', + '修仙', + '仙侠', + '赛博', + '蒸汽', + '废土', + '悬疑', + '宫廷', + '海岛', + '边境', + '宗教', + '朝堂', + '奇谭', + '妖异', + '科幻', + '神秘', + '冒险', + '克苏鲁', + '侦探', +]; + +const TONE_LEXICON = [ + '冷峻', + '克制', + '压抑', + '浪漫', + '潮湿', + '荒凉', + '悬疑', + '紧张', + '明快', + '史诗', + '残酷', + '诡异', + '黑暗', + '肃杀', + '温柔', + '宏大', + '宿命', + '神秘', +]; + +const RELATIONSHIP_TERMS = [ + '宿敌', + '盟友', + '导师', + '师父', + '搭档', + '同伴', + '恋人', + '家人', + '兄弟', + '姐妹', + '父亲', + '母亲', + '向导', + '引路人', + '守望者', + '巡夜人', +]; + +const META_MESSAGE_PATTERN = + /^(请)?(总结|梳理|归纳|收一下|概括)|继续补充锚点|继续收集锚点/u; + +function toText(value: unknown) { + return typeof value === 'string' ? value.replace(/\s+/gu, ' ').trim() : ''; +} + +function toStringArray(value: unknown, maxCount = 8) { + if (!Array.isArray(value)) { + return []; + } + + return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice( + 0, + maxCount, + ); +} + +function slugify(value: string) { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-') + .replace(/^-+|-+$/gu, ''); + + return normalized || 'entry'; +} + +function createSeedId(prefix: string, label: string, index: number) { + return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; +} + +function clampText(value: string, maxLength: number) { + const normalized = value.trim().replace(/\s+/gu, ' '); + if (!normalized) { + return ''; + } + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; +} + +function splitSentences(text: string) { + return text + .split(/[。!?;\n]/u) + .map((sentence) => sentence.trim()) + .filter(Boolean); +} + +function splitList(text: string, maxCount = 8) { + const normalized = text + .replace(/[“”"'`]/gu, '') + .replace(/^(改成|改为|换成|重设|重新设定|覆盖为|更新为)/u, '') + .replace(/^(包括|比如|例如|像是|例如说)/u, '') + .replace(/^(是|为|有|偏|走|要|想要)/u, '') + .trim(); + + if (!normalized) { + return []; + } + + return [ + ...new Set( + normalized + .split(/[、,,\/|;;]/u) + .map((item) => item.trim()) + .filter((item) => item.length >= 2 && item.length <= 24), + ), + ].slice(0, maxCount); +} + +function mergeStringArray( + base: string[], + patch: string[] | undefined, + maxCount: number, +) { + if (!patch || patch.length === 0) { + return [...base]; + } + + return [ + ...new Set([...base, ...patch.map((item) => toText(item)).filter(Boolean)]), + ].slice(0, maxCount); +} + +function mergeNarrativeText(base: string, patch: string | undefined) { + const nextText = toText(patch); + if (!nextText) { + return base; + } + if (!base) { + return nextText; + } + if (base.includes(nextText)) { + return base; + } + + return `${base}\n${nextText}`.trim(); +} + +function normalizeCreatorFactionSeed( + value: unknown, + index: number, +): CreatorFactionSeedRecord | null { + if (!value || typeof value !== 'object') { + return null; + } + + const item = value as Record; + const name = toText(item.name); + const publicGoal = toText(item.publicGoal); + const tension = toText(item.tension); + const notes = toText(item.notes); + + if (!name && !publicGoal && !tension && !notes) { + return null; + } + + return { + id: + toText(item.id) || + createSeedId('creator-faction', name || publicGoal, index), + name, + publicGoal, + tension, + notes, + locked: Boolean(item.locked), + }; +} + +function normalizeCreatorCharacterSeed( + value: unknown, + index: number, +): CreatorCharacterSeedRecord | null { + if (!value || typeof value !== 'object') { + return null; + } + + const item = value as Record; + const name = toText(item.name); + const role = toText(item.role); + const publicMask = toText(item.publicMask); + const hiddenHook = toText(item.hiddenHook); + const relationToPlayer = toText(item.relationToPlayer); + const notes = toText(item.notes); + + if ( + !name && + !role && + !publicMask && + !hiddenHook && + !relationToPlayer && + !notes + ) { + return null; + } + + return { + id: + toText(item.id) || + createSeedId('creator-character', name || role || publicMask, index), + name, + role, + publicMask, + hiddenHook, + relationToPlayer, + notes, + locked: Boolean(item.locked), + }; +} + +function normalizeCreatorLandmarkSeed( + value: unknown, + index: number, +): CreatorLandmarkSeedRecord | null { + if (!value || typeof value !== 'object') { + return null; + } + + const item = value as Record; + const name = toText(item.name); + const purpose = toText(item.purpose); + const mood = toText(item.mood); + const secret = toText(item.secret); + + if (!name && !purpose && !mood && !secret) { + return null; + } + + return { + id: + toText(item.id) || + createSeedId('creator-landmark', name || purpose || mood, index), + name, + purpose, + mood, + secret, + locked: Boolean(item.locked), + }; +} + +function normalizeAnchorArray( + value: unknown, + normalizer: (value: unknown, index: number) => T | null, + maxCount: number, +) { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item, index) => normalizer(item, index)) + .filter((item): item is T => Boolean(item)) + .slice(0, maxCount); +} + +function mergeSeedArray( + base: T[], + patch: T[] | undefined, + maxCount: number, + mergeEntry: (current: T, next: T) => T, +) { + if (!patch || patch.length === 0) { + return [...base]; + } + + const nextItems = [...base]; + + patch.forEach((entry) => { + const normalizedName = toText(entry.name); + const existingIndex = nextItems.findIndex( + (item) => + item.id === entry.id || + (normalizedName && + toText(item.name).toLowerCase() === normalizedName.toLowerCase()), + ); + + if (existingIndex >= 0) { + nextItems[existingIndex] = mergeEntry(nextItems[existingIndex], entry); + return; + } + + nextItems.push(entry); + }); + + return nextItems.slice(0, maxCount); +} + +function mergeCharacterSeed( + current: CreatorCharacterSeedRecord, + next: CreatorCharacterSeedRecord, +): CreatorCharacterSeedRecord { + return { + ...current, + ...next, + id: next.id || current.id, + name: toText(next.name) || current.name, + role: toText(next.role) || current.role, + publicMask: toText(next.publicMask) || current.publicMask, + hiddenHook: toText(next.hiddenHook) || current.hiddenHook, + relationToPlayer: toText(next.relationToPlayer) || current.relationToPlayer, + notes: toText(next.notes) || current.notes, + locked: + typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked), + }; +} + +function mergeFactionSeed( + current: CreatorFactionSeedRecord, + next: CreatorFactionSeedRecord, +): CreatorFactionSeedRecord { + return { + ...current, + ...next, + id: next.id || current.id, + name: toText(next.name) || current.name, + publicGoal: toText(next.publicGoal) || current.publicGoal, + tension: toText(next.tension) || current.tension, + notes: toText(next.notes) || current.notes, + locked: + typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked), + }; +} + +function mergeLandmarkSeed( + current: CreatorLandmarkSeedRecord, + next: CreatorLandmarkSeedRecord, +): CreatorLandmarkSeedRecord { + return { + ...current, + ...next, + id: next.id || current.id, + name: toText(next.name) || current.name, + purpose: toText(next.purpose) || current.purpose, + mood: toText(next.mood) || current.mood, + secret: toText(next.secret) || current.secret, + locked: + typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked), + }; +} + +export function createEmptyCreatorIntentRecord( + sourceMode: CustomWorldCreatorInputMode = 'freeform', +): CustomWorldCreatorIntentRecord { + return { + sourceMode, + rawSettingText: '', + worldHook: '', + themeKeywords: [], + toneDirectives: [], + playerPremise: '', + openingSituation: '', + coreConflicts: [], + keyFactions: [], + keyCharacters: [], + keyLandmarks: [], + iconicElements: [], + forbiddenDirectives: [], + }; +} + +export function normalizeCreatorIntentRecord( + value: unknown, + fallbackMode: CustomWorldCreatorInputMode = 'freeform', +): CustomWorldCreatorIntentRecord | null { + if (!value || typeof value !== 'object') { + return null; + } + + const item = value as Record; + const sourceMode = + item.sourceMode === 'card' || item.sourceMode === 'freeform' + ? item.sourceMode + : fallbackMode; + const rawSettingText = toText(item.rawSettingText); + const worldHook = toText(item.worldHook); + const playerPremise = toText(item.playerPremise); + const openingSituation = toText(item.openingSituation); + const themeKeywords = toStringArray(item.themeKeywords, 8); + const toneDirectives = toStringArray(item.toneDirectives, 8); + const coreConflicts = toStringArray(item.coreConflicts, 6); + const iconicElements = toStringArray(item.iconicElements, 8); + const forbiddenDirectives = toStringArray(item.forbiddenDirectives, 8); + const keyFactions = normalizeAnchorArray( + item.keyFactions, + normalizeCreatorFactionSeed, + 6, + ); + const keyCharacters = normalizeAnchorArray( + item.keyCharacters, + normalizeCreatorCharacterSeed, + 8, + ); + const keyLandmarks = normalizeAnchorArray( + item.keyLandmarks, + normalizeCreatorLandmarkSeed, + 8, + ); + + if ( + !rawSettingText && + !worldHook && + themeKeywords.length === 0 && + toneDirectives.length === 0 && + !playerPremise && + !openingSituation && + coreConflicts.length === 0 && + keyFactions.length === 0 && + keyCharacters.length === 0 && + keyLandmarks.length === 0 && + iconicElements.length === 0 && + forbiddenDirectives.length === 0 + ) { + return null; + } + + return { + sourceMode, + rawSettingText, + worldHook, + themeKeywords, + toneDirectives, + playerPremise, + openingSituation, + coreConflicts, + keyFactions, + keyCharacters, + keyLandmarks, + iconicElements, + forbiddenDirectives, + }; +} + +export function mergeCreatorIntentRecord( + current: CustomWorldCreatorIntentRecord | null | undefined, + patch: ExtractedCreatorIntentPatch | null | undefined, + fallbackMode: CustomWorldCreatorInputMode = 'freeform', +) { + if (!patch) { + return ( + normalizeCreatorIntentRecord(current, fallbackMode) ?? + createEmptyCreatorIntentRecord(fallbackMode) + ); + } + + const base = + normalizeCreatorIntentRecord(current, fallbackMode) ?? + createEmptyCreatorIntentRecord(fallbackMode); + const replaceFields = new Set(patch.replaceFields ?? []); + const patchIntent = + normalizeCreatorIntentRecord( + { + sourceMode: base.sourceMode, + ...patch, + }, + base.sourceMode, + ) ?? createEmptyCreatorIntentRecord(base.sourceMode); + + return { + ...base, + rawSettingText: replaceFields.has('rawSettingText') + ? toText(patchIntent.rawSettingText) || base.rawSettingText + : mergeNarrativeText(base.rawSettingText, patchIntent.rawSettingText), + worldHook: toText(patchIntent.worldHook) || base.worldHook, + themeKeywords: replaceFields.has('themeKeywords') + ? [...patchIntent.themeKeywords] + : mergeStringArray(base.themeKeywords, patchIntent.themeKeywords, 8), + toneDirectives: replaceFields.has('toneDirectives') + ? [...patchIntent.toneDirectives] + : mergeStringArray(base.toneDirectives, patchIntent.toneDirectives, 8), + playerPremise: toText(patchIntent.playerPremise) || base.playerPremise, + openingSituation: + toText(patchIntent.openingSituation) || base.openingSituation, + coreConflicts: replaceFields.has('coreConflicts') + ? [...patchIntent.coreConflicts] + : mergeStringArray(base.coreConflicts, patchIntent.coreConflicts, 6), + keyFactions: replaceFields.has('keyFactions') + ? [...patchIntent.keyFactions] + : mergeSeedArray( + base.keyFactions, + patchIntent.keyFactions, + 6, + mergeFactionSeed, + ), + keyCharacters: replaceFields.has('keyCharacters') + ? [...patchIntent.keyCharacters] + : mergeSeedArray( + base.keyCharacters, + patchIntent.keyCharacters, + 8, + mergeCharacterSeed, + ), + keyLandmarks: replaceFields.has('keyLandmarks') + ? [...patchIntent.keyLandmarks] + : mergeSeedArray( + base.keyLandmarks, + patchIntent.keyLandmarks, + 8, + mergeLandmarkSeed, + ), + iconicElements: replaceFields.has('iconicElements') + ? [...patchIntent.iconicElements] + : mergeStringArray(base.iconicElements, patchIntent.iconicElements, 8), + forbiddenDirectives: replaceFields.has('forbiddenDirectives') + ? [...patchIntent.forbiddenDirectives] + : mergeStringArray( + base.forbiddenDirectives, + patchIntent.forbiddenDirectives, + 8, + ), + } satisfies CustomWorldCreatorIntentRecord; +} + +export function hasMeaningfulCreatorIntentRecord( + intent: CustomWorldCreatorIntentRecord | null | undefined, +) { + return Boolean( + intent && + (intent.rawSettingText || + intent.worldHook || + intent.themeKeywords.length > 0 || + intent.toneDirectives.length > 0 || + intent.playerPremise || + intent.openingSituation || + intent.coreConflicts.length > 0 || + intent.keyFactions.length > 0 || + intent.keyCharacters.length > 0 || + intent.keyLandmarks.length > 0 || + intent.iconicElements.length > 0 || + intent.forbiddenDirectives.length > 0), + ); +} + +function buildAnchorLine(label: string, content: string) { + return content ? `${label}:${content}` : ''; +} + +export function buildCreatorIntentDisplayText( + intent: CustomWorldCreatorIntentRecord | null | undefined, +) { + if (!hasMeaningfulCreatorIntentRecord(intent)) { + return ''; + } + + const lines = [ + intent?.worldHook ? `世界一句话:${intent.worldHook}` : '', + buildAnchorLine('玩家身份', intent?.playerPremise || ''), + buildAnchorLine('开局处境', intent?.openingSituation || ''), + buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''), + buildAnchorLine( + '主题气质', + [...(intent?.themeKeywords ?? []), ...(intent?.toneDirectives ?? [])] + .filter(Boolean) + .join('、'), + ), + buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''), + ].filter(Boolean); + + return lines.join('\n'); +} + +export function buildDraftTitleFromIntent( + intent: CustomWorldCreatorIntentRecord | null | undefined, +) { + return ( + clampText(intent?.worldHook || '', 24) || + clampText(intent?.rawSettingText || '', 24) || + '未命名草稿' + ); +} + +export function buildDraftSummaryFromIntent( + intent: CustomWorldCreatorIntentRecord | null | undefined, +) { + const summary = buildCreatorIntentDisplayText(intent); + if (summary) { + return clampText(summary.replace(/\n+/gu, ' · '), 180); + } + + return ( + clampText(intent?.rawSettingText || '', 180) || '还在收集你的世界锚点。' + ); +} + +export function buildAnchorPackFromIntent( + intent: CustomWorldCreatorIntentRecord | null | undefined, + options: { + completedKeys?: string[]; + missingKeys?: string[]; + } = {}, +) { + return { + worldSummary: clampText( + intent?.worldHook || intent?.rawSettingText || '', + 96, + ), + creatorIntentSummary: clampText(buildDraftSummaryFromIntent(intent), 180), + completedKeys: [...(options.completedKeys ?? [])], + missingKeys: [...(options.missingKeys ?? [])], + keyCharacterAnchors: + intent?.keyCharacters.map((entry) => ({ + id: entry.id, + name: entry.name || '未命名关键人物', + summary: clampText( + [entry.role, entry.relationToPlayer, entry.hiddenHook] + .filter(Boolean) + .join(';'), + 60, + ), + })) ?? [], + motifDirectives: [ + ...(intent?.themeKeywords ?? []), + ...(intent?.toneDirectives ?? []), + ...(intent?.iconicElements ?? []), + ].slice(0, 12), + }; +} + +function findSentenceByPattern(text: string, pattern: RegExp) { + return splitSentences(text).find((sentence) => pattern.test(sentence)) ?? ''; +} + +function extractAfterCue(text: string, cues: string[]) { + const sentences = splitSentences(text); + + for (const sentence of sentences) { + for (const cue of cues) { + const index = sentence.indexOf(cue); + if (index < 0) { + continue; + } + + const candidate = sentence + .slice(index + cue.length) + .replace(/^[::,,是为偏走要想]+/u, '') + .trim(); + if (candidate) { + return candidate; + } + } + } + + return ''; +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); +} + +function isExplicitRewrite(text: string, cues: string[]) { + const rewritePattern = /(改成|改为|换成|重设|重新设定|覆盖为|更新为)/u; + + return cues.some((cue) => { + const escapedCue = escapeRegExp(cue); + return ( + new RegExp( + `${escapedCue}[^。!?;\\n]{0,28}${rewritePattern.source}`, + 'u', + ).test(text) || + new RegExp( + `${rewritePattern.source}[^。!?;\\n]{0,28}${escapedCue}`, + 'u', + ).test(text) + ); + }); +} + +function extractWorldHook(text: string, contextText: string) { + const explicit = + extractAfterCue(text, ['世界一句话', '核心幻想', '一句话概括', '一句话']) || + extractAfterCue(text, ['这个世界', '整体设定', '世界设定']); + if (explicit) { + return clampText(explicit, 72); + } + + const firstSentence = splitSentences(contextText)[0] ?? ''; + if (firstSentence.length >= 8 && !META_MESSAGE_PATTERN.test(firstSentence)) { + return clampText(firstSentence, 72); + } + + return ''; +} + +function extractThemeKeywords(text: string) { + const explicitSource = extractAfterCue(text, [ + '主题关键词', + '关键词', + '主题', + '题材', + ]).replace( + /(?:,|,).*(气质|风格|基调|氛围|核心冲突|冲突|玩家|开局|不要|避免).*/u, + '', + ); + const explicit = splitList(explicitSource); + const inferred = THEME_LEXICON.filter((entry) => text.includes(entry)); + return [...new Set([...explicit, ...inferred])].slice(0, 8); +} + +function extractToneDirectives(text: string) { + const explicit = splitList( + extractAfterCue(text, ['气质', '风格', '基调', '氛围', '风味']), + ); + const inferred = TONE_LEXICON.filter((entry) => text.includes(entry)); + return [...new Set([...explicit, ...inferred])].slice(0, 8); +} + +function extractPlayerPremise(text: string) { + const explicit = + extractAfterCue(text, [ + '玩家是', + '玩家身份是', + '主角是', + '你扮演', + '玩家身份', + ]) || findSentenceByPattern(text, /(玩家|主角|你扮演|身份|视角)/u); + + return clampText(explicit, 96); +} + +function extractOpeningSituation(text: string) { + const explicit = + extractAfterCue(text, [ + '开局是', + '开局', + '故事开场', + '开场', + '一开始', + '起始', + ]) || findSentenceByPattern(text, /(开局|开场|一开始|故事开始|起始|初始)/u); + + return clampText(explicit, 96); +} + +function extractCoreConflicts(text: string) { + const explicit = splitList( + extractAfterCue(text, ['核心冲突', '冲突', '危机', '主要矛盾']), + 6, + ); + const inferred = splitSentences(text) + .filter((sentence) => + /(冲突|危机|争夺|战争|对抗|灾变|失衡|威胁|追杀|背叛|悬念)/u.test( + sentence, + ), + ) + .map((sentence) => clampText(sentence, 72)); + + return [...new Set([...explicit, ...inferred])].slice(0, 6); +} + +function extractForbiddenDirectives(text: string) { + return splitSentences(text) + .filter((sentence) => /(不要|避免|禁止|不能|别出现)/u.test(sentence)) + .map((sentence) => + clampText( + sentence.replace(/^(不要|避免|禁止|不能|别出现)/u, '').trim() || + sentence, + 48, + ), + ) + .filter(Boolean) + .slice(0, 8); +} + +function extractIconicElements(text: string) { + const explicit = splitList( + extractAfterCue(text, [ + '标志性元素', + '标志性要素', + '标志元素', + '视觉符号', + '核心意象', + '一眼能认出来的设定', + ]), + ); + + return explicit.slice(0, 8); +} + +function extractCharacterName(sentence: string) { + const matchers = [ + /叫([A-Za-z0-9\u4e00-\u9fa5·-]{2,12})/u, + /名为([A-Za-z0-9\u4e00-\u9fa5·-]{2,12})/u, + /([A-Za-z0-9\u4e00-\u9fa5·-]{2,12})(?:是|作为|担任)/u, + ]; + + for (const matcher of matchers) { + const matched = sentence.match(matcher); + const candidate = toText(matched?.[1]); + if ( + candidate && + !['玩家', '主角', '世界', '故事', '开局', '气质'].includes(candidate) + ) { + return candidate; + } + } + + return ''; +} + +function extractRelationToPlayer(sentence: string) { + const explicit = sentence.match( + /(与玩家[^,。;]+|和玩家[^,。;]+|对玩家[^,。;]+|主角的[^,。;]+)/u, + ); + if (explicit?.[1]) { + return clampText(explicit[1], 48); + } + + const relationKeyword = RELATIONSHIP_TERMS.find((entry) => + sentence.includes(entry), + ); + return relationKeyword ?? ''; +} + +function extractHiddenHook(sentence: string) { + const explicit = sentence.match( + /(其实[^,。;]+|暗地里[^,。;]+|暗线是[^,。;]+|秘密是[^,。;]+|真实身份[^,。;]+|真正目的[^,。;]+)/u, + ); + + return clampText(toText(explicit?.[1]), 64); +} + +function extractRole(sentence: string, name: string) { + if (!name) { + return ''; + } + + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + const matcher = new RegExp(`${escapedName}(?:是|作为|担任)([^,。;]+)`, 'u'); + const matched = sentence.match(matcher); + return clampText(toText(matched?.[1]), 48); +} + +function extractCharacterSeeds(text: string) { + const candidateSentences = splitSentences(text).filter((sentence) => + /(关键人物|关键角色|人物|角色|宿敌|盟友|导师|搭档|同伴|恋人|家人|与玩家|对玩家)/u.test( + sentence, + ), + ); + + return candidateSentences + .map((sentence, index) => { + const name = extractCharacterName(sentence); + const relationToPlayer = extractRelationToPlayer(sentence); + const hiddenHook = extractHiddenHook(sentence); + const role = extractRole(sentence, name); + + if (!name && !role && !relationToPlayer && !hiddenHook) { + return null; + } + + return { + id: createSeedId( + 'creator-character', + name || role || hiddenHook, + index, + ), + name, + role, + publicMask: '', + hiddenHook, + relationToPlayer, + notes: '', + } satisfies CreatorCharacterSeedRecord; + }) + .filter((entry): entry is CreatorCharacterSeedRecord => Boolean(entry)) + .slice(0, 3); +} + +function shouldAppendRawSettingText(text: string) { + return text.length >= 8 && !META_MESSAGE_PATTERN.test(text); +} + +export function extractCreatorIntentPatch(params: { + currentIntent: CustomWorldCreatorIntentRecord | null | undefined; + latestUserMessage: string; + recentMessages?: string[]; +}) { + const currentIntent = + normalizeCreatorIntentRecord(params.currentIntent) ?? + createEmptyCreatorIntentRecord('freeform'); + const latestUserMessage = toText(params.latestUserMessage); + const recentMessages = (params.recentMessages ?? []) + .map((entry) => toText(entry)) + .filter(Boolean) + .slice(-10); + const contextText = [...recentMessages, latestUserMessage].join('\n'); + + if (!latestUserMessage) { + return {} satisfies ExtractedCreatorIntentPatch; + } + + const patch: ExtractedCreatorIntentPatch = {}; + const markReplace = ( + field: NonNullable[number], + ) => { + patch.replaceFields = [...new Set([...(patch.replaceFields ?? []), field])]; + }; + + if (shouldAppendRawSettingText(latestUserMessage)) { + patch.rawSettingText = latestUserMessage; + } + + const worldHook = extractWorldHook( + latestUserMessage, + currentIntent.worldHook ? latestUserMessage : contextText, + ); + if (worldHook) { + patch.worldHook = worldHook; + if ( + isExplicitRewrite(latestUserMessage, [ + '世界一句话', + '核心幻想', + '一句话概括', + '这个世界', + '世界设定', + ]) + ) { + markReplace('worldHook'); + } + } + + const themeKeywords = extractThemeKeywords(latestUserMessage); + if (themeKeywords.length > 0) { + patch.themeKeywords = themeKeywords; + if ( + isExplicitRewrite(latestUserMessage, [ + '主题关键词', + '关键词', + '主题', + '题材', + ]) + ) { + markReplace('themeKeywords'); + } + } + + const toneDirectives = extractToneDirectives(latestUserMessage); + if (toneDirectives.length > 0) { + patch.toneDirectives = toneDirectives; + if ( + isExplicitRewrite(latestUserMessage, ['气质', '风格', '基调', '氛围']) + ) { + markReplace('toneDirectives'); + } + } + + const playerPremise = extractPlayerPremise(latestUserMessage); + if (playerPremise) { + patch.playerPremise = playerPremise; + if ( + isExplicitRewrite(latestUserMessage, ['玩家', '玩家身份', '主角', '身份']) + ) { + markReplace('playerPremise'); + } + } + + const openingSituation = extractOpeningSituation(latestUserMessage); + if (openingSituation) { + patch.openingSituation = openingSituation; + if ( + isExplicitRewrite(latestUserMessage, ['开局', '故事开场', '开场', '起始']) + ) { + markReplace('openingSituation'); + } + } + + const coreConflicts = extractCoreConflicts(latestUserMessage); + if (coreConflicts.length > 0) { + patch.coreConflicts = coreConflicts; + if ( + isExplicitRewrite(latestUserMessage, [ + '核心冲突', + '冲突', + '危机', + '主要矛盾', + ]) + ) { + markReplace('coreConflicts'); + } + } + + const keyCharacters = extractCharacterSeeds(latestUserMessage); + if (keyCharacters.length > 0) { + patch.keyCharacters = keyCharacters; + if ( + isExplicitRewrite(latestUserMessage, [ + '关键人物', + '关键角色', + '人物', + '角色', + ]) + ) { + markReplace('keyCharacters'); + } + } + + const iconicElements = extractIconicElements(latestUserMessage); + if (iconicElements.length > 0) { + patch.iconicElements = iconicElements; + if ( + isExplicitRewrite(latestUserMessage, [ + '标志性元素', + '标志性要素', + '标志元素', + '视觉符号', + '核心意象', + ]) + ) { + markReplace('iconicElements'); + } + } + + const forbiddenDirectives = extractForbiddenDirectives(latestUserMessage); + if (forbiddenDirectives.length > 0) { + patch.forbiddenDirectives = forbiddenDirectives; + if ( + isExplicitRewrite(latestUserMessage, ['禁忌', '禁止事项', '不要', '避免']) + ) { + markReplace('forbiddenDirectives'); + } + } + + return patch; +} diff --git a/server-node/src/services/customWorldAgentOrchestrator.ts b/server-node/src/services/customWorldAgentOrchestrator.ts new file mode 100644 index 00000000..86190c53 --- /dev/null +++ b/server-node/src/services/customWorldAgentOrchestrator.ts @@ -0,0 +1,1625 @@ +import crypto from 'node:crypto'; + +import type { + CreateCustomWorldAgentSessionRequest, + CustomWorldAgentActionRequest, + CustomWorldAgentActionResponse, + CustomWorldAgentMessage, + CustomWorldAgentOperationRecord, + CustomWorldAgentSessionSnapshot, + CustomWorldDraftCardSummary, + CustomWorldPendingClarification, + CustomWorldSuggestedAction, + SendCustomWorldAgentMessageRequest, + SendCustomWorldAgentMessageResponse, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import { badRequest, notFound } from '../errors.js'; +import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js'; +import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js'; +import { + buildPendingClarifications, + evaluateCreatorIntentReadiness, + resolveCreatorIntentStage, +} from './customWorldAgentClarificationService.js'; +import { + CustomWorldAgentDraftCompiler, + getWorldFoundationCardId, + normalizeFoundationDraftProfile, +} from './customWorldAgentDraftCompiler.js'; +import { updateDraftCardSections } from './customWorldAgentDraftEditService.js'; +import { CustomWorldAgentEntityGenerationService } from './customWorldAgentEntityGenerationService.js'; +import { CustomWorldAgentFoundationDraftService } from './customWorldAgentFoundationDraftService.js'; +import { + buildAnchorPackFromIntent, + buildCreatorIntentDisplayText, + buildDraftSummaryFromIntent, + buildDraftTitleFromIntent, + createEmptyCreatorIntentRecord, + type CustomWorldCreatorIntentRecord, + extractCreatorIntentPatch, + mergeCreatorIntentRecord, + normalizeCreatorIntentRecord, +} from './customWorldAgentIntentExtractionService.js'; +import { + type CustomWorldAgentSessionRecord, + CustomWorldAgentSessionStore, +} from './customWorldAgentSessionStore.js'; +import { + rebuildRoleAssetCoverage, + resolveRoleAssetStatusLabel, +} from './customWorldAgentRoleAssetStateService.js'; +import type { UpstreamLlmClient } from './llmClient.js'; + +const PHASE2_FORCE_FAIL_TOKEN = '__phase1_force_fail__'; +const AUTO_COMPLETE_PATTERN = /自动补全|默认方案|帮我补全/u; + +function truncateText(value: string, maxLength: number) { + if (value.length <= maxLength) { + return value; + } + + return `${value.slice(0, Math.max(0, maxLength - 1)).trim()}…`; +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function buildSuggestedActions(params: { + stage?: CustomWorldAgentSessionRecord['stage']; + isReady?: boolean; + draftProfile?: unknown; + draftCards?: CustomWorldDraftCardSummary[]; +} = {}): CustomWorldSuggestedAction[] { + const profile = normalizeFoundationDraftProfile(params.draftProfile); + const actions: CustomWorldSuggestedAction[] = [ + { + id: 'request_summary', + type: 'request_summary', + label: + params.stage === 'object_refining' || params.stage === 'visual_refining' + ? '总结当前世界底稿' + : '总结当前设定', + }, + ]; + + if (params.stage === 'foundation_review' && params.isReady) { + actions.push({ + id: 'draft_foundation', + type: 'draft_foundation', + label: '整理一版世界底稿', + }); + return actions; + } + + if ( + (params.stage === 'object_refining' || params.stage === 'visual_refining') && + profile + ) { + const worldCardId = + params.draftCards?.find((entry) => entry.kind === 'world')?.id ?? + getWorldFoundationCardId(); + const firstCharacter = [...profile.playableNpcs, ...profile.storyNpcs][0]; + const firstLandmark = profile.landmarks[0]; + + actions.push({ + id: 'refine_world', + type: 'refine_focus_target', + label: '先看世界总卡', + targetId: worldCardId, + }); + + if (firstCharacter) { + actions.push({ + id: `refine-character-${firstCharacter.id}`, + type: 'refine_focus_target', + label: `精修角色:${firstCharacter.name}`, + targetId: firstCharacter.id, + }); + } + + if (firstLandmark) { + actions.push({ + id: `refine-landmark-${firstLandmark.id}`, + type: 'refine_focus_target', + label: `继续补地点:${firstLandmark.name}`, + targetId: firstLandmark.id, + }); + } + } + + return actions; +} + +function buildAutoCompletePatch(intent: CustomWorldCreatorIntentRecord) { + return { + worldHook: + intent.worldHook || + intent.rawSettingText || + '一个被未知异象改变秩序的边境世界。', + playerPremise: intent.playerPremise || '玩家是被卷入核心危机的返乡者。', + openingSituation: + intent.openingSituation || '开局时,玩家正抵达危机爆发的现场。', + themeKeywords: + intent.themeKeywords.length > 0 ? intent.themeKeywords : ['奇幻'], + toneDirectives: + intent.toneDirectives.length > 0 ? intent.toneDirectives : ['悬疑'], + coreConflicts: + intent.coreConflicts.length > 0 + ? intent.coreConflicts + : ['旧秩序与新威胁正在争夺世界的未来。'], + keyCharacters: + intent.keyCharacters.length > 0 + ? intent.keyCharacters + : [ + { + id: 'auto-key-character-1', + name: '未命名关键人物', + role: '关键关系', + publicMask: '看似能帮助玩家的人。', + hiddenHook: '掌握一条会改变局势的暗线。', + relationToPlayer: '旧识', + notes: '自动补全,可继续修改。', + }, + ], + iconicElements: + intent.iconicElements.length > 0 ? intent.iconicElements : ['失落信标'], + }; +} + +function buildOperation(type: CustomWorldAgentOperationRecord['type']) { + const phaseDetail = + type === 'draft_foundation' + ? '正在把已确认锚点编成第一版世界底稿。' + : type === 'update_draft_card' + ? '正在把这次设定改动写回草稿。' + : type === 'generate_characters' + ? '正在围绕当前底稿补出新角色。' + : type === 'generate_landmarks' + ? '正在围绕当前底稿补出新地点。' + : type === 'generate_role_assets' + ? '正在准备角色资产工坊入口。' + : type === 'sync_role_assets' + ? '正在把角色资产结果写回世界草稿。' + : '正在整理这一轮新增的世界锚点。'; + + return { + operationId: `operation-${crypto.randomBytes(10).toString('hex')}`, + type, + status: 'queued', + phaseLabel: '已接收请求', + phaseDetail, + progress: 10, + error: null, + } satisfies CustomWorldAgentOperationRecord; +} + +function buildUserMessage( + text: string, + clientMessageId: string, +): CustomWorldAgentMessage { + return { + id: + clientMessageId.trim() || + `message-${crypto.randomBytes(8).toString('hex')}`, + role: 'user', + kind: 'chat', + text, + createdAt: new Date().toISOString(), + relatedOperationId: null, + }; +} + +function buildRoleAssetSyncResultText(params: { + roleName: string; + assetStatusLabel: string; +}) { + return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}。`; +} + +function getRecentUserMessages(session: CustomWorldAgentSessionRecord) { + return session.messages + .filter((message) => message.role === 'user') + .map((message) => message.text.trim()) + .filter(Boolean) + .slice(-12); +} + +function buildQuestionLines( + pendingClarifications: CustomWorldPendingClarification[], +) { + return pendingClarifications.map( + (entry, index) => `${index + 1}. ${entry.question}`, + ); +} + +function composeAssistantReply(params: { + openingText: string; + intent: CustomWorldCreatorIntentRecord; + pendingClarifications: CustomWorldPendingClarification[]; + isReady: boolean; +}) { + const questionLines = buildQuestionLines(params.pendingClarifications); + + return [ + params.openingText, + params.isReady + ? '最小锚点已经齐备。' + : questionLines.slice(0, 1).join('\n'), + ].join('\n'); +} + +function buildDerivedState( + intent: CustomWorldCreatorIntentRecord, + hasUserInput: boolean, +) { + const readiness = evaluateCreatorIntentReadiness(intent); + const pendingClarifications = buildPendingClarifications(intent, readiness); + const stage = resolveCreatorIntentStage({ + hasUserInput, + readiness, + }); + + return { + readiness, + pendingClarifications, + stage, + anchorPack: buildAnchorPackFromIntent(intent, { + completedKeys: readiness.completedKeys, + missingKeys: readiness.missingKeys, + }), + draftProfile: { + title: buildDraftTitleFromIntent(intent), + summary: buildDraftSummaryFromIntent(intent), + }, + suggestedActions: buildSuggestedActions({ + stage, + isReady: readiness.isReady, + }), + }; +} + +function buildWelcomeMessage(params: { + seedText: string; + intent: CustomWorldCreatorIntentRecord; + pendingClarifications: CustomWorldPendingClarification[]; + isReady: boolean; +}) { + const openingText = params.seedText + ? `收到:${truncateText(params.seedText, 88)}` + : '想做一个什么样的世界?'; + + return composeAssistantReply({ + openingText, + intent: params.intent, + pendingClarifications: params.pendingClarifications, + isReady: params.isReady, + }); +} + +function buildAssistantMessage(params: { + latestUserText: string; + relatedOperationId: string; + intent: CustomWorldCreatorIntentRecord; + pendingClarifications: CustomWorldPendingClarification[]; + isReady: boolean; +}) { + return { + id: `message-${crypto.randomBytes(8).toString('hex')}`, + role: 'assistant', + kind: params.isReady ? 'summary' : 'clarification', + text: composeAssistantReply({ + openingText: `收到:${truncateText(params.latestUserText, 88)}`, + intent: params.intent, + pendingClarifications: params.pendingClarifications, + isReady: params.isReady, + }), + createdAt: new Date().toISOString(), + relatedOperationId: params.relatedOperationId, + } satisfies CustomWorldAgentMessage; +} + +function buildAgentLlmPrompt(params: { + session: CustomWorldAgentSessionRecord; + latestUserText: string; + intent: CustomWorldCreatorIntentRecord; + pendingClarifications: CustomWorldPendingClarification[]; + isReady: boolean; +}) { + const recentMessages = params.session.messages + .slice(-18) + .map((message) => `${message.role}: ${message.text}`) + .join('\n'); + const pendingQuestions = params.pendingClarifications + .slice(0, 1) + .map((entry) => `${entry.label}: ${entry.question}`) + .join('\n'); + + return [ + '当前结构化世界锚点:', + buildCreatorIntentDisplayText(params.intent) || '暂无', + '', + '注意:上面这些已确认设定和下面的历史对话都算有效上下文。不要重复追问用户已经明确回答过的信息。', + '', + `锚点是否齐备:${params.isReady ? '是' : '否'}`, + pendingQuestions ? `待确认问题:\n${pendingQuestions}` : '', + '', + '最近对话:', + recentMessages || '暂无', + '', + `用户最新输入:${params.latestUserText}`, + '', + '请输出严格 JSON,格式如下:{"reply":"...","recommendedReplies":["...","...","..."]}', + '', + params.isReady + ? [ + 'reply 字段要求:', + '- 用中文自然回复。', + '- 像创作者搭档,不要写系统说明,不要列规则,不要提到 JSON 或后端。', + '- 第一段先明确回应并收住用户刚刚给出的具体设定。', + '- 第二段明确告诉用户:关键设定已经足够,可以帮他生成第一版游戏草稿了。', + '- 最后固定补一句自然问题,询问是否现在开始生成草稿。', + '- 整体要短,聚焦推进。', + 'recommendedReplies 字段要求:', + '- 必须正好 3 条。', + '- 每条都是用户下一句可以直接发送的话。', + '- 第 1 条必须表达开始生成草稿。', + '- 第 2 条应是让 Agent 先总结一下当前设定。', + '- 第 3 条应是用户还想再补充一点设定。', + ].join('\n') + : [ + 'reply 字段要求:', + '- 用中文自然回复。', + '- 像创作者搭档,不要写系统说明,不要列规则,不要提到 JSON 或后端。', + '- 第一段必须明确回应并收住用户上一次给出的具体落地设定,不能只说“收到”。', + '- 第二段开始固定只追问 1 个当前最关键、最能推进游戏设定的问题。', + '- 这个问题必须帮助你更快拿到作品最核心的设定信息。', + '- 必要时给一个很短的示例,帮助用户高效回答。', + 'recommendedReplies 字段要求:', + '- 必须正好 3 条。', + '- 3 条都必须是对当前这一个问题的直接回答。', + '- 不允许继续提问。', + '- 不允许写成“你先帮我”“继续问我”这种让 Agent 行动的句子。', + '- 回答要尽量具体,优先提供能推进作品设定的核心信息。', + ].join('\n'), + ] + .filter(Boolean) + .join('\n'); +} + +function parseAssistantTurnJson(text: string) { + try { + const parsed = JSON.parse(text) as { + reply?: unknown; + recommendedReplies?: unknown; + }; + const reply = + typeof parsed.reply === 'string' ? parsed.reply.trim() : ''; + const recommendedReplies = Array.isArray(parsed.recommendedReplies) + ? parsed.recommendedReplies + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter(Boolean) + .slice(0, 3) + : []; + + return { + reply, + recommendedReplies, + }; + } catch { + return { + reply: '', + recommendedReplies: [], + }; + } +} + +function buildFallbackRecommendedReplies(params: { + pendingClarifications: CustomWorldPendingClarification[]; + isReady: boolean; +}) { + if (params.isReady) { + return ['现在开始生成草稿', '先总结一下当前设定', '我还想再补充一点']; + } + + const nextQuestion = params.pendingClarifications[0]; + if (!nextQuestion) { + return ['继续', '给我一个默认方案', '先总结一下']; + } + + if (nextQuestion.targetKey === 'world_hook') { + return [ + '一个被潮雾切开的列岛世界。', + '一个旧神遗产复苏的边境世界。', + '一个灯塔决定航路生死的海雾世界。', + ]; + } + + if (nextQuestion.targetKey === 'player_premise') { + return [ + '玩家是被迫返乡的失职守灯人。', + '玩家是背着旧案回来的流亡航海士。', + '玩家是被逐出组织的前探路员。', + ]; + } + + if (nextQuestion.targetKey === 'theme_and_tone') { + return [ + '整体偏冷峻、潮湿、悬疑。', + '气质偏压迫、克制、带一点宿命感。', + '我想要浪漫外壳下的阴冷悬疑。', + ]; + } + + if (nextQuestion.targetKey === 'core_conflict') { + return [ + '核心冲突是旧航路解释权之争。', + '主要危机是被封印的灾难正在重演。', + '核心矛盾是守旧势力和新秩序正面冲突。', + ]; + } + + if (nextQuestion.targetKey === 'relationship_seed') { + return [ + '关键人物是玩家的旧友兼宿敌。', + '她表面帮助玩家,其实另有立场。', + '关键钩子是玩家必须再次相信曾经背叛自己的人。', + ]; + } + + return [ + '标志性元素是潮雾钟声。', + '标志性规则是夜里不能出海。', + '地标意象是永不熄灭的盐火灯塔。', + ]; +} + +function buildFoundationDraftAssistantMessage(params: { + relatedOperationId: string; + draftProfile: unknown; +}) { + const profile = normalizeFoundationDraftProfile(params.draftProfile); + const leadCharacter = profile?.playableNpcs[0]; + const leadLandmark = profile?.landmarks[0]; + + return { + id: `message-${crypto.randomBytes(8).toString('hex')}`, + role: 'assistant', + kind: 'summary', + text: [ + `我先把第一版世界底稿整理出来了:${profile?.summary || '底稿已经生成完成。'}`, + '', + `当前已经落下来的第一批对象数量是:关键角色 ${profile?.playableNpcs.length ?? 0} 个,关键地点 ${profile?.landmarks.length ?? 0} 个,势力 ${profile?.factions.length ?? 0} 个。`, + `建议你先从“${profile?.name || '世界总卡'}”这张世界总卡看起${leadCharacter ? `,再顺着角色「${leadCharacter.name}」往下细修` : ''}${leadLandmark ? `,地点可以先看「${leadLandmark.name}」` : ''}。`, + ].join('\n'), + createdAt: new Date().toISOString(), + relatedOperationId: params.relatedOperationId, + } satisfies CustomWorldAgentMessage; +} + +function buildObjectRefiningAssistantMessage(params: { + latestUserText: string; + relatedOperationId: string; + draftProfile: unknown; +}) { + const profile = normalizeFoundationDraftProfile(params.draftProfile); + const leadCharacter = profile?.playableNpcs[0]; + const leadLandmark = profile?.landmarks[0]; + + return { + id: `message-${crypto.randomBytes(8).toString('hex')}`, + role: 'assistant', + kind: 'summary', + text: [ + `我先把你这轮补充挂回当前底稿语境里:${truncateText(params.latestUserText, 88)}`, + '', + profile?.summary || '当前底稿仍然保留,你可以继续围绕已有卡片精修。', + '', + `现在更适合直接看卡继续收紧内容${leadCharacter ? `,角色建议先看「${leadCharacter.name}」` : ''}${leadLandmark ? `,地点建议先看「${leadLandmark.name}」` : ''}。`, + ].join('\n'), + createdAt: new Date().toISOString(), + relatedOperationId: params.relatedOperationId, + } satisfies CustomWorldAgentMessage; +} + +function buildActionResultMessage(params: { + relatedOperationId: string; + text: string; +}) { + return { + id: `message-${crypto.randomBytes(8).toString('hex')}`, + role: 'assistant', + kind: 'action_result', + text: params.text, + createdAt: new Date().toISOString(), + relatedOperationId: params.relatedOperationId, + } satisfies CustomWorldAgentMessage; +} + +export class CustomWorldAgentOrchestrator { + private readonly foundationDraftService: CustomWorldAgentFoundationDraftService; + + private readonly draftCompiler: CustomWorldAgentDraftCompiler; + + private readonly entityGenerationService: CustomWorldAgentEntityGenerationService; + + private readonly changeSummaryService: CustomWorldAgentChangeSummaryService; + + private readonly assetBridgeService: CustomWorldAgentAssetBridgeService; + + constructor( + private readonly sessionStore: CustomWorldAgentSessionStore, + private readonly llmClient: UpstreamLlmClient | null = null, + ) { + this.foundationDraftService = new CustomWorldAgentFoundationDraftService(); + this.draftCompiler = new CustomWorldAgentDraftCompiler(); + this.entityGenerationService = new CustomWorldAgentEntityGenerationService( + llmClient, + ); + this.changeSummaryService = new CustomWorldAgentChangeSummaryService(); + this.assetBridgeService = new CustomWorldAgentAssetBridgeService(); + } + + async createSession( + userId: string, + payload: CreateCustomWorldAgentSessionRequest, + ): Promise { + const seedText = payload.seedText?.trim() ?? ''; + const baseIntent = createEmptyCreatorIntentRecord('freeform'); + const seedPatch = seedText + ? extractCreatorIntentPatch({ + currentIntent: baseIntent, + latestUserMessage: seedText, + }) + : {}; + const creatorIntent = mergeCreatorIntentRecord(baseIntent, seedPatch); + const derivedState = buildDerivedState(creatorIntent, Boolean(seedText)); + const fallbackWelcomeMessage = buildWelcomeMessage({ + seedText, + intent: creatorIntent, + pendingClarifications: derivedState.pendingClarifications, + isReady: derivedState.readiness.isReady, + }); + const initialAssistantTurn = await this.generateAssistantTurn({ + session: { + sessionId: 'preview', + userId, + seedText, + stage: derivedState.stage, + focusCardId: null, + creatorIntent, + creatorIntentReadiness: derivedState.readiness, + anchorPack: derivedState.anchorPack, + lockState: {}, + draftProfile: derivedState.draftProfile, + messages: [], + draftCards: [], + pendingClarifications: derivedState.pendingClarifications, + suggestedActions: derivedState.suggestedActions, + recommendedReplies: [], + qualityFindings: [], + assetCoverage: { + roleAssets: [], + sceneAssets: [], + allRoleAssetsReady: false, + allSceneAssetsReady: false, + }, + operations: [], + checkpoints: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + latestUserText: seedText, + fallbackReply: fallbackWelcomeMessage, + intent: creatorIntent, + pendingClarifications: derivedState.pendingClarifications, + isReady: derivedState.readiness.isReady, + }); + + const record = await this.sessionStore.create(userId, { + seedText, + welcomeMessage: initialAssistantTurn.reply, + creatorIntent, + creatorIntentReadiness: derivedState.readiness, + anchorPack: derivedState.anchorPack, + draftProfile: derivedState.draftProfile, + pendingClarifications: derivedState.pendingClarifications, + stage: derivedState.stage, + suggestedActions: derivedState.suggestedActions, + recommendedReplies: initialAssistantTurn.recommendedReplies, + }); + + return (await this.sessionStore.getSnapshot( + userId, + record.sessionId, + )) as CustomWorldAgentSessionSnapshot; + } + + async getSessionSnapshot(userId: string, sessionId: string) { + return this.sessionStore.getSnapshot(userId, sessionId); + } + + async submitMessage( + userId: string, + sessionId: string, + payload: SendCustomWorldAgentMessageRequest, + ): Promise { + const session = await this.sessionStore.get(userId, sessionId); + if (!session) { + throw notFound('custom world agent session not found'); + } + + const trimmedText = payload.text.trim(); + const operation = buildOperation('process_message'); + await this.sessionStore.createOperation(userId, sessionId, operation); + await this.sessionStore.appendMessage( + userId, + sessionId, + buildUserMessage(trimmedText, payload.clientMessageId), + ); + + void this.processMessageOperation({ + userId, + sessionId, + operationId: operation.operationId, + latestUserText: trimmedText, + }); + + return { + operation, + }; + } + + async executeAction( + userId: string, + sessionId: string, + payload: CustomWorldAgentActionRequest, + ): Promise { + const session = await this.sessionStore.get(userId, sessionId); + if (!session) { + throw notFound('custom world agent session not found'); + } + + if (payload.action === 'draft_foundation') { + if (session.stage !== 'foundation_review') { + throw badRequest( + 'draft_foundation is only available during foundation_review', + ); + } + + if (!session.creatorIntentReadiness.isReady) { + throw badRequest('draft_foundation requires a ready session'); + } + + const operation = buildOperation('draft_foundation'); + await this.sessionStore.createOperation(userId, sessionId, operation); + void this.processDraftFoundationOperation({ + userId, + sessionId, + operationId: operation.operationId, + }); + + return { + operation, + }; + } + + if ( + payload.action === 'update_draft_card' || + payload.action === 'generate_characters' || + payload.action === 'generate_landmarks' || + payload.action === 'generate_role_assets' || + payload.action === 'sync_role_assets' + ) { + if ( + session.stage !== 'object_refining' && + session.stage !== 'visual_refining' + ) { + throw badRequest( + `${payload.action} is only available during object_refining or visual_refining`, + ); + } + + const hasDraftFoundation = Boolean( + normalizeFoundationDraftProfile(session.draftProfile) && + session.draftCards.length > 0, + ); + if (!hasDraftFoundation) { + throw badRequest(`${payload.action} requires an existing draft foundation`); + } + } + + if (payload.action === 'update_draft_card') { + if (!payload.cardId.trim()) { + throw badRequest('update_draft_card requires cardId'); + } + if (!Array.isArray(payload.sections) || payload.sections.length === 0) { + throw badRequest('update_draft_card requires sections'); + } + + const operation = buildOperation('update_draft_card'); + await this.sessionStore.createOperation(userId, sessionId, operation); + void this.processUpdateDraftCardOperation({ + userId, + sessionId, + operationId: operation.operationId, + payload, + }); + + return { + operation, + }; + } + + if (payload.action === 'generate_characters') { + if (payload.count < 1 || payload.count > 3) { + throw badRequest('generate_characters count must be between 1 and 3'); + } + + const operation = buildOperation('generate_characters'); + await this.sessionStore.createOperation(userId, sessionId, operation); + void this.processGenerateCharactersOperation({ + userId, + sessionId, + operationId: operation.operationId, + payload, + }); + + return { + operation, + }; + } + + if (payload.action === 'generate_landmarks') { + if (payload.count < 1 || payload.count > 3) { + throw badRequest('generate_landmarks count must be between 1 and 3'); + } + + const operation = buildOperation('generate_landmarks'); + await this.sessionStore.createOperation(userId, sessionId, operation); + void this.processGenerateLandmarksOperation({ + userId, + sessionId, + operationId: operation.operationId, + payload, + }); + + return { + operation, + }; + } + + if (payload.action === 'generate_role_assets') { + if (!Array.isArray(payload.roleIds) || payload.roleIds.length !== 1) { + throw badRequest('generate_role_assets currently requires exactly one roleId'); + } + + const operation = buildOperation('generate_role_assets'); + await this.sessionStore.createOperation(userId, sessionId, operation); + void this.processGenerateRoleAssetsOperation({ + userId, + sessionId, + operationId: operation.operationId, + payload, + }); + + return { + operation, + }; + } + + if (payload.action === 'sync_role_assets') { + if (!payload.roleId.trim()) { + throw badRequest('sync_role_assets requires roleId'); + } + if (!payload.portraitPath.trim() || !payload.generatedVisualAssetId.trim()) { + throw badRequest( + 'sync_role_assets requires portraitPath and generatedVisualAssetId', + ); + } + + const operation = buildOperation('sync_role_assets'); + await this.sessionStore.createOperation(userId, sessionId, operation); + void this.processSyncRoleAssetsOperation({ + userId, + sessionId, + operationId: operation.operationId, + payload, + }); + + return { + operation, + }; + } + + if (payload.action === 'publish_world') { + throw badRequest('publish_world is not available in phase5'); + } + + throw badRequest(`${payload.action} is not available in phase5`); + } + + async getOperation(userId: string, sessionId: string, operationId: string) { + return this.sessionStore.getOperation(userId, sessionId, operationId); + } + + async getCardDetail(userId: string, sessionId: string, cardId: string) { + const session = await this.sessionStore.get(userId, sessionId); + if (!session) { + return null; + } + + return this.draftCompiler.getDraftCardDetail(session.draftProfile, cardId); + } + + private async generateAssistantTurn(params: { + session: CustomWorldAgentSessionRecord; + latestUserText: string; + fallbackReply: string; + intent: CustomWorldCreatorIntentRecord; + pendingClarifications: CustomWorldPendingClarification[]; + isReady: boolean; + }) { + const fallbackReplies = buildFallbackRecommendedReplies({ + pendingClarifications: params.pendingClarifications, + isReady: params.isReady, + }); + + if (!this.llmClient) { + return { + reply: params.fallbackReply, + recommendedReplies: fallbackReplies, + }; + } + + try { + const content = await this.llmClient.requestMessageContent({ + systemPrompt: '你只输出严格 JSON,不输出 Markdown。', + userPrompt: buildAgentLlmPrompt({ + session: params.session, + latestUserText: params.latestUserText, + intent: params.intent, + pendingClarifications: params.pendingClarifications, + isReady: params.isReady, + }), + timeoutMs: 60000, + debugLabel: 'custom-world-agent-chat-turn', + }); + const parsed = parseAssistantTurnJson(content); + + return { + reply: parsed.reply || params.fallbackReply, + recommendedReplies: + parsed.recommendedReplies.length === 3 + ? parsed.recommendedReplies + : fallbackReplies, + }; + } catch { + return { + reply: params.fallbackReply, + recommendedReplies: fallbackReplies, + }; + } + } + + private async processDraftFoundationOperation(params: { + userId: string; + sessionId: string; + operationId: string; + }) { + const { userId, sessionId, operationId } = params; + + try { + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'running', + phaseLabel: '生成世界底稿', + phaseDetail: '正在根据已确认锚点编译第一版世界结构。', + progress: 38, + }); + + await sleep(30); + + const latestSession = (await this.sessionStore.get( + userId, + sessionId, + )) as CustomWorldAgentSessionRecord | null; + if (!latestSession) { + throw new Error('custom world agent session not found'); + } + + if ( + latestSession.stage !== 'foundation_review' || + !latestSession.creatorIntentReadiness.isReady + ) { + throw new Error('session is not ready for draft_foundation'); + } + + const draftProfile = this.foundationDraftService.generate({ + creatorIntent: latestSession.creatorIntent, + anchorPack: latestSession.anchorPack, + }); + + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + phaseLabel: '编译草稿卡', + phaseDetail: '正在把世界底稿整理成可浏览的卡片摘要和详情结构。', + progress: 72, + }); + + const draftCards = this.draftCompiler.compileDraftCards(draftProfile); + const assetCoverage = rebuildRoleAssetCoverage(draftProfile); + const nextStage = 'object_refining' as const; + const nextSuggestedActions = buildSuggestedActions({ + stage: nextStage, + isReady: true, + draftProfile, + draftCards, + }); + + await this.sessionStore.replaceDerivedState(userId, sessionId, { + stage: nextStage, + draftProfile: draftProfile as unknown as Record, + draftCards, + assetCoverage, + pendingClarifications: [], + suggestedActions: nextSuggestedActions, + recommendedReplies: [], + }); + await this.sessionStore.appendCheckpoint(userId, sessionId, { + label: '世界底稿 V1', + }); + await this.sessionStore.appendMessage( + userId, + sessionId, + buildFoundationDraftAssistantMessage({ + relatedOperationId: operationId, + draftProfile, + }), + ); + + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'completed', + phaseLabel: '世界底稿已生成', + phaseDetail: `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成。`, + progress: 100, + error: null, + }); + } catch (error) { + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'failed', + phaseLabel: '底稿生成失败', + phaseDetail: '这一轮没有成功把锚点编成世界底稿。', + progress: 100, + error: + error instanceof Error + ? error.message + : 'draft foundation failed', + }); + } + } + + private async processUpdateDraftCardOperation(params: { + userId: string; + sessionId: string; + operationId: string; + payload: Extract; + }) { + const { userId, sessionId, operationId, payload } = params; + + try { + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'running', + phaseLabel: '写回草稿设定', + phaseDetail: '正在把这次编辑内容写回当前世界底稿。', + progress: 34, + }); + + const latestSession = (await this.sessionStore.get( + userId, + sessionId, + )) as CustomWorldAgentSessionRecord | null; + if (!latestSession) { + throw new Error('custom world agent session not found'); + } + + const nextDraftProfile = updateDraftCardSections({ + draftProfile: (latestSession.draftProfile ?? {}) as Record, + cardId: payload.cardId, + sections: payload.sections, + }); + + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + phaseLabel: '重编译草稿卡', + phaseDetail: '正在同步更新草稿摘要和详情内容。', + progress: 72, + }); + + const nextDraftCards = this.draftCompiler.compileDraftCards(nextDraftProfile); + const assetCoverage = rebuildRoleAssetCoverage(nextDraftProfile); + const nextStage = + latestSession.stage === 'visual_refining' + ? ('visual_refining' as const) + : ('object_refining' as const); + const nextSuggestedActions = buildSuggestedActions({ + stage: nextStage, + isReady: true, + draftProfile: nextDraftProfile, + draftCards: nextDraftCards, + }); + const updatedDetail = this.draftCompiler.getDraftCardDetail( + nextDraftProfile, + payload.cardId, + ); + const changedSectionIds = new Set( + payload.sections.map((section) => section.sectionId.trim()).filter(Boolean), + ); + + await this.sessionStore.replaceDerivedState(userId, sessionId, { + stage: nextStage, + draftProfile: nextDraftProfile, + draftCards: nextDraftCards, + assetCoverage, + focusCardId: payload.cardId, + suggestedActions: nextSuggestedActions, + recommendedReplies: [], + }); + await this.sessionStore.appendCheckpoint(userId, sessionId, { + label: `编辑 ${updatedDetail?.title || '草稿卡'}`, + }); + await this.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: this.changeSummaryService.buildSummary({ + action: 'update_draft_card', + cardId: payload.cardId, + changedLabels: + updatedDetail?.sections + .filter((section) => changedSectionIds.has(section.id)) + .map((section) => section.label) ?? [], + draftProfile: nextDraftProfile, + }), + }), + ); + + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'completed', + phaseLabel: '草稿设定已保存', + phaseDetail: `「${updatedDetail?.title || '当前卡片'}」的设定已经同步更新。`, + progress: 100, + error: null, + }); + } catch (error) { + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'failed', + phaseLabel: '保存失败', + phaseDetail: '这次草稿编辑没有成功写回到底稿。', + progress: 100, + error: + error instanceof Error ? error.message : 'update draft card failed', + }); + } + } + + private async processGenerateCharactersOperation(params: { + userId: string; + sessionId: string; + operationId: string; + payload: Extract; + }) { + const { userId, sessionId, operationId, payload } = params; + + try { + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'running', + phaseLabel: '生成新角色', + phaseDetail: '正在围绕当前世界底稿补出新角色。', + progress: 32, + }); + + const latestSession = (await this.sessionStore.get( + userId, + sessionId, + )) as CustomWorldAgentSessionRecord | null; + if (!latestSession) { + throw new Error('custom world agent session not found'); + } + + const generationResult = + await this.entityGenerationService.generateAdditionalCharacters({ + creatorIntent: latestSession.creatorIntent, + anchorPack: latestSession.anchorPack, + draftProfile: (latestSession.draftProfile ?? {}) as Record, + count: payload.count, + promptText: payload.promptText, + anchorCardIds: + payload.anchorCardIds && payload.anchorCardIds.length > 0 + ? payload.anchorCardIds + : latestSession.focusCardId + ? [latestSession.focusCardId] + : [getWorldFoundationCardId()], + }); + + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + phaseLabel: '插入新角色卡', + phaseDetail: '正在把新角色插回草稿并刷新卡片列表。', + progress: 74, + }); + + const nextDraftCards = this.draftCompiler.compileDraftCards( + generationResult.draftProfile, + ); + const assetCoverage = rebuildRoleAssetCoverage(generationResult.draftProfile); + const nextStage = + latestSession.stage === 'visual_refining' + ? ('visual_refining' as const) + : ('object_refining' as const); + const nextSuggestedActions = buildSuggestedActions({ + stage: nextStage, + isReady: true, + draftProfile: generationResult.draftProfile, + draftCards: nextDraftCards, + }); + const focusCardId = generationResult.generatedCharacters[0]?.id ?? null; + + await this.sessionStore.replaceDerivedState(userId, sessionId, { + stage: nextStage, + draftProfile: generationResult.draftProfile, + draftCards: nextDraftCards, + assetCoverage, + focusCardId, + suggestedActions: nextSuggestedActions, + recommendedReplies: [], + }); + await this.sessionStore.appendCheckpoint(userId, sessionId, { + label: `新增角色 ${generationResult.generatedCharacters.length} 个`, + }); + await this.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: this.changeSummaryService.buildSummary({ + action: 'generate_characters', + names: generationResult.generatedCharacters.map((entry) => entry.name), + draftProfile: generationResult.draftProfile, + }), + }), + ); + + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'completed', + phaseLabel: '新角色已加入草稿', + phaseDetail: `已补出 ${generationResult.generatedCharacters.length} 个新角色。`, + progress: 100, + error: null, + }); + } catch (error) { + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'failed', + phaseLabel: '角色生成失败', + phaseDetail: '这一轮没有成功补出新角色。', + progress: 100, + error: + error instanceof Error ? error.message : 'generate characters failed', + }); + } + } + + private async processGenerateLandmarksOperation(params: { + userId: string; + sessionId: string; + operationId: string; + payload: Extract; + }) { + const { userId, sessionId, operationId, payload } = params; + + try { + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'running', + phaseLabel: '生成新地点', + phaseDetail: '正在围绕当前世界底稿补出新地点。', + progress: 32, + }); + + const latestSession = (await this.sessionStore.get( + userId, + sessionId, + )) as CustomWorldAgentSessionRecord | null; + if (!latestSession) { + throw new Error('custom world agent session not found'); + } + + const generationResult = + await this.entityGenerationService.generateAdditionalLandmarks({ + creatorIntent: latestSession.creatorIntent, + anchorPack: latestSession.anchorPack, + draftProfile: (latestSession.draftProfile ?? {}) as Record, + count: payload.count, + promptText: payload.promptText, + anchorCardIds: + payload.anchorCardIds && payload.anchorCardIds.length > 0 + ? payload.anchorCardIds + : latestSession.focusCardId + ? [latestSession.focusCardId] + : [getWorldFoundationCardId()], + }); + + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + phaseLabel: '插入新地点卡', + phaseDetail: '正在把新地点插回草稿并刷新卡片列表。', + progress: 74, + }); + + const nextDraftCards = this.draftCompiler.compileDraftCards( + generationResult.draftProfile, + ); + const assetCoverage = rebuildRoleAssetCoverage(generationResult.draftProfile); + const nextStage = + latestSession.stage === 'visual_refining' + ? ('visual_refining' as const) + : ('object_refining' as const); + const nextSuggestedActions = buildSuggestedActions({ + stage: nextStage, + isReady: true, + draftProfile: generationResult.draftProfile, + draftCards: nextDraftCards, + }); + const focusCardId = generationResult.generatedLandmarks[0]?.id ?? null; + + await this.sessionStore.replaceDerivedState(userId, sessionId, { + stage: nextStage, + draftProfile: generationResult.draftProfile, + draftCards: nextDraftCards, + assetCoverage, + focusCardId, + suggestedActions: nextSuggestedActions, + recommendedReplies: [], + }); + await this.sessionStore.appendCheckpoint(userId, sessionId, { + label: `新增地点 ${generationResult.generatedLandmarks.length} 个`, + }); + await this.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: this.changeSummaryService.buildSummary({ + action: 'generate_landmarks', + names: generationResult.generatedLandmarks.map((entry) => entry.name), + draftProfile: generationResult.draftProfile, + }), + }), + ); + + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'completed', + phaseLabel: '新地点已加入草稿', + phaseDetail: `已补出 ${generationResult.generatedLandmarks.length} 个新地点。`, + progress: 100, + error: null, + }); + } catch (error) { + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'failed', + phaseLabel: '地点生成失败', + phaseDetail: '这一轮没有成功补出新地点。', + progress: 100, + error: + error instanceof Error ? error.message : 'generate landmarks failed', + }); + } + } + + private async processGenerateRoleAssetsOperation(params: { + userId: string; + sessionId: string; + operationId: string; + payload: Extract; + }) { + const { userId, sessionId, operationId, payload } = params; + + try { + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'running', + phaseLabel: '准备角色资产工坊', + phaseDetail: '正在校验角色并整理工坊上下文。', + progress: 40, + }); + + const latestSession = (await this.sessionStore.get( + userId, + sessionId, + )) as CustomWorldAgentSessionRecord | null; + if (!latestSession) { + throw new Error('custom world agent session not found'); + } + + const roleId = payload.roleIds[0]!; + const studioContext = this.assetBridgeService.buildRoleAssetStudioContext( + latestSession.draftProfile, + roleId, + ); + const nextStage = 'visual_refining' as const; + const nextSuggestedActions = buildSuggestedActions({ + stage: nextStage, + isReady: true, + draftProfile: latestSession.draftProfile, + draftCards: latestSession.draftCards, + }); + + await this.sessionStore.replaceDerivedState(userId, sessionId, { + stage: nextStage, + focusCardId: roleId, + suggestedActions: nextSuggestedActions, + recommendedReplies: [], + }); + await this.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: `已为「${studioContext.roleName}」准备好角色资产工坊,先生成主图候选,再补核心动作。`, + }), + ); + + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'completed', + phaseLabel: '角色资产工坊已就绪', + phaseDetail: `「${studioContext.roleName}」现在可以开始生成主图和动作。`, + progress: 100, + error: null, + }); + } catch (error) { + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'failed', + phaseLabel: '角色资产工坊准备失败', + phaseDetail: '这一轮没有成功进入角色资产工坊。', + progress: 100, + error: + error instanceof Error ? error.message : 'generate role assets failed', + }); + } + } + + private async processSyncRoleAssetsOperation(params: { + userId: string; + sessionId: string; + operationId: string; + payload: Extract; + }) { + const { userId, sessionId, operationId, payload } = params; + + try { + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'running', + phaseLabel: '同步角色资产', + phaseDetail: '正在把主图与动作结果写回当前世界草稿。', + progress: 36, + }); + + const latestSession = (await this.sessionStore.get( + userId, + sessionId, + )) as CustomWorldAgentSessionRecord | null; + if (!latestSession) { + throw new Error('custom world agent session not found'); + } + + const syncResult = this.assetBridgeService.applyRoleAssetPublishResult( + latestSession.draftProfile, + payload, + ); + + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + phaseLabel: '刷新角色卡摘要', + phaseDetail: '正在同步更新角色卡状态与资产覆盖。', + progress: 72, + }); + + const nextDraftCards = this.draftCompiler.compileDraftCards( + syncResult.draftProfile, + ); + const assetCoverage = rebuildRoleAssetCoverage(syncResult.draftProfile); + const nextSuggestedActions = buildSuggestedActions({ + stage: 'visual_refining', + isReady: true, + draftProfile: syncResult.draftProfile, + draftCards: nextDraftCards, + }); + + await this.sessionStore.replaceDerivedState(userId, sessionId, { + stage: 'visual_refining', + draftProfile: syncResult.draftProfile, + draftCards: nextDraftCards, + assetCoverage, + focusCardId: payload.roleId, + suggestedActions: nextSuggestedActions, + recommendedReplies: [], + }); + await this.sessionStore.appendCheckpoint(userId, sessionId, { + label: `同步角色资产 ${syncResult.updatedAssetSummary.roleName}`, + }); + await this.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: buildRoleAssetSyncResultText({ + roleName: syncResult.updatedAssetSummary.roleName, + assetStatusLabel: resolveRoleAssetStatusLabel( + syncResult.updatedAssetSummary.status, + ), + }), + }), + ); + + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'completed', + phaseLabel: '角色资产已同步', + phaseDetail: `「${syncResult.updatedAssetSummary.roleName}」的资产状态已更新为${resolveRoleAssetStatusLabel(syncResult.updatedAssetSummary.status)}。`, + progress: 100, + error: null, + }); + } catch (error) { + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'failed', + phaseLabel: '角色资产同步失败', + phaseDetail: '这一轮没有成功把角色资产写回草稿。', + progress: 100, + error: + error instanceof Error ? error.message : 'sync role assets failed', + }); + } + } + + private async processMessageOperation(params: { + userId: string; + sessionId: string; + operationId: string; + latestUserText: string; + }) { + const { userId, sessionId, operationId, latestUserText } = params; + + try { + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'running', + phaseLabel: '提取世界锚点', + phaseDetail: '正在把这轮自然语言补充整理成结构化创作意图。', + progress: 45, + }); + + await sleep(30); + + if (latestUserText.includes(PHASE2_FORCE_FAIL_TOKEN)) { + throw new Error('phase2 forced failure'); + } + + const latestSession = (await this.sessionStore.get( + userId, + sessionId, + )) as CustomWorldAgentSessionRecord | null; + if (!latestSession) { + throw new Error('custom world agent session not found'); + } + + const currentIntent = + normalizeCreatorIntentRecord(latestSession.creatorIntent) ?? + createEmptyCreatorIntentRecord('freeform'); + const recentMessages = getRecentUserMessages(latestSession).slice(0, -1); + const intentPatch = extractCreatorIntentPatch({ + currentIntent, + latestUserMessage: latestUserText, + recentMessages, + }); + const nextIntent = mergeCreatorIntentRecord( + currentIntent, + AUTO_COMPLETE_PATTERN.test(latestUserText) + ? { + ...intentPatch, + ...buildAutoCompletePatch(currentIntent), + } + : intentPatch, + ); + const derivedState = buildDerivedState(nextIntent, true); + const shouldPreserveDraftStage = + (latestSession.stage === 'object_refining' || + latestSession.stage === 'visual_refining') && + latestSession.draftCards.length > 0; + const preservedStage = + latestSession.stage === 'visual_refining' + ? ('visual_refining' as const) + : ('object_refining' as const); + const nextSuggestedActions = shouldPreserveDraftStage + ? buildSuggestedActions({ + stage: preservedStage, + isReady: true, + draftProfile: latestSession.draftProfile, + draftCards: latestSession.draftCards, + }) + : derivedState.suggestedActions; + + await this.sessionStore.replaceDerivedState(userId, sessionId, { + stage: shouldPreserveDraftStage + ? preservedStage + : derivedState.stage, + creatorIntent: nextIntent, + creatorIntentReadiness: derivedState.readiness, + anchorPack: derivedState.anchorPack, + draftProfile: shouldPreserveDraftStage + ? latestSession.draftProfile + : derivedState.draftProfile, + pendingClarifications: shouldPreserveDraftStage + ? latestSession.pendingClarifications + : derivedState.pendingClarifications, + suggestedActions: nextSuggestedActions, + draftCards: shouldPreserveDraftStage + ? latestSession.draftCards + : undefined, + }); + + const fallbackAssistantMessage = shouldPreserveDraftStage + ? buildObjectRefiningAssistantMessage({ + latestUserText, + relatedOperationId: operationId, + draftProfile: latestSession.draftProfile, + }) + : buildAssistantMessage({ + latestUserText, + relatedOperationId: operationId, + intent: nextIntent, + pendingClarifications: derivedState.pendingClarifications, + isReady: derivedState.readiness.isReady, + }); + const assistantTurn = shouldPreserveDraftStage + ? { + reply: fallbackAssistantMessage.text, + recommendedReplies: [] as string[], + } + : await this.generateAssistantTurn({ + session: latestSession, + latestUserText, + fallbackReply: fallbackAssistantMessage.text, + intent: nextIntent, + pendingClarifications: derivedState.pendingClarifications, + isReady: derivedState.readiness.isReady, + }); + const assistantMessage = { + ...fallbackAssistantMessage, + text: assistantTurn.reply, + }; + const recommendedReplies = assistantTurn.recommendedReplies; + await this.sessionStore.appendMessage( + userId, + sessionId, + assistantMessage, + ); + await this.sessionStore.replaceDerivedState(userId, sessionId, { + recommendedReplies, + }); + + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'completed', + phaseLabel: '锚点已更新', + phaseDetail: shouldPreserveDraftStage + ? '这轮补充已挂回当前底稿语境,现有草稿卡保持可继续浏览。' + : derivedState.readiness.isReady + ? '最小锚点已齐备,可以进入下一阶段。' + : '这一轮的创作锚点和澄清问题已经同步完成。', + progress: 100, + error: null, + }); + } catch (error) { + await this.sessionStore.updateOperation(userId, sessionId, operationId, { + status: 'failed', + phaseLabel: '处理失败', + phaseDetail: '这一轮消息没有成功沉淀为创作锚点。', + progress: 100, + error: + error instanceof Error ? error.message : 'process message failed', + }); + } + } +} diff --git a/server-node/src/services/customWorldAgentPhase2.test.ts b/server-node/src/services/customWorldAgentPhase2.test.ts new file mode 100644 index 00000000..5f151a95 --- /dev/null +++ b/server-node/src/services/customWorldAgentPhase2.test.ts @@ -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 + >(); + const profilesByUser = new Map[]>(); + + const getSessionBucket = (userId: string) => { + const existing = sessionsByUser.get(userId); + if (existing) { + return existing; + } + + const nextBucket = new Map(); + 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)?.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)?.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, ['冷峻']); +}); diff --git a/server-node/src/services/customWorldAgentPhase3.test.ts b/server-node/src/services/customWorldAgentPhase3.test.ts new file mode 100644 index 00000000..e0a8ea64 --- /dev/null +++ b/server-node/src/services/customWorldAgentPhase3.test.ts @@ -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 + >(); + const profilesByUser = new Map[]>(); + + const getSessionBucket = (userId: string) => { + const existing = sessionsByUser.get(userId); + if (existing) { + return existing; + } + + const nextBucket = new Map(); + 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)?.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); +}); diff --git a/server-node/src/services/customWorldAgentPhase4.test.ts b/server-node/src/services/customWorldAgentPhase4.test.ts new file mode 100644 index 00000000..e1c393f7 --- /dev/null +++ b/server-node/src/services/customWorldAgentPhase4.test.ts @@ -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 + >(); + const profilesByUser = new Map[]>(); + + const getSessionBucket = (userId: string) => { + const existing = sessionsByUser.get(userId); + if (existing) { + return existing; + } + + const nextBucket = new Map(); + 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); +}); diff --git a/server-node/src/services/customWorldAgentPhase5.test.ts b/server-node/src/services/customWorldAgentPhase5.test.ts new file mode 100644 index 00000000..414cdeec --- /dev/null +++ b/server-node/src/services/customWorldAgentPhase5.test.ts @@ -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 + >(); + const profilesByUser = new Map[]>(); + + const getSessionBucket = (userId: string) => { + const existing = sessionsByUser.get(userId); + if (existing) { + return existing; + } + + const nextBucket = new Map(); + 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 | 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); +}); diff --git a/server-node/src/services/customWorldAgentRoleAssetStateService.ts b/server-node/src/services/customWorldAgentRoleAssetStateService.ts new file mode 100644 index 00000000..d8ca5b02 --- /dev/null +++ b/server-node/src/services/customWorldAgentRoleAssetStateService.ts @@ -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 | null; +}; + +type DraftRoleKind = 'playable' | 'story'; + +type MergeRoleAssetIntoDraftProfilePayload = { + roleId: string; + portraitPath: string; + generatedVisualAssetId: string; + generatedAnimationSetId?: string | null; + animationMap?: Record | 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) + : null; +} + +function toRecordArray(value: unknown) { + return Array.isArray(value) + ? value.filter( + (item): item is Record => + 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 | 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, + ): 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, + payload: MergeRoleAssetIntoDraftProfilePayload, +) { + const nextDraftProfile = { + ...draftProfileInput, + }; + let updatedRole: Record | 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, + }; +} diff --git a/server-node/src/services/customWorldAgentSessionStore.ts b/server-node/src/services/customWorldAgentSessionStore.ts new file mode 100644 index 00000000..73cc6da1 --- /dev/null +++ b/server-node/src/services/customWorldAgentSessionStore.ts @@ -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 | null; + creatorIntentReadiness: CreatorIntentReadiness; + anchorPack: Record | null; + lockState: Record | null; + draftProfile: Record | 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(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) + : 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).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, +) { + 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; +}) { + 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, +) { + 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).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, + ) { + 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; + } +} diff --git a/server-node/src/services/customWorldGenerationService.ts b/server-node/src/services/customWorldGenerationService.ts index 3d9e38ef..0a657318 100644 --- a/server-node/src/services/customWorldGenerationService.ts +++ b/server-node/src/services/customWorldGenerationService.ts @@ -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; } diff --git a/server-node/src/services/customWorldSessionStore.ts b/server-node/src/services/customWorldSessionStore.ts index 05c5d704..6fb99c6a 100644 --- a/server-node/src/services/customWorldSessionStore.ts +++ b/server-node/src/services/customWorldSessionStore.ts @@ -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>(); + 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(); - 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); } } diff --git a/server-node/src/services/customWorldWorkSummaryService.ts b/server-node/src/services/customWorldWorkSummaryService.ts new file mode 100644 index 00000000..d5274831 --- /dev/null +++ b/server-node/src/services/customWorldWorkSummaryService.ts @@ -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) + : 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) { + 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; + 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), + ); +} diff --git a/src/components/AdventurePanel.test.tsx b/src/components/AdventurePanel.test.tsx new file mode 100644 index 00000000..ea9ee746 --- /dev/null +++ b/src/components/AdventurePanel.test.tsx @@ -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( + 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('剧情推理完成,继续后显示新的冒险选项'); +}); diff --git a/src/components/CustomWorldResultView.tsx b/src/components/CustomWorldResultView.tsx index 5d25d548..6e8e39e0 100644 --- a/src/components/CustomWorldResultView.tsx +++ b/src/components/CustomWorldResultView.tsx @@ -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({ ) : null}
- 修改设定 - 重新生成 + {onEditSetting ? ( + 修改设定 + ) : null} + {triggerRegenerate ? ( + 重新生成 + ) : null} {profile.generationStatus === 'key_only' && onContinueExpand ? ( 继续补全世界 diff --git a/src/components/CustomWorldRoleAssetStudioModal.tsx b/src/components/CustomWorldRoleAssetStudioModal.tsx index 86bed95b..03dfa223 100644 --- a/src/components/CustomWorldRoleAssetStudioModal.tsx +++ b/src/components/CustomWorldRoleAssetStudioModal.tsx @@ -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; +}; 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 | null; + }, + options?: { + closeAfterSync?: boolean; + }, + ) => void; onClose: () => void; + syncBusy?: boolean; + visualPointCost?: number; + animationPointCost?: number; + priorityTier?: 'hero' | 'featured' | 'supporting'; }) { + const [workingRole, setWorkingRole] = useState(role); const [sourceMode, setSourceMode] = useState>( 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 | 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, ) => { @@ -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), ...(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 } >
@@ -695,18 +819,21 @@ export function CustomWorldRoleAssetStudioModal({
+ + 本轮预计 {visualPointCost} 积分 + } label={isGeneratingVisuals ? '生成中...' : '生成主图候选'} onClick={() => void handleGenerateVisuals()} - disabled={isGeneratingVisuals} + disabled={isGeneratingVisuals || syncBusy} tone="sky" /> } label={isApplyingVisual ? '应用中...' : '应用主图'} onClick={() => void handleApplyVisual()} - disabled={isApplyingVisual || !selectedVisualDraft} + disabled={isApplyingVisual || !selectedVisualDraft || syncBusy} tone="green" />
@@ -723,7 +850,7 @@ export function CustomWorldRoleAssetStudioModal({ {previewImageSrc ? ( {role.name} ) : selectedTemplate ? ( @@ -811,7 +938,9 @@ export function CustomWorldRoleAssetStudioModal({ : `生成${selectedActionConfig?.label ?? '当前'}动作` } onClick={() => void handleGenerateSingleAnimation()} - disabled={isGeneratingAnimations || !role.imageSrc} + disabled={ + isGeneratingAnimations || !workingRole.imageSrc || syncBusy + } tone="sky" /> void handleGenerateAllAnimations()} - disabled={isGeneratingAnimations || !role.imageSrc} + disabled={ + isGeneratingAnimations || !workingRole.imageSrc || syncBusy + } /> } label={isApplyingAnimations ? '应用中...' : '应用动作'} onClick={() => void handleApplyAnimations()} - disabled={isApplyingAnimations} + disabled={isApplyingAnimations || syncBusy} tone="green" />
+
+ 本轮动作草稿预计消耗 {animationPointCost} 积分。 +
{animationStatus ? (
{animationStatus} @@ -843,7 +977,7 @@ export function CustomWorldRoleAssetStudioModal({ 已应用动作 {appliedActionCount}/{CORE_ACTIONS.length} - {role.generatedVisualAssetId ? ( + {workingRole.generatedVisualAssetId ? ( 主图已应用 ) : ( 待应用主图 @@ -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 (
), } : selectedTemplate.animationMap, }} @@ -916,7 +1060,7 @@ export function CustomWorldRoleAssetStudioModal({ ) : previewImageSrc ? ( {role.name} ) : ( @@ -934,14 +1078,20 @@ export function CustomWorldRoleAssetStudioModal({
-
{role.name}
+
+ {workingRole.name} +
- {role.title} / {role.role} + {workingRole.title} / {workingRole.role}
- {role.description ?
{role.description}
: null} - {role.combatStyle ?
战斗风格:{role.combatStyle}
: null} - {role.tags.length > 0 ?
标签:{role.tags.join('、')}
: null} + {workingRole.description ?
{workingRole.description}
: null} + {workingRole.combatStyle ? ( +
战斗风格:{workingRole.combatStyle}
+ ) : null} + {workingRole.tags && workingRole.tags.length > 0 ? ( +
标签:{workingRole.tags.join('、')}
+ ) : null}
@@ -959,7 +1109,7 @@ export function CustomWorldRoleAssetStudioModal({
主图状态
- {role.generatedVisualAssetId ? ( + {workingRole.generatedVisualAssetId ? ( 已应用 ) : ( 待生成 @@ -976,7 +1126,11 @@ export function CustomWorldRoleAssetStudioModal({ )}
- 当前面板只保留主图和图生视频抽帧这条生产链,不提供视频预览、抽帧编辑、修帧和导出步骤。 + 角色优先级:{priorityTier === 'hero' + ? '主角级' + : priorityTier === 'featured' + ? '重点角色' + : '支撑角色'}
diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 43c9767b..3c590d9f 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -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('checking'); diff --git a/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx b/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx new file mode 100644 index 00000000..776516e2 --- /dev/null +++ b/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx @@ -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( + , + ); + const readyHtml = renderToStaticMarkup( + , + ); + + expect(pendingHtml).toContain('待补充问题'); + expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里'); + expect(readyHtml).toContain('最小锚点已齐备,可以进入下一阶段'); +}); diff --git a/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx b/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx new file mode 100644 index 00000000..334a2412 --- /dev/null +++ b/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx @@ -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 ( +
+
+ 下一阶段 +
+
+ 最小锚点已齐备,可以进入下一阶段 +
+
+ ); + } + + return ( +
+
+
+
+ 待补充问题 +
+
+ 先补最关键的 1 到 3 项 +
+
+ + {pendingClarifications.length} + +
+ +
+ {pendingClarifications.slice(0, 3).map((item, index) => ( +
+
+
+ {index + 1}. {item.label} +
+
P{item.priority}
+
+
+ {item.question} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/custom-world-agent/CustomWorldAgentComposer.tsx b/src/components/custom-world-agent/CustomWorldAgentComposer.tsx new file mode 100644 index 00000000..c1178424 --- /dev/null +++ b/src/components/custom-world-agent/CustomWorldAgentComposer.tsx @@ -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; + 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 ( +
+
+
+ + {showAutoComplete ? ( + + ) : null} +
+