diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx index 0c5253c6c..4716418ee 100644 --- a/surfsense_web/components/new-chat/chat-header.tsx +++ b/surfsense_web/components/new-chat/chat-header.tsx @@ -44,21 +44,28 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) { const [isVisionGlobal, setIsVisionGlobal] = useState(false); const [visionDialogMode, setVisionDialogMode] = useState<"create" | "edit" | "view">("view"); + // Default provider for create dialogs + const [defaultLLMProvider, setDefaultLLMProvider] = useState(); + const [defaultImageProvider, setDefaultImageProvider] = useState(); + const [defaultVisionProvider, setDefaultVisionProvider] = useState(); + // LLM handlers const handleEditLLMConfig = useCallback( (config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => { setSelectedConfig(config); setIsGlobal(global); setDialogMode(global ? "view" : "edit"); + setDefaultLLMProvider(undefined); setDialogOpen(true); }, [] ); - const handleAddNewLLM = useCallback(() => { + const handleAddNewLLM = useCallback((provider?: string) => { setSelectedConfig(null); setIsGlobal(false); setDialogMode("create"); + setDefaultLLMProvider(provider); setDialogOpen(true); }, []); @@ -68,10 +75,11 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) { }, []); // Image model handlers - const handleAddImageModel = useCallback(() => { + const handleAddImageModel = useCallback((provider?: string) => { setSelectedImageConfig(null); setIsImageGlobal(false); setImageDialogMode("create"); + setDefaultImageProvider(provider); setImageDialogOpen(true); }, []); @@ -80,6 +88,7 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) { setSelectedImageConfig(config); setIsImageGlobal(global); setImageDialogMode(global ? "view" : "edit"); + setDefaultImageProvider(undefined); setImageDialogOpen(true); }, [] @@ -91,10 +100,11 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) { }, []); // Vision model handlers - const handleAddVisionModel = useCallback(() => { + const handleAddVisionModel = useCallback((provider?: string) => { setSelectedVisionConfig(null); setIsVisionGlobal(false); setVisionDialogMode("create"); + setDefaultVisionProvider(provider); setVisionDialogOpen(true); }, []); @@ -103,6 +113,7 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) { setSelectedVisionConfig(config); setIsVisionGlobal(global); setVisionDialogMode(global ? "view" : "edit"); + setDefaultVisionProvider(undefined); setVisionDialogOpen(true); }, [] @@ -131,6 +142,7 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) { isGlobal={isGlobal} searchSpaceId={searchSpaceId} mode={dialogMode} + defaultProvider={defaultLLMProvider} /> ); diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx index ec5bf6760..26937e18b 100644 --- a/surfsense_web/components/new-chat/model-selector.tsx +++ b/surfsense_web/components/new-chat/model-selector.tsx @@ -1,8 +1,20 @@ "use client"; +import type React from "react"; import { useAtomValue } from "jotai"; -import { Bot, Check, ChevronDown, Edit3, Eye, ImageIcon, Plus, Search, Zap } from "lucide-react"; -import { type UIEvent, useCallback, useMemo, useState } from "react"; +import { + Bot, + Check, + ChevronDown, + Edit3, + Eye, + ImageIcon, + Layers, + Plus, + Search, + Zap, +} from "lucide-react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { globalImageGenConfigsAtom, @@ -22,17 +34,16 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from "@/components/ui/command"; + Drawer, + DrawerContent, + DrawerHandle, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Spinner } from "@/components/ui/spinner"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { GlobalImageGenConfig, GlobalNewLLMConfig, @@ -41,16 +52,209 @@ import type { NewLLMConfigPublic, VisionLLMConfig, } from "@/contracts/types/new-llm-config.types"; +import { useIsMobile } from "@/hooks/use-mobile"; import { getProviderIcon } from "@/lib/provider-icons"; import { cn } from "@/lib/utils"; +// ─── Helpers ──────────────────────────────────────────────────────── + +const PROVIDER_NAMES: Record = { + OPENAI: "OpenAI", + ANTHROPIC: "Anthropic", + GOOGLE: "Google", + AZURE: "Azure", + AZURE_OPENAI: "Azure OpenAI", + AWS_BEDROCK: "AWS Bedrock", + BEDROCK: "Bedrock", + DEEPSEEK: "DeepSeek", + MISTRAL: "Mistral", + COHERE: "Cohere", + GROQ: "Groq", + OLLAMA: "Ollama", + TOGETHER_AI: "Together AI", + FIREWORKS_AI: "Fireworks AI", + REPLICATE: "Replicate", + HUGGINGFACE: "HuggingFace", + PERPLEXITY: "Perplexity", + XAI: "xAI", + OPENROUTER: "OpenRouter", + CEREBRAS: "Cerebras", + SAMBANOVA: "SambaNova", + VERTEX_AI: "Vertex AI", + MINIMAX: "MiniMax", + MOONSHOT: "Moonshot", + ZHIPU: "Zhipu", + DEEPINFRA: "DeepInfra", + CLOUDFLARE: "Cloudflare", + DATABRICKS: "Databricks", + NSCALE: "NScale", + RECRAFT: "Recraft", + XINFERENCE: "XInference", + CUSTOM: "Custom", + AI21: "AI21", + ALIBABA_QWEN: "Qwen", + ANYSCALE: "Anyscale", + COMETAPI: "CometAPI", +}; + +// Provider keys valid per model type, matching backend enums +// (LiteLLMProvider, ImageGenProvider, VisionProvider in db.py) +const LLM_PROVIDER_KEYS: string[] = [ + "OPENAI", + "ANTHROPIC", + "GOOGLE", + "AZURE_OPENAI", + "BEDROCK", + "VERTEX_AI", + "GROQ", + "DEEPSEEK", + "XAI", + "MISTRAL", + "COHERE", + "OPENROUTER", + "TOGETHER_AI", + "FIREWORKS_AI", + "REPLICATE", + "PERPLEXITY", + "OLLAMA", + "CEREBRAS", + "SAMBANOVA", + "DEEPINFRA", + "AI21", + "ALIBABA_QWEN", + "MOONSHOT", + "ZHIPU", + "MINIMAX", + "HUGGINGFACE", + "CLOUDFLARE", + "DATABRICKS", + "ANYSCALE", + "COMETAPI", + "GITHUB_MODELS", + "CUSTOM", +]; + +const IMAGE_PROVIDER_KEYS: string[] = [ + "OPENAI", + "AZURE_OPENAI", + "GOOGLE", + "VERTEX_AI", + "BEDROCK", + "RECRAFT", + "OPENROUTER", + "XINFERENCE", + "NSCALE", +]; + +const VISION_PROVIDER_KEYS: string[] = [ + "OPENAI", + "ANTHROPIC", + "GOOGLE", + "AZURE_OPENAI", + "VERTEX_AI", + "BEDROCK", + "XAI", + "OPENROUTER", + "OLLAMA", + "GROQ", + "TOGETHER_AI", + "FIREWORKS_AI", + "DEEPSEEK", + "MISTRAL", + "CUSTOM", +]; + +const PROVIDER_KEYS_BY_TAB: Record = { + llm: LLM_PROVIDER_KEYS, + image: IMAGE_PROVIDER_KEYS, + vision: VISION_PROVIDER_KEYS, +}; + +function formatProviderName(provider: string): string { + const key = provider.toUpperCase(); + return ( + PROVIDER_NAMES[key] ?? + provider.charAt(0).toUpperCase() + + provider.slice(1).toLowerCase().replace(/_/g, " ") + ); +} + +function normalizeText(input: string): string { + return input + .normalize("NFD") + .replace(/\p{Diacritic}/gu, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +interface ConfigBase { + id: number; + name: string; + model_name: string; + provider: string; +} + +function filterAndScore( + configs: T[], + selectedProvider: string, + searchQuery: string, +): T[] { + let result = configs; + + if (selectedProvider !== "all") { + result = result.filter( + (c) => c.provider.toUpperCase() === selectedProvider, + ); + } + + if (!searchQuery.trim()) return result; + + const normalized = normalizeText(searchQuery); + const tokens = normalized.split(/\s+/).filter(Boolean); + + const scored = result.map((c) => { + const aggregate = normalizeText( + [c.name, c.model_name, c.provider].join(" "), + ); + let score = 0; + if (aggregate.includes(normalized)) score += 5; + for (const token of tokens) { + if (aggregate.includes(token)) score += 1; + } + return { config: c, score }; + }); + + return scored + .filter((s) => s.score > 0) + .sort((a, b) => b.score - a.score) + .map((s) => s.config); +} + +interface DisplayItem { + config: ConfigBase & Record; + isGlobal: boolean; + isAutoMode: boolean; +} + +// ─── Component ────────────────────────────────────────────────────── + interface ModelSelectorProps { - onEditLLM: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void; - onAddNewLLM: () => void; - onEditImage?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void; - onAddNewImage?: () => void; - onEditVision?: (config: VisionLLMConfig | GlobalVisionLLMConfig, isGlobal: boolean) => void; - onAddNewVision?: () => void; + onEditLLM: ( + config: NewLLMConfigPublic | GlobalNewLLMConfig, + isGlobal: boolean, + ) => void; + onAddNewLLM: (provider?: string) => void; + onEditImage?: ( + config: ImageGenerationConfig | GlobalImageGenConfig, + isGlobal: boolean, + ) => void; + onAddNewImage?: (provider?: string) => void; + onEditVision?: ( + config: VisionLLMConfig | GlobalVisionLLMConfig, + isGlobal: boolean, + ) => void; + onAddNewVision?: (provider?: string) => void; className?: string; } @@ -64,40 +268,69 @@ export function ModelSelector({ className, }: ModelSelectorProps) { const [open, setOpen] = useState(false); - const [activeTab, setActiveTab] = useState<"llm" | "image" | "vision">("llm"); - const [llmSearchQuery, setLlmSearchQuery] = useState(""); - const [imageSearchQuery, setImageSearchQuery] = useState(""); - const [visionSearchQuery, setVisionSearchQuery] = useState(""); - const [llmScrollPos, setLlmScrollPos] = useState<"top" | "middle" | "bottom">("top"); - const [imageScrollPos, setImageScrollPos] = useState<"top" | "middle" | "bottom">("top"); - const [visionScrollPos, setVisionScrollPos] = useState<"top" | "middle" | "bottom">("top"); - const handleListScroll = useCallback( - (setter: typeof setLlmScrollPos) => (e: UIEvent) => { - const el = e.currentTarget; - const atTop = el.scrollTop <= 2; - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; - setter(atTop ? "top" : atBottom ? "bottom" : "middle"); - }, - [] + const [activeTab, setActiveTab] = useState<"llm" | "image" | "vision">( + "llm", ); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedProvider, setSelectedProvider] = useState("all"); + const [focusedIndex, setFocusedIndex] = useState(-1); + const [showScrollIndicator, setShowScrollIndicator] = useState(true); + const providerSidebarRef = useRef(null); + const modelListRef = useRef(null); + const searchInputRef = useRef(null); + const isMobile = useIsMobile(); - // LLM data - const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom); + // Reset search + provider when tab changes + useEffect(() => { + setSelectedProvider("all"); + setSearchQuery(""); + setFocusedIndex(-1); + }, [activeTab]); + + // Reset on open + useEffect(() => { + if (open) { + setSearchQuery(""); + setSelectedProvider("all"); + } + }, [open]); + + // Cmd/Ctrl+M shortcut + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "m") { + e.preventDefault(); + setOpen((prev) => !prev); + } + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, []); + + // Focus search input on open + useEffect(() => { + if (open && !isMobile) { + requestAnimationFrame(() => searchInputRef.current?.focus()); + } + }, [open, isMobile, activeTab]); + + // ─── Data ─── + const { data: llmUserConfigs, isLoading: llmUserLoading } = + useAtomValue(newLLMConfigsAtom); const { data: llmGlobalConfigs, isLoading: llmGlobalLoading } = useAtomValue(globalNewLLMConfigsAtom); - const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom); + const { data: preferences, isLoading: prefsLoading } = + useAtomValue(llmPreferencesAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); - const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); - - // Image data + const { mutateAsync: updatePreferences } = useAtomValue( + updateLLMPreferencesMutationAtom, + ); const { data: imageGlobalConfigs, isLoading: imageGlobalLoading } = useAtomValue(globalImageGenConfigsAtom); - const { data: imageUserConfigs, isLoading: imageUserLoading } = useAtomValue(imageGenConfigsAtom); - - // Vision data - const { data: visionGlobalConfigs, isLoading: visionGlobalLoading } = useAtomValue( - globalVisionLLMConfigsAtom - ); + const { data: imageUserConfigs, isLoading: imageUserLoading } = + useAtomValue(imageGenConfigsAtom); + const { data: visionGlobalConfigs, isLoading: visionGlobalLoading } = + useAtomValue(globalVisionLLMConfigsAtom); const { data: visionUserConfigs, isLoading: visionUserLoading } = useAtomValue(visionLLMConfigsAtom); @@ -110,133 +343,220 @@ export function ModelSelector({ visionGlobalLoading || visionUserLoading; - // ─── LLM current config ─── + // ─── Current selected configs ─── const currentLLMConfig = useMemo(() => { if (!preferences) return null; - const agentLlmId = preferences.agent_llm_id; - if (agentLlmId === null || agentLlmId === undefined) return null; - if (agentLlmId <= 0) { - return llmGlobalConfigs?.find((c) => c.id === agentLlmId) ?? null; - } - return llmUserConfigs?.find((c) => c.id === agentLlmId) ?? null; + const id = preferences.agent_llm_id; + if (id === null || id === undefined) return null; + if (id <= 0) return llmGlobalConfigs?.find((c) => c.id === id) ?? null; + return llmUserConfigs?.find((c) => c.id === id) ?? null; }, [preferences, llmGlobalConfigs, llmUserConfigs]); const isLLMAutoMode = - currentLLMConfig && "is_auto_mode" in currentLLMConfig && currentLLMConfig.is_auto_mode; + currentLLMConfig && + "is_auto_mode" in currentLLMConfig && + currentLLMConfig.is_auto_mode; - // ─── Image current config ─── const currentImageConfig = useMemo(() => { if (!preferences) return null; const id = preferences.image_generation_config_id; if (id === null || id === undefined) return null; - const globalMatch = imageGlobalConfigs?.find((c) => c.id === id); - if (globalMatch) return globalMatch; - return imageUserConfigs?.find((c) => c.id === id) ?? null; + return ( + imageGlobalConfigs?.find((c) => c.id === id) ?? + imageUserConfigs?.find((c) => c.id === id) ?? + null + ); }, [preferences, imageGlobalConfigs, imageUserConfigs]); const isImageAutoMode = - currentImageConfig && "is_auto_mode" in currentImageConfig && currentImageConfig.is_auto_mode; + currentImageConfig && + "is_auto_mode" in currentImageConfig && + currentImageConfig.is_auto_mode; - // ─── Vision current config ─── const currentVisionConfig = useMemo(() => { if (!preferences) return null; const id = preferences.vision_llm_config_id; if (id === null || id === undefined) return null; - const globalMatch = visionGlobalConfigs?.find((c) => c.id === id); - if (globalMatch) return globalMatch; - return visionUserConfigs?.find((c) => c.id === id) ?? null; + return ( + visionGlobalConfigs?.find((c) => c.id === id) ?? + visionUserConfigs?.find((c) => c.id === id) ?? + null + ); }, [preferences, visionGlobalConfigs, visionUserConfigs]); - const isVisionAutoMode = useMemo(() => { - return ( - currentVisionConfig && - "is_auto_mode" in currentVisionConfig && - currentVisionConfig.is_auto_mode + const isVisionAutoMode = + currentVisionConfig && + "is_auto_mode" in currentVisionConfig && + currentVisionConfig.is_auto_mode; + + // ─── Filtered configs (separate global / user for section headers) ─── + const filteredLLMGlobal = useMemo( + () => + filterAndScore(llmGlobalConfigs ?? [], selectedProvider, searchQuery), + [llmGlobalConfigs, selectedProvider, searchQuery], + ); + const filteredLLMUser = useMemo( + () => + filterAndScore(llmUserConfigs ?? [], selectedProvider, searchQuery), + [llmUserConfigs, selectedProvider, searchQuery], + ); + const filteredImageGlobal = useMemo( + () => + filterAndScore( + imageGlobalConfigs ?? [], + selectedProvider, + searchQuery, + ), + [imageGlobalConfigs, selectedProvider, searchQuery], + ); + const filteredImageUser = useMemo( + () => + filterAndScore( + imageUserConfigs ?? [], + selectedProvider, + searchQuery, + ), + [imageUserConfigs, selectedProvider, searchQuery], + ); + const filteredVisionGlobal = useMemo( + () => + filterAndScore( + visionGlobalConfigs ?? [], + selectedProvider, + searchQuery, + ), + [visionGlobalConfigs, selectedProvider, searchQuery], + ); + const filteredVisionUser = useMemo( + () => + filterAndScore( + visionUserConfigs ?? [], + selectedProvider, + searchQuery, + ), + [visionUserConfigs, selectedProvider, searchQuery], + ); + + // Combined display list for keyboard navigation + const currentDisplayItems: DisplayItem[] = useMemo(() => { + const toItems = ( + configs: ConfigBase[], + isGlobal: boolean, + ): DisplayItem[] => + configs.map((c) => ({ + config: c as ConfigBase & Record, + isGlobal, + isAutoMode: + isGlobal && + "is_auto_mode" in c && + !!(c as Record).is_auto_mode, + })); + + switch (activeTab) { + case "llm": + return [ + ...toItems(filteredLLMGlobal, true), + ...toItems(filteredLLMUser, false), + ]; + case "image": + return [ + ...toItems(filteredImageGlobal, true), + ...toItems(filteredImageUser, false), + ]; + case "vision": + return [ + ...toItems(filteredVisionGlobal, true), + ...toItems(filteredVisionUser, false), + ]; + } + }, [ + activeTab, + filteredLLMGlobal, + filteredLLMUser, + filteredImageGlobal, + filteredImageUser, + filteredVisionGlobal, + filteredVisionUser, + ]); + + // ─── Provider sidebar data ─── + // Collect which providers actually have configured models for the active tab + const configuredProviderSet = useMemo(() => { + const configs = + activeTab === "llm" + ? [ + ...(llmGlobalConfigs ?? []), + ...(llmUserConfigs ?? []), + ] + : activeTab === "image" + ? [ + ...(imageGlobalConfigs ?? []), + ...(imageUserConfigs ?? []), + ] + : [ + ...(visionGlobalConfigs ?? []), + ...(visionUserConfigs ?? []), + ]; + const set = new Set(); + for (const c of configs) { + if (c.provider) set.add(c.provider.toUpperCase()); + } + return set; + }, [ + activeTab, + llmGlobalConfigs, + llmUserConfigs, + imageGlobalConfigs, + imageUserConfigs, + visionGlobalConfigs, + visionUserConfigs, + ]); + + // Show only providers valid for the active tab; configured ones first + const activeProviders = useMemo(() => { + const tabKeys = PROVIDER_KEYS_BY_TAB[activeTab] ?? LLM_PROVIDER_KEYS; + const configured = tabKeys.filter((p) => + configuredProviderSet.has(p), ); - }, [currentVisionConfig]); - - // ─── LLM filtering ─── - const filteredLLMGlobal = useMemo(() => { - if (!llmGlobalConfigs) return []; - if (!llmSearchQuery) return llmGlobalConfigs; - const q = llmSearchQuery.toLowerCase(); - return llmGlobalConfigs.filter( - (c) => - c.name.toLowerCase().includes(q) || - c.model_name.toLowerCase().includes(q) || - c.provider.toLowerCase().includes(q) + const unconfigured = tabKeys.filter( + (p) => !configuredProviderSet.has(p), ); - }, [llmGlobalConfigs, llmSearchQuery]); + return ["all", ...configured, ...unconfigured]; + }, [activeTab, configuredProviderSet]); - const filteredLLMUser = useMemo(() => { - if (!llmUserConfigs) return []; - if (!llmSearchQuery) return llmUserConfigs; - const q = llmSearchQuery.toLowerCase(); - return llmUserConfigs.filter( - (c) => - c.name.toLowerCase().includes(q) || - c.model_name.toLowerCase().includes(q) || - c.provider.toLowerCase().includes(q) - ); - }, [llmUserConfigs, llmSearchQuery]); + const providerModelCounts = useMemo(() => { + const allConfigs = + activeTab === "llm" + ? [ + ...(llmGlobalConfigs ?? []), + ...(llmUserConfigs ?? []), + ] + : activeTab === "image" + ? [ + ...(imageGlobalConfigs ?? []), + ...(imageUserConfigs ?? []), + ] + : [ + ...(visionGlobalConfigs ?? []), + ...(visionUserConfigs ?? []), + ]; + const counts: Record = { all: allConfigs.length }; + for (const c of allConfigs) { + const p = c.provider.toUpperCase(); + counts[p] = (counts[p] || 0) + 1; + } + return counts; + }, [ + activeTab, + llmGlobalConfigs, + llmUserConfigs, + imageGlobalConfigs, + imageUserConfigs, + visionGlobalConfigs, + visionUserConfigs, + ]); - const totalLLMModels = (llmGlobalConfigs?.length ?? 0) + (llmUserConfigs?.length ?? 0); - - // ─── Image filtering ─── - const filteredImageGlobal = useMemo(() => { - if (!imageGlobalConfigs) return []; - if (!imageSearchQuery) return imageGlobalConfigs; - const q = imageSearchQuery.toLowerCase(); - return imageGlobalConfigs.filter( - (c) => - c.name.toLowerCase().includes(q) || - c.model_name.toLowerCase().includes(q) || - c.provider.toLowerCase().includes(q) - ); - }, [imageGlobalConfigs, imageSearchQuery]); - - const filteredImageUser = useMemo(() => { - if (!imageUserConfigs) return []; - if (!imageSearchQuery) return imageUserConfigs; - const q = imageSearchQuery.toLowerCase(); - return imageUserConfigs.filter( - (c) => - c.name.toLowerCase().includes(q) || - c.model_name.toLowerCase().includes(q) || - c.provider.toLowerCase().includes(q) - ); - }, [imageUserConfigs, imageSearchQuery]); - - const totalImageModels = (imageGlobalConfigs?.length ?? 0) + (imageUserConfigs?.length ?? 0); - - // ─── Vision filtering ─── - const filteredVisionGlobal = useMemo(() => { - if (!visionGlobalConfigs) return []; - if (!visionSearchQuery) return visionGlobalConfigs; - const q = visionSearchQuery.toLowerCase(); - return visionGlobalConfigs.filter( - (c) => - c.name.toLowerCase().includes(q) || - c.model_name.toLowerCase().includes(q) || - c.provider.toLowerCase().includes(q) - ); - }, [visionGlobalConfigs, visionSearchQuery]); - - const filteredVisionUser = useMemo(() => { - if (!visionUserConfigs) return []; - if (!visionSearchQuery) return visionUserConfigs; - const q = visionSearchQuery.toLowerCase(); - return visionUserConfigs.filter( - (c) => - c.name.toLowerCase().includes(q) || - c.model_name.toLowerCase().includes(q) || - c.provider.toLowerCase().includes(q) - ); - }, [visionUserConfigs, visionSearchQuery]); - - const totalVisionModels = (visionGlobalConfigs?.length ?? 0) + (visionUserConfigs?.length ?? 0); - - // ─── Handlers ─── + // ─── Selection handlers ─── const handleSelectLLM = useCallback( async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => { if (currentLLMConfig?.id === config.id) { @@ -254,21 +574,11 @@ export function ModelSelector({ }); toast.success(`Switched to ${config.name}`); setOpen(false); - } catch (error) { - console.error("Failed to switch model:", error); + } catch { toast.error("Failed to switch model"); } }, - [currentLLMConfig, searchSpaceId, updatePreferences] - ); - - const handleEditLLMConfig = useCallback( - (e: React.MouseEvent, config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => { - e.stopPropagation(); - onEditLLM(config, isGlobal); - setOpen(false); - }, - [onEditLLM] + [currentLLMConfig, searchSpaceId, updatePreferences], ); const handleSelectImage = useCallback( @@ -292,7 +602,7 @@ export function ModelSelector({ toast.error("Failed to switch image model"); } }, - [currentImageConfig, searchSpaceId, updatePreferences] + [currentImageConfig, searchSpaceId, updatePreferences], ); const handleSelectVision = useCallback( @@ -316,649 +626,687 @@ export function ModelSelector({ toast.error("Failed to switch vision model"); } }, - [currentVisionConfig, searchSpaceId, updatePreferences] + [currentVisionConfig, searchSpaceId, updatePreferences], ); - return ( - - - + + + {isAll + ? "All Models" + : formatProviderName( + provider, + )} + {isConfigured + ? ` (${count})` + : " — not configured"} + + + + ); + })} + + {!isMobile && showScrollIndicator && ( +
+ +
+ )} + + ); + }; + + // ─── Render: Model card ─── + const getSelectedId = () => { + switch (activeTab) { + case "llm": + return currentLLMConfig?.id; + case "image": + return currentImageConfig?.id; + case "vision": + return currentVisionConfig?.id; + } + }; + + const renderModelCard = (item: DisplayItem, index: number) => { + const { config, isAutoMode } = item; + const isSelected = getSelectedId() === config.id; + const isFocused = focusedIndex === index; + const hasCitations = + "citations_enabled" in config && !!config.citations_enabled; + + return ( +
handleSelectItem(item)} + onMouseEnter={() => setFocusedIndex(index)} + className={cn( + "group flex items-start gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer", + "transition-all duration-150 mx-1", + "hover:bg-accent/40 active:scale-[0.99]", + isSelected && "bg-primary/6 dark:bg-primary/8", + isFocused && "bg-accent/50 ring-1 ring-primary/20", + )} + > + {/* Provider icon */} +
+ {getProviderIcon(config.provider as string, { + isAutoMode, + className: "size-5", + })} +
+ + {/* Model info */} +
+
+ + {config.name} + + {isAutoMode && ( + + Recommended + + )} +
+
+ + {isAutoMode + ? "Auto Mode" + : (config.model_name as string)} + + {!isAutoMode && hasCitations && ( + + Citations + + )} +
+
+ + {/* Actions */} +
+ {!isAutoMode && ( + + )} + {isSelected && ( + + )} +
+
+ ); + }; + + // ─── Render: Full content ─── + const renderContent = () => { + const globalItems = currentDisplayItems.filter((i) => i.isGlobal); + const userItems = currentDisplayItems.filter((i) => !i.isGlobal); + const globalStartIdx = 0; + const userStartIdx = globalItems.length; + + const addHandler = + activeTab === "llm" + ? onAddNewLLM + : activeTab === "image" + ? onAddNewImage + : onAddNewVision; + const addLabel = + activeTab === "llm" + ? "Add Model" + : activeTab === "image" + ? "Add Image Model" + : "Add Vision Model"; + + return ( +
+ {/* Tab header */} +
+
+ {( + [ + { + value: "llm" as const, + icon: Zap, + label: "LLM", + }, + { + value: "image" as const, + icon: ImageIcon, + label: "Image", + }, + { + value: "vision" as const, + icon: Eye, + label: "Vision", + }, + ] as const + ).map(({ value, icon: Icon, label }) => ( + + ))} +
+
+ + {/* Two-pane layout */} +
+ {/* Provider sidebar */} + {renderProviderSidebar()} + + {/* Main content */} +
+ {/* Search */} +
+ + + setSearchQuery(e.target.value) + } + onKeyDown={handleKeyDown} + autoFocus={!isMobile} + role="combobox" + aria-expanded={true} + aria-controls="model-selector-list" + className={cn( + "w-full pl-8 pr-3 py-1.5 text-xs rounded-lg", + "bg-secondary/30 border border-border/40", + "focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40", + "placeholder:text-muted-foreground/50", + "transition-[box-shadow,border-color] duration-200", + )} + /> +
+ + {/* Provider header when filtered */} + {selectedProvider !== "all" && ( +
+ {getProviderIcon(selectedProvider, { + className: "size-4", + })} + + {formatProviderName(selectedProvider)} + + + {configuredProviderSet.has( + selectedProvider, + ) + ? `${providerModelCounts[selectedProvider] || 0} models` + : "Not configured"} + +
+ )} + + {/* Model list */} +
+ {currentDisplayItems.length === 0 ? ( +
+ {selectedProvider !== "all" && + !configuredProviderSet.has( + selectedProvider, + ) ? ( + <> +
+ {getProviderIcon( + selectedProvider, + { + className: + "size-10", + }, + )} +
+

+ No{" "} + {formatProviderName( + selectedProvider, + )}{" "} + models configured +

+

+ Add a model with this + provider to get started +

+ {addHandler && ( + + )} + + ) : ( + <> + +

+ No models found +

+

+ Try a different search + term +

+ + )} +
+ ) : ( + <> + {globalItems.length > 0 && ( + <> +
+ Global Models +
+ {globalItems.map((item, i) => + renderModelCard( + item, + globalStartIdx + i, + ), + )} + + )} + {globalItems.length > 0 && + userItems.length > 0 && ( +
+ )} + {userItems.length > 0 && ( + <> +
+ Your Configurations +
+ {userItems.map((item, i) => + renderModelCard( + item, + userStartIdx + i, + ), + )} + + )} + + )} +
+ + {/* Add model button */} + {addHandler && ( +
+ +
+ )} +
+
+
+ ); + }; + + // ─── Trigger button ─── + const triggerButton = ( + - +
+ {/* Image */} + {currentImageConfig ? ( + <> + {getProviderIcon(currentImageConfig.provider, { + isAutoMode: isImageAutoMode ?? false, + })} + + {currentImageConfig.name} + + + ) : ( + + )} +
+ {/* Vision */} + {currentVisionConfig ? ( + <> + {getProviderIcon(currentVisionConfig.provider, { + isAutoMode: isVisionAutoMode ?? false, + })} + + {currentVisionConfig.name} + + + ) : ( + + )} + + )} + + + ); + // ─── Shell: Drawer on mobile, Popover on desktop ─── + if (isMobile) { + return ( + + {triggerButton} + + + + Select Model + +
+ {renderContent()} +
+
+
+ ); + } + + return ( + + {triggerButton} e.preventDefault()} > - setActiveTab(v as "llm" | "image" | "vision")} - className="w-full" - > -
- - - - LLM - - - - Image - - - - Vision - - -
- - {/* ─── LLM Tab ─── */} - - - {totalLLMModels > 3 && ( -
- -
- )} - - - -
- -

