diff --git a/src/app/api/generate/providers/__tests__/newapiwg.test.ts b/src/app/api/generate/providers/__tests__/newapiwg.test.ts deleted file mode 100644 index 9fe6522e..00000000 --- a/src/app/api/generate/providers/__tests__/newapiwg.test.ts +++ /dev/null @@ -1,1184 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - fetchNewApiWGModels, - generateWithNewApiWG, - inferNewApiWGCapabilities, - normalizeNewApiWGError, -} from "../newapiwg"; -import type { GenerationInput } from "@/lib/providers/types"; -import { deleteImage, storeImage } from "@/lib/images/store"; - -let capturedBody: Record | null = null; - -function makeInput(overrides: Partial = {}): GenerationInput { - return { - model: { - id: "doubao-seedance-2-0-260128", - name: "Seedance", - description: null, - provider: "newapiwg", - capabilities: ["text-to-video", "image-to-video", "audio-to-video"], - }, - prompt: "a dancing robot", - images: [], - parameters: {}, - ...overrides, - }; -} - -describe("NewApiWG model capability inference", () => { - it("classifies real NewAPI gateway model ids without treating chat models as image generators", () => { - expect(inferNewApiWGCapabilities({ - id: "apiyi_sora_image", - owned_by: "custom", - supported_endpoint_types: ["openai"], - })).toEqual(["text-to-image"]); - - expect(inferNewApiWGCapabilities({ - id: "gpt-image-2-all", - owned_by: "openai", - supported_endpoint_types: ["openai"], - })).toEqual(["text-to-image", "image-to-image"]); - - expect(inferNewApiWGCapabilities({ - id: "apiyi_nano_banana_pro", - owned_by: "custom", - supported_endpoint_types: ["gemini", "openai"], - })).toEqual(["text-to-image", "image-to-image"]); - - expect(inferNewApiWGCapabilities({ - id: "gemini-3-pro-image-preview", - owned_by: "custom", - supported_endpoint_types: ["gemini", "openai"], - })).toEqual(["text-to-image", "image-to-image"]); - - expect(inferNewApiWGCapabilities({ - id: "doubao-seedance-2-0-260128", - owned_by: "doubao-video", - supported_endpoint_types: ["openai"], - })).toEqual(["text-to-video", "image-to-video"]); - - expect(inferNewApiWGCapabilities({ - id: "doubao-seed-2-0-pro-260215", - name: "Doubao Seed 2.0 Pro", - owned_by: "doubao", - supported_endpoint_types: ["openai"], - })).toEqual([]); - - expect(inferNewApiWGCapabilities({ - id: "doubao-1-5-vision-pro", - owned_by: "doubao", - supported_endpoint_types: ["openai"], - })).toEqual([]); - - expect(inferNewApiWGCapabilities({ - id: "speech-2.8-hd", - owned_by: "minimax", - supported_endpoint_types: ["openai"], - })).toEqual(["text-to-audio"]); - - expect(inferNewApiWGCapabilities({ - id: "kimi-k2.6", - owned_by: "moonshot", - supported_endpoint_types: ["openai"], - })).toEqual([]); - - expect(inferNewApiWGCapabilities({ - id: "popiart-custom-model", - owned_by: "popiart", - supported_endpoint_types: ["openai"], - })).toEqual([ - "text-to-image", - "image-to-image", - "text-to-video", - "image-to-video", - "text-to-3d", - "image-to-3d", - "text-to-audio", - "audio-to-video", - ]); - }); - - it("omits pure chat models from generation model discovery", async () => { - vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ - data: [ - { id: "kimi-k2.6", owned_by: "moonshot", supported_endpoint_types: ["openai"] }, - { id: "doubao-seed-2-0-pro-260215", owned_by: "doubao", supported_endpoint_types: ["openai"] }, - { id: "doubao-seedance-2-0-260128", owned_by: "doubao-video", supported_endpoint_types: ["openai"] }, - { id: "apiyi_sora_image", owned_by: "custom", supported_endpoint_types: ["openai"] }, - ], - }), { status: 200 }))); - - const models = await fetchNewApiWGModels("test-key", "https://newapi.example/v1"); - - expect(models.map((model) => model.id)).toEqual([ - "doubao-seedance-2-0-260128", - "apiyi_sora_image", - ]); - }); - - it("maps NewApiWG model point cost to provider pricing", async () => { - vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ - data: [ - { - id: "wan-2.7", - name: "万相 2.7", - owned_by: "wan-video", - supported_endpoint_types: ["openai"], - points: 2, - }, - ], - }), { status: 200 }))); - - const models = await fetchNewApiWGModels("test-key", "https://newapi.example/v1"); - - expect(models[0].pricing).toEqual({ - type: "per-run", - amount: 2, - currency: "points", - }); - }); - - it("maps NewApiWG model point cost aliases to provider pricing", async () => { - vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ - data: [ - { - id: "apiyi_nano_banana_2", - name: "Nano Banana 2", - owned_by: "apiyi-image", - supported_endpoint_types: ["openai"], - metadata: { - pricing: { - point_cost: "1.5", - }, - }, - }, - ], - }), { status: 200 }))); - - const models = await fetchNewApiWGModels("test-key", "https://newapi.example/v1"); - - expect(models[0].pricing).toEqual({ - type: "per-run", - amount: 1.5, - currency: "points", - }); - }); - - it("merges NewApiWG billing catalog points into model point cost", async () => { - const fetchMock = vi.fn(async (url: string | URL | Request) => { - const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; - if (urlStr.includes("/api/billing_catalog")) { - return new Response(JSON.stringify({ - success: true, - data: { - page: 1, - page_size: 100, - total: 1, - items: [ - { - scene: "image", - model_name: "gpt-image-2-all", - base_points: 12, - variants: [ - { - variant_key: "default", - base_points: 12, - effective_points: 12, - }, - ], - }, - ], - }, - }), { status: 200 }); - } - if (urlStr.endsWith("/api/pricing")) { - return new Response(JSON.stringify({ - data: [ - { - model_name: "gpt-image-2-all", - quota_type: 1, - model_price: 0.028767123287671233, - supported_endpoint_types: ["image-generation", "openai"], - }, - ], - }), { status: 200 }); - } - - return new Response(JSON.stringify({ - data: [ - { - id: "gpt-image-2-all", - name: "gpt-image-2-all", - owned_by: "openai", - supported_endpoint_types: ["openai"], - points: 2, - }, - ], - }), { status: 200 }); - }); - vi.stubGlobal("fetch", fetchMock); - - const models = await fetchNewApiWGModels("test-key", "https://newapi.example/v1"); - - expect(models[0].pricing).toEqual({ - type: "per-run", - amount: 12, - currency: "points", - }); - expect(fetchMock.mock.calls.some(([url]) => { - const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; - return urlStr.endsWith("/api/pricing"); - })).toBe(false); - }); - - it("falls back to public NewApiWG billing catalog when model listing fails", async () => { - vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request) => { - const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; - if (urlStr.includes("/api/billing_catalog")) { - return new Response(JSON.stringify({ - success: true, - data: { - page: 1, - page_size: 100, - total: 1, - items: [ - { - scene: "image", - model_name: "gpt-image-2-all", - base_points: 12, - variants: [ - { - variant_key: "default", - base_points: 12, - effective_points: 12, - }, - ], - }, - ], - }, - }), { status: 200 }); - } - - return new Response(JSON.stringify({ error: { message: "invalid token" } }), { status: 401 }); - })); - - const models = await fetchNewApiWGModels("bad-key", "https://newapi.example/v1", "gpt-image-2-all"); - - expect(models).toHaveLength(1); - expect(models[0]).toMatchObject({ - id: "gpt-image-2-all", - pricing: { - type: "per-run", - amount: 12, - currency: "points", - }, - }); - }); - - it("falls back to legacy NewApiWG pricing when billing catalog is unavailable", async () => { - vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request) => { - const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; - if (urlStr.includes("/api/billing_catalog")) { - return new Response(JSON.stringify({ error: "not found" }), { status: 404 }); - } - if (urlStr.endsWith("/api/pricing")) { - return new Response(JSON.stringify({ - data: [ - { - model_name: "gpt-image-2-all", - quota_type: 1, - model_price: 0.028767123287671233, - supported_endpoint_types: ["image-generation", "openai"], - }, - ], - }), { status: 200 }); - } - - return new Response(JSON.stringify({ - data: [ - { - id: "gpt-image-2-all", - name: "gpt-image-2-all", - owned_by: "openai", - supported_endpoint_types: ["openai"], - }, - ], - }), { status: 200 }); - })); - - const models = await fetchNewApiWGModels("test-key", "https://newapi.example/v1"); - - expect(models[0]).toMatchObject({ - id: "gpt-image-2-all", - pricing: { - type: "per-run", - amount: 2, - currency: "points", - }, - }); - }); - - it("maps NewApiWG billing catalog dimension variants to pricing variants", async () => { - vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request) => { - const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; - if (urlStr.includes("/api/billing_catalog")) { - return new Response(JSON.stringify({ - success: true, - data: { - page: 1, - page_size: 100, - total: 1, - items: [ - { - scene: "image", - model_name: "apiyi_nano_banana_2", - base_points: 15, - variants: [ - { variant_key: "default", dimensions: {}, base_points: 15, effective_points: 15 }, - { variant_key: "size=1k", dimensions: { size: "1k" }, base_points: 15, effective_points: 15 }, - { variant_key: "size=4k", dimensions: { size: "4k" }, base_points: 24, effective_points: 24 }, - ], - }, - ], - }, - }), { status: 200 }); - } - - return new Response(JSON.stringify({ - data: [ - { - id: "apiyi_nano_banana_2", - name: "apiyi_nano_banana_2", - owned_by: "apiyi", - supported_endpoint_types: ["openai"], - }, - ], - }), { status: 200 }); - })); - - const models = await fetchNewApiWGModels("test-key", "https://newapi.example/v1"); - - expect(models[0].pricing).toMatchObject({ - type: "per-run", - amount: 15, - currency: "points", - variants: [ - { dimensions: { size: "1k" }, amount: 15 }, - { dimensions: { size: "4k" }, amount: 24 }, - ], - }); - }); - - it("forwards login token headers to the gateway", async () => { - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ - data: [{ id: "apiyi_sora_image", owned_by: "custom", supported_endpoint_types: ["openai"] }], - }), { status: 200 })); - vi.stubGlobal("fetch", fetchMock); - - await fetchNewApiWGModels("test-key", "https://newapi.example/v1", undefined, "login-token"); - - const init = fetchMock.mock.calls[0][1] as RequestInit; - const headers = new Headers(init.headers); - expect(headers.get("Authorization")).toBe("Bearer test-key"); - expect(headers.get("X-User-Authorization")).toBe("Bearer login-token"); - expect(headers.get("X-User-Token")).toBe("login-token"); - expect(headers.get("token")).toBe("login-token"); - }); -}); - -describe("NewApiWG generation payloads", () => { - beforeEach(() => { - capturedBody = null; - vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => { - const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; - - if (urlStr === "https://cdn.example.com/seedream.png") { - return new Response(Buffer.from("abc"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - } - - if (urlStr.includes("/v1beta/models/") && urlStr.includes(":generateContent") && init?.method === "POST") { - capturedBody = JSON.parse(init.body as string); - return new Response(JSON.stringify({ - candidates: [{ - content: { - parts: [{ - inlineData: { - mimeType: "image/png", - data: "gemini123", - }, - }], - }, - }], - }), { - status: 200, - }); - } - - if (urlStr.includes("/chat/completions") && init?.method === "POST") { - capturedBody = JSON.parse(init.body as string); - if (capturedBody?.model === "gpt-image-2-all") { - return new Response(JSON.stringify({ - id: "chatcmpl-test", - object: "chat.completion", - model: "gpt-image-2-all", - choices: [{ - message: { - role: "assistant", - content: "![image](https://r2cdn.copilotbase.com/r2cdn2/generated.png)\n\n", - }, - finish_reason: "stop", - }], - }), { - status: 200, - }); - } - return new Response(JSON.stringify({ - choices: [{ - message: { - content: [{ - type: "image_url", - image_url: { url: "data:image/png;base64,abc123" }, - }], - }, - }], - }), { - status: 200, - }); - } - - if (urlStr.includes("/video/generations") && init?.method === "POST") { - capturedBody = JSON.parse(init.body as string); - return new Response(JSON.stringify({ video_url: "https://cdn.example.com/video.mp4" }), { - status: 200, - }); - } - - if (urlStr.includes("/images/generations") && init?.method === "POST") { - capturedBody = JSON.parse(init.body as string); - return new Response(JSON.stringify({ - data: [{ url: "https://cdn.example.com/seedream.png" }], - }), { - status: 200, - }); - } - - return new Response("Not Found", { status: 404 }); - })); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); - vi.useRealTimers(); - }); - - it("passes connected media inputs with NewApiWG gateway field names", async () => { - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - images: ["data:image/png;base64,abc"], - parameters: { - duration: 5, - audios: ["https://cdn.example.com/legacy-param-audio.mp3"], - }, - dynamicInputs: { - image: "data:image/png;base64,abc", - voices: ["https://cdn.example.com/input-audio.mp3"], - reference_audio_urls: ["https://cdn.example.com/legacy-audio.mp3"], - }, - })); - - expect(result.success).toBe(true); - expect(capturedBody).not.toBeNull(); - expect(capturedBody!.prompt).toBe("a dancing robot"); - expect(capturedBody!.images).toEqual(["data:image/png;base64,abc"]); - expect(capturedBody!.voices).toEqual(["https://cdn.example.com/input-audio.mp3"]); - expect(capturedBody!.image).toBeUndefined(); - expect(capturedBody!.audios).toBeUndefined(); - expect(capturedBody!.reference_audio_urls).toBeUndefined(); - expect(capturedBody!.duration).toBe(5); - }); - - it("logs image generation parameters without calling the gateway when debug skip is enabled", async () => { - vi.stubEnv("SKIP_GENERATION_GATEWAY", "true"); - - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "seedream-5-0-260128", - name: "Seedream", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image"], - }, - prompt: "draw a banana", - images: ["data:image/png;base64,input"], - })); - - expect(result.success).toBe(false); - expect(result.error).toContain("SKIP_GENERATION_GATEWAY=true"); - expect(vi.mocked(global.fetch)).not.toHaveBeenCalled(); - }); - - it("maps stale Seedance frame aliases to gateway images", async () => { - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - dynamicInputs: { - first_frame_url: "data:image/png;base64,first", - }, - })); - - expect(result.success).toBe(true); - expect(capturedBody).not.toBeNull(); - expect(capturedBody!.images).toEqual(["data:image/png;base64,first"]); - expect(capturedBody!.first_frame_url).toBeUndefined(); - expect(capturedBody!.image).toBeUndefined(); - }); - - it("keeps Vidu reference videos on the schema field expected by the gateway", async () => { - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "viduq3-turbo", - name: "viduq3-turbo", - description: null, - provider: "newapiwg", - capabilities: ["text-to-video", "image-to-video", "video-to-video"], - }, - dynamicInputs: { - reference_video_urls: ["data:video/mp4;base64,reference"], - video_urls: "data:video/mp4;base64,connected", - }, - })); - - expect(result.success).toBe(true); - expect(capturedBody).not.toBeNull(); - expect(capturedBody!.reference_video_urls).toEqual([ - "data:video/mp4;base64,reference", - "data:video/mp4;base64,connected", - ]); - expect(capturedBody!.videos).toBeUndefined(); - expect(capturedBody!.video_urls).toBeUndefined(); - }); - - it("drops video references when the NewApiWG model does not support video inputs", async () => { - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "text-video-only", - name: "Text Video Only", - description: null, - provider: "newapiwg", - capabilities: ["text-to-video"], - }, - dynamicInputs: { - videos: ["https://cdn.example.com/reference.mp4"], - }, - parameters: { - referenceSubjectList: [ - { id: 1, type: "video", url: "https://cdn.example.com/reference.mp4", name: "视频1" }, - ], - }, - })); - - expect(result.success).toBe(true); - expect(capturedBody).not.toBeNull(); - expect(capturedBody!.videos).toBeUndefined(); - expect(capturedBody!.referenceSubjectList).toBeUndefined(); - }); - - it("resolves temporary local media inputs before NewApiWG video generation", async () => { - const imageId = storeImage("data:image/png;base64,dmlkZW8tcmVm"); - try { - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - images: [`http://localhost:3000/api/images/${imageId}`], - })); - - expect(result.success).toBe(true); - expect(capturedBody!.images).toEqual(["data:image/png;base64,dmlkZW8tcmVm"]); - } finally { - deleteImage(imageId); - } - }); - - it("routes nano banana image models through Gemini native generation", async () => { - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "apiyi_nano_banana_2", - name: "apiyi_nano_banana_2", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "draw a banana", - images: ["data:image/png;base64,input"], - })); - - expect(result.success).toBe(true); - expect(result.outputs?.[0]).toEqual({ type: "image", data: "data:image/png;base64,gemini123" }); - expect(capturedBody!.contents).toEqual([ - { - parts: [ - { text: "draw a banana" }, - { inlineData: { mimeType: "image/png", data: "input" } }, - ] - }, - ]); - expect(capturedBody!.generationConfig).toMatchObject({ - responseModalities: ["IMAGE"], - imageConfig: { aspectRatio: "1:1", imageSize: "2K" }, - responseFormat: { - image: { aspectRatio: "1:1", imageSize: "2K" }, - }, - }); - }); - - it("converts HTTP reference images only when Gemini native generation needs inline data", async () => { - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "apiyi_nano_banana_2", - name: "apiyi_nano_banana_2", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "draw a banana", - images: ["https://cdn.example.com/seedream.png"], - })); - - expect(result.success).toBe(true); - expect(capturedBody!.contents).toEqual([ - { - parts: [ - { text: "draw a banana" }, - { inlineData: { mimeType: "image/png", data: "YWJj" } }, - ], - }, - ]); - }); - - it("resolves temporary local reference image URLs before SSRF validation", async () => { - const imageId = storeImage("data:image/png;base64,bG9jYWw="); - try { - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "apiyi_nano_banana_2", - name: "apiyi_nano_banana_2", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "draw a banana", - images: [`http://localhost:3000/api/images/${imageId}`], - })); - - expect(result.success).toBe(true); - expect(capturedBody!.contents).toEqual([ - { - parts: [ - { text: "draw a banana" }, - { inlineData: { mimeType: "image/png", data: "bG9jYWw=" } }, - ], - }, - ]); - } finally { - deleteImage(imageId); - } - }); - - it("blocks private remote reference images before server-side fetch", async () => { - const fetchMock = vi.mocked(fetch); - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "apiyi_nano_banana_2", - name: "apiyi_nano_banana_2", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "draw a banana", - images: ["http://127.0.0.1/private.png"], - })); - - expect(result.success).toBe(false); - expect(result.error).toContain("Blocked reference image URL"); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("accepts Gemini native fileData URLs without image file extensions", async () => { - vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => { - capturedBody = init?.body ? JSON.parse(init.body as string) : null; - return new Response(JSON.stringify({ - candidates: [{ - content: { - parts: [{ - fileData: { - mimeType: "image/png", - fileUri: "https://cdn.example.com/generated/abc123?signature=test", - }, - }], - }, - }], - }), { status: 200 }); - })); - - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "apiyi_nano_banana_2", - name: "apiyi_nano_banana_2", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "draw a banana", - })); - - expect(result.success).toBe(true); - expect(result.outputs?.[0]).toEqual({ - type: "image", - data: "", - url: "https://cdn.example.com/generated/abc123?signature=test", - }); - }); - - it("retries transient overload errors from Gemini native image generation", async () => { - vi.useFakeTimers(); - const fetchMock = vi.fn(async () => { - if (fetchMock.mock.calls.length === 1) { - return new Response(JSON.stringify({ error: { message: "system disk overloaded" } }), { - status: 500, - }); - } - return new Response(JSON.stringify({ - candidates: [{ - content: { - parts: [{ - inlineData: { - mimeType: "image/png", - data: "retry-success", - }, - }], - }, - }], - }), { status: 200 }); - }); - vi.stubGlobal("fetch", fetchMock); - vi.spyOn(console, "warn").mockImplementation(() => {}); - - const promise = generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "apiyi_nano_banana_2", - name: "APIYI Nano Banana 2", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "draw a banana", - })); - - await vi.advanceTimersByTimeAsync(500); - const result = await promise; - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(result.success).toBe(true); - expect(result.outputs?.[0]).toEqual({ - type: "image", - data: "data:image/png;base64,retry-success", - }); - }); - - it("routes sora image models through chat completions", async () => { - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "sora_image", - name: "sora_image", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image"], - }, - prompt: "draw a banana", - })); - - expect(result.success).toBe(true); - expect(result.outputs?.[0]).toEqual({ type: "image", data: "data:image/png;base64,abc123" }); - expect(capturedBody!.model).toBe("sora_image"); - expect(capturedBody!.messages).toEqual([{ role: "user", content: "draw a banana" }]); - }); - - it("extracts markdown image URLs from gpt-image-2-all chat responses", async () => { - const fetchMock = vi.mocked(fetch); - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "gpt-image-2-all", - name: "gpt-image-2-all", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "draw a banana", - images: ["data:image/png;base64,input"], - })); - - expect(result.success).toBe(true); - expect(result.outputs?.[0]).toEqual({ - type: "image", - data: "", - url: "https://r2cdn.copilotbase.com/r2cdn2/generated.png", - }); - expect(capturedBody!.model).toBe("gpt-image-2-all"); - expect(capturedBody!.messages).toEqual([{ - role: "user", - content: [ - { type: "text", text: "draw a banana" }, - { type: "image_url", image_url: { url: "data:image/png;base64,input" } }, - ], - }]); - expect(fetchMock.mock.calls.some(([url]) => - String(url).includes("r2cdn.copilotbase.com") - )).toBe(false); - }); - - it("resolves temporary local reference image URLs in OpenAI chat image requests", async () => { - const imageId = storeImage("data:image/png;base64,bG9jYWw="); - try { - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "gpt-image-2-all", - name: "gpt-image-2-all", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "喝咖啡", - images: [`http://localhost:3000/api/images/${imageId}`], - })); - - expect(result.success).toBe(true); - expect(capturedBody!.messages).toEqual([{ - role: "user", - content: [ - { type: "text", text: "喝咖啡" }, - { type: "image_url", image_url: { url: "data:image/png;base64,bG9jYWw=" } }, - ], - }]); - } finally { - deleteImage(imageId); - } - }); - - it("fails OpenAI chat image requests when a temporary local reference image is missing", async () => { - const fetchMock = vi.mocked(fetch); - const missingUrl = "http://localhost:3000/api/images/00000000-0000-0000-0000-000000000000"; - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "gpt-image-2-all", - name: "gpt-image-2-all", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "喝咖啡", - images: [missingUrl], - })); - - expect(result.success).toBe(false); - expect(result.error).toBe("Local temporary reference image was not found. Please retry the generation."); - expect(capturedBody).toBeNull(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("maps gpt-image-2-all resolution and aspect ratio controls to chat completion size", async () => { - await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "gpt-image-2-all", - name: "gpt-image-2-all", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "draw a portrait", - parameters: { - quality: "auto", - size: "1024x1024", - aspectRatio: "9:16", - aspect_ratio: "9:16", - resolution: "2K", - imageSize: "2K", - }, - })); - - expect(capturedBody!.quality).toBe("auto"); - expect(capturedBody!.size).toBe("1152x2048"); - expect(capturedBody!.aspectRatio).toBeUndefined(); - expect(capturedBody!.aspect_ratio).toBeUndefined(); - expect(capturedBody!.resolution).toBeUndefined(); - expect(capturedBody!.imageSize).toBeUndefined(); - }); - - it("maps gpt-image-2-all 1K square requests to a square chat completion size", async () => { - await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "gpt-image-2-all", - name: "gpt-image-2-all", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "draw a portrait", - parameters: { - aspectRatio: "1:1", - aspect_ratio: "1:1", - resolution: "1K", - imageSize: "1K", - }, - })); - - expect(capturedBody!.size).toBe("1024x1024"); - expect(capturedBody!.aspectRatio).toBeUndefined(); - expect(capturedBody!.aspect_ratio).toBeUndefined(); - expect(capturedBody!.resolution).toBeUndefined(); - expect(capturedBody!.imageSize).toBeUndefined(); - }); - - it("downgrades unsupported gpt-image-2-all 4K requests to 2K size", async () => { - await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "gpt-image-2-all", - name: "gpt-image-2-all", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "draw a wide landscape", - parameters: { - resolution: "4K", - imageSize: "4K", - }, - })); - - expect(capturedBody!.size).toBe("2048x1152"); - }); - - it("maps gpt-image-2-vip resolution controls to chat completion size", async () => { - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "gpt-image-2-vip", - name: "gpt-image-2-vip", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "draw a wide landscape", - parameters: { - resolution: "2K", - imageSize: "2K", - aspectRatio: "16:9", - aspect_ratio: "16:9", - }, - })); - - expect(result.success).toBe(true); - expect(capturedBody!.model).toBe("gpt-image-2-vip"); - expect(capturedBody!.size).toBe("2048x1152"); - expect(capturedBody!.aspectRatio).toBeUndefined(); - expect(capturedBody!.aspect_ratio).toBeUndefined(); - expect(capturedBody!.resolution).toBeUndefined(); - expect(capturedBody!.imageSize).toBeUndefined(); - }); - - it("routes Seedream image models through images generations", async () => { - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "seedream-5-0-260128", - name: "seedream-5-0-260128", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "draw a banana", - images: ["data:image/png;base64,input"], - })); - - expect(result.success).toBe(true); - expect(result.outputs?.[0]).toEqual({ - type: "image", - data: "", - url: "https://cdn.example.com/seedream.png", - }); - expect(capturedBody!.model).toBe("seedream-5-0-260128"); - expect(capturedBody!.image).toBe("data:image/png;base64,input"); - }); - - it("resolves temporary local image inputs before NewApiWG image generation", async () => { - const imageId = storeImage("data:image/png;base64,aW1hZ2UtcmVm"); - try { - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "seedream-5-0-260128", - name: "seedream-5-0-260128", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "draw a banana", - images: [`http://localhost:3000/api/images/${imageId}`], - })); - - expect(result.success).toBe(true); - expect(capturedBody!.image).toBe("data:image/png;base64,aW1hZ2UtcmVm"); - } finally { - deleteImage(imageId); - } - }); - - it("maps Seedream image resolution and aspect ratio to provider pixel size", async () => { - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "apiyi_seedream5.0", - name: "APIYI Seedream 5.0", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "draw a landscape", - parameters: { - size: "1024x1024", - resolution: "4K", - imageSize: "4K", - aspectRatio: "16:9", - aspect_ratio: "16:9", - quality: "high", - }, - })); - - expect(result.success).toBe(true); - expect(capturedBody!.model).toBe("apiyi_seedream5.0"); - expect(capturedBody!.size).toBe("3840x2160"); - expect(capturedBody!.quality).toBe("high"); - expect(capturedBody!.resolution).toBeUndefined(); - expect(capturedBody!.imageSize).toBeUndefined(); - expect(capturedBody!.aspectRatio).toBeUndefined(); - expect(capturedBody!.aspect_ratio).toBeUndefined(); - }); - - it("raises Seedream 1K requests to the provider minimum pixel size", async () => { - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "doubao-seedream-4-5-251128", - name: "Doubao Seedream 4.5", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "draw a wide maze", - parameters: { - resolution: "1K", - imageSize: "1K", - aspectRatio: "21:9", - aspect_ratio: "21:9", - }, - })); - - expect(result.success).toBe(true); - expect(capturedBody!.model).toBe("doubao-seedream-4-5-251128"); - expect(capturedBody!.size).toBe("2936x1264"); - }); - - it("blocks cached Doubao chat models before calling media generation endpoints", async () => { - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "doubao-seed-2-0-pro-260215", - name: "Doubao Seed 2.0 Pro", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image", "image-to-image"], - }, - prompt: "draw a banana", - })); - - expect(result.success).toBe(false); - expect(result.error).toBe( - "Doubao Seed 2.0 Pro: this is a text/chat model, not an image, video, audio, or 3D generation model. Choose a generation model such as PopiArt Nano Pro, Seedream, or Seedance." - ); - expect(capturedBody).toBeNull(); - }); - - it("normalizes transient provider overload errors", () => { - expect(normalizeNewApiWGError("system disk overloaded", "Nano Banana")).toBe( - "Nano Banana: provider is temporarily overloaded. Please retry in a moment or switch models." - ); - }); - - it("normalizes insufficient NewApiWG points errors", () => { - expect(normalizeNewApiWGError("insufficient_points (request 1e5gtm)", "Seedance")).toBe( - "Seedance: NewApiWG account has insufficient points for this generation. Add balance, switch to a cheaper model, or configure another API key." - ); - }); - - it("normalizes Volcengine real-person image safety errors", () => { - expect(normalizeNewApiWGError( - "The request failed because the input image may contain real person.", - "Seedance" - )).toBe( - "Seedance: input image was rejected by the provider safety review because it may contain a real person or privacy information. Use a non-person image, anonymize the face, or try text-to-video." - ); - }); - - it("returns the readable upstream message instead of the error code", async () => { - vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => { - const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; - if (urlStr.includes("/video/generations") && init?.method === "POST") { - return new Response(JSON.stringify({ - error: { - code: "InputImageSensitiveContentDetected.PrivacyInformation", - message: "The request failed because the input image may contain real person.", - type: "BadRequest", - }, - }), { - status: 400, - }); - } - return new Response("Not Found", { status: 404 }); - })); - - const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - images: ["data:image/png;base64,input"], - })); - - expect(result.success).toBe(false); - expect(result.error).toBe( - "Seedance: input image was rejected by the provider safety review because it may contain a real person or privacy information. Use a non-person image, anonymize the face, or try text-to-video." - ); - }); - - it("returns normalized overload errors from image generation", async () => { - vi.useFakeTimers(); - vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => { - const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; - if (urlStr.includes("/images/generations") && init?.method === "POST") { - return new Response(JSON.stringify({ error: { message: "system disk overloaded" } }), { - status: 500, - }); - } - return new Response("Not Found", { status: 404 }); - })); - vi.spyOn(console, "warn").mockImplementation(() => {}); - - const promise = generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ - model: { - id: "seedream-5-0-260128", - name: "Seedream", - description: null, - provider: "newapiwg", - capabilities: ["text-to-image"], - }, - prompt: "draw a banana", - })); - - await vi.advanceTimersByTimeAsync(2000); - const result = await promise; - - expect(result.success).toBe(false); - expect(result.error).toBe( - "Seedream: provider is temporarily overloaded. Please retry in a moment or switch models." - ); - }); -}); diff --git a/src/app/api/generate/providers/__tests__/popiserver.test.ts b/src/app/api/generate/providers/__tests__/popiserver.test.ts index c5fba3e7..8073587f 100644 --- a/src/app/api/generate/providers/__tests__/popiserver.test.ts +++ b/src/app/api/generate/providers/__tests__/popiserver.test.ts @@ -31,6 +31,7 @@ function makeInput(): GenerationInput { aiModelId: 15, aiModelCode: "viduq2-pro", aiModelCodeAlias: "viduq2-pro", + isSupportImages: true, }, }, prompt: "@角色1 和 @角色2 在一起吃火锅", @@ -426,6 +427,7 @@ describe("popiserver generation provider", () => { aiModelId: 87, aiModelCode: "gpt-image-2-all", aiModelCodeAlias: "gpt-image-2-all", + isSupportImages: true, }, }; input.prompt = "@图1 作为人物参考生成海报"; @@ -676,6 +678,7 @@ describe("popiserver generation provider", () => { aiModelId: 29, aiModelCode: "seedance 2.0", aiModelCodeAlias: "doubao-seedance-2-0-fast-260128", + isSupportImages: true, isSupportAudios: true, isSupportVideos: true, }, diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index 03b4eaeb..f2ecacfd 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -15,7 +15,6 @@ import { useI18n } from "@/i18n"; import { useWorkflowStore } from "@/store/workflowStore"; import { useModelStore, type PopiModelDetail } from "@/store/modelStore"; import { useGenerationPreferenceStore } from "@/store/generationPreferenceStore"; -import { getNodeOutputMediaType } from "@/utils/nodeHandles"; import { inferNodeSizeFromData, resolveNodeSizeFromAspectRatio } from "@/utils/nodeSizing"; import { deriveComposerContextForNodeId, @@ -24,12 +23,8 @@ import { } from "@/utils/composerNodeDescriptor"; import { buildGenerationConfigDraftFromModelSchema, - buildInitialGenerationNodeData, buildSubmittableGenerationConfig, filterTaskRoutingParameters, - readAudioGenerationConfig, - readImageGenerationConfig, - readVideoGenerationConfig, type GenerationNodeConfig, type PopiserverTaskPayloadMedia, } from "@/utils/generationConfig"; @@ -41,7 +36,6 @@ import { syncPromptReferenceMaterials, type ComposerReferenceInput, type ComposerReferenceMaterial, - type ComposerReferenceMaterialType, } from "@/utils/composerReferenceSubjects"; import { buildPopiserverPriceQuoteRequest, @@ -52,7 +46,38 @@ import { buildComposerPricingContext, buildFinalComposerPrompt, } from "@/utils/composerPricingContext"; -import { selectedModelCapabilities } from "@/utils/selectedModel"; +import { + getConcreteGenerationNodeType, + inferCapabilityFromModel, + inferNodeTypeFromModel, + modelSupportsCapability, + preferenceTypeForCapability, + storedModelFitsCapability, +} from "@/components/composer/composerModelCapability"; +import { + buildComposerConfigPersistPatch, + buildComposerGenerationConfig, + buildInitialDataForNode, + composerConfigEquals, + configFromDraft, + contextIdentity, + formatPointAmount, + getConnectedTextItems, + readAspectRatioFromComposerConfig, + readDraftFromContext, + referenceSubjectsToMaterials, + EMPTY_SELECTED_MODEL, + type ComposerConfig, + type ComposerDraft, + type ComposerGenerationConfig, +} from "@/components/composer/composerDraftHelpers"; +import { + getConnectedEdgeIdsByType, + getConnectedEdgesByType, + getReferenceImageThumbnailUrl, + getReferenceVideoDurationSeconds, + uniqueReferenceVideoInputs, +} from "@/components/composer/composerReferenceEdges"; import { ComposerPromptMaterials, type ComposerConnectedTextCard, @@ -66,22 +91,16 @@ import type { GenerateVideoNodeData, ImageInputNodeData, LLMGenerateNodeData, - LLMModelType, NanoBananaNodeData, - NodeType, - SelectedModel, SmartTextNodeData, VideoStitchNodeData, WorkflowEdge, WorkflowNode, - WorkflowNodeData, } from "@/types"; import type { ModelParameter } from "@/lib/providers/types"; import type { ConnectedInputs } from "@/store/utils/connectedInputs"; -type ComposerDraft = GenerationNodeConfig; -export type ComposerGenerationConfig = Omit; -type ComposerConfig = ComposerGenerationConfig; +export type { ComposerGenerationConfig }; type PricingInputsSnapshot = { config: ComposerConfig; @@ -100,330 +119,11 @@ const PROMPT_LINE_HEIGHT_PX = 24; const PROMPT_MIN_HEIGHT_PX = PROMPT_LINE_HEIGHT_PX * 2; const PROMPT_MAX_HEIGHT_PX = PROMPT_LINE_HEIGHT_PX * 4; const EMPTY_EXTRA_TASK_PARAMS: Record = {}; -const ASPECT_RATIO_PARAMETER_NAMES = ["ratio", "aspectRatio", "aspect_ratio", "imageRatio", "image_ratio"]; - -const EMPTY_SELECTED_MODEL: SelectedModel = { - provider: "popiserver", - modelId: "", - displayName: "", - capabilities: [], -}; - -const DEFAULT_PROMPT_TEXT_MODEL: SelectedModel = { - provider: "popiserver", - modelId: "", - displayName: "", - capabilities: ["text-to-text"], -}; function getComposerModelPopupContainer(): HTMLElement { return document.body; } -function finiteNumberFromUnknown(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string") { - const parsed = Number.parseFloat(value.trim()); - return Number.isFinite(parsed) ? parsed : null; - } - return null; -} - -function getConnectedTextItems(inputs: { text: string | null; textItems: string[] } | null | undefined): string[] { - if (!inputs) return []; - const items = inputs.textItems.length > 0 ? inputs.textItems : inputs.text ? [inputs.text] : []; - return items.map((item) => item.trim()).filter(Boolean); -} - -function hasAnyCapability(model: SelectedModel, capabilities: string[]): boolean { - return Boolean(selectedModelCapabilities(model).some((capability) => capabilities.includes(capability))); -} - -function modelSupportsCapability(model: SelectedModel, capability: ComposerCapability): boolean { - if (capability === "all") return true; - if (capability === "image") return hasAnyCapability(model, ["text-to-image", "image-to-image"]); - if (capability === "video") return hasAnyCapability(model, ["text-to-video", "image-to-video", "video-to-video", "audio-to-video"]); - if (capability === "3d") return hasAnyCapability(model, ["text-to-3d", "image-to-3d"]); - if (capability === "llm") return hasAnyCapability(model, ["text-to-text"]); - return hasAnyCapability(model, ["text-to-audio"]); -} - -function storedModelFitsCapability(model: SelectedModel, capability: ComposerCapability): boolean { - if (capability === "all") return true; - if (selectedModelCapabilities(model).length === 0) return true; - return modelSupportsCapability(model, capability); -} - -function inferCapabilityFromModel(model: SelectedModel): Exclude | null { - if (modelSupportsCapability(model, "video")) return "video"; - if (modelSupportsCapability(model, "3d")) return "3d"; - if (modelSupportsCapability(model, "audio")) return "audio"; - if (modelSupportsCapability(model, "image")) return "image"; - if (modelSupportsCapability(model, "llm")) return "llm"; - return null; -} - -function inferNodeTypeFromModel(model: SelectedModel): NodeType | null { - const capability = inferCapabilityFromModel(model); - if (capability === "video") return "smartVideo"; - if (capability === "3d") return "generate3d"; - if (capability === "audio") return "smartAudio"; - if (capability === "image") return "smartImage"; - if (capability === "llm") return "smartText"; - return null; -} - -function preferenceTypeForCapability(capability: ComposerCapability): "image" | "video" | "audio" | null { - if (capability === "image") return "image"; - if (capability === "video") return "video"; - if (capability === "audio") return "audio"; - return null; -} - -function formatPointAmount(amount: number): string { - if (Number.isInteger(amount)) return String(amount); - return amount.toFixed(2).replace(/\.?0+$/, ""); -} - -function createEmptyDraft(): ComposerDraft { - return { - prompt: "", - selectedModel: EMPTY_SELECTED_MODEL, - inputImages: [], - batchSize: 1, - audioVoiceId: undefined, - referenceSubjectList: [], - parameters: {}, - extraTaskParams: {}, - }; -} - -function configFromDraft(draft: ComposerDraft): ComposerConfig { - const { prompt: _prompt, ...config } = draft; - return config; -} - -function buildComposerGenerationConfig(config: ComposerConfig, prompt: string): GenerationNodeConfig { - return { - ...config, - prompt, - }; -} - -function readAspectRatioFromComposerConfig(config: Pick): string | undefined { - for (const name of ASPECT_RATIO_PARAMETER_NAMES) { - const value = config.parameters?.[name]; - if (typeof value === "string" && value.trim()) return value.trim(); - } - return undefined; -} - -function composerConfigEquals(a: ComposerConfig, b: ComposerConfig): boolean { - return JSON.stringify(a) === JSON.stringify(b); -} - -function referenceSubjectsToMaterials(subjects: ComposerConfig["referenceSubjectList"]): ComposerReferenceMaterial[] { - return readReferenceSubjectList(subjects).map((subject, index) => ({ - id: index + 1, - type: subject.type, - url: subject.url, - name: subject.name.startsWith("@") ? subject.name : `@${subject.name}`, - sourceNodeId: subject.id, - ...(subject.thumbnailUrl ? { thumbnailUrl: subject.thumbnailUrl } : {}), - ...(subject.duration ? { duration: subject.duration } : {}), - })); -} - -function createPromptDraft(node: WorkflowNode): ComposerDraft { - const data = node.data as { prompt?: unknown }; - return { - ...createEmptyDraft(), - prompt: typeof data.prompt === "string" ? data.prompt : "", - selectedModel: DEFAULT_PROMPT_TEXT_MODEL, - }; -} - -function createSelectedModelFromLlmData(data: LLMGenerateNodeData): SelectedModel { - if (data.selectedModel?.provider === "popiserver") return data.selectedModel; - const model = data.model || ""; - return { - provider: "popiserver", - modelId: model, - displayName: model, - capabilities: ["text-to-text"], - }; -} - -function readDraftFromContext(context: ComposerContext): ComposerDraft { - if (context.mode === "empty-create") return createEmptyDraft(); - if (context.mode === "unsupported-node" && context.node?.type === "prompt") return createPromptDraft(context.node); - if (!context.node) return createEmptyDraft(); - if (context.node.type === "smartImage" || context.node.type === "nanoBanana") { - return readImageGenerationConfig(context.node.data as NanoBananaNodeData, EMPTY_SELECTED_MODEL); - } - if (context.node.type === "smartVideo" || context.node.type === "generateVideo") { - return readVideoGenerationConfig(context.node.data as GenerateVideoNodeData, EMPTY_SELECTED_MODEL); - } - if (context.node.type === "smartAudio" || context.node.type === "generateAudio") { - return readAudioGenerationConfig(context.node.data as GenerateAudioNodeData, EMPTY_SELECTED_MODEL); - } - if (context.node.type === "llmGenerate" || context.node.type === "smartText") { - const data = context.node.data as LLMGenerateNodeData; - return { - ...createEmptyDraft(), - prompt: data.inputPrompt ?? "", - selectedModel: createSelectedModelFromLlmData(data), - parameters: {}, - }; - } - return createEmptyDraft(); -} - -function contextIdentity(context: ComposerContext): string { - return `${context.mode}:${context.node?.id ?? "none"}:${context.nodeType ?? "none"}:${context.selectedCount}`; -} - -function getConcreteGenerationNodeType(nodeType: NodeType | null): NodeType | null { - if (nodeType === "smartImage") return "nanoBanana"; - if (nodeType === "smartVideo") return "generateVideo"; - if (nodeType === "smartAudio") return "generateAudio"; - return null; -} - -function buildInitialDataForNode(nodeType: NodeType, draft: ComposerDraft): Partial { - if ( - nodeType === "smartImage" || - nodeType === "smartVideo" || - nodeType === "smartAudio" || - nodeType === "nanoBanana" || - nodeType === "generateVideo" || - nodeType === "generate3d" || - nodeType === "generateAudio" - ) { - return buildInitialGenerationNodeData(nodeType, draft); - } - if (nodeType === "llmGenerate" || nodeType === "smartText") { - return { - ...(nodeType === "smartText" ? { manualLocked: false, manualText: "" } : {}), - inputPrompt: draft.prompt, - inputImages: [], - inputVideos: [], - outputText: null, - provider: "popiserver", - model: draft.selectedModel.modelId as LLMModelType, - selectedModel: { - ...draft.selectedModel, - capabilities: draft.selectedModel.capabilities ?? ["text-to-text"], - }, - status: "idle", - error: null, - } as Partial; - } - return {}; -} - -function buildComposerPersistParameters(node: WorkflowNode, config: ComposerConfig): ComposerConfig["parameters"] { - void node; - return filterTaskRoutingParameters(config.parameters); -} - -function buildComposerConfigPersistPatch(node: WorkflowNode, config: GenerationNodeConfig): Partial { - const nodeType = node.type; - const parameters = buildComposerPersistParameters(node, config); - if (nodeType === "smartImage" || nodeType === "nanoBanana") { - const aspectRatio = readAspectRatioFromComposerConfig(config); - return { - inputPrompt: config.prompt.trim(), - selectedModel: config.selectedModel, - batchSize: config.batchSize, - parameters, - extraTaskParams: config.extraTaskParams ?? {}, - ...(aspectRatio ? { aspectRatio } : {}), - } as Partial; - } - if (nodeType === "smartVideo" || nodeType === "generateVideo") { - return { - inputPrompt: config.prompt.trim(), - selectedModel: config.selectedModel, - parameters, - extraTaskParams: config.extraTaskParams ?? {}, - subType: config.subType, - } as Partial; - } - if (nodeType === "smartAudio" || nodeType === "generateAudio") { - return { - inputPrompt: config.prompt.trim(), - selectedModel: config.selectedModel, - audioVoiceId: config.audioVoiceId, - parameters, - extraTaskParams: config.extraTaskParams ?? {}, - } as Partial; - } - if (nodeType === "llmGenerate" || nodeType === "smartText") { - return { - inputPrompt: config.prompt, - selectedModel: config.selectedModel, - model: config.selectedModel.modelId as LLMModelType, - } as Partial; - } - return {}; -} - -function getConnectedEdgesByType( - targetNodeId: string, - nodes: WorkflowNode[], - edges: WorkflowEdge[], - type: ComposerReferenceMaterialType -): Array<{ id: string; sourceNodeId: string }> { - return edges - .filter((edge) => { - if (edge.target !== targetNodeId) return false; - const sourceNode = nodes.find((node) => node.id === edge.source); - return sourceNode ? getNodeOutputMediaType(sourceNode) === type : false; - }) - .map((edge) => ({ id: edge.id, sourceNodeId: edge.source })); -} - -function getConnectedEdgeIdsByType( - targetNodeId: string, - nodes: WorkflowNode[], - edges: WorkflowEdge[], - type: ComposerReferenceMaterialType | "text" -): string[] { - return edges - .filter((edge) => { - if (edge.target !== targetNodeId) return false; - const sourceNode = nodes.find((node) => node.id === edge.source); - return sourceNode ? getNodeOutputMediaType(sourceNode) === type : false; - }) - .map((edge) => edge.id); -} - -function getReferenceImageThumbnailUrl(sourceNode: WorkflowNode | undefined): string | undefined { - const previewImg = (sourceNode?.data as { previewImg?: unknown } | undefined)?.previewImg; - return typeof previewImg === "string" && previewImg.length > 0 ? previewImg : undefined; -} - -function getReferenceVideoDurationSeconds(videos: ComposerReferenceInput[]): number { - const seenUrls = new Set(); - const totalDuration = videos.reduce((sum, video) => { - if (seenUrls.has(video.url)) return sum; - seenUrls.add(video.url); - const duration = finiteNumberFromUnknown(video.duration); - return duration !== null && duration > 0 ? sum + duration : sum; - }, 0); - return Math.ceil(totalDuration); -} - -function uniqueReferenceVideoInputs(videos: ComposerReferenceInput[]): ComposerReferenceInput[] { - const seenUrls = new Set(); - return videos.filter((video) => { - if (seenUrls.has(video.url)) return false; - seenUrls.add(video.url); - return true; - }); -} - function IconButton({ label, children, onClick }: { label: string; children: ReactNode; onClick?: () => void }) { return (