修正画布 Agent 图片编辑素材类型
新 Agent 图片编辑任务以空 assetKind 表示普通静态图片。 仅为可信历史 Agent 队列任务归一旧 synthetic 素材类型。 兼容已持久化旧 Agent 图片资源并保持普通请求失败关闭。 补充 Agent payload 到 worker 门禁及历史兼容回归测试。
This commit is contained in:
@@ -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" },
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user