fix custom world agent draft profile identity
This commit is contained in:
@@ -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 兜底命中”双保险,把同一份草稿重新收口为单一身份。
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
|
||||
@@ -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<String, JsonValue>,
|
||||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||||
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<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(
|
||||
ctx: &ReducerContext,
|
||||
session: &CustomWorldAgentSession,
|
||||
|
||||
@@ -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<String, JsonValue>,
|
||||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||||
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<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(
|
||||
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",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
'保存自定义世界失败',
|
||||
|
||||
Reference in New Issue
Block a user