diff --git a/src/app/api/generate/__tests__/route.test.ts b/src/app/api/generate/__tests__/route.test.ts index 8e1fe0c5..7e84d60c 100644 --- a/src/app/api/generate/__tests__/route.test.ts +++ b/src/app/api/generate/__tests__/route.test.ts @@ -57,6 +57,7 @@ function createMockPostRequest( Authorization: "Bearer login-token", ...(headers || {}), }), + nextUrl: new URL("http://localhost:3000/api/generate"), } as unknown as NextRequest; } @@ -109,6 +110,23 @@ describe("/api/generate route", () => { process.env = originalEnv; }); + it("returns a readable error when the JSON request body was truncated", async () => { + const request = { + json: vi.fn().mockRejectedValue( + new SyntaxError("Unterminated string in JSON at position 10481152") + ), + headers: new Headers({ Authorization: "Bearer login-token" }), + nextUrl: new URL("http://localhost:3000/api/generate"), + } as unknown as NextRequest; + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain("生成请求过大或不完整"); + expect(data.error).not.toContain("Unterminated string"); + }); + describe("getConnectionParams", () => { it("should prefer provider API key headers over env vars", () => { process.env.GEMINI_API_KEY = "env-gemini-key"; diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 55dfe417..6d012e73 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -22,6 +22,7 @@ import { generateWithNewApiWG } from "./providers/newapiwg"; import { getRequestToken, requireConfiguredValue, requireLogin } from "../_auth"; import { ApiError, apiErrorResponse } from "../_errors"; import { resolveUserGatewayApiKey } from "../_gatewayApiKey"; +import { resolveTemporaryImageUrlsInArray } from "@/lib/images/temporaryUrls"; // Re-export for backward compatibility (test file imports from route) export const clearFalInputMappingCache = _clearFalInputMappingCache; @@ -170,12 +171,29 @@ function mergeImageGenerationParameters( return Object.keys(merged).length > 0 ? merged : undefined; } +async function readGenerateRequest(request: NextRequest): Promise { + try { + return await request.json(); + } catch (error) { + const message = error instanceof Error ? error.message : ""; + if ( + error instanceof SyntaxError || + /json|unterminated string|unexpected end/i.test(message) + ) { + throw ApiError.badRequest( + "生成请求过大或不完整:参考图数据可能在发送时被截断。请重试,或压缩参考图后再生成。" + ); + } + throw error; + } +} + export async function POST(request: NextRequest) { const requestId = Math.random().toString(36).substring(7); console.log(`\n[API:${requestId}] ========== NEW GENERATE REQUEST ==========`); try { - const body: MultiProviderGenerateRequest = await request.json(); + const body = await readGenerateRequest(request); const { images, prompt, @@ -189,6 +207,7 @@ export async function POST(request: NextRequest) { dynamicInputs, mediaType, } = body; + const requestImages = resolveTemporaryImageUrlsInArray(images); // Prompt is required unless: // - Provided via dynamicInputs @@ -199,7 +218,7 @@ export async function POST(request: NextRequest) { ? dynamicInputs.prompt : Array.isArray(dynamicInputs.prompt) && dynamicInputs.prompt.length > 0 )); - const hasImages = (images && images.length > 0); + const hasImages = requestImages.length > 0; const hasMediaInputs = dynamicInputs && Object.keys(dynamicInputs).some(key => key.includes('frame') || key.includes('image') || key.includes('video') || key.includes('audio') ); @@ -232,7 +251,7 @@ export async function POST(request: NextRequest) { description: null, }, prompt: prompt || "", - images: images ? [...images] : [], + images: requestImages, parameters: mergeImageGenerationParameters(parameters, aspectRatio, resolution), dynamicInputs, }; @@ -267,7 +286,7 @@ export async function POST(request: NextRequest) { ); // Keep Data URIs as-is since localhost URLs won't work (provider can't reach them) - const processedImages: string[] = images ? [...images] : []; + const processedImages = requestImages; // Process dynamicInputs: filter empty values, keep Data URIs let processedDynamicInputs: Record | undefined = undefined; @@ -337,7 +356,7 @@ export async function POST(request: NextRequest) { } // Pass images as-is; generateWithFalQueue uploads base64 to CDN internally - const processedImages: string[] = images ? [...images] : []; + const processedImages = requestImages; // Process dynamicInputs: filter empty values let processedDynamicInputs: Record | undefined = undefined; @@ -407,7 +426,7 @@ export async function POST(request: NextRequest) { ); // Process images - Kie requires URLs, we'll upload base64 images in generateWithKie - const processedImages: string[] = images ? [...images] : []; + const processedImages = requestImages; // Process dynamicInputs: filter empty values let processedDynamicInputs: Record | undefined = undefined; @@ -475,7 +494,7 @@ export async function POST(request: NextRequest) { ); // Keep Data URIs as-is since localhost URLs won't work - const processedImages: string[] = images ? [...images] : []; + const processedImages = requestImages; // Process dynamicInputs: filter empty values let processedDynamicInputs: Record | undefined = undefined; @@ -576,7 +595,7 @@ export async function POST(request: NextRequest) { geminiApiKey, selectedModel.modelId, resolvedPrompt || "", - images || [], + requestImages, veoParams, ); @@ -602,7 +621,7 @@ export async function POST(request: NextRequest) { requestId, geminiApiKey, resolvedPrompt, - images || [], + requestImages, geminiModel, aspectRatio, resolution, diff --git a/src/app/api/images/upload/route.ts b/src/app/api/images/upload/route.ts new file mode 100644 index 00000000..6e8eb078 --- /dev/null +++ b/src/app/api/images/upload/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { deleteImages, storeImage } from "@/lib/images"; + +function extensionFromMime(mimeType: string): string { + if (mimeType === "image/jpeg") return "jpg"; + if (mimeType === "image/webp") return "webp"; + if (mimeType === "image/gif") return "gif"; + return "png"; +} + +export async function POST(request: NextRequest): Promise { + const formData = await request.formData(); + const file = formData.get("file"); + + if (!(file instanceof File)) { + return NextResponse.json( + { success: false, error: "Missing image file" }, + { status: 400 } + ); + } + + if (!file.type.startsWith("image/")) { + return NextResponse.json( + { success: false, error: "Only image uploads are supported" }, + { status: 400 } + ); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + const dataUrl = `data:${file.type};base64,${buffer.toString("base64")}`; + const id = storeImage(dataUrl); + const url = `${request.nextUrl.origin}/api/images/${id}`; + + return NextResponse.json({ + success: true, + id, + url, + filename: file.name || `image.${extensionFromMime(file.type)}`, + }); +} + +export async function DELETE(request: NextRequest): Promise { + const body = await request.json().catch(() => ({})); + const ids = Array.isArray(body.ids) + ? body.ids.filter((id: unknown): id is string => typeof id === "string" && id.length > 0) + : []; + + deleteImages(ids); + return NextResponse.json({ success: true }); +} diff --git a/src/lib/images/temporaryUrls.ts b/src/lib/images/temporaryUrls.ts new file mode 100644 index 00000000..06d54ffc --- /dev/null +++ b/src/lib/images/temporaryUrls.ts @@ -0,0 +1,36 @@ +import { getImage } from "./store"; + +function parseTemporaryImageId(url: string): string | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + + // Do not fetch arbitrary URLs here. This only maps the random local image id + // from /api/images/{id} back to the in-memory store. + if (!parsed.pathname.startsWith("/api/images/")) { + return null; + } + + const id = decodeURIComponent(parsed.pathname.slice("/api/images/".length)); + return id || null; +} + +export function resolveTemporaryImageUrlToDataUrl(url: string): string | null { + const id = parseTemporaryImageId(url); + if (!id) return null; + + const image = getImage(id); + if (!image) return null; + return `data:${image.mimeType};base64,${Buffer.from(image.data).toString("base64")}`; +} + +export function resolveTemporaryImageUrlsInArray( + images: string[] | undefined +): string[] { + return (images || []).map((image) => + resolveTemporaryImageUrlToDataUrl(image) ?? image + ); +} diff --git a/src/store/execution/__tests__/nanoBananaExecutor.test.ts b/src/store/execution/__tests__/nanoBananaExecutor.test.ts index 00e1df8c..28276d7e 100644 --- a/src/store/execution/__tests__/nanoBananaExecutor.test.ts +++ b/src/store/execution/__tests__/nanoBananaExecutor.test.ts @@ -100,6 +100,28 @@ beforeEach(() => { mockWriteBrowserGenerationFile.mockResolvedValue({ filePath: "browserfs://root/project/generations/img.png" }); }); +async function withProviderMode(mode: "multi" | "popi", run: () => Promise): Promise { + const originalNextPublicMode = process.env.NEXT_PUBLIC_PROVIDER_MODE; + const originalProviderMode = process.env.PROVIDER_MODE; + + process.env.NEXT_PUBLIC_PROVIDER_MODE = mode; + process.env.PROVIDER_MODE = mode; + try { + return await run(); + } finally { + if (originalNextPublicMode === undefined) { + delete process.env.NEXT_PUBLIC_PROVIDER_MODE; + } else { + process.env.NEXT_PUBLIC_PROVIDER_MODE = originalNextPublicMode; + } + if (originalProviderMode === undefined) { + delete process.env.PROVIDER_MODE; + } else { + process.env.PROVIDER_MODE = originalProviderMode; + } + } +} + describe("executeNanoBanana", () => { it("should throw when no text input is provided", async () => { const node = makeNode(); @@ -619,16 +641,18 @@ describe("executeNanoBanana", () => { }); it("should track cost for gemini provider", async () => { - const node = makeNode(); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result" }), - }); + await withProviderMode("multi", async () => { + const node = makeNode(); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result" }), + }); - const ctx = makeCtx(node); - await executeNanoBanana(ctx); + const ctx = makeCtx(node); + await executeNanoBanana(ctx); - expect(ctx.addIncurredCost).toHaveBeenCalledWith(0.05); + expect(ctx.addIncurredCost).toHaveBeenCalledWith(0.05); + }); }); it("should track cost for fal provider", async () => { @@ -728,6 +752,60 @@ describe("executeNanoBanana", () => { expect(body.images).toEqual(["data:image/png;base64,img1"]); }); + it("uploads large NewApiWG reference images before sending the generate request", async () => { + const largeImage = `data:image/png;base64,${"a".repeat(400_000)}`; + const node = makeNode({ + selectedModel: { + provider: "newapiwg", + modelId: "apiyi_nano_banana_2", + displayName: "apiyi_nano_banana_2", + capabilities: ["text-to-image", "image-to-image"], + }, + }); + const ctx = makeCtx(node, { + getConnectedInputs: vi.fn().mockReturnValue({ + images: [largeImage], + videos: [], + audio: [], + text: "with large image", + dynamicInputs: {}, + easeCurve: null, + }), + }); + + mockFetch.mockImplementation(async (url: string) => { + if (url === "/api/images/upload") { + return { + ok: true, + json: () => Promise.resolve({ + success: true, + id: "tmp-image-1", + url: "http://localhost:3000/api/images/tmp-image-1", + }), + }; + } + if (url === "/api/generate") { + return { + ok: true, + json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result" }), + }; + } + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + await executeNanoBanana(ctx); + + const generateCall = mockFetch.mock.calls.find(([url]) => url === "/api/generate"); + const cleanupCall = mockFetch.mock.calls.find( + ([url, init]) => url === "/api/images/upload" && init?.method === "DELETE" + ); + const body = JSON.parse(generateCall?.[1].body); + + expect(body.images).toEqual(["http://localhost:3000/api/images/tmp-image-1"]); + expect(body.images[0]).not.toContain("base64"); + expect(cleanupCall?.[1].body).toBe(JSON.stringify({ ids: ["tmp-image-1"] })); + }); + it("should fall back to stored inputs in regenerate mode", async () => { const node = makeNode({ inputImages: ["stored-img.png"], @@ -821,51 +899,53 @@ describe("executeNanoBanana", () => { }); it("falls back on primary failure and stamps metadata", async () => { - const node = makeNode({ - fallbackModel: { - provider: "replicate", - modelId: "flux-dev", - displayName: "Flux Dev", - }, - }); - - // Primary fails, fallback succeeds - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ success: false, error: "Primary boom" }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ success: true, image: "data:image/png;base64,fallback" }), + await withProviderMode("multi", async () => { + const node = makeNode({ + fallbackModel: { + provider: "replicate", + modelId: "flux-dev", + displayName: "Flux Dev", + }, }); - const ctx = makeCtx(node); - await executeNanoBanana(ctx); - - expect(mockFetch).toHaveBeenCalledTimes(2); - - // Second fetch should carry the fallback model - const secondBody = JSON.parse(mockFetch.mock.calls[1][1].body); - expect(secondBody.selectedModel.modelId).toBe("flux-dev"); - - // Metadata stamp should be present in the final updateNodeData call - const calls = (ctx.updateNodeData as ReturnType).mock.calls; - const stampCall = calls.find( - (c: unknown[]) => (c[1] as Record).__usedFallback === true - ); - expect(stampCall).toBeDefined(); - expect((stampCall![1] as Record).__fallbackModelUsed).toBe("Flux Dev"); - expect((stampCall![1] as Record).__primaryError).toBe("Primary boom"); + // Primary fails, fallback succeeds + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: false, error: "Primary boom" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, image: "data:image/png;base64,fallback" }), + }); - const completeCall = calls.find( - (c: unknown[]) => (c[1] as Record).status === "complete" - ); - expect(completeCall?.[1]).toMatchObject({ - lastUsedModel: { - provider: "replicate", - modelId: "flux-dev", - }, + const ctx = makeCtx(node); + await executeNanoBanana(ctx); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Second fetch should carry the fallback model + const secondBody = JSON.parse(mockFetch.mock.calls[1][1].body); + expect(secondBody.selectedModel.modelId).toBe("flux-dev"); + + // Metadata stamp should be present in the final updateNodeData call + const calls = (ctx.updateNodeData as ReturnType).mock.calls; + const stampCall = calls.find( + (c: unknown[]) => (c[1] as Record).__usedFallback === true + ); + expect(stampCall).toBeDefined(); + expect((stampCall![1] as Record).__fallbackModelUsed).toBe("Flux Dev"); + expect((stampCall![1] as Record).__primaryError).toBe("Primary boom"); + + const completeCall = calls.find( + (c: unknown[]) => (c[1] as Record).status === "complete" + ); + expect(completeCall?.[1]).toMatchObject({ + lastUsedModel: { + provider: "replicate", + modelId: "flux-dev", + }, + }); }); }); }); diff --git a/src/store/execution/nanoBananaExecutor.ts b/src/store/execution/nanoBananaExecutor.ts index ce2661a5..afec84be 100644 --- a/src/store/execution/nanoBananaExecutor.ts +++ b/src/store/execution/nanoBananaExecutor.ts @@ -27,6 +27,10 @@ import { writeBrowserGenerationFile, } from "@/utils/browserFileSystem"; import { isHttpMediaUrl } from "@/utils/mediaResolver"; +import { + cleanupTemporaryImages, + uploadTemporaryImagesForGeneration, +} from "@/utils/temporaryImageUpload"; export interface NanoBananaOptions { /** When true, falls back to stored inputImages/inputPrompt if no connections provide them. */ @@ -136,28 +140,35 @@ export async function executeNanoBanana( const sanitizedDynamicInputs = { ...dynamicInputs }; delete sanitizedDynamicInputs.prompt; - const requestPayload = { - images, - prompt: finalPrompt, - aspectRatio: (parametersOverride?.aspectRatio as string) ?? nodeData.aspectRatio, - resolution: (parametersOverride?.resolution as string) ?? nodeData.resolution, - model: nodeData.model, - useGoogleSearch: (parametersOverride?.useGoogleSearch as boolean) ?? nodeData.useGoogleSearch, - useImageSearch: (parametersOverride?.useImageSearch as boolean) ?? nodeData.useImageSearch, - selectedModel: modelToUse, - parameters: parametersOverride ?? nodeData.parameters, - dynamicInputs: sanitizedDynamicInputs, - }; - - // Final guard: assert that prompt is a string before sending to API - if (typeof requestPayload.prompt !== 'string') { - const errorMsg = `Internal error: prompt is ${typeof requestPayload.prompt}, expected string`; - console.error('[nanoBanana]', errorMsg); - updateNodeData(node.id, { status: 'error', error: errorMsg }); - throw new Error(errorMsg); - } + let temporaryImageIds: string[] = []; try { + const preparedImages = provider === "newapiwg" + ? await uploadTemporaryImagesForGeneration(images) + : { images, cleanupIds: [] }; + temporaryImageIds = preparedImages.cleanupIds; + + const requestPayload = { + images: preparedImages.images, + prompt: finalPrompt, + aspectRatio: (parametersOverride?.aspectRatio as string) ?? nodeData.aspectRatio, + resolution: (parametersOverride?.resolution as string) ?? nodeData.resolution, + model: nodeData.model, + useGoogleSearch: (parametersOverride?.useGoogleSearch as boolean) ?? nodeData.useGoogleSearch, + useImageSearch: (parametersOverride?.useImageSearch as boolean) ?? nodeData.useImageSearch, + selectedModel: modelToUse, + parameters: parametersOverride ?? nodeData.parameters, + dynamicInputs: sanitizedDynamicInputs, + }; + + // Final guard: assert that prompt is a string before sending to API + if (typeof requestPayload.prompt !== 'string') { + const errorMsg = `Internal error: prompt is ${typeof requestPayload.prompt}, expected string`; + console.error('[nanoBanana]', errorMsg); + updateNodeData(node.id, { status: 'error', error: errorMsg }); + throw new Error(errorMsg); + } + const requestedImageCount = nodeData.imageCount === 2 || nodeData.imageCount === 4 ? nodeData.imageCount : 1; @@ -418,6 +429,8 @@ export async function executeNanoBanana( error: errorMessage, }); throw new Error(errorMessage); + } finally { + await cleanupTemporaryImages(temporaryImageIds); } }; diff --git a/src/utils/temporaryImageUpload.ts b/src/utils/temporaryImageUpload.ts new file mode 100644 index 00000000..5b133660 --- /dev/null +++ b/src/utils/temporaryImageUpload.ts @@ -0,0 +1,95 @@ +const TEMP_IMAGE_UPLOAD_THRESHOLD_BYTES = 256 * 1024; + +type UploadedTemporaryImages = { + images: string[]; + cleanupIds: string[]; +}; + +function dataUrlMimeType(dataUrl: string): string | null { + return dataUrl.match(/^data:([^;]+);base64,/)?.[1] ?? null; +} + +function approximateDataUrlBytes(dataUrl: string): number { + const base64 = dataUrl.match(/^data:[^;]+;base64,(.+)$/)?.[1]; + if (!base64) return 0; + const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; + return Math.max(0, Math.floor((base64.length * 3) / 4) - padding); +} + +function dataUrlToBlob(dataUrl: string): Blob { + const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/); + if (!match) throw new Error("Invalid image data URL"); + + const [, mimeType, base64] = match; + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return new Blob([bytes], { type: mimeType }); +} + +function shouldUploadTemporaryImage(image: string): boolean { + return image.startsWith("data:image/") && + approximateDataUrlBytes(image) > TEMP_IMAGE_UPLOAD_THRESHOLD_BYTES; +} + +function extensionFromMime(mimeType: string | null): string { + if (mimeType === "image/jpeg") return "jpg"; + if (mimeType === "image/webp") return "webp"; + if (mimeType === "image/gif") return "gif"; + return "png"; +} + +async function uploadTemporaryImage(image: string): Promise<{ url: string; id: string }> { + const mimeType = dataUrlMimeType(image); + const blob = dataUrlToBlob(image); + const formData = new FormData(); + formData.append("file", blob, `reference.${extensionFromMime(mimeType)}`); + + const response = await fetch("/api/images/upload", { + method: "POST", + body: formData, + }); + + const result = await response.json().catch(() => null) as + | { success?: boolean; url?: string; id?: string; error?: string } + | null; + + if (!response.ok || !result?.success || !result.id) { + throw new Error(result?.error || "Failed to prepare reference image"); + } + + const sameOriginUrl = typeof window !== "undefined" + ? `${window.location.origin}/api/images/${encodeURIComponent(result.id)}` + : result.url; + if (!sameOriginUrl) throw new Error("Failed to prepare reference image"); + + return { url: sameOriginUrl, id: result.id }; +} + +export async function uploadTemporaryImagesForGeneration( + images: string[] +): Promise { + const cleanupIds: string[] = []; + const preparedImages = await Promise.all( + images.map(async (image) => { + if (!shouldUploadTemporaryImage(image)) return image; + const uploaded = await uploadTemporaryImage(image); + cleanupIds.push(uploaded.id); + return uploaded.url; + }) + ); + + return { images: preparedImages, cleanupIds }; +} + +export async function cleanupTemporaryImages(ids: string[]): Promise { + if (ids.length === 0) return; + + await fetch("/api/images/upload", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }).catch(() => undefined); +}