Browse Source

feat: add Image Search toggle for Nano Banana 2

Nano Banana 2 (gemini-3.1-flash-image) supports a new searchTypes API
that allows enabling web search and image search independently. This adds
a useImageSearch toggle visible only for Nano Banana 2, while keeping the
existing Google Search toggle using the legacy format for Nano Banana Pro.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
cbc278f801
  1. 95
      src/app/api/generate/__tests__/route.test.ts
  2. 11
      src/app/api/generate/providers/gemini.ts
  3. 4
      src/app/api/generate/route.ts
  4. 18
      src/components/SplitGridSettingsModal.tsx
  5. 21
      src/components/nodes/GenerateImageNode.tsx
  6. 4
      src/lib/chat/contextBuilder.test.ts
  7. 1
      src/lib/chat/editOperations.test.ts
  8. 1
      src/lib/quickstart/templates.ts
  9. 2
      src/lib/quickstart/validation.ts
  10. 1
      src/store/execution/__tests__/nanoBananaExecutor.test.ts
  11. 1
      src/store/execution/nanoBananaExecutor.ts
  12. 2
      src/store/utils/__tests__/localStorage.test.ts
  13. 2
      src/store/utils/localStorage.ts
  14. 3
      src/store/utils/nodeDefaults.ts
  15. 3
      src/types/api.ts
  16. 5
      src/types/nodes.ts

95
src/app/api/generate/__tests__/route.test.ts

