"use client"; import { useAtom, useAtomValue } from "jotai"; import { Check, CheckCircle2, ChevronsUpDown, Eye, EyeOff, RefreshCcw, Settings, Trash2, XCircle, } from "lucide-react"; import { useState } from "react"; import { addManualModelMutationAtom, bulkUpdateModelsMutationAtom, createModelConnectionMutationAtom, deleteModelConnectionMutationAtom, discoverConnectionModelsMutationAtom, updateModelConnectionMutationAtom, updateModelMutationAtom, updateModelRolesMutationAtom, verifyModelConnectionMutationAtom, } from "@/atoms/model-connections/model-connections-mutation.atoms"; import { globalModelConnectionsAtom, modelConnectionsAtom, modelProvidersAtom, modelRolesAtom, } from "@/atoms/model-connections/model-connections-query.atoms"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import type { ConnectionRead, ConnectionUpdateRequest, ModelRead, } from "@/contracts/types/model-connections.types"; import { getProviderIcon } from "@/lib/provider-icons"; import { cn } from "@/lib/utils"; // Free-text URL hints (datalist), mirroring OpenWebUI. These never restrict // what the user can type — any OpenAI-compatible endpoint works. const URL_SUGGESTIONS = [ "https://api.openai.com/v1", "https://api.anthropic.com/v1", "https://openrouter.ai/api/v1", "https://generativelanguage.googleapis.com/v1beta/openai", "https://api.groq.com/openai/v1", "https://api.mistral.ai/v1", "https://api.deepseek.com/v1", "https://api.x.ai/v1", "http://host.docker.internal:11434", "http://host.docker.internal:1234/v1", "http://host.docker.internal:8000/v1", ]; function modelLabel(model: ModelRead) { return model.display_name || model.model_id; } function capability(model: ModelRead, key: "chat" | "vision" | "image_gen") { if (key === "chat") return Boolean(model.supports_chat); if (key === "vision") return Boolean(model.supports_image_input); return Boolean(model.supports_image_generation); } type ModelCapabilityFilter = "chat" | "vision" | "image_gen"; const MODEL_CAPABILITY_FILTERS: { key: ModelCapabilityFilter; label: string }[] = [ { key: "chat", label: "Chat" }, { key: "vision", label: "Vision" }, { key: "image_gen", label: "Image" }, ]; function UrlSuggestionCombobox({ value, onChange, placeholder, }: { value: string; onChange: (value: string) => void; placeholder: string; }) { const [open, setOpen] = useState(false); return ( Use the custom URL you typed {URL_SUGGESTIONS.map((url) => ( { onChange(url); setOpen(false); }} > {url} ))} ); } function StatusBadge({ connection }: { connection: ConnectionRead }) { if (connection.last_status === "OK") { return ( Healthy ); } if (connection.last_status) { return ( {connection.last_status} ); } return Not tested; } function flattenModels(connections: ConnectionRead[]) { return connections.flatMap((connection) => connection.models.map((model) => ({ ...model, connectionName: connection.provider, connectionId: connection.id, provider: connection.provider, })) ); } function ConnectionCard({ connection }: { connection: ConnectionRead }) { const verifyConnection = useAtomValue(verifyModelConnectionMutationAtom); const discoverModels = useAtomValue(discoverConnectionModelsMutationAtom); const updateConnection = useAtomValue(updateModelConnectionMutationAtom); const deleteConnection = useAtomValue(deleteModelConnectionMutationAtom); const addManualModel = useAtomValue(addManualModelMutationAtom); const updateModel = useAtomValue(updateModelMutationAtom); const bulkUpdateModels = useAtomValue(bulkUpdateModelsMutationAtom); const allowlist = Array.isArray(connection.extra?.model_ids) ? (connection.extra.model_ids as string[]) : []; const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [baseUrlDraft, setBaseUrlDraft] = useState(connection.base_url ?? ""); const [apiKeyDraft, setApiKeyDraft] = useState(""); const [showApiKey, setShowApiKey] = useState(false); const [allowlistText, setAllowlistText] = useState(allowlist.join(", ")); const [manualModelId, setManualModelId] = useState(""); const [modelFilter, setModelFilter] = useState(null); const providerLabel = connection.provider; const isLocal = connection.provider === "ollama_chat" || connection.provider === "lm_studio" || !connection.base_url?.startsWith("https"); const filteredModels = modelFilter ? connection.models.filter((model) => capability(model, modelFilter)) : connection.models; const allFilteredModelsEnabled = filteredModels.length > 0 && filteredModels.every((model) => model.enabled); const hasConnectionChanges = baseUrlDraft.trim() !== (connection.base_url ?? "") || apiKeyDraft.trim() !== (connection.api_key ?? ""); function handleSettingsOpenChange(open: boolean) { setIsSettingsOpen(open); if (open) { setBaseUrlDraft(connection.base_url ?? ""); setApiKeyDraft(connection.api_key ?? ""); setShowApiKey(false); setAllowlistText(allowlist.join(", ")); } } function saveConnectionSettings() { const data: ConnectionUpdateRequest = { base_url: baseUrlDraft.trim() || null, }; if (apiKeyDraft.trim() !== (connection.api_key ?? "")) { data.api_key = apiKeyDraft.trim() || null; } updateConnection.mutate( { id: connection.id, data }, { onSuccess: () => setApiKeyDraft(""), } ); } function saveAllowlist() { const ids = allowlistText .split(",") .map((value) => value.trim()) .filter(Boolean); updateConnection.mutate({ id: connection.id, data: { extra: { ...(connection.extra ?? {}), model_ids: ids } }, }); } function addModel() { const modelId = manualModelId.trim(); if (!modelId) return; addManualModel.mutate( { connectionId: connection.id, data: { model_id: modelId } }, { onSuccess: () => setManualModelId("") } ); } function deleteCurrentConnection() { deleteConnection.mutate(connection.id); } function toggleFilteredModels() { const nextEnabled = !allFilteredModelsEnabled; const modelIds = filteredModels .filter((model) => model.enabled !== nextEnabled) .map((model) => model.id); if (modelIds.length === 0) return; bulkUpdateModels.mutate({ connectionId: connection.id, data: { model_ids: modelIds, enabled: nextEnabled }, }); } return (
{getProviderIcon(providerLabel, { className: "size-4" })} {providerLabel} {connection.scope === "GLOBAL" ? ( Default ) : null}
{connection.base_url || "Provider default endpoint"}
{getProviderIcon(providerLabel, { className: "size-5" })}
Configure {providerLabel} Manage credentials and choose which models are available from this provider.

