Spaces:
Runtime error
Runtime error
| "use client"; | |
| import { useState, useMemo, useEffect } from "react"; | |
| import { useTranslations } from "next-intl"; | |
| import Modal from "./Modal"; | |
| import { buildPassthroughAliasModels } from "./modelSelectModalHelpers"; | |
| 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; id?: string | number }>; | |
| title?: string; | |
| modelAliases?: Record<string, string>; | |
| 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<any[]>([]); | |
| const [providerNodes, setProviderNodes] = useState<any[]>([]); | |
| const [customModels, setCustomModels] = useState<Record<string, any>>({}); | |
| // Models discovered live from a custom provider's upstream `/models` endpoint, | |
| // keyed by provider id. Merged into the alias/custom/fallback list below and | |
| // tagged with the `auto` source badge. Ported from upstream PR | |
| // decolua/9router#2018 (Hamsa_M). | |
| const [fetchedModels, setFetchedModels] = useState<Record<string, any[]>>({}); | |
| 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]); | |
| // Fetch the live model catalog for one custom provider from its connection's | |
| // upstream `/models` endpoint. Returns the model array, or null on any failure. | |
| const fetchProviderModels = async (providerId: string): Promise<any[] | null> => { | |
| try { | |
| // Find the connection id for this provider — the route is keyed by connection. | |
| const connection = activeProviders.find((p) => p.provider === providerId); | |
| if (!connection?.id) return null; | |
| const res = await fetch(`/api/providers/${connection.id}/models`); | |
| if (!res.ok) { | |
| console.warn(`Failed to fetch models for ${providerId}: ${res.status}`); | |
| return null; | |
| } | |
| const data = await res.json(); | |
| return data.models || []; | |
| } catch (error) { | |
| console.error(`Error fetching models for ${providerId}:`, error); | |
| return null; | |
| } | |
| }; | |
| // When the modal opens, dynamically load models for every connected custom | |
| // (openai-/anthropic-compatible) provider in parallel. | |
| useEffect(() => { | |
| if (!isOpen) return; | |
| let cancelled = false; | |
| const loadCustomProviderModels = async () => { | |
| const customProviderIds = activeProviders | |
| .filter( | |
| (p) => | |
| isOpenAICompatibleProvider(p.provider) || isAnthropicCompatibleProvider(p.provider) | |
| ) | |
| .map((p) => p.provider); | |
| if (customProviderIds.length === 0) return; | |
| const fetched: Record<string, any[]> = {}; | |
| await Promise.all( | |
| customProviderIds.map(async (providerId) => { | |
| const models = await fetchProviderModels(providerId); | |
| if (models && models.length > 1) { | |
| fetched[providerId] = models; | |
| } | |
| }) | |
| ); | |
| if (!cancelled) setFetchedModels(fetched); | |
| }; | |
| loadCustomProviderModels(); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [isOpen, activeProviders]); | |
| 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<string, any> = {}; | |
| // 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) { | |
| // Passthrough aliases are stored prefixed by the canonical providerId | |
| // (e.g. "github/gpt-4"), not the public alias (e.g. "gh/"), so we must | |
| // filter/strip by providerId — matching the sibling custom-provider | |
| // branch below. (port: decolua/9router#485) | |
| const aliasModels = buildPassthroughAliasModels( | |
| modelAliases as Record<string, string>, | |
| providerId | |
| ); | |
| // 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<string, string>) | |
| .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", | |
| })); | |
| // Models discovered live from the provider's upstream `/models` endpoint. | |
| // Deduped against alias, fallback, and user-added custom models; tagged | |
| // with the `auto` source so the badge reads "auto". | |
| const fetchedEntries = (fetchedModels[providerId] || []) | |
| .map((m) => { | |
| const id = m.id || m.slug || m.model || m.name; | |
| return { | |
| id, | |
| name: m.name || m.displayName || id, | |
| value: `${nodePrefix}/${id}`, | |
| isFetched: true, | |
| source: "auto", | |
| }; | |
| }) | |
| .filter( | |
| (fm) => | |
| fm.id && | |
| !nodeModels.some((nm) => nm.id === fm.id) && | |
| !fallbackEntries.some((fbm) => fbm.id === fm.id) && | |
| !customEntries.some((cm) => cm.id === fm.id) | |
| ); | |
| const allModels = [...nodeModels, ...fallbackEntries, ...customEntries, ...fetchedEntries]; | |
| 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, | |
| fetchedModels, | |
| ]); | |
| // 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<string, any> = {}; | |
| 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 ? ( | |
| <button | |
| type="button" | |
| onClick={() => { | |
| onClose(); | |
| setSearchQuery(""); | |
| }} | |
| className="w-full px-3 py-2 text-sm font-medium rounded border border-primary bg-primary text-white hover:bg-primary/90 transition-colors" | |
| > | |
| {t("done")} | |
| </button> | |
| ) : null; | |
| return ( | |
| <Modal | |
| isOpen={isOpen} | |
| onClose={() => { | |
| onClose(); | |
| setSearchQuery(""); | |
| }} | |
| title={resolvedTitle} | |
| size="md" | |
| className="p-4!" | |
| footer={doneFooter} | |
| > | |
| {/* Search - compact */} | |
| <div className="mb-3"> | |
| <div className="relative"> | |
| <span className="material-symbols-outlined absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted text-[16px]"> | |
| search | |
| </span> | |
| <input | |
| type="text" | |
| placeholder={t("search")} | |
| value={searchQuery} | |
| onChange={(e) => 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" | |
| /> | |
| </div> | |
| </div> | |
| {/* Models grouped by provider - compact */} | |
| <div className="max-h-[300px] overflow-y-auto space-y-3"> | |
| {/* Combos section - always first */} | |
| {showCombos && filteredCombos.length > 0 && ( | |
| <div> | |
| <div className="flex items-center gap-1.5 mb-1.5 sticky top-0 bg-surface py-0.5"> | |
| <span className="material-symbols-outlined text-primary text-[14px]">layers</span> | |
| <span className="text-xs font-medium text-primary">{t("combos")}</span> | |
| <span className="text-[10px] text-text-muted">({filteredCombos.length})</span> | |
| </div> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {filteredCombos.map((combo) => { | |
| const isSelected = isValueSelected(combo.name); | |
| return ( | |
| <button | |
| key={combo.id} | |
| onClick={() => | |
| handleSelect({ id: combo.name, name: combo.name, value: combo.name }) | |
| } | |
| className={` | |
| px-2 py-1 rounded-xl text-xs font-medium transition-all border hover:cursor-pointer | |
| ${ | |
| isSelected | |
| ? "bg-primary text-white border-primary" | |
| : "bg-surface border-border text-text-main hover:border-primary/50 hover:bg-primary/5" | |
| } | |
| `} | |
| > | |
| {combo.name} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {/* Provider models */} | |
| {Object.entries(filteredGroups).map(([providerId, group]: [string, any]) => ( | |
| <div key={providerId}> | |
| {/* Provider header */} | |
| <div className="flex items-center gap-1.5 mb-1.5 sticky top-0 bg-surface py-0.5"> | |
| <div className="w-2 h-2 rounded-full" style={{ backgroundColor: group.color }} /> | |
| <span className="text-xs font-medium text-primary">{group.name}</span> | |
| <span className="text-[10px] text-text-muted">({group.models.length})</span> | |
| </div> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {group.models.map((model) => { | |
| const isSelected = isValueSelected(model.value); | |
| const isAdded = addedModelValues.includes(model.value); | |
| return ( | |
| <button | |
| key={model.id} | |
| onClick={() => handleSelect(model)} | |
| className={` | |
| px-2 py-1 rounded-xl text-xs font-medium transition-all border hover:cursor-pointer | |
| ${ | |
| isSelected | |
| ? "bg-primary text-white border-primary" | |
| : isAdded | |
| ? "bg-emerald-500/15 border-emerald-500/30 text-emerald-700 dark:text-emerald-400" | |
| : "bg-surface border-border text-text-main hover:border-primary/50 hover:bg-primary/5" | |
| } | |
| `} | |
| > | |
| {isAdded && <span className="mr-0.5 opacity-70">✓</span>} | |
| {model.name} | |
| {model.source && ( | |
| <span className="ml-1 text-[10px] uppercase opacity-70"> | |
| {getModelCatalogSourceLabel(model.source)} | |
| </span> | |
| )} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ))} | |
| {Object.keys(filteredGroups).length === 0 && filteredCombos.length === 0 && ( | |
| <div className="text-center py-4 text-text-muted"> | |
| <span className="material-symbols-outlined text-2xl mb-1 block">search_off</span> | |
| <p className="text-xs">{t("noModelsFound")}</p> | |
| </div> | |
| )} | |
| </div> | |
| {multiSelect && ( | |
| <div className="mt-4 flex items-center justify-between gap-2 border-t border-border pt-3"> | |
| <span className="text-xs text-text-muted">{resolvedSelectedModels.length} selected</span> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| type="button" | |
| onClick={() => onSelect(null)} | |
| className="px-2 py-1 text-xs rounded border border-border bg-surface hover:bg-primary/5" | |
| > | |
| {t("clear")} | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => { | |
| onClose(); | |
| setSearchQuery(""); | |
| }} | |
| className="px-2 py-1 text-xs rounded border border-border bg-surface hover:bg-primary/5" | |
| > | |
| {t("done")} | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </Modal> | |
| ); | |
| } | |