"use client"; import { useAtom, useAtomValue } from "jotai"; import { Check, ChevronDown, Search, SlidersHorizontal } from "lucide-react"; import { useRouter } from "next/navigation"; import type { UIEvent } from "react"; import { useCallback, useMemo, useState } from "react"; import { updateModelRolesMutationAtom } from "@/atoms/model-connections/model-connections-mutation.atoms"; import { globalModelConnectionsAtom, modelConnectionsAtom, modelRolesAtom, } from "@/atoms/model-connections/model-connections-query.atoms"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle, DrawerHeader, DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Spinner } from "@/components/ui/spinner"; import type { ConnectionRead, ModelRead } from "@/contracts/types/model-connections.types"; import { useIsMobile } from "@/hooks/use-mobile"; import { AUTO_PROVIDER_ICON_KEY, getProviderIcon } from "@/lib/provider-icons"; import { cn } from "@/lib/utils"; import { providerDisplay } from "../settings/model-connections/provider-metadata"; interface ModelSelectorProps { searchSpaceId: number; className?: string; onChatModelSelected?: () => void; } type ChatModel = ModelRead & { connectionId: number; connectionLabel: string; connectionScope: string; provider: string; }; const AUTO_CHAT_MODEL_ID = 0; function connectionLabel(connection: ConnectionRead) { if (connection.scope === "GLOBAL") return "Global"; return providerDisplay(connection.provider).name; } function flattenChatModels(connections: ConnectionRead[]) { return connections.flatMap((connection) => connection.models .filter((model) => model.enabled && Boolean(model.supports_chat)) .map((model) => ({ ...model, connectionId: connection.id, connectionLabel: connectionLabel(connection), connectionScope: connection.scope, provider: connection.provider, })) ); } function isFreeGlobalModel(model: ChatModel) { return model.connectionScope === "GLOBAL" && model.billing_tier?.toLowerCase() === "free"; } function modelName(model: ChatModel) { const name = model.display_name || model.model_id; if (model.connectionScope === "GLOBAL") { return name.replace(/\s+\(free\)$/i, ""); } return name; } function filterChatModels(models: ChatModel[], search: string) { const normalized = search.trim().toLowerCase(); if (!normalized) return models; return models.filter((model) => [modelName(model), model.model_id, model.connectionLabel] .join(" ") .toLowerCase() .includes(normalized) ); } function groupedModels(models: ChatModel[]) { return models.reduce>((groups, model) => { const key = model.connectionLabel; if (!groups[key]) groups[key] = []; groups[key].push(model); return groups; }, {}); } export function ModelSelector({ searchSpaceId, className, onChatModelSelected, }: ModelSelectorProps) { const router = useRouter(); const isMobile = useIsMobile(); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top"); const [{ data: globalConnections = [], isLoading: globalLoading }] = useAtom( globalModelConnectionsAtom ); const [{ data: connections = [], isLoading: connectionsLoading }] = useAtom(modelConnectionsAtom); const [{ data: roles }] = useAtom(modelRolesAtom); const updateRoles = useAtomValue(updateModelRolesMutationAtom); const allChatModels = useMemo( () => flattenChatModels([...globalConnections, ...connections]), [globalConnections, connections] ); const visibleChatModels = useMemo( () => filterChatModels(allChatModels, search), [allChatModels, search] ); const chatModelsById = useMemo( () => new Map(allChatModels.map((model) => [model.id, model])), [allChatModels] ); const selectedModelId = roles?.chat_model_id ?? AUTO_CHAT_MODEL_ID; const selected = chatModelsById.get(selectedModelId); const groups = useMemo(() => groupedModels(visibleChatModels), [visibleChatModels]); const loading = globalLoading || connectionsLoading; const hasSearchQuery = search.trim().length > 0; function handleOpenChange(nextOpen: boolean) { if (!nextOpen) setSearch(""); setOpen(nextOpen); } function selectModel(modelId: number) { updateRoles.mutate({ chat_model_id: modelId }); setSearch(""); setOpen(false); requestAnimationFrame(() => { onChatModelSelected?.(); }); } function manageModelConnections() { setOpen(false); router.push(`/dashboard/${searchSpaceId}/search-space-settings/models`); } const handleScroll = useCallback((event: UIEvent) => { const el = event.currentTarget; const atTop = el.scrollTop <= 2; const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); }, []); const content = (
setSearch(event.target.value)} placeholder="Search chat models" className="h-8 border-0 bg-transparent pl-6 text-sm shadow-none" />
{loading ? (
) : Object.keys(groups).length === 0 ? (
{hasSearchQuery ? "No matching chat models." : "No enabled chat models. Add or enable models in Settings."}
) : ( Object.entries(groups).map(([connection, models]) => (
{connection}
{models.map((model) => ( ))}
)) )}
); const trigger = ( ); if (isMobile) { return ( {trigger} Select Chat Model {content} ); } return ( {trigger} {content} ); }