修正画布 Agent 图片编辑素材类型

新 Agent 图片编辑任务以空 assetKind 表示普通静态图片。

仅为可信历史 Agent 队列任务归一旧 synthetic 素材类型。

兼容已持久化旧 Agent 图片资源并保持普通请求失败关闭。

补充 Agent payload 到 worker 门禁及历史兼容回归测试。
This commit is contained in:
2026-08-06 16:00:17 +08:00
parent e35429bb88
commit 0d148d3e75
5 changed files with 158 additions and 20 deletions
@@ -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`
File diff suppressed because one or more lines are too long
@@ -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();
@@ -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");
@@ -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::<EditorImageEditRequest>(
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<EditorImageEditRequest, String> {
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::<Value>(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" },
})