From 1664bc6bc8b133fbe8ce93a0c91abc570f31dade Mon Sep 17 00:00:00 2001 From: Linghong Date: Sat, 8 Aug 2026 07:40:27 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83=20Agent=20?= =?UTF-8?q?=E9=9F=B3=E9=A2=91=E5=9B=9E=E5=A1=AB=E4=B8=A2=E5=A4=B1=20provid?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原子提交与 worker 直接完成两条路径共用同一份结果紧凑化实现,删除重复副本。 紧凑化对 Agent 消费者保留音频必填 provider,历史上该豁免只加在 worker 副本里,原子化改造新写的副本没带上。 新增 Agent 音频紧凑结果仍可被 EditorAudioGenerateResponse 反序列化的契约测试,覆盖音效与背景音乐。 --- .../crates/api-server/src/editor_project.rs | 68 ++++++++++++++++- .../src/external_generation_worker.rs | 75 +------------------ 2 files changed, 69 insertions(+), 74 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index be3e9708b..68d9f7319 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -31,6 +31,8 @@ use platform_oss::{ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest as _, Sha256}; +#[cfg(test)] +use shared_contracts::assets::EditorAudioGenerateResponse; use shared_contracts::assets::{ EDITOR_ASSET_LABEL_MAX_CHARS, EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest, @@ -1127,11 +1129,22 @@ fn serialize_atomic_editor_generation_job_result( Ok(payload_json) } -fn compact_editor_generation_result(mut result: Value) -> Value { +/// 队列结果落库前的通用紧凑化。原子提交与 worker 直接 complete 两条路径共用同一份实现, +/// 避免其中一条单独演进后丢掉音频豁免(历史上就出现过:豁免只加在 worker 副本里, +/// 原子化改造新写的副本没带上,导致 Agent 音效成功却回填失败)。 +pub(crate) fn compact_editor_generation_result(mut result: Value) -> Value { let Some(object) = result.as_object_mut() else { return result; }; - object.remove("provider"); + // 音频 Agent 使用完整音频 DTO 回填,必须保留其必填 provider;其它生成结果继续隐藏 + // 供应商和内部后处理实现。正常的用户可见模型仍然保留,以便卡片恢复原有展示。 + let is_audio_result = object + .get("audioKind") + .and_then(Value::as_str) + .is_some_and(|kind| matches!(kind, "sound-effect" | "background-music")); + if !is_audio_result { + object.remove("provider"); + } if object .get("model") .and_then(Value::as_str) @@ -19695,6 +19708,57 @@ mod tests { } } + /// 画布 Agent 音频回填读的是完整 `EditorAudioGenerateResponse`,其 `provider` 是必填。 + /// 紧凑化对 External API 消费者隐藏 provider(见上一条),但对 Agent 消费者必须保留, + /// 否则 ElevenLabs、扣费、OSS 与画布写回全部成功之后,卡片仍会在四次回填尝试后显示失败。 + #[test] + fn atomic_agent_audio_result_keeps_provider_for_tool_call_backfill() { + for audio_kind in ["sound-effect", "background-music"] { + let mut job = atomic_editor_generation_job_fixture(); + job.request_payload_json = json!({ + "generationInputs": { "source": "editor-agent" }, + }) + .to_string(); + let context = EditorGenerationQueueResultContext::from_job(&job); + let payload: Value = serde_json::from_str( + serialize_atomic_editor_generation_job_result( + &context, + &json!({ + "ok": true, + "audioSrc": "/generated/editor/audio.mp3", + "objectKey": "generated/editor/audio.mp3", + "assetObjectId": "asset-object-audio", + "width": 420, + "height": 120, + "sourceType": "generated", + "prompt": "按钮点击声", + "actualPrompt": "button click", + "model": "eleven_text_to_sound_v2", + "provider": "elevenlabs", + "taskId": "job-audio-1", + "priceMudPoints": 5, + "audioKind": audio_kind, + "durationSeconds": 3.5, + "loop": true, + }), + ) + .expect("agent audio compact payload") + .as_str(), + ) + .expect("agent audio compact payload should be JSON"); + + let result = &payload["editor-agent-tool-call-result"]; + assert_eq!(result["provider"], "elevenlabs", "{audio_kind}"); + // 真正的锁:生产端紧凑化后的 payload 必须仍能被消费端的 DTO 反序列化。 + let backfilled: EditorAudioGenerateResponse = serde_json::from_value(result.clone()) + .unwrap_or_else(|error| { + panic!("{audio_kind} agent payload must stay backfillable: {error}") + }); + assert_eq!(backfilled.provider, "elevenlabs"); + assert_eq!(backfilled.audio_kind, audio_kind); + } + } + #[test] fn atomic_icon_ui_results_keep_required_consumer_fields() { let source = include_str!("editor_project.rs"); diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index ab80acff0..e0d7cf6fc 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -44,9 +44,9 @@ use crate::{ EditorGenerationCaller, EditorGenerationOperationContext, EditorGenerationPhaseReporter, EditorGenerationQueueResultContext, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest, - edit_editor_image_for_owner, extract_editor_ui_design_assets_for_owner, - generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner, - remove_editor_image_background_for_owner, + compact_editor_generation_result, edit_editor_image_for_owner, + extract_editor_ui_design_assets_for_owner, generate_editor_icon_spritesheet_for_owner, + generate_editor_image_for_owner, remove_editor_image_background_for_owner, }, request_context::RequestContext, state::AppState, @@ -1222,75 +1222,6 @@ fn is_editor_agent_generation_job(job: &ExternalGenerationJobRecord) -> bool { }) } -fn compact_editor_generation_result(mut result: Value) -> Value { - let Some(object) = result.as_object_mut() else { - return result; - }; - // 音频 Agent 使用完整音频 DTO 回填,必须保留其必填 provider;其它生成结果继续隐藏 - // 供应商和内部后处理实现。正常的用户可见模型仍然保留,以便卡片恢复原有展示。 - let is_audio_result = object - .get("audioKind") - .and_then(Value::as_str) - .is_some_and(|kind| matches!(kind, "sound-effect" | "background-music")); - if !is_audio_result { - object.remove("provider"); - } - if object - .get("model") - .and_then(Value::as_str) - .is_some_and(is_editor_internal_processing_model) - { - object.remove("model"); - } - if let Some(generation_inputs) = object - .get_mut("generationInputs") - .and_then(Value::as_object_mut) - { - for field in ["screenColorHex", "mattingProvider", "mattingModel"] { - generation_inputs.remove(field); - } - } - object.remove("project"); - object.remove("asset"); - object.remove("spritesheetAsset"); - for field in ["resource", "spritesheetResource"] { - let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) else { - continue; - }; - resource.retain(|key, _| { - matches!( - key.as_str(), - "resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId" - ) - }); - } - if let Some(icon_image_srcs) = object - .get_mut("iconImageSrcs") - .and_then(Value::as_array_mut) - { - for icon in icon_image_srcs { - let Some(icon) = icon.as_object_mut() else { - continue; - }; - if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) { - resource.retain(|key, _| { - matches!( - key.as_str(), - "resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId" - ) - }); - } - icon.retain(|key, _| { - matches!( - key.as_str(), - "name" | "imageSrc" | "width" | "height" | "resource" - ) - }); - } - } - result -} - fn compact_external_api_generation_result(result: Value) -> Value { let mut result = result.get("data").cloned().unwrap_or(result); let Some(object) = result.as_object_mut() else {