@ -298,6 +298,101 @@ describe("/api/generate route", () => {
); );
}); });
it("should apply imageSearch searchType for nano-banana-2 with useImageSearch only", async () => {
process.env.GEMINI_API_KEY = "test-gemini-key";
mockGenerateContent.mockResolvedValueOnce(createGeminiImageResponse());
const request = createMockPostRequest({
prompt: "Find similar images",
model: "nano-banana-2",
useImageSearch: true,
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(mockGenerateContent).toHaveBeenCalledWith(
expect.objectContaining({
tools: [{ googleSearch: { searchTypes: { imageSearch: {} } } }],
})
);
});
it("should apply both webSearch and imageSearch for nano-banana-2 with both toggles", async () => {
process.env.GEMINI_API_KEY = "test-gemini-key";
mockGenerateContent.mockResolvedValueOnce(createGeminiImageResponse());
const request = createMockPostRequest({
prompt: "Latest technology with images",
model: "nano-banana-2",
useGoogleSearch: true,
useImageSearch: true,
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(mockGenerateContent).toHaveBeenCalledWith(
expect.objectContaining({
tools: [{ googleSearch: { searchTypes: { webSearch: {}, imageSearch: {} } } }],
})
);
});
it("should use webSearch searchType for nano-banana-2 with useGoogleSearch only", async () => {
process.env.GEMINI_API_KEY = "test-gemini-key";
mockGenerateContent.mockResolvedValueOnce(createGeminiImageResponse());
const request = createMockPostRequest({
prompt: "Latest technology trends",
model: "nano-banana-2",
useGoogleSearch: true,
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(mockGenerateContent).toHaveBeenCalledWith(
expect.objectContaining({
tools: [{ googleSearch: { searchTypes: { webSearch: {} } } }],
})
);
});
it("should ignore useImageSearch for nano-banana-pro", async () => {
process.env.GEMINI_API_KEY = "test-gemini-key";
mockGenerateContent.mockResolvedValueOnce(createGeminiImageResponse());
const request = createMockPostRequest({
prompt: "Test prompt",
model: "nano-banana-pro",
useGoogleSearch: true,
useImageSearch: true,
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
// nano-banana-pro should use old format, ignoring useImageSearch
expect(mockGenerateContent).toHaveBeenCalledWith(
expect.objectContaining({
tools: [{ googleSearch: {} }],
})
);
});
it("should use nano-banana-pro as default model", async () => { it("should use nano-banana-pro as default model", async () => {
process.env.GEMINI_API_KEY = "test-gemini-key"; process.env.GEMINI_API_KEY = "test-gemini-key";

11
src/app/api/generate/providers/gemini.ts

@ -28,7 +28,8 @@ export async function generateWithGemini(
model: ModelType, model: ModelType,
aspectRatio?: string, aspectRatio?: string,
resolution?: string, resolution?: string,
useGoogleSearch?: boolean useGoogleSearch?: boolean,
useImageSearch?: boolean
): Promise<NextResponse<GenerateResponse>> { ): Promise<NextResponse<GenerateResponse>> {
console.log(`[API:${requestId}] Gemini generation - Model: ${model}, Images: ${images?.length || 0}, Prompt: ${prompt?.length || 0} chars`); console.log(`[API:${requestId}] Gemini generation - Model: ${model}, Images: ${images?.length || 0}, Prompt: ${prompt?.length || 0} chars`);
@ -82,7 +83,13 @@ export async function generateWithGemini(
// Add tools array for Google Search (Nano Banana Pro and Nano Banana 2) // Add tools array for Google Search (Nano Banana Pro and Nano Banana 2)
const tools = []; const tools = [];
if ((model === "nano-banana-pro" || model === "nano-banana-2") && useGoogleSearch) { if (model === "nano-banana-2" && (useGoogleSearch || useImageSearch)) {
// Nano Banana 2 uses searchTypes to enable web and/or image search independently
const searchTypes: Record<string, Record<string, never>> = {};
if (useGoogleSearch) searchTypes.webSearch = {};
if (useImageSearch) searchTypes.imageSearch = {};
tools.push({ googleSearch: { searchTypes } });
} else if (model === "nano-banana-pro" && useGoogleSearch) {
tools.push({ googleSearch: {} }); tools.push({ googleSearch: {} });
} }

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

@ -95,6 +95,7 @@ export async function POST(request: NextRequest) {
aspectRatio, aspectRatio,
resolution, resolution,
useGoogleSearch, useGoogleSearch,
useImageSearch,
selectedModel, selectedModel,
parameters, parameters,
dynamicInputs, dynamicInputs,
@ -485,7 +486,8 @@ export async function POST(request: NextRequest) {
geminiModel, geminiModel,
aspectRatio, aspectRatio,
resolution, resolution,
useGoogleSearch useGoogleSearch,
useImageSearch
); );
} catch (error) { } catch (error) {
// Extract error information // Extract error information

18
src/components/SplitGridSettingsModal.tsx

@ -49,6 +49,7 @@ export function SplitGridSettingsModal({
const [resolution, setResolution] = useState(nodeData.generateSettings.resolution); const [resolution, setResolution] = useState(nodeData.generateSettings.resolution);
const [model, setModel] = useState(nodeData.generateSettings.model); const [model, setModel] = useState(nodeData.generateSettings.model);
const [useGoogleSearch, setUseGoogleSearch] = useState(nodeData.generateSettings.useGoogleSearch); const [useGoogleSearch, setUseGoogleSearch] = useState(nodeData.generateSettings.useGoogleSearch);
const [useImageSearch, setUseImageSearch] = useState(nodeData.generateSettings.useImageSearch);
const { rows, cols } = LAYOUT_OPTIONS[selectedLayoutIndex]; const { rows, cols } = LAYOUT_OPTIONS[selectedLayoutIndex];
const targetCount = rows * cols; const targetCount = rows * cols;
@ -108,6 +109,7 @@ export function SplitGridSettingsModal({
resolution, resolution,
model, model,
useGoogleSearch, useGoogleSearch,
useImageSearch,
}); });
// Create prompt node (below imageInput) // Create prompt node (below imageInput)
@ -158,6 +160,7 @@ export function SplitGridSettingsModal({
resolution, resolution,
model, model,
useGoogleSearch, useGoogleSearch,
useImageSearch,
}, },
childNodeIds, childNodeIds,
gridRows: rows, gridRows: rows,
@ -168,7 +171,7 @@ export function SplitGridSettingsModal({
onClose(); onClose();
}, [ }, [
nodeId, targetCount, defaultPrompt, aspectRatio, resolution, nodeId, targetCount, defaultPrompt, aspectRatio, resolution,
model, useGoogleSearch, rows, cols, selectedLayoutIndex, getNodeById, model, useGoogleSearch, useImageSearch, rows, cols, selectedLayoutIndex, getNodeById,
addNode, updateNodeData, onConnect, addEdgeWithType, onClose addNode, updateNodeData, onConnect, addEdgeWithType, onClose
]); ]);
@ -316,6 +319,19 @@ export function SplitGridSettingsModal({
Google Search Google Search
</label> </label>
</div> </div>
{model === "nano-banana-2" && (
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input
type="checkbox"
checked={useImageSearch}
onChange={(e) => setUseImageSearch(e.target.checked)}
className="w-4 h-4 rounded border-neutral-600 bg-neutral-900"
/>
Image Search
</label>
</div>
)}
</> </>
)} )}
</div> </div>

21
src/components/nodes/GenerateImageNode.tsx

@ -233,6 +233,15 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
[id, updateNodeData] [id, updateNodeData]
); );
const handleImageSearchToggle = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const useImageSearch = e.target.checked;
updateNodeData(id, { useImageSearch });
saveNanoBananaDefaults({ useImageSearch });
},
[id, updateNodeData]
);
const handleParametersChange = useCallback( const handleParametersChange = useCallback(
(parameters: Record<string, unknown>) => { (parameters: Record<string, unknown>) => {
updateNodeData(id, { parameters }); updateNodeData(id, { parameters });
@ -727,6 +736,18 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
<span>Google Search</span> <span>Google Search</span>
</label> </label>
)} )}
{/* Image Search toggle - only for Nano Banana 2 */}
{currentProvider === "gemini" && currentModelId === "nano-banana-2" && (
<label className="flex items-center gap-1.5 text-[10px] text-neutral-300 shrink-0 cursor-pointer">
<input
type="checkbox"
checked={nodeData.useImageSearch}
onChange={handleImageSearchToggle}
className="w-3 h-3 rounded border-neutral-700 bg-neutral-900/50 text-neutral-600 focus:ring-1 focus:ring-neutral-600 focus:ring-offset-0"
/>
<span>Image Search</span>
</label>
)}
</div> </div>
</BaseNode> </BaseNode>

4
src/lib/chat/contextBuilder.test.ts

@ -239,6 +239,7 @@ describe("stripBinaryData", () => {
resolution: "1K", resolution: "1K",
model: "nano-banana", model: "nano-banana",
useGoogleSearch: false, useGoogleSearch: false,
useImageSearch: false,
}, },
childNodeIds: [], childNodeIds: [],
gridRows: 2, gridRows: 2,
@ -258,6 +259,7 @@ describe("stripBinaryData", () => {
resolution: "1K", resolution: "1K",
model: "nano-banana", model: "nano-banana",
useGoogleSearch: false, useGoogleSearch: false,
useImageSearch: false,
}); });
expect(stripped.data.sourceImage).toMatch(/\[image:.*KB\]/); expect(stripped.data.sourceImage).toMatch(/\[image:.*KB\]/);
}); });
@ -373,7 +375,7 @@ describe("stripBinaryData", () => {
sourceImage: FAKE_BASE64_PNG, sourceImage: FAKE_BASE64_PNG,
targetCount: 4, targetCount: 4,
defaultPrompt: "test", defaultPrompt: "test",
generateSettings: { aspectRatio: "1:1", resolution: "1K", model: "nano-banana", useGoogleSearch: false }, generateSettings: { aspectRatio: "1:1", resolution: "1K", model: "nano-banana", useGoogleSearch: false, useImageSearch: false },
childNodeIds: [], childNodeIds: [],
gridRows: 2, gridRows: 2,
gridCols: 2, gridCols: 2,

1
src/lib/chat/editOperations.test.ts

@ -24,6 +24,7 @@ vi.mock("@/store/utils/nodeDefaults", () => ({
resolution: "1K", resolution: "1K",
model: "nano-banana", model: "nano-banana",
useGoogleSearch: false, useGoogleSearch: false,
useImageSearch: false,
status: "idle", status: "idle",
error: null, error: null,
imageHistory: [], imageHistory: [],

1
src/lib/quickstart/templates.ts

@ -82,6 +82,7 @@ const createNanoBananaData = () => ({
resolution: "1K" as const, resolution: "1K" as const,
model: "nano-banana-pro" as const, model: "nano-banana-pro" as const,
useGoogleSearch: false, useGoogleSearch: false,
useImageSearch: false,
status: "idle" as const, status: "idle" as const,
error: null, error: null,
imageHistory: [], imageHistory: [],

2
src/lib/quickstart/validation.ts

@ -264,6 +264,7 @@ function createDefaultNodeData(type: NodeType): WorkflowNodeData {
resolution: "1K", resolution: "1K",
model: "nano-banana-pro", model: "nano-banana-pro",
useGoogleSearch: false, useGoogleSearch: false,
useImageSearch: false,
status: "idle", status: "idle",
error: null, error: null,
imageHistory: [], imageHistory: [],
@ -325,6 +326,7 @@ function createDefaultNodeData(type: NodeType): WorkflowNodeData {
resolution: "1K", resolution: "1K",
model: "nano-banana-pro", model: "nano-banana-pro",
useGoogleSearch: false, useGoogleSearch: false,
useImageSearch: false,
}, },
childNodeIds: [], childNodeIds: [],
gridRows: 2, gridRows: 2,

1
src/store/execution/__tests__/nanoBananaExecutor.test.ts

@ -27,6 +27,7 @@ function makeNode(data: Record<string, unknown> = {}): WorkflowNode {
resolution: "1024x1024", resolution: "1024x1024",
model: "nano-banana", model: "nano-banana",
useGoogleSearch: false, useGoogleSearch: false,
useImageSearch: false,
selectedModel: { provider: "gemini", modelId: "nano-banana", displayName: "Nano Banana" }, selectedModel: { provider: "gemini", modelId: "nano-banana", displayName: "Nano Banana" },
parameters: {}, parameters: {},
imageHistory: [], imageHistory: [],

1
src/store/execution/nanoBananaExecutor.ts

@ -101,6 +101,7 @@ export async function executeNanoBanana(
resolution: nodeData.resolution, resolution: nodeData.resolution,
model: nodeData.model, model: nodeData.model,
useGoogleSearch: nodeData.useGoogleSearch, useGoogleSearch: nodeData.useGoogleSearch,
useImageSearch: nodeData.useImageSearch,
selectedModel: nodeData.selectedModel, selectedModel: nodeData.selectedModel,
parameters: nodeData.parameters, parameters: nodeData.parameters,
dynamicInputs: sanitizedDynamicInputs, dynamicInputs: sanitizedDynamicInputs,

2
src/store/utils/__tests__/localStorage.test.ts

@ -171,6 +171,7 @@ describe("localStorage utilities", () => {
resolution: "1K", resolution: "1K",
model: "nano-banana-pro", model: "nano-banana-pro",
useGoogleSearch: false, useGoogleSearch: false,
useImageSearch: false,
}); });
}); });
@ -199,6 +200,7 @@ describe("localStorage utilities", () => {
resolution: "1K", resolution: "1K",
model: "nano-banana-pro", model: "nano-banana-pro",
useGoogleSearch: false, useGoogleSearch: false,
useImageSearch: false,
}); });
}); });
}); });

