"use client"; import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { createPortal } from "react-dom"; import { useWorkflowStore, useProviderApiKeys } from "@/store/workflowStore"; import { deduplicatedFetch } from "@/utils/deduplicatedFetch"; import { useReactFlow } from "@xyflow/react"; import { ProviderType, RecentModel } from "@/types"; import { ProviderModel, ModelCapability } from "@/lib/providers/types"; // localStorage cache for models (persists across dev server restarts) const MODELS_CACHE_KEY = "node-banana-models-cache"; const MODELS_CACHE_TTL = 48 * 60 * 60 * 1000; // 48 hours interface ModelsCacheEntry { models: ProviderModel[]; timestamp: number; } function getCachedModels(cacheKey: string): ModelsCacheEntry | null { try { const cache = JSON.parse(localStorage.getItem(MODELS_CACHE_KEY) || "{}"); const entry = cache[cacheKey]; if (entry && Date.now() - entry.timestamp < MODELS_CACHE_TTL) { return entry; } } catch { // Ignore cache errors } return null; } function setCachedModels(cacheKey: string, models: ProviderModel[]) { try { const cache = JSON.parse(localStorage.getItem(MODELS_CACHE_KEY) || "{}"); cache[cacheKey] = { models, timestamp: Date.now() }; localStorage.setItem(MODELS_CACHE_KEY, JSON.stringify(cache)); } catch { // Ignore cache errors } } // Provider icons — all normalized to w-3.5 h-3.5 with viewBoxes cropped to fill consistently const ReplicateIcon = () => ( ); const FalIcon = () => ( ); const GeminiIcon = () => ( ); const KieIcon = () => ( ); const WaveSpeedIcon = () => ( ); // Get the center of the React Flow pane in screen coordinates function getPaneCenter() { const pane = document.querySelector(".react-flow"); if (pane) { const rect = pane.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, }; } return { x: window.innerWidth / 2, y: window.innerHeight / 2 }; } // Capability filter options type CapabilityFilter = "all" | "image" | "video"; // API response type interface ModelsResponse { success: boolean; models?: ProviderModel[]; error?: string; } interface ModelSearchDialogProps { isOpen: boolean; onClose: () => void; initialProvider?: ProviderType | null; /** When provided, calls this callback instead of creating a new node */ onModelSelected?: (model: ProviderModel) => void; /** Initial capability filter - 'image' for image nodes, 'video' for video nodes */ initialCapabilityFilter?: CapabilityFilter; } export function ModelSearchDialog({ isOpen, onClose, initialProvider, onModelSelected, initialCapabilityFilter, }: ModelSearchDialogProps) { const { addNode, incrementModalCount, decrementModalCount, recentModels, trackModelUsage, } = useWorkflowStore(); // Use stable selector for API keys to prevent unnecessary re-fetches const { replicateApiKey, falApiKey, kieApiKey, wavespeedApiKey } = useProviderApiKeys(); const { screenToFlowPosition } = useReactFlow(); // State const [searchQuery, setSearchQuery] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [providerFilter, setProviderFilter] = useState( initialProvider || "all" ); const [capabilityFilter, setCapabilityFilter] = useState(initialCapabilityFilter || "all"); const [models, setModels] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); // Refs const searchInputRef = useRef(null); // Track request version to ignore stale responses const requestVersionRef = useRef(0); // Register modal with store useEffect(() => { if (isOpen) { incrementModalCount(); return () => decrementModalCount(); } }, [isOpen, incrementModalCount, decrementModalCount]); // Debounce search query useEffect(() => { const timer = setTimeout(() => { setDebouncedSearch(searchQuery); }, 300); return () => clearTimeout(timer); }, [searchQuery]); // Update provider filter when initialProvider changes useEffect(() => { if (initialProvider) { setProviderFilter(initialProvider); } }, [initialProvider]); // Fetch models const fetchModels = useCallback(async () => { // Increment version to track this request const thisVersion = ++requestVersionRef.current; // Build cache key from filters const cacheKey = `${providerFilter}:${capabilityFilter}:${debouncedSearch}`; // Check localStorage cache first const cached = getCachedModels(cacheKey); if (cached) { setModels(cached.models); return; } setIsLoading(true); setError(null); try { // Build query params const params = new URLSearchParams(); if (debouncedSearch) { params.set("search", debouncedSearch); } if (providerFilter !== "all") { params.set("provider", providerFilter); } if (capabilityFilter !== "all") { const capabilities = capabilityFilter === "image" ? "text-to-image,image-to-image" : "text-to-video,image-to-video"; params.set("capabilities", capabilities); } // Build headers with API keys const headers: Record = {}; if (replicateApiKey) { headers["X-Replicate-Key"] = replicateApiKey; } if (falApiKey) { headers["X-Fal-Key"] = falApiKey; } if (kieApiKey) { headers["X-Kie-Key"] = kieApiKey; } if (wavespeedApiKey) { headers["X-WaveSpeed-Key"] = wavespeedApiKey; } const response = await deduplicatedFetch(`/api/models?${params.toString()}`, { headers, }); // Check if this request is still current if (thisVersion !== requestVersionRef.current) { return; // Ignore stale response } const data: ModelsResponse = await response.json(); if (data.success && data.models) { setModels(data.models); // Cache the successful result setCachedModels(cacheKey, data.models); } else { setError(data.error || "Failed to fetch models"); setModels([]); } } catch (err) { // Check if this request is still current if (thisVersion !== requestVersionRef.current) { return; // Ignore stale error } setError(err instanceof Error ? err.message : "Failed to fetch models"); setModels([]); } finally { // Only update loading state if this is still the current request if (thisVersion === requestVersionRef.current) { setIsLoading(false); } } }, [debouncedSearch, providerFilter, capabilityFilter, replicateApiKey, falApiKey, kieApiKey, wavespeedApiKey]); // Fetch models when filters change useEffect(() => { if (isOpen) { fetchModels(); } }, [isOpen, fetchModels]); // Focus search input when dialog opens useEffect(() => { if (isOpen && searchInputRef.current) { searchInputRef.current.focus(); } }, [isOpen]); // Handle model selection const handleSelectModel = useCallback( (model: ProviderModel) => { // Track model usage for "recently used" feature trackModelUsage({ provider: model.provider, modelId: model.id, displayName: model.name, }); // If onModelSelected is provided, use it to update an existing node if (onModelSelected) { onModelSelected(model); onClose(); return; } // Otherwise, create a new node const center = getPaneCenter(); const position = screenToFlowPosition({ x: center.x + Math.random() * 100 - 50, y: center.y + Math.random() * 100 - 50, }); // Determine node type based on model capabilities const isVideoModel = model.capabilities.some( (cap) => cap === "text-to-video" || cap === "image-to-video" ); const nodeType = isVideoModel ? "generateVideo" : "nanoBanana"; addNode(nodeType, position, { selectedModel: { provider: model.provider, modelId: model.id, displayName: model.name, }, }); onClose(); }, [screenToFlowPosition, addNode, onClose, onModelSelected, trackModelUsage] ); // Handle escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { onClose(); } }; if (isOpen) { window.addEventListener("keydown", handleKeyDown); } return () => { window.removeEventListener("keydown", handleKeyDown); }; }, [isOpen, onClose]); // Handle backdrop click const handleBackdropClick = useCallback( (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose(); } }, [onClose] ); // Get provider badge color const getProviderBadgeColor = (provider: ProviderType) => { switch (provider) { case "gemini": return "bg-green-500/20 text-green-300"; case "replicate": return "bg-blue-500/20 text-blue-300"; case "fal": return "bg-yellow-500/20 text-yellow-300"; case "kie": return "bg-orange-500/20 text-orange-300"; case "wavespeed": return "bg-purple-500/20 text-purple-300"; default: return "bg-neutral-500/20 text-neutral-300"; } }; // Get provider display name const getProviderDisplayName = (provider: ProviderType) => { switch (provider) { case "gemini": return "Gemini"; case "replicate": return "Replicate"; case "fal": return "fal.ai"; case "kie": return "Kie.ai"; case "wavespeed": return "WaveSpeed"; default: return provider; } }; // Filter recent models by capability const filteredRecentModels = useMemo(() => { return recentModels .filter((recent) => { // Find matching model in current models list to check capabilities const matchingModel = models.find((m) => m.id === recent.modelId); if (!matchingModel && capabilityFilter !== "all") { // If model not loaded yet and filter is active, exclude it return false; } if (capabilityFilter === "all") return true; if (!matchingModel) return true; // Show if we can't verify capabilities const isImage = matchingModel.capabilities.some( (cap) => cap === "text-to-image" || cap === "image-to-image" ); const isVideo = matchingModel.capabilities.some( (cap) => cap === "text-to-video" || cap === "image-to-video" ); if (capabilityFilter === "image") return isImage; if (capabilityFilter === "video") return isVideo; return true; }) .slice(0, 4); // Show max 4 }, [recentModels, models, capabilityFilter]); // Get display name with suffix for fal.ai models to differentiate variants const getDisplayName = (model: ProviderModel): string => { if (model.provider === "fal") { // Extract the last segment of the ID (e.g., "effects" from "kling-video/v1.6/pro/effects") const segments = model.id.split("/"); const lastSegment = segments[segments.length - 1]; // Only add suffix if it's not already in the name (case-insensitive) if (lastSegment && !model.name.toLowerCase().includes(lastSegment.toLowerCase())) { return `${model.name} - ${lastSegment}`; } } return model.name; }; // Get model page URL for the provider's website const getModelUrl = (model: ProviderModel): string | null => { if (model.pageUrl) return model.pageUrl; switch (model.provider) { case "replicate": return `https://replicate.com/${model.id}`; case "fal": return `https://fal.ai/models/${model.id}`; case "wavespeed": return `https://wavespeed.ai`; default: return null; } }; // Get capability badges - show all capabilities to differentiate similar models const getCapabilityBadges = (capabilities: ModelCapability[]) => { const badges: React.ReactNode[] = []; capabilities.forEach((cap) => { let color = ""; let label = ""; switch (cap) { case "text-to-image": color = "bg-green-500/20 text-green-300"; label = "txt→img"; break; case "image-to-image": color = "bg-cyan-500/20 text-cyan-300"; label = "img→img"; break; case "text-to-video": color = "bg-purple-500/20 text-purple-300"; label = "txt→vid"; break; case "image-to-video": color = "bg-pink-500/20 text-pink-300"; label = "img→vid"; break; } if (label) { badges.push( {label} ); } }); return badges; }; if (!isOpen) return null; const dialogContent = (
{/* Header */}

