From 77f2c9d52c837ff5dc5aff8aff148c1527bb9118 Mon Sep 17 00:00:00 2001 From: VicoHu Date: Thu, 17 Sep 2026 17:43:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(ark-assets):=20=E6=96=B9=E8=88=9F=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E5=BA=93=20-=20=E9=98=BB=E6=AD=A2=E5=9B=BE=E7=89=87?= =?UTF-8?q?=20Agent=20Plan=20=E8=AF=AF=E7=94=A8=20asset://=20=E5=8F=AF?= =?UTF-8?q?=E4=BF=A1=E7=B4=A0=E6=9D=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent Plan Seedream 与 Seedance 共用 /api/plan/v3,BaseURL 判断会把图片生成误判为视频可信素材路径。仅视频 mode/协议才同步或提交 asset://。 Co-authored-by: Cursor --- .../app/provider_ark_private_assets.go | 10 ++- backend/internal/app/provider_test.go | 42 +++++++++ .../internal/app/provider_video_options.go | 16 +++- docs/content/docs/progress/pending-test.mdx | 8 ++ web/src/lib/seedance-video.ts | 7 +- web/src/services/api/generation-task.ts | 13 ++- web/test/generation-task-ark-asset.test.ts | 86 +++++++++++++++++++ 7 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 web/test/generation-task-ark-asset.test.ts diff --git a/backend/internal/app/provider_ark_private_assets.go b/backend/internal/app/provider_ark_private_assets.go index e2e8fab99..461bfbb47 100644 --- a/backend/internal/app/provider_ark_private_assets.go +++ b/backend/internal/app/provider_ark_private_assets.go @@ -47,7 +47,8 @@ func withoutProviderAnalytics(ctx context.Context) context.Context { } func (s *Service) prepareArkPrivateAssetReferences(ctx context.Context, userID string, input *canvasGenerationInput) error { - if input == nil || !isArkPrivateAssetVideoConfig(input.Config) || !parseBool(input.Config.ArkPrivateAssetUpload, true) { + // 可信素材 asset:// 仅方舟视频协议支持;Agent Plan Seedream 等图片渠道不能上传或改写。 + if input == nil || input.Mode != "video" || !isArkPrivateAssetVideoConfig(input.Config) || !parseBool(input.Config.ArkPrivateAssetUpload, true) { return nil } hasOwnedReference := false @@ -107,7 +108,12 @@ func (s *Service) prepareArkPrivateAssetReferences(ctx context.Context, userID s } func isArkPrivateAssetVideoConfig(config providerConfig) bool { - return config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) || isArkPlanVideoConfig(config) + iface := strings.TrimSpace(config.InterfaceType) + // Agent Plan 图片与视频共用 /api/plan/v3;图片协议不得进入可信素材同步。 + if iface == string(model.ChannelInterfaceVolcengineArkImage) || iface == "volcengine-ark-agent-plan-image" { + return false + } + return iface == string(model.ChannelInterfaceVolcengineArkVideo) || iface == "volcengine-ark-agent-plan-video" || isArkPlanVideoConfig(config) } func arkPrivateAssetAutomaticSyncEnabled(setting arkPrivateAssetSettingValue) (bool, error) { diff --git a/backend/internal/app/provider_test.go b/backend/internal/app/provider_test.go index fa11b17cc..c7a72b583 100644 --- a/backend/internal/app/provider_test.go +++ b/backend/internal/app/provider_test.go @@ -2213,6 +2213,48 @@ func TestArkPlanConfigStaysSeparateFromSeedanceVideosEndpoint(t *testing.T) { } } +func TestArkPlanImageConfigDoesNotUseVideoAssetPath(t *testing.T) { + imageConfig := providerConfig{ + InterfaceType: "volcengine-ark-agent-plan-image", + BaseURL: "https://ark.cn-beijing.volces.com/api/plan/v3", + Model: "doubao-seedream-4-0-250828", + } + if isArkPlanVideoConfig(imageConfig) { + t.Fatal("agent plan image must not match isArkPlanVideoConfig") + } + if isArkPrivateAssetVideoConfig(imageConfig) { + t.Fatal("agent plan image must not trigger ark private asset sync") + } + if isSeedanceVideoConfig(imageConfig) { + t.Fatal("agent plan image must not match isSeedanceVideoConfig") + } + + videoConfig := providerConfig{ + InterfaceType: "volcengine-ark-agent-plan-video", + BaseURL: "https://ark.cn-beijing.volces.com/api/plan/v3", + Model: "doubao-seedance-1-5-pro-251215", + } + if !isArkPlanVideoConfig(videoConfig) || !isArkPrivateAssetVideoConfig(videoConfig) { + t.Fatal("agent plan video should keep video/asset path") + } +} + +func TestPrepareArkPrivateAssetReferencesSkipsImageMode(t *testing.T) { + svc := &Service{} + err := svc.prepareArkPrivateAssetReferences(context.Background(), "user-1", &canvasGenerationInput{ + Mode: "image", + Config: providerConfig{ + InterfaceType: "volcengine-ark-agent-plan-image", + BaseURL: "https://ark.cn-beijing.volces.com/api/plan/v3", + ArkPrivateAssetUpload: "true", + }, + ReferenceImages: []providerMedia{{StorageKey: "resource:res-1", URL: "https://example.com/ref.png"}}, + }) + if err != nil { + t.Fatalf("prepareArkPrivateAssetReferences() error = %v", err) + } +} + func TestVolcengineArkVideoProtocolUsesContentTaskAndDownloadsResult(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") paths := make([]string, 0, 3) diff --git a/backend/internal/app/provider_video_options.go b/backend/internal/app/provider_video_options.go index f1cf56720..d070484a5 100644 --- a/backend/internal/app/provider_video_options.go +++ b/backend/internal/app/provider_video_options.go @@ -5,6 +5,8 @@ import ( "strconv" "strings" "time" + + "infinite-canvas/backend/internal/model" ) func isPublicMediaURL(value string) bool { @@ -13,8 +15,8 @@ func isPublicMediaURL(value string) bool { } func isSeedanceVideoConfig(config providerConfig) bool { - model := strings.ToLower(config.Model) - return strings.Contains(model, "seedance") || strings.Contains(model, "doubao-seedance") || isArkPlanVideoConfig(config) + modelName := strings.ToLower(config.Model) + return strings.Contains(modelName, "seedance") || strings.Contains(modelName, "doubao-seedance") || isArkPlanVideoConfig(config) } func isGrokVideoConfig(config providerConfig) bool { @@ -22,7 +24,15 @@ func isGrokVideoConfig(config providerConfig) bool { } func isArkPlanVideoConfig(config providerConfig) bool { - return strings.Contains(strings.ToLower(config.BaseURL), "/api/plan/v3") + if !strings.Contains(strings.ToLower(config.BaseURL), "/api/plan/v3") { + return false + } + // Agent Plan 图片与视频共用 /api/plan/v3;按协议排除图片,避免 Seedream 误走视频/可信素材路径。 + iface := strings.TrimSpace(config.InterfaceType) + if iface == string(model.ChannelInterfaceVolcengineArkImage) || iface == "volcengine-ark-agent-plan-image" { + return false + } + return true } func normalizeImageQuality(value string) string { diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 1a61b474c..11f78111b 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,14 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## Agent Plan Seedream 误走可信素材(2026-09-17) + +根因:`isArkPlanVideoConfig` / 前端 `usesArkVideoAssetReference` 仅按 Base URL 含 `/api/plan/v3` 判断,Agent Plan Seedream 与 Seedance 共用该路径,图片生成被误判为视频可信素材路径,自动 CreateAsset 并把参考图改成 `asset://`,上游图片接口拒绝。修复:后端仅 `mode=video` 且非图片协议时同步可信素材;前端仅视频 mode + 视频协议(或非图片协议的 Plan BaseURL)才提交 `asset://`。后端单测已覆盖。需浏览器验收: + +- [ ] Agent Plan Seedream / 任意 Base URL 为 `/api/plan/v3` 的图片渠道带参考图生成:任务进度无「同步方舟可信素材」,请求 `image` 为公网 URL 或 data URL,不是 `asset://`。 +- [ ] 同一张已录入方舟素材 ID 的参考图用于 Agent Plan / 方舟 Seedance 视频生成时,仍可走 `asset://` 并跳过重复上传。 +- [ ] 官方 `volcengine-ark-image` / `volcengine-ark-video` 行为不变。 + ## 画布节点快速引用 Agent - 多选节点后,选区浮动工具栏应显示 `@` 图标;点击「发送到 Agent」后,Agent 输入框应自动带入所有选中节点引用。 diff --git a/web/src/lib/seedance-video.ts b/web/src/lib/seedance-video.ts index 54afcf826..928f82fb2 100644 --- a/web/src/lib/seedance-video.ts +++ b/web/src/lib/seedance-video.ts @@ -59,7 +59,12 @@ const seedancePixels = { export function isSeedanceVideoConfig(config: AiConfig | Pick) { const requestConfig = "channels" in config ? resolveModelRequestConfig(config, config.model || config.videoModel) : config; - return isSeedanceVideoModel(modelOptionName(requestConfig.model || requestConfig.videoModel)) || isArkPlanBaseUrl(requestConfig.baseUrl); + // Agent Plan 图片与视频共用 /api/plan/v3;按协议排除图片,避免 Seedream 误判为 Seedance。 + if ("interfaceType" in requestConfig) { + const interfaceType = requestConfig.interfaceType; + if (interfaceType === "volcengine-ark-image" || interfaceType === "volcengine-ark-agent-plan-image") return false; + } + return isSeedanceVideoModel(modelOptionName(requestConfig.model || requestConfig.videoModel)) || isArkPlanBaseUrl(requestConfig.baseUrl || ""); } export function isSeedanceVideoModel(model: string) { diff --git a/web/src/services/api/generation-task.ts b/web/src/services/api/generation-task.ts index 4f563a877..7a4c93cf9 100644 --- a/web/src/services/api/generation-task.ts +++ b/web/src/services/api/generation-task.ts @@ -95,7 +95,7 @@ export async function runBackendGenerationTask( throwIfAborted(signal); assertClientPromptLimit(mode, prompt, config, metadata); assertBackendRuntimeConfigured(config, mode); - const prepared = await prepareGenerationReferences({ config, referenceImages, referenceVideos, referenceAudios, mask }); + const prepared = await prepareGenerationReferences({ config, mode, referenceImages, referenceVideos, referenceAudios, mask }); throwIfAborted(signal); return createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, textHistory, signal, metadata, onTaskUpdate, onTextDelta, streamText, enableThinking, clientOperationId, retryOf, attemptGroupId }, prepared, dependencies); } @@ -233,12 +233,14 @@ function assertClientPromptLimit(mode: BackendGenerationMode, prompt: string, co async function prepareGenerationReferences({ config, + mode, referenceImages = [], referenceVideos = [], referenceAudios = [], mask, -}: Pick): Promise { - const preferArkAssetUrl = usesArkVideoAssetReference(config); +}: Pick): Promise { + // asset:// 仅视频生成可用;Agent Plan Seedream 与 Seedance 共用 /api/plan/v3,不能按 BaseURL 误判。 + const preferArkAssetUrl = mode === "video" && usesArkVideoAssetReference(config); const preparedImages = await Promise.all(referenceImages.map((image) => prepareBackendImageReference(image, preferArkAssetUrl))); const preparedVideos = await Promise.all(referenceVideos.map(prepareBackendMediaReference)); const preparedAudios = await Promise.all(referenceAudios.map(prepareBackendMediaReference)); @@ -249,7 +251,10 @@ async function prepareGenerationReferences({ // 与后端 isArkPrivateAssetVideoConfig 对齐:方舟视频渠道允许参考图直接 asset:// 引用, // 跳过可信素材上传同步(适合已录入方舟素材 ID 的素材,例如被授权的真人像素材)。 function usesArkVideoAssetReference(config: AiConfig) { - return resolveModelRequestConfig(config, config.model).interfaceType === "volcengine-ark-video" || isArkPlanBaseUrl(config.baseUrl || ""); + const interfaceType = resolveModelRequestConfig(config, config.model).interfaceType; + if (interfaceType === "volcengine-ark-video" || interfaceType === "volcengine-ark-agent-plan-video") return true; + if (interfaceType === "volcengine-ark-image" || interfaceType === "volcengine-ark-agent-plan-image") return false; + return isArkPlanBaseUrl(config.baseUrl || ""); } async function createAndWaitGenerationTask(options: BackendGenerationTaskOptions, prepared: PreparedGenerationReferences, dependencies: GenerationTaskDependencies) { diff --git a/web/test/generation-task-ark-asset.test.ts b/web/test/generation-task-ark-asset.test.ts new file mode 100644 index 000000000..1adb96f04 --- /dev/null +++ b/web/test/generation-task-ark-asset.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; + +import { prepareBackendGenerationTask } from "../src/services/api/generation-task"; +import { createModelChannel, defaultConfig, encodeChannelModel } from "../src/stores/use-config-store"; + +function agentPlanImageConfig() { + const channel = createModelChannel({ + id: "ark-plan-image", + name: "Ark Plan Seedream", + baseUrl: "https://ark.cn-beijing.volces.com/api/plan/v3", + apiKey: "test-key", + interfaceType: "volcengine-ark-agent-plan-image", + models: ["doubao-seedream-4-0-250828"], + modelCosts: [{ + model: "doubao-seedream-4-0-250828", + capability: "image", + protocol: "volcengine-ark-agent-plan-image", + billingMode: "fixed_request", + unitPriceMicrocredits: 1, + }], + }); + const model = encodeChannelModel(channel.id, "doubao-seedream-4-0-250828"); + return { ...defaultConfig, channels: [channel], model, baseUrl: channel.baseUrl, interfaceType: channel.interfaceType }; +} + +function agentPlanVideoConfig() { + const channel = createModelChannel({ + id: "ark-plan-video", + name: "Ark Plan Seedance", + baseUrl: "https://ark.cn-beijing.volces.com/api/plan/v3", + apiKey: "test-key", + interfaceType: "volcengine-ark-agent-plan-video", + models: ["doubao-seedance-1-5-pro-251215"], + modelCosts: [{ + model: "doubao-seedance-1-5-pro-251215", + capability: "video", + protocol: "volcengine-ark-agent-plan-video", + billingMode: "token", + inputPricePerMillion: 1, + outputPricePerMillion: 1, + }], + }); + const model = encodeChannelModel(channel.id, "doubao-seedance-1-5-pro-251215"); + return { ...defaultConfig, channels: [channel], model, videoModel: model, baseUrl: channel.baseUrl, interfaceType: channel.interfaceType }; +} + +describe("generation-task ark asset references", () => { + test("agent plan Seedream image mode does not rewrite references to asset://", async () => { + const input = await prepareBackendGenerationTask({ + mode: "image", + prompt: "edit this", + config: agentPlanImageConfig(), + referenceImages: [{ + id: "ref-1", + name: "ref.png", + type: "image/png", + url: "https://cdn.example.com/ref.png", + storageKey: "resource:res-1", + arkAssetId: "asset-already-synced", + }], + }); + const images = (input.input as { referenceImages?: Array<{ url?: string; storageKey?: string }> }).referenceImages || []; + expect(images).toHaveLength(1); + expect(images[0]?.url).not.toStartWith("asset://"); + expect(images[0]?.storageKey).toBe("resource:res-1"); + }); + + test("agent plan Seedance video mode still prefers asset:// when arkAssetId exists", async () => { + const input = await prepareBackendGenerationTask({ + mode: "video", + prompt: "animate this", + config: agentPlanVideoConfig(), + referenceImages: [{ + id: "ref-1", + name: "ref.png", + type: "image/png", + url: "https://cdn.example.com/ref.png", + storageKey: "resource:res-1", + arkAssetId: "asset-already-synced", + }], + }); + const images = (input.input as { referenceImages?: Array<{ url?: string }> }).referenceImages || []; + expect(images).toHaveLength(1); + expect(images[0]?.url).toBe("asset://asset-already-synced"); + }); +});