2
src/store/utils/localStorage.ts

@ -29,6 +29,7 @@ export interface GenerateImageDefaults {
resolution: string; resolution: string;
model: string; model: string;
useGoogleSearch: boolean; useGoogleSearch: boolean;
useImageSearch: boolean;
} }
const DEFAULT_GENERATE_IMAGE_SETTINGS: GenerateImageDefaults = { const DEFAULT_GENERATE_IMAGE_SETTINGS: GenerateImageDefaults = {
@ -36,6 +37,7 @@ const DEFAULT_GENERATE_IMAGE_SETTINGS: GenerateImageDefaults = {
resolution: "1K", resolution: "1K",
model: "nano-banana-pro", model: "nano-banana-pro",
useGoogleSearch: false, useGoogleSearch: false,
useImageSearch: false,
}; };
// Default provider settings // Default provider settings

3
src/store/utils/nodeDefaults.ts

@ -141,6 +141,7 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => {
const aspectRatio = nodeDefaults.generateImage?.aspectRatio ?? legacyDefaults.aspectRatio; const aspectRatio = nodeDefaults.generateImage?.aspectRatio ?? legacyDefaults.aspectRatio;
const resolution = nodeDefaults.generateImage?.resolution ?? legacyDefaults.resolution; const resolution = nodeDefaults.generateImage?.resolution ?? legacyDefaults.resolution;
const useGoogleSearch = nodeDefaults.generateImage?.useGoogleSearch ?? legacyDefaults.useGoogleSearch; const useGoogleSearch = nodeDefaults.generateImage?.useGoogleSearch ?? legacyDefaults.useGoogleSearch;
const useImageSearch = nodeDefaults.generateImage?.useImageSearch ?? legacyDefaults.useImageSearch;
return { return {
inputImages: [], inputImages: [],
@ -151,6 +152,7 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => {
model: legacyDefaults.model, // Keep legacy model field for backward compat model: legacyDefaults.model, // Keep legacy model field for backward compat
selectedModel, selectedModel,
useGoogleSearch, useGoogleSearch,
useImageSearch,
status: "idle", status: "idle",
error: null, error: null,
imageHistory: [], imageHistory: [],
@ -222,6 +224,7 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => {
resolution: "1K", resolution: "1K",
model: "nano-banana-pro", model: "nano-banana-pro",
useGoogleSearch: false, useGoogleSearch: false,
useImageSearch: false,
}, },
childNodeIds: [], childNodeIds: [],
gridRows: 2, gridRows: 2,

3
src/types/api.ts

@ -15,7 +15,8 @@ export interface GenerateRequest {
aspectRatio?: AspectRatio; aspectRatio?: AspectRatio;
resolution?: Resolution; // Only for Nano Banana Pro resolution?: Resolution; // Only for Nano Banana Pro
model?: ModelType; model?: ModelType;
useGoogleSearch?: boolean; // Only for Nano Banana Pro useGoogleSearch?: boolean; // Only for Nano Banana Pro and Nano Banana 2
useImageSearch?: boolean; // Only for Nano Banana 2
mediaType?: "image" | "video" | "3d" | "audio"; // Indicates expected output type for provider routing mediaType?: "image" | "video" | "3d" | "audio"; // Indicates expected output type for provider routing
} }

5
src/types/nodes.ts

@ -170,7 +170,8 @@ export interface NanoBananaNodeData extends BaseNodeData {
resolution: Resolution; // Only used by Nano Banana Pro resolution: Resolution; // Only used by Nano Banana Pro
model: ModelType; model: ModelType;
selectedModel?: SelectedModel; // Multi-provider model selection (optional for backward compat) selectedModel?: SelectedModel; // Multi-provider model selection (optional for backward compat)
useGoogleSearch: boolean; // Only available for Nano Banana Pro useGoogleSearch: boolean; // Only available for Nano Banana Pro and Nano Banana 2
useImageSearch: boolean; // Only available for Nano Banana 2
parameters?: Record<string, unknown>; // Model-specific parameters for external providers parameters?: Record<string, unknown>; // Model-specific parameters for external providers
inputSchema?: ModelInputDef[]; // Model's input schema for dynamic handles inputSchema?: ModelInputDef[]; // Model's input schema for dynamic handles
status: NodeStatus; status: NodeStatus;
@ -362,6 +363,7 @@ export interface SplitGridNodeData extends BaseNodeData {
resolution: Resolution; resolution: Resolution;
model: ModelType; model: ModelType;
useGoogleSearch: boolean; useGoogleSearch: boolean;
useImageSearch: boolean;
}; };
childNodeIds: Array<{ childNodeIds: Array<{
imageInput: string; imageInput: string;
@ -433,6 +435,7 @@ export interface GenerateImageNodeDefaults {
aspectRatio?: string; aspectRatio?: string;
resolution?: string; resolution?: string;
useGoogleSearch?: boolean; useGoogleSearch?: boolean;
useImageSearch?: boolean;
} }
export interface GenerateVideoNodeDefaults { export interface GenerateVideoNodeDefaults {

Loading…
Cancel
Save