"use client"; import { useMemo, useState, useCallback, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { Node } from "@xyflow/react"; import { useWorkflowStore, saveNanoBananaDefaults, useProviderApiKeys } from "@/store/workflowStore"; import { NodeType, NanoBananaNodeData, LLMGenerateNodeData, GenerateVideoNodeData, Generate3DNodeData, GenerateAudioNodeData, EaseCurveNodeData, ConditionalSwitchNodeData, AspectRatio, Resolution, ModelType, MODEL_DISPLAY_NAMES, ProviderType, SelectedModel, LLMProvider, LLMModelType, MatchMode, ConditionalSwitchRule } from "@/types"; import { ProviderModel, ModelCapability } from "@/lib/providers/types"; import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog"; import { ModelParameters } from "./ModelParameters"; import { CubicBezierEditor } from "@/components/CubicBezierEditor"; import { deduplicatedFetch } from "@/utils/deduplicatedFetch"; import { evaluateRule } from "@/store/utils/ruleEvaluation"; import { EASING_PRESETS, getPresetBezier, getEasingBezier } from "@/lib/easing-presets"; import { getAllEasingNames, getEasingFunction } from "@/lib/easing-functions"; import { getModelPageUrl, getProviderDisplayName } from "@/utils/providerUrls"; import { useInlineParameters } from "@/hooks/useInlineParameters"; // List of node types that have configurable parameters const CONFIGURABLE_NODE_TYPES: NodeType[] = [ "nanoBanana", "generateVideo", "generate3d", "generateAudio", "llmGenerate", "easeCurve", "conditionalSwitch", ]; // Generation node types that can use inline parameters const GENERATION_NODE_TYPES: NodeType[] = [ "nanoBanana", "generateVideo", "generate3d", "generateAudio", "llmGenerate", ]; // Base 10 aspect ratios (all Gemini image models) const BASE_ASPECT_RATIOS: AspectRatio[] = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"]; // Extended 14 aspect ratios (Nano Banana 2 adds extreme ratios) const EXTENDED_ASPECT_RATIOS: AspectRatio[] = ["1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"]; // Resolutions per model const RESOLUTIONS_PRO: Resolution[] = ["1K", "2K", "4K"]; const RESOLUTIONS_NB2: Resolution[] = ["512", "1K", "2K", "4K"]; // Hardcoded Gemini image models const GEMINI_IMAGE_MODELS: { value: ModelType; label: string }[] = [ { value: "nano-banana", label: "Nano Banana" }, { value: "nano-banana-2", label: "Nano Banana 2" }, { value: "nano-banana-pro", label: "Nano Banana Pro" }, ]; // LLM providers and models const LLM_PROVIDERS: { value: LLMProvider; label: string }[] = [ { value: "google", label: "Google" }, { value: "openai", label: "OpenAI" }, { value: "anthropic", label: "Anthropic" }, ]; const LLM_MODELS: Record = { google: [ { value: "gemini-3-flash-preview", label: "Gemini 3 Flash" }, { value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, { value: "gemini-3-pro-preview", label: "Gemini 3.0 Pro" }, { value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro" }, ], openai: [ { value: "gpt-4.1-mini", label: "GPT-4.1 Mini" }, { value: "gpt-4.1-nano", label: "GPT-4.1 Nano" }, ], anthropic: [ { value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5" }, { value: "claude-haiku-4.5", label: "Claude Haiku 4.5" }, { value: "claude-opus-4.6", label: "Claude Opus 4.6" }, ], }; // Image/video/audio/3d generation capabilities const IMAGE_CAPABILITIES: ModelCapability[] = ["text-to-image", "image-to-image"]; const VIDEO_CAPABILITIES: ModelCapability[] = ["text-to-video", "image-to-video"]; const AUDIO_CAPABILITIES: ModelCapability[] = ["text-to-audio"]; const MODEL_3D_CAPABILITIES: ModelCapability[] = ["text-to-3d", "image-to-3d"]; // Easing names const ALL_EASING_NAMES = getAllEasingNames(); const PRESET_NAMES = new Set(EASING_PRESETS); // Generate SVG polyline for easing preview function generateEasingPolyline( easingName: string, width: number, height: number, samples: number = 20 ): string { const fn = getEasingFunction(easingName); return Array.from({ length: samples + 1 }, (_, i) => { const t = i / samples; const y = fn(t); return `${(t * width).toFixed(1)},${((1 - y) * height).toFixed(1)}`; }).join(" "); } /** * Fixed-position control panel on the right side of viewport * Displays controls for the currently selected node */ export function ControlPanel() { const selectedNode = useWorkflowStore((state) => { const selected = state.nodes.filter((n) => n.selected); return selected.length === 1 ? selected[0] : null; }); const { inlineParametersEnabled } = useInlineParameters(); // Check if the selected node is configurable const isConfigurable = selectedNode && CONFIGURABLE_NODE_TYPES.includes(selectedNode.type as NodeType); // If no single node selected or not configurable, hide panel if (!selectedNode || !isConfigurable) { return null; } // Check if this is a generation node const isGenerationNode = selectedNode && GENERATION_NODE_TYPES.includes(selectedNode.type as NodeType); // Hide for generation nodes when inline parameters enabled if (isGenerationNode && inlineParametersEnabled) { return null; } return (
{/* Header */}

