From 0d148d3e7513481af3b49e57d8ed5c5388a659b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 16:00:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=94=BB=E5=B8=83=20Agent=20?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E7=BC=96=E8=BE=91=E7=B4=A0=E6=9D=90=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新 Agent 图片编辑任务以空 assetKind 表示普通静态图片。 仅为可信历史 Agent 队列任务归一旧 synthetic 素材类型。 兼容已持久化旧 Agent 图片资源并保持普通请求失败关闭。 补充 Agent payload 到 worker 门禁及历史兼容回归测试。 --- .../shared-memory/decision-log.md | 1 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../api-server/src/editor_agent/tool.rs | 34 +++++- .../crates/api-server/src/editor_project.rs | 35 +++++- .../src/external_generation_worker.rs | 106 +++++++++++++++--- 5 files changed, 158 insertions(+), 20 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9dc3b4dec..16f1b4b25 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6499,6 +6499,7 @@ - 背景:画布生成结果统一显示快速编辑,但部分角色动作 / 序列帧和音频结果会进入不受支持的图片编辑链路;不同入口各自判断时也容易继续漂移。 - 决策:快速编辑只支持普通静态图片、角色图、规范图、完整图标图集、UI 设计图、宣发图和视频。单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持;新增媒体或素材类型默认不开放。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁统一调用同一个正向白名单;后端图片编辑 BFF 基于目标图层的有效素材类型与媒体类型执行同一正向门禁,视频快速编辑只走视频生成接口。 - 边界:角色动作继续通过对应的动作生成链路处理,不再把当前帧当作可快速编辑图片。 +- 2026-08-06 修订:画布 Agent 的 `edit_image` 只接受图片输入,新任务以 `assetKind=null` 表示普通静态图片,不再使用 synthetic `editor_agent_edit_image`。worker 仅按服务端生成的 `editor-agent:` dedupe namespace 识别并归一历史排队 payload;普通调用伪造旧值继续被拒绝。已持久化资源中的旧值只有在后端从真实目标图层 / 项目资源解析后才兼容为空类型,避免历史 Agent 结果失去快速编辑能力,同时不扩大请求白名单。 - 验证:模型测试覆盖允许与拒绝类型,工具栏和两类右键菜单测试覆盖单个拆分图标、角色动作及音频不展示,提交工作流测试覆盖单个拆分图标和角色动作绕过入口时仍拒绝;后端表驱动测试覆盖全部现役素材 / 媒体类型与未知类型,锁定图片编辑端点失败关闭。 - 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts`、`ImageCanvasSelectedLayerToolbarView.tsx`、`ImageCanvasContextMenusView.tsx`、`useImageCanvasGenerationWorkflow.ts`、`useImageCanvasGenerationSubmissionWorkflow.ts`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index d1c800d43..047c65736 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -19,7 +19,7 @@ - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 -- 快速编辑使用统一正向白名单:只支持普通静态图片、角色图、规范图、完整图标图集、UI 设计图、宣发图和视频;单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁必须复用同一白名单,新增媒体或素材类型默认不开放。`/api/editor/images/edits` 的 queue 与 inline 路径还必须基于目标图层的有效 `assetKind + mediaType` 执行后端正向门禁;视频快速编辑只能走视频生成接口,不得进入图片编辑 BFF;角色动作只能通过对应的动作生成链路继续编辑。 +- 快速编辑使用统一正向白名单:只支持普通静态图片、角色图、规范图、完整图标图集、UI 设计图、宣发图和视频;单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁必须复用同一白名单,新增媒体或素材类型默认不开放。`/api/editor/images/edits` 的 queue 与 inline 路径还必须基于目标图层的有效 `assetKind + mediaType` 执行后端正向门禁;视频快速编辑只能走视频生成接口,不得进入图片编辑 BFF;角色动作只能通过对应的动作生成链路继续编辑。画布 Agent 的 `edit_image` 只接收图片输入,新任务按普通静态图片提交空 `assetKind`,不得再生成 `assetKind="editor_agent_edit_image"`;worker 仅对 `dedupeKey` 属于可信 `editor-agent:` 命名空间的历史排队任务把该旧 synthetic 值归一为空,普通请求直接提交旧值仍失败关闭。已持久化项目资源中的该旧值在从真实目标图层解析后按普通静态图片兼容,不把该兼容扩大到客户端请求字段。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其重绘 / 改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材不提供 `快速编辑`,只能通过角色动作生成链路继续编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,并在同一轮账号项目 / 素材快照读取中同时恢复用户可见源模型,禁止为 object key、所有权和源模型分别重复拉取全量快照;随后只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index a14b96efd..738016c46 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -676,7 +676,7 @@ impl EditorAgentTool for EditImageTool { image_size: None, reference_image_srcs: Some(reference_image_srcs), project_id: Some(context.conversation.project_id.clone()), - asset_kind: Some("editor_agent_edit_image".to_string()), + asset_kind: None, generation_inputs: context.generation_inputs("用户指令", &title), asset_folder_id: Some("project".to_string()), asset_label: Some(title.clone()), @@ -1527,6 +1527,38 @@ mod tests { ); } + #[test] + fn edit_image_job_uses_empty_asset_kind_for_ordinary_static_image() { + let pricing = pricing(); + let conversation = conversation(); + let project = project(); + let tool = editor_agent_tool(EditImageTool::NAME, &context_with_image("image-1")) + .expect("edit tool should resolve"); + let args = tool + .validate_args(&json!({ + "object_image_id": "image-1", + "prompt": "改成蓝色" + })) + .expect("edit args should validate"); + + let job = tool + .prepare_job( + &args, + &EditorAgentPrepareJobContext { + conversation: &conversation, + project: &project, + message_id: 7, + pricing: &pricing, + }, + ) + .expect("edit job should prepare"); + + assert_eq!(job.job_kind, EDITOR_IMAGE_EDIT_JOB_KIND); + assert!(job.payload["assetKind"].is_null()); + assert_eq!(job.payload["projectId"], "project-1"); + assert!(job.payload["targetLayerId"].is_null()); + } + #[test] fn dyn_job_preparation_keeps_queue_contract_and_defaults() { let pricing = pricing(); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index c07465af7..1f0517817 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -145,6 +145,7 @@ const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character"; const EDITOR_GENERATED_IMAGE_ASSET_KIND: &str = "editor_generated_image"; const EDITOR_SPEC_IMAGE_ASSET_KIND: &str = "editor_spec_image"; const EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND: &str = "editor_quick_edit_image"; +pub(crate) const EDITOR_AGENT_LEGACY_IMAGE_EDIT_ASSET_KIND: &str = "editor_agent_edit_image"; const EDITOR_UI_DESIGN_IMAGE_ASSET_KIND: &str = "editor_ui_design_image"; const EDITOR_IMAGE_EDIT_ASSET_KIND: &str = "editor_image_edit"; const EDITOR_ICON_SPRITESHEET_ASSET_KIND: &str = "editor_icon_spritesheet"; @@ -2838,7 +2839,7 @@ fn align_editor_image_edit_dimension(value: u32) -> u32 { value.saturating_add(15) / 16 * 16 } -fn ensure_editor_image_edit_source_kind_allowed( +pub(crate) fn ensure_editor_image_edit_source_kind_allowed( asset_kind: Option<&str>, media_type: Option<&str>, ) -> Result<(), AppError> { @@ -2870,6 +2871,17 @@ fn ensure_editor_image_edit_source_kind_allowed( ) } +fn normalize_editor_image_edit_resolved_source_kind<'a>( + resolved_asset_kind: Option<&'a str>, + request_asset_kind: Option<&'a str>, +) -> Option<&'a str> { + match resolved_asset_kind { + Some(EDITOR_AGENT_LEGACY_IMAGE_EDIT_ASSET_KIND) => None, + Some(asset_kind) => Some(asset_kind), + None => request_asset_kind, + } +} + fn resolve_editor_image_edit_target_layer_source_kind<'a>( layers: &'a Value, resources: &'a [EditorProjectResourcePayload], @@ -2963,7 +2975,7 @@ async fn ensure_editor_image_edit_source_allowed( target_layer_id, )?; ensure_editor_image_edit_source_kind_allowed( - asset_kind.or(payload.asset_kind.as_deref()), + normalize_editor_image_edit_resolved_source_kind(asset_kind, payload.asset_kind.as_deref()), media_type, ) } @@ -16590,6 +16602,25 @@ mod tests { ); } + #[test] + fn legacy_editor_agent_resource_kind_is_only_normalized_after_source_resolution() { + assert!( + ensure_editor_image_edit_source_kind_allowed( + Some(EDITOR_AGENT_LEGACY_IMAGE_EDIT_ASSET_KIND), + Some("image"), + ) + .is_err(), + "客户端直接提交历史 synthetic assetKind 应继续失败关闭" + ); + + let resolved_kind = normalize_editor_image_edit_resolved_source_kind( + Some(EDITOR_AGENT_LEGACY_IMAGE_EDIT_ASSET_KIND), + Some(EDITOR_AGENT_LEGACY_IMAGE_EDIT_ASSET_KIND), + ); + assert_eq!(resolved_kind, None); + assert!(ensure_editor_image_edit_source_kind_allowed(resolved_kind, Some("image")).is_ok()); + } + #[test] fn editor_image_edit_guards_before_queue_and_owner_execution() { 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 73097118e..e2534a594 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -40,8 +40,8 @@ use crate::{ EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND, }, editor_project::{ - EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, EditorBackgroundRemovalRequest, - EditorGenerationCaller, EditorGenerationPhaseReporter, + EDITOR_AGENT_LEGACY_IMAGE_EDIT_ASSET_KIND, EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, + EditorBackgroundRemovalRequest, EditorGenerationCaller, EditorGenerationPhaseReporter, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest, edit_editor_image_for_owner, extract_editor_ui_design_assets_for_owner, @@ -794,12 +794,9 @@ async fn process_external_generation_job_once( } } EDITOR_IMAGE_EDIT_JOB_KIND => { - let payload = match serde_json::from_str::( - job.request_payload_json.as_str(), - ) { + let payload = match parse_editor_image_edit_worker_payload(&job) { Ok(payload) => payload, - Err(error) => { - let message = format!("图片画布改图任务参数解析失败:{error}"); + Err(message) => { fail_job(&state, &worker_id, &job, message.clone()).await?; return Err(message); } @@ -1141,6 +1138,21 @@ fn worker_request_context( .with_external_call_deadline(provider_deadline) } +fn parse_editor_image_edit_worker_payload( + job: &ExternalGenerationJobRecord, +) -> Result { + let mut payload: EditorImageEditRequest = + serde_json::from_str(job.request_payload_json.as_str()) + .map_err(|error| format!("图片画布改图任务参数解析失败:{error}"))?; + if is_editor_agent_generation_job(job) + && payload.asset_kind.as_deref().map(str::trim) + == Some(EDITOR_AGENT_LEGACY_IMAGE_EDIT_ASSET_KIND) + { + payload.asset_kind = None; + } + Ok(payload) +} + fn editor_generation_worker_caller( worker_id: &str, job: &ExternalGenerationJobRecord, @@ -1236,15 +1248,7 @@ fn is_external_api_generation_job(job: &ExternalGenerationJobRecord) -> bool { } fn is_editor_agent_generation_job(job: &ExternalGenerationJobRecord) -> bool { - serde_json::from_str::(job.request_payload_json.as_str()) - .ok() - .is_some_and(|payload| { - payload - .pointer("/generationInputs/source") - .and_then(Value::as_str) - // TODO extract const - .is_some_and(|source| source.trim() == "editor-agent") - }) + job.dedupe_key.trim().starts_with("editor-agent:") } fn compact_editor_generation_result(mut result: Value) -> Value { @@ -1750,6 +1754,74 @@ mod tests { assert!(caller.phase_reporter.is_some()); } + #[test] + fn editor_agent_image_edit_payload_reaches_worker_gate_with_empty_asset_kind() { + let mut job = external_generation_job_record_fixture(Some("lease-1")); + job.job_kind = EDITOR_IMAGE_EDIT_JOB_KIND.to_string(); + job.dedupe_key = "editor-agent:conversation-1:7:edit-image".to_string(); + job.request_payload_json = json!({ + "prompt": "改成蓝色", + "sourceImageSrc": "users/user-1/source.png", + "projectId": "project-1", + "assetKind": null, + "generationInputs": { "source": "editor-agent" } + }) + .to_string(); + + let payload = parse_editor_image_edit_worker_payload(&job) + .expect("新 Agent 图片编辑 payload 应由 worker 解析"); + assert!(payload.asset_kind.is_none()); + assert!( + crate::editor_project::ensure_editor_image_edit_source_kind_allowed( + payload.asset_kind.as_deref(), + None, + ) + .is_ok() + ); + } + + #[test] + fn legacy_editor_agent_image_edit_kind_is_normalized_only_for_trusted_job() { + let mut job = external_generation_job_record_fixture(Some("lease-1")); + job.job_kind = EDITOR_IMAGE_EDIT_JOB_KIND.to_string(); + job.dedupe_key = "editor-agent:conversation-1:7:edit-image".to_string(); + job.request_payload_json = json!({ + "prompt": "改成蓝色", + "sourceImageSrc": "users/user-1/source.png", + "projectId": "project-1", + "assetKind": "editor_agent_edit_image", + "generationInputs": { "source": "editor-agent" } + }) + .to_string(); + + let legacy_agent_payload = parse_editor_image_edit_worker_payload(&job) + .expect("历史 Agent 图片编辑 payload 应由 worker 解析"); + assert!(legacy_agent_payload.asset_kind.is_none()); + assert!( + crate::editor_project::ensure_editor_image_edit_source_kind_allowed( + legacy_agent_payload.asset_kind.as_deref(), + None, + ) + .is_ok() + ); + + job.dedupe_key = "editor:image-edit:project-1:request-1".to_string(); + let untrusted_payload = parse_editor_image_edit_worker_payload(&job) + .expect("普通画布图片编辑 payload 应由 worker 解析"); + assert_eq!( + untrusted_payload.asset_kind.as_deref(), + Some(EDITOR_AGENT_LEGACY_IMAGE_EDIT_ASSET_KIND) + ); + assert!( + crate::editor_project::ensure_editor_image_edit_source_kind_allowed( + untrusted_payload.asset_kind.as_deref(), + None, + ) + .is_err(), + "普通请求不得用历史 Agent synthetic assetKind 绕过白名单" + ); + } + #[cfg(any())] #[test] fn worker_write_guard_requires_claimed_job_lease_token() { @@ -1971,6 +2043,7 @@ mod tests { #[test] fn editor_agent_result_payload_keeps_compact_response() { let mut job = external_generation_job_record_fixture(Some("lease-1")); + job.dedupe_key = "editor-agent:conversation-1:7:generate-image".to_string(); job.request_payload_json = json!({ "generationInputs": { "source": "editor-agent" }, }) @@ -2072,6 +2145,7 @@ mod tests { #[test] fn editor_agent_spritesheet_result_keeps_all_persisted_slices() { let mut job = external_generation_job_record_fixture(Some("lease-1")); + job.dedupe_key = "editor-agent:conversation-1:7:generate-icon-spritesheet".to_string(); job.request_payload_json = json!({ "generationInputs": { "source": "editor-agent" }, })