No models found

-

Try a different search term

-
-
- - {/* Global LLM Configs */} - {filteredLLMGlobal.length > 0 && ( - -
- Global Models -
- {filteredLLMGlobal.map((config) => { - const isSelected = currentLLMConfig?.id === config.id; - const isAutoMode = "is_auto_mode" in config && config.is_auto_mode; - return ( - handleSelectLLM(config)} - className={cn( - "mx-2 rounded-lg mb-1 cursor-pointer group transition-all", - "hover:bg-accent/50 dark:hover:bg-white/[0.06]", - isSelected && "bg-accent/80 dark:bg-white/[0.06]", - isAutoMode && "" - )} - > -
-
-
- {getProviderIcon(config.provider, { isAutoMode })} -
-
-
- {config.name} - {isAutoMode && ( - - Recommended - - )} - {isSelected && ( - - )} -
-
- - {isAutoMode ? "Auto Mode" : config.model_name} - - {!isAutoMode && config.citations_enabled && ( - - Citations - - )} -
-
-
- {!isAutoMode && ( - - )} -
-
- ); - })} -
- )} - - {filteredLLMGlobal.length > 0 && filteredLLMUser.length > 0 && ( - - )} - - {/* User LLM Configs */} - {filteredLLMUser.length > 0 && ( - -
- Your Configurations -
- {filteredLLMUser.map((config) => { - const isSelected = currentLLMConfig?.id === config.id; - return ( - handleSelectLLM(config)} - className={cn( - "mx-2 rounded-lg mb-1 cursor-pointer group transition-all", - "hover:bg-accent/50 dark:hover:bg-white/[0.06]", - isSelected && "bg-accent/80 dark:bg-white/[0.06]" - )} - > -
-
-
{getProviderIcon(config.provider)}
-
-
- {config.name} - {isSelected && ( - - )} -
-
- - {config.model_name} - - {config.citations_enabled && ( - - Citations - - )} -
-
-
- -
-
- ); - })} -
- )} - - {/* Add New LLM Config */} -
- -
-
-
-
- - {/* ─── Image Tab ─── */} - - - {totalImageModels > 3 && ( -
- -
- )} - - -
- -

