From 13bc79306f7e7f61daae5b9436d2ae3d438f29f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Tue, 21 Apr 2026 10:30:12 +0800 Subject: [PATCH] 1 --- ...NG_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md | 25 + ...D_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md | 99 ++ ...DRAFT_RESULT_ASSET_MERGE_FIX_2026-04-21.md | 96 ++ ...AFT_RESULT_AUTOSAVE_DB_CHECK_2026-04-21.md | 119 ++ ...HAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md | 1032 +++++++++++++++++ ...ON_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md | 2 + ...END_MIGRATION_EXECUTION_PLAN_2026-04-21.md | 169 +++ docs/technical/README.md | 3 + packages/shared/src/contracts/auth.ts | 3 +- packages/shared/src/contracts/story.ts | 23 +- server-node/src/auth/accessSessionCookie.ts | 84 ++ server-node/src/config.ts | 34 + server-node/src/middleware/auth.ts | 6 +- .../modules/custom-world/runtimeProfile.ts | 116 ++ .../modules/quest/questStoryActionService.ts | 316 +++++ .../src/modules/story/runtimeSession.ts | 15 + .../src/modules/story/storyActionRoutes.ts | 32 +- .../src/modules/story/storyActionService.ts | 82 +- server-node/src/routes/authRoutes.ts | 33 +- server-node/src/routes/runtimeRoutes.ts | 25 + .../services/customWorldAgentOrchestrator.ts | 126 ++ .../services/customWorldAgentPhase4.test.ts | 156 +++ .../services/customWorldAgentSessionStore.ts | 50 +- .../services/customWorldWorkSummaryService.ts | 2 +- ...ustomWorldCreationHub.interaction.test.tsx | 6 +- .../CustomWorldCreationHub.test.tsx | 2 +- .../CustomWorldCreationHub.tsx | 6 +- .../custom-world-home/CustomWorldWorkCard.tsx | 7 +- .../game-shell/PlatformHomeView.tsx | 2 +- ...meSelectionFlow.agent.interaction.test.tsx | 267 +++-- .../game-shell/PreGameSelectionFlow.tsx | 107 +- src/hooks/story/npcEncounterActions.test.ts | 103 +- src/hooks/story/npcEncounterActions.ts | 158 +-- .../story/runtimeStoryCoordinator.test.ts | 37 +- src/hooks/story/runtimeStoryCoordinator.ts | 27 +- src/hooks/story/uiTypes.ts | 2 +- src/services/aiService.ts | 33 +- src/services/apiClient.test.ts | 161 +-- src/services/apiClient.ts | 107 +- src/services/authService.test.ts | 312 ++--- src/services/authService.ts | 22 +- .../customWorldAgentDraftResult.test.ts | 82 ++ src/services/customWorldAgentDraftResult.ts | 345 ++++-- .../customWorldAgentGenerationProgress.ts | 9 +- src/services/platformBrowseHistory.ts | 172 --- src/services/questDirector.ts | 184 +-- src/services/runtimeItemAiDirector.ts | 128 +- src/services/runtimeStoryService.test.ts | 77 ++ src/services/runtimeStoryService.ts | 44 +- 49 files changed, 3691 insertions(+), 1357 deletions(-) create mode 100644 docs/technical/AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md create mode 100644 docs/technical/AGENT_DRAFT_RESULT_ASSET_MERGE_FIX_2026-04-21.md create mode 100644 docs/technical/AGENT_DRAFT_RESULT_AUTOSAVE_DB_CHECK_2026-04-21.md create mode 100644 docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md create mode 100644 docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md create mode 100644 server-node/src/auth/accessSessionCookie.ts delete mode 100644 src/services/platformBrowseHistory.ts diff --git a/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md index 9e9f7a80..41ea6323 100644 --- a/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md +++ b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md @@ -127,6 +127,31 @@ 这属于仓库当前既有工程问题,不是本批次引入的新断裂。 +## 4.1 2026-04-21 补充修正:会话探测 401 自触发循环 + +在这批收口完成后,前端又暴露出一条更细的鉴权恢复回路问题: + +1. `AuthGate` 启动时会调用 `getCurrentAuthUser()` 探测现有会话 +2. `/api/auth/me` 返回 `401` 时,`apiClient.ts` 会默认广播一次 `AUTH_STATE_EVENT` +3. `AuthGate` 自己又监听这个事件并重新 `hydrate()` +4. 最终形成 `hydrate -> /auth/me 401 -> emit -> hydrate` 的自触发循环 + +这条链的问题不在“是否允许 401”,而在: + +**会话探测请求把“未登录态探测”错误地当成了“全局登录态变更”。** + +因此这里补了一条更细粒度的约束: + +1. `apiClient.ts` 新增 `notifyAuthStateChange` 选项,默认仍保持原有广播行为 +2. `getCurrentAuthUser()` 作为会话探测请求,显式关闭这类 401 广播 +3. 真实登录、登出、刷新成功后,仍保留全局鉴权变更通知 + +这样修完后: + +1. `AuthGate` 仍会优先尝试服务端会话恢复 +2. 无会话时会正常落回未登录分支 +3. 不会因为探测型 401 把自己重新唤醒并刷爆控制台 + --- ## 5. 本批次完成后的实际收益 diff --git a/docs/technical/AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md b/docs/technical/AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md new file mode 100644 index 00000000..31baf4db --- /dev/null +++ b/docs/technical/AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md @@ -0,0 +1,99 @@ +# Agent 对话框与结果页精修职责边界修正 + +更新时间:`2026-04-21` + +## 1. 结论 + +本次修正把“Agent 对话框”和“结果页精修”重新拆清楚: + +1. `CustomWorldAgentWorkspace` 只负责八锚点信息收集、八锚点进度展示、八锚点完成后的“整理世界底稿”动作。 +2. “精修”不是 Agent 对话框里的概念,不再通过 Agent 建议动作进入角色、地点、世界总卡的局部修整。 +3. 已经生成底稿的草稿,从创作中心点击后进入结果页继续完善。 +4. 尚未生成底稿的草稿,从创作中心点击后才恢复 Agent 对话框继续补齐八锚点。 +5. 结果页负责成稿后的编辑、补全、进入世界前确认和自动保存,并通过 `sync_result_profile` 回写到当前 Agent session。 + +一句话: + +**Agent 收八锚点,结果页做精修。** + +--- + +## 2. 为什么要修正 + +旧实现把 `object_refining` 草稿卡片显示成“继续精修”,但点击后直接恢复 Agent 工作区。 + +这个行为会让用户产生两个误解: + +1. 以为精修是 Agent 对话框里的下一阶段。 +2. 以为 Agent 对话框不仅负责收集八锚点,还负责后续对象级编辑。 + +这和当前产品边界不一致。Agent 对话框应该保持轻量,只用于拿到足够稳定的八锚点输入;对象、场景、封面、世界档案的修整都应该在结果页完成。 + +--- + +## 3. 当前落地规则 + +### 3.1 创作中心草稿点击分流 + +`custom-world/works` 返回 `agent_session` 草稿后,前端按草稿是否已有底稿内容分流: + +1. `playableNpcCount <= 0 && landmarkCount <= 0` + - 视为八锚点仍未整理成底稿。 + - 点击进入 `agent-workspace`。 +2. `playableNpcCount > 0 || landmarkCount > 0` + - 视为已有可编辑底稿。 + - 点击读取对应 Agent session,编译为 `CustomWorldProfile`,进入 `custom-world-result`。 + +### 3.2 Agent 对话框动作边界 + +Agent 会话建议动作只保留: + +1. 总结当前设定 / 总结当前世界底稿。 +2. 八锚点准备完成后的“整理一版世界底稿”。 + +不再在 Agent 会话快照里继续生成或兼容展示: + +1. `refine_focus_target` +2. “精修角色” +3. “继续补地点” +4. “先看世界总卡” + +旧 session 快照如果仍带有 `refine_focus_target`,服务端兼容层会过滤掉,避免旧数据把精修入口重新塞回 Agent 对话框。 + +### 3.3 结果页精修边界 + +Agent 来源结果页不再是冻结预览态。 + +当前允许在结果页继续进行成稿精修,包括: + +1. 编辑世界信息。 +2. 编辑角色、场景、封面等对象档案。 +3. 删除或调整已有对象。 +4. 自动保存到作品草稿。 +5. 进入世界前通过 `sync_result_profile` 写回 Agent session。 + +为了保持主链简洁,Agent 来源结果页仍不重新打开“通过 Agent 对话精修对象”的入口。 + +--- + +## 4. 对历史文档口径的覆盖 + +这份文档覆盖 [AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md) 中“Agent 来源结果页冻结为预览态”的阶段性口径。 + +新的主口径是: + +1. Agent 来源结果页可以编辑,因为精修本来就应该发生在结果页。 +2. 需要收紧的是 Agent 对话框,不是结果页。 +3. 结果页编辑后仍必须同步回 Agent session,保持进入世界前的数据真相源一致。 + +--- + +## 5. 验收标准 + +本次修正完成后应满足: + +1. 创作中心已有底稿草稿按钮文案为“继续完善”,点击进入结果页。 +2. 创作中心未成稿草稿按钮仍为“继续创作”,点击进入 Agent 对话框。 +3. Agent 对话框不出现“精修角色 / 补地点 / 看世界总卡”类对象精修入口。 +4. Agent 来源结果页可以打开编辑弹窗进行精修。 +5. 返回创作从结果页回到创作中心,不回到 Agent 对话框。 diff --git a/docs/technical/AGENT_DRAFT_RESULT_ASSET_MERGE_FIX_2026-04-21.md b/docs/technical/AGENT_DRAFT_RESULT_ASSET_MERGE_FIX_2026-04-21.md new file mode 100644 index 00000000..9079d0da --- /dev/null +++ b/docs/technical/AGENT_DRAFT_RESULT_ASSET_MERGE_FIX_2026-04-21.md @@ -0,0 +1,96 @@ +# Agent 草稿结果页资产合并修复 2026-04-21 + +更新时间:`2026-04-21` + +## 1. 问题现象 + +当前创作流程里,用户在“生成草稿”后反馈: + +1. 角色主图没有稳定出现在结果页 +2. 场景背景图有时可见,有时角色图缺失 +3. 自动保存后的作品库条目里,分幕图可能已经存在,但场景角色主图仍为空 + +## 2. 本次真实排查结论 + +本轮不是单一的“没写数据库”问题,而是 `agent draft -> result profile` 桥接层存在一类更隐蔽的集合漂移问题。 + +排查后确认: + +1. 最新 `custom_world_sessions.payload_json` 里的 `draftProfile.storyNpcs[].imageSrc` 已经存在 +2. 最新 `draftProfile.sceneChapters[].acts[].backgroundImageSrc` 也已经存在 +3. 对应图片文件也真实存在于仓库根 `public/` 下 +4. 最新 `custom_world_profiles.payload_json` 里,分幕图通常已保存成功 +5. 但场景角色主图可能仍为空 + +根因在于: + +1. 结果页桥接层在 `draftProfile.legacyResultProfile` 存在时,仍把 `legacyResultProfile` 视为主列表 +2. 旧逻辑只会按 `id` 把 `draftProfile` 里的图片字段回贴到 `legacyResultProfile` +3. 一旦后续草稿精修导致 `draftProfile` 的角色集合、角色 id 或角色命名发生漂移 +4. 旧 `legacyResultProfile` 就会继续主导结果页和自动保存对象列表 +5. 最新角色主图虽然已在 `draftProfile` 里生成完成,但会因为匹配失败而被整批吞掉 + +这类问题在场景角色上最明显,因为角色集合最容易在后续精修中替换。 + +## 3. 修复策略 + +本轮在: + +- `src/services/customWorldAgentDraftResult.ts` + +调整桥接规则: + +1. `legacyResultProfile` 仍保留,继续提供运行时富字段 +2. 但角色、场景、分幕等对象集合不再默认由 `legacyResultProfile` 主导 +3. 最新 `draftProfile` 成为结果页对象列表的主来源 +4. `legacyResultProfile` 只负责给命中的对象补运行时富字段 +5. 匹配优先级为: + - 先按 `id` + - 再按名称兜底 + +具体规则: + +1. `playableNpcs`:以最新 draft 集合为主,legacy 只补富字段与旧运行时字段 +2. `storyNpcs`:同上,避免旧角色列表吞掉新角色主图 +3. `sceneChapterBlueprints`:以最新 draft 幕列表为主,legacy 只补章节/幕已有运行时字段 +4. `landmarks`:优先更新最新 draft 命中的场景对象,但保留 legacy 中未被命中的剩余运行时场景,避免丢连接与残留信息 +5. `camp`:保留 legacy 基础信息,但优先取 draft 最新图片字段 + +## 4. 修复后的链路意义 + +修复后: + +1. 草稿自动资产服务生成的角色主图不会再因为旧 `legacyResultProfile` 的角色集合过时而丢失 +2. 分幕图继续可以稳定进入结果页与自动保存 +3. 作品库自动保存时,结果页编译出的 profile 更接近“当前草稿真实快照”,而不是历史 legacy 快照 + +## 5. 新增验证 + +本轮补了前端桥接测试: + +- `src/services/customWorldAgentDraftResult.test.ts` + +新增验证点: + +1. 当 `draftProfile.storyNpcs` 与 `legacyResultProfile.storyNpcs` 集合漂移时 +2. 结果页仍应优先展示最新 draft 角色 +3. 最新角色主图与最新分幕图不能被旧 legacy 快照吞掉 + +## 6. 当前状态 + +本轮修复后,本地已验证: + +1. `src/services/customWorldAgentDraftResult.test.ts` +2. `src/components/CustomWorldResultView.test.tsx` +3. `src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx` +4. `npm run check:encoding` + +均通过。 + +## 7. 后续建议 + +这次问题再次说明: + +1. `legacyResultProfile` 继续长期作为结果页主列表来源风险很高 +2. 只做“按 id 回贴图片字段”在对象集合会漂移的链路里不够稳 +3. 后续如果继续推进后端单一真相源,应优先把结果页对象集合完全改为服务端最新 compile/preview 结果,而不是继续让前端桥接层承担最终裁决 diff --git a/docs/technical/AGENT_DRAFT_RESULT_AUTOSAVE_DB_CHECK_2026-04-21.md b/docs/technical/AGENT_DRAFT_RESULT_AUTOSAVE_DB_CHECK_2026-04-21.md new file mode 100644 index 00000000..4aa0786f --- /dev/null +++ b/docs/technical/AGENT_DRAFT_RESULT_AUTOSAVE_DB_CHECK_2026-04-21.md @@ -0,0 +1,119 @@ +# 创作流程草稿/图片/动作自动保存数据库检查 2026-04-21 + +更新时间:`2026-04-21` + +## 1. 本次检查范围 + +本次检查只聚焦当前创作流程里下面这条链路: + +`结果页前端编辑 -> 自动保存 -> Agent session 主链同步 -> 作品库落库` + +重点核对三类内容: + +1. 草稿文本类修改 +2. 生成后的角色图片、地点图片、分幕图 +3. 角色动作相关资产字段 + +## 2. 当前实际自动保存链路 + +当前前端主入口在: + +- `src/components/game-shell/PreGameSelectionFlow.tsx` + +实际行为如下: + +1. 结果页编辑统一通过 `onProfileChange` 更新 `generatedCustomWorldProfile` +2. 当结果页停留在 `custom-world-result` 阶段时,前端会对 profile 做防抖自动保存 +3. 如果当前结果页来源是 `agent-draft`,自动保存前会先执行 `sync_result_profile` +4. `sync_result_profile` 完成后,前端不直接保存旧内存 profile,而是优先保存“从最新 session 重编译出的 profile” +5. 作品库保存最终走 `PUT /api/runtime/custom-world-library/:profileId` +6. Express 后端通过 `runtimeRepository.upsertCustomWorldProfile(...)` 把 profile 写入 `custom_world_profiles.payload_json` + +所以数据库层本身是有正常落库能力的。 + +## 3. 本次检查前确认成立的部分 + +以下能力在本次检查前已经成立: + +1. 结果页普通草稿字段编辑会触发自动保存 +2. 自动保存会真正调用后端作品库接口并更新数据库 +3. 返回创作、进入世界两条路径也会优先同步 Agent session +4. `legacyResultProfile` 已作为阶段一桥接快照保留在 session 中 + +## 4. 本次发现的真实风险 + +风险不在数据库写入本身,而在: + +`sync_result_profile -> session 重编译结果页 profile` + +此前 `sync_result_profile` 只回写: + +1. 基础摘要字段 +2. `legacyResultProfile` + +但没有把结果页里已经确认过的资产字段同步回 foundation draft 对应节点。 + +这会导致一个阶段性风险: + +1. 用户在结果页换了新的角色图 +2. 或者结果页里刚确认了新的动作资产字段 +3. 或者结果页里刚确认了新的地点图、分幕图 +4. 自动保存前前端先做一次 session 同步 +5. 同步完成后又从 session 重编译结果页 profile +6. 重编译过程会把 draft 层旧资产字段再次并入结果 profile + +这样就可能出现: + +**数据库自动保存成功了,但保存进去的是“被旧 draft 资产字段回退过的版本”,不是用户刚在结果页看到的最新图/动作。** + +## 5. 本轮修复 + +本轮在: + +- `server-node/src/services/customWorldAgentOrchestrator.ts` + +补了一个收窄修复: + +1. `sync_result_profile` 仍然保持阶段一边界,不做整套 runtime -> foundation draft 反解 +2. 但会按相同 id,把结果页里已确认的资产字段同步回 draft 层已有对象 +3. 同步范围包括: + - 角色 `imageSrc` + - 角色 `generatedVisualAssetId` + - 角色 `generatedAnimationSetId` + - 角色 `animationMap` + - 地点 `imageSrc` + - 分幕 `backgroundImageSrc` + - 分幕 `backgroundAssetId` + +这样后续再从 session 重编译结果页 profile 时,最新资产字段不会再被旧 draft 值回退。 + +## 6. 验证补充 + +本轮补了服务端测试: + +- `server-node/src/services/customWorldAgentPhase4.test.ts` + +新增验证点: + +1. `sync_result_profile` 后,最新角色主图会写回 draft +2. 最新角色动作资产字段会写回 draft +3. 最新地点图会写回 draft +4. 最新分幕图会写回 draft + +## 7. 结论 + +截至本轮修复后,当前创作流程里: + +1. 草稿文本修改可以自动保存到数据库 +2. 结果页中确认后的角色图、地点图、分幕图可以随自动保存稳定进入数据库 +3. 角色动作相关资产字段可以随 session 同步和自动保存稳定保留 + +但仍需注意: + +1. 当前仍是阶段一兼容链路,核心桥接字段仍然是 `legacyResultProfile` +2. 正式发布链 `publish_world` 还没有在当前阶段打通 +3. 前端仍依赖 `buildCustomWorldProfileFromAgentDraft()` 作为 session -> 结果页兼容编译层 + +因此本轮结论是: + +**当前“前端修改 -> 自动保存 -> 数据库”主链可用;本次已补上图片与动作资产在 session 重编译阶段的回退风险。** diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md new file mode 100644 index 00000000..6ad80bc5 --- /dev/null +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md @@ -0,0 +1,1032 @@ +# 当前创作流程链路前后端脚本重构执行方案 + +更新时间:`2026-04-21` + +## 0. 文档目标 + +本文件只服务一件事: + +**把当前“创作入口 -> Agent 会话 -> 世界底稿 -> 结果页编辑 -> 自动保存 -> 作品库 -> 进入世界”这条链路上的前后端脚本,整理成一份可以直接指导后续编码拆分的执行方案。** + +本轮不直接改业务逻辑,只明确: + +1. 当前链路上的真实脚本地图 +2. 当前可读性差、可扩展性差的结构性问题 +3. 目标分层与真相源边界 +4. 文件级拆分建议 +5. 分阶段落地计划与验收标准 + +--- + +## 1. 范围与依据 + +### 1.1 本文覆盖的创作链路 + +```text +平台创作入口 +-> Agent session 创建 / 恢复 +-> Agent 对话与 action 执行 +-> foundation draft 生成 +-> 角色图 / 地点图 / 分幕图 / 动作资产生成与同步 +-> 结果页编辑 +-> 自动保存到作品库 +-> works 聚合展示 / 恢复创作 +-> 进入游戏世界 +``` + +### 1.2 本文主要依据 + +1. `docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md` +2. `docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md` +3. `docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md` +4. `docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md` +5. `docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md` +6. `docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md` +7. `docs/technical/AGENT_DRAFT_RESULT_AUTOSAVE_DB_CHECK_2026-04-21.md` + +--- + +## 2. 当前链路脚本地图 + +## 2.1 前端主链脚本 + +| 文件 | 当前职责 | 当前问题 | +| --- | --- | --- | +| `src/components/game-shell/PreGameSelectionFlow.tsx` | 平台 tab、详情页、创作入口、Agent session 创建与恢复、operation 轮询、结果页自动保存、session 同步、进入世界前同步、works/library/gallery/history/save 拉取 | 单文件承载过多流程编排,页面壳层、状态机、网络请求、自动保存和世界进入逻辑混在一起,是当前前端最大热点 | +| `src/components/custom-world-home/CustomWorldCreationHub.tsx` | 创作中心 works 展示、继续创作、草稿与已发布作品入口 | 读模型已经存在,但与平台壳层仍有状态耦合,入口职责还没有完全收口 | +| `src/components/CustomWorldResultView.tsx` | 结果页预览、结果页内生成角色/地点、结果页内触发编辑、资产调试面板 | 结果页同时是预览层、编辑层、生成层,仍保留 legacy profile 直改能力 | +| `src/components/CustomWorldEntityEditorModal.tsx` | 世界、封面、营地、角色、地点等多种对象编辑,以及部分资产与运行时预览能力 | 单文件过大,编辑表单、资产工作流、运行时预览混合,后续很难局部扩展 | +| `src/components/CustomWorldRoleAssetStudioModal.tsx` | 角色主图候选、动作生成、动作发布、缓存读写 | 视觉生成、动作生成、缓存、发布四类职责耦合在一个模态层里 | +| `src/services/customWorldAgentDraftResult.ts` | `Agent session draftProfile -> legacy CustomWorldProfile` 的桥接编译与资产合并 | 前端承担了结构化编译责任,是“前端只做表现”边界下最应继续收缩的兼容层 | +| `src/services/aiService.ts` | Agent session、消息流、operation、旧 custom world 相关请求、若干结果页生成动作 | custom world 相关 API 与 story/chat/legacy AI 接口混放,不利于链路收口 | +| `src/services/storageService.ts` | works、library、gallery、browse history、save archive、profile dashboard | custom world 作品链 API 与通用 runtime 存储 API 混放,边界不清晰 | + +## 2.2 后端主链脚本 + +| 文件 | 当前职责 | 当前问题 | +| --- | --- | --- | +| `packages/shared/src/contracts/customWorldAgent.ts` | session、message、draft、asset、works、action、operation 契约 | 契约体量过大,action 定义与后端真实执行能力存在漂移 | +| `server-node/src/routes/customWorldAgent.ts` | Agent session 与 action 路由入口 | 应继续保持薄路由,但当前下游编排层过重,路由可读性受限 | +| `server-node/src/routes/runtimeRoutes.ts` | works、library、gallery、runtime 相关通用路由 | custom world 作品链路仍混在 runtime 大路由里,不利于独立演进 | +| `server-node/src/services/customWorldAgentOrchestrator.ts` | session 生命周期、消息处理、action 分发、result sync、派生状态拼装、suggested actions、质量状态拼装 | 当前后端最大热点之一,承担了过多业务分支和字段同步细节 | +| `server-node/src/services/customWorldAgentSessionStore.ts` | session 创建、读写、兼容旧结构补齐、snapshot 输出 | store、兼容转换、session factory 三类职责还没有拆开 | +| `server-node/src/services/customWorldAgentFoundationDraftService.ts` | eight-anchor / intent -> foundation draft;内部依赖 runtime profile 编译再转回 draft | 存在“先编 legacy runtime profile,再转回 foundation draft”的双重编译 | +| `server-node/src/modules/custom-world/runtimeProfile.ts` | custom world runtime profile 规范化、构建、编译、属性 schema、场景章节处理 | 文件过大,normalize/build/schema/scene/role 等职责全部堆在一起 | +| `server-node/src/repositories/runtimeRepository.ts` | save/settings/custom world profiles/custom world sessions/browse history 等仓储 | 仓储按技术分组,不按领域分组;custom world 相关方法和通用 runtime 方法耦合 | +| `server-node/src/services/customWorldWorkSummaryService.ts` | 聚合 Agent 草稿与已发布 profile,生成 works 读模型 | 汇总逻辑、展示语义、metadata 回退混在一起,适合作为独立 read model 层继续收口 | + +## 2.3 当前链路上的次级执行模块 + +以下脚本不是主入口,但属于链路中的重要执行点,后续重构不能绕开: + +1. `server-node/src/services/customWorldAgentEntityGenerationService.ts` +2. `server-node/src/services/customWorldAgentAutoAssetService.ts` +3. `server-node/src/services/customWorldAgentAssetBridgeService.ts` +4. `server-node/src/services/customWorldAgentDraftCompiler.ts` +5. `server-node/src/services/customWorldAgentRoleAssetStateService.ts` + +这些模块后续要继续明确边界: + +1. 生成型 service 只负责生成结果 +2. bridge / sync 型 service 只负责把已确认结果写回 session +3. snapshot / read model 型 service 只负责组织前端展示数据 + +--- + +## 3. 当前结构性问题 + +## 3.1 前端壳层承担了过多编排责任 + +`PreGameSelectionFlow.tsx` 当前既是平台页面壳层,又是创作流程控制器,还同时负责: + +1. Agent session 创建与恢复 +2. operation 轮询 +3. works、gallery、history、save、dashboard 拉取 +4. 结果页自动保存 +5. 结果页改动同步回 session +6. 进入世界前同步 + +这会导致: + +1. 页面组件一改就容易碰到主链数据流 +2. 自动保存与页面切换耦合过紧 +3. 后续任何新增 action 都会继续堆进壳层文件 + +## 3.2 前端仍在承担结果 profile 编译责任 + +`buildCustomWorldProfileFromAgentDraft()` 仍是当前 session draft -> result profile 的关键桥接点。 + +这意味着: + +1. Agent session 不是最终唯一真相源 +2. 前端在裁决字段取舍、默认值、资产合并 +3. 自动保存和进入世界依赖的是前端重编译结果,而不是服务端正式输出 + +这与“前端只做表现,逻辑和数据收回 Express 后端”的项目约束不一致。 + +## 3.3 结果页仍是 legacy 编辑器兼容工作台 + +`CustomWorldResultView.tsx`、`CustomWorldEntityEditorModal.tsx`、`CustomWorldRoleAssetStudioModal.tsx` 当前仍然存在: + +1. 直接改 legacy `CustomWorldProfile` +2. 直接调用 legacy 生成接口补角色、地点 +3. 资产工坊直接处理缓存、候选、发布 + +结果页就不只是“预览 / 发布前收口层”,而是独立并行编辑器。 + +## 3.4 后端编排层和编译层都过重 + +`customWorldAgentOrchestrator.ts` 与 `runtimeProfile.ts` 是当前两个最明显的大文件热点: + +1. 一个同时承担 message orchestration、action dispatch、result sync、snapshot 拼装 +2. 一个同时承担 runtime normalize、schema build、scene chapter compile、role build + +结果是: + +1. 新增 action 成本高 +2. 新增字段时容易遗漏多个分支 +3. 单测很难精准覆盖局部职责 + +## 3.5 works、library、publish、enter world 仍没有完全收成单一路径 + +当前仍然是: + +1. Agent session 草稿链 +2. 已保存 profile 链 +3. 结果页 legacy profile 直改链 +4. works 聚合读模型链 + +多条 pipeline 并存会继续放大桥接层复杂度。 + +--- + +## 4. 目标分层架构 + +## 4.1 目标原则 + +后续重构必须统一遵守 5 条原则: + +1. **Agent session 是创作态唯一真相源。** +2. **服务端编译结果预览是结果页唯一数据来源。** +3. **published profile 是进入世界与作品库持久化的正式真相。** +4. **前端只保留展示状态、交互状态、表单草稿态,不再承担结构化编译。** +5. **action 能力由后端注册表统一声明,前端不再假设 contract 中定义的 action 一定真实可用。** + +## 4.2 目标链路 + +```text +前端平台壳层 +-> custom world 专属 client +-> Agent route / works route / library route +-> session application service +-> action registry +-> foundation / entity / asset / publish domain services +-> session store / custom world repository +-> 服务端 result preview compiler +-> 前端结果页展示与编辑 +-> 服务端 autosave / publish / enter-world gate +``` + +## 4.3 三类真相源 + +后续必须严格区分三类数据: + +| 数据 | 真相位置 | 用途 | +| --- | --- | --- | +| 创作态 session | Agent session store | 对话、草稿、锁定、suggested actions、asset 覆盖率、阶段状态 | +| 结果页预览态 result preview | 服务端 preview compiler 输出 | 结果页展示、结果页局部编辑回填、自动保存前比对 | +| 已发布世界 profile | custom world profile repository | 作品库、发布态、进入世界、对外展示 | + +前端不再把 `draftProfile -> runtime profile` 编译结果视为正式真相,只能把它视为临时兼容输出,且这条兼容层要持续收缩。 + +--- + +## 4.4 RPG 创作流程脚本命名规范 + +这套创作流程只服务 **RPG 类型游戏**,后续命名不能继续沿用过于泛化的 `customWorld`、`runtime`、`flow` 混搭口径,而应把“RPG 创作域”显式写进命名里。 + +### 命名目标 + +1. 让人一眼看出这是 **RPG 世界创作链**,不是通用世界编辑器。 +2. 让文件名能直接表达层级:页面壳层、工作流、应用服务、编译器、仓储、读模型。 +3. 避免继续出现“同一文件名里既有业务域又有历史兼容语义”的情况。 + +### 推荐命名根 + +后续新建或重命名文件时,优先使用下面 3 类命名根: + +1. `rpgCreation`:用于前端创作流程壳层、workflow、client、view model +2. `rpgWorld`:用于后端世界草稿、世界预览、世界发布、世界仓储 +3. `rpgAgent`:用于 Agent session、message turn、action executor、snapshot + +### 命名规则 + +1. 前端组件文件使用 `RpgCreation` 前缀。 +2. 前端 hooks / workflow 文件使用 `useRpgCreation` 前缀。 +3. 前端 client / adapter / mapper 文件使用 `rpgCreation` 前缀。 +4. 后端应用服务文件使用 `RpgAgent` 或 `RpgWorld` 前缀。 +5. 后端仓储文件使用 `RpgWorld...Repository` 或 `RpgAgent...Repository`。 +6. 共享契约文件使用 `rpgCreation...` 或 `rpgAgent...` 小驼峰命名。 +7. 禁止再新增过于泛化的 `customWorld*Service.ts`、`customWorld*Flow.tsx` 作为新主命名。 + +### 文件命名示例 + +#### 前端示例 + +1. `src/components/game-shell/rpg-creation-flow/RpgCreationShell.tsx` +2. `src/components/game-shell/rpg-creation-flow/useRpgCreationPlatformBootstrap.ts` +3. `src/components/game-shell/rpg-creation-flow/useRpgCreationSessionController.ts` +4. `src/components/rpg-creation-result/RpgCreationResultView.tsx` +5. `src/components/rpg-creation-editor/RpgCreationRoleEditorSection.tsx` +6. `src/services/rpg-creation/rpgCreationAgentClient.ts` +7. `src/services/rpg-creation/rpgCreationPreviewAdapter.ts` + +#### 后端示例 + +1. `server-node/src/routes/rpgCreationAgentRoutes.ts` +2. `server-node/src/routes/rpgWorldLibraryRoutes.ts` +3. `server-node/src/services/RpgAgentOrchestrator.ts` +4. `server-node/src/services/RpgAgentActionRegistry.ts` +5. `server-node/src/services/RpgWorldPreviewCompiler.ts` +6. `server-node/src/repositories/RpgWorldProfileRepository.ts` +7. `server-node/src/repositories/RpgAgentSessionRepository.ts` + +#### 共享契约示例 + +1. `packages/shared/src/contracts/rpgAgentSession.ts` +2. `packages/shared/src/contracts/rpgAgentActions.ts` +3. `packages/shared/src/contracts/rpgCreationPreview.ts` +4. `packages/shared/src/contracts/rpgCreationWorkSummary.ts` + +### 兼容期命名策略 + +因为当前主链已有大量 `customWorld*` 文件,重构期间采用“两段式迁移”: + +1. 第一阶段先新增按规范命名的新目录和 façade。 +2. 第二阶段再把旧 `customWorld*` 文件逐步迁到 `rpgCreation* / rpgWorld* / rpgAgent*` 命名。 + +### 命名禁忌 + +后续重构中禁止继续出现以下命名问题: + +1. 一个文件名同时表达多个层级,例如 `FlowServiceController`。 +2. 用 `runtime` 指代创作态脚本。 +3. 用 `customWorld` 指代实际上只服务 RPG 创作链的新模块。 +4. 用 `Helper`、`Utils`、`Manager` 作为主业务模块名。 + +--- + +## 5. 前端重构拆分方案 + +## 5.1 `PreGameSelectionFlow.tsx` 拆分方案 + +### 现状问题 + +当前文件同时承担“平台壳层 + 数据加载器 + 创作流程控制器 + 自动保存协调器 + 世界进入协调器”五类职责。 + +### 目标拆分 + +保留 `PreGameSelectionFlow.tsx` 作为页面壳层,只负责: + +1. stage 切换 +2. 组件装配 +3. 视觉级 loading / error 展示 + +从该文件中拆出以下模块: + +1. `src/components/game-shell/custom-world-flow/useCustomWorldPlatformBootstrap.ts` +2. `src/components/game-shell/custom-world-flow/useCustomWorldWorkEntries.ts` +3. `src/components/game-shell/custom-world-flow/useCustomWorldAgentSessionController.ts` +4. `src/components/game-shell/custom-world-flow/useCustomWorldAgentOperationPolling.ts` +5. `src/components/game-shell/custom-world-flow/useCustomWorldResultAutosave.ts` +6. `src/components/game-shell/custom-world-flow/useCustomWorldEnterWorld.ts` +7. `src/components/game-shell/custom-world-flow/useCustomWorldDetailNavigation.ts` + +### 编码要求 + +1. 壳层文件内不再直接拼接 route path。 +2. 壳层文件内不再直接包含自动保存防抖实现。 +3. 壳层文件内不再直接包含 session -> result profile 编译细节。 +4. 壳层文件内不再直接处理 works/library/history/save 的多路请求编排。 + +## 5.2 custom world 专属 client 拆分方案 + +### 现状问题 + +`aiService.ts` 和 `storageService.ts` 中 custom world 相关接口已经比较多,继续堆在通用 service 里会加重跨域耦合。 + +### 目标拆分 + +新增 custom world 专属 client 目录: + +1. `src/services/custom-world/customWorldAgentClient.ts` +2. `src/services/custom-world/customWorldWorkClient.ts` +3. `src/services/custom-world/customWorldLibraryClient.ts` +4. `src/services/custom-world/customWorldAssetClient.ts` + +### 保留边界 + +1. `aiService.ts` 只保留 story/chat/通用 AI 能力。 +2. `storageService.ts` 只保留 save/settings/profile dashboard 等通用 runtime 存储。 +3. custom world 相关请求全部从通用 service 中迁出后,旧导出保留一个阶段的兼容别名,再统一删除。 + +## 5.3 `CustomWorldResultView.tsx` 拆分方案 + +### 现状问题 + +该文件同时承担结果概览、实体目录、结果页动作、局部生成动作和调试面板。 + +### 目标拆分 + +保留 `CustomWorldResultView.tsx` 作为结果页组合壳层,并拆出: + +1. `src/components/custom-world-result/CustomWorldResultHeader.tsx` +2. `src/components/custom-world-result/CustomWorldResultActionBar.tsx` +3. `src/components/custom-world-result/CustomWorldResultEntitySection.tsx` +4. `src/components/custom-world-result/CustomWorldAssetCoveragePanel.tsx` +5. `src/components/custom-world-result/CustomWorldAssetDebugPanel.tsx` +6. `src/components/custom-world-result/useCustomWorldResultActions.ts` + +### 边界要求 + +1. 结果页只调用 custom world 专属 client。 +2. 结果页不再直接依赖 legacy 生成函数作为长期主链能力。 +3. 调试面板与正式结果页逻辑隔离,避免调试代码继续污染主流程组件。 + +## 5.4 `CustomWorldEntityEditorModal.tsx` 拆分方案 + +### 现状问题 + +该文件把 world / cover / camp / role / landmark / chapter 编辑全部放在一个文件里,且还混入运行时预览与资产工作流。 + +### 目标拆分 + +保留 modal 壳层,拆出: + +1. `src/components/custom-world-editor/CustomWorldWorldEditorSection.tsx` +2. `src/components/custom-world-editor/CustomWorldCoverEditorSection.tsx` +3. `src/components/custom-world-editor/CustomWorldCampEditorSection.tsx` +4. `src/components/custom-world-editor/CustomWorldRoleEditorSection.tsx` +5. `src/components/custom-world-editor/CustomWorldLandmarkEditorSection.tsx` +6. `src/components/custom-world-editor/CustomWorldSceneChapterEditorSection.tsx` +7. `src/components/custom-world-editor/customWorldResultFormMapper.ts` + +### 边界要求 + +1. 编辑 section 只负责表单表现。 +2. 提交 patch、差异比较、字段清洗都收口到独立 mapper / mutation 层。 +3. 运行时预览和战斗预览不继续堆在结果编辑主链文件里。 + +## 5.5 `CustomWorldRoleAssetStudioModal.tsx` 拆分方案 + +### 现状问题 + +角色图候选、动作模板、动作生成、缓存恢复、结果发布都在一个文件里,后续加“场景资产工坊”时会继续复制相同问题。 + +### 目标拆分 + +建议拆为: + +1. `src/components/custom-world-asset-studio/useRoleVisualCandidateWorkflow.ts` +2. `src/components/custom-world-asset-studio/useRoleAnimationWorkflow.ts` +3. `src/components/custom-world-asset-studio/roleAssetStudioModel.ts` +4. `src/components/custom-world-asset-studio/roleAssetStudioPublishClient.ts` +5. `src/components/custom-world-asset-studio/CustomWorldRoleAssetStudioModal.tsx` + +### 边界要求 + +1. 缓存模型独立于 UI。 +2. 发布动作只负责把“已确认资产结果”提交给后端。 +3. 动作生成参数模板不要散落在 UI 文件里。 + +## 5.6 `customWorldAgentDraftResult.ts` 收缩方案 + +### 当前定位 + +这是当前最重要的前端兼容桥接层。 + +### 过渡策略 + +阶段一不直接删除,但要改名并收缩定位: + +1. 建议迁为 `src/services/custom-world/customWorldResultPreviewAdapter.ts` +2. 只保留“服务端 result preview -> 前端 view model”的轻量适配 +3. 禁止继续在其中新增业务裁决和字段拼装逻辑 + +### 长期目标 + +服务端提供正式 `result preview` 输出后,前端不再执行 `draftProfile -> CustomWorldProfile` 编译,本文件可以在后续阶段物理删除。 + +--- + +## 6. 后端重构拆分方案 + +## 6.1 route 层收口 + +### 当前问题 + +`customWorldAgent.ts` 与 `runtimeRoutes.ts` 的下游能力边界还不够清晰,custom world 作品链仍混在 runtime 大路由里。 + +### 目标拆分 + +1. `server-node/src/routes/customWorldAgent.ts` 只保留 Agent session / action / operation 路由。 +2. 从 `server-node/src/routes/runtimeRoutes.ts` 中拆出: + - `server-node/src/routes/customWorldWorks.ts` + - `server-node/src/routes/customWorldLibrary.ts` + - `server-node/src/routes/customWorldGallery.ts` + +### 编码要求 + +1. 路由层只做鉴权、请求校验、应用服务调用、响应映射。 +2. 不在路由层拼装 session 派生状态。 +3. 不在路由层做 draft / profile 字段兼容转换。 + +## 6.2 `customWorldAgentOrchestrator.ts` 拆分方案 + +### 当前问题 + +该文件当前同时承担: + +1. message turn 处理 +2. action 分发 +3. session 读写 +4. result profile sync +5. suggested actions 生成 +6. 派生状态与 operation 文案拼装 + +### 目标拆分 + +保留 `customWorldAgentOrchestrator.ts` 作为应用服务 façade,只负责主链事务编排,并拆出: + +1. `server-node/src/services/customWorldAgentMessageTurnService.ts` +2. `server-node/src/services/customWorldAgentActionRegistry.ts` +3. `server-node/src/services/customWorldAgentActionExecutors/` +4. `server-node/src/services/customWorldAgentResultSyncService.ts` +5. `server-node/src/services/customWorldAgentSuggestedActionService.ts` +6. `server-node/src/services/customWorldAgentSnapshotBuilder.ts` +7. `server-node/src/services/customWorldAgentQualityGateService.ts` + +### 关键要求 + +1. `action -> executor` 必须通过注册表映射,不再在 orchestrator 里堆分支。 +2. `publish_world`、`generate_scene_assets`、`expand_long_tail` 等 contract 中存在的 action,要么接入真实 executor,要么明确标记为未开放并返回禁用原因。 +3. `sync_result_profile` 的字段回写细节只允许出现在独立 sync service 中。 + +## 6.3 `customWorldAgentSessionStore.ts` 拆分方案 + +### 当前问题 + +store 当前混合了: + +1. session factory +2. session persistence +3. 旧结构兼容补齐 +4. snapshot 输出 + +### 目标拆分 + +1. `server-node/src/services/customWorldAgentSessionFactory.ts` +2. `server-node/src/services/customWorldAgentSessionCompatibility.ts` +3. `server-node/src/services/customWorldAgentSessionStore.ts` +4. `server-node/src/repositories/customWorldAgentSessionRepository.ts` + +### 关键要求 + +1. session store 只保留高层读写接口。 +2. 兼容 legacy session 的逻辑单独隔离,避免污染新字段演进。 +3. persistence adapter 不再依赖 runtime 仓储大文件直接暴露全部能力。 + +## 6.4 `customWorldAgentFoundationDraftService.ts` 拆分方案 + +### 当前问题 + +当前 foundation draft 生成还带着“runtime profile 双重编译”的历史包袱。 + +### 目标拆分 + +1. `server-node/src/services/customWorldAgentFoundationInputBuilder.ts` +2. `server-node/src/services/customWorldAgentFoundationLlmService.ts` +3. `server-node/src/services/customWorldAgentFoundationNormalizer.ts` +4. `server-node/src/services/customWorldAgentFoundationDraftService.ts` +5. `server-node/src/services/customWorldAgentResultPreviewCompiler.ts` + +### 关键要求 + +1. foundation draft 生成与 runtime preview 编译彻底拆开。 +2. 不再通过“先编 legacy runtime profile,再转回 foundation draft”维持主链。 +3. `legacyResultProfile` 只作为阶段性兼容字段存在,禁止继续扩大依赖面。 + +## 6.5 `runtimeProfile.ts` 拆分方案 + +### 当前问题 + +这是当前 custom world runtime 编译中心,但文件过大,后续继续在原文件上叠加只会放大维护成本。 + +### 目标拆分 + +新增目录: + +1. `server-node/src/modules/custom-world/runtime-profile/index.ts` +2. `server-node/src/modules/custom-world/runtime-profile/normalizeShared.ts` +3. `server-node/src/modules/custom-world/runtime-profile/normalizeRole.ts` +4. `server-node/src/modules/custom-world/runtime-profile/normalizeLandmark.ts` +5. `server-node/src/modules/custom-world/runtime-profile/normalizeSceneChapter.ts` +6. `server-node/src/modules/custom-world/runtime-profile/normalizeCamp.ts` +7. `server-node/src/modules/custom-world/runtime-profile/buildCompiledProfile.ts` +8. `server-node/src/modules/custom-world/runtime-profile/buildAttributeSchema.ts` +9. `server-node/src/modules/custom-world/runtime-profile/creatorIntentBridge.ts` + +### 过渡策略 + +1. 原 `runtimeProfile.ts` 先保留为 façade 导出层。 +2. 新调用逐步改走目录化模块。 +3. 旧导出在阶段性兼容完成后再统一清理。 + +## 6.6 仓储层与 works 聚合层拆分方案 + +### 当前问题 + +`runtimeRepository.ts` 与 `customWorldWorkSummaryService.ts` 目前都承担了过多跨域职责。 + +### 目标拆分 + +建议把 `runtimeRepository.ts` 拆成: + +1. `server-node/src/repositories/runtimeSaveRepository.ts` +2. `server-node/src/repositories/runtimeSettingsRepository.ts` +3. `server-node/src/repositories/customWorldProfileRepository.ts` +4. `server-node/src/repositories/customWorldAgentSessionRepository.ts` +5. `server-node/src/repositories/profileBrowseHistoryRepository.ts` + +建议把 `customWorldWorkSummaryService.ts` 拆成: + +1. `server-node/src/services/customWorldWorkSummaryAssembler.ts` +2. `server-node/src/services/customWorldWorkCoverResolver.ts` +3. `server-node/src/services/customWorldWorkSummaryService.ts` + +### 关键要求 + +1. works service 只做读模型聚合,不直接承担 session 兼容修补。 +2. repository 按领域而不是按技术杂糅方式拆开。 +3. works 聚合输出必须稳定支持“继续创作”和“进入世界”两个入口判定。 + +## 6.7 结果预览与发布链统一方案 + +### 当前问题 + +当前“结果页可进入世界”和“正式发布”还不是一条统一主链。 + +### 目标方案 + +后续增加服务端 result preview / publish gate 之后,统一为: + +```text +结果页编辑 +-> 服务端写回 session patch +-> 服务端编译 result preview +-> 自动保存或发布 +-> 发布校验 +-> 生成 published profile +-> 进入世界 +``` + +### 关键要求 + +1. 进入世界前不再绕开 publish gate。 +2. `qualityFindings` 与 blocker 要成为真实 gate,而不是仅展示字段。 +3. 自动保存保存的是“服务端确认后的 preview / profile”,而不是前端私有重编译产物。 + +--- + +## 7. 共享契约重构方案 + +## 7.1 `customWorldAgent.ts` 拆分建议 + +建议拆为以下契约文件,再由 index 统一 re-export: + +1. `packages/shared/src/contracts/customWorldAgentAnchors.ts` +2. `packages/shared/src/contracts/customWorldAgentDraft.ts` +3. `packages/shared/src/contracts/customWorldAgentActions.ts` +4. `packages/shared/src/contracts/customWorldAgentSession.ts` +5. `packages/shared/src/contracts/customWorldWorkSummary.ts` + +## 7.2 契约演进要求 + +后续 contract 调整需要新增两类约束: + +1. `supportedActions` 或等价能力矩阵,由后端真实注册表生成,前端只消费它,不再根据类型字面量自行假设按钮可用。 +2. `resultPreview` 或独立 preview contract,明确区分“session 草稿”和“结果页预览”。 + +## 7.3 兼容字段处理 + +以下字段或能力要被明确标为“兼容过渡态”: + +1. `legacyResultProfile` +2. 前端 `buildCustomWorldProfileFromAgentDraft()` +3. 结果页直接调用 legacy 生成函数补实体 + +所有新功能禁止再建立对这些兼容字段的新增依赖。 + +--- + +## 8. 测试与文档同步方案 + +## 8.1 测试分层 + +后续重构至少补齐 4 层测试: + +1. **unit tests**:针对 result sync、action registry、foundation normalizer、runtime preview compiler +2. **contract tests**:针对 session snapshot、works summary、preview contract +3. **integration tests**:覆盖“创建 session -> 发消息 -> draft foundation -> 编辑结果 -> 自动保存 -> 继续创作” +4. **regression tests**:覆盖角色图、地点图、分幕图、动作资产字段不会在 session 重编译中回退 + +## 8.2 fixture 要求 + +建议补充固定 fixture: + +1. 最小 eight-anchor session fixture +2. foundation draft fixture +3. result preview fixture +4. published profile fixture + +这样 runtime compiler、autosave、works 聚合三条链可以共享相同样本。 + +## 8.3 文档同步要求 + +每完成一个阶段,至少同步更新: + +1. 本执行方案 +2. `docs/technical/README.md` +3. 与本阶段对应的阶段性技术文档 +4. 若边界发生变化,还要同步相关 PRD / 审计文档口径 + +--- + +## 9. 可并行重构工作包 + +本次执行计划需要拆成多个可同时推进的工作部分,避免所有人都集中修改同一个热点文件。 + +并行原则如下: + +1. 每个工作包只负责一组清晰模块。 +2. 每个工作包必须有明确写入边界。 +3. 同一阶段允许并行,但禁止多人同时大改同一核心文件。 +4. 先做 façade 和新目录,再做调用迁移,最后做旧层清理。 + +## 9.1 工作包 A:命名规范与目录骨架 + +### 目标 + +先建立 RPG 创作域的新命名与目录骨架,给后续并行迁移提供统一落点。 + +### 负责范围 + +1. 前端 `src/components/game-shell/rpg-creation-flow/` +2. 前端 `src/components/rpg-creation-result/` +3. 前端 `src/components/rpg-creation-editor/` +4. 前端 `src/services/rpg-creation/` +5. 后端 `server-node/src/routes/` 下 RPG 创作相关新路由文件 +6. 后端 `server-node/src/services/` 下 RPG 创作相关 façade 文件 +7. 共享契约新文件骨架 + +### 写入边界 + +1. 可以新建目录和 façade 文件。 +2. 可以改文档和导出索引。 +3. 不负责大规模迁移老逻辑。 + +### 前置依赖 + +无,可最先开始。 + +## 9.2 工作包 B:前端平台壳层与流程编排拆分 + +### 目标 + +把 `PreGameSelectionFlow.tsx` 从大编排文件拆成壳层 + hooks。 + +### 负责范围 + +1. `PreGameSelectionFlow.tsx` +2. 平台 bootstrap、session controller、operation polling、detail navigation 相关 hooks +3. 平台侧 works / dashboard / save / history 拉取协调 + +### 写入边界 + +1. 主要修改前端壳层与流程 hooks。 +2. 不直接改后端 route / service 语义。 +3. 不承担结果页编辑器拆分。 + +### 前置依赖 + +最好在工作包 A 的目录骨架准备好后开始。 + +## 9.3 工作包 C:前端结果页与编辑器拆分 + +### 目标 + +把结果页、实体编辑器、角色资产工坊拆成组合壳层与独立 section / workflow。 + +### 负责范围 + +1. `CustomWorldResultView.tsx` +2. `CustomWorldEntityEditorModal.tsx` +3. `CustomWorldRoleAssetStudioModal.tsx` +4. 结果页 action hooks、表单 mapper、资产 workflow + +### 写入边界 + +1. 主要改结果页与编辑器相关前端组件。 +2. 允许补前端 view model 与 mapper。 +3. 不直接改平台壳层主状态编排。 + +### 前置依赖 + +依赖工作包 A 的命名规范与目录落点,和工作包 B 并行。 + +## 9.4 工作包 D:前端 custom world client 收口 + +### 目标 + +把 custom world 专属接口从 `aiService.ts`、`storageService.ts` 中迁出。 + +### 负责范围 + +1. `aiService.ts` +2. `storageService.ts` +3. 新增 `rpgCreation*Client` 文件 +4. 调整前端调用导入路径 + +### 写入边界 + +1. 只负责 API client 与请求封装。 +2. 不负责结果页 UI 拆分。 +3. 不负责后端业务实现重构。 + +### 前置依赖 + +依赖工作包 A 的命名和目录约束;可与 B、C 并行。 + +## 9.5 工作包 E:后端 Agent 编排拆分 + +### 目标 + +拆解 `customWorldAgentOrchestrator.ts`,引入 registry、snapshot builder、result sync service。 + +### 负责范围 + +1. `customWorldAgentOrchestrator.ts` +2. action registry +3. action executors +4. result sync service +5. snapshot builder +6. quality gate service + +### 写入边界 + +1. 主改后端应用服务层。 +2. 不负责 runtime profile 编译模块的目录化拆分。 +3. 不负责前端壳层迁移。 + +### 前置依赖 + +建议在工作包 A 后开始;可与 B、C、D 并行。 + +## 9.6 工作包 F:后端 session/store/repository 拆分 + +### 目标 + +拆出 session factory、compatibility、repository adapter,并把 custom world 仓储从 runtime 大仓储中分离。 + +### 负责范围 + +1. `customWorldAgentSessionStore.ts` +2. `runtimeRepository.ts` +3. `customWorldWorkSummaryService.ts` +4. 新 session repository / profile repository / work summary assembler + +### 写入边界 + +1. 主改后端持久化与读模型层。 +2. 不负责 action executor 细节。 +3. 不负责前端调用改造。 + +### 前置依赖 + +与工作包 E 有接口协作关系,但可以并行推进,最终通过 façade 汇合。 + +## 9.7 工作包 G:后端 preview compiler 与 runtime profile 目录化 + +### 目标 + +把 `runtimeProfile.ts` 拆成目录化模块,并引入服务端 result preview compiler。 + +### 负责范围 + +1. `runtimeProfile.ts` +2. `runtime-profile/` 新目录 +3. result preview compiler +4. foundation draft 与 preview 编译的边界收口 + +### 写入边界 + +1. 只负责编译层、normalize 层和 preview 输出。 +2. 不直接重构路由层。 +3. 不直接迁前端组件。 + +### 前置依赖 + +与工作包 E、F 并行,但在主链接入前需要先和 E 对齐 preview contract。 + +## 9.8 工作包 H:共享契约与测试基建 + +### 目标 + +拆分共享契约,补齐 fixture、contract tests、integration tests。 + +### 负责范围 + +1. `packages/shared/src/contracts/` +2. preview / action / session / works summary 契约 +3. fixture +4. unit / contract / integration / regression tests + +### 写入边界 + +1. 契约变更必须同步测试。 +2. 不直接承担业务 UI 拆分。 +3. 不直接承担数据库仓储重构。 + +### 前置依赖 + +可从工作包 A 开始先建骨架,随后跟随 B 到 G 持续补齐。 + +## 9.9 并行推进关系 + +推荐并行顺序如下: + +```text +第一批并行: +工作包 A + 工作包 H + +第二批并行: +工作包 B + 工作包 C + 工作包 D + 工作包 E + 工作包 F + 工作包 G + +第三批收口: +把 B~H 的 façade 接回主链 +-> 联调自动保存 / works / publish / enter world +-> 清理旧兼容层 +``` + +## 9.10 并行协作约束 + +为避免多人互相覆盖,本轮建议遵守: + +1. 工作包 B 独占 `PreGameSelectionFlow.tsx`。 +2. 工作包 C 独占 `CustomWorldResultView.tsx`、`CustomWorldEntityEditorModal.tsx`、`CustomWorldRoleAssetStudioModal.tsx`。 +3. 工作包 D 独占 `aiService.ts`、`storageService.ts`。 +4. 工作包 E 独占 `customWorldAgentOrchestrator.ts`。 +5. 工作包 F 独占 `customWorldAgentSessionStore.ts`、`runtimeRepository.ts`、`customWorldWorkSummaryService.ts`。 +6. 工作包 G 独占 `runtimeProfile.ts` 及其新目录。 +7. 工作包 H 独占 shared contracts 主文件和测试 fixture 总目录。 + +--- + +## 10. 分阶段落地计划 + +## Phase 0:冻结口径与清点兼容层 + +### 目标 + +把当前链路拆分前的边界先冻结,避免后续一边拆一边新增同类耦合。 + +### 工作项 + +1. 完成本文档并作为后续施工总口径 +2. 标记兼容层:`customWorldAgentDraftResult.ts`、`legacyResultProfile`、结果页 legacy 生成动作 +3. 列出当前 action contract 与真实 executor 的对照表 + +### 验收标准 + +1. 团队对“session / preview / published profile”三类真相源达成一致 +2. 新需求不再默认往 `PreGameSelectionFlow.tsx` 和 `customWorldAgentOrchestrator.ts` 继续堆逻辑 + +## Phase 1:目录骨架、命名规范与前端拆分并行启动 + +### 目标 + +先建立 RPG 创作域的新目录和命名规范,并把前端热点文件拆成可维护结构,但不改当前主流程行为。 + +### 工作项 + +1. 完成工作包 A +2. 完成工作包 B +3. 完成工作包 C +4. 完成工作包 D + +### 验收标准 + +1. `PreGameSelectionFlow.tsx` 只剩 stage 与组件装配逻辑 +2. 通用 service 中不再继续新增 custom world workflow 接口 +3. 自动保存、session 恢复、进入世界逻辑都有独立 hook / coordinator +4. 新增文件遵循 RPG 创作域命名规范 + +## Phase 2:后端应用服务、仓储、编译层并行拆分 + +### 目标 + +把后端主链从“大 orchestrator + 大 store + 大 compiler”拆成 registry + services + repositories + compiler modules。 + +### 工作项 + +1. 完成工作包 E +2. 完成工作包 F +3. 完成工作包 G +4. 工作包 H 同步补 contract 与测试 + +### 验收标准 + +1. `customWorldAgentOrchestrator.ts` 不再包含具体字段回写实现 +2. action 的启用 / 禁用状态可由后端统一描述 +3. session 兼容逻辑可以脱离 store 单独测试 +4. `runtimeProfile.ts` 已退化为 façade 或兼容导出层 + +## Phase 3:结果预览编译后移到后端 + +### 目标 + +消除前端“本地编译结果 profile”的主链地位。 + +### 工作项 + +1. 新增服务端 result preview compiler +2. 结果页改为消费服务端 preview +3. `customWorldAgentDraftResult.ts` 改为薄适配层 + +### 验收标准 + +1. 前端不再把 `buildCustomWorldProfileFromAgentDraft()` 作为正式编译步骤 +2. 自动保存与 session 同步都基于服务端确认后的 preview +3. 结果页字段回退问题不再依赖前端兼容修补 + +## Phase 4:发布链、自动保存链、进入世界链统一 + +### 目标 + +把“可玩”与“已发布”的门槛统一到后端。 + +### 工作项 + +1. 打通 publish gate +2. 把 `qualityFindings` / blocker 接成真实阻断条件 +3. enter world 统一走服务端发布态或明确允许的预览态 + +### 验收标准 + +1. 前端不能绕开 publish / gate 直接进世界 +2. works、library、enter world 三处状态语义一致 +3. 发布失败可以给出明确 blocker 与恢复入口 + +## Phase 5:兼容层清理 + +### 目标 + +在主链稳定后,物理清理历史桥接层和重复 pipeline。 + +### 工作项 + +1. 删除前端 draft result 编译桥 +2. 删除结果页 legacy 直改链的残余入口 +3. 清理 contract 中已废弃 action / 字段 + +### 验收标准 + +1. 创作主链只剩 session -> preview -> published profile 三层 +2. 不再存在“前端本地编译 profile 才能自动保存”的依赖 +3. 文档、契约、测试口径一致 + +--- + +## 11. 本次执行约束 + +后续按本文落地时,需要持续遵守以下约束: + +1. 不做大爆炸式重写,按阶段保留 façade 与兼容层。 +2. 不新开平行系统,优先在现有创作中心、结果页、Agent 工作区上做结构拆分。 +3. 前端新增文件优先按“壳层 / hook / client / section”拆,不把逻辑再塞回组件文件。 +4. 后端新增文件优先按“route / application service / domain service / repository / compiler”拆,不再继续扩大大文件。 +5. 每个阶段完成后同步文档与测试,不允许代码结构已经迁移但文档还停留在旧链路口径。 + +--- + +## 12. 结论 + +当前创作流程的核心问题,不是单点 bug,而是: + +**前端壳层、前端兼容编译层、后端编排层、后端 runtime 编译层同时过重,导致整条创作链处在“多条 pipeline 并存、桥接层过多、职责分层混乱”的过渡态。** + +后续重构的正确方向不是继续在热点文件上补判断,而是按本文把主链收成: + +**session 真相源 -> 服务端 preview 编译 -> published profile 发布态** + +只有这样,当前链路的可读性、可扩展性和后续功能落地稳定性,才会一起提升。 diff --git a/docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md b/docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md index 20c8aa9f..a9a02faf 100644 --- a/docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md +++ b/docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md @@ -2,6 +2,8 @@ 更新时间:`2026-04-21` +补充修正:`2026-04-21` 本文档的“草稿恢复优先回 Agent 工作区”和“Agent 来源结果页冻结为预览收口层”属于阶段性收口口径,已被 [AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md](./AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md) 覆盖。当前主口径是:Agent 对话框只收集八锚点,已有底稿的草稿从创作中心进入结果页继续完善。 + ## 1. 结论先行 当前这条 Agent 创作流已经完成阶段一到阶段三的主要收口。 diff --git a/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md b/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md new file mode 100644 index 00000000..90da8e88 --- /dev/null +++ b/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md @@ -0,0 +1,169 @@ +# 前端逻辑后移实施方案(2026-04-21) + +更新时间:`2026-04-21` + +## 1. 目标 + +本方案只回答一件事: + +**怎样把当前仍残留在前端的正式运行时逻辑、正式会话真相与正式生成编排,继续收回到 Express 后端。** + +这份文档不是泛泛而谈的方向说明,而是直接面向本轮与后续几轮编码落地的实施基线。 + +--- + +## 2. 本轮确定的硬边界 + +根据仓库约束与当前审计结果,本轮继续冻结以下边界: + +1. 前端只负责表现、输入采集、临时 UI 状态与服务端结果渲染。 +2. 后端负责正式鉴权、正式会话、正式运行时快照、正式任务生成、正式运行时物品意图生成、正式自定义世界生成。 +3. 浏览器内不再保存 access token,不再把浏览历史作为本地正式真相,不再保留正式 quest / runtime item / custom world 生成编排。 +4. 运行时主链必须继续向“前端提交意图,后端解释快照并返回展示模型”收敛。 + +--- + +## 3. 现状拆分 + +当前残留问题已经收敛为三批: + +### 3.1 第一批:正式真相仍在前端 + +1. `src/services/apiClient.ts` + - 浏览器仍保存 access token,并拼接 `Authorization: Bearer ...` +2. `src/services/authService.ts` + - 登录、微信绑定等流程仍把 access token 当作前端真相 +3. `src/components/game-shell/PreGameSelectionFlow.tsx` + - 浏览历史仍是本地写入 + 后端回填的双真相 +4. `src/services/platformBrowseHistory.ts` + - 维护浏览历史本地存储、迁移标记与同步状态 + +### 3.2 第二批:运行时主链仍依赖前端预写快照 + +1. `src/hooks/story/runtimeStoryCoordinator.ts` + - 在请求 runtime state / runtime action 前,仍先 `PUT /runtime/save/snapshot` +2. `src/hooks/story/npcEncounterActions.ts` + - 待接委托的“更换任务”“放弃任务”仍由前端正式结算 + +### 3.3 第三批:正式生成编排仍残留在浏览器 + +1. `src/services/questDirector.ts` +2. `src/services/runtimeItemAiDirector.ts` +3. `src/services/aiService.ts` 的 custom world profile 生成入口 +4. `src/services/ai.ts` 中仍保留的浏览器侧 legacy AI orchestration + +--- + +## 4. 分批实施策略 + +## 4.1 第一批:先收正式真相 + +### 鉴权 + +目标状态: + +1. 后端通过 HttpOnly Cookie 持有 refresh session 与 access session。 +2. 前端请求层不再读写 access token。 +3. 前端只监听鉴权状态事件,不解释 token 生命周期。 + +本批涉及: + +1. `server-node/src/auth/accessSessionCookie.ts` +2. `server-node/src/routes/authRoutes.ts` +3. `server-node/src/middleware/auth.ts` +4. `src/services/apiClient.ts` +5. `src/services/authService.ts` +6. `src/components/auth/AuthGate.tsx` + +### 浏览历史 + +目标状态: + +1. 浏览历史唯一真相在 `runtimeRepository`。 +2. 前端不再保留本地浏览历史、迁移标记、同步标记。 +3. 浏览历史只通过 `storageService` 读取和写入。 + +本批涉及: + +1. `src/components/game-shell/PreGameSelectionFlow.tsx` +2. `src/components/game-shell/PlatformHomeView.tsx` +3. `src/services/storageService.ts` +4. `src/services/platformBrowseHistory.ts` + +## 4.2 第二批:把 runtime story 快照解释权收回后端 + +目标状态: + +1. 前端不再通过单独的 `PUT /runtime/save/snapshot` 预写快照再触发动作。 +2. runtime state / runtime action 允许前端提交当前快照上下文,由后端内部决定是否写入、如何解释、何时持久化。 +3. NPC 待接委托的 replace / abandon / accept 全部走后端 runtime action。 + +建议实施方式: + +1. 扩展 `packages/shared/src/contracts/story.ts` + - `RuntimeStoryActionRequest` 增加可选 `snapshot` + - 新增 `RuntimeStoryStateRequest` +2. 新增 `POST /api/runtime/story/state/resolve` +3. `storyActionService` 内部统一处理“请求携带快照上下文时的服务端同步” +4. 把 `npc_chat_quest_offer_replace` / `npc_chat_quest_offer_abandon` 接到后端 runtime action + +## 4.3 第三批:把正式生成编排收成后端唯一出口 + +目标状态: + +1. `questDirector` 只保留轻量 SDK。 +2. `runtimeItemAiDirector` 只保留轻量 SDK。 +3. custom world profile 正式生成走后端 route。 +4. 浏览器侧 `src/services/ai.ts` 不再承担正式浏览器主链。 + +建议实施方式: + +1. `server-node/src/routes/runtimeRoutes.ts` + - 补 `custom-world/profile` 正式 route +2. `src/services/aiService.ts` + - custom world 入口改走后端 +3. `src/services/questDirector.ts` + - 只请求 `/api/runtime/quests/generate` +4. `src/services/runtimeItemAiDirector.ts` + - 只请求 `/api/runtime/items/runtime-intent` + +--- + +## 5. 本轮落地范围 + +本轮优先完成以下内容: + +1. 鉴权 access token 从前端 localStorage 后移到后端 Cookie。 +2. 浏览历史从前端本地真相后移到后端唯一真相。 +3. custom world profile 正式生成入口补齐后端 route,并把前端收成 SDK。 +4. `questDirector` / `runtimeItemAiDirector` 收缩为前端 SDK。 +5. runtime story contract 开始补“随请求提交快照上下文”的后端承接能力,并把 NPC 待接委托 replace / abandon 接到后端。 + +--- + +## 6. 验收标准 + +### 第一批验收 + +1. 浏览器中不再保存 access token。 +2. `fetchWithApiAuth` 不再拼接 Bearer token。 +3. 浏览历史仅通过远端接口读写。 +4. `src/services/platformBrowseHistory.ts` 不再是正式链路依赖。 + +### 第二批验收 + +1. `runtimeStoryCoordinator.ts` 不再在动作前独立 `PUT /runtime/save/snapshot`。 +2. `NPC` 待接委托 replace / abandon / accept 都以后端返回结果为准。 + +### 第三批验收 + +1. `questDirector.ts` 与 `runtimeItemAiDirector.ts` 不再保留正式 fallback orchestration。 +2. custom world profile 的浏览器正式入口不再直接 import legacy `./ai`。 + +--- + +## 7. 一句话结论 + +这轮迁移的重点不是“把几个 helper 挪到 server-node 目录”,而是: + +**把前端里仍然承担正式真相、正式运行时解释和正式生成编排的那一层职责,继续收回到 Express 后端。** diff --git a/docs/technical/README.md b/docs/technical/README.md index e98fb37a..5d9a63e1 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,7 @@ ## 文档列表 +- [FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md](./FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md):把鉴权、浏览历史、runtime story 快照、NPC 待接委托与正式生成编排继续后移到 Express 后端的实施方案与验收口径。 - [REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md](./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md):落实工程清理审计第一阶段后的仓库噪音清理范围、忽略规则闭合点与后续约束。 - [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md):后端提示词收口到 `server-node/src/prompts/` 的目录方案、兼容策略与后续新增规则。 - [CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md](./CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md):世界草稿生成失败后等待页误显示为“卡在编译草稿卡”的根因拆解、主链与增强链路边界,以及本次修复策略。 @@ -12,7 +13,9 @@ - [AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md):阶段一保持结果页深度编辑能力不变,同时把结果页完整世界快照同步回 Agent session 主链的方案说明。 - [AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md):阶段二把平台创作入口统一到聚合作品列表,并收紧 Agent 结果页的新增入口职责边界。 - [AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md):阶段三继续降级旧 pipeline,让创作中心只认 Agent 草稿与已发布作品,并把 Agent 结果页冻结为预览收口层。 +- [AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md](./AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md):修正 Agent 对话框与结果页职责边界,明确 Agent 只收集八锚点,已有底稿的精修进入结果页完成。 - [CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md](./CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md):对照当前优化计划核查四阶段完成度,并明确这轮只允许物理删除旧 `custom-world/sessions` 世界生成链,不误伤 Agent 主链与已保存作品兼容编辑链。 +- [CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):梳理当前创作入口到结果页自动保存再到进入世界的全链前后端脚本地图,并给出文件级重构拆分方案、目标分层与阶段验收标准。 - [CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md](./CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md):创作页移动端底部 Tab、亮色主题 token 与滚动权责修复记录。 - [TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md](./TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md):把外部仓库 TXT 模式完整迁入当前项目的冻结边界、模块映射、分阶段计划与验收清单。 - [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。 diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 6ee28cc6..62fdc2a8 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -78,7 +78,8 @@ export type AuthPhoneChangeResponse = { }; export type AuthRefreshResponse = { - token: string; + ok: true; + token?: string; }; export type AuthSessionSummary = { diff --git a/packages/shared/src/contracts/story.ts b/packages/shared/src/contracts/story.ts index 22a54340..025a0c1f 100644 --- a/packages/shared/src/contracts/story.ts +++ b/packages/shared/src/contracts/story.ts @@ -325,6 +325,9 @@ export const TASK6_RUNTIME_FUNCTION_IDS = [ 'forge_reforge', 'inventory_use', 'npc_gift', + 'npc_chat_quest_offer_abandon', + 'npc_chat_quest_offer_replace', + 'npc_chat_quest_offer_view', 'npc_quest_accept', 'npc_quest_turn_in', 'npc_trade', @@ -360,6 +363,9 @@ export type RuntimeStoryOptionInteraction = | 'help' | 'fight' | 'leave' + | 'quest_offer_abandon' + | 'quest_offer_replace' + | 'quest_offer_view' | 'recruit' | 'spar' | 'trade' @@ -481,7 +487,22 @@ export type RuntimeStoryPatch = }; export type RuntimeStoryActionRequest = - RuntimeActionRequest; + RuntimeActionRequest & { + snapshot?: SavedGameSnapshotInput; + }; + +export type RuntimeStoryStateRequest< + TSnapshotGameState = JsonObject, + TSnapshotCurrentStory = JsonObject, +> = { + sessionId: string; + clientVersion?: number; + snapshot?: SavedGameSnapshotInput< + TSnapshotGameState, + string, + TSnapshotCurrentStory + >; +}; export type RuntimeStoryActionResponse< TSnapshotGameState = JsonObject, diff --git a/server-node/src/auth/accessSessionCookie.ts b/server-node/src/auth/accessSessionCookie.ts new file mode 100644 index 00000000..54705270 --- /dev/null +++ b/server-node/src/auth/accessSessionCookie.ts @@ -0,0 +1,84 @@ +import type { Request, Response } from 'express'; + +import type { AppConfig } from '../config.js'; + +function buildCookieParts( + config: AppConfig, + value: string, + options: { + maxAgeSeconds: number; + }, +) { + const parts = [ + `${config.authSession.accessCookieName}=${encodeURIComponent(value)}`, + `Path=${config.authSession.accessCookiePath}`, + 'HttpOnly', + `SameSite=${config.authSession.accessCookieSameSite}`, + `Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`, + ]; + + if (config.authSession.accessCookieSecure) { + parts.push('Secure'); + } + + return parts.join('; '); +} + +function appendSetCookieHeader(response: Response, cookieValue: string) { + const currentHeader = response.getHeader('Set-Cookie'); + if (!currentHeader) { + response.setHeader('Set-Cookie', cookieValue); + return; + } + + if (Array.isArray(currentHeader)) { + response.setHeader('Set-Cookie', [...currentHeader, cookieValue]); + return; + } + + response.setHeader('Set-Cookie', [String(currentHeader), cookieValue]); +} + +export function setAccessSessionCookie( + response: Response, + config: AppConfig, + token: string, + maxAgeSeconds: number, +) { + appendSetCookieHeader( + response, + buildCookieParts(config, token, { + maxAgeSeconds, + }), + ); +} + +export function clearAccessSessionCookie(response: Response, config: AppConfig) { + appendSetCookieHeader( + response, + buildCookieParts(config, '', { + maxAgeSeconds: 0, + }), + ); +} + +export function readAccessSessionToken(request: Request, config: AppConfig) { + const cookieHeader = request.header('cookie')?.trim() || ''; + if (!cookieHeader) { + return ''; + } + + const cookieEntries = cookieHeader.split(';'); + for (const entry of cookieEntries) { + const [rawName, ...valueParts] = entry.split('='); + const name = rawName?.trim(); + if (name !== config.authSession.accessCookieName) { + continue; + } + + const rawValue = valueParts.join('=').trim(); + return rawValue ? decodeURIComponent(rawValue) : ''; + } + + return ''; +} diff --git a/server-node/src/config.ts b/server-node/src/config.ts index d59dc300..62aa943e 100644 --- a/server-node/src/config.ts +++ b/server-node/src/config.ts @@ -74,6 +74,11 @@ export type AppConfig = { mockAvatarUrl: string; }; authSession: { + accessCookieName: string; + accessCookieTtlSeconds: number; + accessCookieSecure: boolean; + accessCookieSameSite: 'Lax' | 'Strict' | 'None'; + accessCookiePath: string; refreshCookieName: string; refreshSessionTtlDays: number; refreshCookieSecure: boolean; @@ -274,6 +279,11 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig { 'AUTH_REFRESH_COOKIE_SAME_SITE', 'Lax', ); + const accessSameSite = readString( + env, + 'AUTH_ACCESS_COOKIE_SAME_SITE', + 'Lax', + ); return { nodeEnv, @@ -484,6 +494,30 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig { mockAvatarUrl: readString(env, 'WECHAT_MOCK_AVATAR_URL', ''), }, authSession: { + accessCookieName: readString( + env, + 'AUTH_ACCESS_COOKIE_NAME', + 'genarrative_access_session', + ), + accessCookieTtlSeconds: readPositiveInt( + env, + 'AUTH_ACCESS_COOKIE_TTL_SECONDS', + 7200, + ), + accessCookieSecure: readBoolean( + env, + 'AUTH_ACCESS_COOKIE_SECURE', + readString(env, 'NODE_ENV', 'development') === 'production', + ), + accessCookieSameSite: + accessSameSite === 'None' || accessSameSite === 'Strict' + ? (accessSameSite as AppConfig['authSession']['accessCookieSameSite']) + : 'Lax', + accessCookiePath: readString( + env, + 'AUTH_ACCESS_COOKIE_PATH', + '/', + ), refreshCookieName: readString( env, 'AUTH_REFRESH_COOKIE_NAME', diff --git a/server-node/src/middleware/auth.ts b/server-node/src/middleware/auth.ts index a67a415a..e7429d9b 100644 --- a/server-node/src/middleware/auth.ts +++ b/server-node/src/middleware/auth.ts @@ -1,5 +1,6 @@ import type { NextFunction, Request, Response } from 'express'; +import { readAccessSessionToken } from '../auth/accessSessionCookie.js'; import { verifyAccessToken } from '../auth/token.js'; import type { AppConfig } from '../config.js'; import { unauthorized } from '../errors.js'; @@ -16,9 +17,10 @@ function readBearerToken(request: Request) { export function requireJwtAuth(config: AppConfig, userRepository: UserRepository) { return async (request: Request, _response: Response, next: NextFunction) => { try { - const token = readBearerToken(request); + const token = + readBearerToken(request) || readAccessSessionToken(request, config); if (!token) { - throw unauthorized('缺少 Authorization Bearer Token'); + throw unauthorized('缺少登录凭证'); } const claims = await verifyAccessToken(token, config); diff --git a/server-node/src/modules/custom-world/runtimeProfile.ts b/server-node/src/modules/custom-world/runtimeProfile.ts index b4a706ff..039b1f2a 100644 --- a/server-node/src/modules/custom-world/runtimeProfile.ts +++ b/server-node/src/modules/custom-world/runtimeProfile.ts @@ -23,6 +23,8 @@ import type { CustomWorldRoleProfile, CustomWorldRoleSkill, RoleAttributeProfile, + SceneActBlueprint, + SceneChapterBlueprint, WorldAttributeSchema, WorldAttributeSlot, WorldType, @@ -83,6 +85,18 @@ const WORLD_ATTRIBUTE_SLOT_IDS = [ 'axis_e', 'axis_f', ] as const; +const SCENE_ACT_STAGES = new Set([ + 'opening', + 'expansion', + 'turning_point', + 'climax', + 'aftermath', +]); +const SCENE_ACT_ADVANCE_RULES = new Set([ + 'after_primary_contact', + 'after_active_step_complete', + 'after_chapter_resolution', +]); export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5; export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30; @@ -1434,6 +1448,105 @@ function normalizeItemList(value: unknown) { .filter((entry) => entry.name && entry.category); } +function normalizeSceneActStageCoverage(value: unknown) { + const stageCoverage = Array.isArray(value) + ? value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry): entry is SceneActBlueprint['stageCoverage'][number] => + SCENE_ACT_STAGES.has(entry as never), + ) + : []; + + return [...new Set(stageCoverage)]; +} + +function normalizeSceneActBlueprint( + value: unknown, + index: number, + sceneId: string, +): SceneActBlueprint | null { + const item = + value && typeof value === 'object' + ? (value as Record) + : null; + if (!item) { + return null; + } + + const encounterNpcIds = toStringArray(item.encounterNpcIds); + const stageCoverage = normalizeSceneActStageCoverage(item.stageCoverage); + const advanceRule = toText(item.advanceRule); + const title = toText(item.title); + const summary = toText(item.summary); + + if (!title && !summary && encounterNpcIds.length === 0) { + return null; + } + + return { + id: toText(item.id) || `saved-scene-act-${sceneId}-${index + 1}`, + sceneId, + title: title || `第 ${index + 1} 幕`, + summary: summary || title || `围绕${sceneId}继续推进`, + stageCoverage: + stageCoverage.length > 0 + ? stageCoverage + : index === 0 + ? ['opening'] + : ['climax', 'aftermath'], + backgroundImageSrc: toText(item.backgroundImageSrc) || undefined, + backgroundAssetId: toText(item.backgroundAssetId) || undefined, + encounterNpcIds, + primaryNpcId: toText(item.primaryNpcId) || encounterNpcIds[0] || '', + linkedThreadIds: toStringArray(item.linkedThreadIds), + advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never) + ? (advanceRule as SceneActBlueprint['advanceRule']) + : 'after_active_step_complete', + actGoal: toText(item.actGoal), + transitionHook: toText(item.transitionHook), + }; +} + +function normalizeSceneChapterBlueprints(value: unknown) { + if (!Array.isArray(value)) { + return null; + } + + const normalized = value + .filter( + (entry): entry is Record => + Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry), + ) + .map((entry, index) => { + const sceneId = toText(entry.sceneId); + if (!sceneId) { + return null; + } + + const acts = Array.isArray(entry.acts) + ? entry.acts + .map((act, actIndex) => + normalizeSceneActBlueprint(act, actIndex, sceneId), + ) + .filter((act): act is SceneActBlueprint => Boolean(act)) + : []; + + return { + id: toText(entry.id) || `saved-scene-chapter-${sceneId}-${index + 1}`, + sceneId, + title: toText(entry.title) || toText(entry.sceneName) || sceneId, + summary: toText(entry.summary), + linkedThreadIds: toStringArray(entry.linkedThreadIds), + linkedLandmarkIds: toStringArray(entry.linkedLandmarkIds), + acts, + } satisfies SceneChapterBlueprint; + }) + .filter((entry): entry is SceneChapterBlueprint => Boolean(entry)); + + return normalized.length > 0 ? normalized : null; +} + function normalizeLandmarks(params: { landmarks: Array>; storyNpcs: CustomWorldNpc[]; @@ -1655,6 +1768,9 @@ export function normalizeCustomWorldProfile( Array.isArray(item.threadContracts) ? (item.threadContracts as Array>) : null, + sceneChapterBlueprints: normalizeSceneChapterBlueprints( + item.sceneChapterBlueprints, + ), scenarioPackId: toText(item.scenarioPackId) || null, campaignPackId: toText(item.campaignPackId) || null, }; diff --git a/server-node/src/modules/quest/questStoryActionService.ts b/server-node/src/modules/quest/questStoryActionService.ts index 42f35395..de2ce3ac 100644 --- a/server-node/src/modules/quest/questStoryActionService.ts +++ b/server-node/src/modules/quest/questStoryActionService.ts @@ -1,4 +1,5 @@ import type { + RuntimeStoryOptionView, RuntimeStoryActionRequest, RuntimeStoryPatch, } from '../../../../packages/shared/src/contracts/story.js'; @@ -29,6 +30,9 @@ import { } from '../story/runtimeSession.js'; const SUPPORTED_QUEST_STORY_FUNCTION_IDS = new Set([ + 'npc_chat_quest_offer_abandon', + 'npc_chat_quest_offer_replace', + 'npc_chat_quest_offer_view', 'npc_quest_accept', 'npc_quest_turn_in', ]); @@ -37,6 +41,9 @@ type QuestStoryResolution = { actionText: string; resultText: string; patches: RuntimeStoryPatch[]; + storyText?: string; + presentationOptions?: RuntimeStoryOptionView[]; + savedCurrentStory?: JsonRecord; }; type JsonRecord = Record; @@ -140,6 +147,144 @@ function readPendingQuestOffer( return quest as RuntimeQuestLogEntry; } +function readPendingQuestOfferContext( + currentStory: unknown, + npcKey: string, +) { + if (!isObject(currentStory)) { + return null; + } + + const npcChatState = isObject(currentStory.npcChatState) + ? currentStory.npcChatState + : null; + const pendingQuestOffer = isObject(npcChatState?.pendingQuestOffer) + ? npcChatState.pendingQuestOffer + : null; + const quest = readPendingQuestOffer(currentStory, npcKey); + + if (!quest) { + return null; + } + + const dialogue = Array.isArray(currentStory.dialogue) + ? currentStory.dialogue + .filter((entry) => isObject(entry)) + .map((entry) => ({ ...entry })) + : []; + const turnCount = + typeof npcChatState?.turnCount === 'number' && + Number.isFinite(npcChatState.turnCount) + ? Math.max(0, Math.round(npcChatState.turnCount)) + : 0; + const customInputPlaceholder = + readString(npcChatState?.customInputPlaceholder) || '输入你想对 TA 说的话'; + + return { + dialogue, + turnCount, + customInputPlaceholder, + quest, + introText: readString(pendingQuestOffer?.introText), + }; +} + +function buildNpcChatOption( + encounter: RuntimeEncounter, + actionText: string, +) { + return { + functionId: 'npc_chat', + actionText, + text: actionText, + detailText: '', + interaction: { + kind: 'npc', + npcId: encounter.id ?? encounter.npcName, + action: 'chat', + }, + visuals: { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + } satisfies JsonRecord; +} + +function buildPendingQuestOfferOptions(encounter: RuntimeEncounter) { + const npcId = encounter.id ?? encounter.npcName; + const buildOption = ( + functionId: + | 'npc_chat_quest_offer_view' + | 'npc_chat_quest_offer_replace' + | 'npc_chat_quest_offer_abandon', + actionText: string, + action: 'quest_offer_view' | 'quest_offer_replace' | 'quest_offer_abandon', + ) => + ({ + functionId, + actionText, + text: actionText, + detailText: '', + interaction: { + kind: 'npc', + npcId, + action, + }, + visuals: { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + runtimePayload: + functionId === 'npc_chat_quest_offer_view' + ? { npcChatQuestOfferAction: 'view' } + : functionId === 'npc_chat_quest_offer_replace' + ? { npcChatQuestOfferAction: 'replace' } + : { npcChatQuestOfferAction: 'abandon' }, + }) satisfies JsonRecord; + + return [ + buildOption('npc_chat_quest_offer_view', '查看任务', 'quest_offer_view'), + buildOption( + 'npc_chat_quest_offer_replace', + '更换任务', + 'quest_offer_replace', + ), + buildOption( + 'npc_chat_quest_offer_abandon', + '放弃任务', + 'quest_offer_abandon', + ), + ]; +} + +function buildPostQuestOfferChatOptions(encounter: RuntimeEncounter) { + return [ + '那先继续聊聊你刚才没说完的部分', + '除了委托,你对眼前局势还有什么判断', + '先把这附近真正危险的地方说清楚', + ].map((actionText) => buildNpcChatOption(encounter, actionText)); +} + +function buildQuestOfferDialogueText( + encounter: RuntimeEncounter, + quest: RuntimeQuestLogEntry, +) { + const summaryText = readString(quest.summary) || readString(quest.description); + return `${encounter.npcName}沉吟了片刻,像是终于把真正想托付的事说了出来。${ + summaryText + ? `如果你愿意,我想把这件事正式交给你:${summaryText}` + : '如果你愿意,我想把眼前这件事正式交给你。' + }`; +} + function ensureEncounterQuestContext(session: RuntimeSession) { const state = session.rawGameState as unknown as RuntimeGameState; const encounter = getNpcEncounter(session, state); @@ -225,6 +370,171 @@ function resolveQuestAcceptAction( }; } +function resolveQuestOfferViewAction( + session: RuntimeSession, + currentStory?: unknown, +): QuestStoryResolution { + const { encounter, npcKey } = ensureEncounterQuestContext(session); + const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey); + if (!pendingOffer) { + throw conflict('当前没有待处理的委托可查看。'); + } + + return { + actionText: `查看${encounter.npcName}提出的委托`, + resultText: readString(pendingOffer.introText) || buildQuestOfferDialogueText(encounter, pendingOffer.quest), + patches: [], + }; +} + +function resolveQuestOfferReplaceAction( + session: RuntimeSession, + currentStory?: unknown, +): QuestStoryResolution { + const { state, encounter, npcKey } = ensureEncounterQuestContext(session); + const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey); + if (!pendingOffer) { + throw conflict('当前没有待处理的委托可更换。'); + } + + const nextQuest = buildQuestForEncounter({ + issuerNpcId: npcKey, + issuerNpcName: encounter.npcName, + roleText: encounter.context, + scene: state.currentScenePreset, + worldType: state.worldType, + context: { + worldType: state.worldType, + recentStoryMoments: Array.isArray(state.storyHistory) + ? state.storyHistory.slice(-6) + : [], + playerCharacter: state.playerCharacter ?? null, + playerProgression: state.playerProgression ?? null, + }, + currentQuests: (Array.isArray(state.quests) ? state.quests : []).map((item) => ({ + id: item.id, + issuerNpcId: item.issuerNpcId, + status: item.status, + })), + }); + + if (!nextQuest) { + throw conflict('当前没有更合适的委托可供更换。'); + } + + const dialogue = [ + ...pendingOffer.dialogue, + { + speaker: 'player', + text: '能不能换一份更适合眼下局势的委托?', + }, + { + speaker: 'npc', + speakerName: encounter.npcName, + text: buildQuestOfferDialogueText(encounter, nextQuest), + }, + ]; + + return { + actionText: `请${encounter.npcName}更换委托`, + resultText: buildQuestOfferDialogueText(encounter, nextQuest), + storyText: buildQuestOfferDialogueText(encounter, nextQuest), + savedCurrentStory: { + text: dialogue + .map((entry) => readString(entry.text)) + .filter(Boolean) + .join('\n'), + options: buildPendingQuestOfferOptions(encounter), + displayMode: 'dialogue', + dialogue, + streaming: false, + npcChatState: { + npcId: npcKey, + npcName: encounter.npcName, + turnCount: pendingOffer.turnCount, + customInputPlaceholder: pendingOffer.customInputPlaceholder, + pendingQuestOffer: { + quest: nextQuest, + }, + }, + }, + presentationOptions: buildPendingQuestOfferOptions(encounter).map((option) => ({ + functionId: readString(option.functionId), + actionText: readString(option.actionText), + detailText: '', + scope: 'npc', + interaction: isObject(option.interaction) + ? (option.interaction as RuntimeStoryOptionView['interaction']) + : undefined, + payload: isObject(option.runtimePayload) + ? (option.runtimePayload as Record) + : undefined, + })), + patches: [], + }; +} + +function resolveQuestOfferAbandonAction( + session: RuntimeSession, + currentStory?: unknown, +): QuestStoryResolution { + const { encounter, npcKey } = ensureEncounterQuestContext(session); + const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey); + if (!pendingOffer) { + throw conflict('当前没有待处理的委托可放弃。'); + } + + const npcReply = `${encounter.npcName}点了点头,没有继续强求,只把这份委托暂时收了回去。`; + const dialogue = [ + ...pendingOffer.dialogue, + { + speaker: 'player', + text: '这件事我先不接,咱们还是先聊别的。', + }, + { + speaker: 'npc', + speakerName: encounter.npcName, + text: npcReply, + }, + ]; + + return { + actionText: `暂不接受${encounter.npcName}的委托`, + resultText: npcReply, + storyText: npcReply, + savedCurrentStory: { + text: dialogue + .map((entry) => readString(entry.text)) + .filter(Boolean) + .join('\n'), + options: buildPostQuestOfferChatOptions(encounter), + displayMode: 'dialogue', + dialogue, + streaming: false, + npcChatState: { + npcId: npcKey, + npcName: encounter.npcName, + turnCount: pendingOffer.turnCount, + customInputPlaceholder: pendingOffer.customInputPlaceholder, + pendingQuestOffer: null, + }, + }, + presentationOptions: buildPostQuestOfferChatOptions(encounter).map((option) => ({ + functionId: readString(option.functionId), + actionText: readString(option.actionText), + detailText: '', + scope: 'npc', + interaction: isObject(option.interaction) + ? (option.interaction as RuntimeStoryOptionView['interaction']) + : undefined, + payload: isObject(option.runtimePayload) + ? (option.runtimePayload as Record) + : undefined, + })), + patches: [], + }; +} + function resolveQuestTurnInAction( session: RuntimeSession, request: RuntimeStoryActionRequest, @@ -311,6 +621,12 @@ export function resolveQuestStoryAction( } = {}, ): QuestStoryResolution { switch (request.action.functionId) { + case 'npc_chat_quest_offer_view': + return resolveQuestOfferViewAction(session, options.currentStory); + case 'npc_chat_quest_offer_replace': + return resolveQuestOfferReplaceAction(session, options.currentStory); + case 'npc_chat_quest_offer_abandon': + return resolveQuestOfferAbandonAction(session, options.currentStory); case 'npc_quest_accept': return resolveQuestAcceptAction(session, options.currentStory); case 'npc_quest_turn_in': diff --git a/server-node/src/modules/story/runtimeSession.ts b/server-node/src/modules/story/runtimeSession.ts index 7a87dfff..7e38ce43 100644 --- a/server-node/src/modules/story/runtimeSession.ts +++ b/server-node/src/modules/story/runtimeSession.ts @@ -738,6 +738,21 @@ function buildOptionInteraction( npc_spar: { kind: 'npc', npcId, action: 'spar' }, npc_trade: { kind: 'npc', npcId, action: 'trade' }, npc_gift: { kind: 'npc', npcId, action: 'gift' }, + npc_chat_quest_offer_view: { + kind: 'npc', + npcId, + action: 'quest_offer_view', + }, + npc_chat_quest_offer_replace: { + kind: 'npc', + npcId, + action: 'quest_offer_replace', + }, + npc_chat_quest_offer_abandon: { + kind: 'npc', + npcId, + action: 'quest_offer_abandon', + }, npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' }, npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' }, }; diff --git a/server-node/src/modules/story/storyActionRoutes.ts b/server-node/src/modules/story/storyActionRoutes.ts index f9bc124c..857e0f21 100644 --- a/server-node/src/modules/story/storyActionRoutes.ts +++ b/server-node/src/modules/story/storyActionRoutes.ts @@ -1,7 +1,10 @@ import { Router } from 'express'; import { z } from 'zod'; -import type { RuntimeStoryActionRequest } from '../../../../packages/shared/src/contracts/story.js'; +import type { + RuntimeStoryActionRequest, + RuntimeStoryStateRequest, +} from '../../../../packages/shared/src/contracts/story.js'; import type { AppContext } from '../../context.js'; import { badRequest } from '../../errors.js'; import { asyncHandler, sendApiResponse } from '../../http.js'; @@ -17,6 +20,7 @@ const actionPayloadSchema = z.record(z.string(), z.unknown()); const runtimeStoryActionSchema = z.object({ sessionId: z.string().trim().min(1), clientVersion: z.number().int().min(0).optional(), + snapshot: z.unknown().optional(), action: z.object({ type: z.literal('story_choice'), functionId: z.string().trim().min(1), @@ -25,6 +29,12 @@ const runtimeStoryActionSchema = z.object({ }), }); +const runtimeStoryStateResolveSchema = z.object({ + sessionId: z.string().trim().min(1), + clientVersion: z.number().int().min(0).optional(), + snapshot: z.unknown().optional(), +}); + export function createStoryActionRoutes(context: AppContext) { const router = Router(); const requireAuth = requireJwtAuth(context.config, context.userRepository); @@ -70,5 +80,25 @@ export function createStoryActionRoutes(context: AppContext) { }), ); + router.post( + '/state/resolve', + routeMeta({ operation: 'runtime.story.state.resolve' }), + asyncHandler(async (request, response) => { + const payload = runtimeStoryStateResolveSchema.parse( + request.body, + ) as RuntimeStoryStateRequest; + sendApiResponse( + response, + await getRuntimeStoryState({ + runtimeRepository: context.runtimeRepository, + userId: request.userId!, + sessionId: payload.sessionId, + clientVersion: payload.clientVersion, + snapshot: payload.snapshot, + }), + ); + }), + ); + return router; } diff --git a/server-node/src/modules/story/storyActionService.ts b/server-node/src/modules/story/storyActionService.ts index 15c84ef6..86203ef3 100644 --- a/server-node/src/modules/story/storyActionService.ts +++ b/server-node/src/modules/story/storyActionService.ts @@ -4,6 +4,7 @@ import type { RuntimeStoryActionResponse, RuntimeStoryOptionView, RuntimeStoryPatch, + RuntimeStoryStateRequest, } from '../../../../packages/shared/src/contracts/story.js'; import { conflict, invalidRequest } from '../../errors.js'; import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js'; @@ -59,6 +60,8 @@ type StoryResolution = { resultText: string; patches: RuntimeStoryPatch[]; storyText?: string; + presentationOptions?: RuntimeStoryOptionView[]; + savedCurrentStory?: JsonRecord; battle?: RuntimeBattlePresentation | null; toast?: string | null; }; @@ -604,6 +607,48 @@ function readSavedStoryText(currentStory: unknown) { return ''; } +function normalizeIncomingSnapshot(snapshot: unknown) { + if (!isObject(snapshot)) { + return null; + } + + const gameState = 'gameState' in snapshot ? snapshot.gameState : null; + const bottomTab = readString(snapshot.bottomTab) || 'adventure'; + const currentStory = 'currentStory' in snapshot ? snapshot.currentStory : null; + const savedAt = readString(snapshot.savedAt) || new Date().toISOString(); + + if (!gameState || !isObject(gameState)) { + return null; + } + + return normalizeSavedSnapshotPayload({ + savedAt, + bottomTab, + gameState, + currentStory: currentStory ?? null, + }); +} + +async function resolveSnapshotForRequest(params: { + runtimeRepository: RuntimeRepositoryPort; + userId: string; + snapshot?: unknown; +}) { + const incomingSnapshot = normalizeIncomingSnapshot(params.snapshot); + if (incomingSnapshot) { + return hydrateSavedSnapshot( + await params.runtimeRepository.putSnapshot(params.userId, incomingSnapshot), + )!; + } + + const persistedSnapshot = await params.runtimeRepository.getSnapshot(params.userId); + if (!persistedSnapshot) { + throw conflict('运行时快照不存在,请先初始化并保存一次游戏'); + } + + return hydrateSavedSnapshot(persistedSnapshot)!; +} + function buildFallbackStoryText(session: RuntimeSession) { if (session.inBattle && session.sceneHostileNpcs.length > 0) { return `眼前的冲突还没结束,${session.sceneHostileNpcs[0]!.name}仍在逼你立刻做出下一步判断。`; @@ -860,11 +905,11 @@ export async function resolveRuntimeStoryAction(params: { userId: string; request: RuntimeStoryActionRequest; }) { - const snapshot = await params.runtimeRepository.getSnapshot(params.userId); - if (!snapshot) { - throw conflict('运行时快照不存在,请先初始化并保存一次游戏'); - } - const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!; + const hydratedSnapshot = await resolveSnapshotForRequest({ + runtimeRepository: params.runtimeRepository, + userId: params.userId, + snapshot: params.request.snapshot, + }); const functionId = typeof params.request.action.functionId === 'string' @@ -968,6 +1013,12 @@ export async function resolveRuntimeStoryAction(params: { storyText, options, ); + if (resolution.presentationOptions?.length) { + options = resolution.presentationOptions; + } + if (resolution.savedCurrentStory) { + savedCurrentStory = resolution.savedCurrentStory; + } const pendingQuestAcceptedCurrentStory = functionId === 'npc_quest_accept' ? buildPendingQuestAcceptedCurrentStory({ @@ -1061,14 +1112,25 @@ export async function getRuntimeStoryState(params: { runtimeRepository: RuntimeRepositoryPort; userId: string; sessionId: string; + clientVersion?: number; + snapshot?: RuntimeStoryStateRequest['snapshot']; }) { - const snapshot = await params.runtimeRepository.getSnapshot(params.userId); - if (!snapshot) { - throw conflict('运行时快照不存在,请先初始化并保存一次游戏'); - } - const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!; + const hydratedSnapshot = await resolveSnapshotForRequest({ + runtimeRepository: params.runtimeRepository, + userId: params.userId, + snapshot: params.snapshot, + }); const session = loadRuntimeSession(hydratedSnapshot, params.sessionId); + if ( + typeof params.clientVersion === 'number' && + params.clientVersion !== session.runtimeVersion + ) { + throw conflict('运行时版本已变化,请先同步最新快照后再读取状态', { + clientVersion: params.clientVersion, + serverVersion: session.runtimeVersion, + }); + } ensureNpcInventorySessionState(session); const options = buildAvailableOptions(session); const storyText = diff --git a/server-node/src/routes/authRoutes.ts b/server-node/src/routes/authRoutes.ts index 4afe0e86..5461e28b 100644 --- a/server-node/src/routes/authRoutes.ts +++ b/server-node/src/routes/authRoutes.ts @@ -1,4 +1,4 @@ -import { type Request, Router } from 'express'; +import { type Request, type Response, Router } from 'express'; import { z } from 'zod'; import type { @@ -30,6 +30,10 @@ import { sendPhoneLoginCode, startWechatLogin, } from '../auth/authService.js'; +import { + clearAccessSessionCookie, + setAccessSessionCookie, +} from '../auth/accessSessionCookie.js'; import { clearRefreshSessionCookie, readRefreshSessionToken, @@ -112,6 +116,23 @@ function buildRefreshCookieLifetimeSeconds( ); } +function buildAccessCookieLifetimeSeconds(context: AppContext) { + return Math.max(0, context.config.authSession.accessCookieTtlSeconds); +} + +async function writeAccessSessionCookie( + context: AppContext, + response: Response, + token: string, +) { + setAccessSessionCookie( + response, + context.config, + token, + buildAccessCookieLifetimeSeconds(context), + ); +} + export function createAuthRoutes(context: AppContext) { const router = Router(); const requireAuth = requireJwtAuth(context.config, context.userRepository); @@ -145,6 +166,7 @@ export function createAuthRoutes(context: AppContext) { user, requestContext, ); + await writeAccessSessionCookie(context, response, result.token); setRefreshSessionCookie( response, context.config, @@ -223,6 +245,7 @@ export function createAuthRoutes(context: AppContext) { user, requestContext, ); + await writeAccessSessionCookie(context, response, result.token); setRefreshSessionCookie( response, context.config, @@ -298,6 +321,7 @@ export function createAuthRoutes(context: AppContext) { user, requestContext, ); + await writeAccessSessionCookie(context, response, result.token); setRefreshSessionCookie( response, context.config, @@ -309,7 +333,6 @@ export function createAuthRoutes(context: AppContext) { 302, buildAuthResultRedirectUrl(redirectPath, { auth_provider: 'wechat', - auth_token: result.token, auth_binding_status: result.user.bindingStatus, }), ); @@ -352,6 +375,7 @@ export function createAuthRoutes(context: AppContext) { user, requestContext, ); + await writeAccessSessionCookie(context, response, result.token); setRefreshSessionCookie( response, context.config, @@ -369,6 +393,7 @@ export function createAuthRoutes(context: AppContext) { const refreshToken = readRefreshSessionToken(request, context.config); try { const result = await refreshAuthSession(context, refreshToken); + await writeAccessSessionCookie(context, response, result.token); setRefreshSessionCookie( response, context.config, @@ -376,9 +401,11 @@ export function createAuthRoutes(context: AppContext) { buildRefreshCookieLifetimeSeconds(context, result.refreshExpiresAt), ); sendApiResponse(response, { + ok: true, token: result.token, }); } catch (error) { + clearAccessSessionCookie(response, context.config); clearRefreshSessionCookie(response, context.config); throw error; } @@ -479,6 +506,7 @@ export function createAuthRoutes(context: AppContext) { routeMeta({ operation: 'auth.logout_all' }), requireAuth, asyncHandler(async (request, response) => { + clearAccessSessionCookie(response, context.config); clearRefreshSessionCookie(response, context.config); sendApiResponse( response, @@ -498,6 +526,7 @@ export function createAuthRoutes(context: AppContext) { asyncHandler(async (request, response) => { const refreshToken = readRefreshSessionToken(request, context.config); await revokeRefreshSession(context, refreshToken); + clearAccessSessionCookie(response, context.config); clearRefreshSessionCookie(response, context.config); sendApiResponse( response, diff --git a/server-node/src/routes/runtimeRoutes.ts b/server-node/src/routes/runtimeRoutes.ts index b67a2538..ddcd13d5 100644 --- a/server-node/src/routes/runtimeRoutes.ts +++ b/server-node/src/routes/runtimeRoutes.ts @@ -7,6 +7,7 @@ import type { CustomWorldGalleryResponse, CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse, + GenerateCustomWorldProfileInput, PlatformBrowseHistoryBatchSyncRequest, PlatformBrowseHistoryResponse, PlatformBrowseHistoryWriteEntry, @@ -50,6 +51,7 @@ import { streamNpcChatTurnFromOrchestrator, streamNpcRecruitDialogueFromOrchestrator, } from '../modules/ai/chatOrchestrator.js'; +import { generateCustomWorldProfileFromOrchestrator } from '../modules/ai/customWorldOrchestrator.js'; import { hydrateSavedSnapshot, normalizeSavedSnapshotPayload, @@ -118,6 +120,12 @@ const customWorldProfileSchema = z.object({ profile: jsonObjectSchema, }); +const customWorldProfileGenerationSchema = z.object({ + settingText: z.string().trim().min(1), + creatorIntent: jsonObjectSchema.nullish(), + generationMode: z.enum(['fast', 'full']).optional(), +}); + const customWorldSceneNpcSchema = z.object({ profile: jsonObjectSchema, landmarkId: z.string().trim().min(1), @@ -600,6 +608,23 @@ export function createRuntimeRoutes(context: AppContext) { }), ); + router.post( + '/runtime/custom-world/profile', + routeMeta({ operation: 'runtime.customWorld.profile' }), + asyncHandler(async (request, response) => { + const payload = customWorldProfileGenerationSchema.parse( + request.body, + ) as GenerateCustomWorldProfileInput; + sendApiResponse( + response, + await generateCustomWorldProfileFromOrchestrator( + context.llmClient, + payload, + ), + ); + }), + ); + router.post( '/runtime/custom-world-library/:profileId/publish', routeMeta({ operation: 'runtime.customWorldLibrary.publish' }), diff --git a/server-node/src/services/customWorldAgentOrchestrator.ts b/server-node/src/services/customWorldAgentOrchestrator.ts index a1292a02..484e2320 100644 --- a/server-node/src/services/customWorldAgentOrchestrator.ts +++ b/server-node/src/services/customWorldAgentOrchestrator.ts @@ -198,6 +198,116 @@ function buildRoleAssetSyncResultText(params: { return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}。`; } +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function toRecordArray(value: unknown) { + return Array.isArray(value) + ? value.filter((item): item is Record => isRecord(item)) + : []; +} + +function cloneJsonRecord(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function syncRoleAssetsFromResultProfile(params: { + currentRoles: unknown; + resultRoles: unknown; +}) { + const resultRoleById = new Map( + toRecordArray(params.resultRoles).map((role) => [toText(role.id), role]), + ); + + return toRecordArray(params.currentRoles).map((currentRole) => { + const resultRole = resultRoleById.get(toText(currentRole.id)); + if (!resultRole) { + return currentRole; + } + + return { + ...currentRole, + imageSrc: toText(resultRole.imageSrc) || null, + generatedVisualAssetId: toText(resultRole.generatedVisualAssetId) || null, + generatedAnimationSetId: + toText(resultRole.generatedAnimationSetId) || null, + animationMap: isRecord(resultRole.animationMap) + ? cloneJsonRecord(resultRole.animationMap) + : null, + } satisfies Record; + }); +} + +function syncLandmarkAssetsFromResultProfile(params: { + currentLandmarks: unknown; + resultLandmarks: unknown; +}) { + const resultLandmarkById = new Map( + toRecordArray(params.resultLandmarks).map((landmark) => [ + toText(landmark.id), + landmark, + ]), + ); + + return toRecordArray(params.currentLandmarks).map((currentLandmark) => { + const resultLandmark = resultLandmarkById.get(toText(currentLandmark.id)); + if (!resultLandmark) { + return currentLandmark; + } + + return { + ...currentLandmark, + imageSrc: toText(resultLandmark.imageSrc) || null, + } satisfies Record; + }); +} + +function syncSceneChapterAssetsFromResultProfile(params: { + currentSceneChapters: unknown; + resultSceneChapters: unknown; +}) { + const resultSceneChapterBySceneId = new Map( + toRecordArray(params.resultSceneChapters).map((chapter) => [ + toText(chapter.sceneId), + chapter, + ]), + ); + + return toRecordArray(params.currentSceneChapters).map((currentChapter) => { + const resultChapter = resultSceneChapterBySceneId.get( + toText(currentChapter.sceneId), + ); + if (!resultChapter) { + return currentChapter; + } + + const resultActById = new Map( + toRecordArray(resultChapter.acts).map((act) => [toText(act.id), act]), + ); + + return { + ...currentChapter, + acts: toRecordArray(currentChapter.acts).map((currentAct) => { + const resultAct = resultActById.get(toText(currentAct.id)); + if (!resultAct) { + return currentAct; + } + + return { + ...currentAct, + backgroundImageSrc: toText(resultAct.backgroundImageSrc) || null, + backgroundAssetId: toText(resultAct.backgroundAssetId) || null, + } satisfies Record; + }), + } satisfies Record; + }); +} + function syncResultProfileIntoDraftProfile(params: { currentDraftProfile: Record | null | undefined; resultProfile: CustomWorldProfile; @@ -215,6 +325,22 @@ function syncResultProfileIntoDraftProfile(params: { playerGoal: resultProfile.playerGoal, majorFactions: resultProfile.majorFactions, coreConflicts: resultProfile.coreConflicts, + playableNpcs: syncRoleAssetsFromResultProfile({ + currentRoles: currentDraftProfile.playableNpcs, + resultRoles: resultProfile.playableNpcs, + }), + storyNpcs: syncRoleAssetsFromResultProfile({ + currentRoles: currentDraftProfile.storyNpcs, + resultRoles: resultProfile.storyNpcs, + }), + landmarks: syncLandmarkAssetsFromResultProfile({ + currentLandmarks: currentDraftProfile.landmarks, + resultLandmarks: resultProfile.landmarks, + }), + sceneChapters: syncSceneChapterAssetsFromResultProfile({ + currentSceneChapters: currentDraftProfile.sceneChapters, + resultSceneChapters: resultProfile.sceneChapterBlueprints, + }), legacyResultProfile: resultProfile as unknown as Record, } satisfies Record; } diff --git a/server-node/src/services/customWorldAgentPhase4.test.ts b/server-node/src/services/customWorldAgentPhase4.test.ts index d2a6302b..edf5c4db 100644 --- a/server-node/src/services/customWorldAgentPhase4.test.ts +++ b/server-node/src/services/customWorldAgentPhase4.test.ts @@ -421,6 +421,162 @@ test('phase4 sync_result_profile keeps existing foundation structure while updat ); }); +test('phase4 sync_result_profile also writes latest role and scene assets back into draft profile', async () => { + const runtimeRepository = createRuntimeRepositoryStub(); + const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); + const userId = 'user-phase4-sync-result-profile-assets'; + const session = await createObjectRefiningSession(orchestrator, userId); + const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!; + const playableRole = baselineProfile.playableNpcs[0]!; + const storyRole = baselineProfile.storyNpcs[0]!; + const landmark = baselineProfile.landmarks[0]!; + + const response = await orchestrator.executeAction(userId, session.sessionId, { + action: 'sync_result_profile', + profile: { + id: `agent-draft-${session.sessionId}`, + settingText: '被海雾吞没的旧航路群岛', + name: '潮雾列岛·结果页精修版', + subtitle: '旧灯塔与失控航路', + summary: '结果页已经把最新图与动作一起确认。 ', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船夜与假航灯的真正操盘者。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + attributeSchema: { + id: 'schema:test', + worldId: 'CUSTOM', + schemaVersion: 1, + schemaName: '测试', + generatedFrom: { + worldType: 'CUSTOM', + worldName: '潮雾列岛·结果页精修版', + settingSummary: '测试', + tone: '测试', + conflictCore: '测试', + }, + slots: [], + }, + playableNpcs: [ + { + id: playableRole.id, + name: playableRole.name, + title: '结果页角色', + role: '关键同行者', + description: '结果页确认的最新角色资产。', + backstory: '测试', + personality: '冷静', + motivation: '验证资产回写', + combatStyle: '观察', + initialAffinity: 12, + relationshipHooks: [], + tags: [], + imageSrc: '/generated/playable/latest-master.png', + generatedVisualAssetId: 'visual-playable-latest', + generatedAnimationSetId: 'anim-playable-latest', + animationMap: { + idle: { + spriteSheetPath: '/generated/playable/idle.png', + }, + }, + }, + ], + storyNpcs: [ + { + id: storyRole.id, + name: storyRole.name, + title: '结果页场景角色', + role: '场景关键角色', + description: '结果页确认的最新场景角色资产。', + backstory: '测试', + personality: '克制', + motivation: '验证资产回写', + combatStyle: '观察', + initialAffinity: 6, + relationshipHooks: [], + tags: [], + imageSrc: '/generated/story/latest-master.png', + generatedVisualAssetId: 'visual-story-latest', + }, + ], + items: [], + landmarks: [ + { + id: landmark.id, + name: landmark.name, + description: '结果页确认的最新地点图。', + dangerLevel: '中', + sceneNpcIds: [], + connections: [], + imageSrc: '/generated/landmark/latest-scene.png', + }, + ], + sceneChapterBlueprints: [ + { + id: 'scene-chapter-1', + sceneId: landmark.id, + title: '灯塔初章', + summary: '结果页确认最新分幕图。', + linkedThreadIds: [], + linkedLandmarkIds: [landmark.id], + acts: [ + { + id: `${landmark.id}-act-1`, + sceneId: landmark.id, + title: '第一幕', + summary: '第一幕', + stageCoverage: ['opening'], + backgroundImageSrc: '/generated/scene/act-1-latest.png', + backgroundAssetId: 'scene-asset-latest', + encounterNpcIds: [], + primaryNpcId: '', + linkedThreadIds: [], + advanceRule: 'after_primary_contact', + actGoal: '验证分幕图回写', + transitionHook: '进入下一幕', + }, + ], + }, + ], + generationMode: 'full', + generationStatus: 'complete', + }, + }); + const operation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + response.operation.operationId, + ); + const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); + const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile)!; + const syncedPlayable = profile.playableNpcs.find( + (entry) => entry.id === playableRole.id, + ); + const syncedStory = profile.storyNpcs.find((entry) => entry.id === storyRole.id); + const syncedLandmark = profile.landmarks.find((entry) => entry.id === landmark.id); + const syncedSceneAct = profile.sceneChapters[0]?.acts[0]; + + assert.equal(operation?.status, 'completed'); + assert.equal(syncedPlayable?.imageSrc, '/generated/playable/latest-master.png'); + assert.equal(syncedPlayable?.generatedVisualAssetId, 'visual-playable-latest'); + assert.equal(syncedPlayable?.generatedAnimationSetId, 'anim-playable-latest'); + assert.deepEqual(syncedPlayable?.animationMap, { + idle: { + spriteSheetPath: '/generated/playable/idle.png', + }, + }); + assert.equal(syncedStory?.imageSrc, '/generated/story/latest-master.png'); + assert.equal(syncedStory?.generatedVisualAssetId, 'visual-story-latest'); + assert.equal(syncedLandmark?.imageSrc, '/generated/landmark/latest-scene.png'); + assert.equal(syncedSceneAct?.backgroundImageSrc, '/generated/scene/act-1-latest.png'); + assert.equal(syncedSceneAct?.backgroundAssetId, 'scene-asset-latest'); +}); + test('phase4 generate_characters appends story npcs and updates work summary counts', async () => { const runtimeRepository = createRuntimeRepositoryStub(); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); diff --git a/server-node/src/services/customWorldAgentSessionStore.ts b/server-node/src/services/customWorldAgentSessionStore.ts index 3ae5c140..8ec5f599 100644 --- a/server-node/src/services/customWorldAgentSessionStore.ts +++ b/server-node/src/services/customWorldAgentSessionStore.ts @@ -368,10 +368,15 @@ function buildCompatibleSuggestedActions(params: { record: CustomWorldAgentSessionRecord; stage: CustomWorldAgentStage; readiness: CreatorIntentReadiness; - draftProfile: Record; }) { if (params.record.suggestedActions.length > 0) { - return params.record.suggestedActions; + // 旧快照里可能带有“精修对象”的 Agent 建议动作;当前 Agent 对话框只服务八锚点收集。 + const compatibleActions = params.record.suggestedActions.filter( + (action) => action.type !== 'refine_focus_target', + ); + if (compatibleActions.length > 0) { + return compatibleActions; + } } const actions: CustomWorldSuggestedAction[] = [ @@ -384,16 +389,6 @@ function buildCompatibleSuggestedActions(params: { : '总结当前设定', }, ]; - 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', @@ -403,36 +398,6 @@ function buildCompatibleSuggestedActions(params: { 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; } @@ -533,7 +498,6 @@ function applyCompatibility(record: CustomWorldAgentSessionRecord) { record, stage, readiness: creatorIntentReadiness, - draftProfile, }), assetCoverage: buildCompatibleAssetCoverage(record, draftProfile), recommendedReplies: normalizeRecommendedReplies( diff --git a/server-node/src/services/customWorldWorkSummaryService.ts b/server-node/src/services/customWorldWorkSummaryService.ts index a77454d5..9111b4b9 100644 --- a/server-node/src/services/customWorldWorkSummaryService.ts +++ b/server-node/src/services/customWorldWorkSummaryService.ts @@ -55,7 +55,7 @@ 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 === 'object_refining') return '待完善草稿'; if (stage === 'visual_refining') return '视觉工坊'; if (stage === 'long_tail_review') return '扩展长尾'; if (stage === 'ready_to_publish') return '准备发布'; diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 8d0d0db7..01bf7097 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -17,7 +17,7 @@ const baseDraftItem: CustomWorldWorkSummary = { updatedAt: new Date('2026-04-14T10:00:00.000Z').toISOString(), publishedAt: null, stage: 'object_refining', - stageLabel: '精修对象', + stageLabel: '待完善草稿', playableNpcCount: 3, landmarkCount: 4, sessionId: 'session-1', @@ -35,7 +35,7 @@ test('creation hub reflects updated draft title summary and counts after rerende onBack={() => {}} onRetry={() => {}} onCreateNew={() => {}} - onResumeDraft={() => {}} + onOpenDraft={() => {}} onEnterPublished={() => {}} />, ); @@ -62,7 +62,7 @@ test('creation hub reflects updated draft title summary and counts after rerende onBack={() => {}} onRetry={() => {}} onCreateNew={() => {}} - onResumeDraft={() => {}} + onOpenDraft={() => {}} onEnterPublished={() => {}} />, ); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index 5e822639..2eae91fc 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -33,7 +33,7 @@ test('creation hub draft card renders compiled work summary fields', () => { onBack={() => {}} onRetry={() => {}} onCreateNew={() => {}} - onResumeDraft={() => {}} + onOpenDraft={() => {}} onEnterPublished={() => {}} />, ); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index eaae45e5..156e1810 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -15,7 +15,7 @@ type CustomWorldCreationHubProps = { onBack: () => void; onRetry: () => void; onCreateNew: () => void; - onResumeDraft: (sessionId: string) => void; + onOpenDraft: (item: CustomWorldWorkSummary) => void; onEnterPublished: (profileId: string) => void; }; @@ -36,7 +36,7 @@ export function CustomWorldCreationHub({ onBack, onRetry, onCreateNew, - onResumeDraft, + onOpenDraft, onEnterPublished, }: CustomWorldCreationHubProps) { const [activeFilter, setActiveFilter] = @@ -129,7 +129,7 @@ export function CustomWorldCreationHub({ item={item} onClick={() => { if (item.sourceType === 'agent_session' && item.sessionId) { - onResumeDraft(item.sessionId); + onOpenDraft(item); return; } diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index 42294958..6f67d7b7 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -27,6 +27,11 @@ export function CustomWorldWorkCard({ const isDraft = item.status === 'draft'; const hasFoundationDraft = item.playableNpcCount > 0 || item.landmarkCount > 0; + const actionLabel = isDraft + ? hasFoundationDraft + ? '继续完善' + : '继续创作' + : '进入世界'; const roleCountLabel = isDraft ? '角色' : '可扮演角色'; return ( @@ -104,7 +109,7 @@ export function CustomWorldWorkCard({ onClick={onClick} className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm" > - {isDraft ? (hasFoundationDraft ? '继续精修' : '继续创作') : '进入世界'} + {actionLabel} diff --git a/src/components/game-shell/PlatformHomeView.tsx b/src/components/game-shell/PlatformHomeView.tsx index 6fc20457..2c1fee68 100644 --- a/src/components/game-shell/PlatformHomeView.tsx +++ b/src/components/game-shell/PlatformHomeView.tsx @@ -30,13 +30,13 @@ import { import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, + PlatformBrowseHistoryEntry, ProfileDashboardCardKey, ProfileDashboardSummary, ProfileSaveArchiveSummary, } from '../../../packages/shared/src/contracts/runtime'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { AuthUser } from '../../services/authService'; -import type { PlatformBrowseHistoryEntry } from '../../services/platformBrowseHistory'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformBrandLogo } from './PlatformBrandLogo'; diff --git a/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx b/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx index 19ee8402..7416cca6 100644 --- a/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx +++ b/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx @@ -223,6 +223,97 @@ const mockAuthUser: AuthUser = { wechatBound: false, }; +const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = { + ...mockSession, + stage: 'object_refining', + creatorIntent: { + sourceMode: 'card', + worldHook: '被海雾吞没的旧航路群岛', + playerPremise: '玩家回到群岛调查沉船真相。', + themeKeywords: ['海雾', '旧航路'], + toneDirectives: ['压抑', '悬疑'], + openingSituation: '首夜就有陌生船只闯入禁航区。', + coreConflicts: ['航运公会与守灯会争夺航路控制权'], + keyFactions: [], + keyCharacters: [], + keyLandmarks: [], + iconicElements: ['会移动的海雾'], + forbiddenDirectives: [], + rawSettingText: '', + }, + draftProfile: { + name: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船与禁航区异动的真相。', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + playableNpcs: [ + { + id: 'playable-1', + name: '沈砺', + title: '旧航路引路人', + role: '关键同行者', + publicIdentity: '最熟悉旧航路的人。', + publicMask: '看上去像可靠旧友。', + currentPressure: '他必须在两股势力间站队。', + hiddenHook: '暗中替沉船商盟引路。', + relationToPlayer: '旧友兼潜在背叛者', + threadIds: ['thread-1'], + summary: '他像旧友,但也像一把始终没收回鞘的刀。', + }, + ], + storyNpcs: [ + { + id: 'story-1', + name: '顾潮音', + title: '守灯会值夜人', + role: '场景关键角色', + publicIdentity: '负责夜间巡灯与封锁。', + publicMask: '对外一直冷静克制。', + currentPressure: '她知道更多禁航区真相。', + hiddenHook: '曾亲眼见过失控海雾吞船。', + relationToPlayer: '最早愿意交换线索的人', + threadIds: ['thread-1'], + summary: '她总像比所有人更早知道海雾会往哪一侧压下来。', + }, + ], + landmarks: [ + { + id: 'landmark-1', + name: '回潮旧灯塔', + purpose: '观察雾潮与往来船只', + mood: '潮湿、压抑、风声不止', + importance: '开局核心场景', + characterIds: ['story-1'], + threadIds: ['thread-1'], + summary: '旧灯塔是整片群岛最先看见异动的地方。', + }, + ], + factions: [], + threads: [], + chapters: [], + worldHook: '被海雾吞没的旧航路群岛', + playerPremise: '玩家回到群岛调查沉船真相。', + openingSituation: '首夜就有陌生船只闯入禁航区。', + iconicElements: ['会移动的海雾'], + sourceAnchorSummary: '海雾、旧灯塔、失控航路。', + }, + draftCards: [ + { + id: 'world-foundation', + kind: 'world', + title: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成。', + status: 'warning', + linkedIds: ['playable-1', 'story-1', 'landmark-1'], + warningCount: 0, + }, + ], +}; + type TestAuthValue = { user: AuthUser | null; openLoginModal: (postLoginAction?: (() => void) | null) => void; @@ -416,7 +507,7 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and ).toBeTruthy(); }); -test('create tab uses unified creation hub and can resume an agent draft', async () => { +test('create tab opens compiled agent draft in result refinement page', async () => { const user = userEvent.setup(); vi.mocked(listCustomWorldWorks).mockResolvedValue([ @@ -425,7 +516,7 @@ test('create tab uses unified creation hub and can resume an agent draft', async sourceType: 'agent_session', status: 'draft', title: '潮雾列岛', - subtitle: '精修对象', + subtitle: '待完善草稿', summary: '玩家是失职返乡的守灯人。', coverImageSrc: null, coverRenderMode: 'image', @@ -433,7 +524,7 @@ test('create tab uses unified creation hub and can resume an agent draft', async updatedAt: '2026-04-20T10:00:00.000Z', publishedAt: null, stage: 'object_refining', - stageLabel: '精修对象', + stageLabel: '待完善草稿', playableNpcCount: 3, landmarkCount: 4, roleVisualReadyCount: 1, @@ -445,21 +536,70 @@ test('create tab uses unified creation hub and can resume an agent draft', async canEnterWorld: false, }, ]); + vi.mocked(getCustomWorldAgentSession).mockResolvedValue( + compiledAgentDraftSession, + ); render(); await openCreationHub(user); - expect( - screen.getByRole('button', { name: /继续精修/u }), - ).toBeTruthy(); - expect(screen.getByRole('button', { name: /继续精修/u })).toBeTruthy(); + expect(screen.getByRole('button', { name: /继续完善/u })).toBeTruthy(); - await user.click(screen.getByRole('button', { name: /继续精修/u })); + await user.click(screen.getByRole('button', { name: /继续完善/u })); + + await waitFor( + async () => { + expect(await screen.findByText('世界档案')).toBeTruthy(); + expect(screen.queryByText('Agent工作区:custom-world-agent-session-1')).toBeNull(); + expect(screen.getByRole('button', { name: /返回创作/u })).toBeTruthy(); + }, + { timeout: 2500 }, + ); +}); + +test('create tab resumes agent workspace when draft has no compiled result yet', async () => { + const user = userEvent.setup(); + + vi.mocked(listCustomWorldWorks).mockResolvedValue([ + { + workId: 'draft:custom-world-agent-session-1', + sourceType: 'agent_session', + status: 'draft', + title: '潮雾列岛', + subtitle: '补齐关键锚点', + summary: '玩家是失职返乡的守灯人。', + coverImageSrc: null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + updatedAt: '2026-04-20T10:00:00.000Z', + publishedAt: null, + stage: 'clarifying', + stageLabel: '补齐关键锚点', + playableNpcCount: 0, + landmarkCount: 0, + roleVisualReadyCount: 0, + roleAnimationReadyCount: 0, + roleAssetSummaryLabel: null, + sessionId: 'custom-world-agent-session-1', + profileId: null, + canResume: true, + canEnterWorld: false, + }, + ]); + + render(); + + await openCreationHub(user); + + expect(screen.getByRole('button', { name: /继续创作/u })).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: /继续创作/u })); expect( await screen.findByText('Agent工作区:custom-world-agent-session-1'), ).toBeTruthy(); + expect(screen.queryByText('世界档案')).toBeNull(); }); test('clicking a public work while logged out routes through requireAuth', async () => { @@ -581,7 +721,7 @@ test('starting draft generation leaves the agent workspace and shows the generat expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull(); }); -test('existing draft sessions enter the agent preview layout without opening legacy editor', async () => { +test('existing draft sessions open result page refinement instead of agent dialog', async () => { const user = userEvent.setup(); vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({ @@ -593,96 +733,9 @@ test('existing draft sessions enter the agent preview layout without opening leg progress: 100, error: null, }); - vi.mocked(getCustomWorldAgentSession).mockResolvedValue({ - ...mockSession, - stage: 'object_refining', - creatorIntent: { - sourceMode: 'card', - worldHook: '被海雾吞没的旧航路群岛', - playerPremise: '玩家回到群岛调查沉船真相。', - themeKeywords: ['海雾', '旧航路'], - toneDirectives: ['压抑', '悬疑'], - openingSituation: '首夜就有陌生船只闯入禁航区。', - coreConflicts: ['航运公会与守灯会争夺航路控制权'], - keyFactions: [], - keyCharacters: [], - keyLandmarks: [], - iconicElements: ['会移动的海雾'], - forbiddenDirectives: [], - rawSettingText: '', - }, - draftProfile: { - name: '潮雾列岛', - subtitle: '旧灯塔与失控航路', - summary: '第一版世界底稿已经整理完成。', - tone: '压抑、潮湿、悬疑', - playerGoal: '查清沉船与禁航区异动的真相。', - majorFactions: ['守灯会', '航运公会'], - coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], - playableNpcs: [ - { - id: 'playable-1', - name: '沈砺', - title: '旧航路引路人', - role: '关键同行者', - publicIdentity: '最熟悉旧航路的人。', - publicMask: '看上去像可靠旧友。', - currentPressure: '他必须在两股势力间站队。', - hiddenHook: '暗中替沉船商盟引路。', - relationToPlayer: '旧友兼潜在背叛者', - threadIds: ['thread-1'], - summary: '他像旧友,但也像一把始终没收回鞘的刀。', - }, - ], - storyNpcs: [ - { - id: 'story-1', - name: '顾潮音', - title: '守灯会值夜人', - role: '场景关键角色', - publicIdentity: '负责夜间巡灯与封锁。', - publicMask: '对外一直冷静克制。', - currentPressure: '她知道更多禁航区真相。', - hiddenHook: '曾亲眼见过失控海雾吞船。', - relationToPlayer: '最早愿意交换线索的人', - threadIds: ['thread-1'], - summary: '她总像比所有人更早知道海雾会往哪一侧压下来。', - }, - ], - landmarks: [ - { - id: 'landmark-1', - name: '回潮旧灯塔', - purpose: '观察雾潮与往来船只', - mood: '潮湿、压抑、风声不止', - importance: '开局核心场景', - characterIds: ['story-1'], - threadIds: ['thread-1'], - summary: '旧灯塔是整片群岛最先看见异动的地方。', - }, - ], - factions: [], - threads: [], - chapters: [], - worldHook: '被海雾吞没的旧航路群岛', - playerPremise: '玩家回到群岛调查沉船真相。', - openingSituation: '首夜就有陌生船只闯入禁航区。', - iconicElements: ['会移动的海雾'], - sourceAnchorSummary: '海雾、旧灯塔、失控航路。', - }, - draftCards: [ - { - id: 'world-foundation', - kind: 'world', - title: '潮雾列岛', - subtitle: '旧灯塔与失控航路', - summary: '第一版世界底稿已经整理完成。', - status: 'warning', - linkedIds: ['playable-1', 'story-1', 'landmark-1'], - warningCount: 0, - }, - ], - }); + vi.mocked(getCustomWorldAgentSession).mockResolvedValue( + compiledAgentDraftSession, + ); render(); @@ -704,12 +757,12 @@ test('existing draft sessions enter the agent preview layout without opening leg await user.click(screen.getByRole('button', { name: /场景角色/u })); expect(screen.getByRole('button', { name: /顾潮音/u })).toBeTruthy(); - expect(screen.queryByText(/编辑场景角色:顾潮音/u)).toBeNull(); - expect(screen.queryByRole('button', { name: /AI生成/u })).toBeNull(); - expect(screen.queryByText('技能')).toBeNull(); + await user.click(screen.getByRole('button', { name: /顾潮音/u })); + expect(await screen.findByText(/编辑场景角色:顾潮音/u)).toBeTruthy(); + expect(screen.getByRole('button', { name: /AI生成/u })).toBeTruthy(); }); -test('agent draft result back button returns to workspace without redundant sync when session is already latest', async () => { +test('agent draft result back button returns to creation hub without redundant sync when session is already latest', async () => { const user = userEvent.setup(); vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({ @@ -843,7 +896,7 @@ test('agent draft result back button returns to workspace without redundant sync } satisfies CustomWorldAgentSessionSnapshot; vi.mocked(getCustomWorldAgentSession).mockResolvedValue(resultSession); - render(); + render(); await openNewRpgCreation(user); @@ -858,9 +911,7 @@ test('agent draft result back button returns to workspace without redundant sync await user.click(screen.getByRole('button', { name: /返回创作/u })); await waitFor(() => { - expect( - screen.getByText('Agent工作区:custom-world-agent-session-1'), - ).toBeTruthy(); + expect(screen.getByText('创作中心')).toBeTruthy(); }); expect( @@ -968,7 +1019,7 @@ test('agent draft result auto-save persists the latest profile rebuilt from sync } satisfies CustomWorldAgentSessionSnapshot; vi.mocked(getCustomWorldAgentSession).mockResolvedValue(syncedSession); - render(); + render(); await openNewRpgCreation(user); diff --git a/src/components/game-shell/PreGameSelectionFlow.tsx b/src/components/game-shell/PreGameSelectionFlow.tsx index ebd227d3..2357d18a 100644 --- a/src/components/game-shell/PreGameSelectionFlow.tsx +++ b/src/components/game-shell/PreGameSelectionFlow.tsx @@ -20,6 +20,8 @@ import type { import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, + PlatformBrowseHistoryEntry, + PlatformBrowseHistoryWriteEntry, ProfileDashboardSummary, ProfileSaveArchiveSummary, } from '../../../packages/shared/src/contracts/runtime'; @@ -46,14 +48,6 @@ import { writeCustomWorldAgentUiState, } from '../../services/customWorldAgentUiState'; import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent'; -import { - hasPendingPlatformBrowseHistoryMigration, - markPlatformBrowseHistoryMigrated, - type PlatformBrowseHistoryEntry, - type PlatformBrowseHistoryWriteEntry, - readPlatformBrowseHistory, - writePlatformBrowseHistory, -} from '../../services/platformBrowseHistory'; import { deleteCustomWorldProfile, getCustomWorldGalleryDetail, @@ -64,7 +58,6 @@ import { listProfileSaveArchives, publishCustomWorldProfile, resumeProfileSaveArchive, - syncProfileBrowseHistory, unpublishCustomWorldProfile, upsertCustomWorldProfile, upsertProfileBrowseHistory, @@ -381,18 +374,11 @@ export function PreGameSelectionFlow({ const appendBrowseHistoryEntry = useCallback( async (entry: PlatformBrowseHistoryWriteEntry) => { - const nextEntries = writePlatformBrowseHistory(authUi?.user, entry); - setHistoryEntries(nextEntries); setHistoryError(null); - if (!authUi?.user) { - return; - } - try { const syncedEntries = await upsertProfileBrowseHistory(entry); setHistoryEntries(syncedEntries); - markPlatformBrowseHistoryMigrated(authUi?.user); } catch (error) { setHistoryError(resolveErrorMessage(error, '写入浏览历史失败。')); } @@ -444,8 +430,7 @@ export function PreGameSelectionFlow({ let isActive = true; void (async () => { - const localHistoryEntries = readPlatformBrowseHistory(authUi?.user); - setHistoryEntries(localHistoryEntries); + setHistoryEntries([]); setHistoryError(null); setSaveError(null); setIsLoadingPlatform(true); @@ -472,22 +457,7 @@ export function PreGameSelectionFlow({ isAuthenticated ? listCustomWorldWorks() : Promise.resolve([]), listCustomWorldGallery(), isAuthenticated ? getProfileDashboard() : Promise.resolve(null), - isAuthenticated - ? (async () => { - let nextEntries = await listProfileBrowseHistory(); - - if ( - hasPendingPlatformBrowseHistoryMigration(authUi?.user) && - localHistoryEntries.length > 0 - ) { - nextEntries = - await syncProfileBrowseHistory(localHistoryEntries); - markPlatformBrowseHistoryMigrated(authUi?.user); - } - - return nextEntries; - })() - : Promise.resolve(localHistoryEntries), + isAuthenticated ? listProfileBrowseHistory() : Promise.resolve([]), isAuthenticated ? listProfileSaveArchives() : Promise.resolve([]), ]); if (!isActive) { @@ -881,8 +851,6 @@ export function PreGameSelectionFlow({ const isAgentDraftGenerationView = customWorldGenerationViewSource === 'agent-draft-foundation'; const isAgentDraftResultView = customWorldResultViewSource === 'agent-draft'; - const isAgentDraftResultEditingFrozen = - customWorldResultViewSource === 'agent-draft'; const activeGenerationSettingText = agentDraftSettingPreview; const activeGenerationProgress = agentDraftGenerationProgress; const isActiveGenerationRunning = @@ -1096,7 +1064,8 @@ export function PreGameSelectionFlow({ setCustomWorldAutoSaveState('idle'); setCustomWorldGenerationViewSource(null); setCustomWorldResultViewSource(null); - setSelectionStage('agent-workspace'); + setPlatformTab('create'); + setSelectionStage('platform'); }; const retryAgentDraftGeneration = () => { @@ -1133,17 +1102,39 @@ export function PreGameSelectionFlow({ const handleOpenCreationWork = useCallback( async (work: CustomWorldWorkSummary) => { if (work.status === 'draft' && work.sessionId) { - // 阶段二要求草稿优先回到 Agent 工作区,而不是再次自动顶回结果页。 - isAgentDraftResultAutoOpenSuppressedRef.current = true; persistAgentUiState(work.sessionId, null); - setGeneratedCustomWorldProfile(null); setCustomWorldError(null); setCustomWorldAutoSaveError(null); setCustomWorldAutoSaveState('idle'); setCustomWorldGenerationViewSource(null); - setCustomWorldResultViewSource(null); + + const shouldOpenAgentWorkspace = + work.playableNpcCount <= 0 && work.landmarkCount <= 0; + + if (shouldOpenAgentWorkspace) { + // 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。 + isAgentDraftResultAutoOpenSuppressedRef.current = true; + setGeneratedCustomWorldProfile(null); + setCustomWorldResultViewSource(null); + setPlatformTab('create'); + setSelectionStage('agent-workspace'); + return; + } + + isAgentDraftResultAutoOpenSuppressedRef.current = false; + const latestSession = await syncAgentSessionSnapshot(work.sessionId); + const nextProfile = buildCustomWorldProfileFromAgentDraft(latestSession); + if (!nextProfile) { + setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。'); + setPlatformTab('create'); + setSelectionStage('agent-workspace'); + return; + } + + setGeneratedCustomWorldProfile(normalizeAgentBackedProfile(nextProfile)); + setCustomWorldResultViewSource('agent-draft'); setPlatformTab('create'); - setSelectionStage('agent-workspace'); + setSelectionStage('custom-world-result'); return; } @@ -1179,6 +1170,7 @@ export function PreGameSelectionFlow({ openLibraryDetail, persistAgentUiState, savedCustomWorldEntries, + syncAgentSessionSnapshot, setSelectionStage, ], ); @@ -1583,32 +1575,9 @@ export function PreGameSelectionFlow({ }); }} onCreateNew={openCreationTypePicker} - onResumeDraft={(sessionId) => { + onOpenDraft={(item) => { runProtectedAction(() => { - void handleOpenCreationWork({ - workId: `draft:${sessionId}`, - sourceType: 'agent_session', - status: 'draft', - title: '', - subtitle: '', - summary: '', - coverImageSrc: null, - coverRenderMode: 'image', - coverCharacterImageSrcs: [], - updatedAt: new Date().toISOString(), - publishedAt: null, - stage: null, - stageLabel: '', - playableNpcCount: 0, - landmarkCount: 0, - roleVisualReadyCount: 0, - roleAnimationReadyCount: 0, - roleAssetSummaryLabel: null, - sessionId, - profileId: null, - canResume: true, - canEnterWorld: false, - }); + void handleOpenCreationWork(item); }); }} onEnterPublished={(profileId) => { @@ -1918,10 +1887,10 @@ export function PreGameSelectionFlow({ }); }); }} - readOnly={isAgentDraftResultEditingFrozen} + readOnly={false} compactAgentResultMode={isAgentDraftResultView} backLabel={isAgentDraftResultView ? '返回创作' : undefined} - editActionLabel="去Agent调整设定" + editActionLabel="继续调整设定" enterWorldActionLabel="进入世界" autoSaveState={customWorldAutoSaveState} /> diff --git a/src/hooks/story/npcEncounterActions.test.ts b/src/hooks/story/npcEncounterActions.test.ts index 7db5c9aa..6cfb9838 100644 --- a/src/hooks/story/npcEncounterActions.test.ts +++ b/src/hooks/story/npcEncounterActions.test.ts @@ -3,11 +3,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { resolveServerRuntimeChoiceMock, streamNpcChatTurnMock, - generateQuestForNpcEncounterMock, } = vi.hoisted(() => ({ resolveServerRuntimeChoiceMock: vi.fn(), streamNpcChatTurnMock: vi.fn(), - generateQuestForNpcEncounterMock: vi.fn(), })); vi.mock('./runtimeStoryCoordinator', () => ({ @@ -18,10 +16,6 @@ vi.mock('../../services/aiService', () => ({ streamNpcChatTurn: streamNpcChatTurnMock, })); -vi.mock('../../services/questDirector', () => ({ - generateQuestForNpcEncounter: generateQuestForNpcEncounterMock, -})); - import { AnimationState, type Character, @@ -593,7 +587,6 @@ describe('npcEncounterActions', () => { beforeEach(() => { resolveServerRuntimeChoiceMock.mockReset(); streamNpcChatTurnMock.mockReset(); - generateQuestForNpcEncounterMock.mockReset(); }); it.each([ @@ -1371,8 +1364,6 @@ describe('npcEncounterActions', () => { }), }), ); - expect(generateQuestForNpcEncounterMock).not.toHaveBeenCalled(); - const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment; expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(pendingQuest); expect(lastStory.npcAffinityEffect).toEqual({ @@ -1393,18 +1384,38 @@ describe('npcEncounterActions', () => { ); }); - it('replaces a pending quest offer by reusing the existing quest generator', async () => { + it('replaces a pending quest offer through the server runtime resolver', async () => { const currentQuest = createQuest('quest-bridge-offer', '断桥口的密信'); const nextQuest = createQuest('quest-bridge-replaced', '断桥夜巡'); - generateQuestForNpcEncounterMock.mockResolvedValueOnce(nextQuest); + resolveServerRuntimeChoiceMock.mockResolvedValueOnce({ + hydratedSnapshot: { + gameState: createState(), + }, + nextStory: createPendingQuestOfferStory(nextQuest), + }); const actions = createNpcEncounterActions({ currentStory: createPendingQuestOfferStory(currentQuest), }); - await expect(actions.replacePendingNpcQuestOffer()).resolves.toBe(true); + expect(actions.replacePendingNpcQuestOffer()).toBe(true); + await flushAsyncWork(); - expect(generateQuestForNpcEncounterMock).toHaveBeenCalledTimes(1); + expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith( + expect.objectContaining({ + gameState: actions.gameState, + currentStory: actions.currentStory, + option: expect.objectContaining({ + functionId: 'npc_chat_quest_offer_replace', + actionText: '你请断桥客换一份更合适的委托。', + interaction: { + kind: 'npc', + npcId: 'npc-rival', + action: 'quest_offer_replace', + }, + }), + }), + ); const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment; expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(nextQuest); expect(lastStory.options.map((option) => option.actionText)).toEqual([ @@ -1485,14 +1496,78 @@ describe('npcEncounterActions', () => { ); }); - it('abandons a pending quest offer and returns to free npc chat', () => { + it('abandons a pending quest offer through the server runtime resolver', async () => { const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信'); + resolveServerRuntimeChoiceMock.mockResolvedValueOnce({ + hydratedSnapshot: { + gameState: createState(), + }, + nextStory: { + ...createPendingQuestOfferStory(pendingQuest), + options: [ + createOption('npc_chat', '那先继续聊聊你刚才没说完的部分', { + kind: 'npc', + npcId: 'npc-rival', + action: 'chat', + }), + createOption('npc_chat', '除了委托,你对眼前局势还有什么判断', { + kind: 'npc', + npcId: 'npc-rival', + action: 'chat', + }), + createOption('npc_chat', '先把这附近真正危险的地方说清楚', { + kind: 'npc', + npcId: 'npc-rival', + action: 'chat', + }), + ], + dialogue: [ + { + speaker: 'npc', + speakerName: '断桥客', + text: '这件事我只想托给你。', + }, + { + speaker: 'player', + text: '这件事我先不接,咱们还是先聊别的。', + }, + { + speaker: 'npc', + speakerName: '断桥客', + text: '那就先聊别的。', + }, + ], + npcChatState: { + npcId: 'npc-rival', + npcName: '断桥客', + turnCount: 2, + customInputPlaceholder: '输入你想对 TA 说的话', + pendingQuestOffer: null, + }, + } satisfies StoryMoment, + }); const actions = createNpcEncounterActions({ currentStory: createPendingQuestOfferStory(pendingQuest), }); expect(actions.abandonPendingNpcQuestOffer()).toBe(true); + await flushAsyncWork(); + expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith( + expect.objectContaining({ + gameState: actions.gameState, + currentStory: actions.currentStory, + option: expect.objectContaining({ + functionId: 'npc_chat_quest_offer_abandon', + actionText: '你暂时没有接下断桥客提出的委托。', + interaction: { + kind: 'npc', + npcId: 'npc-rival', + action: 'quest_offer_abandon', + }, + }), + }), + ); const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment; expect(lastStory.npcChatState?.pendingQuestOffer).toBeNull(); expect(lastStory.options.map((option) => option.actionText)).toEqual([ diff --git a/src/hooks/story/npcEncounterActions.ts b/src/hooks/story/npcEncounterActions.ts index 4b5df44a..7cefe20d 100644 --- a/src/hooks/story/npcEncounterActions.ts +++ b/src/hooks/story/npcEncounterActions.ts @@ -24,7 +24,6 @@ import type { StoryGenerationContext } from '../../services/aiTypes'; import { resolveLimitedPrimaryNpcChatState, } from '../../services/customWorldSceneActRuntime'; -import { generateQuestForNpcEncounter } from '../../services/questDirector'; import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory'; import { createHistoryMoment } from '../../services/storyHistory'; import type { @@ -1558,81 +1557,36 @@ export function createStoryNpcEncounterActions({ } }; - const replacePendingNpcQuestOffer = async () => { - const playerCharacter = gameState.playerCharacter; + const replacePendingNpcQuestOffer = () => { const encounter = gameState.currentEncounter; const pendingQuestOffer = isNpcEncounter(encounter) ? getPendingQuestOffer(currentStory, encounter) : null; - if (!playerCharacter || !encounter || !pendingQuestOffer) { + if (!encounter || !pendingQuestOffer) { return false; } - - const encounterKey = getNpcEncounterKey(encounter); - const currentNpcChatState = - currentStory?.npcChatState?.npcId === encounterKey - ? currentStory.npcChatState - : null; - const currentDialogue = - currentStory?.dialogue && currentNpcChatState - ? [...currentStory.dialogue] - : []; - const turnCount = currentNpcChatState?.turnCount ?? 0; - const playerLine = '能不能换一份更适合眼下局势的委托?'; - const generationState = { - ...gameState, - storyHistory: appendHistory( - gameState, - `你请${encounter.npcName}换一份更合适的委托。`, - `${encounter.npcName}重新斟酌起该交给你的事。`, - ), - }; - - setAiError(null); - setIsLoading(true); - - try { - const nextQuest = await generateQuestForNpcEncounter({ - state: generationState, - encounter, - }); - if (!nextQuest) { - setAiError('当前没有更合适的委托可供更换。'); - return false; - } - - setGameState(generationState); - setCurrentStory( - buildNpcChatStoryMoment({ - encounter, - dialogue: [ - ...currentDialogue, - { - speaker: 'player', - text: playerLine, - }, - { - speaker: 'npc', - speakerName: encounter.npcName, - text: buildQuestOfferDialogueText(encounter, nextQuest), - }, - ], - options: buildPendingQuestOfferOptions(encounter), - streaming: false, - turnCount, - pendingQuestOffer: { - quest: nextQuest, - }, - }), - ); - return true; - } catch (error) { - console.error('Failed to replace pending npc quest offer:', error); - setAiError(error instanceof Error ? error.message : '更换任务失败'); - return false; - } finally { - setIsLoading(false); - } + void resolveServerNpcStoryAction({ + option: { + functionId: 'npc_chat_quest_offer_replace', + actionText: `你请${encounter.npcName}换一份更合适的委托。`, + text: `你请${encounter.npcName}换一份更合适的委托。`, + detailText: '', + visuals: { + playerAnimation: AnimationState.IDLE, + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + interaction: { + kind: 'npc', + npcId: encounter.id ?? encounter.npcName, + action: 'quest_offer_replace', + }, + }, + }); + return true; }; const abandonPendingNpcQuestOffer = () => { @@ -1643,49 +1597,27 @@ export function createStoryNpcEncounterActions({ if (!encounter || !pendingQuestOffer) { return false; } - - const encounterKey = getNpcEncounterKey(encounter); - const currentNpcChatState = - currentStory?.npcChatState?.npcId === encounterKey - ? currentStory.npcChatState - : null; - const currentDialogue = - currentStory?.dialogue && currentNpcChatState - ? [...currentStory.dialogue] - : []; - const turnCount = currentNpcChatState?.turnCount ?? 0; - const playerLine = '这件事我先不接,咱们还是先聊别的。'; - const npcReply = `${encounter.npcName}点了点头,没有继续强求,只把这份委托暂时收了回去。`; - const nextState = { - ...gameState, - storyHistory: appendHistory( - gameState, - `你暂时没有接下${encounter.npcName}提出的委托。`, - npcReply, - ), - }; - - setGameState(nextState); - setCurrentStory( - buildNpcChatStoryMoment({ - encounter, - dialogue: [ - ...currentDialogue, - { - speaker: 'player', - text: playerLine, - }, - { - speaker: 'npc', - speakerName: encounter.npcName, - text: npcReply, - }, - ], - options: buildPostQuestOfferChatSuggestions(encounter), - streaming: false, - turnCount, - }), - ); + void resolveServerNpcStoryAction({ + option: { + functionId: 'npc_chat_quest_offer_abandon', + actionText: `你暂时没有接下${encounter.npcName}提出的委托。`, + text: `你暂时没有接下${encounter.npcName}提出的委托。`, + detailText: '', + visuals: { + playerAnimation: AnimationState.IDLE, + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + interaction: { + kind: 'npc', + npcId: encounter.id ?? encounter.npcName, + action: 'quest_offer_abandon', + }, + }, + }); return true; }; diff --git a/src/hooks/story/runtimeStoryCoordinator.test.ts b/src/hooks/story/runtimeStoryCoordinator.test.ts index 6cb43945..57d79332 100644 --- a/src/hooks/story/runtimeStoryCoordinator.test.ts +++ b/src/hooks/story/runtimeStoryCoordinator.test.ts @@ -1,23 +1,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { - putSaveSnapshotMock, getRuntimeStoryStateMock, resolveRuntimeStoryActionMock, getRuntimeSessionIdMock, getRuntimeClientVersionMock, } = vi.hoisted(() => ({ - putSaveSnapshotMock: vi.fn(), getRuntimeStoryStateMock: vi.fn(), resolveRuntimeStoryActionMock: vi.fn(), getRuntimeSessionIdMock: vi.fn(() => 'runtime-main'), getRuntimeClientVersionMock: vi.fn(() => 0), })); -vi.mock('../../services/storageService', () => ({ - putSaveSnapshot: putSaveSnapshotMock, -})); - vi.mock('../../services/runtimeStoryService', async () => { const actual = await vi.importActual( @@ -149,7 +143,6 @@ function createRuntimeNpcBattleSnapshot( describe('runtimeStoryCoordinator', () => { beforeEach(() => { - putSaveSnapshotMock.mockReset(); getRuntimeStoryStateMock.mockReset(); resolveRuntimeStoryActionMock.mockReset(); getRuntimeSessionIdMock.mockReset(); @@ -209,12 +202,15 @@ describe('runtimeStoryCoordinator', () => { currentStory, }); - expect(putSaveSnapshotMock).toHaveBeenCalledWith({ - gameState, - bottomTab: 'adventure', - currentStory, + expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({ + sessionId: 'runtime-main', + clientVersion: 7, + snapshot: { + gameState, + bottomTab: 'adventure', + currentStory, + }, }); - expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main'); expect(options).toEqual([ expect.objectContaining({ functionId: 'npc_chat', @@ -306,11 +302,6 @@ describe('runtimeStoryCoordinator', () => { }, }); - expect(putSaveSnapshotMock).toHaveBeenCalledWith({ - gameState, - bottomTab: 'adventure', - currentStory, - }); expect(resolveRuntimeStoryActionMock).toHaveBeenCalledWith({ sessionId: 'runtime-main', clientVersion: 7, @@ -319,6 +310,11 @@ describe('runtimeStoryCoordinator', () => { payload: { note: 'server-runtime-test', }, + snapshot: { + gameState, + bottomTab: 'adventure', + currentStory, + }, }); expect(result.hydratedSnapshot).toBe(hydratedSnapshot); expect(result.nextStory).toEqual( @@ -414,7 +410,9 @@ describe('runtimeStoryCoordinator', () => { const result = await resumeServerRuntimeStory(localHydratedSnapshot); - expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main'); + expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({ + sessionId: 'runtime-main', + }); expect(result.hydratedSnapshot).toBe(serverHydratedSnapshot); expect(result.nextStory).toEqual( expect.objectContaining({ @@ -614,6 +612,9 @@ describe('runtimeStoryCoordinator', () => { const result = await resumeServerRuntimeStory(localHydratedSnapshot); + expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({ + sessionId: 'runtime-main', + }); expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual( expect.objectContaining({ id: 'npc-bandit', diff --git a/src/hooks/story/runtimeStoryCoordinator.ts b/src/hooks/story/runtimeStoryCoordinator.ts index 09f35ca6..6d9075d8 100644 --- a/src/hooks/story/runtimeStoryCoordinator.ts +++ b/src/hooks/story/runtimeStoryCoordinator.ts @@ -5,11 +5,11 @@ import { getRuntimeSessionId, getRuntimeStoryState, resolveRuntimeStoryAction, + type RuntimeStorySnapshotRequest, resolveRuntimeStoryMoment, type RuntimeStoryChoicePayload, type RuntimeStoryResponse, } from '../../services/runtimeStoryService'; -import { putSaveSnapshot } from '../../services/storageService'; import type { GameState, StoryMoment, StoryOption } from '../../types'; function getRuntimeResponseOptions(response: RuntimeStoryResponse) { @@ -18,26 +18,26 @@ function getRuntimeResponseOptions(response: RuntimeStoryResponse) { : response.presentation.options; } -async function syncRuntimeSnapshot( +function buildRuntimeSnapshotRequest( gameState: GameState, currentStory: StoryMoment | null, ) { - await putSaveSnapshot({ + return { gameState, bottomTab: 'adventure', currentStory, - }); + } satisfies RuntimeStorySnapshotRequest; } export async function loadServerRuntimeOptionCatalog(params: { gameState: GameState; currentStory: StoryMoment | null; }) { - await syncRuntimeSnapshot(params.gameState, params.currentStory); - - const response = await getRuntimeStoryState( - getRuntimeSessionId(params.gameState), - ); + const response = await getRuntimeStoryState({ + sessionId: getRuntimeSessionId(params.gameState), + clientVersion: getRuntimeClientVersion(params.gameState), + snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory), + }); const options = resolveRuntimeStoryMoment({ response, hydratedSnapshot: response.snapshot, @@ -64,9 +64,9 @@ export async function resumeServerRuntimeStory( }; } - const response = await getRuntimeStoryState( - getRuntimeSessionId(hydratedSnapshot.gameState), - ); + const response = await getRuntimeStoryState({ + sessionId: getRuntimeSessionId(hydratedSnapshot.gameState), + }); const resumedSnapshot = rehydrateSavedSnapshot(response.snapshot); const runtimeOptions = getRuntimeResponseOptions(response); const nextStory = @@ -96,8 +96,6 @@ export async function resolveServerRuntimeChoice(params: { Partial>; payload?: RuntimeStoryChoicePayload; }) { - await syncRuntimeSnapshot(params.gameState, params.currentStory); - const response = await resolveRuntimeStoryAction({ sessionId: getRuntimeSessionId(params.gameState), clientVersion: getRuntimeClientVersion(params.gameState), @@ -107,6 +105,7 @@ export async function resolveServerRuntimeChoice(params: { ? params.option.interaction.npcId : undefined, payload: params.payload, + snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory), }); const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot); diff --git a/src/hooks/story/uiTypes.ts b/src/hooks/story/uiTypes.ts index dd2f4f3a..9b901fc4 100644 --- a/src/hooks/story/uiTypes.ts +++ b/src/hooks/story/uiTypes.ts @@ -82,7 +82,7 @@ export interface QuestFlowUi { } export interface NpcChatQuestOfferUi { - replacePendingOffer: () => Promise; + replacePendingOffer: () => boolean; abandonPendingOffer: () => boolean; acceptPendingOffer: () => string | null; } diff --git a/src/services/aiService.ts b/src/services/aiService.ts index b887ec0d..ba6cc75e 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -353,8 +353,37 @@ export async function generateCustomWorldProfile( input: GenerateCustomWorldProfileInput | string, options: GenerateCustomWorldProfileOptions = {}, ): Promise { - const aiClient = await loadLegacyAiModule(); - return aiClient.generateCustomWorldProfile(input, options); + const normalizedInput = + typeof input === 'string' + ? { + settingText: input, + } + : input; + + if (typeof window === 'undefined') { + const aiClient = await loadLegacyAiModule(); + return aiClient.generateCustomWorldProfile(normalizedInput, options); + } + + if (options.signal?.aborted) { + throw options.signal.reason instanceof Error + ? options.signal.reason + : new Error('世界生成已中断。'); + } + + const profile = await requestPostJson( + `${RUNTIME_API_BASE}/custom-world/profile`, + normalizedInput, + '生成自定义世界失败', + ); + + if (options.signal?.aborted) { + throw options.signal.reason instanceof Error + ? options.signal.reason + : new Error('世界生成已中断。'); + } + + return profile; } export async function generateCustomWorldSceneImage( diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index 4962b9c4..4be91695 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -1,33 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + AUTH_STATE_EVENT, ApiClientError, - clearStoredAccessToken, fetchWithApiAuth, - getStoredAccessToken, requestJson, - setStoredAccessToken, } from './apiClient'; -function createMemoryStorage() { - const values = new Map(); - - return { - getItem(key: string) { - return values.has(key) ? values.get(key)! : null; - }, - setItem(key: string, value: string) { - values.set(key, value); - }, - removeItem(key: string) { - values.delete(key); - }, - clear() { - values.clear(); - }, - }; -} - function createResponseMock(params: { status: number; body?: string; @@ -54,50 +33,18 @@ function createResponseMock(params: { describe('apiClient', () => { const fetchMock = vi.fn(); + const dispatchEventMock = vi.fn(); beforeEach(() => { vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('window', { - localStorage: createMemoryStorage(), - dispatchEvent: vi.fn(), + dispatchEvent: dispatchEventMock, }); fetchMock.mockReset(); - clearStoredAccessToken(); + dispatchEventMock.mockReset(); }); - it('attaches auth headers and clears stale tokens on unauthorized responses', async () => { - setStoredAccessToken('jwt-token'); - fetchMock - .mockResolvedValueOnce(createResponseMock({ status: 401 })) - .mockResolvedValueOnce(createResponseMock({ status: 401 })); - - const response = await fetchWithApiAuth('/api/protected', { method: 'GET' }); - - expect(response.status).toBe(401); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock).toHaveBeenCalledWith( - '/api/protected', - expect.objectContaining({ - credentials: 'same-origin', - headers: expect.objectContaining({ - Authorization: 'Bearer jwt-token', - 'x-genarrative-response-envelope': 'v1', - }), - }), - ); - expect(fetchMock).toHaveBeenNthCalledWith( - 2, - '/api/auth/refresh', - expect.objectContaining({ - method: 'POST', - credentials: 'same-origin', - }), - ); - expect(getStoredAccessToken()).toBe(''); - }); - - it('refreshes the access token once and retries the original request', async () => { - setStoredAccessToken('expired-token'); + it('refreshes cookie session once and retries the original request', async () => { fetchMock .mockResolvedValueOnce(createResponseMock({ status: 401 })) .mockResolvedValueOnce( @@ -106,7 +53,7 @@ describe('apiClient', () => { body: JSON.stringify({ ok: true, data: { - token: 'fresh-token', + ok: true, }, error: null, meta: { @@ -138,41 +85,115 @@ describe('apiClient', () => { ); expect(result).toEqual({ value: 7 }); - expect(getStoredAccessToken()).toBe('fresh-token'); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + '/api/runtime/protected', + expect.objectContaining({ + credentials: 'same-origin', + headers: expect.objectContaining({ + 'x-genarrative-response-envelope': 'v1', + }), + }), + ); expect(fetchMock).toHaveBeenNthCalledWith( 2, '/api/auth/refresh', expect.objectContaining({ method: 'POST', + credentials: 'same-origin', }), ); expect(fetchMock).toHaveBeenNthCalledWith( 3, '/api/runtime/protected', expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer fresh-token', - }), + credentials: 'same-origin', + }), + ); + expect(dispatchEventMock).toHaveBeenCalledTimes(1); + expect(dispatchEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: AUTH_STATE_EVENT, }), ); }); - it('does not refresh or emit auth changes for 401 responses without auth context', async () => { + it('does not emit auth change events when 401 probe requests opt into silent mode', async () => { fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); + const response = await fetchWithApiAuth( + '/api/auth/me', + { + method: 'GET', + }, + { + notifyAuthStateChange: false, + skipRefresh: true, + }, + ); + + expect(response.status).toBe(401); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(dispatchEventMock).not.toHaveBeenCalled(); + }); + + it('emits auth change events when refresh fails on protected requests', async () => { + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + .mockResolvedValueOnce(createResponseMock({ status: 401 })); + const response = await fetchWithApiAuth('/api/runtime/protected', { method: 'GET', }); expect(response.status).toBe(401); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(dispatchEventMock).toHaveBeenCalledTimes(1); + }); + + it('accepts refresh responses that only acknowledge renewed cookie state', async () => { + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + .mockResolvedValueOnce( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { + ok: true, + }, + error: null, + meta: { + apiVersion: '2026-04-08', + }, + }), + }), + ) + .mockResolvedValueOnce( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { + value: 9, + }, + error: null, + meta: { + apiVersion: '2026-04-08', + }, + }), + }), + ); + + const result = await requestJson<{ value: number }>( '/api/runtime/protected', - expect.objectContaining({ - credentials: 'same-origin', - }), + { method: 'GET' }, + '读取受保护数据失败', ); - expect(window.dispatchEvent).not.toHaveBeenCalled(); + + expect(result).toEqual({ value: 9 }); + expect(fetchMock).toHaveBeenCalledTimes(3); }); it('retries transient get requests before unwrapping the response envelope', async () => { diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 752a166e..61966e05 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -7,8 +7,8 @@ import { parseApiErrorMessage, unwrapApiResponse, } from '../../packages/shared/src/http'; +import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth'; -const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1'; export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed'; const REQUEST_ID_HEADER = 'x-request-id'; const API_VERSION_HEADER = 'x-api-version'; @@ -30,6 +30,8 @@ export type ApiRequestOptions = { skipAuth?: boolean; omitEnvelopeHeader?: boolean; skipRefresh?: boolean; + // 会话探测类请求需要静默处理 401,避免 AuthGate 因自发广播再次触发 hydrate。 + notifyAuthStateChange?: boolean; }; type ResolvedRetryOptions = { @@ -48,10 +50,6 @@ type ParsedApiErrorShape = { meta: Partial; }; -type RefreshTokenResponse = { - token: string; -}; - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } @@ -311,11 +309,7 @@ export class ApiClientError extends Error { } } -function canUseLocalStorage() { - return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; -} - -function emitAuthStateChange() { +export function emitAuthStateChange() { if (typeof window === 'undefined') { return; } @@ -330,72 +324,18 @@ function emitAuthStateChange() { } } -export function getStoredAccessToken() { - if (!canUseLocalStorage()) { - return ''; - } - - return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || ''; -} - -export function setStoredAccessToken( - token: string, - options: { - emit?: boolean; - } = {}, -) { - if (!canUseLocalStorage()) { - return; - } - - const nextToken = token.trim(); - const previousToken = getStoredAccessToken(); - if (nextToken) { - window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken); - } else { - window.localStorage.removeItem(ACCESS_TOKEN_KEY); - } - - // 只有登录态令牌真的发生变化时才广播事件,避免无意义的鉴权重算。 - if (options.emit !== false && previousToken !== nextToken) { - emitAuthStateChange(); - } -} - -export function clearStoredAccessToken( - options: { - emit?: boolean; - } = {}, -) { - if (!canUseLocalStorage()) { - return; - } - - const previousToken = getStoredAccessToken(); - window.localStorage.removeItem(ACCESS_TOKEN_KEY); - - // 未登录态下重复清空 token 不应触发状态刷新,否则会放大 401 循环。 - if (options.emit !== false && previousToken) { - emitAuthStateChange(); - } -} - -function withAuthorizationHeaders( +function withApiHeaders( headers?: HeadersInit, - options: Pick = {}, + options: Pick = {}, ) { const nextHeaders = normalizeHeaders(headers); - const token = getStoredAccessToken(); - if (token && !options.skipAuth) { - nextHeaders.Authorization = `Bearer ${token}`; - } if (!options.omitEnvelopeHeader) { nextHeaders[API_RESPONSE_ENVELOPE_HEADER] = API_RESPONSE_ENVELOPE_VERSION; } return nextHeaders; } -let refreshAccessTokenPromise: Promise | null = null; +let refreshAccessTokenPromise: Promise | null = null; async function refreshAccessToken() { if (refreshAccessTokenPromise) { @@ -412,24 +352,19 @@ async function refreshAccessToken() { }); if (!response.ok) { - clearStoredAccessToken(); throw await buildApiClientError(response, '刷新登录状态失败'); } const responseText = await response.text(); const payload = responseText - ? unwrapApiResponse( - JSON.parse(responseText) as RefreshTokenResponse, + ? unwrapApiResponse( + JSON.parse(responseText) as AuthRefreshResponse, ) : null; - if (!payload?.token?.trim()) { - clearStoredAccessToken(); + if (payload?.ok !== true) { throw new Error('刷新登录状态失败'); } - - setStoredAccessToken(payload.token, { emit: false }); - return payload.token; })(); try { @@ -446,25 +381,20 @@ export async function fetchWithApiAuth( ) { const method = (init.method ?? 'GET').toUpperCase(); const retry = resolveRetryOptions(method, options.retry); + const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false; let attempt = 0; let refreshAttempted = false; for (;;) { try { - const requestHeaders = withAuthorizationHeaders(init.headers, options); - const hasAuthHeader = Boolean( - requestHeaders.Authorization?.trim() || - requestHeaders.authorization?.trim(), - ); const response = await fetch(input, { credentials: 'same-origin', ...init, - headers: requestHeaders, + headers: withApiHeaders(init.headers, options), }); if ( response.status === 401 && - hasAuthHeader && !options.skipAuth && !options.skipRefresh && !refreshAttempted @@ -472,12 +402,19 @@ export async function fetchWithApiAuth( try { await refreshAccessToken(); refreshAttempted = true; + if (shouldNotifyAuthStateChange) { + emitAuthStateChange(); + } continue; } catch { - clearStoredAccessToken(); + if (shouldNotifyAuthStateChange) { + emitAuthStateChange(); + } + } + } else if (response.status === 401 && !options.skipAuth) { + if (shouldNotifyAuthStateChange) { + emitAuthStateChange(); } - } else if (response.status === 401) { - clearStoredAccessToken(); } if (!shouldRetryResponse(response.status, attempt, retry)) { diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index 03e245c2..566b38db 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -1,15 +1,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { requestJsonMock } = vi.hoisted(() => ({ - requestJsonMock: vi.fn(), +const apiClientMocks = vi.hoisted(() => ({ + emitAuthStateChange: vi.fn(), + requestJson: vi.fn(), })); -import { - ApiClientError, - clearStoredAccessToken, - getStoredAccessToken, - setStoredAccessToken, -} from './apiClient'; +vi.mock('./apiClient', async () => { + const actual = await vi.importActual('./apiClient'); + return { + ...actual, + emitAuthStateChange: apiClientMocks.emitAuthStateChange, + requestJson: apiClientMocks.requestJson, + }; +}); + +import { ApiClientError } from './apiClient'; import { authEntryWithStoredCredentials, bindWechatPhone, @@ -22,49 +27,34 @@ import { getAuthRiskBlocks, getAuthSessions, getCaptchaChallengeFromError, + getCurrentAuthUser, liftAuthRiskBlock, loginWithPhoneCode, logoutAllAuthSessions, - revokeAuthSession, sendPhoneLoginCode, startWechatLogin, } from './authService'; -function createMemoryStorage() { - const values = new Map(); - +function createWindowMock(overrides: Record = {}) { return { - getItem(key: string) { - return values.has(key) ? values.get(key)! : null; + dispatchEvent: vi.fn(), + location: { + pathname: '/', + hash: '', + search: '', + assign: vi.fn(), }, - setItem(key: string, value: string) { - values.set(key, value); - }, - removeItem(key: string) { - values.delete(key); - }, - clear() { - values.clear(); + history: { + replaceState: vi.fn(), }, + ...overrides, }; } -vi.mock('./apiClient', async () => { - const actual = await vi.importActual('./apiClient'); - return { - ...actual, - requestJson: requestJsonMock, - }; -}); - -describe('authService auto auth', () => { +describe('authService', () => { beforeEach(() => { - vi.stubGlobal('window', { - localStorage: createMemoryStorage(), - dispatchEvent: vi.fn(), - }); - requestJsonMock.mockReset(); - clearStoredAccessToken(); + vi.clearAllMocks(); + vi.stubGlobal('window', createWindowMock()); }); it('creates credentials that match current username/password constraints', () => { @@ -75,9 +65,8 @@ describe('authService auto auth', () => { expect(credentials.password.length).toBeGreaterThanOrEqual(6); }); - it('stores jwt after auth entry without persisting guest credentials locally', async () => { - requestJsonMock.mockResolvedValue({ - token: 'jwt-token-value', + it('auth entry trims guest credentials and emits auth state changes', async () => { + apiClientMocks.requestJson.mockResolvedValue({ user: { id: 'user_1', username: 'guest_abc123abc123', @@ -95,8 +84,7 @@ describe('authService auto auth', () => { }); expect(user.username).toBe('guest_abc123abc123'); - expect(getStoredAccessToken()).toBe('jwt-token-value'); - expect(requestJsonMock).toHaveBeenCalledWith( + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/entry', expect.objectContaining({ body: JSON.stringify({ @@ -106,11 +94,11 @@ describe('authService auto auth', () => { }), '登录失败', ); + expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1); }); it('creates a fresh guest credential pair for auto auth when a session is missing', async () => { - requestJsonMock.mockResolvedValue({ - token: 'jwt-restored', + apiClientMocks.requestJson.mockResolvedValue({ user: { id: 'user_saved', username: 'guest_saveduser01', @@ -124,7 +112,7 @@ describe('authService auto auth', () => { const result = await ensureAutoAuthUser(); const authEntryBody = JSON.parse( - requestJsonMock.mock.calls[0]?.[1]?.body as string, + apiClientMocks.requestJson.mock.calls[0]?.[1]?.body as string, ) as { username: string; password: string; @@ -136,19 +124,11 @@ describe('authService auto auth', () => { /^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u, ); expect(authEntryBody).toEqual(result.credentials); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/entry', - expect.objectContaining({ - method: 'POST', - body: expect.any(String), - }), - '登录失败', - ); + expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1); }); it('deduplicates concurrent auto auth requests', async () => { - requestJsonMock.mockResolvedValue({ - token: 'jwt-auto', + apiClientMocks.requestJson.mockResolvedValue({ user: { id: 'user_auto', username: 'guest_auto', @@ -165,19 +145,12 @@ describe('authService auto auth', () => { ensureAutoAuthUser(), ]); - expect(requestJsonMock).toHaveBeenCalledTimes(1); + expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1); expect(firstResult).toEqual(secondResult); - const authEntryBody = JSON.parse( - requestJsonMock.mock.calls[0]?.[1]?.body as string, - ) as { - username: string; - password: string; - }; - expect(authEntryBody).toEqual(firstResult.credentials); }); - it('sends phone login code through the new auth endpoint', async () => { - requestJsonMock.mockResolvedValue({ + it('sends phone login code through the auth endpoint', async () => { + apiClientMocks.requestJson.mockResolvedValue({ ok: true, cooldownSeconds: 60, expiresInSeconds: 300, @@ -187,7 +160,7 @@ describe('authService auto auth', () => { const result = await sendPhoneLoginCode(' 138 0013 8000 '); expect(result.cooldownSeconds).toBe(60); - expect(requestJsonMock).toHaveBeenCalledWith( + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/phone/send-code', expect.objectContaining({ body: JSON.stringify({ @@ -199,28 +172,6 @@ describe('authService auto auth', () => { ); }); - it('sends phone change code with the correct scene', async () => { - requestJsonMock.mockResolvedValue({ - ok: true, - cooldownSeconds: 60, - expiresInSeconds: 300, - providerRequestId: 'mock-request-id', - }); - - await sendPhoneLoginCode('13900139000', 'change_phone'); - - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/phone/send-code', - expect.objectContaining({ - body: JSON.stringify({ - phone: '13900139000', - scene: 'change_phone', - }), - }), - '发送验证码失败', - ); - }); - it('extracts captcha challenge details from api errors', () => { expect(getCaptchaChallengeFromError(new Error('plain error'))).toBeNull(); @@ -246,9 +197,8 @@ describe('authService auto auth', () => { }); }); - it('stores jwt after phone login', async () => { - requestJsonMock.mockResolvedValue({ - token: 'phone-jwt-token', + it('emits auth state changes after phone login', async () => { + apiClientMocks.requestJson.mockResolvedValue({ user: { id: 'user_phone', username: '138****8000', @@ -263,8 +213,7 @@ describe('authService auto auth', () => { const user = await loginWithPhoneCode('13800138000', '123456'); expect(user.username).toBe('138****8000'); - expect(getStoredAccessToken()).toBe('phone-jwt-token'); - expect(requestJsonMock).toHaveBeenCalledWith( + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/phone/login', expect.objectContaining({ body: JSON.stringify({ @@ -274,11 +223,11 @@ describe('authService auto auth', () => { }), '登录失败', ); + expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1); }); - it('binds wechat phone and stores jwt after activation', async () => { - requestJsonMock.mockResolvedValue({ - token: 'wechat-bind-token', + it('emits auth state changes after wechat bind activation', async () => { + apiClientMocks.requestJson.mockResolvedValue({ user: { id: 'user_wechat', username: '138****8000', @@ -293,22 +242,11 @@ describe('authService auto auth', () => { const user = await bindWechatPhone('13800138000', '123456'); expect(user.wechatBound).toBe(true); - expect(getStoredAccessToken()).toBe('wechat-bind-token'); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/wechat/bind-phone', - expect.objectContaining({ - body: JSON.stringify({ - phone: '13800138000', - code: '123456', - }), - }), - '绑定手机号失败', - ); + expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1); }); - it('changes phone number without replacing the stored access token', async () => { - setStoredAccessToken('active-token'); - requestJsonMock.mockResolvedValue({ + it('changes phone number without emitting a global auth state refresh', async () => { + apiClientMocks.requestJson.mockResolvedValue({ user: { id: 'user_phone', username: '139****9000', @@ -323,41 +261,29 @@ describe('authService auto auth', () => { const user = await changePhoneNumber('13900139000', '123456'); expect(user.phoneNumberMasked).toBe('139****9000'); - expect(getStoredAccessToken()).toBe('active-token'); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/phone/change', - expect.objectContaining({ - body: JSON.stringify({ - phone: '13900139000', - code: '123456', - }), - }), - '更换手机号失败', - ); + expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled(); }); it('starts wechat login by navigating to backend authorization url', async () => { const assignMock = vi.fn(); - vi.stubGlobal('window', { - localStorage: createMemoryStorage(), - dispatchEvent: vi.fn(), - location: { - pathname: '/', - hash: '', - search: '', - assign: assignMock, - }, - history: { - replaceState: vi.fn(), - }, - }); - requestJsonMock.mockResolvedValue({ + vi.stubGlobal( + 'window', + createWindowMock({ + location: { + pathname: '/', + hash: '', + search: '', + assign: assignMock, + }, + }), + ); + apiClientMocks.requestJson.mockResolvedValue({ authorizationUrl: '/api/auth/wechat/callback?mock_code=wx-user&state=state123', }); await startWechatLogin(); - expect(requestJsonMock).toHaveBeenCalledWith( + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/wechat/start?redirectPath=%2F', expect.objectContaining({ method: 'GET', @@ -370,14 +296,14 @@ describe('authService auto auth', () => { }); it('loads available login methods for the unauthenticated login screen', async () => { - requestJsonMock.mockResolvedValue({ + apiClientMocks.requestJson.mockResolvedValue({ availableLoginMethods: ['phone', 'wechat'], }); const result = await getAuthLoginOptions(); expect(result.availableLoginMethods).toEqual(['phone', 'wechat']); - expect(requestJsonMock).toHaveBeenCalledWith( + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/login-options', expect.objectContaining({ method: 'GET', @@ -386,20 +312,22 @@ describe('authService auto auth', () => { ); }); - it('consumes auth callback hash and stores token', () => { + it('consumes auth callback hash without trying to persist tokens locally', () => { const replaceStateMock = vi.fn(); - vi.stubGlobal('window', { - localStorage: createMemoryStorage(), - dispatchEvent: vi.fn(), - location: { - pathname: '/', - search: '', - hash: '#auth_provider=wechat&auth_token=wx-token&auth_binding_status=pending_bind_phone', - }, - history: { - replaceState: replaceStateMock, - }, - }); + vi.stubGlobal( + 'window', + createWindowMock({ + location: { + pathname: '/', + search: '', + hash: '#auth_provider=wechat&auth_binding_status=pending_bind_phone', + assign: vi.fn(), + }, + history: { + replaceState: replaceStateMock, + }, + }), + ); const result = consumeAuthCallbackResult(); @@ -408,12 +336,36 @@ describe('authService auto auth', () => { bindingStatus: 'pending_bind_phone', error: null, }); - expect(getStoredAccessToken()).toBe('wx-token'); + expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled(); expect(replaceStateMock).toHaveBeenCalledWith(null, '', '/'); }); + it('gets current auth user with silent auth-state notification settings', async () => { + apiClientMocks.requestJson.mockResolvedValue({ + user: null, + availableLoginMethods: ['phone'], + }); + + const result = await getCurrentAuthUser(); + + expect(result).toEqual({ + user: null, + availableLoginMethods: ['phone'], + }); + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/auth/me', + expect.objectContaining({ + method: 'GET', + }), + '读取当前用户失败', + { + notifyAuthStateChange: false, + }, + ); + }); + it('loads auth sessions from account center endpoint', async () => { - requestJsonMock.mockResolvedValue({ + apiClientMocks.requestJson.mockResolvedValue({ sessions: [ { sessionId: 'usess_1', @@ -432,17 +384,10 @@ describe('authService auto auth', () => { const sessions = await getAuthSessions(); expect(sessions).toHaveLength(1); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/sessions', - expect.objectContaining({ - method: 'GET', - }), - '读取登录设备失败', - ); }); it('loads recent auth audit logs', async () => { - requestJsonMock.mockResolvedValue({ + apiClientMocks.requestJson.mockResolvedValue({ logs: [ { id: 'audit_1', @@ -459,17 +404,10 @@ describe('authService auto auth', () => { const logs = await getAuthAuditLogs(); expect(logs).toHaveLength(1); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/audit-logs', - expect.objectContaining({ - method: 'GET', - }), - '读取账号操作记录失败', - ); }); it('loads current risk blocks', async () => { - requestJsonMock.mockResolvedValue({ + apiClientMocks.requestJson.mockResolvedValue({ blocks: [ { scopeType: 'phone', @@ -484,23 +422,16 @@ describe('authService auto auth', () => { const blocks = await getAuthRiskBlocks(); expect(blocks).toHaveLength(1); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/risk-blocks', - expect.objectContaining({ - method: 'GET', - }), - '读取安全状态失败', - ); }); it('lifts a risk block by scope type', async () => { - requestJsonMock.mockResolvedValue({ + apiClientMocks.requestJson.mockResolvedValue({ ok: true, }); await liftAuthRiskBlock('phone'); - expect(requestJsonMock).toHaveBeenCalledWith( + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/risk-blocks/phone/lift', expect.objectContaining({ method: 'POST', @@ -509,37 +440,20 @@ describe('authService auto auth', () => { ); }); - it('revokes a remote auth session by id', async () => { - requestJsonMock.mockResolvedValue({ - ok: true, - }); - - await revokeAuthSession('usess_123'); - - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/sessions/usess_123/revoke', - expect.objectContaining({ - method: 'POST', - }), - '移除登录设备失败', - ); - }); - - it('clears local auth state after logout all sessions', async () => { - setStoredAccessToken('stale-token'); - requestJsonMock.mockResolvedValue({ + it('emits auth change after logout all sessions', async () => { + apiClientMocks.requestJson.mockResolvedValue({ ok: true, }); await logoutAllAuthSessions(); - expect(requestJsonMock).toHaveBeenCalledWith( + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/logout-all', expect.objectContaining({ method: 'POST', }), '退出全部设备失败', ); - expect(getStoredAccessToken()).toBe(''); + expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1); }); }); diff --git a/src/services/authService.ts b/src/services/authService.ts index b6736431..dbf7b1ca 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -23,9 +23,8 @@ import type { } from '../../packages/shared/src/contracts/auth'; import { ApiClientError, - clearStoredAccessToken, + emitAuthStateChange, requestJson, - setStoredAccessToken, } from './apiClient'; export type { AuthUser } from '../../packages/shared/src/contracts/auth'; @@ -117,7 +116,7 @@ export function createAutoAuthCredentials(): AutoAuthCredentials { } export function clearAuthSession() { - clearStoredAccessToken(); + emitAuthStateChange(); } export async function sendPhoneLoginCode( @@ -160,7 +159,7 @@ export async function loginWithPhoneCode(phone: string, code: string) { '登录失败', ); - setStoredAccessToken(response.token); + emitAuthStateChange(); return response.user; } @@ -178,7 +177,7 @@ export async function bindWechatPhone(phone: string, code: string) { '绑定手机号失败', ); - setStoredAccessToken(response.token); + emitAuthStateChange(); return response.user; } @@ -233,7 +232,7 @@ export async function authEntry(username: string, password: string) { '登录失败', ); - setStoredAccessToken(response.token); + emitAuthStateChange(); return response.user; } @@ -279,19 +278,14 @@ export function consumeAuthCallbackResult(): ConsumedAuthCallback | null { } const params = new URLSearchParams(hash); - const authToken = params.get('auth_token'); const authError = params.get('auth_error'); const providerValue = params.get('auth_provider'); const bindingStatus = params.get('auth_binding_status'); - if (!authToken && !authError) { + if (!bindingStatus && !authError && !providerValue) { return null; } - if (authToken) { - setStoredAccessToken(authToken); - } - if (typeof window.history?.replaceState === 'function') { window.history.replaceState( null, @@ -314,6 +308,10 @@ export async function getCurrentAuthUser(): Promise { method: 'GET', }, '读取当前用户失败', + { + // 会话恢复阶段允许 401 作为“未登录”信号,不应再广播一次全局鉴权事件。 + notifyAuthStateChange: false, + }, ); return { diff --git a/src/services/customWorldAgentDraftResult.test.ts b/src/services/customWorldAgentDraftResult.test.ts index c75afc02..4d6c8ddf 100644 --- a/src/services/customWorldAgentDraftResult.test.ts +++ b/src/services/customWorldAgentDraftResult.test.ts @@ -777,3 +777,85 @@ test('embedded legacy result profile keeps result-page settings in runtime chara '守灯会值夜人,对外总像比别人更冷静一步。', ); }); + +test('embedded legacy result profile uses latest draft role collection when legacy role ids drift', () => { + const profile = buildCustomWorldProfileFromAgentDraft({ + ...session, + draftProfile: { + ...session.draftProfile, + legacyResultProfile: buildLegacyResultProfile(), + storyNpcs: [ + { + id: 'story-npc-latest-1', + name: '林教授', + title: '深海学院导师', + role: '场景关键角色', + publicIdentity: '研究古代海洋遗迹的资深学者。', + publicMask: '总是先观察,再给出判断。', + currentPressure: '必须在遗迹崩塌前带出关键样本。', + hiddenHook: '他知道遗迹深处那扇门为何会苏醒。', + relationToPlayer: '最早愿意共享海图的人', + threadIds: ['thread-1'], + summary: '他像学者,也像提前看见灾变的人。', + imageSrc: + '/generated-characters/story-npc-latest-1/visual/asset-latest/master.png', + generatedVisualAssetId: 'asset-latest-story', + }, + ], + sceneChapters: [ + { + id: 'scene-chapter-latest-1', + sceneId: 'landmark-1', + sceneName: '回潮旧灯塔', + title: '灯塔新章', + summary: '围绕林教授推进的新章节。', + linkedThreadIds: ['thread-1'], + linkedLandmarkIds: ['landmark-1'], + acts: [ + { + id: 'scene-act-latest-1', + title: '第一幕', + summary: '先接林教授的入口信息。', + stageCoverage: ['opening'], + backgroundImageSrc: + '/generated-custom-world-scenes/landmark-1/scene-act-latest-1/scene.png', + backgroundAssetId: 'scene-asset-latest', + encounterNpcIds: ['story-npc-latest-1'], + primaryNpcId: 'story-npc-latest-1', + linkedThreadIds: ['thread-1'], + actGoal: '接住新的入口信息', + transitionHook: '向下一幕推进。', + advanceRule: 'after_primary_contact', + }, + ], + }, + ], + landmarks: [ + { + ...session.draftProfile.landmarks[0], + imageSrc: '/generated-custom-world-scenes/landmark-1/latest-scene.png', + }, + ], + }, + }); + + expect(profile?.storyNpcs).toHaveLength(1); + expect(profile?.storyNpcs[0]?.id).toBe('story-npc-latest-1'); + expect(profile?.storyNpcs[0]?.name).toBe('林教授'); + expect(profile?.storyNpcs[0]?.imageSrc).toBe( + '/generated-characters/story-npc-latest-1/visual/asset-latest/master.png', + ); + expect(profile?.storyNpcs[0]?.generatedVisualAssetId).toBe( + 'asset-latest-story', + ); + expect(profile?.storyNpcs[0]?.narrativeProfile).toBeFalsy(); + expect(profile?.landmarks[0]?.imageSrc).toBe( + '/generated-custom-world-scenes/landmark-1/latest-scene.png', + ); + expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.primaryNpcId).toBe( + 'story-npc-latest-1', + ); + expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc).toBe( + '/generated-custom-world-scenes/landmark-1/scene-act-latest-1/scene.png', + ); +}); diff --git a/src/services/customWorldAgentDraftResult.ts b/src/services/customWorldAgentDraftResult.ts index 24bfa722..1df6f997 100644 --- a/src/services/customWorldAgentDraftResult.ts +++ b/src/services/customWorldAgentDraftResult.ts @@ -147,6 +147,13 @@ type AdaptedDraftLandmark = { connections: never[]; }; +type AdaptedDraftCamp = { + name: string; + description: string; + dangerLevel: string; + imageSrc?: string; +}; + function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set) { return toRecordArray(value) .map((record, index) => { @@ -178,108 +185,225 @@ function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set) { .filter(Boolean) as AdaptedDraftLandmark[]; } -function mergeDraftRoleAssetsIntoProfile( - baseProfile: CustomWorldProfile, - draftRoles: AdaptedDraftCharacter[], - roleKind: 'playable' | 'story', -) { - const draftRoleById = new Map(draftRoles.map((role) => [role.id, role])); - const currentRoles = - roleKind === 'playable' ? baseProfile.playableNpcs : baseProfile.storyNpcs; - const mergedRoles = currentRoles.map((role) => { - const draftRole = draftRoleById.get(role.id); - if (!draftRole) { - return role; - } +function adaptDraftCamp(value: unknown): AdaptedDraftCamp | null { + if (!isRecord(value)) { + return null; + } - return { - ...role, - imageSrc: draftRole.imageSrc ?? role.imageSrc, - generatedVisualAssetId: - draftRole.generatedVisualAssetId ?? role.generatedVisualAssetId, - generatedAnimationSetId: - draftRole.generatedAnimationSetId ?? role.generatedAnimationSetId, - animationMap: draftRole.animationMap ?? role.animationMap, - }; - }); - - if (roleKind === 'playable') { - return { - ...baseProfile, - playableNpcs: mergedRoles, - } satisfies CustomWorldProfile; + const name = toText(value.name); + const description = toText(value.description); + if (!name && !description) { + return null; } return { - ...baseProfile, - storyNpcs: mergedRoles, - } satisfies CustomWorldProfile; + name: name || '开局据点', + description: description || '开局落脚点仍待继续精修。', + dangerLevel: + toText(value.dangerLevel) || toText(value.mood) || 'medium', + imageSrc: toText(value.imageSrc) || undefined, + } satisfies AdaptedDraftCamp; } -function mergeDraftSceneAssetsIntoProfile( - baseProfile: CustomWorldProfile, - draftSceneChapters: CustomWorldProfile['sceneChapterBlueprints'], - draftLandmarks: AdaptedDraftLandmark[], +function normalizeMatchText(value: unknown) { + return toText(value).toLocaleLowerCase(); +} + +function findRecordMatchIndex( + records: Record[], + matcher: (record: Record) => boolean, + usedIndexes: Set, ) { - const normalizedDraftSceneChapters = draftSceneChapters ?? []; - const draftSceneChapterBySceneId = new Map( - normalizedDraftSceneChapters.map((chapter) => [chapter.sceneId, chapter]), + const matchedIndex = records.findIndex( + (record, index) => !usedIndexes.has(index) && matcher(record), ); - const draftLandmarkById = new Map(draftLandmarks.map((entry) => [entry.id, entry])); + if (matchedIndex >= 0) { + usedIndexes.add(matchedIndex); + } + return matchedIndex; +} - const nextCamp = baseProfile.camp - ? { - ...baseProfile.camp, - imageSrc: baseProfile.camp.imageSrc, - } - : baseProfile.camp; +function mergeDraftRolesIntoProfileRecord(params: { + baseRoles: unknown; + draftRoles: AdaptedDraftCharacter[]; +}) { + const baseRoles = toRecordArray(params.baseRoles); + if (params.draftRoles.length <= 0) { + return baseRoles; + } - const nextLandmarks = baseProfile.landmarks.map((landmark) => { - const draftLandmark = draftLandmarkById.get(landmark.id); + const usedIndexes = new Set(); + + // 当前 draft 才是最新角色集合;legacy 只负责为同一对象补运行时富字段, + // 不能再让旧列表继续主导结果页,否则会把新角色主图和新对象列表吞掉。 + return params.draftRoles.map((draftRole) => { + let matchedIndex = findRecordMatchIndex( + baseRoles, + (record) => toText(record.id) === draftRole.id, + usedIndexes, + ); + + if (matchedIndex < 0) { + matchedIndex = findRecordMatchIndex( + baseRoles, + (record) => normalizeMatchText(record.name) === normalizeMatchText(draftRole.name), + usedIndexes, + ); + } + + const baseRole = matchedIndex >= 0 ? baseRoles[matchedIndex] : null; + const baseImageSrc = toText(baseRole?.imageSrc) || undefined; + const baseGeneratedVisualAssetId = + toText(baseRole?.generatedVisualAssetId) || undefined; + const baseGeneratedAnimationSetId = + toText(baseRole?.generatedAnimationSetId) || undefined; return { - ...landmark, - imageSrc: draftLandmark?.imageSrc ?? landmark.imageSrc, - }; + ...(baseRole ?? {}), + ...draftRole, + imageSrc: draftRole.imageSrc ?? baseImageSrc, + generatedVisualAssetId: + draftRole.generatedVisualAssetId ?? baseGeneratedVisualAssetId, + generatedAnimationSetId: + draftRole.generatedAnimationSetId ?? baseGeneratedAnimationSetId, + animationMap: + draftRole.animationMap ?? + (isRecord(baseRole?.animationMap) ? baseRole?.animationMap : undefined), + } satisfies Record; + }); +} + +function mergeDraftLandmarksIntoProfileRecord(params: { + baseLandmarks: unknown; + draftLandmarks: AdaptedDraftLandmark[]; +}) { + const baseLandmarks = toRecordArray(params.baseLandmarks); + if (params.draftLandmarks.length <= 0) { + return baseLandmarks; + } + + const usedIndexes = new Set(); + const mergedLandmarks = params.draftLandmarks.map((draftLandmark) => { + let matchedIndex = findRecordMatchIndex( + baseLandmarks, + (record) => toText(record.id) === draftLandmark.id, + usedIndexes, + ); + + if (matchedIndex < 0) { + matchedIndex = findRecordMatchIndex( + baseLandmarks, + (record) => + normalizeMatchText(record.name) === normalizeMatchText(draftLandmark.name), + usedIndexes, + ); + } + + const baseLandmark = matchedIndex >= 0 ? baseLandmarks[matchedIndex] : null; + const baseImageSrc = toText(baseLandmark?.imageSrc) || undefined; + return { + ...(baseLandmark ?? {}), + id: draftLandmark.id, + name: draftLandmark.name, + description: draftLandmark.description, + dangerLevel: draftLandmark.dangerLevel, + imageSrc: draftLandmark.imageSrc ?? baseImageSrc, + sceneNpcIds: + draftLandmark.sceneNpcIds.length > 0 + ? draftLandmark.sceneNpcIds + : toStringArray(baseLandmark?.sceneNpcIds), + } satisfies Record; }); - const nextSceneChapterBlueprints = - normalizedDraftSceneChapters.length > 0 - ? baseProfile.sceneChapterBlueprints?.map((chapter) => { - const draftChapter = draftSceneChapterBySceneId.get(chapter.sceneId); - if (!draftChapter) { - return chapter; - } + const remainingLegacyLandmarks = baseLandmarks.filter( + (_entry, index) => !usedIndexes.has(index), + ); - const draftActById = new Map( - draftChapter.acts.map((act) => [act.id, act]), - ); + return [...mergedLandmarks, ...remainingLegacyLandmarks]; +} - return { - ...chapter, - acts: chapter.acts.map((act) => { - const draftAct = draftActById.get(act.id); - if (!draftAct) { - return act; - } +function mergeDraftSceneChaptersIntoProfileRecord(params: { + baseSceneChapters: unknown; + draftSceneChapters: CustomWorldProfile['sceneChapterBlueprints']; +}) { + const baseSceneChapters = toRecordArray(params.baseSceneChapters); + const draftSceneChapters = params.draftSceneChapters ?? []; + if (draftSceneChapters.length <= 0) { + return baseSceneChapters; + } - return { - ...act, - backgroundImageSrc: - draftAct.backgroundImageSrc ?? act.backgroundImageSrc, - backgroundAssetId: - draftAct.backgroundAssetId ?? act.backgroundAssetId, - }; - }), - }; - }) ?? normalizedDraftSceneChapters - : baseProfile.sceneChapterBlueprints; + const usedChapterIndexes = new Set(); + return draftSceneChapters.map((draftChapter) => { + let matchedChapterIndex = findRecordMatchIndex( + baseSceneChapters, + (record) => toText(record.sceneId) === draftChapter.sceneId, + usedChapterIndexes, + ); + if (matchedChapterIndex < 0) { + matchedChapterIndex = findRecordMatchIndex( + baseSceneChapters, + (record) => + normalizeMatchText(record.title) === normalizeMatchText(draftChapter.title), + usedChapterIndexes, + ); + } + + const baseChapter = + matchedChapterIndex >= 0 ? baseSceneChapters[matchedChapterIndex] : null; + const baseActs = toRecordArray(baseChapter?.acts); + const usedActIndexes = new Set(); + const mergedActs = draftChapter.acts.map((draftAct) => { + let matchedActIndex = findRecordMatchIndex( + baseActs, + (record) => toText(record.id) === draftAct.id, + usedActIndexes, + ); + + if (matchedActIndex < 0) { + matchedActIndex = findRecordMatchIndex( + baseActs, + (record) => + normalizeMatchText(record.title) === normalizeMatchText(draftAct.title), + usedActIndexes, + ); + } + + const baseAct = matchedActIndex >= 0 ? baseActs[matchedActIndex] : null; + const baseBackgroundImageSrc = + toText(baseAct?.backgroundImageSrc) || undefined; + const baseBackgroundAssetId = + toText(baseAct?.backgroundAssetId) || undefined; + return { + ...(baseAct ?? {}), + ...draftAct, + backgroundImageSrc: draftAct.backgroundImageSrc ?? baseBackgroundImageSrc, + backgroundAssetId: draftAct.backgroundAssetId ?? baseBackgroundAssetId, + } satisfies Record; + }); + + return { + ...(baseChapter ?? {}), + ...draftChapter, + acts: mergedActs, + } satisfies Record; + }); +} + +function mergeDraftCampIntoProfileRecord(params: { + baseCamp: unknown; + draftCamp: AdaptedDraftCamp | null; +}) { + if (!params.draftCamp) { + return isRecord(params.baseCamp) ? params.baseCamp : undefined; + } + + const baseCamp = isRecord(params.baseCamp) ? params.baseCamp : null; + const baseImageSrc = toText(baseCamp?.imageSrc) || undefined; return { - ...baseProfile, - camp: nextCamp, - landmarks: nextLandmarks, - sceneChapterBlueprints: nextSceneChapterBlueprints, - } satisfies CustomWorldProfile; + ...(baseCamp ?? {}), + ...params.draftCamp, + imageSrc: params.draftCamp.imageSrc ?? baseImageSrc, + } satisfies Record; } function toStageCoverage(value: unknown) { @@ -396,25 +520,36 @@ export function buildCustomWorldProfileFromAgentDraft( storyNpcIdSet, landmarkIdSet, ); + const draftCamp = adaptDraftCamp(draftProfile.camp); const legacyResultProfile = normalizeCustomWorldProfileRecord( draftProfile.legacyResultProfile, ); if (legacyResultProfile) { - const mergedPlayableProfile = mergeDraftRoleAssetsIntoProfile( - legacyResultProfile, - playableNpcs, - 'playable', - ); - const mergedStoryProfile = mergeDraftRoleAssetsIntoProfile( - mergedPlayableProfile, - storyNpcs, - 'story', - ); - return mergeDraftSceneAssetsIntoProfile( - mergedStoryProfile, - draftSceneChapterBlueprints, - adaptedLandmarks, - ); + const mergedProfile = normalizeCustomWorldProfileRecord({ + ...legacyResultProfile, + playableNpcs: mergeDraftRolesIntoProfileRecord({ + baseRoles: legacyResultProfile.playableNpcs, + draftRoles: playableNpcs, + }), + storyNpcs: mergeDraftRolesIntoProfileRecord({ + baseRoles: legacyResultProfile.storyNpcs, + draftRoles: storyNpcs, + }), + landmarks: mergeDraftLandmarksIntoProfileRecord({ + baseLandmarks: legacyResultProfile.landmarks, + draftLandmarks: adaptedLandmarks, + }), + camp: mergeDraftCampIntoProfileRecord({ + baseCamp: legacyResultProfile.camp, + draftCamp, + }), + sceneChapterBlueprints: mergeDraftSceneChaptersIntoProfileRecord({ + baseSceneChapters: legacyResultProfile.sceneChapterBlueprints, + draftSceneChapters: draftSceneChapterBlueprints, + }), + }); + + return mergedProfile ?? legacyResultProfile; } const normalized = normalizeCustomWorldProfileRecord({ @@ -435,14 +570,12 @@ export function buildCustomWorldProfileFromAgentDraft( playableNpcs, storyNpcs, landmarks: adaptedLandmarks, - camp: isRecord(draftProfile.camp) + camp: draftCamp ? { - name: toText(draftProfile.camp.name), - description: toText(draftProfile.camp.description), - dangerLevel: - toText(draftProfile.camp.dangerLevel) || - toText(draftProfile.camp.mood), - imageSrc: toText(draftProfile.camp.imageSrc) || undefined, + name: draftCamp.name, + description: draftCamp.description, + dangerLevel: draftCamp.dangerLevel, + imageSrc: draftCamp.imageSrc, } : undefined, sceneChapterBlueprints: draftSceneChapterBlueprints, diff --git a/src/services/customWorldAgentGenerationProgress.ts b/src/services/customWorldAgentGenerationProgress.ts index 0b6a2f42..1934db61 100644 --- a/src/services/customWorldAgentGenerationProgress.ts +++ b/src/services/customWorldAgentGenerationProgress.ts @@ -295,8 +295,8 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [ }, { id: 'workspace', - label: '准备精修工作区', - detail: '正在写回草稿数据,并切回可继续精修的工作区。', + label: '准备结果页', + detail: '正在写回草稿数据,并打开可继续完善的结果页。', matchers: ['世界底稿已生成'], minProgress: 100, }, @@ -324,7 +324,8 @@ function resolveAgentDraftFoundationStepIndexByProgress(progress: number) { index < AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 1; index += 1 ) { - if (progress >= AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index].minProgress) { + const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index]; + if (step && progress >= step.minProgress) { matchedIndex = index; } } @@ -348,7 +349,7 @@ function resolveAgentDraftFoundationStepIndex( index -= 1 ) { const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index]; - if (step.matchers.some((matcher) => phaseLabel.includes(matcher))) { + if (step?.matchers.some((matcher) => phaseLabel.includes(matcher))) { return index; } } diff --git a/src/services/platformBrowseHistory.ts b/src/services/platformBrowseHistory.ts deleted file mode 100644 index 5e432608..00000000 --- a/src/services/platformBrowseHistory.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { - PlatformBrowseHistoryEntry, - PlatformBrowseHistoryWriteEntry, -} from '../../packages/shared/src/contracts/runtime'; -import type { AuthUser } from './authService'; - -export type { PlatformBrowseHistoryEntry, PlatformBrowseHistoryWriteEntry }; - -const HISTORY_STORAGE_KEY_PREFIX = 'genarrative.platform.browse-history.v1'; -const HISTORY_SYNC_KEY_PREFIX = 'genarrative.platform.browse-history.synced.v1'; -const MAX_HISTORY_ENTRIES = 20; - -function canUseLocalStorage() { - return ( - typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' - ); -} - -function buildHistoryStorageKey(user: AuthUser | null | undefined) { - const accountId = user?.id?.trim() || user?.username?.trim() || 'guest'; - return `${HISTORY_STORAGE_KEY_PREFIX}:${accountId}`; -} - -function buildHistorySyncKey(user: AuthUser | null | undefined) { - const accountId = user?.id?.trim() || user?.username?.trim() || 'guest'; - return `${HISTORY_SYNC_KEY_PREFIX}:${accountId}`; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readString(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function normalizeHistoryEntry( - value: unknown, -): PlatformBrowseHistoryEntry | null { - if (!isRecord(value)) { - return null; - } - - const ownerUserId = readString(value.ownerUserId); - const profileId = readString(value.profileId); - const worldName = readString(value.worldName); - const visitedAt = readString(value.visitedAt); - - if (!ownerUserId || !profileId || !worldName || !visitedAt) { - return null; - } - - return { - ownerUserId, - profileId, - worldName, - subtitle: readString(value.subtitle), - summaryText: readString(value.summaryText), - coverImageSrc: readString(value.coverImageSrc) || null, - themeMode: - (readString( - value.themeMode, - ) as PlatformBrowseHistoryEntry['themeMode']) || 'mythic', - authorDisplayName: readString(value.authorDisplayName) || '玩家', - visitedAt, - }; -} - -function sortHistoryEntries(entries: PlatformBrowseHistoryEntry[]) { - return [...entries].sort((left, right) => { - return ( - new Date(right.visitedAt).getTime() - new Date(left.visitedAt).getTime() - ); - }); -} - -export function readPlatformBrowseHistory(user: AuthUser | null | undefined) { - if (!canUseLocalStorage()) { - return [] as PlatformBrowseHistoryEntry[]; - } - - const raw = window.localStorage.getItem(buildHistoryStorageKey(user)); - if (!raw?.trim()) { - return [] as PlatformBrowseHistoryEntry[]; - } - - try { - const parsed = JSON.parse(raw) as unknown[]; - if (!Array.isArray(parsed)) { - return [] as PlatformBrowseHistoryEntry[]; - } - - return sortHistoryEntries( - parsed - .map((entry) => normalizeHistoryEntry(entry)) - .filter((entry): entry is PlatformBrowseHistoryEntry => Boolean(entry)), - ).slice(0, MAX_HISTORY_ENTRIES); - } catch { - return [] as PlatformBrowseHistoryEntry[]; - } -} - -export function writePlatformBrowseHistory( - user: AuthUser | null | undefined, - entry: PlatformBrowseHistoryWriteEntry, -) { - if (!canUseLocalStorage()) { - return [] as PlatformBrowseHistoryEntry[]; - } - - const nextEntry: PlatformBrowseHistoryEntry = { - ownerUserId: entry.ownerUserId.trim(), - profileId: entry.profileId.trim(), - worldName: entry.worldName.trim(), - subtitle: entry.subtitle?.trim() || '', - summaryText: entry.summaryText?.trim() || '', - coverImageSrc: entry.coverImageSrc?.trim() || null, - themeMode: entry.themeMode || 'mythic', - authorDisplayName: entry.authorDisplayName?.trim() || '玩家', - visitedAt: entry.visitedAt?.trim() || new Date().toISOString(), - }; - const deduped = readPlatformBrowseHistory(user).filter( - (current) => - !( - current.ownerUserId === nextEntry.ownerUserId && - current.profileId === nextEntry.profileId - ), - ); - const nextEntries = sortHistoryEntries([nextEntry, ...deduped]).slice( - 0, - MAX_HISTORY_ENTRIES, - ); - - window.localStorage.setItem( - buildHistoryStorageKey(user), - JSON.stringify(nextEntries), - ); - - return nextEntries; -} - -export function clearPlatformBrowseHistory(user: AuthUser | null | undefined) { - if (!canUseLocalStorage()) { - return; - } - - window.localStorage.removeItem(buildHistoryStorageKey(user)); - window.localStorage.removeItem(buildHistorySyncKey(user)); -} - -export function hasPendingPlatformBrowseHistoryMigration( - user: AuthUser | null | undefined, -) { - if (!canUseLocalStorage()) { - return false; - } - - return ( - readPlatformBrowseHistory(user).length > 0 && - window.localStorage.getItem(buildHistorySyncKey(user)) !== '1' - ); -} - -export function markPlatformBrowseHistoryMigrated( - user: AuthUser | null | undefined, -) { - if (!canUseLocalStorage()) { - return; - } - - window.localStorage.setItem(buildHistorySyncKey(user), '1'); -} diff --git a/src/services/questDirector.ts b/src/services/questDirector.ts index 45aa18a7..4015d741 100644 --- a/src/services/questDirector.ts +++ b/src/services/questDirector.ts @@ -2,21 +2,11 @@ import { getNpcDisclosureStage, getNpcWarmthStage, } from '../data/npcInteractions'; -import { - buildFallbackQuestIntent, - compileQuestIntentToQuest, - evaluateQuestOpportunity, -} from '../data/questFlow'; +import { evaluateQuestOpportunity } from '../data/questFlow'; import type { Encounter, GameState, QuestLogEntry } from '../types'; import type { QuestGenerationContext } from './aiTypes'; import { requestJson } from './apiClient'; -import { requestChatMessageContent } from './llmClient'; -import { parseJsonResponseText } from './llmParsers'; -import { - buildQuestIntentPrompt, - QUEST_INTENT_SYSTEM_PROMPT, -} from './questPrompt'; -import type { QuestIntent, QuestPreviewRequest } from './questTypes'; +import type { QuestPreviewRequest } from './questTypes'; import { buildFallbackActorNarrativeProfile, normalizeActorNarrativeProfile, @@ -24,37 +14,6 @@ import { import { buildThemePackFromWorldProfile } from './storyEngine/themePack'; import { buildFallbackWorldStoryGraph } from './storyEngine/worldStoryGraph'; -const QUEST_DIRECTOR_TIMEOUT_MS = 12000; - -function coerceString(value: unknown, fallback: string) { - return typeof value === 'string' && value.trim() ? value.trim() : fallback; -} - -function coerceQuestTitle(value: unknown, fallback: string) { - const title = coerceString(value, fallback) - .replace(/[《》「」“”"']/gu, '') - .replace(/[,。!?;:,.!?;:].*$/u, '') - .trim(); - - if (title.length <= 12) { - return title; - } - - return fallback.length <= 12 ? fallback : fallback.slice(0, 10); -} - -function coerceStringArray(value: unknown, fallback: string[]) { - if (!Array.isArray(value)) { - return fallback; - } - - const items = value - .map((item) => (typeof item === 'string' ? item.trim() : '')) - .filter(Boolean); - - return items.length > 0 ? items : fallback; -} - function resolveIssuerNarrativeProfile(state: GameState, encounter: Encounter) { if (encounter.narrativeProfile) { return encounter.narrativeProfile; @@ -87,73 +46,6 @@ function resolveIssuerNarrativeProfile(state: GameState, encounter: Encounter) { ); } -function sanitizeQuestIntent( - rawIntent: unknown, - fallback: QuestIntent, -): QuestIntent { - if (!rawIntent || typeof rawIntent !== 'object') { - return fallback; - } - - const intent = rawIntent as Record; - - return { - title: coerceQuestTitle(intent.title, fallback.title), - description: coerceString(intent.description, fallback.description), - summary: coerceString(intent.summary, fallback.summary), - narrativeType: - typeof intent.narrativeType === 'string' && - [ - 'bounty', - 'escort', - 'investigation', - 'retrieval', - 'relationship', - 'trial', - ].includes(intent.narrativeType) - ? (intent.narrativeType as QuestIntent['narrativeType']) - : fallback.narrativeType, - dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed), - issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal), - playerHook: coerceString(intent.playerHook, fallback.playerHook), - worldReason: coerceString(intent.worldReason, fallback.worldReason), - recommendedObjectiveKinds: coerceStringArray( - intent.recommendedObjectiveKinds, - fallback.recommendedObjectiveKinds, - ).filter((kind) => - [ - 'defeat_hostile_npc', - 'inspect_treasure', - 'spar_with_npc', - 'talk_to_npc', - 'reach_scene', - 'deliver_item', - ].includes(kind), - ) as QuestIntent['recommendedObjectiveKinds'], - urgency: - typeof intent.urgency === 'string' && - ['low', 'medium', 'high'].includes(intent.urgency) - ? (intent.urgency as QuestIntent['urgency']) - : fallback.urgency, - intimacy: - typeof intent.intimacy === 'string' && - ['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy) - ? (intent.intimacy as QuestIntent['intimacy']) - : fallback.intimacy, - rewardTheme: - typeof intent.rewardTheme === 'string' && - ['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes( - intent.rewardTheme, - ) - ? (intent.rewardTheme as QuestIntent['rewardTheme']) - : fallback.rewardTheme, - followupHooks: coerceStringArray( - intent.followupHooks, - fallback.followupHooks, - ), - }; -} - export function buildQuestGenerationContextFromState(params: { state: GameState; encounter: Encounter; @@ -235,67 +127,13 @@ export async function generateQuestForNpcEncounter(params: { return null; } - const fallbackIntent = buildFallbackQuestIntent(request); - - if (typeof window !== 'undefined') { - try { - return await requestJson( - '/api/runtime/quests/generate', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(params), - }, - '任务生成失败', - ); - } catch (error) { - console.warn( - '[QuestDirector] backend quest generation failed, using deterministic fallback', - error, - ); - return compileQuestIntentToQuest( - { - ...request, - origin: 'fallback_builder', - }, - fallbackIntent, - ); - } - } - - try { - const content = await requestChatMessageContent( - QUEST_INTENT_SYSTEM_PROMPT, - buildQuestIntentPrompt({ - context: request.context!, - scene: request.scene, - opportunity, - }), - { - timeoutMs: QUEST_DIRECTOR_TIMEOUT_MS, - debugLabel: 'quest-intent', - }, - ); - const parsed = parseJsonResponseText(content) as { intent?: unknown }; - const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent); - return compileQuestIntentToQuest( - { - ...request, - origin: 'ai_compiled', - }, - intent, - ); - } catch (error) { - console.warn( - '[QuestDirector] falling back to deterministic quest intent', - error, - ); - return compileQuestIntentToQuest( - { - ...request, - origin: 'fallback_builder', - }, - fallbackIntent, - ); - } + return requestJson( + '/api/runtime/quests/generate', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }, + '任务生成失败', + ); } diff --git a/src/services/runtimeItemAiDirector.ts b/src/services/runtimeItemAiDirector.ts index 43afca96..653caa1d 100644 --- a/src/services/runtimeItemAiDirector.ts +++ b/src/services/runtimeItemAiDirector.ts @@ -1,134 +1,24 @@ -import {buildRuntimeItemAiIntent} from '../data/runtimeItemNarrative'; import type { RuntimeItemAiIntent, RuntimeItemGenerationContext, RuntimeItemPlan, } from '../types'; import { requestJson } from './apiClient'; -import {requestChatMessageContent} from './llmClient'; -import {parseJsonResponseText} from './llmParsers'; -import { - buildRuntimeItemIntentPrompt, - RUNTIME_ITEM_INTENT_SYSTEM_PROMPT, -} from './runtimeItemAiPrompt'; - -const RUNTIME_ITEM_INTENT_TIMEOUT_MS = 9000; - -function coerceString(value: unknown, fallback: string) { - return typeof value === 'string' && value.trim() ? value.trim() : fallback; -} - -function coerceStringArray(value: unknown, fallback: string[], limit: number) { - if (!Array.isArray(value)) { - return fallback; - } - - const normalized = value - .map(item => (typeof item === 'string' ? item.trim() : '')) - .filter(Boolean) - .slice(0, limit); - - return normalized.length > 0 ? normalized : fallback; -} - -function sanitizeRuntimeItemAiIntent( - rawIntent: unknown, - fallback: RuntimeItemAiIntent, -): RuntimeItemAiIntent { - if (!rawIntent || typeof rawIntent !== 'object') { - return fallback; - } - - const intent = rawIntent as Record; - const desiredFunctionalBias = coerceStringArray( - intent.desiredFunctionalBias, - fallback.desiredFunctionalBias, - 2, - ).filter( - ( - item, - ): item is RuntimeItemAiIntent['desiredFunctionalBias'][number] => - ['heal', 'mana', 'cooldown', 'guard', 'damage'].includes(item), - ); - const tone = coerceString(intent.tone, fallback.tone); - - return { - shortNameSeed: coerceString(intent.shortNameSeed, fallback.shortNameSeed), - sourcePhrase: coerceString(intent.sourcePhrase, fallback.sourcePhrase), - reasonToAppear: coerceString(intent.reasonToAppear, fallback.reasonToAppear), - relationHooks: coerceStringArray(intent.relationHooks, fallback.relationHooks, 2), - desiredBuildTags: coerceStringArray(intent.desiredBuildTags, fallback.desiredBuildTags, 3), - desiredFunctionalBias: - desiredFunctionalBias.length > 0 - ? desiredFunctionalBias - : fallback.desiredFunctionalBias, - tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone) - ? (tone as RuntimeItemAiIntent['tone']) - : fallback.tone, - visibleClue: coerceString(intent.visibleClue, fallback.visibleClue ?? ''), - witnessMark: coerceString(intent.witnessMark, fallback.witnessMark ?? ''), - unfinishedBusiness: coerceString( - intent.unfinishedBusiness, - fallback.unfinishedBusiness ?? '', - ), - hiddenHook: coerceString(intent.hiddenHook, fallback.hiddenHook ?? ''), - reactionHooks: coerceStringArray( - intent.reactionHooks, - fallback.reactionHooks ?? [], - 4, - ), - namingPattern: coerceString(intent.namingPattern, fallback.namingPattern ?? ''), - }; -} export async function generateRuntimeItemAiIntents(params: { context: RuntimeItemGenerationContext; plans: RuntimeItemPlan[]; }) { - const fallbackIntents = params.plans.map(plan => - buildRuntimeItemAiIntent(params.context, plan), - ); - - if (typeof window !== 'undefined') { - try { - const response = await requestJson<{ - intents?: unknown[]; - }>( - '/api/runtime/items/runtime-intent', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(params), - }, - '运行时物品意图生成失败', - ); - const rawIntents = Array.isArray(response.intents) ? response.intents : []; - return params.plans.map((_, index) => - sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!), - ); - } catch (error) { - console.warn( - '[runtimeItemAiDirector] backend intent generation failed, using deterministic fallback', - error, - ); - return fallbackIntents; - } - } - - const content = await requestChatMessageContent( - RUNTIME_ITEM_INTENT_SYSTEM_PROMPT, - buildRuntimeItemIntentPrompt(params), + const response = await requestJson<{ + intents?: RuntimeItemAiIntent[]; + }>( + '/api/runtime/items/runtime-intent', { - timeoutMs: RUNTIME_ITEM_INTENT_TIMEOUT_MS, - debugLabel: 'runtime-item-intent', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), }, + '运行时物品意图生成失败', ); - const parsed = parseJsonResponseText(content) as { - intents?: unknown[]; - }; - const rawIntents = Array.isArray(parsed.intents) ? parsed.intents : []; - - return params.plans.map((_, index) => - sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!), - ); + return Array.isArray(response.intents) ? response.intents : []; } diff --git a/src/services/runtimeStoryService.test.ts b/src/services/runtimeStoryService.test.ts index 76cb56d9..a7390aa7 100644 --- a/src/services/runtimeStoryService.test.ts +++ b/src/services/runtimeStoryService.test.ts @@ -18,6 +18,7 @@ import { buildStoryMomentFromRuntimeOptions, getRuntimeClientVersion, getRuntimeSessionId, + getRuntimeStoryState, isServerRuntimeFunctionId, isTask5RuntimeFunctionId, resolveRuntimeStoryAction, @@ -75,6 +76,7 @@ describe('runtimeStoryService', () => { optionText: '继续交谈', }, }, + snapshot: undefined, }), }), '执行运行时动作失败', @@ -129,6 +131,7 @@ describe('runtimeStoryService', () => { itemId: 'focus-tonic', }, }, + snapshot: undefined, }), }), '执行运行时动作失败', @@ -136,6 +139,80 @@ describe('runtimeStoryService', () => { ); }); + it('submits runtime state resolution with snapshot context to the server', async () => { + requestJsonMock.mockResolvedValue({ + sessionId: 'runtime-main', + serverVersion: 4, + viewModel: { + player: { + hp: 100, + maxHp: 100, + mana: 20, + maxMana: 20, + }, + encounter: null, + companions: [], + availableOptions: [], + status: { + inBattle: false, + npcInteractionActive: false, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + }, + }, + presentation: { + actionText: '', + resultText: '', + storyText: '服务端故事', + options: [], + }, + patches: [], + snapshot: { + version: 2, + savedAt: '2026-04-08T00:00:00.000Z', + bottomTab: 'adventure', + gameState: {}, + currentStory: null, + }, + }); + + await getRuntimeStoryState({ + sessionId: 'runtime-main', + clientVersion: 7, + snapshot: { + gameState: { currentScene: 'Story' } as never, + bottomTab: 'adventure', + currentStory: { + text: '本地故事', + options: [], + } as never, + }, + }); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/runtime/story/state/resolve', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 7, + snapshot: { + gameState: { + currentScene: 'Story', + }, + bottomTab: 'adventure', + currentStory: { + text: '本地故事', + options: [], + }, + }, + }), + }), + '读取运行时故事状态失败', + expect.any(Object), + ); + }); + it('keeps disabled runtime options when rebuilding a story moment', () => { const story = buildStoryMomentFromRuntimeOptions({ storyText: '服务端返回的新故事', diff --git a/src/services/runtimeStoryService.ts b/src/services/runtimeStoryService.ts index dd41476f..4b121f08 100644 --- a/src/services/runtimeStoryService.ts +++ b/src/services/runtimeStoryService.ts @@ -1,7 +1,9 @@ import type { + RuntimeStoryActionRequest, RuntimeStoryActionResponse, RuntimeStoryChoicePayload, RuntimeStoryOptionView, + RuntimeStoryStateRequest, ServerRuntimeFunctionId, Task5RuntimeFunctionId, } from '../../packages/shared/src/contracts/story'; @@ -44,6 +46,10 @@ export type RuntimeStoryResponse = RuntimeStoryActionResponse< StoryMoment >; export type { RuntimeStoryChoicePayload }; +export type RuntimeStorySnapshotRequest = RuntimeStoryStateRequest< + HydratedGameState, + StoryMoment +>['snapshot']; function requestRuntimeStoryJson( path: string, @@ -170,15 +176,35 @@ export function resolveRuntimeStoryMoment(params: { } export async function getRuntimeStoryState( - sessionId: string, + params: { + sessionId: string; + clientVersion?: number; + snapshot?: RuntimeStorySnapshotRequest; + }, options: RuntimeStoryServiceOptions = {}, ) { - const response = await requestRuntimeStoryJson( - `/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`, - { method: 'GET' }, - '读取运行时故事状态失败', - options, - ); + const normalizedSessionId = params.sessionId || DEFAULT_SESSION_ID; + const response = params.snapshot + ? await requestRuntimeStoryJson( + '/state/resolve', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: normalizedSessionId, + clientVersion: params.clientVersion, + snapshot: params.snapshot, + } satisfies RuntimeStoryStateRequest), + }, + '读取运行时故事状态失败', + options, + ) + : await requestRuntimeStoryJson( + `/state/${encodeURIComponent(normalizedSessionId)}`, + { method: 'GET' }, + '读取运行时故事状态失败', + options, + ); return { ...response, @@ -195,6 +221,7 @@ export async function resolveRuntimeStoryAction( option: Pick; targetId?: string; payload?: RuntimeStoryChoicePayload; + snapshot?: RuntimeStorySnapshotRequest; }, options: RuntimeStoryServiceOptions = {}, ) { @@ -215,7 +242,8 @@ export async function resolveRuntimeStoryAction( ...(params.payload ?? {}), }, }, - }), + snapshot: params.snapshot, + } satisfies RuntimeStoryActionRequest), }, '执行运行时动作失败', options,