"use client"; import React, { useState, useEffect, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; import { useWorkflowStore } from "@/store/workflowStore"; import { useReactFlow } from "@xyflow/react"; import { ProviderType } from "@/types"; import { ProviderModel, ModelCapability } from "@/lib/providers/types"; // 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; } export function ModelSearchDialog({ isOpen, onClose, initialProvider, }: ModelSearchDialogProps) { const { providerSettings, addNode, incrementModalCount, decrementModalCount, } = useWorkflowStore(); const { screenToFlowPosition } = useReactFlow(); // State const [searchQuery, setSearchQuery] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [providerFilter, setProviderFilter] = useState( initialProvider || "all" ); const [capabilityFilter, setCapabilityFilter] = useState("all"); const [models, setModels] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); // Refs const searchInputRef = useRef(null); const abortControllerRef = useRef(null); // 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 () => { // Cancel previous request if (abortControllerRef.current) { abortControllerRef.current.abort(); } abortControllerRef.current = new AbortController(); 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 = {}; const replicateKey = providerSettings.providers.replicate?.apiKey; const falKey = providerSettings.providers.fal?.apiKey; if (replicateKey) { headers["X-Replicate-Key"] = replicateKey; } if (falKey) { headers["X-Fal-Key"] = falKey; } const response = await fetch(`/api/models?${params.toString()}`, { headers, signal: abortControllerRef.current.signal, }); const data: ModelsResponse = await response.json(); if (data.success && data.models) { setModels(data.models); } else { setError(data.error || "Failed to fetch models"); setModels([]); } } catch (err) { if (err instanceof Error && err.name === "AbortError") { return; // Ignore aborted requests } setError(err instanceof Error ? err.message : "Failed to fetch models"); setModels([]); } finally { setIsLoading(false); } }, [debouncedSearch, providerFilter, capabilityFilter, providerSettings]); // 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) => { const center = getPaneCenter(); const position = screenToFlowPosition({ x: center.x + Math.random() * 100 - 50, y: center.y + Math.random() * 100 - 50, }); addNode("nanoBanana", position, { selectedModel: { provider: model.provider, modelId: model.id, displayName: model.name, }, }); onClose(); }, [screenToFlowPosition, addNode, onClose] ); // 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 "replicate": return "bg-blue-500/20 text-blue-300"; case "fal": return "bg-yellow-500/20 text-yellow-300"; default: return "bg-neutral-500/20 text-neutral-300"; } }; // 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 => { if (model.provider === "replicate") { return `https://replicate.com/${model.id}`; } else if (model.provider === "fal") { return `https://fal.ai/models/${model.id}`; } return "#"; }; // 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 */} {/* Capability Filter */}
{/* Model List */}
{isLoading ? (
Loading models...
) : error ? (

{error}

) : models.length === 0 ? (

No models found

Try adjusting your search or filters

) : (
{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); }