Browse Source

feat(03-01): create GenerateImageNode with provider/model selector

- Rename NanoBananaNode.tsx to GenerateImageNode.tsx
- Add provider dropdown showing enabled providers (Gemini always, others if configured)
- Add model dropdown filtering to image capabilities (text-to-image, image-to-image)
- Keep aspect ratio/resolution controls for Gemini only, hide for external providers
- Update index.ts exports with backward-compatible NanoBananaNode alias

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
977b5f530b
  1. 219
      src/components/nodes/GenerateImageNode.tsx
  2. 2
      src/components/nodes/index.ts

219
src/components/nodes/NanoBananaNode.tsx → src/components/nodes/GenerateImageNode.tsx

@ -1,10 +1,11 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useState, useEffect, useMemo } from "react";
import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode"; import { BaseNode } from "./BaseNode";
import { useWorkflowStore, saveNanoBananaDefaults } from "@/store/workflowStore"; import { useWorkflowStore, saveNanoBananaDefaults } from "@/store/workflowStore";
import { NanoBananaNodeData, AspectRatio, Resolution, ModelType } from "@/types"; import { NanoBananaNodeData, AspectRatio, Resolution, ModelType, ProviderType, SelectedModel } from "@/types";
import { ProviderModel, ModelCapability } from "@/lib/providers/types";
// All 10 aspect ratios supported by both models // All 10 aspect ratios supported by both models
const ASPECT_RATIOS: AspectRatio[] = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"]; const ASPECT_RATIOS: AspectRatio[] = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"];
@ -12,18 +13,115 @@ const ASPECT_RATIOS: AspectRatio[] = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5",
// Resolutions only for Nano Banana Pro (gemini-3-pro-image-preview) // Resolutions only for Nano Banana Pro (gemini-3-pro-image-preview)
const RESOLUTIONS: Resolution[] = ["1K", "2K", "4K"]; const RESOLUTIONS: Resolution[] = ["1K", "2K", "4K"];
const MODELS: { value: ModelType; label: string }[] = [ // Hardcoded Gemini image models (always available)
const GEMINI_IMAGE_MODELS: { value: ModelType; label: string }[] = [
{ value: "nano-banana", label: "Nano Banana" }, { value: "nano-banana", label: "Nano Banana" },
{ value: "nano-banana-pro", label: "Nano Banana Pro" }, { value: "nano-banana-pro", label: "Nano Banana Pro" },
]; ];
// Image generation capabilities
const IMAGE_CAPABILITIES: ModelCapability[] = ["text-to-image", "image-to-image"];
type NanoBananaNodeType = Node<NanoBananaNodeData, "nanoBanana">; type NanoBananaNodeType = Node<NanoBananaNodeData, "nanoBanana">;
export function NanoBananaNode({ id, data, selected }: NodeProps<NanoBananaNodeType>) { export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNodeType>) {
const nodeData = data; const nodeData = data;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const generationsPath = useWorkflowStore((state) => state.generationsPath); const generationsPath = useWorkflowStore((state) => state.generationsPath);
const providerSettings = useWorkflowStore((state) => state.providerSettings);
const [isLoadingCarouselImage, setIsLoadingCarouselImage] = useState(false); const [isLoadingCarouselImage, setIsLoadingCarouselImage] = useState(false);
const [externalModels, setExternalModels] = useState<ProviderModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
// Get the current selected provider (default to gemini)
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "gemini";
// Get enabled providers
const enabledProviders = useMemo(() => {
const providers: { id: ProviderType; name: string }[] = [];
// Gemini is always available
providers.push({ id: "gemini", name: "Gemini" });
// Add other enabled providers
if (providerSettings.providers.replicate?.enabled && providerSettings.providers.replicate?.apiKey) {
providers.push({ id: "replicate", name: "Replicate" });
}
if (providerSettings.providers.fal?.enabled && providerSettings.providers.fal?.apiKey) {
providers.push({ id: "fal", name: "fal.ai" });
}
return providers;
}, [providerSettings]);
// Fetch models from external providers when provider changes
useEffect(() => {
const fetchModels = async () => {
if (currentProvider === "gemini") {
setExternalModels([]);
return;
}
setIsLoadingModels(true);
try {
const capabilities = IMAGE_CAPABILITIES.join(",");
const response = await fetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`);
if (response.ok) {
const models = await response.json();
setExternalModels(models);
} else {
setExternalModels([]);
}
} catch (error) {
console.error("Failed to fetch models:", error);
setExternalModels([]);
} finally {
setIsLoadingModels(false);
}
};
fetchModels();
}, [currentProvider]);
// Handle provider change
const handleProviderChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const provider = e.target.value as ProviderType;
if (provider === "gemini") {
// Reset to Gemini default
const newSelectedModel: SelectedModel = {
provider: "gemini",
modelId: nodeData.model || "nano-banana-pro",
displayName: GEMINI_IMAGE_MODELS.find(m => m.value === (nodeData.model || "nano-banana-pro"))?.label || "Nano Banana Pro",
};
updateNodeData(id, { selectedModel: newSelectedModel });
} else {
// Set placeholder for external provider
const newSelectedModel: SelectedModel = {
provider,
modelId: "",
displayName: "Select model...",
};
updateNodeData(id, { selectedModel: newSelectedModel });
}
},
[id, nodeData.model, updateNodeData]
);
// Handle model change for external providers
const handleExternalModelChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const modelId = e.target.value;
const model = externalModels.find(m => m.id === modelId);
if (model) {
const newSelectedModel: SelectedModel = {
provider: currentProvider,
modelId: model.id,
displayName: model.name,
};
updateNodeData(id, { selectedModel: newSelectedModel });
}
},
[id, currentProvider, externalModels, updateNodeData]
);
const handleAspectRatioChange = useCallback( const handleAspectRatioChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => { (e: React.ChangeEvent<HTMLSelectElement>) => {
@ -48,6 +146,14 @@ export function NanoBananaNode({ id, data, selected }: NodeProps<NanoBananaNodeT
const model = e.target.value as ModelType; const model = e.target.value as ModelType;
updateNodeData(id, { model }); updateNodeData(id, { model });
saveNanoBananaDefaults({ model }); saveNanoBananaDefaults({ model });
// Also update selectedModel for consistency
const newSelectedModel: SelectedModel = {
provider: "gemini",
modelId: model,
displayName: GEMINI_IMAGE_MODELS.find(m => m.value === model)?.label || model,
};
updateNodeData(id, { selectedModel: newSelectedModel });
}, },
[id, updateNodeData] [id, updateNodeData]
); );
@ -141,7 +247,8 @@ export function NanoBananaNode({ id, data, selected }: NodeProps<NanoBananaNodeT
} }
}, [id, nodeData.imageHistory, nodeData.selectedHistoryIndex, isLoadingCarouselImage, loadImageById, updateNodeData]); }, [id, nodeData.imageHistory, nodeData.selectedHistoryIndex, isLoadingCarouselImage, loadImageById, updateNodeData]);
const isNanoBananaPro = nodeData.model === "nano-banana-pro"; const isGeminiProvider = currentProvider === "gemini";
const isNanoBananaPro = isGeminiProvider && nodeData.model === "nano-banana-pro";
const hasCarouselImages = (nodeData.imageHistory || []).length > 1; const hasCarouselImages = (nodeData.imageHistory || []).length > 1;
return ( return (
@ -316,46 +423,87 @@ export function NanoBananaNode({ id, data, selected }: NodeProps<NanoBananaNodeT
</div> </div>
)} )}
{/* Provider selector - only show if multiple providers are enabled */}
{enabledProviders.length > 1 && (
<select
value={currentProvider}
onChange={handleProviderChange}
className="w-full text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 shrink-0"
>
{enabledProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
)}
{/* Model selector */} {/* Model selector */}
<select {isGeminiProvider ? (
value={nodeData.model}
onChange={handleModelChange}
className="w-full text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 shrink-0"
>
{MODELS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
{/* Aspect ratio and resolution row */}
<div className="flex gap-1.5 shrink-0">
<select <select
value={nodeData.aspectRatio} value={nodeData.model}
onChange={handleAspectRatioChange} onChange={handleModelChange}
className="flex-1 text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300" className="w-full text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 shrink-0"
> >
{ASPECT_RATIOS.map((ratio) => ( {GEMINI_IMAGE_MODELS.map((m) => (
<option key={ratio} value={ratio}> <option key={m.value} value={m.value}>
{ratio} {m.label}
</option> </option>
))} ))}
</select> </select>
{isNanoBananaPro && ( ) : (
<select
value={nodeData.selectedModel?.modelId || ""}
onChange={handleExternalModelChange}
disabled={isLoadingModels}
className="w-full text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 shrink-0 disabled:opacity-50"
>
{isLoadingModels ? (
<option value="">Loading models...</option>
) : externalModels.length === 0 ? (
<option value="">No models available</option>
) : (
<>
<option value="">Select model...</option>
{externalModels.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</>
)}
</select>
)}
{/* Aspect ratio and resolution row - only for Gemini */}
{isGeminiProvider && (
<div className="flex gap-1.5 shrink-0">
<select <select
value={nodeData.resolution} value={nodeData.aspectRatio}
onChange={handleResolutionChange} onChange={handleAspectRatioChange}
className="w-12 text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300" className="flex-1 text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300"
> >
{RESOLUTIONS.map((res) => ( {ASPECT_RATIOS.map((ratio) => (
<option key={res} value={res}> <option key={ratio} value={ratio}>
{res} {ratio}
</option> </option>
))} ))}
</select> </select>
)} {isNanoBananaPro && (
</div> <select
value={nodeData.resolution}
onChange={handleResolutionChange}
className="w-12 text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300"
>
{RESOLUTIONS.map((res) => (
<option key={res} value={res}>
{res}
</option>
))}
</select>
)}
</div>
)}
{/* Google Search toggle - only for Nano Banana Pro */} {/* Google Search toggle - only for Nano Banana Pro */}
{isNanoBananaPro && ( {isNanoBananaPro && (
@ -373,3 +521,6 @@ export function NanoBananaNode({ id, data, selected }: NodeProps<NanoBananaNodeT
</BaseNode> </BaseNode>
); );
} }
// Backward-compatible alias
export { GenerateImageNode as NanoBananaNode };

2
src/components/nodes/index.ts

@ -1,7 +1,7 @@
export { ImageInputNode } from "./ImageInputNode"; export { ImageInputNode } from "./ImageInputNode";
export { AnnotationNode } from "./AnnotationNode"; export { AnnotationNode } from "./AnnotationNode";
export { PromptNode } from "./PromptNode"; export { PromptNode } from "./PromptNode";
export { NanoBananaNode } from "./NanoBananaNode"; export { GenerateImageNode, NanoBananaNode } from "./GenerateImageNode";
export { LLMGenerateNode } from "./LLMGenerateNode"; export { LLMGenerateNode } from "./LLMGenerateNode";
export { SplitGridNode } from "./SplitGridNode"; export { SplitGridNode } from "./SplitGridNode";
export { OutputNode } from "./OutputNode"; export { OutputNode } from "./OutputNode";

Loading…
Cancel
Save