"use client"; import { useState, useMemo, useEffect } from "react"; import { useTranslations } from "next-intl"; import Modal from "./Modal"; import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels"; import { getModelCatalogSourceLabel, matchesModelCatalogQuery, normalizeModelCatalogSource, } from "@/shared/utils/modelCatalogSearch"; import { OAUTH_PROVIDERS, NOAUTH_PROVIDERS, APIKEY_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, } from "@/shared/constants/providers"; // Provider order: OAuth first, then no-auth, then API Key (matches dashboard/providers) const PROVIDER_ORDER = [ ...Object.keys(OAUTH_PROVIDERS), ...Object.keys(NOAUTH_PROVIDERS), ...Object.keys(APIKEY_PROVIDERS), ]; type ModelSelectModalProps = { isOpen: boolean; onClose: () => void; onSelect: (model: unknown) => void; /** * Optional toggle callback — when set, clicking a model already in * `addedModelValues` invokes this instead of `onSelect`, so the modal acts * as an in-place add/remove toggle. Ported from upstream PR * decolua/9router#889 (Fajar Hidayat). */ onDeselect?: (model: unknown) => void; selectedModel?: string; selectedModels?: string[]; activeProviders?: Array<{ provider: string }>; title?: string; modelAliases?: Record; addedModelValues?: string[]; multiSelect?: boolean; showCombos?: boolean; alwaysIncludeProviders?: string[] | null; /** * When true, picking a model does NOT auto-close the modal — the caller must close * explicitly. A "Done" button is rendered in the modal footer so the user has a clear * way to confirm they are finished adding entries. Useful in combo creation, where the * user typically adds several models in a row. Mutually exclusive with `multiSelect` * (which renders its own Clear + Done footer driven by `selectedModels`). * Inspired by upstream PR decolua/9router#1031. Combined with `onDeselect`, this also * enables the toggle-style deselection from upstream PR decolua/9router#889. */ keepOpenOnSelect?: boolean; }; export default function ModelSelectModal({ isOpen, onClose, onSelect, onDeselect, selectedModel, selectedModels = [], activeProviders = [], title, modelAliases = {}, addedModelValues = [], multiSelect = false, showCombos = true, alwaysIncludeProviders = [], keepOpenOnSelect = false, }: ModelSelectModalProps) { const t = useTranslations("common"); const resolvedTitle = title ?? t("selectModel"); const [searchQuery, setSearchQuery] = useState(""); const [combos, setCombos] = useState([]); const [providerNodes, setProviderNodes] = useState([]); const [customModels, setCustomModels] = useState>({}); const fetchCombos = async () => { try { const res = await fetch("/api/combos"); if (!res.ok) throw new Error(`Failed to fetch combos: ${res.status}`); const data = await res.json(); setCombos(data.combos || []); } catch (error) { console.error("Error fetching combos:", error); setCombos([]); } }; useEffect(() => { if (isOpen) fetchCombos(); }, [isOpen]); const fetchProviderNodes = async () => { try { const res = await fetch("/api/provider-nodes"); if (!res.ok) throw new Error(`Failed to fetch provider nodes: ${res.status}`); const data = await res.json(); setProviderNodes(data.nodes || []); } catch (error) { console.error("Error fetching provider nodes:", error); setProviderNodes([]); } }; useEffect(() => { if (isOpen) fetchProviderNodes(); }, [isOpen]); const fetchCustomModels = async () => { try { const res = await fetch("/api/provider-models"); if (!res.ok) throw new Error(`Failed to fetch custom models: ${res.status}`); const data = await res.json(); setCustomModels(data.models || {}); } catch (error) { console.error("Error fetching custom models:", error); setCustomModels({}); } }; useEffect(() => { if (isOpen) fetchCustomModels(); }, [isOpen]); const allProviders = useMemo( () => ({ ...OAUTH_PROVIDERS, ...NOAUTH_PROVIDERS, ...APIKEY_PROVIDERS }), [] ); const alwaysIncludeProvidersKey = Array.isArray(alwaysIncludeProviders) ? alwaysIncludeProviders .filter((providerId) => typeof providerId === "string" && providerId) .join("\0") : ""; // Group models by provider with priority order const groupedModels = useMemo(() => { const groups: Record = {}; // Get all active provider IDs from connections const activeConnectionIds = activeProviders.map((p) => p.provider); const explicitProviderIds = alwaysIncludeProvidersKey ? alwaysIncludeProvidersKey.split("\0") : []; // Only show connected providers (including both standard and custom) const providerIdsToShow = new Set([ ...activeConnectionIds, // Connected providers ...explicitProviderIds, // Zero-config providers required by specific clients ]); // Sort by PROVIDER_ORDER const sortedProviderIds = [...providerIdsToShow].sort((a, b) => { const indexA = PROVIDER_ORDER.indexOf(a); const indexB = PROVIDER_ORDER.indexOf(b); return (indexA === -1 ? 999 : indexA) - (indexB === -1 ? 999 : indexB); }); sortedProviderIds.forEach((providerId) => { const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId; const providerInfo = allProviders[providerId] || { name: providerId, color: "#666" }; const isCustomProvider = isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId); // Get user-added custom models for this provider (if any) const providerCustomModels = customModels[providerId] || []; if (providerInfo.passthroughModels) { const aliasModels = Object.entries(modelAliases as Record) .filter(([, fullModel]: [string, string]) => fullModel.startsWith(`${alias}/`)) .map(([aliasName, fullModel]: [string, string]) => ({ id: fullModel.replace(`${alias}/`, ""), name: aliasName, value: fullModel, source: "alias", })); // Merge custom models for passthrough providers const customEntries = providerCustomModels .filter((cm) => !aliasModels.some((am) => am.id === cm.id)) .map((cm) => ({ id: cm.id, name: cm.name || cm.id, value: `${alias}/${cm.id}`, isCustom: true, source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", })); const allModels = [...aliasModels, ...customEntries]; if (allModels.length > 0) { const matchedNode = providerNodes.find((node) => node.id === providerId); const displayName = matchedNode?.name || providerInfo.name; groups[providerId] = { name: displayName, alias: alias, color: providerInfo.color, models: allModels, }; } } else if (isCustomProvider) { const matchedNode = providerNodes.find((node) => node.id === providerId); const displayName = matchedNode?.name || providerInfo.name; const nodePrefix = matchedNode?.prefix || providerId; // Consider a more user-friendly fallback if providerId is a UUID const nodeModels = Object.entries(modelAliases as Record) .filter(([, fullModel]: [string, string]) => fullModel.startsWith(`${providerId}/`)) .map(([aliasName, fullModel]: [string, string]) => ({ id: fullModel.replace(`${providerId}/`, ""), name: aliasName, value: `${nodePrefix}/${fullModel.replace(`${providerId}/`, "")}`, source: "alias", })); const fallbackEntries = ( getCompatibleFallbackModels(providerId, providerCustomModels) || [] ) .filter((fm) => !nodeModels.some((nm) => nm.id === fm.id)) .map((fm) => ({ id: fm.id, name: fm.name || fm.id, value: `${nodePrefix}/${fm.id}`, isFallback: true, source: "fallback", })); // Merge custom models for custom providers const customEntries = providerCustomModels .filter( (cm) => !nodeModels.some((nm) => nm.id === cm.id) && !fallbackEntries.some((fm) => fm.id === cm.id) ) .map((cm) => ({ id: cm.id, name: cm.name || cm.id, value: `${nodePrefix}/${cm.id}`, isCustom: true, source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", })); const allModels = [...nodeModels, ...fallbackEntries, ...customEntries]; if (allModels.length > 0) { groups[providerId] = { name: displayName, alias: nodePrefix, color: providerInfo.color, models: allModels, isCustom: true, hasModels: true, }; } } else { const systemModels = getModelsByProviderId(providerId); // Merge system models with user-added custom models const systemEntries = systemModels.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, source: "system", })); const customEntries = providerCustomModels .filter((cm) => !systemModels.some((sm) => sm.id === cm.id)) .map((cm) => ({ id: cm.id, name: cm.name || cm.id, value: `${alias}/${cm.id}`, isCustom: true, source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", })); const allModels = [...systemEntries, ...customEntries]; if (allModels.length > 0) { groups[providerId] = { name: providerInfo.name, alias: alias, color: providerInfo.color, models: allModels, }; } } }); return groups; }, [ activeProviders, alwaysIncludeProvidersKey, modelAliases, allProviders, providerNodes, customModels, ]); // Filter combos by search query const filteredCombos = useMemo(() => { if (!searchQuery.trim()) return combos; const query = searchQuery.toLowerCase(); return combos.filter((c) => c.name.toLowerCase().includes(query)); }, [combos, searchQuery]); // Filter models by search query const filteredGroups = useMemo(() => { if (!searchQuery.trim()) return groupedModels; const query = searchQuery.toLowerCase(); const filtered: Record = {}; Object.entries(groupedModels).forEach(([providerId, group]: [string, any]) => { const matchedModels = group.models.filter((model) => matchesModelCatalogQuery(query, { modelId: model.id, modelName: model.name, source: model.source, }) ); const providerNameMatches = group.name.toLowerCase().includes(query); if (matchedModels.length > 0 || providerNameMatches) { filtered[providerId] = { ...group, models: matchedModels.length > 0 ? matchedModels : group.models, }; } }); return filtered; }, [groupedModels, searchQuery]); const resolvedSelectedModels = multiSelect ? selectedModels : selectedModel ? [selectedModel] : []; const isValueSelected = (value: string) => resolvedSelectedModels.includes(value); const handleSelect = (model: any) => { // Upstream PR decolua/9router#889: when the model is already in // `addedModelValues` AND a deselect callback was supplied, the click acts // as an in-place remove instead of a duplicate add. const candidateValue = typeof model?.value === "string" ? model.value : typeof model?.name === "string" ? model.name : typeof model === "string" ? model : ""; const isAdded = candidateValue ? addedModelValues.includes(candidateValue) : false; if (isAdded && onDeselect) { onDeselect(model); } else { onSelect(model); } // Legacy single-pick auto-closes; multiSelect or keepOpenOnSelect keep the // modal open so the user can toggle several entries in a row. if (!multiSelect && !keepOpenOnSelect) { onClose(); setSearchQuery(""); } }; // Footer "Done" button for single-select callers that opted out of auto-close // (e.g. combo creation, where users add several models in a row). Skipped when // `multiSelect` is on — that mode renders its own Clear + Done footer below the body. const doneFooter = keepOpenOnSelect && !multiSelect ? ( ) : null; return ( { onClose(); setSearchQuery(""); }} title={resolvedTitle} size="md" className="p-4!" footer={doneFooter} > {/* Search - compact */}
search setSearchQuery(e.target.value)} className="w-full pl-8 pr-3 py-1.5 bg-surface border border-border rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" />
{/* Models grouped by provider - compact */}
{/* Combos section - always first */} {showCombos && filteredCombos.length > 0 && (
layers {t("combos")} ({filteredCombos.length})
{filteredCombos.map((combo) => { const isSelected = isValueSelected(combo.name); return ( ); })}
)} {/* Provider models */} {Object.entries(filteredGroups).map(([providerId, group]: [string, any]) => (
{/* Provider header */}
{group.name} ({group.models.length})
{group.models.map((model) => { const isSelected = isValueSelected(model.value); const isAdded = addedModelValues.includes(model.value); return ( ); })}
))} {Object.keys(filteredGroups).length === 0 && filteredCombos.length === 0 && (
search_off

{t("noModelsFound")}

)}
{multiSelect && (
{resolvedSelectedModels.length} selected
)} ); }