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 type { AgentCallRecord } from "@/lib/ai/canvas-agent-runtime/protocol"; 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 }, }; function harness(results?: any[] | ((count: number) => any[])) { const saved: any[] = []; const generated: any[] = []; const operations: any[] = []; const referenceUrl = "https://test.example/reference.png"; const original = Buffer.from("original reference bytes"); 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: value.plan.mediaItems.map((item: { mediaItemId: string }) => item.mediaItemId) }; }, }, "@/lib/canvas/generate-image": { generateCanvasImage: async (value: any) => { generated.push(value); const images = typeof results === "function" ? results(value.imageCount) : results; return { modelRef: Array.isArray(results) ? "backup" : value.model, images: images ?? [{ 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: referenceUrl } }] }, "./review": { analyzeRuntimeImages: async ({ reviewPorts }: { reviewPorts: { readImage(url: string): Promise } }) => ({ bytes: (await reviewPorts.readImage(referenceUrl)).length }) }, "@/lib/ai/rmbg/service": { fetchImageBufferFromUrl: async () => original }, "./image-access-tools": { registerImageCanvasAccessTools() {} }, }); const build = async (imagePreferences: any = preferences, priorCalls?: AgentCallRecord[]) => 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, priorCalls, emit() {}, }); return { build, generated, operations, saved, referenceUrl, original }; } test("the generation tool can reuse original bytes read by inspection within the same task", async () => { const h = harness(); const selection = { parameters: { imageCount: 1 } }; const tools = await h.build(selection); await tools.executable.analyzeCanvasImages.execute({ nodeIds: ["reference"], question: "识别素材" }, "inspect"); await tools.executable.generateCanvasImages.execute({ prompt: "修改参考图", referenceNodeIds: ["reference"] }, "generate"); expect(h.generated[0].referenceImageBuffers.get(h.referenceUrl)).toBe(h.original); const nextTask = await h.build(selection); await nextTask.executable.generateCanvasImages.execute({ prompt: "修改参考图", referenceNodeIds: ["reference"] }, "next"); expect(h.generated[1].referenceImageBuffers.has(h.referenceUrl)).toBe(false); }); 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 generation share the task limit and only fill the missing images", async () => { const image = { originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" }; const h = harness(count => Array.from({ length: count === 3 ? 1 : count }, () => image)); const tools = await h.build(); const first = await tools.executable.editTargetImage.execute({ nodeIds: ["reference"], args: { prompt: "换背景", modelId: "system-default" } }, "edit-1"); expect(first.status).toBe("partial"); const filled = await tools.executable.generateCanvasImages.execute({ prompt: "补齐图片", modelId: "system-default" }, "fill-1"); expect(filled.status).toBe("succeeded"); await expect(tools.executable.generateCanvasImages.execute({ prompt: "再次修正" }, "retry-1")).rejects.toThrow("张数已占满"); expect(h.generated).toHaveLength(2); expect(h.generated.map(call => call.imageCount)).toEqual([3, 2]); for (const invocation of h.generated) expect(invocation).toMatchObject({ model: preferences.modelId, imageSize: "4K", aspectRatio: "3:4", allowFailover: false }); }); test("one selected image allows only one submission even when calls overlap or the task resumes", async () => { const h = harness(); const selection = { parameters: { imageCount: 1 } }; const tools = await h.build(selection); const generate = (callId: string) => tools.executable.generateCanvasImages.execute({ prompt: "修改图片" }, callId); const [first, duplicate] = await Promise.allSettled([generate("original"), generate("parallel")]); expect(first.status).toBe("fulfilled"); expect(duplicate.status).toBe("rejected"); if (first.status !== "fulfilled") throw first.reason; const resumed = await h.build(selection, [{ id: "original", name: "generateCanvasImages", input: {}, status: "succeeded", output: first.value }]); await expect(resumed.executable.generateCanvasImages.execute({ prompt: "继续" }, "resume")).rejects.toThrow("张数已占满"); expect(h.generated).toHaveLength(1); const next = await h.build(selection); await next.executable.generateCanvasImages.execute({ prompt: "用户新指令" }, "next"); expect(h.generated).toHaveLength(2); }); 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); }); 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"); }); 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/canvas/operations/box-cutout": {}, "@/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); }); test("an unavailable planner model recovers automatically with the user's image parameters", async () => { const h = harness(); const tools = await h.build({ parameters: { imageSize: "4K", aspectRatio: "3:4" } }); await tools.executable.generateCanvasImages.execute({ prompt: "画图", modelId: "retired-planner-model" }, "auto-retired"); expect(h.generated).toHaveLength(1); expect(h.generated[0]).toMatchObject({ model: "system-default", imageSize: "4K", aspectRatio: "3:4", allowFailover: true, agentImageMode: "auto" }); }); test("front-view edits use reference-image generation with the user's selected model and parameters", async () => { const h = harness(); const tools = await h.build(); await tools.executable.generateCanvasImages.execute({ prompt: "把柜子角度改为正面,保持柜子结构和材质", referenceNodeIds: ["reference"] }, "front-view"); expect(h.generated).toHaveLength(1); expect(h.generated[0]).toMatchObject({ prompt: "把柜子角度改为正面,保持柜子结构和材质", referenceImages: ["https://test.example/reference.png"], model: "user-image-model", aspectRatio: "3:4", imageSize: "4K", imageCount: 3, allowFailover: false }); expect(h.operations[0].plan.preserveOriginal).toBe(true); });