From da7c1ff0c5fd987e72eb55a458b07b146bdf433d Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 23 Apr 2026 13:54:38 +0800 Subject: [PATCH] fix custom world agent draft profile identity --- ...AFT_PROFILE_ID_STABILITY_FIX_2026-04-23.md | 109 +++++++++++ .../crates/api-server/src/custom_world.rs | 2 +- .../crates/shared-contracts/src/runtime.rs | 1 + .../spacetime-module/src/custom_world/mod.rs | 49 ++++- server-rs/crates/spacetime-module/src/lib.rs | 179 +++++++++++++++++- ...gEntryFlowShell.agent.interaction.test.tsx | 6 + .../rpg-entry/useRpgCreationResultAutosave.ts | 12 +- .../rpg-creation/rpgCreationLibraryClient.ts | 4 + 8 files changed, 356 insertions(+), 6 deletions(-) create mode 100644 docs/technical/AGENT_DRAFT_PROFILE_ID_STABILITY_FIX_2026-04-23.md diff --git a/docs/technical/AGENT_DRAFT_PROFILE_ID_STABILITY_FIX_2026-04-23.md b/docs/technical/AGENT_DRAFT_PROFILE_ID_STABILITY_FIX_2026-04-23.md new file mode 100644 index 00000000..8cec3026 --- /dev/null +++ b/docs/technical/AGENT_DRAFT_PROFILE_ID_STABILITY_FIX_2026-04-23.md @@ -0,0 +1,109 @@ +# Agent 草稿编译后重复生成草稿修复 2026-04-23 + +更新时间:`2026-04-23` + +## 1. 问题现象 + +当前创作链里,用户打开一个已有 Agent 草稿,继续编辑并触发结果页编译或自动保存后,作品库里会新增一份 draft,而不是更新原来的那一份。 + +这会带来两个直接问题: + +1. 同一个会话在作品库里出现多份草稿 +2. 原草稿状态没有被正确推进,导致发布、恢复、继续创作都可能命中旧条目 + +## 2. 根因拆解 + +本次问题不是单点,而是两段身份链没有收口到同一套规则: + +### 2.1 `sync_result_profile` 会直接覆盖 session 里的 `draft_profile_json.id` + +当前 `sync_result_profile` 会把结果页传回来的 `profile` 直接写回 `draft_profile_json`。 + +如果结果页上的 `profile.id` 已经不是原草稿 id,那么: + +1. session 主链中的 `draft_profile_json.id` 会被改成新 id +2. `resultPreview.preview.id` 也会跟着变成新 id +3. 前端 autosave 会拿着这个新 id 去调作品库 `PUT /custom-world-library/:profileId` + +### 2.2 作品库 upsert 只按 `profile_id` 命中,不按 `source_agent_session_id` 兜底 + +当前作品库落库路径: + +`结果页 autosave -> PUT /custom-world-library/:profileId -> upsert_custom_world_profile_record` + +其中普通 autosave 路径此前存在两个问题: + +1. 前端没有透传 `sourceAgentSessionId` +2. 后端普通 upsert 只按 `(owner_user_id, profile_id)` 查旧记录 + +所以一旦 `profile.id` 漂移,后端就会把它当作一条新的 draft 插入。 + +## 3. 修复目标 + +本轮修复要求同时满足下面两条: + +1. Agent 草稿结果页继续编辑时,必须优先继承当前 session 已有的稳定 `profileId` +2. 即使前端传来的 `profile.id` 已经漂移,作品库 upsert 也要能按 `source_agent_session_id` 命中同一份 draft 并更新 + +## 4. 本轮落地策略 + +### 4.1 session 主链侧:稳定保留草稿 id + +在 `sync_result_profile` 中,若当前 session 已经存在草稿身份: + +1. 优先读取 `draft_profile_json.legacyResultProfile.id` +2. 其次读取 `draft_profile_json.id` +3. 若命中稳定 id,则把传入 profile 的: + - 顶层 `id` + - `legacyResultProfile.id` + 都强制回写为这个稳定 id + +这样可以保证: + +1. `draft_profile_json.id` 不会被结果页里的漂移 id 覆盖 +2. `resultPreview.preview.id` 会持续稳定 +3. 前端后续 autosave 会继续更新原草稿 + +### 4.2 作品库保存侧:透传 `sourceAgentSessionId` + +前端 `upsertRpgWorldProfile(...)` 新增可选参数: + +`sourceAgentSessionId?: string | null` + +结果页属于 Agent 草稿视图时,autosave 会把 `activeAgentSessionId` 一并传给作品库接口。 + +### 4.3 后端 upsert 侧:按 session 命中旧 draft + +普通作品库 `PUT /custom-world-library/:profileId` 接口新增读取 `sourceAgentSessionId`。 + +Spacetime `upsert_custom_world_profile_record(...)` 在按 `profile_id` 未命中时,新增二级兜底: + +1. `owner_user_id` 相同 +2. `publication_status == draft` +3. `deleted_at == None` +4. `source_agent_session_id == input.source_agent_session_id` + +若命中这条旧 draft: + +1. 删除旧 row +2. 使用旧 row 的 `profile_id` +3. 更新 payload / metadata / updated_at + +这样即使前端 path 参数已经是新 id,也仍然会命中原草稿并更新,而不是再插入第二份草稿。 + +## 5. 验收标准 + +修复后应满足: + +1. 打开已有 Agent draft 后继续编译,不会新增第二份 draft +2. 原 draft 的 `profileId` 保持不变 +3. `resultPreview.preview.id` 与作品库 `profileId` 一致 +4. 自动保存、继续创作、进入世界、发布前检查都围绕同一份草稿身份工作 + +## 6. 结论 + +这次问题的本质不是“自动保存重复调用”,而是: + +**Agent 草稿在 session 主链和作品库 upsert 两端都缺少稳定身份约束。** + +本轮通过“session 保 id + 作品库按 session 兜底命中”双保险,把同一份草稿重新收口为单一身份。 diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index 373c6e07..3477faa3 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -148,7 +148,7 @@ pub async fn put_custom_world_library_profile( .upsert_custom_world_profile(CustomWorldProfileUpsertRecordInput { profile_id: profile_id.clone(), owner_user_id: owner_user_id.clone(), - source_agent_session_id: None, + source_agent_session_id: payload.source_agent_session_id.clone(), world_name: metadata.world_name, subtitle: metadata.subtitle, summary_text: metadata.summary_text, diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 87a02abf..35d006ea 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -225,6 +225,7 @@ pub struct RuntimeInventoryStateResponse { #[serde(rename_all = "camelCase")] pub struct CustomWorldProfileUpsertRequest { pub profile: serde_json::Value, + pub source_agent_session_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] 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 2c1a2ef2..8bd90c2f 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -993,7 +993,18 @@ fn upsert_custom_world_profile_record( .custom_world_profile() .profile_id() .find(&input.profile_id) - .filter(|row| row.owner_user_id == input.owner_user_id); + .filter(|row| row.owner_user_id == input.owner_user_id) + .or_else(|| { + input.source_agent_session_id.as_ref().and_then(|session_id| { + ctx.db.custom_world_profile().iter().find(|row| { + is_same_agent_draft_profile_candidate( + row, + &input.owner_user_id, + session_id, + ) + }) + }) + }); let next_row = match current { Some(existing) => { @@ -1798,11 +1809,16 @@ fn execute_sync_result_profile_action( payload: &JsonMap, ) -> Result { ensure_refining_stage(session.stage, "sync_result_profile")?; - let profile = payload + let mut profile = payload .get("profile") .and_then(JsonValue::as_object) .cloned() .ok_or_else(|| "sync_result_profile requires profile".to_string())?; + if let Some(stable_profile_id) = resolve_stable_agent_draft_profile_id(session) { + // 结果页回写时必须沿用当前草稿的稳定身份,避免把同一草稿写成新条目。 + profile.insert("id".to_string(), JsonValue::String(stable_profile_id.clone())); + upsert_nested_result_profile_id(&mut profile, &stable_profile_id); + } let draft_profile = ensure_minimal_draft_profile(profile, &session.seed_text); let gate = summarize_publish_gate_from_json( &session.session_id, @@ -1854,6 +1870,35 @@ fn execute_sync_result_profile_action( Ok(build_custom_world_agent_operation_snapshot(&operation)) } +fn resolve_stable_agent_draft_profile_id(session: &CustomWorldAgentSession) -> Option { + parse_optional_session_object(session.draft_profile_json.as_deref()).and_then(|profile| { + read_optional_text_field(&profile, &["legacyResultProfile.id", "id"]) + }) +} + +fn upsert_nested_result_profile_id(profile: &mut JsonMap, stable_profile_id: &str) { + let legacy_result_profile = profile + .entry("legacyResultProfile".to_string()) + .or_insert_with(|| JsonValue::Object(JsonMap::new())); + if let Some(object) = legacy_result_profile.as_object_mut() { + object.insert( + "id".to_string(), + JsonValue::String(stable_profile_id.to_string()), + ); + } +} + +fn is_same_agent_draft_profile_candidate( + row: &CustomWorldProfile, + owner_user_id: &str, + source_agent_session_id: &str, +) -> bool { + row.owner_user_id == owner_user_id + && row.deleted_at.is_none() + && row.publication_status == CustomWorldPublicationStatus::Draft + && row.source_agent_session_id.as_deref() == Some(source_agent_session_id) +} + fn execute_publish_world_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index b19c0e8b..a5ecfa03 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -3954,7 +3954,18 @@ fn upsert_custom_world_profile_record( .custom_world_profile() .profile_id() .find(&input.profile_id) - .filter(|row| row.owner_user_id == input.owner_user_id); + .filter(|row| row.owner_user_id == input.owner_user_id) + .or_else(|| { + input.source_agent_session_id.as_ref().and_then(|session_id| { + ctx.db.custom_world_profile().iter().find(|row| { + is_same_agent_draft_profile_candidate( + row, + &input.owner_user_id, + session_id, + ) + }) + }) + }); let next_row = match current { Some(existing) => { @@ -4790,11 +4801,16 @@ fn execute_sync_result_profile_action( payload: &JsonMap, ) -> Result { ensure_refining_stage(session.stage, "sync_result_profile")?; - let profile = payload + let mut profile = payload .get("profile") .and_then(JsonValue::as_object) .cloned() .ok_or_else(|| "sync_result_profile requires profile".to_string())?; + if let Some(stable_profile_id) = resolve_stable_agent_draft_profile_id(session) { + // 结果页回写时必须沿用当前草稿的稳定身份,避免把同一草稿写成新条目。 + profile.insert("id".to_string(), JsonValue::String(stable_profile_id.clone())); + upsert_nested_result_profile_id(&mut profile, &stable_profile_id); + } let draft_profile = ensure_minimal_draft_profile(profile, &session.seed_text); let gate = summarize_publish_gate_from_json( &session.session_id, @@ -4850,6 +4866,35 @@ fn execute_sync_result_profile_action( Ok(build_custom_world_agent_operation_snapshot(&operation)) } +fn resolve_stable_agent_draft_profile_id(session: &CustomWorldAgentSession) -> Option { + parse_optional_session_object(session.draft_profile_json.as_deref()).and_then(|profile| { + read_optional_text_field(&profile, &["legacyResultProfile.id", "id"]) + }) +} + +fn upsert_nested_result_profile_id(profile: &mut JsonMap, stable_profile_id: &str) { + let legacy_result_profile = profile + .entry("legacyResultProfile".to_string()) + .or_insert_with(|| JsonValue::Object(JsonMap::new())); + if let Some(object) = legacy_result_profile.as_object_mut() { + object.insert( + "id".to_string(), + JsonValue::String(stable_profile_id.to_string()), + ); + } +} + +fn is_same_agent_draft_profile_candidate( + row: &CustomWorldProfile, + owner_user_id: &str, + source_agent_session_id: &str, +) -> bool { + row.owner_user_id == owner_user_id + && row.deleted_at.is_none() + && row.publication_status == CustomWorldPublicationStatus::Draft + && row.source_agent_session_id.as_deref() == Some(source_agent_session_id) +} + fn execute_publish_world_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, @@ -9111,3 +9156,133 @@ fn append_big_fish_system_message( created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros), }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_stable_agent_draft_profile_id_prefers_legacy_result_profile_id() { + let session = CustomWorldAgentSession { + session_id: "session-1".to_string(), + owner_user_id: "user-1".to_string(), + seed_text: "seed".to_string(), + current_turn: 1, + progress_percent: 100, + stage: RpgAgentStage::ObjectRefining, + focus_card_id: None, + anchor_content_json: "{}".to_string(), + creator_intent_json: None, + creator_intent_readiness_json: "{}".to_string(), + anchor_pack_json: None, + lock_state_json: None, + draft_profile_json: Some( + r#"{"id":"drifted-profile","legacyResultProfile":{"id":"stable-profile"}}"# + .to_string(), + ), + last_assistant_reply: None, + publish_gate_json: None, + result_preview_json: None, + pending_clarifications_json: "[]".to_string(), + quality_findings_json: "[]".to_string(), + suggested_actions_json: "[]".to_string(), + recommended_replies_json: "[]".to_string(), + asset_coverage_json: "{}".to_string(), + checkpoints_json: "[]".to_string(), + created_at: Timestamp::from_micros_since_unix_epoch(1), + updated_at: Timestamp::from_micros_since_unix_epoch(1), + }; + + assert_eq!( + resolve_stable_agent_draft_profile_id(&session), + Some("stable-profile".to_string()) + ); + } + + #[test] + fn same_agent_draft_profile_candidate_requires_same_owner_active_draft_and_session() { + let matching = CustomWorldProfile { + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_agent_session_id: Some("session-1".to_string()), + publication_status: CustomWorldPublicationStatus::Draft, + world_name: "潮雾列岛".to_string(), + subtitle: String::new(), + summary_text: String::new(), + theme_mode: CustomWorldThemeMode::Mythic, + cover_image_src: None, + profile_payload_json: "{}".to_string(), + playable_npc_count: 0, + landmark_count: 0, + author_display_name: "玩家".to_string(), + published_at: None, + deleted_at: None, + created_at: Timestamp::from_micros_since_unix_epoch(1), + updated_at: Timestamp::from_micros_since_unix_epoch(1), + }; + let deleted = CustomWorldProfile { + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_agent_session_id: Some("session-1".to_string()), + publication_status: CustomWorldPublicationStatus::Draft, + world_name: "潮雾列岛".to_string(), + subtitle: String::new(), + summary_text: String::new(), + theme_mode: CustomWorldThemeMode::Mythic, + cover_image_src: None, + profile_payload_json: "{}".to_string(), + playable_npc_count: 0, + landmark_count: 0, + author_display_name: "玩家".to_string(), + published_at: None, + deleted_at: Some(Timestamp::from_micros_since_unix_epoch(2)), + created_at: Timestamp::from_micros_since_unix_epoch(1), + updated_at: Timestamp::from_micros_since_unix_epoch(1), + }; + let published = CustomWorldProfile { + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_agent_session_id: Some("session-1".to_string()), + publication_status: CustomWorldPublicationStatus::Published, + world_name: "潮雾列岛".to_string(), + subtitle: String::new(), + summary_text: String::new(), + theme_mode: CustomWorldThemeMode::Mythic, + cover_image_src: None, + profile_payload_json: "{}".to_string(), + playable_npc_count: 0, + landmark_count: 0, + author_display_name: "玩家".to_string(), + published_at: None, + deleted_at: None, + created_at: Timestamp::from_micros_since_unix_epoch(1), + updated_at: Timestamp::from_micros_since_unix_epoch(1), + }; + + assert!(is_same_agent_draft_profile_candidate( + &matching, + "user-1", + "session-1", + )); + assert!(!is_same_agent_draft_profile_candidate( + &matching, + "user-2", + "session-1", + )); + assert!(!is_same_agent_draft_profile_candidate( + &matching, + "user-1", + "session-2", + )); + assert!(!is_same_agent_draft_profile_candidate( + &deleted, + "user-1", + "session-1", + )); + assert!(!is_same_agent_draft_profile_candidate( + &published, + "user-1", + "session-1", + )); + } +} diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 00857a42..81568b14 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -1763,10 +1763,16 @@ test('agent draft result auto-save persists the latest profile rebuilt from sync const latestSavedProfile = vi .mocked(upsertRpgWorldProfile) .mock.calls.at(-1)?.[0]; + const latestSaveRequest = vi + .mocked(upsertRpgWorldProfile) + .mock.calls.at(-1)?.[1]; expect(latestSavedProfile?.name).toBe('潮雾列岛·session最新版'); expect(latestSavedProfile?.summary).toBe( '作品库应该保存这份同步后的最新快照。', ); + expect(latestSaveRequest).toEqual({ + sourceAgentSessionId: 'custom-world-agent-session-1', + }); }); test('agent draft result can open from server result preview without embedded legacyResultProfile', async () => { diff --git a/src/components/rpg-entry/useRpgCreationResultAutosave.ts b/src/components/rpg-entry/useRpgCreationResultAutosave.ts index bb571ce2..4a89ad48 100644 --- a/src/components/rpg-entry/useRpgCreationResultAutosave.ts +++ b/src/components/rpg-entry/useRpgCreationResultAutosave.ts @@ -126,7 +126,15 @@ export function useRpgCreationResultAutosave( try { const mutation = - await upsertRpgWorldProfile(normalizedProfile); + await upsertRpgWorldProfile( + normalizedProfile, + { + sourceAgentSessionId: + isAgentDraftResultView && activeAgentSessionId + ? activeAgentSessionId + : null, + }, + ); if (latestAutoSaveRequestIdRef.current !== requestId) { return mutation; } @@ -159,7 +167,9 @@ export function useRpgCreationResultAutosave( } }, [ + activeAgentSessionId, generatedCustomWorldProfile, + isAgentDraftResultView, refreshCustomWorldWorks, setSavedCustomWorldEntries, setSelectedDetailEntry, diff --git a/src/services/rpg-creation/rpgCreationLibraryClient.ts b/src/services/rpg-creation/rpgCreationLibraryClient.ts index f49ff078..c159acc1 100644 --- a/src/services/rpg-creation/rpgCreationLibraryClient.ts +++ b/src/services/rpg-creation/rpgCreationLibraryClient.ts @@ -28,6 +28,9 @@ export async function listRpgWorldLibrary( export async function upsertRpgWorldProfile( profile: CustomWorldProfile, + request: { + sourceAgentSessionId?: string | null; + } = {}, options: RpgCreationRuntimeRequestOptions = {}, ) { const response = await requestRpgCreationRuntimeJson< @@ -39,6 +42,7 @@ export async function upsertRpgWorldProfile( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile, + sourceAgentSessionId: request.sourceAgentSessionId ?? null, }), }, '保存自定义世界失败',