From 28c2a1ceb8558f3c56c330a94695a7bc34d11547 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 21 Oct 2026 10:20:59 +0800 Subject: [PATCH] feat(agent): recover automatic image generation without switching user-pinned models --- .../canvas-agent-runtime/image-preferences.ts | 17 +++++ lib/ai/canvas-agent-runtime/tools.ts | 29 +++++--- lib/ai/runtime/agent-image-failover.ts | 16 ++++ lib/ai/runtime/agent-model-circuit-store.ts | 10 ++- lib/ai/zcode-agent/task.ts | 2 +- lib/canvas/capabilities/ai-sdk-tools.ts | 4 + lib/canvas/capabilities/headless-executor.ts | 4 + lib/canvas/generate-image.ts | 74 +++++++++++++------ playwright.canvas-agent.config.ts | 2 +- tests/helpers/agent-image-provider-harness.ts | 26 +++++++ ...anvas-agent-runtime-image-failover.test.ts | 63 ++++++++++++++++ ...as-agent-runtime-image-preferences.test.ts | 33 ++++++++- 12 files changed, 237 insertions(+), 43 deletions(-) create mode 100644 lib/ai/runtime/agent-image-failover.ts create mode 100644 tests/helpers/agent-image-provider-harness.ts create mode 100644 tests/routes/canvas-agent-runtime-image-failover.test.ts diff --git a/lib/ai/canvas-agent-runtime/image-preferences.ts b/lib/ai/canvas-agent-runtime/image-preferences.ts index 8a1adcc59..6bbd92c0a 100644 --- a/lib/ai/canvas-agent-runtime/image-preferences.ts +++ b/lib/ai/canvas-agent-runtime/image-preferences.ts @@ -34,3 +34,20 @@ export function agentProviderImageParameters(parameters: AgentImageParameters) { return value !== undefined ? [[key, value]] : []; })); } + +/** Only user controls or an explicit instruction pin a model; the planner's own + * modelId and a preset default remain eligible for automatic recovery. */ +export function resolveUserImageModel(text: string, entries: Array<{ model: { id: string; modelId: string; displayName?: string | null } }>, selectedId?: string) { + if (selectedId) return selectedId; + const names = entries.flatMap(({ model }) => [model.modelId, model.displayName].filter((name): name is string => Boolean(name && name.length >= 4)).map(name => ({ id: model.id, name }))) + .sort((a, b) => b.name.length - a.name.length); + const matches: Array<{ id: string; index: number; length: number }> = []; + for (const { id, name } of names) { + const escaped = name.split(/[\s_-]+/).map(part => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\s_-]*"); + const pattern = new RegExp(`(?:使用|选择|指定|通过|用)\\s*(?:模型\\s*)?${escaped}(?![a-z0-9.-])`, "ig"); + for (const match of text.matchAll(pattern)) { + if (!/(?:不要|不|别|禁止|无需)\s*$/.test(text.slice(Math.max(0, match.index! - 8), match.index!))) matches.push({ id, index: match.index!, length: name.length }); + } + } + return matches.sort((a, b) => b.index - a.index || b.length - a.length)[0]?.id; +} diff --git a/lib/ai/canvas-agent-runtime/tools.ts b/lib/ai/canvas-agent-runtime/tools.ts index f54adfd84..ff690172b 100644 --- a/lib/ai/canvas-agent-runtime/tools.ts +++ b/lib/ai/canvas-agent-runtime/tools.ts @@ -1,5 +1,5 @@ import type { AgentVisionExecutor } from "../runtime/agent-vision-failover"; -import { applyAgentImagePreferences, agentProviderImageParameters, type AgentImagePreferences } from "./image-preferences"; +import { resolveUserImageModel, applyAgentImagePreferences, agentProviderImageParameters, 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"; @@ -202,7 +202,8 @@ export async function buildCanvasRuntimeTools(params: { intent: sources.length ? "image_edit" : "image_gen", hasImage: sources.length > 0, }).catch(() => null); - const requestedModel = params.imagePreferences?.modelId || input.modelId || params.preferredModelId; + const pinnedModel = resolveUserImageModel(params.objective || "", models, params.imagePreferences?.modelId); + const requestedModel = pinnedModel || input.modelId || params.preferredModelId; const requestedEntry = requestedModel ? models.find(({ model }) => model.healthStatus !== "auto_disabled" && (model.id === requestedModel || model.modelId === requestedModel)) : null; if (requestedModel && !requestedEntry) throw new Error("指定的图片模型不可用,未切换到其他模型收费生成"); const chosen = requestedEntry || models.find( @@ -224,7 +225,7 @@ export async function buildCanvasRuntimeTools(params: { { imageSize }, ) * input.imageCount; if ( - points.enabled && + pinnedModel && points.enabled && points.blockOnInsufficient !== false && !user?.isHighVip && (user?.pointsBalance ?? 0) < cost @@ -240,7 +241,10 @@ export async function buildCanvasRuntimeTools(params: { negativePrompt: input.negativePrompt, promptExtend: input.promptExtend, providerImageParams: agentProviderImageParameters(input), - allowFailover: !params.imagePreferences?.modelId, + allowFailover: !pinnedModel, + agentImageMode: pinnedModel ? "pinned" : "auto", + agentImageMaxCreditCost: points.enabled && points.blockOnInsufficient !== false && !user?.isHighVip + ? Math.max(0, user?.pointsBalance ?? 0) / input.imageCount : undefined, ownerKey: params.userId, referenceImages: sources.map((node) => node.data.src || node.data.url!), debugLabel: "canvas-agent-runtime", @@ -269,7 +273,7 @@ export async function buildCanvasRuntimeTools(params: { height: ratio ? Math.round((1024 * Number(ratio[2])) / Number(ratio[1])) : 1024, prompt: input.prompt, sourceNodeIds: input.referenceNodeIds, - metadata: { canvasAgentRunId: params.runId, model: result.modelRef }, + metadata: { canvasAgentRunId: params.runId, model: image.modelRef || result.modelRef }, })), }, }); @@ -283,15 +287,21 @@ export async function buildCanvasRuntimeTools(params: { updatedNodeIds: operation.updatedNodeIds, canvasRevision: operation.canvasRevision, }); + const billingGroups = new Map(); + for (const image of result.images) { + const ref = image.modelRef || result.modelRef; + billingGroups.set(ref, [...(billingGroups.get(ref) || []), image]); + } + for (const [modelRef, group] of billingGroups) { await saveGeneratedImages( - result.images + group .map((image) => `![thumb|||${image.originalUrl}|||${imageSize}](${image.thumbnailUrl})`) .join("\n"), params.userId, params.chatId, params.assistantMessageId, input.prompt, - result.modelRef, + modelRef, { projectId: params.projectId, sourceImageUrls: sources.map((node) => node.data.src || node.data.url!), @@ -300,9 +310,10 @@ export async function buildCanvasRuntimeTools(params: { aspectRatio: input.aspectRatio, }, ); + } return { - status: "succeeded", - appliedImagePreferences: { modelId: chosen.model.id, imageSize, aspectRatio: input.aspectRatio, imageCount: input.imageCount }, + status: result.images.length < input.imageCount ? "partial" : "succeeded", + appliedImagePreferences: { modelId: result.modelRef, imageSize, aspectRatio: input.aspectRatio, imageCount: input.imageCount }, createdNodeIds: operation.createdNodeIds, outputs: result.images.map((image) => ({ url: image.originalUrl })), model: result.modelRef, diff --git a/lib/ai/runtime/agent-image-failover.ts b/lib/ai/runtime/agent-image-failover.ts new file mode 100644 index 000000000..01c72ff9f --- /dev/null +++ b/lib/ai/runtime/agent-image-failover.ts @@ -0,0 +1,16 @@ +import { agentModelFault, type AgentModelFault } from "./agent-model-failover"; + +export function agentImageFailurePolicy(error: unknown): { retry: boolean; pause: AgentModelFault | null } { + const value = error as { message?: string; providerOutcomeUnknown?: boolean; status?: number; statusCode?: number } | null; + const message = String(value?.message || "").toLowerCase(); + // A submitted image job may still succeed. Retain its task and reconcile it; + // never submit another paid job just because a poll/connection timed out. + const unknown = value?.providerOutcomeUnknown || /request_outcome_unknown|may still be running|outcome.unknown|timed out|timeout|超时|fetch failed|econnreset|socket hang up/.test(message); + if (unknown && !/task failed|task.*(?:failed|cancelled|canceled)|任务.*(?:失败|取消)/.test(message)) return { retry: false, pause: null }; + if (/content.?policy|moderation|safety|违规|敏感|积分不足|配额不足|user cancel|user abort/.test(message)) return { retry: false, pause: null }; + const fault = agentModelFault(error); + if (fault) return { retry: true, pause: fault }; + if (/task failed|image generation failed|图片生成失败|未返回.*图片|empty.result|empty.response|provider.error|上游.*错误|模型.*维护/.test(message)) + return { retry: true, pause: { reason: "image_generation_failed", status: Number(value?.statusCode || value?.status) || null } }; + return { retry: false, pause: null }; +} diff --git a/lib/ai/runtime/agent-model-circuit-store.ts b/lib/ai/runtime/agent-model-circuit-store.ts index 118949ff3..86090dc06 100644 --- a/lib/ai/runtime/agent-model-circuit-store.ts +++ b/lib/ai/runtime/agent-model-circuit-store.ts @@ -4,10 +4,9 @@ import { db } from "@/lib/db"; import { aiModelHealthEvent, systemSettings } from "@/lib/db/schema"; import { AGENT_MODEL_COOLDOWN_MS, type AgentCircuitStore } from "./agent-model-failover"; -const prefix = "agent_multimodal_circuit:"; // PostgreSQL is shared by every process and release. Do not reuse the general // image-model health ban (which has a different, two-hour recovery policy). -export const agentModelCircuitStore: AgentCircuitStore = { +export function createAgentCircuitStore(prefix: string, context: string): AgentCircuitStore { return { async isPaused(entry) { const [row] = await db.select({ value: systemSettings.value, now: sql`clock_timestamp()` }) .from(systemSettings).where(eq(systemSettings.key, prefix + entry.model.id)).limit(1); @@ -26,9 +25,12 @@ export const agentModelCircuitStore: AgentCircuitStore = { await tx.insert(aiModelHealthEvent).values({ modelId: entry.model.id, providerId: entry.provider.id, modelRuntimeId: entry.model.modelId, modelDisplayName: entry.model.displayName, providerType: entry.provider.type, providerName: entry.provider.name, - eventType: "agent_circuit_open", severity: "warning", context: "canvas-agent-multimodal", + eventType: "agent_circuit_open", severity: "warning", context, counted: false, errorMessage: fault.reason, metadata: { ...value, cooldownMs: AGENT_MODEL_COOLDOWN_MS }, }); }); }, -}; +}; } + +export const agentModelCircuitStore = createAgentCircuitStore("agent_multimodal_circuit:", "canvas-agent-multimodal"); +export const agentImageCircuitStore = createAgentCircuitStore("agent_image_circuit:", "canvas-agent-image"); diff --git a/lib/ai/zcode-agent/task.ts b/lib/ai/zcode-agent/task.ts index a51d89617..260ae5518 100644 --- a/lib/ai/zcode-agent/task.ts +++ b/lib/ai/zcode-agent/task.ts @@ -56,7 +56,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise( operation, new Promise((_, reject) => { timeoutId = setTimeout(() => { - reject( - new Error( - `${params.providerType} 图片生成超时(${Math.round( - params.timeoutMs / 1000 - )}秒),将自动切换备用模型` - ) - ); + reject(Object.assign(new Error(`${params.providerType} 图片生成超时(${Math.round(params.timeoutMs / 1000)}秒),请稍后确认结果`), { providerOutcomeUnknown: true })); }, params.timeoutMs); }), ]); @@ -4507,6 +4507,7 @@ function findCanvasFailoverModel(params: { } if ( !item.model.isEnabled || + item.model.healthStatus === "auto_disabled" || item.model.isSubModel || (item.model.excludeFromFailover && !isSameNameScaleAdjustFallback) ) { @@ -4628,6 +4629,7 @@ export async function generateCanvasImage( request: CanvasGenerateImageRequest ): Promise { const startedAt = Date.now(); + if (request.agentImageMode === "pinned") request = { ...request, allowFailover: false }; const prompt = String(request.prompt || "").trim(); if (!prompt) { throw new Error("请输入提示词"); @@ -4898,7 +4900,13 @@ export async function generateCanvasImage( ); const allModels = await getEnabledAiModelsWithProvider(); - const imageModels = allModels.filter((m) => m.model.supportsImageGen); + let imageModels = allModels.filter((m) => m.model.supportsImageGen); + if (request.agentImageMode === "auto") { + const states = await Promise.all(imageModels.map(async entry => ({ entry, paused: await agentImageCircuitStore.isPaused(entry) }))); + imageModels = states.filter(item => !item.paused && item.entry.model.healthStatus !== "auto_disabled" && + isCanvasImageSizeSupported(item.entry.model.imageGenConfig as CanvasImageGenConfig | null, normalizeCanvasImageSizeValue(request.imageSize)) && + (request.agentImageMaxCreditCost === undefined || getModelGenerationCreditCost({ imageGenConfig: item.entry.model.imageGenConfig as DiscountableImageGenConfigLike | null }, "image", { imageSize: request.imageSize }) <= request.agentImageMaxCreditCost)).map(item => item.entry); + } if (!imageModels || imageModels.length === 0) { throw new Error("暂无可用的图片生成模型"); @@ -4909,8 +4917,12 @@ export async function generateCanvasImage( : imageModels.find((m) => m.model.isDefault) || imageModels[0]; if (!selectedModel) { - selectedModel = imageModels[0]; + if (model && request.allowFailover === false) throw new Error("指定的图片模型不可用,未切换到其他模型"); + selectedModel = request.agentImageMode === "auto" ? imageModels.find(entry => !entry.model.excludeFromFailover) : imageModels[0]; + if (!selectedModel) throw new Error("当前没有可用的备用图片模型,请稍后重试"); } + if (request.agentImageMode === "pinned" && !isCanvasImageSizeSupported(selectedModel.model.imageGenConfig as CanvasImageGenConfig | null, normalizeCanvasImageSizeValue(request.imageSize))) + throw new Error("指定的图片模型不支持所选尺寸,请调整图片尺寸"); const normalizedReferenceImages = Array.from( new Set( @@ -5064,7 +5076,7 @@ export async function generateCanvasImage( imageCount: requestedImageCount, imageSize: requestedImageSize, imageGenConfig: selectedModel.model.imageGenConfig as CanvasImageGenConfig | null | undefined, - ignoreModelMaxImages: request.ignoreModelMaxImages === true, + ignoreModelMaxImages: Boolean(request.agentImageMode) || request.ignoreModelMaxImages === true, }); const imageSize = normalizedImageParams.imageSize; const imageCount = @@ -5451,7 +5463,9 @@ export async function generateCanvasImage( ); } - const providerBatchLimit = getProviderBatchLimit(provider.type); + const providerBatchLimit = request.agentImageMode + ? Math.min(getProviderBatchLimit(provider.type), Math.max(1, Number((selectedModel.model.imageGenConfig as CanvasImageGenConfig | null)?.maxImages) || 12)) + : getProviderBatchLimit(provider.type); const providerBatchTimeoutMs = getCanvasProviderBatchTimeoutMs({ debugLabel, debugMeta, @@ -5536,12 +5550,11 @@ export async function generateCanvasImage( ...debugMeta, }); if (parsedImages.length === 0) { - if (collectedImages.length > 0) { - break; - } + if (collectedImages.length > 0 && request.agentImageMode !== "auto") break; throw new Error("图片生成失败"); } - collectedImages.push(...parsedImages.slice(0, batchImageCount)); + collectedImages.push(...parsedImages.slice(0, batchImageCount).map(image => request.agentImageMode ? { ...image, modelRef: selectedModel.model.id } : image)); + if (request.agentImageMode === "auto" && parsedImages.length < batchImageCount) throw new Error("图片生成失败:模型返回数量不足"); } if (collectedImages.length === 0) { @@ -5634,7 +5647,7 @@ export async function generateCanvasImage( ? request.internalRetryVariantIndex + 1 : 0; const retryVariant = retryVariants[nextRetryVariantIndex]; - if (retryVariant) { + if (retryVariant && !request.agentImageMode) { const { debugMeta: retryVariantDebugMeta, label: retryVariantLabel, @@ -5687,6 +5700,9 @@ export async function generateCanvasImage( selectedModelId: selectedModel.model.modelId, error: caughtError instanceof Error ? caughtError.message : String(caughtError), }); + } else if (request.agentImageMode === "auto") { + const failure = agentImageFailurePolicy(caughtError); + if (failure.pause) await agentImageCircuitStore.fail(selectedModel, failure.pause); } else { await recordAiModelHealthFailure({ model: selectedModel.model, @@ -5719,22 +5735,21 @@ export async function generateCanvasImage( ? request.internalFailoverTriedModelRefs : []), selectedModel.model.id, - selectedModel.model.modelId, + ...(request.agentImageMode === "auto" ? [] : [selectedModel.model.modelId]), ].filter(Boolean)) ); const allowFailover = request.allowFailover !== false; - const shouldFailover = - shouldFailoverAiModelError(caughtError) || - (!scaleAdjustRejected && - Boolean(debugMeta?.detailFixedModelApplied) && - Boolean(caughtError)); + const shouldFailover = request.agentImageMode === "auto" + ? agentImageFailurePolicy(caughtError).retry + : shouldFailoverAiModelError(caughtError) || + (!scaleAdjustRejected && Boolean(debugMeta?.detailFixedModelApplied) && Boolean(caughtError)); const failureClassification = classifyAiModelHealthFailure(caughtError); const failedProviderTypes = Array.from( new Set([ ...(Array.isArray(request.internalFailoverTriedProviderTypes) ? request.internalFailoverTriedProviderTypes : []), - ...(shouldFailover && + ...(request.agentImageMode !== "auto" && shouldFailover && failureClassification.reason !== "non_provider_failure" && failureClassification.reason !== "user_cancelled" ? [provider.type] @@ -5769,8 +5784,9 @@ export async function generateCanvasImage( ...debugMeta, }); - return await generateCanvasImage({ + const fallbackResult = await generateCanvasImage({ ...request, + ...(request.agentImageMode === "auto" ? { imageCount: Math.max(1, targetImageCount - collectedImages.length) } : {}), model: fallbackModel.model.id, internalFailoverTriedModelRefs: failedModelRefs, internalFailoverTriedProviderTypes: failedProviderTypes, @@ -5779,9 +5795,19 @@ export async function generateCanvasImage( failoverFromModelId: selectedModel.model.modelId, failoverFromModelRef: selectedModel.model.id, }, + }).catch(error => { + if (request.agentImageMode === "auto" && collectedImages.length) return { images: [], model: selectedModel.model.displayName, modelRef: selectedModel.model.id, prompt: resultPrompt }; + throw error; }); + return request.agentImageMode === "auto" + ? { ...fallbackResult, images: [...collectedImages, ...fallbackResult.images].slice(0, targetImageCount) } + : fallbackResult; } + if (request.agentImageMode && collectedImages.length) return { + images: collectedImages, model: selectedModel.model.displayName, modelRef: selectedModel.model.id, prompt: resultPrompt, + }; + if (scaleAdjustRejection?.images?.length) { console.warn("[Canvas Generate Image] Rejecting scale adjust candidate because no effective edit was detected:", { debugLabel, diff --git a/playwright.canvas-agent.config.ts b/playwright.canvas-agent.config.ts index 607a5bb3e..b7accc168 100644 --- a/playwright.canvas-agent.config.ts +++ b/playwright.canvas-agent.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./tests/routes", - testMatch: /(?:canvas-agent-runtime(?:-store|-review|-video|-image-preferences|-progress)?|canvas-agent-project-tools|agent-capability-access)\.test\.tsx?/, + testMatch: /(?:canvas-agent-runtime(?:-store|-review|-video|-image-preferences|-image-failover|-progress)?|canvas-agent-project-tools|agent-capability-access)\.test\.tsx?/, fullyParallel: true, retries: 0, workers: 2, diff --git a/tests/helpers/agent-image-provider-harness.ts b/tests/helpers/agent-image-provider-harness.ts new file mode 100644 index 000000000..85adddfca --- /dev/null +++ b/tests/helpers/agent-image-provider-harness.ts @@ -0,0 +1,26 @@ +import { serverLoader } from "./canvas-download-server-loader"; + +export function agentImageProviderHarness(options: { generate?: (id: string, count: number, call: number) => string; models?: any[] } = {}) { + const calls: any[] = [], paused = new Set(); + const models = options.models || ["primary", "backup"].map((id, i) => ({ model: { id, modelId: id, displayName: id, isEnabled: true, supportsImageGen: true, supportsVision: true, isDefault: !i, imageGenConfig: { maxImages: 12 }, capabilities: { kind: "image" } }, provider: { id: "same-provider", type: "openai-image", apiKey: "synthetic", isEnabled: true } })); + const mocks: Record = { + "@/lib/db/queries": { getEnabledAiModelsWithProvider: async () => models }, + "@/lib/ai/rmbg/service": {}, + "@/lib/net/url-access": { filterReachableRemoteUrls: async (urls: string[]) => ({ kept: urls, dropped: [] }) }, + "@/lib/canvas/image-concurrency": { acquireCanvasImageModelSlot: async () => ({ waitMs: 0, maxConcurrent: 999, release: async () => {} }), normalizeCanvasImageModelKey: (v: string) => v, recordCanvasImageModelExecutionDuration() {} }, + "@/lib/ai/runtime/model-health": { recordAiModelHealthSuccess: async () => {}, recordAiModelHealthFailure: async () => {}, classifyAiModelHealthFailure: () => ({ reason: "provider_error" }), shouldFailoverAiModelError: () => false }, + "@/lib/ai/runtime/agent-model-circuit-store": { agentImageCircuitStore: { isPaused: async (e: any) => paused.has(e.model.id), fail: async (e: any) => { paused.add(e.model.id); } } }, + "@/lib/ai/image-model-pricing": { getModelGenerationCreditCost: ({ imageGenConfig }: any) => imageGenConfig?.creditCost || 0 }, + "@/lib/storage/service": {}, + }; + for (const name of ["gemini-image", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "vip98k", "stepfun-image", "midjourney", "runway", "krea", "volcano-vision"]) mocks[`@/lib/ai/providers/${name}`] = {}; + mocks["@/lib/ai/providers/openai-image"] = { createOpenAIImage: () => ({ chat: (id: string) => ({ doGenerate: async (params: any) => { + calls.push({ id, ...params }); + const text = options.generate?.(id, params.imageParams.imageCount, calls.length) || `![image](https://fixture.invalid/${id}-${calls.length}.png)`; + return { text }; + } }) }) }; + const generate = serverLoader(mocks)("lib/canvas/generate-image.ts").generateCanvasImage; + const request = { prompt: "a cat", model: "primary", imageCount: 1, imageSize: "2K", aspectRatio: "3:4", agentImageMode: "auto", allowFailover: true, referenceImages: ["https://fixture.invalid/source.png"], negativePrompt: "no text", providerImageParams: { seed: 0, watermark: false } }; + return { generate, request, calls, paused }; +} + diff --git a/tests/routes/canvas-agent-runtime-image-failover.test.ts b/tests/routes/canvas-agent-runtime-image-failover.test.ts new file mode 100644 index 000000000..b53ac7683 --- /dev/null +++ b/tests/routes/canvas-agent-runtime-image-failover.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "@playwright/test"; +import { agentImageProviderHarness as harness } from "../helpers/agent-image-provider-harness"; +import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover"; + + +test("automatic image generation switches within the same provider and skips a paused model on the next task", async () => { + const h = harness({ generate: id => { if (id === "primary") throw Object.assign(new Error("channel unavailable"), { status: 503 }); return "![image](https://fixture.invalid/ok.png)"; } }); + const result = await h.generate(h.request); + expect(result.modelRef).toBe("backup"); expect(h.calls.map(c => c.id)).toEqual(["primary", "backup"]); + expect([...h.paused]).toEqual(["primary"]); + expect(h.calls[1].imageParams).toMatchObject({ imageSize: "2K", aspectRatio: "3:4", negativePrompt: "no text", seed: 0, watermark: false }); + await h.generate(h.request); + expect(h.calls.map(c => c.id)).toEqual(["primary", "backup", "backup"]); +}); + +test("a pinned model failure never calls any backup or silently substitutes a missing model", async () => { + const h = harness({ generate: () => { throw Object.assign(new Error("invalid authentication"), { status: 401 }); } }); + await expect(h.generate({ ...h.request, agentImageMode: "pinned", allowFailover: true })).rejects.toThrow(); + expect(h.calls.map(c => c.id)).toEqual(["primary"]); + await expect(h.generate({ ...h.request, agentImageMode: "pinned", model: "missing" })).rejects.toThrow("指定的图片模型不可用"); + expect(h.calls).toHaveLength(1); +}); + +test("a partly successful batch keeps its delivered images and only generates the missing count", async () => { + const h = harness({ generate: (id, _n, call) => { if (id === "primary" && call === 2) throw new Error("VIP98K task failed"); return `![image](https://fixture.invalid/${id}-${call}.png)`; } }); + const result = await h.generate({ ...h.request, imageCount: 3 }); + expect(h.calls.map(c => c.id)).toEqual(["primary", "primary", "backup", "backup"]); + expect(result.images).toHaveLength(3); + expect(result.images.map((i: any) => i.modelRef)).toEqual(["primary", "backup", "backup"]); + expect(result.images[0].originalUrl).toContain("primary-1"); +}); + +test("if every backup fails, previously delivered images are retained", async () => { + const h = harness({ generate: (_id, _n, call) => { if (call > 1) throw new Error("VIP98K task failed"); return "![image](https://fixture.invalid/keep.png)"; } }); + const result = await h.generate({ ...h.request, imageCount: 3 }); + expect(result.images).toHaveLength(1); expect(result.images[0].originalUrl).toContain("keep.png"); +}); + +test("a submitted task with an unknown outcome is not submitted again through a backup", async () => { + const h = harness({ generate: () => { throw new Error("VIP98K task timed out; it may still be running"); } }); + await expect(h.generate(h.request)).rejects.toThrow(); + expect(h.calls.map(c => c.id)).toEqual(["primary"]); expect(h.paused.size).toBe(0); + expect(agentImageFailurePolicy(Object.assign(new Error("timeout"), { providerOutcomeUnknown: true })).retry).toBe(false); + expect(agentImageFailurePolicy(new Error("VIP98K task failed: timeout")).retry).toBe(true); +}); + +test("content restrictions and user input problems cannot be bypassed by switching services", () => { + for (const error of [new Error("content policy rejection"), Object.assign(new Error("bad input"), { status: 400 }), new Error("用户积分不足")]) expect(agentImageFailurePolicy(error)).toEqual({ retry: false, pause: null }); +}); + + +test("automatic fallback respects requested dimensions, disabled candidates and the user's available credit", async () => { + const models = [ + { id: "primary", creditCost: 1, supportedImageSizes: ["2K"] }, + { id: "wrong-size", creditCost: 1, supportedImageSizes: ["1K"] }, + { id: "too-expensive", creditCost: 10, supportedImageSizes: ["2K"] }, + { id: "disabled-health", creditCost: 1, supportedImageSizes: ["2K"], healthStatus: "auto_disabled" }, + { id: "backup", creditCost: 2, supportedImageSizes: ["2K"] }, + ].map(({ id, healthStatus, ...config }) => ({ model: { id, modelId: id, displayName: id, isEnabled: true, healthStatus, supportsImageGen: true, supportsVision: true, imageGenConfig: config }, provider: { type: "openai-image", isEnabled: true } })); + const h = harness({ models, generate: id => { if (id === "primary") throw new Error("VIP98K task failed"); return "![image](https://fixture.invalid/ok.png)"; } }); + const result = await h.generate({ ...h.request, agentImageMaxCreditCost: 3 }); + expect(result.modelRef).toBe("backup"); expect(h.calls.map(c => c.id)).toEqual(["primary", "backup"]); +}); diff --git a/tests/routes/canvas-agent-runtime-image-preferences.test.ts b/tests/routes/canvas-agent-runtime-image-preferences.test.ts index 1c4f260aa..3e1296156 100644 --- a/tests/routes/canvas-agent-runtime-image-preferences.test.ts +++ b/tests/routes/canvas-agent-runtime-image-preferences.test.ts @@ -7,15 +7,16 @@ const preferences = { parameters: { imageCount: 3, aspectRatio: "3:4", imageSize: "4K", negativePrompt: "不要文字", quality: "high" as const, size: "1536*2048", promptExtend: false, watermark: false, seed: 0 }, }; -function harness() { +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: value.model, images: [{ originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" }] }; } }, - "@/lib/ai/runtime/generated-image-persistence": { saveGeneratedImages: async () => {} }, + "@/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": { @@ -31,7 +32,7 @@ function harness() { request: new Request("https://test.example/api/canvas/agent-chat"), model: {}, supportsVision: true, preferredModelId: "system-default", imagePreferences, emit() {}, }); - return { build, generated, operations }; + 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); @@ -73,3 +74,27 @@ test("planning uses the image count for generation tasks but does not turn analy 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"); +});