You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

587 lines
20 KiB

"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<ProviderType | "all">(
initialProvider || "all"
);
const [capabilityFilter, setCapabilityFilter] =
useState<CapabilityFilter>("all");
const [models, setModels] = useState<ProviderModel[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Refs
const searchInputRef = useRef<HTMLInputElement>(null);
const abortControllerRef = useRef<AbortController | null>(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<string, string> = {};
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<HTMLDivElement>) => {
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(
<span
key={cap}
className={`text-[10px] px-1.5 py-0.5 rounded ${color}`}
>
{label}
</span>
);
}
});
return badges;
};
if (!isOpen) return null;
const dialogContent = (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60"
onClick={handleBackdropClick}
>
<div className="relative bg-neutral-800 border border-neutral-700 rounded-lg shadow-2xl w-full max-w-5xl max-h-[85vh] flex flex-col mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-700">
<h2 className="text-lg font-semibold text-neutral-100">
Browse Models
</h2>
<button
onClick={onClose}
className="p-1.5 text-neutral-400 hover:text-neutral-100 hover:bg-neutral-700 rounded transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Filter Bar */}
<div className="px-6 py-4 border-b border-neutral-700">
<div className="flex flex-col sm:flex-row gap-3">
{/* Search Input */}
<div className="flex-1 relative">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => 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"
/>
</div>
{/* Provider Filter */}
<select
value={providerFilter}
onChange={(e) =>
setProviderFilter(e.target.value as ProviderType | "all")
}
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"
>
<option value="all">All Providers</option>
<option value="replicate">Replicate</option>
<option value="fal">fal.ai</option>
</select>
{/* Capability Filter */}
<select
value={capabilityFilter}
onChange={(e) =>
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"
>
<option value="all">All Types</option>
<option value="image">Image</option>
<option value="video">Video</option>
</select>
</div>
</div>
{/* Model List */}
<div className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className="flex items-center justify-center h-48">
<div className="flex flex-col items-center gap-3">
<svg
className="w-8 h-8 animate-spin text-neutral-400"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span className="text-sm text-neutral-400">
Loading models...
</span>
</div>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center h-48 gap-3">
<svg
className="w-10 h-10 text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<p className="text-sm text-neutral-400 text-center max-w-xs">
{error}
</p>
<button
onClick={fetchModels}
className="px-3 py-1.5 text-sm bg-neutral-700 hover:bg-neutral-600 text-neutral-200 rounded transition-colors"
>
Try Again
</button>
</div>
) : models.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-2">
<svg
className="w-10 h-10 text-neutral-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="text-sm text-neutral-400">No models found</p>
<p className="text-xs text-neutral-500">
Try adjusting your search or filters
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{models.map((model) => (
<button
key={`${model.provider}-${model.id}`}
onClick={() => 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 */}
<div className="w-20 h-20 rounded bg-neutral-600 overflow-hidden flex-shrink-0">
{model.coverImage ? (
<img
src={model.coverImage}
alt={model.name}
className="w-full h-full object-cover"
onError={(e) => {
// Hide broken images
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<svg
className="w-8 h-8 text-neutral-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
)}
</div>
{/* Model Info */}
<div className="flex-1 min-w-0">
{/* Model name with variant suffix for fal.ai */}
<div className="font-medium text-neutral-100 text-sm truncate">
{getDisplayName(model)}
</div>
{/* Model ID with link to provider page */}
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-neutral-500 truncate font-mono">
{model.id}
</span>
<a
href={getModelUrl(model)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-neutral-500 hover:text-neutral-300 transition-colors flex-shrink-0"
title={`View on ${model.provider === "fal" ? "fal.ai" : "Replicate"}`}
>
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>
{/* Badges row */}
<div className="flex items-center gap-1.5 flex-wrap mt-1.5">
<span
className={`text-[10px] px-1.5 py-0.5 rounded ${getProviderBadgeColor(model.provider)}`}
>
{model.provider === "fal" ? "fal.ai" : "Replicate"}
</span>
{getCapabilityBadges(model.capabilities)}
</div>
{/* Description - more lines */}
{model.description && (
<p className="mt-1.5 text-xs text-neutral-400 line-clamp-3">
{model.description}
</p>
)}
</div>
{/* Hover indicator */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0 self-center">
<svg
className="w-5 h-5 text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</div>
</button>
))}
</div>
)}
</div>
{/* Footer with model count */}
{!isLoading && !error && models.length > 0 && (
<div className="px-6 py-3 border-t border-neutral-700 text-xs text-neutral-400">
{models.length} model{models.length !== 1 ? "s" : ""} found
</div>
)}
</div>
</div>
);
// Use portal to render outside React Flow stacking context
return createPortal(dialogContent, document.body);
}