diff --git a/app/api/canvas/agent-chat/route.ts b/app/api/canvas/agent-chat/route.ts index 69c930789..c8a51da34 100644 --- a/app/api/canvas/agent-chat/route.ts +++ b/app/api/canvas/agent-chat/route.ts @@ -329,6 +329,7 @@ export async function POST(request: Request) { withModel, imageSize: snapshot.imagePreferences?.parameters?.imageSize, imagePreferences: snapshot.imagePreferences, + priorCalls: snapshot.calls, preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined), operationSelectedNodeIds: selectedNodeIds, objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join( diff --git a/lib/ai/canvas-agent-runtime/image-preferences.ts b/lib/ai/canvas-agent-runtime/image-preferences.ts index 6bbd92c0a..5078e722a 100644 --- a/lib/ai/canvas-agent-runtime/image-preferences.ts +++ b/lib/ai/canvas-agent-runtime/image-preferences.ts @@ -13,13 +13,18 @@ export type AgentImageParameters = { }; export type AgentImagePreferences = { modelId?: string; parameters?: AgentImageParameters }; +export function selectedAgentImageCount(preferences?: AgentImagePreferences) { + const count = preferences?.parameters?.imageCount ?? preferences?.parameters?.n; + return count === undefined ? undefined : Math.floor(count); +} + export function applyAgentImagePreferences>( input: T, preferences?: AgentImagePreferences, ): T & AgentImageParameters & { modelId?: string } { const parameters = Object.fromEntries(Object.entries(preferences?.parameters || {}) .filter(([, value]) => value !== undefined)); - const count = preferences?.parameters?.imageCount ?? preferences?.parameters?.n; + const count = selectedAgentImageCount(preferences); return { ...input, ...parameters, diff --git a/lib/ai/canvas-agent-runtime/results.ts b/lib/ai/canvas-agent-runtime/results.ts index f3a4174d1..05ffc653e 100644 --- a/lib/ai/canvas-agent-runtime/results.ts +++ b/lib/ai/canvas-agent-runtime/results.ts @@ -1,5 +1,14 @@ import type { AgentCallRecord, AgentRunSnapshot } from "./protocol"; +export function countAgentGeneratedImages(calls: AgentCallRecord[]) { + return calls.reduce((count, call) => { + const output = call.output; + if (call.status !== "succeeded" || !output?.appliedImagePreferences || + !Array.isArray(output.createdNodeIds)) return count; + return count + output.createdNodeIds.length; + }, 0); +} + // A spontaneous download command is not an unfinished image edit. Keep genuine // download requests and all editor panels waiting for their actual interaction. export function isAncillaryDownload(snapshot: AgentRunSnapshot, call: AgentCallRecord) { diff --git a/lib/ai/canvas-agent-runtime/review.ts b/lib/ai/canvas-agent-runtime/review.ts index 2806bf8f5..b877d78b4 100644 --- a/lib/ai/canvas-agent-runtime/review.ts +++ b/lib/ai/canvas-agent-runtime/review.ts @@ -5,7 +5,8 @@ import sharp from "sharp"; import { z } from "zod"; import { readCanvasImages, readOwnedCanvas, type RuntimeImageNode } from "./canvas"; import type { AgentRunSnapshot } from "./protocol"; -import { finalResultNodeIds, originalSourceNodeIds, requiresCanvasInteraction } from "./results"; +import { countAgentGeneratedImages, finalResultNodeIds, originalSourceNodeIds, requiresCanvasInteraction } from "./results"; +import { selectedAgentImageCount } from "./image-preferences"; import { isCompletedSimpleCutout } from "./cutout-completion"; export async function inspectImageAlpha(source: Buffer) { @@ -124,14 +125,16 @@ export async function verifyCanvasAgentResults(params: { (call) => call.status !== "succeeded" || requiresCanvasInteraction(snapshot, call) || - (typeof call.output?.status === "string" && call.output.status !== "succeeded"), + (typeof call.output?.status === "string" && call.output.status !== "succeeded" && + !(call.output.status === "partial" && call.output.appliedImagePreferences)), ); if (incomplete.length) return { passed: false, issues: ["部分操作仍在等待交互或未完成"] }; const plan = calls.filter((call) => call.name === "planCanvasTask").at(-1)?.output; if (!plan) return { passed: false, issues: ["缺少明确的任务验收计划"] }; // Intermediate images consumed by later write tools do not count as final deliverables. const finalIds = finalResultNodeIds(snapshot); - const expectedCount = snapshot.requirements?.expectedImageCount ?? plan.expectedImageCount; + const expectedCount = snapshot.requirements?.expectedImageCount ?? plan.expectedImageCount ?? + (countAgentGeneratedImages(calls) > 0 ? selectedAgentImageCount(snapshot.imagePreferences) : undefined); if (expectedCount && finalIds.length !== expectedCount) { return { passed: false, diff --git a/lib/ai/canvas-agent-runtime/tools.ts b/lib/ai/canvas-agent-runtime/tools.ts index 5bb56959a..e3487c586 100644 --- a/lib/ai/canvas-agent-runtime/tools.ts +++ b/lib/ai/canvas-agent-runtime/tools.ts @@ -1,5 +1,11 @@ import type { AgentVisionExecutor } from "../runtime/agent-vision-failover"; -import { resolveUserImageModel, applyAgentImagePreferences, agentProviderImageParameters, type AgentImagePreferences } from "./image-preferences"; +import { + agentProviderImageParameters, + applyAgentImagePreferences, + resolveUserImageModel, + selectedAgentImageCount, + type AgentImagePreferences, +} from "./image-preferences"; import { tool, type LanguageModel, type ToolSet } from "ai"; import { z } from "zod"; import { createCanvasCapabilityTools } from "@/lib/canvas/capabilities/ai-sdk-tools"; @@ -18,6 +24,8 @@ import { sortStyleProImageModelEntries, } from "@/lib/style-pro/model-options"; import type { CanvasAgentLoopPorts, CanvasAgentTool } from "./loop"; +import { AgentToolRejectedError, type AgentCallRecord } from "./protocol"; +import { countAgentGeneratedImages } from "./results"; import { readCanvasImages, readOwnedCanvas } from "./canvas"; import { analyzeRuntimeImages } from "./review"; import { isExplicitDestructiveIntent } from "./capability-access"; @@ -37,11 +45,13 @@ export async function buildCanvasRuntimeTools(params: { imageSize?: string; preferredModelId?: string; imagePreferences?: AgentImagePreferences; + priorCalls?: AgentCallRecord[]; emit: (type: string, data: unknown) => void; objective?: string; operationSelectedNodeIds?: string[]; }) { const { nodes } = await readOwnedCanvas(params.userId, params.projectId); + const selectedImageCount = selectedAgentImageCount(params.imagePreferences); const imageNodeIds = nodes.filter((node) => node.type === "image").map((node) => node.id); const defaults = params.selectedNodeIds.filter((id) => imageNodeIds.includes(id)); const sdk = await createCanvasCapabilityTools({ @@ -128,8 +138,8 @@ export async function buildCanvasRuntimeTools(params: { visualCriteria: z.array(z.string().max(200)).max(10), }), async (input) => ({ ...input, - ...(input.expectedImageCount !== undefined && params.imagePreferences?.parameters?.imageCount !== undefined - ? { expectedImageCount: params.imagePreferences.parameters.imageCount } : {}), + ...(input.expectedImageCount !== undefined && selectedImageCount !== undefined + ? { expectedImageCount: selectedImageCount } : {}), ...(params.imagePreferences?.parameters?.aspectRatio && params.imagePreferences.parameters.aspectRatio !== "auto" ? { aspectRatio: params.imagePreferences.parameters.aspectRatio } : {}), }), @@ -174,7 +184,9 @@ export async function buildCanvasRuntimeTools(params: { async (input) => analyzeRuntimeImages({ ...params, ...input }), ); - let generatedCount = 0; + let submittedCount = 0; + let occupiedImageSlots = countAgentGeneratedImages(params.priorCalls ?? []); + const countLimitMessage = "本次设定的生成张数已占满,请先查看正在生成或已经生成的结果。"; register( "generateCanvasImages", "生成图片", @@ -189,9 +201,8 @@ export async function buildCanvasRuntimeTools(params: { }), async (toolInput, callId) => { const input = applyAgentImagePreferences(toolInput, params.imagePreferences); - // Bound per-task spend without introducing any global/model concurrency queue. - if (generatedCount + input.imageCount > 24) - throw new Error("本次任务已达到 24 张生成预算,请检查结果后继续。"); + if (selectedImageCount !== undefined && occupiedImageSlots >= selectedImageCount) + throw new AgentToolRejectedError(countLimitMessage); const sources = await readCanvasImages( params.userId, params.projectId, @@ -214,10 +225,17 @@ export async function buildCanvasRuntimeTools(params: { if (!chosen) throw new Error("系统未配置可用的图片生成模型"); const imageSize = input.imageSize || params.imageSize || "2K"; const quota = await checkUserImageQuota(params.userId); - if (!quota.allowed || (quota.remaining !== null && input.imageCount > quota.remaining)) - throw new Error("本月图片生成配额不足。"); const points = await getPointsSystemConfig(); const user = await getUserById({ id: params.userId }); + // Reserve the remaining slots before dispatch, including calls still in flight. + if (selectedImageCount !== undefined) { + input.imageCount = Math.min(input.imageCount, selectedImageCount - occupiedImageSlots); + if (input.imageCount < 1) throw new AgentToolRejectedError(countLimitMessage); + } + if (submittedCount + input.imageCount > 24) + throw new AgentToolRejectedError("本次任务已达到 24 张生成预算,请检查结果后继续。"); + if (!quota.allowed || (quota.remaining !== null && input.imageCount > quota.remaining)) + throw new Error("本月图片生成配额不足。"); const cost = getModelGenerationCreditCost( { imageGenConfig: chosen.model.imageGenConfig as DiscountableImageGenConfigLike | null }, @@ -231,7 +249,8 @@ export async function buildCanvasRuntimeTools(params: { (user?.pointsBalance ?? 0) < cost ) throw new Error("积分不足,请充值或联系管理员。"); - generatedCount += input.imageCount; + submittedCount += input.imageCount; + occupiedImageSlots += input.imageCount; const result = await generateCanvasImage({ prompt: input.prompt, model: chosen.model.id, @@ -311,6 +330,7 @@ export async function buildCanvasRuntimeTools(params: { }, ); } + occupiedImageSlots += result.images.length - input.imageCount; return { status: result.images.length < input.imageCount ? "partial" : "succeeded", appliedImagePreferences: { modelId: result.modelRef, imageSize, aspectRatio: input.aspectRatio, imageCount: input.imageCount }, diff --git a/lib/ai/zcode-agent/task.ts b/lib/ai/zcode-agent/task.ts index 9fc2d247c..45d6c9201 100644 --- a/lib/ai/zcode-agent/task.ts +++ b/lib/ai/zcode-agent/task.ts @@ -7,7 +7,8 @@ import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop"; import { AgentToolRejectedError, publicAgentError, type AgentCallRecord, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol"; import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error"; import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion"; -import { requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results"; +import { countAgentGeneratedImages, requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results"; +import { selectedAgentImageCount } from "@/lib/ai/canvas-agent-runtime/image-preferences"; import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error"; import { publicAssistantText } from "./assistant-output"; @@ -49,6 +50,16 @@ export async function runZcodeCanvasTask(ports: Ports): Promise= count; + } + function reviewMessage(issues: string[]) { + const summary = `结果待核对:${issues.join(";")}`; + return imageCountReached() + ? `${summary}\n\n已保留本次生成的图片。如需再生成,请发送新指令。` + : summary; + } let modelError = ""; let toolFailure = ""; let nativeFailed = false; @@ -84,6 +95,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise 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/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 () => ({}) }, @@ -27,10 +42,10 @@ function harness(results?: any[]) { "./review": {}, "./image-access-tools": { registerImageCanvasAccessTools() {} }, }); - const build = async (imagePreferences: any = preferences) => mock("lib/ai/canvas-agent-runtime/tools.ts").buildCanvasRuntimeTools({ + 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, emit() {}, + preferredModelId: "system-default", imagePreferences, priorCalls, emit() {}, }); return { build, generated, operations, saved }; } @@ -47,12 +62,35 @@ test("real generation tool honors the chosen image model and all parameters desp 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"); +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); - 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" }); diff --git a/tests/routes/canvas-agent-runtime-review.test.ts b/tests/routes/canvas-agent-runtime-review.test.ts index 574aa1076..b2cbc32f0 100644 --- a/tests/routes/canvas-agent-runtime-review.test.ts +++ b/tests/routes/canvas-agent-runtime-review.test.ts @@ -94,6 +94,17 @@ test("checks real image bytes and compares results to their source images", asyn expect(result.issues).toEqual([]); }); +test("a partial image batch can be filled and verified without discarding the first result", async () => { + const f = await fixture('{"passed":true,"issues":[],"failedNodeIds":[]}'); + const { snapshot } = f.params; + snapshot.requirements = { expectedImageCount: 2 }; + snapshot.calls[1].output = { status: "partial", createdNodeIds: ["result"], appliedImagePreferences: { imageCount: 2 } }; + await expect(verifyCanvasAgentResults(f.params)).resolves.toMatchObject({ passed: false, retryable: true }); + snapshot.resultNodeIds.push("remaining-result"); + snapshot.calls.push({ id: "fill", name: "generateCanvasImages", input: {}, status: "succeeded", output: { status: "succeeded", createdNodeIds: ["remaining-result"] } }); + await expect(verifyCanvasAgentResults(f.params)).resolves.toMatchObject({ passed: true }); +}); + test("malformed vision JSON switches models, while a valid failed review never triggers failover", async () => { const bad = await fixture("not JSON"); const validNegative = await fixture('{"passed":false,"issues":["主体不一致"],"failedNodeIds":["result"]}'); diff --git a/tests/routes/zcode-agent-task.test.ts b/tests/routes/zcode-agent-task.test.ts index c90995fe9..8bab1daeb 100644 --- a/tests/routes/zcode-agent-task.test.ts +++ b/tests/routes/zcode-agent-task.test.ts @@ -30,6 +30,26 @@ function fixture() { const result = { type: "result", sessionId: "test-session", response: "已完成" }; +test("a failed quality check keeps the single generated image without opening a repair round", async () => { + const { ports, run, snapshot } = fixture(); + snapshot.imagePreferences = { parameters: { imageCount: 1 } }; + let attempts = 0; + ports.tools.generateCanvasImages.execute = async () => ({ + createdNodeIds: ["generated-image"], appliedImagePreferences: { imageCount: 1 }, + }); + ports.native = async options => { + attempts++; + await options.execute("planCanvasTask", {}, "plan", options.signal!); + await options.execute("generateCanvasImages", { referenceNodeIds: ["original"] }, "gen", options.signal!); + return { ...result, response: "图片已生成,正在检查" }; + }; + ports.verify = async () => ({ passed: false, issues: ["主体细节不符"], retryable: true, failedNodeIds: ["generated-image"] }); + expect(await run(ports)).toBe("needs_review"); + expect(attempts).toBe(1); + expect(snapshot.resultNodeIds).toEqual(["generated-image"]); + expect(snapshot.message).toMatch(/主体细节不符[\s\S]*如需再生成,请发送新指令/); +}); + test("an unconfirmed paid tool stops the planner and immediately preserves the reason and original request ID", async () => { const { ports, run, snapshot } = fixture(); const emitted: string[] = [], saved: string[] = [];