From 16cde9b2dbe09ea6e2373dab6b408d2426035f3b Mon Sep 17 00:00:00 2001 From: shrimbly Date: Thu, 5 Feb 2026 08:49:58 +1300 Subject: [PATCH] feat: add nano-banana-pro via Kie, fix dynamic input priority, update test mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add nano-banana-pro model to Kie provider with schema and image input key - Reorder Kie generation to process dynamicInputs before fallback image uploads, preventing double-uploads when schema-mapped connections exist - Update test mocks for deduplicatedFetch, useProviderApiKeys, and static handle changes across GenerateImageNode, GenerateVideoNode, ModelParameters, and ModelSearchDialog tests - Add test commands to CLAUDE.md - Fix SplitGridSettingsModal grid preview count (5 → 6 options) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 2 + src/app/api/generate/route.ts | 60 +++--- src/app/api/models/[modelId]/route.ts | 11 + src/app/api/models/route.ts | 9 + .../__tests__/FloatingActionBar.test.tsx | 1 + .../__tests__/GenerateImageNode.test.tsx | 189 +++--------------- .../__tests__/GenerateVideoNode.test.tsx | 14 ++ .../__tests__/ModelParameters.test.tsx | 31 ++- .../__tests__/ModelSearchDialog.test.tsx | 14 ++ .../__tests__/SplitGridSettingsModal.test.tsx | 2 +- 10 files changed, 136 insertions(+), 197 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 912905e5..942d2e1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,8 @@ npm run dev # Start Next.js dev server at http://localhost:3000 npm run build # Build for production npm run start # Start production server npm run lint # Run Next.js linting +npm run test # Run all tests with Vitest (watch mode) +npm run test:run # Run all tests once (CI mode) ``` ## Environment Setup diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 4898cb1a..9e0a2bb1 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -1280,6 +1280,13 @@ function getKieModelDefaults(modelId: string): Record { quality: "basic", }; + // Nano Banana Pro (Kie) + case "nano-banana-pro": + return { + aspect_ratio: "1:1", + resolution: "1K", + }; + // Flux-2 models case "flux-2/pro-text-to-image": case "flux-2/pro-image-to-image": @@ -1368,6 +1375,7 @@ function getKieModelDefaults(modelId: string): Record { */ function getKieImageInputKey(modelId: string): string { // Model-specific parameter names + if (modelId === "nano-banana-pro") return "image_input"; if (modelId === "seedream/4.5-edit") return "image_urls"; if (modelId === "gpt-image/1.5-image-to-image") return "input_urls"; // Flux-2 I2I models use input_urls @@ -1568,31 +1576,10 @@ async function generateWithKie( delete inputParams.size; } - // Handle image inputs - if (input.images && input.images.length > 0) { - // Upload images to get URLs (Kie requires URLs, not base64) - const imageUrls: string[] = []; - for (const image of input.images) { - if (image.startsWith("http")) { - imageUrls.push(image); - } else { - // Upload base64 image - const url = await uploadImageToKie(requestId, apiKey, image); - imageUrls.push(url); - } - } + // Handle dynamic inputs FIRST (from schema-mapped connections) - these take priority + // Track which image keys dynamicInputs already handled to avoid double-uploads + const handledImageKeys = new Set(); - // Set the correct parameter name for this model - const imageKey = getKieImageInputKey(modelId); - // Some models use singular string, others use arrays - if (imageKey === "image_url" || imageKey === "video_url") { - inputParams[imageKey] = imageUrls[0]; - } else { - inputParams[imageKey] = imageUrls; - } - } - - // Handle dynamic inputs (from schema-mapped connections) if (input.dynamicInputs) { for (const [key, value] of Object.entries(input.dynamicInputs)) { if (value !== null && value !== undefined && value !== '') { @@ -1606,6 +1593,7 @@ async function generateWithKie( } else { inputParams[key] = [url]; } + handledImageKeys.add(key); } else if (Array.isArray(value)) { // Array of values - check if they're data URLs that need uploading const processedArray: string[] = []; @@ -1621,6 +1609,7 @@ async function generateWithKie( } if (processedArray.length > 0) { inputParams[key] = processedArray; + handledImageKeys.add(key); } } else { inputParams[key] = value; @@ -1629,6 +1618,29 @@ async function generateWithKie( } } + // Handle image inputs (fallback - only if dynamicInputs didn't already set the image key) + const imageKey = getKieImageInputKey(modelId); + if (input.images && input.images.length > 0 && !handledImageKeys.has(imageKey)) { + // Upload images to get URLs (Kie requires URLs, not base64) + const imageUrls: string[] = []; + for (const image of input.images) { + if (image.startsWith("http")) { + imageUrls.push(image); + } else { + // Upload base64 image + const url = await uploadImageToKie(requestId, apiKey, image); + imageUrls.push(url); + } + } + + // Some models use singular string, others use arrays + if (imageKey === "image_url" || imageKey === "video_url") { + inputParams[imageKey] = imageUrls[0]; + } else { + inputParams[imageKey] = imageUrls; + } + } + // All remaining Kie models use the standard createTask endpoint const requestBody: Record = { model: modelId, diff --git a/src/app/api/models/[modelId]/route.ts b/src/app/api/models/[modelId]/route.ts index 31d06404..4512ec5e 100644 --- a/src/app/api/models/[modelId]/route.ts +++ b/src/app/api/models/[modelId]/route.ts @@ -584,6 +584,17 @@ function getKieSchema(modelId: string): ExtractedSchema { { name: "input_urls", type: "image", required: true, label: "Image", isArray: true }, ], }, + "nano-banana-pro": { + parameters: [ + { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["1:1", "2:3", "3:2", "4:3", "16:9", "9:16", "21:9", "auto"], default: "1:1" }, + { name: "resolution", type: "string", description: "Output resolution", enum: ["1K", "2K", "4K"], default: "1K" }, + { name: "output_format", type: "string", description: "Output format", enum: ["png", "jpg"], default: "png" }, + ], + inputs: [ + { name: "prompt", type: "text", required: true, label: "Prompt" }, + { name: "image_input", type: "image", required: false, label: "Image", isArray: true }, + ], + }, "grok-imagine/text-to-image": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["2:3", "3:2", "1:1", "16:9", "9:16"], default: "1:1" }, diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index 5a70a703..c7d0e2be 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -140,6 +140,15 @@ const KIE_MODELS: ProviderModel[] = [ coverImage: undefined, pageUrl: "https://kie.ai/flux-2", }, + { + id: "nano-banana-pro", + name: "Nano Banana Pro", + description: "Google Gemini 3 Pro image generation via Kie.ai. Supports text-to-image and image-to-image with up to 8 input images.", + provider: "kie", + capabilities: ["text-to-image", "image-to-image"], + coverImage: undefined, + pageUrl: "https://docs.kie.ai/market/google/pro-image-to-image", + }, { id: "grok-imagine/text-to-image", name: "Grok Imagine", diff --git a/src/components/__tests__/FloatingActionBar.test.tsx b/src/components/__tests__/FloatingActionBar.test.tsx index 1c58088e..8053b7dd 100644 --- a/src/components/__tests__/FloatingActionBar.test.tsx +++ b/src/components/__tests__/FloatingActionBar.test.tsx @@ -75,6 +75,7 @@ const defaultProviderSettings: ProviderSettings = { const createDefaultState = (overrides = {}) => ({ nodes: [], isRunning: false, + currentNodeIds: [], executeWorkflow: mockExecuteWorkflow, regenerateNode: mockRegenerateNode, stopWorkflow: mockStopWorkflow, diff --git a/src/components/__tests__/GenerateImageNode.test.tsx b/src/components/__tests__/GenerateImageNode.test.tsx index 9b0935c3..23e61336 100644 --- a/src/components/__tests__/GenerateImageNode.test.tsx +++ b/src/components/__tests__/GenerateImageNode.test.tsx @@ -4,6 +4,12 @@ import { GenerateImageNode } from "@/components/nodes/GenerateImageNode"; import { ReactFlowProvider } from "@xyflow/react"; import { NanoBananaNodeData, ProviderSettings } from "@/types"; +// Mock deduplicatedFetch to pass through to global fetch (avoids caching issues in tests) +vi.mock("@/utils/deduplicatedFetch", () => ({ + deduplicatedFetch: (...args: Parameters) => fetch(...args), + clearFetchCache: vi.fn(), +})); + // Mock the workflow store const mockUpdateNodeData = vi.fn(); const mockRegenerateNode = vi.fn(); @@ -20,6 +26,14 @@ vi.mock("@/store/workflowStore", () => ({ // When called without selector (destructuring pattern), return the full state object return mockUseWorkflowStore((s: unknown) => s); }, + useProviderApiKeys: () => ({ + replicateApiKey: null, + falApiKey: null, + kieApiKey: null, + wavespeedApiKey: null, + replicateEnabled: false, + kieEnabled: false, + }), saveNanoBananaDefaults: vi.fn(), })); @@ -637,32 +651,8 @@ describe("GenerateImageNode", () => { expect(textHandle).toBeInTheDocument(); }); - describe("Multiple Image Inputs", () => { - it("should render multiple image handles when schema has multiple image inputs", () => { - const { container } = render( - - - - ); - - // Should have two image INPUT handles with data-schema-name attributes (exclude output handle) - const imageInputHandles = container.querySelectorAll('[data-handletype="image"][class*="target"]'); - expect(imageInputHandles.length).toBe(2); - - // Check schema names are set - const schemaNames = Array.from(imageInputHandles).map(h => h.getAttribute('data-schema-name')); - expect(schemaNames).toContain('first_frame'); - expect(schemaNames).toContain('last_frame'); - }); - - it("should position multiple image handles correctly (spaced evenly)", () => { + describe("Static Handles (inputSchema does not affect handle count)", () => { + it("should always render exactly one image and one text input handle regardless of schema", () => { const { container } = render( { ); - // Check handles have style.top set (indicating positioning) - filter to input handles only + // Component uses static handles - always 1 image input and 1 text input const imageInputHandles = container.querySelectorAll('[data-handletype="image"][class*="target"]'); - expect(imageInputHandles.length).toBe(2); - - const image0Handle = imageInputHandles[0] as HTMLElement; - const image1Handle = imageInputHandles[1] as HTMLElement; - - // Both should have top positions set - expect(image0Handle.style.top).toBeTruthy(); - expect(image1Handle.style.top).toBeTruthy(); - - // First handle should be positioned higher (lower percentage) than second - const top0 = parseFloat(image0Handle.style.top); - const top1 = parseFloat(image1Handle.style.top); - expect(top0).toBeLessThan(top1); - }); - - it("should show labels for each image input from schema", () => { - render( - - - - ); - - expect(screen.getByText("First Frame")).toBeInTheDocument(); - expect(screen.getByText("Last Frame")).toBeInTheDocument(); - }); - }); + expect(imageInputHandles.length).toBe(1); - describe("Multiple Text Inputs", () => { - it("should render multiple text handles when schema has multiple text inputs", () => { - const { container } = render( - - - - ); - - // Should have two text handles with data-schema-name attributes const textHandles = container.querySelectorAll('[data-handletype="text"]'); - expect(textHandles.length).toBe(2); - - // Check schema names are set - const schemaNames = Array.from(textHandles).map(h => h.getAttribute('data-schema-name')); - expect(schemaNames).toContain('prompt'); - expect(schemaNames).toContain('negative_prompt'); + expect(textHandles.length).toBe(1); }); - it("should show labels for each text input from schema", () => { + it("should render static 'Image' and 'Prompt' labels", () => { render( ); + // "Image" may appear in multiple places (handle label + node type), just verify it exists + expect(screen.getAllByText("Image").length).toBeGreaterThanOrEqual(1); expect(screen.getByText("Prompt")).toBeInTheDocument(); - expect(screen.getByText("Negative Prompt")).toBeInTheDocument(); }); - }); - describe("Placeholder Handle Variations", () => { - it("should show dimmed image handle (opacity 0.3) when schema has only text inputs", () => { + it("should always have image handle and text handle even with text-only schema", () => { const { container } = render( ); - // Image handle should exist with dimmed opacity const imageHandle = container.querySelector('[data-handletype="image"]') as HTMLElement; - expect(imageHandle).toBeInTheDocument(); - expect(imageHandle.style.opacity).toBe("0.3"); - }); - - it("should show dimmed text handle when schema has only image inputs", () => { - const { container } = render( - - - - ); - - // Text handle should exist with dimmed opacity const textHandle = container.querySelector('[data-handletype="text"]') as HTMLElement; + expect(imageHandle).toBeInTheDocument(); expect(textHandle).toBeInTheDocument(); - expect(textHandle.style.opacity).toBe("0.3"); - }); - - it("should show 'Not used by this model' description for placeholder handles", () => { - const { container } = render( - - - - ); - - // Image handle should have the placeholder title - const imageHandle = container.querySelector('[data-handletype="image"]'); - expect(imageHandle).toHaveAttribute("title", "Not used by this model"); }); }); describe("Handle Ordering", () => { - it("should render image handles before text handles", () => { + it("should render image handle above text handle", () => { const { container } = render( { const textTop = parseFloat(textHandle.style.top); expect(imageTop).toBeLessThan(textTop); }); - - it("should maintain gap between image and text handle groups", () => { - const { container } = render( - - - - ); - - // Get all input handles in order (exclude output handle) - const imageInputHandles = container.querySelectorAll('[data-handletype="image"][class*="target"]'); - const textHandle = container.querySelector('[data-handletype="text"]') as HTMLElement; - - expect(imageInputHandles.length).toBe(2); - - const image0 = imageInputHandles[0] as HTMLElement; - const image1 = imageInputHandles[1] as HTMLElement; - - const top0 = parseFloat(image0.style.top); - const top1 = parseFloat(image1.style.top); - const topText = parseFloat(textHandle.style.top); - - // Gap between image-1 and text should be larger than gap between image-0 and image-1 - const imageDiff = top1 - top0; - const gapDiff = topText - top1; - - // The gap should account for the spacing slot - expect(gapDiff).toBeGreaterThan(imageDiff * 0.9); - }); }); }); diff --git a/src/components/__tests__/GenerateVideoNode.test.tsx b/src/components/__tests__/GenerateVideoNode.test.tsx index 24ffb8fc..eb5294cf 100644 --- a/src/components/__tests__/GenerateVideoNode.test.tsx +++ b/src/components/__tests__/GenerateVideoNode.test.tsx @@ -4,6 +4,12 @@ import { GenerateVideoNode } from "@/components/nodes/GenerateVideoNode"; import { ReactFlowProvider } from "@xyflow/react"; import { GenerateVideoNodeData, ProviderSettings } from "@/types"; +// Mock deduplicatedFetch to pass through to global fetch (avoids caching issues in tests) +vi.mock("@/utils/deduplicatedFetch", () => ({ + deduplicatedFetch: (...args: Parameters) => fetch(...args), + clearFetchCache: vi.fn(), +})); + // Mock the workflow store const mockUpdateNodeData = vi.fn(); const mockRegenerateNode = vi.fn(); @@ -20,6 +26,14 @@ vi.mock("@/store/workflowStore", () => ({ // When called without selector (destructuring pattern), return the full state object return mockUseWorkflowStore((s: unknown) => s); }, + useProviderApiKeys: () => ({ + replicateApiKey: null, + falApiKey: null, + kieApiKey: null, + wavespeedApiKey: null, + replicateEnabled: false, + kieEnabled: false, + }), })); // Mock useReactFlow diff --git a/src/components/__tests__/ModelParameters.test.tsx b/src/components/__tests__/ModelParameters.test.tsx index 89bb3591..86fd460d 100644 --- a/src/components/__tests__/ModelParameters.test.tsx +++ b/src/components/__tests__/ModelParameters.test.tsx @@ -3,8 +3,22 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { ModelParameters } from "@/components/nodes/ModelParameters"; import { ModelParameter } from "@/lib/providers/types"; +// Mock deduplicatedFetch to pass through to global fetch (avoids caching issues in tests) +vi.mock("@/utils/deduplicatedFetch", () => ({ + deduplicatedFetch: (...args: Parameters) => fetch(...args), + clearFetchCache: vi.fn(), +})); + // Mock the workflow store const mockUseWorkflowStore = vi.fn(); +const mockUseProviderApiKeys = vi.fn(() => ({ + replicateApiKey: null as string | null, + falApiKey: null as string | null, + kieApiKey: null as string | null, + wavespeedApiKey: null as string | null, + replicateEnabled: false, + kieEnabled: false, +})); vi.mock("@/store/workflowStore", () => ({ useWorkflowStore: (selector?: (state: unknown) => unknown) => { @@ -13,6 +27,7 @@ vi.mock("@/store/workflowStore", () => ({ } return mockUseWorkflowStore((s: unknown) => s); }, + useProviderApiKeys: () => mockUseProviderApiKeys(), })); // Default store state @@ -698,15 +713,13 @@ describe("ModelParameters", () => { describe("API Key Headers", () => { it("should send Replicate API key header when available", async () => { - mockUseWorkflowStore.mockImplementation((selector) => { - return selector({ - providerSettings: { - providers: { - replicate: { apiKey: "test-replicate-key" }, - fal: { apiKey: null }, - }, - }, - }); + mockUseProviderApiKeys.mockReturnValue({ + replicateApiKey: "test-replicate-key", + falApiKey: null, + kieApiKey: null, + wavespeedApiKey: null, + replicateEnabled: false, + kieEnabled: false, }); (global.fetch as ReturnType).mockResolvedValueOnce({ diff --git a/src/components/__tests__/ModelSearchDialog.test.tsx b/src/components/__tests__/ModelSearchDialog.test.tsx index 8b53a2bf..5a1afd77 100644 --- a/src/components/__tests__/ModelSearchDialog.test.tsx +++ b/src/components/__tests__/ModelSearchDialog.test.tsx @@ -5,6 +5,12 @@ import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog"; import { ProviderSettings } from "@/types"; import { ProviderModel } from "@/lib/providers/types"; +// Mock deduplicatedFetch to pass through to global fetch (avoids caching issues in tests) +vi.mock("@/utils/deduplicatedFetch", () => ({ + deduplicatedFetch: (...args: Parameters) => fetch(...args), + clearFetchCache: vi.fn(), +})); + // Mock the workflow store const mockAddNode = vi.fn(); const mockIncrementModalCount = vi.fn(); @@ -19,6 +25,14 @@ vi.mock("@/store/workflowStore", () => ({ } return mockUseWorkflowStore((s: unknown) => s); }, + useProviderApiKeys: () => ({ + replicateApiKey: "test-replicate-key", + falApiKey: "test-fal-key", + kieApiKey: null, + wavespeedApiKey: null, + replicateEnabled: true, + kieEnabled: false, + }), })); // Mock useReactFlow diff --git a/src/components/__tests__/SplitGridSettingsModal.test.tsx b/src/components/__tests__/SplitGridSettingsModal.test.tsx index c664be64..e3aec33d 100644 --- a/src/components/__tests__/SplitGridSettingsModal.test.tsx +++ b/src/components/__tests__/SplitGridSettingsModal.test.tsx @@ -433,7 +433,7 @@ describe("SplitGridSettingsModal", () => { // Each target count button should have a grid preview const gridPreviews = container.querySelectorAll(".aspect-video"); - expect(gridPreviews.length).toBe(5); // 5 options: 4, 6, 8, 9, 10 + expect(gridPreviews.length).toBe(6); // 6 options: 4, 5, 6, 8, 9, 10 }); });