Browse Source

feat: add nano-banana-pro via Kie, fix dynamic input priority, update test mocks

- 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 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
16cde9b2db
  1. 2
      CLAUDE.md
  2. 60
      src/app/api/generate/route.ts
  3. 11
      src/app/api/models/[modelId]/route.ts
  4. 9
      src/app/api/models/route.ts
  5. 1
      src/components/__tests__/FloatingActionBar.test.tsx
  6. 189
      src/components/__tests__/GenerateImageNode.test.tsx
  7. 14
      src/components/__tests__/GenerateVideoNode.test.tsx
  8. 31
      src/components/__tests__/ModelParameters.test.tsx
  9. 14
      src/components/__tests__/ModelSearchDialog.test.tsx
  10. 2
      src/components/__tests__/SplitGridSettingsModal.test.tsx

2
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

60
src/app/api/generate/route.ts

@ -1280,6 +1280,13 @@ function getKieModelDefaults(modelId: string): Record<string, unknown> {
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<string, unknown> {
*/
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<string>();
// 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<string, unknown> = {
model: modelId,

11
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" },

9
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",

1
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,

189
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<typeof fetch>) => 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(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
selectedModel: { provider: "fal", modelId: "video/frames", displayName: "Video Frames" },
inputSchema: [
{ name: "first_frame", type: "image", required: true, label: "First Frame" },
{ name: "last_frame", type: "image", required: false, label: "Last Frame" },
{ name: "prompt", type: "text", required: true, label: "Prompt" },
],
})} />
</TestWrapper>
);
// 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(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
@ -676,145 +666,53 @@ describe("GenerateImageNode", () => {
</TestWrapper>
);
// 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(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
selectedModel: { provider: "fal", modelId: "video/frames", displayName: "Video Frames" },
inputSchema: [
{ name: "first_frame", type: "image", required: true, label: "First Frame" },
{ name: "last_frame", type: "image", required: false, label: "Last Frame" },
{ name: "prompt", type: "text", required: true, label: "Prompt" },
],
})} />
</TestWrapper>
);
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(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
selectedModel: { provider: "fal", modelId: "flux/dev", displayName: "FLUX Dev" },
inputSchema: [
{ name: "image", type: "image", required: true, label: "Image" },
{ name: "prompt", type: "text", required: true, label: "Prompt" },
{ name: "negative_prompt", type: "text", required: false, label: "Negative Prompt" },
],
})} />
</TestWrapper>
);
// 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(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
selectedModel: { provider: "fal", modelId: "flux/dev", displayName: "FLUX Dev" },
inputSchema: [
{ name: "image", type: "image", required: true, label: "Image" },
{ name: "image", type: "image", required: true, label: "Input Image" },
{ name: "prompt", type: "text", required: true, label: "Prompt" },
{ name: "negative_prompt", type: "text", required: false, label: "Negative Prompt" },
],
})} />
</TestWrapper>
);
// "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(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
selectedModel: { provider: "fal", modelId: "text-only/model", displayName: "Text Only" },
inputSchema: [
{ name: "prompt", type: "text", required: true, label: "Prompt" },
{ name: "negative_prompt", type: "text", required: false, label: "Negative" },
],
})} />
</TestWrapper>
);
// 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(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
selectedModel: { provider: "fal", modelId: "image-only/model", displayName: "Image Only" },
inputSchema: [
{ name: "first_frame", type: "image", required: true, label: "First Frame" },
{ name: "last_frame", type: "image", required: false, label: "Last Frame" },
],
})} />
</TestWrapper>
);
// 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(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
selectedModel: { provider: "fal", modelId: "text-only/model", displayName: "Text Only" },
inputSchema: [
{ name: "prompt", type: "text", required: true, label: "Prompt" },
],
})} />
</TestWrapper>
);
// 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(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
@ -835,41 +733,6 @@ describe("GenerateImageNode", () => {
const textTop = parseFloat(textHandle.style.top);
expect(imageTop).toBeLessThan(textTop);
});
it("should maintain gap between image and text handle groups", () => {
const { container } = render(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
selectedModel: { provider: "fal", modelId: "video/frames", displayName: "Video Frames" },
inputSchema: [
{ name: "first_frame", type: "image", required: true, label: "First Frame" },
{ name: "last_frame", type: "image", required: false, label: "Last Frame" },
{ name: "prompt", type: "text", required: true, label: "Prompt" },
],
})} />
</TestWrapper>
);
// 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);
});
});
});

14
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<typeof fetch>) => 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

31
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<typeof fetch>) => 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<typeof vi.fn>).mockResolvedValueOnce({

14
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<typeof fetch>) => 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

2
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
});
});

Loading…
Cancel
Save