fix(ark-assets): 方舟素材库 - 阻止图片 Agent Plan 误用 asset:// 可信素材
Agent Plan Seedream 与 Seedance 共用 /api/plan/v3,BaseURL 判断会把图片生成误判为视频可信素材路径。仅视频 mode/协议才同步或提交 asset://。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 输入框应自动带入所有选中节点引用。
|
||||
|
||||
@@ -59,7 +59,12 @@ const seedancePixels = {
|
||||
|
||||
export function isSeedanceVideoConfig(config: AiConfig | Pick<AiConfig, "model" | "videoModel" | "baseUrl">) {
|
||||
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) {
|
||||
|
||||
@@ -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<BackendGenerationTaskOptions, "config" | "referenceImages" | "referenceVideos" | "referenceAudios" | "mask">): Promise<PreparedGenerationReferences> {
|
||||
const preferArkAssetUrl = usesArkVideoAssetReference(config);
|
||||
}: Pick<BackendGenerationTaskOptions, "config" | "mode" | "referenceImages" | "referenceVideos" | "referenceAudios" | "mask">): Promise<PreparedGenerationReferences> {
|
||||
// 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) {
|
||||
|
||||
86
web/test/generation-task-ark-asset.test.ts
Normal file
86
web/test/generation-task-ark-asset.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user