No image models found

-

Try a different search term

-
-
- - {/* Global Image Configs */} - {filteredImageGlobal.length > 0 && ( - -
- Global Image Models -
- {filteredImageGlobal.map((config) => { - const isSelected = currentImageConfig?.id === config.id; - const isAuto = "is_auto_mode" in config && config.is_auto_mode; - return ( - handleSelectImage(config.id)} - className={cn( - "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]", - isSelected && "bg-accent/80 dark:bg-white/[0.06]", - isAuto && "" - )} - > -
-
- {getProviderIcon(config.provider, { isAutoMode: isAuto })} -
-
-
- {config.name} - {isAuto && ( - - Recommended - - )} - {isSelected && } -
- - {isAuto ? "Auto Mode" : config.model_name} - -
- {onEditImage && !isAuto && ( - - )} -
-
- ); - })} -
- )} - - {/* User Image Configs */} - {filteredImageUser.length > 0 && ( - <> - {filteredImageGlobal.length > 0 && ( - - )} - -
- Your Image Models -
- {filteredImageUser.map((config) => { - const isSelected = currentImageConfig?.id === config.id; - return ( - handleSelectImage(config.id)} - className={cn( - "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]", - isSelected && "bg-accent/80 dark:bg-white/[0.06]" - )} - > -
-
{getProviderIcon(config.provider)}
-
-
- {config.name} - {isSelected && ( - - )} -
- - {config.model_name} - -
- {onEditImage && ( - - )} -
-
- ); - })} -
- - )} - - {/* Add New Image Config */} - {onAddNewImage && ( -
- -
- )} -
-
-
- - {/* ─── Vision Tab ─── */} - - - {totalVisionModels > 3 && ( -
- -
- )} - - -
- -

