From 1e200ec5ba932c82e77fb04fbcbaf95e50e757d5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 23 Apr 2026 13:35:40 +0800 Subject: [PATCH] fix creation agent session sync and publish gate alignment --- ...SH_GATE_SCHEMA_ALIGNMENT_FIX_2026-04-23.md | 70 +++++++ ...SESSION_SYNC_RENDER_LOOP_FIX_2026-04-23.md | 45 +++++ docs/technical/README.md | 2 + .../crates/module-custom-world/src/lib.rs | 5 +- .../spacetime-module/src/custom_world/mod.rs | 49 ++++- .../PlatformEntryFlowShellImpl.tsx | 7 +- ...gEntryFlowShell.agent.interaction.test.tsx | 177 +++++++++++++++++- 7 files changed, 343 insertions(+), 12 deletions(-) create mode 100644 docs/technical/CREATION_AGENT_PUBLISH_GATE_SCHEMA_ALIGNMENT_FIX_2026-04-23.md create mode 100644 docs/technical/CREATION_AGENT_SESSION_SYNC_RENDER_LOOP_FIX_2026-04-23.md diff --git a/docs/technical/CREATION_AGENT_PUBLISH_GATE_SCHEMA_ALIGNMENT_FIX_2026-04-23.md b/docs/technical/CREATION_AGENT_PUBLISH_GATE_SCHEMA_ALIGNMENT_FIX_2026-04-23.md new file mode 100644 index 00000000..78fa43fe --- /dev/null +++ b/docs/technical/CREATION_AGENT_PUBLISH_GATE_SCHEMA_ALIGNMENT_FIX_2026-04-23.md @@ -0,0 +1,70 @@ +# 创作 Agent 发布门槛字段对齐修复 + +日期:`2026-04-23` + +## 1. 问题现象 + +RPG 创作结果页已经能看到完整草稿内容,但页面底部仍然持续显示旧的发布阻断项,例如: + +1. 缺少 `world hook` +2. 缺少 `player premise` +3. 缺少主线章节草稿 +4. 缺少主线第一幕 + +同时“发布并进入世界”按钮保持禁用,无法实际发布。 + +## 2. 根因 + +这不是单纯的前端提示未刷新,而是 Rust `publish gate` 仍在按旧 schema 校验 `draft_profile_json`。 + +当前前端结果页、自动保存和 session preview 主链的真实结构已经演进为: + +1. 世界一句话与玩家切入信息优先存放在 `anchorContent` 与 `creatorIntent` +2. 场景章节主链字段为 `sceneChapterBlueprints` +3. `settingText` 也会承载世界总体一句话设定 + +但 `server-rs/crates/spacetime-module/src/custom_world/mod.rs` 中的 `summarize_publish_gate_from_json(...)` 仍只检查旧字段: + +1. `worldHook` +2. `playerPremise` +3. `chapters` +4. `sceneChapters` + +结果导致: + +1. 结果页展示的是新 preview +2. 发布门槛检查读的是旧字段 +3. 同一个草稿在 UI 看起来“已经有内容”,但 gate 仍然误判为缺失 + +此外,正式发布编译在把 session draft 编译成发布 profile 时,也只把 `sceneChapters` 映射为 `sceneChapterBlueprints`,没有兼容当前更常见的 `sceneChapterBlueprints` 输入。 + +## 3. 修复策略 + +本轮统一把发布门槛与发布编译对齐到当前前端主链 schema: + +1. `world hook` 检查同时兼容: + - `worldHook` + - `creatorIntent.worldHook` + - `anchorContent.worldPromise.hook` + - `settingText` +2. `player premise` 检查同时兼容: + - `playerPremise` + - `creatorIntent.playerPremise` + - `anchorContent.playerEntryPoint.openingIdentity` + - `anchorContent.playerEntryPoint.openingProblem` + - `anchorContent.playerEntryPoint.entryMotivation` +3. 主线章节检查同时兼容: + - `chapters` + - `sceneChapterBlueprints` + - `sceneChapters` +4. 主线第一幕检查优先读取: + - `sceneChapterBlueprints[*].acts` + - `sceneChapters[*].acts` +5. 发布编译时,`sceneChapterBlueprints` 与旧 `sceneChapters` 都能写入最终 profile。 + +## 4. 验收标准 + +1. 结果页已包含 `anchorContent / creatorIntent / sceneChapterBlueprints` 的草稿,不再被旧 blocker 误判。 +2. `publishReady` 会随当前 session 最新 preview 正确刷新。 +3. “发布并进入世界”在 blocker 清空后恢复可点击。 +4. 正式发布后的 compiled profile 仍保留 `sceneChapterBlueprints`。 diff --git a/docs/technical/CREATION_AGENT_SESSION_SYNC_RENDER_LOOP_FIX_2026-04-23.md b/docs/technical/CREATION_AGENT_SESSION_SYNC_RENDER_LOOP_FIX_2026-04-23.md new file mode 100644 index 00000000..ac152648 --- /dev/null +++ b/docs/technical/CREATION_AGENT_SESSION_SYNC_RENDER_LOOP_FIX_2026-04-23.md @@ -0,0 +1,45 @@ +# 创作 Agent Session 同步渲染环修复 + +日期:`2026-04-23` + +## 1. 问题现象 + +本地联调时,前端在打开 RPG 创作 Agent 工作区后,会持续高频请求: + +- `GET /api/runtime/custom-world/agent/sessions/:sessionId` + +从 `api-server.log` 可以看到,同一个 `sessionId` 会在几毫秒到几十毫秒间隔内被重复读取很多次,明显高于正常 operation 轮询的 `1200ms` 周期。 + +## 2. 根因 + +这不是后端主动重试,也不是 session client 自带重试,而是前端 `useEffect` 被不稳定依赖反复触发。 + +触发链如下: + +1. `PlatformEntryFlowShellImpl.tsx` 内部把 `enterCreateTab` 定义为: + - 依赖整个 `platformBootstrap` 对象。 +2. `usePlatformEntryBootstrap()` 虽然内部的 `setPlatformTab` 是稳定回调,但返回值是一个新的对象字面量。 +3. 组件每次 render 时,`platformBootstrap` 引用都会变化,导致 `enterCreateTab` 也变成新的函数引用。 +4. `useRpgCreationSessionController.ts` 中“同步当前 Agent session 快照”的 `useEffect` 依赖了 `enterCreateTab`。 +5. 该 effect 每次重跑都会调用 `syncAgentSessionSnapshot(activeAgentSessionId)`,进而触发一次新的 `GET /agent/sessions/:sessionId`。 +6. `syncAgentSessionSnapshot(...)` 成功后会 `setAgentSession(...)`,又导致页面 render,从而形成新的 render -> 新 `enterCreateTab` -> effect 重跑 -> 再次 GET 的闭环。 + +因此,真正的根因是: + +- `session 同步 effect` 被一个与业务无关、且每次 render 都变化的函数依赖错误地牵连进了渲染环。 + +## 3. 修复策略 + +本轮不改后端语义,只收紧前端依赖稳定性: + +1. `PlatformEntryFlowShellImpl.tsx` 不再让 `enterCreateTab` 依赖整个 `platformBootstrap` 对象。 +2. 先解构稳定的 `setPlatformTab`,再用它生成 `enterCreateTab`。 +3. 保持 `useRpgCreationSessionController.ts` 现有 effect 逻辑不变,只让它接收到稳定的 `enterCreateTab` 引用。 +4. 增加前端回归测试,确保打开 RPG Agent 工作区后,session 快照不会因为 render 抖动而被重复拉取。 + +## 4. 验收标准 + +1. 打开 RPG 创作工作区后,允许出现首轮必要的 session 同步请求,但不能进入高频重复 GET。 +2. 未启动 operation 轮询时,不应出现毫秒级连续读取同一 `sessionId` 的现象。 +3. 存在 `activeAgentOperationId` 时,只保留原有 `1200ms` 轮询与完成态后的单次 session 刷新。 +4. 创作工作区、草稿结果页、作品详情等原有导航语义保持不变。 diff --git a/docs/technical/README.md b/docs/technical/README.md index b5e4506b..a7f309cf 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -8,6 +8,8 @@ - [RUST_LOCAL_DEV_SPACETIMEDB_PUBLISH_GUARD_AND_AGENT_LLM_FAILURE_POLICY_2026-04-23.md](./RUST_LOCAL_DEV_SPACETIMEDB_PUBLISH_GUARD_AND_AGENT_LLM_FAILURE_POLICY_2026-04-23.md):冻结 Rust 本地联调启动前必须 publish/generate 最新 `spacetime-module` 的守卫,以及 Custom World Agent 在 LLM 失败时禁止写固定 assistant 回复的 finalize 与 HTTP/SSE 错误策略。 - [CREATION_AGENT_CHAT_SCROLL_FOLLOW_POLICY_FIX_2026-04-23.md](./CREATION_AGENT_CHAT_SCROLL_FOLLOW_POLICY_FIX_2026-04-23.md):记录统一创作聊天工作区从“每次更新都强制滚到底”改为“仅在用户仍停留在底部附近时跟随”的滚动策略修复,避免流式回复持续抢走阅读位置。 - [CREATION_AGENT_STREAMING_MESSAGE_STABILITY_FIX_2026-04-23.md](./CREATION_AGENT_STREAMING_MESSAGE_STABILITY_FIX_2026-04-23.md):记录创作 Agent 聊天流式文本、玩家乐观消息、最终 session 回写和草稿切换的展示稳定性修复,避免乱码、闪消、插队和旧草稿闪烁。 +- [CREATION_AGENT_SESSION_SYNC_RENDER_LOOP_FIX_2026-04-23.md](./CREATION_AGENT_SESSION_SYNC_RENDER_LOOP_FIX_2026-04-23.md):记录 RPG 创作 Agent 工作区打开后重复 `GET /agent/sessions/:sessionId` 的前端渲染环根因,以及通过稳定 `enterCreateTab` 依赖收紧 session 同步 effect 的修复口径。 +- [CREATION_AGENT_PUBLISH_GATE_SCHEMA_ALIGNMENT_FIX_2026-04-23.md](./CREATION_AGENT_PUBLISH_GATE_SCHEMA_ALIGNMENT_FIX_2026-04-23.md):记录发布阻断项仍按旧 `worldHook / playerPremise / sceneChapters` schema 校验的问题,以及将 Rust `publish gate` 对齐到 `anchorContent / creatorIntent / sceneChapterBlueprints` 当前主链结构的修复口径。 - [CREATION_HUB_CARD_ACTIONS_2026-04-22.md](./CREATION_HUB_CARD_ACTIONS_2026-04-22.md):冻结创作中心作品卡“体验 / 删除”入口的最小落地语义,明确 RPG 已发布作品软删除、卡片直达运行时,以及暂不扩草稿 / 拼图删除契约。 - [CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md](./CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md):记录创作中心点击类别后长时间停留在“正在开启”的根因与修复口径,收口前端创建会话启动超时、中文错误提示以及 Big Fish / 拼图代理上游超时兜底。 - [JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md](./JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md):冻结 Jenkins `构建 / 部署 / 构建并部署` 三条流水线的职责、版本号传递、上游触发门禁、本地目录部署脚本与 `/home/ubuntu/Genarrative-deploy/` 覆盖策略。 diff --git a/server-rs/crates/module-custom-world/src/lib.rs b/server-rs/crates/module-custom-world/src/lib.rs index 416468fb..a62d94d4 100644 --- a/server-rs/crates/module-custom-world/src/lib.rs +++ b/server-rs/crates/module-custom-world/src/lib.rs @@ -1545,7 +1545,10 @@ fn build_compiled_profile_payload_json( } } - if let Some(scene_chapters) = draft.get("sceneChapters") { + if let Some(scene_chapters) = draft + .get("sceneChapterBlueprints") + .or_else(|| draft.get("sceneChapters")) + { payload.insert("sceneChapterBlueprints".to_string(), scene_chapters.clone()); } diff --git a/server-rs/crates/spacetime-module/src/custom_world/mod.rs b/server-rs/crates/spacetime-module/src/custom_world/mod.rs index c66eb3db..2c1a2ef2 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -2124,14 +2124,35 @@ fn summarize_publish_gate_from_json( } if let Some(profile) = draft_profile { - if read_optional_text_field(profile, &["worldHook"]).is_none() { + if read_optional_text_field( + profile, + &[ + "worldHook", + "creatorIntent.worldHook", + "anchorContent.worldPromise.hook", + "settingText", + ], + ) + .is_none() + { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_world_hook".to_string(), code: "publish_missing_world_hook".to_string(), message: "当前世界缺少 world hook,发布前需要先补齐世界一句话钩子。".to_string(), }); } - if read_optional_text_field(profile, &["playerPremise"]).is_none() { + if read_optional_text_field( + profile, + &[ + "playerPremise", + "creatorIntent.playerPremise", + "anchorContent.playerEntryPoint.openingIdentity", + "anchorContent.playerEntryPoint.openingProblem", + "anchorContent.playerEntryPoint.entryMotivation", + ], + ) + .is_none() + { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_player_premise".to_string(), code: "publish_missing_player_premise".to_string(), @@ -2145,12 +2166,22 @@ fn summarize_publish_gate_from_json( message: "当前世界缺少核心冲突,发布前需要先补齐核心冲突。".to_string(), }); } - if profile + let has_main_chapter = profile .get("chapters") .and_then(JsonValue::as_array) - .map(|value| value.is_empty()) - .unwrap_or(true) - { + .map(|value| !value.is_empty()) + .unwrap_or(false) + || profile + .get("sceneChapterBlueprints") + .and_then(JsonValue::as_array) + .map(|value| !value.is_empty()) + .unwrap_or(false) + || profile + .get("sceneChapters") + .and_then(JsonValue::as_array) + .map(|value| !value.is_empty()) + .unwrap_or(false); + if !has_main_chapter { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_main_chapter".to_string(), code: "publish_missing_main_chapter".to_string(), @@ -2158,7 +2189,8 @@ fn summarize_publish_gate_from_json( }); } let has_scene_act = profile - .get("sceneChapters") + .get("sceneChapterBlueprints") + .or_else(|| profile.get("sceneChapters")) .and_then(JsonValue::as_array) .map(|chapters| { chapters.iter().any(|chapter| { @@ -3031,6 +3063,9 @@ fn ensure_minimal_draft_profile( .entry("sceneChapters".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile + .entry("sceneChapterBlueprints".to_string()) + .or_insert_with(|| JsonValue::Array(Vec::new())); + profile } fn build_minimal_draft_profile_from_seed(seed_text: &str) -> JsonMap { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 21f2e3e7..1e72b48e 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -188,10 +188,13 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage, setSelectedDetailEntry, }); + const { setPlatformTab } = platformBootstrap; const enterCreateTab = useCallback(() => { - platformBootstrap.setPlatformTab('create'); - }, [platformBootstrap]); + // 只依赖稳定的 setter,避免把 bootstrap 对象的 render 级引用变化 + // 传导成 Agent session 恢复 effect 的重复触发。 + setPlatformTab('create'); + }, [setPlatformTab]); const sessionController = useRpgCreationSessionController({ userId: authUi?.user?.id, diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index bb101546..00857a42 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -699,7 +699,31 @@ test('create hub exposes direct template entry, keeps AIRP and visual novel lock ).toBeTruthy(); }); -test('create tab opens compiled agent draft in result refinement page', async () => { +test('opening RPG agent workspace does not refetch session snapshot in a render loop', async () => { + const user = userEvent.setup(); + + render(); + + await openNewRpgCreation(user); + + expect( + await screen.findByText( + 'Agent工作区:custom-world-agent-session-1', + {}, + { timeout: 5000 }, + ), + ).toBeTruthy(); + + await new Promise((resolve) => { + window.setTimeout(resolve, 120); + }); + + expect(getRpgCreationSession).toHaveBeenCalledTimes(1); +}); + +test( + 'create tab opens compiled agent draft in result refinement page', + async () => { const user = userEvent.setup(); vi.mocked(listRpgCreationWorks).mockResolvedValue([ @@ -752,7 +776,9 @@ test('create tab opens compiled agent draft in result refinement page', async () screen.queryByText('Agent工作区:custom-world-agent-session-1'), ).toBeNull(); expect(screen.getByRole('button', { name: /返回创作/u })).toBeTruthy(); -}); + }, + 10000, +); test('create tab resumes agent workspace when draft has no compiled result yet', async () => { const user = userEvent.setup(); @@ -1258,6 +1284,153 @@ test('agent draft result publishes before entering world and uses published prev }); }); +test('agent result view does not keep legacy publish blockers when preview uses anchorContent and sceneChapterBlueprints', async () => { + const user = userEvent.setup(); + + vi.mocked(listRpgCreationWorks).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: 'ready_to_publish', + stageLabel: '待发布草稿', + playableNpcCount: 3, + landmarkCount: 1, + roleVisualReadyCount: 1, + roleAnimationReadyCount: 0, + roleAssetSummaryLabel: null, + sessionId: 'custom-world-agent-session-1', + profileId: null, + canResume: true, + canEnterWorld: false, + }, + ]); + vi.mocked(getRpgCreationOperation).mockResolvedValue({ + operationId: 'operation-draft-foundation-1', + type: 'draft_foundation', + status: 'completed', + phaseLabel: '世界底稿已生成', + phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。', + progress: 100, + error: null, + }); + vi.mocked(getRpgCreationSession).mockResolvedValue({ + ...compiledAgentDraftSession, + stage: 'ready_to_publish', + resultPreview: { + ...compiledAgentDraftSession.resultPreview!, + publishReady: true, + blockers: [], + preview: { + ...compiledAgentDraftSession.resultPreview!.preview, + settingText: '被海雾吞没的旧航路群岛', + anchorContent: { + worldPromise: { + hook: '被海雾吞没的旧航路群岛', + differentiator: '灯塔与禁航令共同决定谁能穿过死潮。', + desiredExperience: '压抑、潮湿、悬疑', + }, + playerFantasy: { + playerRole: '玩家是被迫返乡的守灯人继承者。', + corePursuit: '查清沉船夜与假航灯的关系。', + fearOfLoss: '失去家族最后一条可信航线。', + }, + themeBoundary: { + toneKeywords: ['压抑', '悬疑'], + aestheticDirectives: ['潮湿群岛', '冷雾港口'], + forbiddenDirectives: ['轻喜冒险'], + }, + playerEntryPoint: { + openingIdentity: '返乡守灯人继承者', + openingProblem: '回港首夜撞见禁航区假航灯重亮', + entryMotivation: '阻止更多船只误入死潮', + }, + coreConflict: { + surfaceConflicts: ['守灯会与航运公会争夺航路解释权'], + hiddenCrisis: '有人在借假航灯持续清洗旧案证据', + firstTouchedConflict: '玩家返乡当夜就被卷进封航冲突', + }, + keyRelationships: [], + hiddenLines: { + hiddenTruths: ['沉船夜与假航灯骗局属于同一操盘链条'], + misdirectionHints: ['表面像海雾自然失控'], + revealPacing: '先见异常,再见旧案,再见操盘者', + }, + iconicElements: { + iconicMotifs: ['假航灯', '沉钟回响'], + institutionsOrArtifacts: ['旧灯塔', '禁航碑'], + hardRules: ['错误航灯会把船引进必死水域'], + }, + }, + creatorIntent: { + sourceMode: 'card', + rawSettingText: '', + worldHook: '被海雾吞没的旧航路群岛', + themeKeywords: ['海雾', '旧航路'], + toneDirectives: ['压抑', '悬疑'], + playerPremise: '玩家回到群岛调查沉船真相。', + openingSituation: '首夜就有陌生船只闯入禁航区。', + coreConflicts: ['航运公会与守灯会争夺航路控制权'], + keyFactions: [], + keyCharacters: [], + keyLandmarks: [], + iconicElements: ['会移动的海雾'], + forbiddenDirectives: [], + }, + sceneChapterBlueprints: [ + { + id: 'scene-chapter-1', + sceneId: 'landmark-1', + title: '沉钟栈桥章节', + summary: '围绕沉钟栈桥推进的三幕结构。', + linkedThreadIds: [], + linkedLandmarkIds: ['landmark-1'], + acts: [ + { + id: 'scene-act-1', + sceneId: 'landmark-1', + title: '潮声逼近', + summary: '第一幕先把潮声与旧钟压上来。', + stageCoverage: ['opening'], + encounterNpcIds: ['story-1'], + primaryNpcId: 'story-1', + linkedThreadIds: [], + advanceRule: 'after_primary_contact', + actGoal: '接住首幕压力', + transitionHook: '继续逼近钟楼深处。', + }, + ], + }, + ], + }, + }, + }); + + render(); + + await openCreationHub(user); + expect(await screen.findByRole('button', { name: /继续完善/u })).toBeTruthy(); + await user.click(await screen.findByRole('button', { name: /继续完善/u })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /发布并进入世界/u })).toBeTruthy(); + }); + + expect(screen.queryByText(/当前还有 4 个发布阻断项/u)).toBeNull(); + const actionButton = screen.getByRole('button', { + name: /发布并进入世界/u, + }); + expect((actionButton as HTMLButtonElement).disabled).toBe(false); +}); + test('agent draft result back button returns to creation hub without redundant sync when session is already latest', async () => { const user = userEvent.setup();