Resolve spacetime client binding merge conflicts

This commit is contained in:
2026-04-24 14:44:46 +08:00
parent 4f369617c7
commit f65177b147
26 changed files with 2172 additions and 1020 deletions

View File

@@ -1588,13 +1588,15 @@ fn execute_custom_world_agent_action_tx(
}
"publish_world" => execute_publish_world_action(ctx, &session, &input, &payload),
"revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload),
"generate_characters"
| "generate_landmarks"
| "generate_role_assets"
| "sync_role_assets"
| "generate_scene_assets"
| "sync_scene_assets"
| "expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input),
"generate_characters" | "generate_landmarks" => {
execute_generate_entities_action(ctx, &session, &input, &payload)
}
"generate_role_assets" | "generate_scene_assets" => {
execute_prepare_asset_studio_action(ctx, &session, &input, &payload)
}
"sync_role_assets" => execute_sync_role_assets_action(ctx, &session, &input, &payload),
"sync_scene_assets" => execute_sync_scene_assets_action(ctx, &session, &input, &payload),
"expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input),
other => Err(format!("custom world action `{other}` 当前尚未支持")),
}
}
@@ -2134,6 +2136,146 @@ fn execute_revert_checkpoint_action(
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn execute_prepare_asset_studio_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_draft_refining_stage(session.stage, input.action.as_str())?;
let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref())
.ok_or_else(|| format!("{} requires an existing draft foundation", input.action))?;
let (focus_id, operation_type, message_text, phase_label, phase_detail) =
if input.action == "generate_role_assets" {
let role_id = read_first_payload_text(payload, "roleIds", "roleId")
.ok_or_else(|| "generate_role_assets requires roleIds".to_string())?;
let role = find_profile_entity_by_id(&draft_profile, &["playableNpcs", "storyNpcs"], &role_id)
.ok_or_else(|| "未找到目标角色,无法进入角色资产工坊。".to_string())?;
let role_name = read_optional_text_field(role, &["name"]).unwrap_or_else(|| "角色".to_string());
(
role_id,
RpgAgentOperationType::GenerateRoleAssets,
format!("已为「{}」准备好角色资产工坊,先生成主图候选,再补核心动作。", role_name),
"角色资产工坊已就绪",
format!("{}」现在可以开始生成主图和动作。", role_name),
)
} else {
let scene_id = read_first_payload_text(payload, "sceneIds", "sceneId")
.ok_or_else(|| "generate_scene_assets requires sceneIds".to_string())?;
let scene_kind = payload.get("sceneKind").and_then(JsonValue::as_str).map(str::trim).unwrap_or("landmark");
let scene = if scene_kind == "camp" {
draft_profile.get("camp").and_then(JsonValue::as_object)
} else {
find_profile_entity_by_id(&draft_profile, &["landmarks"], &scene_id)
}
.ok_or_else(|| "未找到目标场景,无法进入场景资产工坊。".to_string())?;
let scene_name = read_optional_text_field(scene, &["name"])
.unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "未命名场景" }.to_string());
(
scene_id,
RpgAgentOperationType::GenerateSceneAssets,
format!("已为「{}」准备好场景图工坊,保存生成结果后会自动同步回当前草稿。", scene_name),
"场景资产工坊已就绪",
format!("{}」现在可以继续生成和确认正式场景图。", scene_name),
)
};
let next_session = rebuild_custom_world_agent_session_row(
session,
CustomWorldAgentSessionPatch {
stage: Some(RpgAgentStage::VisualRefining),
focus_card_id: Some(Some(focus_id)),
last_assistant_reply: Some(Some(message_text.clone())),
updated_at_micros: Some(input.submitted_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?;
replace_custom_world_agent_session(ctx, session, next_session);
append_custom_world_action_result_message(ctx, &session.session_id, &input.operation_id, &message_text, input.submitted_at_micros);
let operation = build_and_insert_custom_world_operation(ctx, &input.operation_id, &session.session_id, operation_type, phase_label, &phase_detail, input.submitted_at_micros);
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn execute_sync_role_assets_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_draft_refining_stage(session.stage, "sync_role_assets")?;
let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref())
.ok_or_else(|| "sync_role_assets requires an existing draft foundation".to_string())?;
let role_id = read_required_payload_text(payload, "roleId", "sync_role_assets requires roleId")?;
let portrait_path = read_required_payload_text(payload, "portraitPath", "sync_role_assets requires portraitPath")?;
let generated_visual_asset_id = read_required_payload_text(payload, "generatedVisualAssetId", "sync_role_assets requires generatedVisualAssetId")?;
let generated_animation_set_id = payload.get("generatedAnimationSetId").and_then(JsonValue::as_str).map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned);
let animation_map = payload.get("animationMap").cloned();
let updated_role = apply_role_asset_publish_result(&mut draft_profile, &role_id, &portrait_path, &generated_visual_asset_id, generated_animation_set_id.as_deref(), animation_map)?;
let role_name = read_optional_text_field(&updated_role, &["name"]).unwrap_or_else(|| "当前角色".to_string());
let asset_status = resolve_role_asset_status(&updated_role);
let asset_status_label = resolve_role_asset_status_label(asset_status).to_string();
upsert_asset_role_card(ctx, &session.session_id, &role_id, &updated_role, asset_status, &asset_status_label, input.submitted_at_micros)?;
let gate = summarize_publish_gate_from_json(&input.session_id, RpgAgentStage::VisualRefining, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json));
let next_session = rebuild_custom_world_agent_session_row(
session,
CustomWorldAgentSessionPatch {
stage: Some(RpgAgentStage::VisualRefining),
focus_card_id: Some(Some(role_id.clone())),
draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)),
last_assistant_reply: Some(Some(format!("已把「{}」的角色资产写回草稿,当前状态:{}", role_name, asset_status_label))),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)),
result_preview_json: Some(build_result_preview_json(Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), input.submitted_at_micros)?),
checkpoints_json: Some(append_checkpoint_json(&session.checkpoints_json, &build_session_checkpoint_value("sync-role-assets", &format!("同步角色资产 {}", role_name), session))?),
asset_coverage_json: Some(build_asset_coverage_json(&draft_profile)?),
updated_at_micros: Some(input.submitted_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?;
replace_custom_world_agent_session(ctx, session, next_session);
append_custom_world_action_result_message(ctx, &session.session_id, &input.operation_id, &format!("已把「{}」的角色资产写回草稿,当前状态:{}", role_name, asset_status_label), input.submitted_at_micros);
let operation = build_and_insert_custom_world_operation(ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::SyncRoleAssets, "角色资产已同步", &format!("{}」的资产状态已更新为{}", role_name, asset_status_label), input.submitted_at_micros);
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn execute_sync_scene_assets_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_draft_refining_stage(session.stage, "sync_scene_assets")?;
let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref())
.ok_or_else(|| "sync_scene_assets requires an existing draft foundation".to_string())?;
let scene_id = read_required_payload_text(payload, "sceneId", "sync_scene_assets requires sceneId")?;
let scene_kind = read_required_payload_text(payload, "sceneKind", "sync_scene_assets requires sceneKind")?;
let image_src = read_required_payload_text(payload, "imageSrc", "sync_scene_assets requires imageSrc")?;
let generated_scene_asset_id = read_required_payload_text(payload, "generatedSceneAssetId", "sync_scene_assets requires generatedSceneAssetId")?;
let updated_scene = apply_scene_asset_publish_result(&mut draft_profile, &scene_id, &scene_kind, &image_src, &generated_scene_asset_id, payload.get("generatedScenePrompt").cloned().unwrap_or(JsonValue::Null), payload.get("generatedSceneModel").cloned().unwrap_or(JsonValue::Null))?;
let scene_name = read_optional_text_field(&updated_scene, &["name"]).unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "当前场景" }.to_string());
upsert_asset_scene_card(ctx, &session.session_id, &scene_id, &scene_kind, &updated_scene, input.submitted_at_micros)?;
let gate = summarize_publish_gate_from_json(&input.session_id, RpgAgentStage::VisualRefining, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json));
let next_session = rebuild_custom_world_agent_session_row(
session,
CustomWorldAgentSessionPatch {
stage: Some(RpgAgentStage::VisualRefining),
focus_card_id: Some(Some(scene_id.clone())),
draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)),
last_assistant_reply: Some(Some(format!("已把「{}」的场景图写回草稿,并同步刷新地点卡与幕背景状态。", scene_name))),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)),
result_preview_json: Some(build_result_preview_json(Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), input.submitted_at_micros)?),
checkpoints_json: Some(append_checkpoint_json(&session.checkpoints_json, &build_session_checkpoint_value("sync-scene-assets", &format!("同步场景资产 {}", scene_name), session))?),
asset_coverage_json: Some(build_asset_coverage_json(&draft_profile)?),
updated_at_micros: Some(input.submitted_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?;
replace_custom_world_agent_session(ctx, session, next_session);
append_custom_world_action_result_message(ctx, &session.session_id, &input.operation_id, &format!("已把「{}」的场景图写回草稿,并同步刷新地点卡与幕背景状态。", scene_name), input.submitted_at_micros);
let operation = build_and_insert_custom_world_operation(ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::SyncSceneAssets, "场景资产已同步", &format!("{}」的场景图已经进入当前草稿。", scene_name), input.submitted_at_micros);
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn execute_placeholder_custom_world_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
@@ -3278,6 +3420,121 @@ fn parse_json_array_or_empty(raw: &str) -> Vec<JsonValue> {
.unwrap_or_default()
}
fn read_first_payload_text(payload: &JsonMap<String, JsonValue>, array_key: &str, scalar_key: &str) -> Option<String> {
payload.get(array_key).and_then(JsonValue::as_array).and_then(|values| values.first()).and_then(JsonValue::as_str)
.or_else(|| payload.get(scalar_key).and_then(JsonValue::as_str))
.map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned)
}
fn find_profile_entity_by_id<'a>(profile: &'a JsonMap<String, JsonValue>, fields: &[&str], entity_id: &str) -> Option<&'a JsonMap<String, JsonValue>> {
for field in fields {
if let Some(entries) = profile.get(*field).and_then(JsonValue::as_array) {
for entry in entries {
let Some(object) = entry.as_object() else { continue; };
if read_optional_text_field(object, &["id"]).as_deref() == Some(entity_id) { return Some(object); }
}
}
}
None
}
fn apply_role_asset_publish_result(profile: &mut JsonMap<String, JsonValue>, role_id: &str, portrait_path: &str, generated_visual_asset_id: &str, generated_animation_set_id: Option<&str>, animation_map: Option<JsonValue>) -> Result<JsonMap<String, JsonValue>, String> {
for field in ["playableNpcs", "storyNpcs"] {
let Some(entries) = profile.get_mut(field).and_then(JsonValue::as_array_mut) else { continue; };
for entry in entries {
let Some(object) = entry.as_object_mut() else { continue; };
if read_optional_text_field(object, &["id"]).as_deref() != Some(role_id) { continue; }
object.insert("imageSrc".to_string(), JsonValue::String(portrait_path.to_string()));
object.insert("generatedVisualAssetId".to_string(), JsonValue::String(generated_visual_asset_id.to_string()));
if let Some(asset_id) = generated_animation_set_id { object.insert("generatedAnimationSetId".to_string(), JsonValue::String(asset_id.to_string())); }
if let Some(map) = animation_map { object.insert("animationMap".to_string(), map); }
return Ok(object.clone());
}
}
Err("目标角色不存在,无法同步角色资产。".to_string())
}
fn apply_scene_asset_publish_result(profile: &mut JsonMap<String, JsonValue>, scene_id: &str, scene_kind: &str, image_src: &str, generated_scene_asset_id: &str, generated_scene_prompt: JsonValue, generated_scene_model: JsonValue) -> Result<JsonMap<String, JsonValue>, String> {
let updated_scene = if scene_kind == "camp" {
let camp = profile.get_mut("camp").and_then(JsonValue::as_object_mut).ok_or_else(|| "目标营地不存在,无法同步场景资产。".to_string())?;
if read_optional_text_field(camp, &["id"]).as_deref() != Some(scene_id) { return Err("目标营地不存在,无法同步场景资产。".to_string()); }
camp.insert("imageSrc".to_string(), JsonValue::String(image_src.to_string()));
camp.insert("generatedSceneAssetId".to_string(), JsonValue::String(generated_scene_asset_id.to_string()));
camp.insert("generatedScenePrompt".to_string(), generated_scene_prompt);
camp.insert("generatedSceneModel".to_string(), generated_scene_model);
camp.clone()
} else {
let landmarks = profile.get_mut("landmarks").and_then(JsonValue::as_array_mut).ok_or_else(|| "目标地点不存在,无法同步场景资产。".to_string())?;
let mut updated = None;
for entry in landmarks {
let Some(object) = entry.as_object_mut() else { continue; };
if read_optional_text_field(object, &["id"]).as_deref() != Some(scene_id) { continue; }
object.insert("imageSrc".to_string(), JsonValue::String(image_src.to_string()));
object.insert("generatedSceneAssetId".to_string(), JsonValue::String(generated_scene_asset_id.to_string()));
object.insert("generatedScenePrompt".to_string(), generated_scene_prompt.clone());
object.insert("generatedSceneModel".to_string(), generated_scene_model.clone());
updated = Some(object.clone());
break;
}
updated.ok_or_else(|| "目标地点不存在,无法同步场景资产。".to_string())?
};
update_scene_chapter_acts_for_scene(profile, scene_id, image_src, generated_scene_asset_id);
Ok(updated_scene)
}
fn update_scene_chapter_acts_for_scene(profile: &mut JsonMap<String, JsonValue>, scene_id: &str, image_src: &str, generated_scene_asset_id: &str) {
let Some(chapters) = profile.get_mut("sceneChapters").and_then(JsonValue::as_array_mut) else { return; };
for chapter in chapters {
let Some(chapter_object) = chapter.as_object_mut() else { continue; };
if read_optional_text_field(chapter_object, &["sceneId"]).as_deref() != Some(scene_id) { continue; }
let Some(acts) = chapter_object.get_mut("acts").and_then(JsonValue::as_array_mut) else { continue; };
for act in acts {
if let Some(act_object) = act.as_object_mut() {
act_object.insert("backgroundImageSrc".to_string(), JsonValue::String(image_src.to_string()));
act_object.insert("backgroundAssetId".to_string(), JsonValue::String(generated_scene_asset_id.to_string()));
}
}
}
}
fn resolve_role_asset_status(role: &JsonMap<String, JsonValue>) -> CustomWorldRoleAssetStatus {
let has_portrait = read_optional_text_field(role, &["imageSrc"]).is_some() && read_optional_text_field(role, &["generatedVisualAssetId"]).is_some();
if !has_portrait { return CustomWorldRoleAssetStatus::Missing; }
let has_animation_set = read_optional_text_field(role, &["generatedAnimationSetId"]).is_some();
let has_animation_map = role.get("animationMap").and_then(JsonValue::as_object).map(|map| !map.is_empty()).unwrap_or(false);
if has_animation_set && has_animation_map { CustomWorldRoleAssetStatus::Complete } else if has_animation_set { CustomWorldRoleAssetStatus::AnimationsReady } else { CustomWorldRoleAssetStatus::VisualReady }
}
fn resolve_role_asset_status_label(status: CustomWorldRoleAssetStatus) -> &'static str {
match status { CustomWorldRoleAssetStatus::Complete => "动作已就绪", CustomWorldRoleAssetStatus::AnimationsReady => "动作补齐中", CustomWorldRoleAssetStatus::VisualReady => "主图已就绪", CustomWorldRoleAssetStatus::Missing => "待生成主图" }
}
fn build_asset_coverage_json(profile: &JsonMap<String, JsonValue>) -> Result<String, String> {
serialize_json_value(&json!({"roleAssets": [], "sceneAssets": [], "allRoleAssetsReady": false, "allSceneAssetsReady": false, "profileId": read_optional_text_field(profile, &["id"])}))
}
fn upsert_asset_role_card(ctx: &ReducerContext, session_id: &str, role_id: &str, role: &JsonMap<String, JsonValue>, asset_status: CustomWorldRoleAssetStatus, asset_status_label: &str, updated_at_micros: i64) -> Result<(), String> {
let title = read_optional_text_field(role, &["name"]).unwrap_or_else(|| "角色".to_string());
let subtitle = read_optional_text_field(role, &["role", "relationToPlayer", "publicMask"]).unwrap_or_else(|| asset_status_label.to_string());
let summary = read_optional_text_field(role, &["summary", "description", "publicMask"]).unwrap_or_else(|| "角色资产已写回草稿。".to_string());
upsert_asset_card(ctx, session_id, role_id, RpgAgentDraftCardKind::Character, &title, &subtitle, &summary, Some(asset_status), Some(asset_status_label), updated_at_micros)
}
fn upsert_asset_scene_card(ctx: &ReducerContext, session_id: &str, scene_id: &str, scene_kind: &str, scene: &JsonMap<String, JsonValue>, updated_at_micros: i64) -> Result<(), String> {
let kind = if scene_kind == "camp" { RpgAgentDraftCardKind::Camp } else { RpgAgentDraftCardKind::Landmark };
let title = read_optional_text_field(scene, &["name"]).unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "场景" }.to_string());
let subtitle = read_optional_text_field(scene, &["purpose", "mood", "dangerLevel"]).unwrap_or_else(|| "场景资产已就绪".to_string());
let summary = read_optional_text_field(scene, &["summary", "description", "publicMask"]).unwrap_or_else(|| "场景图已写回草稿。".to_string());
upsert_asset_card(ctx, session_id, scene_id, kind, &title, &subtitle, &summary, None, Some("场景图已就绪"), updated_at_micros)
}
fn upsert_asset_card(ctx: &ReducerContext, session_id: &str, card_id: &str, kind: RpgAgentDraftCardKind, title: &str, subtitle: &str, summary: &str, asset_status: Option<CustomWorldRoleAssetStatus>, asset_status_label: Option<&str>, updated_at_micros: i64) -> Result<(), String> {
let detail_payload = json!({"id": card_id, "kind": kind.as_str(), "title": title, "sections": [{"id": "summary", "label": "摘要", "value": summary}], "linkedIds": [], "locked": false, "editable": true, "editableSectionIds": ["summary"], "warningMessages": []});
let next = CustomWorldDraftCard { card_id: card_id.to_string(), session_id: session_id.to_string(), kind, status: RpgAgentDraftCardStatus::Draft, title: title.to_string(), subtitle: subtitle.to_string(), summary: summary.to_string(), linked_ids_json: "[]".to_string(), warning_count: 0, asset_status, asset_status_label: asset_status_label.map(ToOwned::to_owned), detail_payload_json: Some(serialize_json_value(&detail_payload)?), created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros) };
if let Some(existing) = ctx.db.custom_world_draft_card().card_id().find(&card_id.to_string()).filter(|entry| entry.session_id == session_id) { replace_custom_world_draft_card(ctx, &existing, CustomWorldDraftCard { created_at: existing.created_at, ..next }); } else { ctx.db.custom_world_draft_card().insert(next); }
Ok(())
}
fn serialize_json_value(value: &JsonValue) -> Result<String, String> {
serde_json::to_string(value).map_err(|error| format!("JSON 序列化失败: {error}"))
}