Leave empty to use the provider default endpoint.

setApiKeyDraft(event.target.value)} placeholder={connection.has_api_key ? "Saved API key" : "Paste an API key"} type={showApiKey ? "text" : "password"} className="pr-11" />
{!isLocal ? (
setAllowlistText(event.target.value)} placeholder="Comma-separated, e.g. anthropic/claude-sonnet-4-5, google/gemini-2.5-pro" />

Leave empty to discover all models. Recommended for providers with large catalogs.

) : null}
Models

Select models to make available for this provider.

setManualModelId(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addModel(); } }} placeholder="Add a model ID manually" />
{connection.models.length > 0 ? (
Filter models {MODEL_CAPABILITY_FILTERS.map((filter) => { const count = connection.models.filter((model) => capability(model, filter.key) ).length; const isActive = modelFilter === filter.key; return ( ); })}
) : null}
{connection.models.length === 0 ? (
No models yet. Use the refresh button to discover models or add one manually.
) : null} {filteredModels.length === 0 && modelFilter ? (
No{" "} {MODEL_CAPABILITY_FILTERS.find( (filter) => filter.key === modelFilter )?.label.toLowerCase()}{" "} models found on this connection.
) : null}
{filteredModels.map((model) => (
updateModel.mutate({ id: model.id, data: { enabled: checked === true }, }) } disabled={updateModel.isPending} />
{modelLabel(model)} {model.source === "MANUAL" ? ( manual ) : null}
{["chat", "vision", "image_gen"] .filter((key) => capability(model, key as "chat" | "vision" | "image_gen") ) .join(", ") || "No discovered capabilities"}
))}
{connection.last_status && connection.last_status !== "OK" ? (

{connection.last_error || "Could not list models."} Chat may still work; add model IDs manually if discovery is unavailable.

) : null}
Delete this provider? {providerLabel} and all of its models will be removed from this search space. This cannot be undone. Cancel Delete
); } export function ModelConnectionsSettings({ searchSpaceId }: { searchSpaceId: number }) { const [{ data: globalConnections = [] }] = useAtom(globalModelConnectionsAtom); const [{ data: connections = [] }] = useAtom(modelConnectionsAtom); const [{ data: providers = [] }] = useAtom(modelProvidersAtom); const [{ data: roles }] = useAtom(modelRolesAtom); const createConnection = useAtomValue(createModelConnectionMutationAtom); const updateRoles = useAtomValue(updateModelRolesMutationAtom); const [provider, setProvider] = useState("openai_compatible"); const [baseUrl, setBaseUrl] = useState(""); const [apiKey, setApiKey] = useState(""); const selectedProvider = providers.find((item) => item.provider === provider); const isOllama = provider === "ollama_chat"; const allConnections = [...globalConnections, ...connections]; const enabledModels = flattenModels(allConnections).filter((model) => model.enabled); const chatModels = enabledModels.filter((model) => capability(model, "chat")); const visionModels = enabledModels.filter((model) => capability(model, "vision")); const imageModels = enabledModels.filter((model) => capability(model, "image_gen")); function handleCreate() { createConnection.mutate( { provider, base_url: baseUrl || null, api_key: apiKey || null, scope: "SEARCH_SPACE", search_space_id: searchSpaceId, extra: {}, enabled: true, }, { onSuccess: () => setApiKey("") } ); } function renderModelOption(model: ModelRead & { connectionName: string; provider: string }) { return ( {getProviderIcon(model.provider, { className: "size-4" })} {modelLabel(model)} · {model.connectionName} ); } return (
Model Connections Add credentials or local endpoints once, then discover reusable models.
setApiKey(event.target.value)} placeholder={isOllama ? "Optional for Ollama" : "API key"} type="password" />

{selectedProvider ? `${selectedProvider.transport} transport, ${selectedProvider.discovery} discovery.` : "Choose a provider preset."}{" "} Base URL is explicit and editable. Local URLs are tested from the backend container, so use host.docker.internal instead of localhost.

{connections.length > 0 ? (

Available Providers

{connections.map((connection) => ( ))}
) : null}
Model Roles Pick which enabled model powers chat, vision, and image generation for this search space.
); }