"use client"; import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { createPortal } from "react-dom"; import { useWorkflowStore, useProviderApiKeys } from "@/store/workflowStore"; import { deduplicatedFetch, clearFetchCache } 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[]; availableProviders?: string[]; 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[], availableProviders?: string[]) { try { const cache = JSON.parse(localStorage.getItem(MODELS_CACHE_KEY) || "{}"); cache[cacheKey] = { models, availableProviders, 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" | "3d" | "audio"; // API response type interface ModelsResponse { success: boolean; models?: ProviderModel[]; /** Providers with API keys configured (env or client header) */ availableProviders?: string[]; 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 [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); const [serverAvailableProviders, setServerAvailableProviders] = useState([]); // 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 (bypassCache = false) => { // Increment version to track this request const thisVersion = ++requestVersionRef.current; // Build cache key from filters const cacheKey = `${providerFilter}:${capabilityFilter}:${debouncedSearch}`; // Check localStorage cache first (skip when bypassing) if (!bypassCache) { const cached = getCachedModels(cacheKey); if (cached) { setModels(cached.models); if (cached.availableProviders) { setServerAvailableProviders(cached.availableProviders); } 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" : capabilityFilter === "video" ? "text-to-video,image-to-video,audio-to-video" : capabilityFilter === "3d" ? "text-to-3d,image-to-3d" : "text-to-audio"; params.set("capabilities", capabilities); } if (bypassCache) { params.set("refresh", "true"); } // 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 (including available providers) setCachedModels(cacheKey, data.models, data.availableProviders); // Update server-reported available providers if (data.availableProviders) { setServerAvailableProviders(data.availableProviders); } } 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]); // Clear all caches and re-fetch models from scratch const handleRefresh = useCallback(async () => { setIsRefreshing(true); try { // Clear localStorage model cache localStorage.removeItem(MODELS_CACHE_KEY); // Clear localStorage schema cache (keep in sync with ModelParameters.tsx) localStorage.removeItem("node-banana-schema-cache"); // Clear in-memory deduplicatedFetch cache clearFetchCache(); // Re-fetch with cache bypass await fetchModels(true); } finally { setIsRefreshing(false); } }, [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" || cap === "audio-to-video" ); const is3DModel = model.capabilities.some( (cap) => cap === "text-to-3d" || cap === "image-to-3d" ); const isAudioModel = model.capabilities.some( (cap) => cap === "text-to-audio" ); const nodeType = isVideoModel ? "generateVideo" : is3DModel ? "generate3d" : isAudioModel ? "generateAudio" : "nanoBanana"; addNode(nodeType, position, { selectedModel: { provider: model.provider, modelId: model.id, displayName: model.name, capabilities: model.capabilities, }, }); 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; } }; // Compute which providers are available based on client API keys + server env vars const availableProviders = useMemo(() => { const providers = new Set(["gemini", "fal"]); // Always available // Client-side keys (from localStorage/provider settings) if (replicateApiKey) providers.add("replicate"); if (kieApiKey) providers.add("kie"); if (wavespeedApiKey) providers.add("wavespeed"); // Server-side keys (from env vars, reported by /api/models) for (const p of serverAvailableProviders) { providers.add(p as ProviderType); } return providers; }, [replicateApiKey, kieApiKey, wavespeedApiKey, serverAvailableProviders]); // Reset provider filter if current selection becomes unavailable useEffect(() => { if (providerFilter !== "all" && !availableProviders.has(providerFilter as ProviderType)) { setProviderFilter("all"); } }, [providerFilter, availableProviders]); // 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" || cap === "audio-to-video" ); const is3D = matchingModel.capabilities.some( (cap) => cap === "text-to-3d" || cap === "image-to-3d" ); const isAudio = matchingModel.capabilities.some( (cap) => cap === "text-to-audio" ); if (capabilityFilter === "image") return isImage; if (capabilityFilter === "video") return isVideo; if (capabilityFilter === "3d") return is3D; if (capabilityFilter === "audio") return isAudio; 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; case "text-to-3d": color = "bg-orange-500/20 text-orange-300"; label = "txt→3d"; break; case "image-to-3d": color = "bg-amber-500/20 text-amber-300"; label = "img→3d"; break; case "text-to-audio": color = "bg-fuchsia-500/20 text-fuchsia-300"; label = "txt→audio"; 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 (only show available providers) */} setProviderFilter("all")} title="All Providers" className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${ providerFilter === "all" ? "bg-neutral-600 text-neutral-100" : "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-700" }`} > All {availableProviders.has("gemini") && ( setProviderFilter("gemini")} title="Gemini" className={`p-2 rounded transition-colors ${ providerFilter === "gemini" ? "bg-green-500/20 text-green-300" : "text-neutral-400 hover:text-green-300 hover:bg-neutral-700" }`} > )} {availableProviders.has("replicate") && ( setProviderFilter("replicate")} title="Replicate" className={`p-2 rounded transition-colors ${ providerFilter === "replicate" ? "bg-blue-500/20 text-blue-300" : "text-neutral-400 hover:text-blue-300 hover:bg-neutral-700" }`} > )} {availableProviders.has("fal") && ( setProviderFilter("fal")} title="fal.ai" className={`p-2 rounded transition-colors ${ providerFilter === "fal" ? "bg-yellow-500/20 text-yellow-300" : "text-neutral-400 hover:text-yellow-300 hover:bg-neutral-700" }`} > )} {availableProviders.has("kie") && ( setProviderFilter("kie")} title="Kie.ai" className={`p-2 rounded transition-colors ${ providerFilter === "kie" ? "bg-orange-500/20 text-orange-300" : "text-neutral-400 hover:text-orange-300 hover:bg-neutral-700" }`} > )} {availableProviders.has("wavespeed") && ( setProviderFilter("wavespeed")} title="WaveSpeed" className={`p-2 rounded transition-colors ${ providerFilter === "wavespeed" ? "bg-purple-500/20 text-purple-300" : "text-neutral-400 hover:text-purple-300 hover:bg-neutral-700" }`} > )} {/* Capability Filter */} setCapabilityFilter(e.target.value as CapabilityFilter) } className="px-3 py-2 text-sm bg-neutral-700 border border-neutral-600 rounded text-neutral-100 focus:outline-none focus:ring-1 focus:ring-neutral-500" > All Types Image Video 3D Audio {/* Refresh Cache */} {/* Model List */} {isLoading ? ( Loading models... ) : error ? ( {error} Try Again ) : 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 ( handleSelectModel(model)} className="flex items-center gap-3 p-3 bg-neutral-700/50 hover:bg-neutral-700 border border-neutral-600/30 hover:border-neutral-500 rounded-lg transition-colors text-left cursor-pointer group" > {/* Small cover image */} {matchingModel?.coverImage ? ( ) : ( )} {recent.displayName} {getProviderDisplayName(recent.provider)} ); })} )} {/* Main Model List */} {models.map((model) => ( handleSelectModel(model)} className="flex items-start gap-3 p-4 bg-neutral-700/50 hover:bg-neutral-700 border border-neutral-600/50 hover:border-neutral-500 rounded-lg transition-colors text-left cursor-pointer group" > {/* Cover Image - larger */} {model.coverImage ? ( { // Hide broken images (e.target as HTMLImageElement).style.display = "none"; }} /> ) : ( )} {/* Model Info */} {/* Model name with variant suffix for fal.ai */} {getDisplayName(model)} {/* Model ID with link to provider page */} {model.id} {getModelUrl(model) && ( e.stopPropagation()} className="text-neutral-500 hover:text-neutral-300 transition-colors flex-shrink-0" title={`View on ${getProviderDisplayName(model.provider)}`} > )} {/* Badges row */} {getProviderDisplayName(model.provider)} {getCapabilityBadges(model.capabilities)} {/* Description - more lines */} {model.description && ( {model.description} )} {/* Hover indicator */} ))} )} {/* 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); }
{error}
No models found
Try adjusting your search or filters
{model.description}