View File

@@ -1192,6 +1192,25 @@ pub fn get_custom_world_agent_operation(
}
}
#[spacetimedb::procedure]
pub fn upsert_custom_world_agent_operation_progress(
ctx: &mut ProcedureContext,
input: CustomWorldAgentOperationProgressInput,
) -> CustomWorldAgentOperationProcedureResult {
match ctx.try_with_tx(|tx| upsert_custom_world_agent_operation_progress_tx(tx, input.clone())) {
Ok(operation) => CustomWorldAgentOperationProcedureResult {
ok: true,
operation: Some(operation),
error_message: None,
},
Err(message) => CustomWorldAgentOperationProcedureResult {
ok: false,
operation: None,
error_message: Some(message),
},
}
}
fn continue_story_tx(
ctx: &ReducerContext,
input: StoryContinueInput,
@@ -1474,6 +1493,59 @@ fn get_custom_world_agent_operation_tx(
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn upsert_custom_world_agent_operation_progress_tx(
ctx: &ReducerContext,
input: CustomWorldAgentOperationProgressInput,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
validate_custom_world_agent_operation_progress_input(&input).map_err(|error| error.to_string())?;
ctx.db
.custom_world_agent_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
let timestamp = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
let operation = if let Some(current) = ctx
.db
.custom_world_agent_operation()
.operation_id()
.find(&input.operation_id)
{
if current.session_id != input.session_id {
return Err("custom_world_agent_operation.session_id 不匹配".to_string());
}
let next = rebuild_custom_world_agent_operation_row(
&current,
CustomWorldAgentOperationPatch {
status: Some(input.operation_status),
phase_label: Some(input.phase_label.clone()),
phase_detail: Some(input.phase_detail.clone()),
progress: Some(input.operation_progress),
error_message: Some(input.error_message.clone()),
updated_at_micros: Some(input.updated_at_micros),
},
)?;
replace_custom_world_agent_operation(ctx, &current, next.clone());
next
} else {
ctx.db.custom_world_agent_operation().insert(CustomWorldAgentOperation {
operation_id: input.operation_id.clone(),
session_id: input.session_id.clone(),
operation_type: input.operation_type,
status: input.operation_status,
phase_label: input.phase_label.clone(),
phase_detail: input.phase_detail.clone(),
progress: input.operation_progress,
error_message: input.error_message.clone(),
created_at: timestamp,
updated_at: timestamp,
})
};
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn finalize_custom_world_agent_message_turn_tx(
ctx: &ReducerContext,
input: CustomWorldAgentMessageFinalizeInput,
@@ -2896,14 +2968,22 @@ fn execute_custom_world_agent_action_tx(
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
if ctx
if let Some(existing_operation) = ctx
.db
.custom_world_agent_operation()
.operation_id()
.find(&input.operation_id)
.is_some()
{
return Err("custom_world_agent_operation.operation_id 已存在".to_string());
let can_reuse_running_draft_operation = input.action.trim() == "draft_foundation"
&& existing_operation.session_id == input.session_id
&& existing_operation.operation_type == RpgAgentOperationType::DraftFoundation
&& matches!(
existing_operation.status,
RpgAgentOperationStatus::Queued | RpgAgentOperationStatus::Running
);
if !can_reuse_running_draft_operation {
return Err("custom_world_agent_operation.operation_id 已存在".to_string());
}
}
let payload = parse_optional_session_object(input.payload_json.as_deref()).unwrap_or_default();
@@ -2990,7 +3070,7 @@ fn execute_draft_foundation_action(
updated_at,
);
let operation = build_and_insert_custom_world_operation(
let operation = complete_custom_world_operation(
ctx,
&input.operation_id,
&session.session_id,
@@ -2998,7 +3078,7 @@ fn execute_draft_foundation_action(
"底稿已整理",
"第一版 foundation draft 已写入会话与世界卡。",
updated_at,
);
)?;
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
@@ -4117,6 +4197,53 @@ fn replace_custom_world_draft_card(
ctx.db.custom_world_draft_card().insert(next);
}
fn complete_custom_world_operation(
ctx: &ReducerContext,
operation_id: &str,
session_id: &str,
operation_type: RpgAgentOperationType,
phase_label: &str,
phase_detail: &str,
timestamp_micros: i64,
) -> Result<CustomWorldAgentOperation, String> {
if let Some(current) = ctx
.db
.custom_world_agent_operation()
.operation_id()
.find(&operation_id.to_string())
{
if current.session_id != session_id {
return Err("custom_world_agent_operation.session_id 不匹配".to_string());
}
if current.operation_type != operation_type {
return Err("custom_world_agent_operation.operation_type 不匹配".to_string());
}
let next = rebuild_custom_world_agent_operation_row(
&current,
CustomWorldAgentOperationPatch {
status: Some(RpgAgentOperationStatus::Completed),
phase_label: Some(phase_label.to_string()),
phase_detail: Some(phase_detail.to_string()),
progress: Some(100),
error_message: Some(None),
updated_at_micros: Some(timestamp_micros),
},
)?;
replace_custom_world_agent_operation(ctx, &current, next.clone());
return Ok(next);
}
Ok(build_and_insert_custom_world_operation(
ctx,
operation_id,
session_id,
operation_type,
phase_label,
phase_detail,
timestamp_micros,
))
}
fn build_and_insert_custom_world_operation(
ctx: &ReducerContext,
operation_id: &str,