fix custom world agent draft profile identity

This commit is contained in:
2026-04-23 13:54:38 +08:00
parent 1e200ec5ba
commit da7c1ff0c5
8 changed files with 356 additions and 6 deletions

View File

@@ -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 兜底命中”双保险,把同一份草稿重新收口为单一身份。

View File

@@ -148,7 +148,7 @@ pub async fn put_custom_world_library_profile(
.upsert_custom_world_profile(CustomWorldProfileUpsertRecordInput { .upsert_custom_world_profile(CustomWorldProfileUpsertRecordInput {
profile_id: profile_id.clone(), profile_id: profile_id.clone(),
owner_user_id: owner_user_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, world_name: metadata.world_name,
subtitle: metadata.subtitle, subtitle: metadata.subtitle,
summary_text: metadata.summary_text, summary_text: metadata.summary_text,

View File

@@ -225,6 +225,7 @@ pub struct RuntimeInventoryStateResponse {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CustomWorldProfileUpsertRequest { pub struct CustomWorldProfileUpsertRequest {
pub profile: serde_json::Value, pub profile: serde_json::Value,
pub source_agent_session_id: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]

View File

@@ -993,7 +993,18 @@ fn upsert_custom_world_profile_record(
.custom_world_profile() .custom_world_profile()
.profile_id() .profile_id()
.find(&input.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 { let next_row = match current {
Some(existing) => { Some(existing) => {
@@ -1798,11 +1809,16 @@ fn execute_sync_result_profile_action(
payload: &JsonMap<String, JsonValue>, payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> { ) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_refining_stage(session.stage, "sync_result_profile")?; ensure_refining_stage(session.stage, "sync_result_profile")?;
let profile = payload let mut profile = payload
.get("profile") .get("profile")
.and_then(JsonValue::as_object) .and_then(JsonValue::as_object)
.cloned() .cloned()
.ok_or_else(|| "sync_result_profile requires profile".to_string())?; .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 draft_profile = ensure_minimal_draft_profile(profile, &session.seed_text);
let gate = summarize_publish_gate_from_json( let gate = summarize_publish_gate_from_json(
&session.session_id, &session.session_id,
@@ -1854,6 +1870,35 @@ fn execute_sync_result_profile_action(
Ok(build_custom_world_agent_operation_snapshot(&operation)) Ok(build_custom_world_agent_operation_snapshot(&operation))
} }
fn resolve_stable_agent_draft_profile_id(session: &CustomWorldAgentSession) -> Option<String> {
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<String, JsonValue>, 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( fn execute_publish_world_action(
ctx: &ReducerContext, ctx: &ReducerContext,
session: &CustomWorldAgentSession, session: &CustomWorldAgentSession,

View File

@@ -3954,7 +3954,18 @@ fn upsert_custom_world_profile_record(
.custom_world_profile() .custom_world_profile()
.profile_id() .profile_id()
.find(&input.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 { let next_row = match current {
Some(existing) => { Some(existing) => {
@@ -4790,11 +4801,16 @@ fn execute_sync_result_profile_action(
payload: &JsonMap<String, JsonValue>, payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> { ) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_refining_stage(session.stage, "sync_result_profile")?; ensure_refining_stage(session.stage, "sync_result_profile")?;
let profile = payload let mut profile = payload
.get("profile") .get("profile")
.and_then(JsonValue::as_object) .and_then(JsonValue::as_object)
.cloned() .cloned()
.ok_or_else(|| "sync_result_profile requires profile".to_string())?; .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 draft_profile = ensure_minimal_draft_profile(profile, &session.seed_text);
let gate = summarize_publish_gate_from_json( let gate = summarize_publish_gate_from_json(
&session.session_id, &session.session_id,
@@ -4850,6 +4866,35 @@ fn execute_sync_result_profile_action(
Ok(build_custom_world_agent_operation_snapshot(&operation)) Ok(build_custom_world_agent_operation_snapshot(&operation))
} }
fn resolve_stable_agent_draft_profile_id(session: &CustomWorldAgentSession) -> Option<String> {
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<String, JsonValue>, 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( fn execute_publish_world_action(
ctx: &ReducerContext, ctx: &ReducerContext,
session: &CustomWorldAgentSession, session: &CustomWorldAgentSession,
@@ -9111,3 +9156,133 @@ fn append_big_fish_system_message(
created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros), 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",
));
}
}

View File

@@ -1763,10 +1763,16 @@ test('agent draft result auto-save persists the latest profile rebuilt from sync
const latestSavedProfile = vi const latestSavedProfile = vi
.mocked(upsertRpgWorldProfile) .mocked(upsertRpgWorldProfile)
.mock.calls.at(-1)?.[0]; .mock.calls.at(-1)?.[0];
const latestSaveRequest = vi
.mocked(upsertRpgWorldProfile)
.mock.calls.at(-1)?.[1];
expect(latestSavedProfile?.name).toBe('潮雾列岛·session最新版'); expect(latestSavedProfile?.name).toBe('潮雾列岛·session最新版');
expect(latestSavedProfile?.summary).toBe( 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 () => { test('agent draft result can open from server result preview without embedded legacyResultProfile', async () => {

View File

@@ -126,7 +126,15 @@ export function useRpgCreationResultAutosave(
try { try {
const mutation = const mutation =
await upsertRpgWorldProfile(normalizedProfile); await upsertRpgWorldProfile(
normalizedProfile,
{
sourceAgentSessionId:
isAgentDraftResultView && activeAgentSessionId
? activeAgentSessionId
: null,
},
);
if (latestAutoSaveRequestIdRef.current !== requestId) { if (latestAutoSaveRequestIdRef.current !== requestId) {
return mutation; return mutation;
} }
@@ -159,7 +167,9 @@ export function useRpgCreationResultAutosave(
} }
}, },
[ [
activeAgentSessionId,
generatedCustomWorldProfile, generatedCustomWorldProfile,
isAgentDraftResultView,
refreshCustomWorldWorks, refreshCustomWorldWorks,
setSavedCustomWorldEntries, setSavedCustomWorldEntries,
setSelectedDetailEntry, setSelectedDetailEntry,

View File

@@ -28,6 +28,9 @@ export async function listRpgWorldLibrary(
export async function upsertRpgWorldProfile( export async function upsertRpgWorldProfile(
profile: CustomWorldProfile, profile: CustomWorldProfile,
request: {
sourceAgentSessionId?: string | null;
} = {},
options: RpgCreationRuntimeRequestOptions = {}, options: RpgCreationRuntimeRequestOptions = {},
) { ) {
const response = await requestRpgCreationRuntimeJson< const response = await requestRpgCreationRuntimeJson<
@@ -39,6 +42,7 @@ export async function upsertRpgWorldProfile(
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
profile, profile,
sourceAgentSessionId: request.sourceAgentSessionId ?? null,
}), }),
}, },
'保存自定义世界失败', '保存自定义世界失败',