fix creation agent session sync and publish gate alignment

This commit is contained in:
2026-04-23 13:35:40 +08:00
parent cabceb998c
commit 1e200ec5ba
7 changed files with 343 additions and 12 deletions

View File

@@ -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`

View File

@@ -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. 创作工作区、草稿结果页、作品详情等原有导航语义保持不变。

View File

@@ -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/` 覆盖策略。

View File

@@ -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());
}

View File

@@ -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<String, JsonValue> {

View File

@@ -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,

View File

@@ -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(<TestWrapper withAuth />);
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(<TestWrapper withAuth />);
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();