fix(agent): keep pinned image features from substituting missing models
This commit is contained in:
parent
4768758dd9
commit
9f468927e1
@ -430,6 +430,9 @@ async function executeForTarget(
|
||||
) {
|
||||
if (!prompt) throw new Error("请提供重绘提示词");
|
||||
const model = await resolveImageModel(command.args.modelId);
|
||||
if (command.source === "agent" && command.args.agentImageMode === "pinned" &&
|
||||
command.args.modelId !== model.model.id && command.args.modelId !== model.model.modelId)
|
||||
throw new Error("指定的图片模型不可用,未切换到其他模型");
|
||||
const result = await generateCanvasImage({
|
||||
prompt,
|
||||
model: model.model.id,
|
||||
|
||||
@ -1,100 +1,496 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||
import { applyAgentImagePreferences, agentProviderImageParameters } from "@/lib/ai/canvas-agent-runtime/image-preferences";
|
||||
import {
|
||||
executeBoxCutoutJob,
|
||||
} from "@/lib/ai/box-cutout/service";
|
||||
import { executeExtendJob } from "@/lib/ai/extend/service";
|
||||
import { executeLayerSplitJob } from "@/lib/ai/layer-split/service";
|
||||
import { executeResizeJob } from "@/lib/ai/resize/service";
|
||||
import {
|
||||
executeRmbgJob,
|
||||
fetchImageBufferFromUrl,
|
||||
} from "@/lib/ai/rmbg/service";
|
||||
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||
import type { CanvasCapabilityTarget } from "@/lib/canvas/capabilities/context";
|
||||
import { resolveCanvasRuntimeEndpoint } from "@/lib/canvas/capabilities/internal-endpoint";
|
||||
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
||||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||||
import {
|
||||
findStyleProImageModelByRef,
|
||||
getStyleProImageModelEntries,
|
||||
sortStyleProImageModelEntries,
|
||||
} from "@/lib/style-pro/model-options";
|
||||
|
||||
const preferences = {
|
||||
modelId: "user-image-model",
|
||||
parameters: { imageCount: 3, aspectRatio: "3:4", imageSize: "4K", negativePrompt: "不要文字",
|
||||
quality: "high" as const, size: "1536*2048", promptExtend: false, watermark: false, seed: 0 },
|
||||
export type CanvasCapabilityMediaResult = {
|
||||
assetId?: string;
|
||||
height?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
nodeType?: "image" | "model3d" | "world3d";
|
||||
prompt?: string;
|
||||
sourceNodeId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
width?: number;
|
||||
};
|
||||
function harness(results?: any[]) {
|
||||
const saved: any[] = [];
|
||||
const generated: any[] = [];
|
||||
const operations: any[] = [];
|
||||
const mock = serverLoader({
|
||||
"@/lib/canvas/capabilities/ai-sdk-tools": { createCanvasCapabilityTools: async () => ({ editTargetImage: {} }) },
|
||||
"@/lib/canvas/capabilities/registry": { CANVAS_CAPABILITY_REGISTRY: [{ id: "image.prompt_regenerate", title: "重绘", risk: "external", agent: { toolName: "editTargetImage" } }] },
|
||||
"@/lib/canvas/agent-mutations": { applyCanvasOperationPlan: async (value: any) => { operations.push(value); return { createdNodeIds: ["new-image"] }; } },
|
||||
"@/lib/canvas/generate-image": { generateCanvasImage: async (value: any) => { generated.push(value); return { modelRef: results ? "backup" : value.model, images: results || [{ originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" }] }; } },
|
||||
"@/lib/ai/runtime/generated-image-persistence": { saveGeneratedImages: async (...args: any[]) => { saved.push(args); } },
|
||||
"@/lib/ai/model-router": { routeToModel: async () => ({ id: "system-default" }) },
|
||||
"@/lib/db/queries": { checkUserImageQuota: async () => ({ allowed: true, remaining: 10 }), getPointsSystemConfig: async () => ({ enabled: false }), getUserById: async () => ({}) },
|
||||
"@/lib/style-pro/model-options": {
|
||||
getStyleProImageModelEntries: async () => ["system-default", "user-image-model"].map(id => ({ model: { id, modelId: id, isDefault: id === "system-default", imageGenConfig: {} } })),
|
||||
sortStyleProImageModelEntries: (entries: any) => entries,
|
||||
},
|
||||
"./canvas": { readOwnedCanvas: async () => ({ nodes: [{ id: "reference", type: "image" }] }), readCanvasImages: async () => [{ id: "reference", data: { src: "https://test.example/reference.png" } }] },
|
||||
"./review": {},
|
||||
"./image-access-tools": { registerImageCanvasAccessTools() {} },
|
||||
});
|
||||
const build = async (imagePreferences: any = preferences) => mock("lib/ai/canvas-agent-runtime/tools.ts").buildCanvasRuntimeTools({
|
||||
userId: "u", chatId: "c", projectId: "p", runId: "r", assistantMessageId: "a", selectedNodeIds: ["reference"],
|
||||
request: new Request("https://test.example/api/canvas/agent-chat"), model: {}, supportsVision: true,
|
||||
preferredModelId: "system-default", imagePreferences, emit() {},
|
||||
});
|
||||
return { build, generated, operations, saved };
|
||||
|
||||
type HeadlessExecutorParams = {
|
||||
command: CanvasCapabilityClientCommand;
|
||||
requestHeaders: Headers;
|
||||
requestUrl: string;
|
||||
targets: CanvasCapabilityTarget[];
|
||||
userId: string;
|
||||
};
|
||||
|
||||
function asFiniteNumber(value: unknown) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : undefined;
|
||||
}
|
||||
test("explicit controls override conflicting planner settings without losing false or zero values", () => {
|
||||
const resolved = applyAgentImagePreferences({ modelId: "planner-choice", imageCount: 1, imageSize: "1K", aspectRatio: "1:1", prompt: "产品图" }, preferences);
|
||||
expect(resolved).toMatchObject({ ...preferences.parameters, modelId: preferences.modelId, prompt: "产品图" });
|
||||
expect(agentProviderImageParameters(resolved)).toEqual({ quality: "high", size: "1536*2048", watermark: false, seed: 0 });
|
||||
});
|
||||
test("real generation tool honors the chosen image model and all parameters despite another default and planner choice", async () => {
|
||||
const h = harness(); const tools = await h.build();
|
||||
const output = await tools.executable.generateCanvasImages.execute({ prompt: "生成产品图", modelId: "planner-choice", imageCount: 1, imageSize: "1K", aspectRatio: "1:1" }, "call-1");
|
||||
expect(h.generated).toHaveLength(1);
|
||||
expect(h.generated[0]).toMatchObject({ prompt: "生成产品图", model: "user-image-model", imageCount: 3, imageSize: "4K", aspectRatio: "3:4", negativePrompt: "不要文字", promptExtend: false, allowFailover: false,
|
||||
providerImageParams: { quality: "high", size: "1536*2048", watermark: false, seed: 0 } });
|
||||
expect(output.appliedImagePreferences).toEqual({ modelId: "user-image-model", imageCount: 3, imageSize: "4K", aspectRatio: "3:4" });
|
||||
});
|
||||
test("redraw and a corrective retry inherit the same user model, batch and image controls", async () => {
|
||||
const h = harness(); const tools = await h.build();
|
||||
await tools.executable.editTargetImage.execute({ nodeIds: ["reference"], args: { prompt: "换背景", modelId: "system-default" } }, "edit-1");
|
||||
await tools.executable.generateCanvasImages.execute({ prompt: "修正背景", modelId: "system-default" }, "retry-1");
|
||||
expect(h.generated).toHaveLength(2);
|
||||
for (const invocation of h.generated) expect(invocation).toMatchObject({ model: preferences.modelId, imageSize: "4K", imageCount: 3, aspectRatio: "3:4", allowFailover: false });
|
||||
});
|
||||
test("an unavailable explicit choice never silently generates or charges through the default", async () => {
|
||||
const h = harness(); const tools = await h.build({ ...preferences, modelId: "disabled-or-missing" });
|
||||
await expect(tools.executable.generateCanvasImages.execute({ prompt: "画图" }, "bad")).rejects.toThrow("指定的图片模型不可用");
|
||||
expect(h.generated).toHaveLength(0); expect(h.operations).toHaveLength(0);
|
||||
});
|
||||
test("Auto selection retains routing and the n count alias without overriding a supplied planner ratio", async () => {
|
||||
const h = harness(); const tools = await h.build({ parameters: { n: 2, imageSize: "8K" } });
|
||||
await tools.executable.generateCanvasImages.execute({ prompt: "画图", aspectRatio: "16:9" }, "auto");
|
||||
expect(h.generated[0]).toMatchObject({ model: "system-default", imageCount: 2, imageSize: "8K", aspectRatio: "16:9", allowFailover: true });
|
||||
});
|
||||
|
||||
test("planning uses the image count for generation tasks but does not turn analysis into an image task", async () => {
|
||||
const h = harness(); const tools = await h.build();
|
||||
const plan = { steps: ["查看画布"], visualCriteria: [] };
|
||||
const analysis = await tools.executable.planCanvasTask.execute(plan, "analysis-plan");
|
||||
expect(analysis.expectedImageCount).toBeUndefined();
|
||||
const generation = await tools.executable.planCanvasTask.execute({ ...plan, expectedImageCount: 1, aspectRatio: "1:1" }, "generation-plan");
|
||||
expect(generation).toMatchObject({ expectedImageCount: 3, aspectRatio: "3:4" });
|
||||
expect(h.generated).toHaveLength(0);
|
||||
});
|
||||
function getCookieHeaders(headers: Headers) {
|
||||
const cookie = headers.get("cookie") || "";
|
||||
return cookie ? { cookie } : undefined;
|
||||
}
|
||||
|
||||
test("an explicit model in the user's words pins generation, while the planner's own choice does not", async () => {
|
||||
const { resolveUserImageModel } = await import("@/lib/ai/canvas-agent-runtime/image-preferences");
|
||||
const entries = [{ model: { id: "pinned", modelId: "nano-banana-pro", displayName: "Banana Pro" } }];
|
||||
expect(resolveUserImageModel("请使用 nano banana pro 画一只小猫", entries)).toBe("pinned");
|
||||
expect(resolveUserImageModel("不要用nano-banana-pro,自动画图", entries)).toBeUndefined();
|
||||
expect(resolveUserImageModel("不用nano-banana-pro,自动画图", entries)).toBeUndefined();
|
||||
expect(resolveUserImageModel("先使用nano-banana-pro,改用other-image", [...entries, { model: { id: "later", modelId: "other-image" } }])).toBe("later");
|
||||
expect(resolveUserImageModel("画一只小猫", entries)).toBeUndefined();
|
||||
expect(resolveUserImageModel("使用nano-banana-pro", entries, "manual-control")).toBe("manual-control");
|
||||
});
|
||||
async function postRuntimeEndpoint(params: {
|
||||
body: Record<string, unknown>;
|
||||
path: string;
|
||||
requestHeaders: Headers;
|
||||
requestUrl: string;
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
const response = await fetch(resolveCanvasRuntimeEndpoint(params.path, params.requestUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getCookieHeaders(params.requestHeaders),
|
||||
},
|
||||
body: JSON.stringify(params.body),
|
||||
signal: AbortSignal.timeout(params.timeoutMs || 20 * 60 * 1000),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || payload?.success === false || payload?.error) {
|
||||
throw new Error(
|
||||
String(payload?.error || payload?.detail || `能力服务返回 ${response.status}`)
|
||||
);
|
||||
}
|
||||
return payload as Record<string, any>;
|
||||
}
|
||||
|
||||
async function postRuntimeFormData(params: {
|
||||
formData: FormData;
|
||||
path: string;
|
||||
requestHeaders: Headers;
|
||||
requestUrl: string;
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
const response = await fetch(resolveCanvasRuntimeEndpoint(params.path, params.requestUrl), {
|
||||
method: "POST",
|
||||
headers: getCookieHeaders(params.requestHeaders),
|
||||
body: params.formData,
|
||||
signal: AbortSignal.timeout(params.timeoutMs || 20 * 60 * 1000),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || payload?.success === false || payload?.error) {
|
||||
throw new Error(
|
||||
String(payload?.error || payload?.detail || `能力服务返回 ${response.status}`)
|
||||
);
|
||||
}
|
||||
return payload as Record<string, any>;
|
||||
}
|
||||
|
||||
function inferTripoImageType(imageUrl: string) {
|
||||
try {
|
||||
const extension = new URL(imageUrl).pathname.split(".").pop()?.toLowerCase();
|
||||
if (extension === "png" || extension === "webp") return extension;
|
||||
} catch {}
|
||||
return "jpg";
|
||||
}
|
||||
|
||||
async function resolveImageModel(modelRef?: unknown) {
|
||||
const entries = sortStyleProImageModelEntries(
|
||||
await getStyleProImageModelEntries({ includeHidden: false })
|
||||
);
|
||||
const requested = findStyleProImageModelByRef(entries, String(modelRef || ""));
|
||||
const selected = requested || entries.find((entry) => entry.model.isDefault) || entries[0];
|
||||
if (!selected) throw new Error("系统未配置可用的图片生成模型");
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function executeForTarget(
|
||||
params: HeadlessExecutorParams,
|
||||
target: CanvasCapabilityTarget
|
||||
): Promise<CanvasCapabilityMediaResult[]> {
|
||||
const { command } = params;
|
||||
const prompt = String(command.args.prompt || "").trim();
|
||||
const common = {
|
||||
sourceNodeId: target.nodeId,
|
||||
prompt: prompt || undefined,
|
||||
};
|
||||
|
||||
if (command.capabilityId === "image.remove_background") {
|
||||
const imageBuffer = await fetchImageBufferFromUrl(target.sourceImageUrl);
|
||||
const result = await executeRmbgJob({
|
||||
userId: params.userId,
|
||||
imageBuffer,
|
||||
sourceImageUrl: target.sourceImageUrl,
|
||||
prompt: prompt || "抠图处理",
|
||||
skipGallerySave: false,
|
||||
});
|
||||
return [{
|
||||
...common,
|
||||
assetId: result.mediaAssetId || undefined,
|
||||
title: `${target.title}-抠图`,
|
||||
url: result.original_url,
|
||||
}];
|
||||
}
|
||||
|
||||
if (command.capabilityId === "image.extend") {
|
||||
const result = await executeExtendJob({
|
||||
userId: params.userId,
|
||||
imageUrl: target.sourceImageUrl,
|
||||
sourceImageUrl: target.sourceImageUrl,
|
||||
prompt: prompt || "图像拓展处理",
|
||||
xScale: asFiniteNumber(command.args.xScale),
|
||||
yScale: asFiniteNumber(command.args.yScale),
|
||||
requestId: command.requestId,
|
||||
});
|
||||
return [{
|
||||
...common,
|
||||
assetId: result.mediaAssetId || undefined,
|
||||
title: `${target.title}-扩图`,
|
||||
url: result.original_url,
|
||||
}];
|
||||
}
|
||||
|
||||
if (command.capabilityId === "image.resize") {
|
||||
const width = asFiniteNumber(command.args.width);
|
||||
const height = asFiniteNumber(command.args.height);
|
||||
if (!width || !height) throw new Error("请提供完整的目标宽度和高度");
|
||||
const result = await executeResizeJob({
|
||||
userId: params.userId,
|
||||
imageUrl: target.sourceImageUrl,
|
||||
sourceImageUrl: target.sourceImageUrl,
|
||||
canvasWidth: Math.round(width),
|
||||
canvasHeight: Math.round(height),
|
||||
backgroundColor: String(command.args.backgroundColor || "").trim() || undefined,
|
||||
transparentBackground: command.args.transparentBackground === true,
|
||||
mimeType: String(command.args.mimeType || "").trim() || undefined,
|
||||
});
|
||||
return [{
|
||||
...common,
|
||||
assetId: result.mediaAssetId || undefined,
|
||||
height: result.canvas_height,
|
||||
title: `${target.title}-${result.canvas_width}x${result.canvas_height}`,
|
||||
url: result.original_url,
|
||||
width: result.canvas_width,
|
||||
}];
|
||||
}
|
||||
|
||||
if (command.capabilityId === "image.box_cutout") {
|
||||
const result = await executeBoxCutoutJob({
|
||||
userId: params.userId,
|
||||
imageUrl: target.sourceImageUrl,
|
||||
sourceNodeId: target.nodeId,
|
||||
cropRect: {
|
||||
x: asFiniteNumber(command.args.x),
|
||||
y: asFiniteNumber(command.args.y),
|
||||
width: asFiniteNumber(command.args.width),
|
||||
height: asFiniteNumber(command.args.height),
|
||||
},
|
||||
paddingRatio: asFiniteNumber(command.args.paddingRatio),
|
||||
prompt: prompt || "框选抠图",
|
||||
});
|
||||
return [{
|
||||
...common,
|
||||
assetId: result.mediaAssetId || undefined,
|
||||
metadata: {
|
||||
cropRectApplied: result.cropRectApplied,
|
||||
paddedCropRectApplied: result.paddedCropRectApplied,
|
||||
},
|
||||
title: `${target.title}-框选抠图`,
|
||||
url: result.original_url,
|
||||
}];
|
||||
}
|
||||
|
||||
if (command.capabilityId === "image.layer_split") {
|
||||
const result = await executeLayerSplitJob({
|
||||
userId: params.userId,
|
||||
imageUrl: target.sourceImageUrl,
|
||||
sourceNodeId: target.nodeId,
|
||||
prompt: prompt || undefined,
|
||||
});
|
||||
return result.layers.map((layer) => ({
|
||||
...common,
|
||||
assetId: layer.mediaAssetId || undefined,
|
||||
metadata: {
|
||||
bounds: layer.bounds || null,
|
||||
layerIndex: layer.layerIndex,
|
||||
model: result.model,
|
||||
provider: result.provider,
|
||||
totalLayers: layer.totalLayers,
|
||||
},
|
||||
title: layer.title || `${target.title}-图层${layer.layerIndex + 1}`,
|
||||
url: layer.originalUrl,
|
||||
}));
|
||||
}
|
||||
|
||||
if (command.capabilityId === "image.enhance") {
|
||||
const result = await requestLongTaskResult({
|
||||
endpoint: resolveCanvasRuntimeEndpoint("/api/enhance", params.requestUrl),
|
||||
payload: {
|
||||
imageUrl: target.sourceImageUrl,
|
||||
prompt: prompt || "图像增强处理",
|
||||
scaleFactor: asFiniteNumber(command.args.scaleFactor),
|
||||
},
|
||||
pollingParams: {
|
||||
prompt: prompt || "图像增强处理",
|
||||
sourceImageUrl: target.sourceImageUrl,
|
||||
},
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
headers: getCookieHeaders(params.requestHeaders),
|
||||
});
|
||||
if (!result?.success || !result.original_url) {
|
||||
throw new Error(String(result?.error || result?.detail || "图像增强失败"));
|
||||
}
|
||||
return [{
|
||||
...common,
|
||||
title: `${target.title}-高清增强`,
|
||||
url: String(result.original_url),
|
||||
}];
|
||||
}
|
||||
|
||||
if (command.capabilityId === "image.remove_watermark") {
|
||||
const result = await requestLongTaskResult({
|
||||
endpoint: resolveCanvasRuntimeEndpoint(
|
||||
"/api/watermark-remove",
|
||||
params.requestUrl
|
||||
),
|
||||
payload: { image_url: target.sourceImageUrl },
|
||||
timeoutMs: 20 * 60 * 1000,
|
||||
headers: getCookieHeaders(params.requestHeaders),
|
||||
});
|
||||
if (!result?.success || !result.original_url) {
|
||||
throw new Error(String(result?.error || result?.detail || "去水印失败"));
|
||||
}
|
||||
return [{
|
||||
...common,
|
||||
title: `${target.title}-去水印`,
|
||||
url: String(result.original_url),
|
||||
}];
|
||||
}
|
||||
|
||||
if (command.capabilityId === "image.multi_angle") {
|
||||
const result = await postRuntimeEndpoint({
|
||||
path: "/api/angle-edit",
|
||||
requestUrl: params.requestUrl,
|
||||
requestHeaders: params.requestHeaders,
|
||||
body: {
|
||||
image_url: target.sourceImageUrl,
|
||||
horizontal_angle: asFiniteNumber(command.args.horizontalAngle) ?? 30,
|
||||
vertical_angle: asFiniteNumber(command.args.verticalAngle) ?? 0,
|
||||
zoom: asFiniteNumber(command.args.zoom) ?? 0,
|
||||
},
|
||||
});
|
||||
const url = String(result.image_url || result.original_url || "").trim();
|
||||
if (!url) throw new Error("多视角服务未返回图片");
|
||||
return [{ ...common, title: `${target.title}-新视角`, url }];
|
||||
}
|
||||
|
||||
if (command.capabilityId === "image.generate_3d_model") {
|
||||
const modelVersion =
|
||||
String(command.args.modelId || command.args.modelVersion || "").trim() ||
|
||||
"tripo-standard";
|
||||
const promptText = prompt || target.title || "图生 3D 模型";
|
||||
const response = await postRuntimeEndpoint({
|
||||
path: "/api/canvas/3d/tasks",
|
||||
requestUrl: params.requestUrl,
|
||||
requestHeaders: params.requestHeaders,
|
||||
timeoutMs: 180_000,
|
||||
body: {
|
||||
taskType: "image_to_model",
|
||||
imageUrl: target.sourceImageUrl,
|
||||
imageType: inferTripoImageType(target.sourceImageUrl),
|
||||
prompt: promptText,
|
||||
modelVersion,
|
||||
faceLimit: 120_000,
|
||||
texture: true,
|
||||
pbr: true,
|
||||
autoSize: false,
|
||||
quad: false,
|
||||
generateParts: false,
|
||||
smartLowPoly: false,
|
||||
geometryQuality: "standard",
|
||||
textureQuality: "standard",
|
||||
textureAlignment: "original_image",
|
||||
orientation: "default",
|
||||
},
|
||||
});
|
||||
const task = response.data as Record<string, unknown> | undefined;
|
||||
const taskId = String(task?.taskId || "").trim();
|
||||
if (!taskId) throw new Error("3D 模型服务未返回任务 ID");
|
||||
const providerId = String(task?.providerId || "").trim() || null;
|
||||
const providerName = String(task?.providerName || "").trim() || null;
|
||||
return [
|
||||
{
|
||||
...common,
|
||||
nodeType: "model3d",
|
||||
title: `${target.title}-3D模型`,
|
||||
url: target.sourceImageUrl,
|
||||
metadata: {
|
||||
taskId,
|
||||
provider: "tripo-3d",
|
||||
mode: "image",
|
||||
status: "generating",
|
||||
sourceImageUrl: target.sourceImageUrl,
|
||||
providerId,
|
||||
providerName,
|
||||
modelVersion,
|
||||
generationStartedAt: new Date().toISOString(),
|
||||
assetPayload: {
|
||||
providerId,
|
||||
providerName,
|
||||
sourceImageUrl: target.sourceImageUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (command.capabilityId === "image.generate_3d_world") {
|
||||
const modelVersion =
|
||||
String(command.args.modelId || command.args.modelVersion || "").trim() ||
|
||||
"world-standard";
|
||||
const promptText = prompt || target.title || "图生 3D 世界";
|
||||
const formData = new FormData();
|
||||
formData.append("sourceUrl", target.sourceImageUrl);
|
||||
formData.append("kind", "image");
|
||||
const uploadResponse = await postRuntimeFormData({
|
||||
formData,
|
||||
path: "/api/canvas/world/upload",
|
||||
requestUrl: params.requestUrl,
|
||||
requestHeaders: params.requestHeaders,
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
const upload = uploadResponse.data as Record<string, unknown> | undefined;
|
||||
const mediaAssetId = String(upload?.mediaAssetId || "").trim();
|
||||
if (!mediaAssetId) throw new Error("3D 世界服务未返回媒体资源 ID");
|
||||
const providerId = String(upload?.providerId || "").trim() || null;
|
||||
const providerName = String(upload?.providerName || "").trim() || null;
|
||||
const taskResponse = await postRuntimeEndpoint({
|
||||
path: "/api/canvas/world/tasks",
|
||||
requestUrl: params.requestUrl,
|
||||
requestHeaders: params.requestHeaders,
|
||||
timeoutMs: 240_000,
|
||||
body: {
|
||||
mode: "image",
|
||||
mediaAssetId,
|
||||
providerId,
|
||||
prompt: promptText,
|
||||
displayName: target.title || promptText,
|
||||
model: modelVersion,
|
||||
disableRecaption: false,
|
||||
makePublic: false,
|
||||
},
|
||||
});
|
||||
const task = taskResponse.data as Record<string, unknown> | undefined;
|
||||
const taskId = String(task?.taskId || "").trim();
|
||||
if (!taskId) throw new Error("3D 世界服务未返回任务 ID");
|
||||
const taskAssetPayload =
|
||||
task?.assetPayload && typeof task.assetPayload === "object"
|
||||
? (task.assetPayload as Record<string, unknown>)
|
||||
: {};
|
||||
return [
|
||||
{
|
||||
...common,
|
||||
nodeType: "world3d",
|
||||
title: `${target.title}-3D世界`,
|
||||
url: target.sourceImageUrl,
|
||||
metadata: {
|
||||
taskId,
|
||||
provider: "worldlabs-3d",
|
||||
mode: "world-image",
|
||||
status: "generating",
|
||||
sourceImageUrl: target.sourceImageUrl,
|
||||
providerId,
|
||||
providerName,
|
||||
modelVersion,
|
||||
generationStartedAt: new Date().toISOString(),
|
||||
assetPayload: {
|
||||
...taskAssetPayload,
|
||||
providerId,
|
||||
providerName,
|
||||
sourceImageUrl: target.sourceImageUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
command.capabilityId === "image.prompt_regenerate" ||
|
||||
command.capabilityId === "image.ai_wash"
|
||||
) {
|
||||
if (!prompt) throw new Error("请提供重绘提示词");
|
||||
const model = await resolveImageModel(command.args.modelId);
|
||||
if (command.source === "agent" && command.args.agentImageMode === "pinned" &&
|
||||
command.args.modelId !== model.model.id && command.args.modelId !== model.model.modelId)
|
||||
throw new Error("指定的图片模型不可用,未切换到其他模型");
|
||||
const result = await generateCanvasImage({
|
||||
prompt,
|
||||
model: model.model.id,
|
||||
...(command.source === "agent" ? {
|
||||
agentImageMode: command.args.agentImageMode === "pinned" ? "pinned" as const : "auto" as const,
|
||||
allowFailover: command.args.agentImageMode !== "pinned",
|
||||
} : {}),
|
||||
ownerKey: params.userId,
|
||||
imageCount: 1,
|
||||
imageSize: String(command.args.imageSize || "2K"),
|
||||
aspectRatio: String(command.args.aspectRatio || "auto"),
|
||||
promptExtend: command.args.promptExtend === true,
|
||||
referenceImage: target.sourceImageUrl,
|
||||
debugLabel:
|
||||
command.capabilityId === "image.ai_wash"
|
||||
? "canvas-capability-ai-wash"
|
||||
: "canvas-capability-regenerate",
|
||||
debugMeta: {
|
||||
capabilityId: command.capabilityId,
|
||||
requestId: command.requestId,
|
||||
sourceNodeId: target.nodeId,
|
||||
},
|
||||
});
|
||||
const image = result.images[0];
|
||||
if (!image?.originalUrl) throw new Error("图片模型未返回有效结果");
|
||||
return [{
|
||||
...common,
|
||||
title:
|
||||
command.capabilityId === "image.ai_wash"
|
||||
? `${target.title}-AI洗图`
|
||||
: `${target.title}-重绘`,
|
||||
url: image.originalUrl,
|
||||
}];
|
||||
}
|
||||
|
||||
throw new Error(`尚未实现后台执行能力:${command.capabilityId}`);
|
||||
}
|
||||
|
||||
export async function executeCanvasCapabilityHeadlessly(
|
||||
params: HeadlessExecutorParams
|
||||
) {
|
||||
const results: CanvasCapabilityMediaResult[] = [];
|
||||
for (const target of params.targets) {
|
||||
results.push(...(await executeForTarget(params, target)));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
test("mixed-model batches bill and label only the delivered images using their actual models", async () => {
|
||||
const h = harness([{ originalUrl: "https://test.example/first.png", thumbnailUrl: "https://test.example/first.png", modelRef: "system-default" }, { originalUrl: "https://test.example/second.png", thumbnailUrl: "https://test.example/second.png", modelRef: "backup" }]);
|
||||
const tools = await h.build({ parameters: { imageCount: 2, imageSize: "2K" } });
|
||||
const result = await tools.executable.generateCanvasImages.execute({ prompt: "画图" }, "mixed");
|
||||
expect(h.saved).toHaveLength(2);
|
||||
expect(h.saved.map(call => call[5])).toEqual(["system-default", "backup"]);
|
||||
expect(h.saved[0][0]).toContain("first.png"); expect(h.saved[0][0]).not.toContain("second.png");
|
||||
expect(h.saved[1][0]).toContain("second.png");
|
||||
expect(h.operations[0].plan.mediaItems.map((item: any) => item.metadata.model)).toEqual(["system-default", "backup"]);
|
||||
expect(result.appliedImagePreferences.modelId).toBe("backup");
|
||||
test("headless image features cannot substitute their default when a pinned model disappears", async () => {
|
||||
let generated = 0;
|
||||
const mocks: Record<string, any> = {
|
||||
"@/lib/ai/box-cutout/service": {}, "@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {}, "@/lib/ai/rmbg/service": {},
|
||||
"@/lib/canvas/capabilities/internal-endpoint": {}, "@/lib/client/long-task": {},
|
||||
"@/lib/canvas/generate-image": { generateCanvasImage: async () => { generated++; return { images: [] }; } },
|
||||
"@/lib/style-pro/model-options": { getStyleProImageModelEntries: async () => [{ model: { id: "default", modelId: "default", isDefault: true } }], sortStyleProImageModelEntries: (entries: any[]) => entries, findStyleProImageModelByRef: () => null },
|
||||
};
|
||||
const { executeCanvasCapabilityHeadlessly } = serverLoader(mocks)("lib/canvas/capabilities/headless-executor.ts");
|
||||
await expect(executeCanvasCapabilityHeadlessly({ userId: "u", requestHeaders: new Headers(), requestUrl: "https://test.example", command: { capabilityId: "image.ai_wash", source: "agent", args: { prompt: "clean image", modelId: "deleted-model", agentImageMode: "pinned" } }, targets: [{ nodeId: "source", title: "source", sourceImageUrl: "https://test.example/source.png" }] })).rejects.toThrow("指定的图片模型不可用");
|
||||
expect(generated).toBe(0);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user