diff --git a/lib/canvas/capabilities/headless-executor.ts b/lib/canvas/capabilities/headless-executor.ts index a65eaa9d3..4000d8d1d 100644 --- a/lib/canvas/capabilities/headless-executor.ts +++ b/lib/canvas/capabilities/headless-executor.ts @@ -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, diff --git a/tests/routes/canvas-agent-runtime-image-preferences.test.ts b/tests/routes/canvas-agent-runtime-image-preferences.test.ts index 3e1296156..4c07b2fa5 100644 --- a/tests/routes/canvas-agent-runtime-image-preferences.test.ts +++ b/tests/routes/canvas-agent-runtime-image-preferences.test.ts @@ -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; + 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; + 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; +} + +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; +} + +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 { + 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 | 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 | 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 | 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) + : {}; + 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 = { + "@/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); });