Browse Models

{/* Filter Bar */}
{/* Search Input */}
setSearchQuery(e.target.value)} placeholder="Search models..." className="w-full pl-10 pr-4 py-2 text-sm bg-neutral-700 border border-neutral-600 rounded text-neutral-100 placeholder:text-neutral-400 focus:outline-none focus:ring-1 focus:ring-neutral-500" />
{/* Provider Filter - Icon Buttons */}
{/* Capability Filter */}
{/* Model List */}
{isLoading ? (
Loading models...
) : error ? (

{error}

) : models.length === 0 ? (

No models found

Try adjusting your search or filters

) : (
{/* Recently Used Section */} {filteredRecentModels.length > 0 && !searchQuery && (

Recently Used

{filteredRecentModels.map((recent) => { const matchingModel = models.find( (m) => m.id === recent.modelId ); // Create a ProviderModel from RecentModel for handleSelectModel const model: ProviderModel = matchingModel || { id: recent.modelId, name: recent.displayName, description: null, provider: recent.provider, capabilities: [], }; return ( ); })}
)} {/* Main Model List */}
{models.map((model) => ( ))}
)}
{/* Footer with model count */} {!isLoading && !error && models.length > 0 && (
{models.length} model{models.length !== 1 ? "s" : ""} found
)}
); // Use portal to render outside React Flow stacking context return createPortal(dialogContent, document.body); }