{getNodeTypeTitle(selectedNode.type as NodeType)}

{/* Node-specific controls */}
{selectedNode.type === "nanoBanana" && ( )} {selectedNode.type === "generateVideo" && ( )} {selectedNode.type === "generate3d" && ( )} {selectedNode.type === "generateAudio" && ( )} {selectedNode.type === "llmGenerate" && ( )} {selectedNode.type === "easeCurve" && ( )} {selectedNode.type === "conditionalSwitch" && ( )}
); } function getNodeTypeTitle(type: NodeType): string { const titles: Record = { nanoBanana: "Generate Image Settings", generateVideo: "Generate Video Settings", generate3d: "Generate 3D Settings", generateAudio: "Generate Audio Settings", llmGenerate: "LLM Settings", easeCurve: "Ease Curve Settings", conditionalSwitch: "Conditional Switch Settings", }; return titles[type] || "Settings"; } // Generate Image Controls function GenerateImageControls({ node }: { node: Node }) { const nodeData = node.data as NanoBananaNodeData; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const isRunning = useWorkflowStore((state) => state.isRunning); const { replicateApiKey, falApiKey, kieApiKey, replicateEnabled, kieEnabled } = useProviderApiKeys(); const [externalModels, setExternalModels] = useState([]); const [isLoadingModels, setIsLoadingModels] = useState(false); const [modelsFetchError, setModelsFetchError] = useState(null); const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false); const currentProvider: ProviderType = nodeData.selectedModel?.provider || "gemini"; // Get enabled providers const enabledProviders = useMemo(() => { const providers: { id: ProviderType; name: string }[] = []; providers.push({ id: "gemini", name: "Gemini" }); providers.push({ id: "fal", name: "fal.ai" }); if (replicateEnabled && replicateApiKey) { providers.push({ id: "replicate", name: "Replicate" }); } if (kieEnabled && kieApiKey) { providers.push({ id: "kie", name: "Kie.ai" }); } return providers; }, [replicateEnabled, replicateApiKey, kieEnabled, kieApiKey]); // Fetch models from external providers const fetchModels = useCallback(async () => { if (currentProvider === "gemini") { setExternalModels([]); setModelsFetchError(null); return; } setIsLoadingModels(true); setModelsFetchError(null); try { const capabilities = IMAGE_CAPABILITIES.join(","); const headers: HeadersInit = {}; if (replicateApiKey) headers["X-Replicate-Key"] = replicateApiKey; if (falApiKey) headers["X-Fal-Key"] = falApiKey; if (kieApiKey) headers["X-Kie-Key"] = kieApiKey; const response = await deduplicatedFetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers }); if (response.ok) { const data = await response.json(); setExternalModels(data.models || []); setModelsFetchError(null); } else { const errorData = await response.json().catch(() => ({})); const errorMsg = errorData.error || `Failed to load models (${response.status})`; setExternalModels([]); setModelsFetchError(errorMsg); } } catch (error) { console.error("Failed to fetch models:", error); setExternalModels([]); setModelsFetchError("Failed to load models. Check your connection."); } finally { setIsLoadingModels(false); } }, [currentProvider, replicateApiKey, falApiKey, kieApiKey]); useEffect(() => { fetchModels(); }, [fetchModels]); const handleProviderChange = useCallback( (e: React.ChangeEvent) => { const provider = e.target.value as ProviderType; if (provider === "gemini") { 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(node.id, { selectedModel: newSelectedModel, parameters: {} }); } else { const newSelectedModel: SelectedModel = { provider, modelId: "", displayName: "Select model...", }; updateNodeData(node.id, { selectedModel: newSelectedModel, parameters: {} }); } }, [node.id, nodeData.model, updateNodeData] ); const handleExternalModelChange = useCallback( (e: React.ChangeEvent) => { 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, capabilities: model.capabilities, }; updateNodeData(node.id, { selectedModel: newSelectedModel, parameters: {} }); } }, [node.id, currentProvider, externalModels, updateNodeData] ); const handleModelChange = useCallback( (e: React.ChangeEvent) => { const model = e.target.value as ModelType; updateNodeData(node.id, { model }); saveNanoBananaDefaults({ model }); const newSelectedModel: SelectedModel = { provider: "gemini", modelId: model, displayName: GEMINI_IMAGE_MODELS.find(m => m.value === model)?.label || model, }; updateNodeData(node.id, { selectedModel: newSelectedModel }); }, [node.id, updateNodeData] ); const handleAspectRatioChange = useCallback( (e: React.ChangeEvent) => { const aspectRatio = e.target.value as AspectRatio; updateNodeData(node.id, { aspectRatio }); saveNanoBananaDefaults({ aspectRatio }); }, [node.id, updateNodeData] ); const handleResolutionChange = useCallback( (e: React.ChangeEvent) => { const resolution = e.target.value as Resolution; updateNodeData(node.id, { resolution }); saveNanoBananaDefaults({ resolution }); }, [node.id, updateNodeData] ); const handleGoogleSearchToggle = useCallback( (e: React.ChangeEvent) => { const useGoogleSearch = e.target.checked; updateNodeData(node.id, { useGoogleSearch }); saveNanoBananaDefaults({ useGoogleSearch }); }, [node.id, updateNodeData] ); const handleImageSearchToggle = useCallback( (e: React.ChangeEvent) => { const useImageSearch = e.target.checked; updateNodeData(node.id, { useImageSearch }); saveNanoBananaDefaults({ useImageSearch }); }, [node.id, updateNodeData] ); const handleParametersChange = useCallback( (parameters: Record) => { updateNodeData(node.id, { parameters }); }, [node.id, updateNodeData] ); const handleBrowseModelSelect = useCallback((model: ProviderModel) => { const newSelectedModel: SelectedModel = { provider: model.provider, modelId: model.id, displayName: model.name, capabilities: model.capabilities, }; updateNodeData(node.id, { selectedModel: newSelectedModel, parameters: {} }); setIsBrowseDialogOpen(false); }, [node.id, updateNodeData]); const isGeminiProvider = currentProvider === "gemini"; const currentModelId = isGeminiProvider ? (nodeData.selectedModel?.modelId || nodeData.model) : null; const supportsResolution = currentModelId === "nano-banana-pro" || currentModelId === "nano-banana-2"; const aspectRatios = currentModelId === "nano-banana-2" ? EXTENDED_ASPECT_RATIOS : BASE_ASPECT_RATIOS; const resolutions = currentModelId === "nano-banana-2" ? RESOLUTIONS_NB2 : RESOLUTIONS_PRO; const hasExternalProviders = !!(replicateEnabled && replicateApiKey) || true; return ( <>
{/* Model name + provider with link — sits directly under title divider */}
{nodeData.selectedModel?.displayName || GEMINI_IMAGE_MODELS.find(m => m.value === nodeData.model)?.label || "Select model..."}
{enabledProviders.find(p => p.id === currentProvider)?.name || currentProvider} {nodeData.selectedModel?.modelId && ( { if (!getModelPageUrl(currentProvider, nodeData.selectedModel?.modelId || "")) { e.preventDefault(); } }} > )}
{/* Gemini-specific controls */} {isGeminiProvider && ( <>
{supportsResolution && (
)} {(currentModelId === "nano-banana-pro" || currentModelId === "nano-banana-2") && (
)} {currentModelId === "nano-banana-2" && (
)} )} {/* External provider parameters */} {!isGeminiProvider && nodeData.selectedModel?.modelId && ( )}
{isBrowseDialogOpen && ( setIsBrowseDialogOpen(false)} onModelSelected={handleBrowseModelSelect} initialCapabilityFilter="image" /> )} ); } // Generate Video Controls function GenerateVideoControls({ node }: { node: Node }) { const nodeData = node.data as GenerateVideoNodeData; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const isRunning = useWorkflowStore((state) => state.isRunning); const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false); const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal"; const handleParametersChange = useCallback( (parameters: Record) => { updateNodeData(node.id, { parameters }); }, [node.id, updateNodeData] ); const handleBrowseModelSelect = useCallback((model: ProviderModel) => { const newSelectedModel: SelectedModel = { provider: model.provider, modelId: model.id, displayName: model.name, capabilities: model.capabilities, }; updateNodeData(node.id, { selectedModel: newSelectedModel, parameters: {} }); setIsBrowseDialogOpen(false); }, [node.id, updateNodeData]); return ( <>
{/* Model name + provider with link */}
{nodeData.selectedModel?.displayName || "Select model..."}
{getProviderDisplayName(currentProvider)} {nodeData.selectedModel?.modelId && ( { if (!getModelPageUrl(currentProvider, nodeData.selectedModel?.modelId || "")) { e.preventDefault(); } }} > )}
{nodeData.selectedModel?.modelId && ( )}
{isBrowseDialogOpen && ( setIsBrowseDialogOpen(false)} onModelSelected={handleBrowseModelSelect} initialCapabilityFilter="video" /> )} ); } // Generate 3D Controls function Generate3DControls({ node }: { node: Node }) { const nodeData = node.data as Generate3DNodeData; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const isRunning = useWorkflowStore((state) => state.isRunning); const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false); const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal"; const handleParametersChange = useCallback( (parameters: Record) => { updateNodeData(node.id, { parameters }); }, [node.id, updateNodeData] ); const handleBrowseModelSelect = useCallback((model: ProviderModel) => { updateNodeData(node.id, { selectedModel: { provider: model.provider, modelId: model.id, displayName: model.name, capabilities: model.capabilities, }, parameters: {} }); setIsBrowseDialogOpen(false); }, [node.id, updateNodeData]); return ( <>
{/* Model name + provider with link */}
{nodeData.selectedModel?.displayName || "Select model..."}
{getProviderDisplayName(currentProvider)} {nodeData.selectedModel?.modelId && ( { if (!getModelPageUrl(currentProvider, nodeData.selectedModel?.modelId || "")) { e.preventDefault(); } }} > )}
{nodeData.selectedModel?.modelId && ( )}
{isBrowseDialogOpen && ( setIsBrowseDialogOpen(false)} onModelSelected={handleBrowseModelSelect} initialCapabilityFilter="3d" /> )} ); } // Generate Audio Controls function GenerateAudioControls({ node }: { node: Node }) { const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const isRunning = useWorkflowStore((state) => state.isRunning); return (
Audio generation settings
); } // LLM Controls function LLMControls({ node }: { node: Node }) { const nodeData = node.data as LLMGenerateNodeData; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const isRunning = useWorkflowStore((state) => state.isRunning); const handleProviderChange = useCallback( (e: React.ChangeEvent) => { const newProvider = e.target.value as LLMProvider; const firstModelForProvider = LLM_MODELS[newProvider][0].value; const updates: Partial = { provider: newProvider, model: firstModelForProvider, }; if (newProvider === "anthropic" && nodeData.temperature > 1) { updates.temperature = 1; } updateNodeData(node.id, updates); }, [node.id, updateNodeData, nodeData.temperature] ); const handleModelChange = useCallback( (e: React.ChangeEvent) => { updateNodeData(node.id, { model: e.target.value as LLMModelType }); }, [node.id, updateNodeData] ); const handleTemperatureChange = useCallback( (e: React.ChangeEvent) => { updateNodeData(node.id, { temperature: parseFloat(e.target.value) }); }, [node.id, updateNodeData] ); const handleMaxTokensChange = useCallback( (e: React.ChangeEvent) => { updateNodeData(node.id, { maxTokens: parseInt(e.target.value, 10) }); }, [node.id, updateNodeData] ); const provider = nodeData.provider || "google"; const availableModels = LLM_MODELS[provider] || LLM_MODELS.google; return (
); } // Ease Curve Controls function EaseCurveControls({ node }: { node: Node }) { const nodeData = node.data as EaseCurveNodeData; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const isRunning = useWorkflowStore((state) => state.isRunning); const edges = useWorkflowStore((state) => state.edges); const removeEdge = useWorkflowStore((state) => state.removeEdge); const [showPresets, setShowPresets] = useState(false); const presetsButtonRef = useRef(null); const inheritedEdge = useMemo(() => { return edges.find((e) => e.target === node.id && e.targetHandle === "easeCurve") || null; }, [edges, node.id]); const isInherited = !!inheritedEdge; const handleBreakInheritance = useCallback(() => { if (inheritedEdge) { removeEdge(inheritedEdge.id); updateNodeData(node.id, { inheritedFrom: null }); } }, [inheritedEdge, removeEdge, node.id, updateNodeData]); const handleBezierChange = useCallback( (value: [number, number, number, number]) => { updateNodeData(node.id, { bezierHandles: value, easingPreset: null }); }, [node.id, updateNodeData] ); const handleSelectEasing = useCallback( (name: string) => { updateNodeData(node.id, { easingPreset: name, bezierHandles: getEasingBezier(name), }); setShowPresets(false); }, [node.id, updateNodeData] ); const handleDurationChange = useCallback( (e: React.ChangeEvent) => { const val = parseFloat(e.target.value); updateNodeData(node.id, { outputDuration: isNaN(val) ? 1.5 : Math.max(0.1, Math.min(30, val)) }); }, [node.id, updateNodeData] ); const editorEasingCurve = useMemo(() => { if (!nodeData.easingPreset) return undefined; return generateEasingPolyline(nodeData.easingPreset, 100, 100, 50); }, [nodeData.easingPreset]); const presetThumbnails = useMemo(() => { return ALL_EASING_NAMES.map((name) => ({ name, polyline: generateEasingPolyline(name, 36, 36), isPreset: PRESET_NAMES.has(name as any), })); }, []); return (
{isInherited && (

Settings inherited

Break connection to edit manually

)}
{nodeData.easingPreset && (
Current: {nodeData.easingPreset}
)}
{showPresets && typeof document !== 'undefined' && createPortal(
{presetThumbnails.map(({ name, polyline }) => ( ))}
, document.body )}
); } // Conditional Switch Controls function ConditionalSwitchControls({ node }: { node: Node }) { const nodeData = node.data as ConditionalSwitchNodeData; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const [editingId, setEditingId] = useState(null); const handleRuleValueChange = useCallback( (ruleId: string, newValue: string) => { const updatedRules = nodeData.rules.map((rule) => rule.id === ruleId ? { ...rule, value: newValue } : rule ); updateNodeData(node.id, { rules: updatedRules, evaluationPaused: false }); }, [node.id, nodeData.rules, updateNodeData] ); const handleModeChange = useCallback( (ruleId: string, newMode: MatchMode) => { const updatedRules = nodeData.rules.map((rule) => rule.id === ruleId ? { ...rule, mode: newMode } : rule ); updateNodeData(node.id, { rules: updatedRules, evaluationPaused: false }); }, [node.id, nodeData.rules, updateNodeData] ); const handleLabelEdit = useCallback( (ruleId: string, newLabel: string) => { const updatedRules = nodeData.rules.map((rule) => rule.id === ruleId ? { ...rule, label: newLabel } : rule ); updateNodeData(node.id, { rules: updatedRules }); setEditingId(null); }, [node.id, nodeData.rules, updateNodeData] ); const handleDelete = useCallback( (ruleId: string) => { if (nodeData.rules.length <= 1) return; const updatedRules = nodeData.rules.filter((rule) => rule.id !== ruleId); updateNodeData(node.id, { rules: updatedRules }); }, [node.id, nodeData.rules, updateNodeData] ); const handleAddRule = useCallback(() => { const newRule: ConditionalSwitchRule = { id: "rule-" + Math.random().toString(36).slice(2, 9), value: "", mode: "contains", label: `Rule ${nodeData.rules.length + 1}`, isMatched: false, }; updateNodeData(node.id, { rules: [...nodeData.rules, newRule] }); }, [node.id, nodeData.rules, updateNodeData]); return (
{nodeData.rules.map((rule, index) => (
handleLabelEdit(rule.id, e.target.value)} onFocus={() => setEditingId(rule.id)} onBlur={() => setEditingId(null)} className="nodrag nopan flex-1 px-1 py-0.5 text-xs bg-transparent border-none text-neutral-200 focus:outline-none" /> {nodeData.rules.length > 1 && ( )}
handleRuleValueChange(rule.id, e.target.value)} placeholder="Enter match value" className="nodrag nopan w-full px-2 py-1 text-xs bg-neutral-700 border border-neutral-600 rounded text-neutral-200 focus:outline-none focus:ring-1 focus:ring-blue-500" /> {rule.isMatched !== undefined && (
{rule.isMatched ? 'Matched' : 'Not matched'}
)}
))}
); }