ai-chatbot/tests/routes/canvas-agent-runtime-image-preferences.test.ts

130 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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";
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[]) {
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 };
}
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);
});
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<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);
});
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);
});