diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout-shell.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout-shell.tsx index 22f68edab..9d9045004 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout-shell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/layout-shell.tsx @@ -5,9 +5,6 @@ import { Bot, CircleUser, Earth, - ImageIcon, - ListChecks, - ScanEye, UserKey, } from "lucide-react"; import Link from "next/link"; @@ -20,10 +17,7 @@ import { cn } from "@/lib/utils"; export type SearchSpaceSettingsTab = | "general" - | "roles" | "models" - | "image-models" - | "vision-models" | "team-roles" | "prompts" | "public-links"; @@ -57,26 +51,11 @@ export function SearchSpaceSettingsLayoutShell({ label: t("nav_general"), icon: , }, - { - value: "roles" as const, - label: t("nav_role_assignments"), - icon: , - }, { value: "models" as const, - label: t("nav_agent_models"), + label: t("nav_models"), icon: , }, - { - value: "image-models" as const, - label: t("nav_image_models"), - icon: , - }, - { - value: "vision-models" as const, - label: t("nav_vision_models"), - icon: , - }, { value: "team-roles" as const, label: t("nav_team_roles"), diff --git a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/models/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/models/page.tsx index d68194782..c97ef7630 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/models/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/search-space-settings/models/page.tsx @@ -1,6 +1,6 @@ -import { AgentModelManager } from "@/components/settings/agent-model-manager"; +import { ModelConnectionsSettings } from "@/components/settings/model-connections-settings"; export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) { const { search_space_id } = await params; - return ; + return ; } diff --git a/surfsense_web/components/settings/llm-role-manager.tsx b/surfsense_web/components/settings/llm-role-manager.tsx index c32e79a8e..547675927 100644 --- a/surfsense_web/components/settings/llm-role-manager.tsx +++ b/surfsense_web/components/settings/llm-role-manager.tsx @@ -48,8 +48,8 @@ import { cn } from "@/lib/utils"; const ROLE_DESCRIPTIONS = { agent: { icon: Bot, - title: "Agent LLM", - description: "Primary LLM for chat interactions and agent operations", + title: "Chat model", + description: "Primary model for chat interactions and agent operations", color: "text-muted-foreground", bgColor: "bg-muted", prefKey: "agent_llm_id" as const, diff --git a/surfsense_web/components/settings/model-connections-settings.tsx b/surfsense_web/components/settings/model-connections-settings.tsx new file mode 100644 index 000000000..5fa4cccf7 --- /dev/null +++ b/surfsense_web/components/settings/model-connections-settings.tsx @@ -0,0 +1,384 @@ +"use client"; + +import { useAtom, useAtomValue } from "jotai"; +import { CheckCircle2, PlugZap, RefreshCcw, XCircle } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + createModelConnectionMutationAtom, + discoverConnectionModelsMutationAtom, + testModelMutationAtom, + updateModelMutationAtom, + updateModelRolesMutationAtom, + verifyModelConnectionMutationAtom, +} 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { + ConnectionProtocol, + ConnectionRead, + ModelRead, +} from "@/contracts/types/model-connections.types"; +import { isCloud } from "@/lib/env-config"; +import { getProviderIcon } from "@/lib/provider-icons"; + +type Preset = { + id: string; + label: string; + protocol: ConnectionProtocol; + nativeProvider?: string; + baseUrl?: string; + local?: boolean; +}; + +const PRESETS: Preset[] = [ + { id: "openai", label: "OpenAI", protocol: "NATIVE", nativeProvider: "OPENAI" }, + { id: "anthropic", label: "Anthropic", protocol: "NATIVE", nativeProvider: "ANTHROPIC" }, + { id: "openrouter", label: "OpenRouter", protocol: "NATIVE", nativeProvider: "OPENROUTER" }, + { + id: "ollama", + label: "Ollama", + protocol: "OLLAMA", + baseUrl: "http://host.docker.internal:11434", + local: true, + }, + { + id: "lmstudio", + label: "LM Studio", + protocol: "OPENAI_COMPATIBLE", + baseUrl: "http://host.docker.internal:1234/v1", + local: true, + }, + { + id: "llamacpp", + label: "llama.cpp", + protocol: "OPENAI_COMPATIBLE", + baseUrl: "http://host.docker.internal:8080/v1", + local: true, + }, + { + id: "localai", + label: "LocalAI", + protocol: "OPENAI_COMPATIBLE", + baseUrl: "http://host.docker.internal:8080/v1", + local: true, + }, + { + id: "vllm", + label: "vLLM", + protocol: "OPENAI_COMPATIBLE", + baseUrl: "http://host.docker.internal:8000/v1", + local: true, + }, +]; + +function modelLabel(model: ModelRead) { + return model.display_name || model.model_id; +} + +function capability(model: ModelRead, key: "chat" | "vision" | "image_gen") { + return Boolean(model.capabilities?.[key]); +} + +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.native_provider || connection.protocol, + connectionId: connection.id, + provider: connection.native_provider || connection.protocol, + })) + ); +} + +export function ModelConnectionsSettings({ searchSpaceId }: { searchSpaceId: number }) { + const [{ data: globalConnections = [] }] = useAtom(globalModelConnectionsAtom); + const [{ data: connections = [] }] = useAtom(modelConnectionsAtom); + const [{ data: roles }] = useAtom(modelRolesAtom); + const createConnection = useAtomValue(createModelConnectionMutationAtom); + const verifyConnection = useAtomValue(verifyModelConnectionMutationAtom); + const discoverModels = useAtomValue(discoverConnectionModelsMutationAtom); + const updateModel = useAtomValue(updateModelMutationAtom); + const testModel = useAtomValue(testModelMutationAtom); + const updateRoles = useAtomValue(updateModelRolesMutationAtom); + + const visiblePresets = useMemo( + () => PRESETS.filter((preset) => !(isCloud() && preset.local)), + [] + ); + const [presetId, setPresetId] = useState(visiblePresets[0]?.id ?? "openai"); + const preset = visiblePresets.find((item) => item.id === presetId) ?? visiblePresets[0]; + const [baseUrl, setBaseUrl] = useState(preset?.baseUrl ?? ""); + const [apiKey, setApiKey] = useState(""); + + 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 onPresetChange(value: string) { + setPresetId(value); + const next = visiblePresets.find((item) => item.id === value); + setBaseUrl(next?.baseUrl ?? ""); + } + + function handleCreate() { + if (!preset) return; + createConnection.mutate({ + protocol: preset.protocol, + native_provider: preset.nativeProvider, + base_url: baseUrl || null, + api_key: apiKey || null, + scope: "SEARCH_SPACE", + search_space_id: searchSpaceId, + extra: {}, + enabled: true, + }); + } + + 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. + + + +
+
+ + +
+
+ + setBaseUrl(event.target.value)} + placeholder="https://api.example.com/v1" + /> +
+
+ + setApiKey(event.target.value)} + placeholder="Optional for local models" + type="password" + /> +
+
+ +
+
+ {preset?.local ? ( +

+ Local URLs are tested from the backend container. Use host.docker.internal instead of + localhost. +

+ ) : null} + +
+ {connections.map((connection) => ( +
+
+
+
+ {getProviderIcon(connection.native_provider || connection.protocol, { + className: "size-4", + })} + {connection.native_provider || connection.protocol} +
+
+ {connection.base_url || "Provider default endpoint"} +
+
+
+ + + +
+
+ {connection.last_error ? ( +

{connection.last_error}

+ ) : null} +
+ {connection.models.map((model) => ( +
+
+
+ {getProviderIcon(connection.native_provider || connection.protocol, { + className: "size-4", + })} + {modelLabel(model)} +
+
+ {["chat", "vision", "image_gen"] + .filter((key) => Boolean(model.capabilities?.[key])) + .join(", ") || "No verified capabilities"} +
+
+
+ + +
+
+ ))} +
+
+ ))} +
+
+
+ + + + Model Roles + + Pick which enabled model powers chat, vision, and image generation for this search + space. + + + +
+ + +
+
+ + +
+
+ + +
+
+
+
+ ); +}