No vision models found

-

Try a different search term

-
-
- - {filteredVisionGlobal.length > 0 && ( - -
- Global Vision Models -
- {filteredVisionGlobal.map((config) => { - const isSelected = currentVisionConfig?.id === config.id; - const isAuto = "is_auto_mode" in config && config.is_auto_mode; - return ( - handleSelectVision(config.id)} - className={cn( - "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]", - isSelected && "bg-accent/80 dark:bg-white/[0.06]" - )} - > -
-
- {getProviderIcon(config.provider, { isAutoMode: isAuto })} -
-
-
- {config.name} - {isAuto && ( - - Recommended - - )} - {isSelected && } -
- - {isAuto ? "Auto Mode" : config.model_name} - -
- {onEditVision && !isAuto && ( - - )} -
-
- ); - })} -
- )} - - {filteredVisionUser.length > 0 && ( - <> - {filteredVisionGlobal.length > 0 && ( - - )} - -
- Your Vision Models -
- {filteredVisionUser.map((config) => { - const isSelected = currentVisionConfig?.id === config.id; - return ( - handleSelectVision(config.id)} - className={cn( - "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]", - isSelected && "bg-accent/80 dark:bg-white/[0.06]" - )} - > -
-
{getProviderIcon(config.provider)}
-
-
- {config.name} - {isSelected && ( - - )} -
- - {config.model_name} - -
- {onEditVision && ( - - )} -
-
- ); - })} -
- - )} - - {onAddNewVision && ( -
- -
- )} -
-
-
-
+ {renderContent()}
); diff --git a/surfsense_web/components/shared/image-config-dialog.tsx b/surfsense_web/components/shared/image-config-dialog.tsx index 2ae53ccca..1b94f0a35 100644 --- a/surfsense_web/components/shared/image-config-dialog.tsx +++ b/surfsense_web/components/shared/image-config-dialog.tsx @@ -48,6 +48,7 @@ interface ImageConfigDialogProps { isGlobal: boolean; searchSpaceId: number; mode: "create" | "edit" | "view"; + defaultProvider?: string; } const INITIAL_FORM = { @@ -67,6 +68,7 @@ export function ImageConfigDialog({ isGlobal, searchSpaceId, mode, + defaultProvider, }: ImageConfigDialogProps) { const [isSubmitting, setIsSubmitting] = useState(false); const [formData, setFormData] = useState(INITIAL_FORM); @@ -87,11 +89,11 @@ export function ImageConfigDialog({ api_version: config.api_version || "", }); } else if (mode === "create") { - setFormData(INITIAL_FORM); + setFormData({ ...INITIAL_FORM, provider: defaultProvider ?? "" }); } setScrollPos("top"); } - }, [open, mode, config, isGlobal]); + }, [open, mode, config, isGlobal, defaultProvider]); const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom); const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom); diff --git a/surfsense_web/components/shared/model-config-dialog.tsx b/surfsense_web/components/shared/model-config-dialog.tsx index 4d2373b49..1a3c8e4a0 100644 --- a/surfsense_web/components/shared/model-config-dialog.tsx +++ b/surfsense_web/components/shared/model-config-dialog.tsx @@ -28,6 +28,7 @@ interface ModelConfigDialogProps { isGlobal: boolean; searchSpaceId: number; mode: "create" | "edit" | "view"; + defaultProvider?: string; } export function ModelConfigDialog({ @@ -37,6 +38,7 @@ export function ModelConfigDialog({ isGlobal, searchSpaceId, mode, + defaultProvider, }: ModelConfigDialogProps) { const [isSubmitting, setIsSubmitting] = useState(false); const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top"); @@ -194,10 +196,12 @@ export function ModelConfigDialog({ {mode === "create" ? ( ) : isGlobal && config ? (
diff --git a/surfsense_web/components/shared/vision-config-dialog.tsx b/surfsense_web/components/shared/vision-config-dialog.tsx index 7332d3dcd..7f96b0594 100644 --- a/surfsense_web/components/shared/vision-config-dialog.tsx +++ b/surfsense_web/components/shared/vision-config-dialog.tsx @@ -49,6 +49,7 @@ interface VisionConfigDialogProps { isGlobal: boolean; searchSpaceId: number; mode: "create" | "edit" | "view"; + defaultProvider?: string; } const INITIAL_FORM = { @@ -68,6 +69,7 @@ export function VisionConfigDialog({ isGlobal, searchSpaceId, mode, + defaultProvider, }: VisionConfigDialogProps) { const [isSubmitting, setIsSubmitting] = useState(false); const [formData, setFormData] = useState(INITIAL_FORM); @@ -87,11 +89,11 @@ export function VisionConfigDialog({ api_version: (config as VisionLLMConfig).api_version || "", }); } else if (mode === "create") { - setFormData(INITIAL_FORM); + setFormData({ ...INITIAL_FORM, provider: defaultProvider ?? "" }); } setScrollPos("top"); } - }, [open, mode, config, isGlobal]); + }, [open, mode, config, isGlobal, defaultProvider]); const { mutateAsync: createConfig } = useAtomValue(createVisionLLMConfigMutationAtom); const { mutateAsync: updateConfig } = useAtomValue(updateVisionLLMConfigMutationAtom);