diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx index 8a8fa11a0..2f1d9d845 100644 --- a/surfsense_web/components/new-chat/chat-header.tsx +++ b/surfsense_web/components/new-chat/chat-header.tsx @@ -8,7 +8,6 @@ import type { NewLLMConfigPublic, } from "@/contracts/types/new-llm-config.types"; import { ImageConfigSidebar } from "./image-config-sidebar"; -import { ImageModelSelector } from "./image-model-selector"; import { ModelConfigSidebar } from "./model-config-sidebar"; import { ModelSelector } from "./model-selector"; @@ -34,7 +33,7 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { const [imageSidebarMode, setImageSidebarMode] = useState<"create" | "edit" | "view">("view"); // LLM handlers - const handleEditConfig = useCallback( + const handleEditLLMConfig = useCallback( (config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => { setSelectedConfig(config); setIsGlobal(global); @@ -44,7 +43,7 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { [] ); - const handleAddNew = useCallback(() => { + const handleAddNewLLM = useCallback(() => { setSelectedConfig(null); setIsGlobal(false); setSidebarMode("create"); @@ -81,8 +80,12 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { return (
- - + void; - onEdit?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void; -} - -export function ImageModelSelector({ className, onAddNew, onEdit }: ImageModelSelectorProps) { - const [open, setOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - - const { data: globalConfigs, isLoading: globalLoading } = useAtomValue(globalImageGenConfigsAtom); - const { data: userConfigs, isLoading: userLoading } = useAtomValue(imageGenConfigsAtom); - const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom); - const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); - const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); - - const isLoading = globalLoading || userLoading || prefsLoading; - - const currentConfig = useMemo(() => { - if (!preferences) return null; - const id = preferences.image_generation_config_id; - if (id === null || id === undefined) return null; - const globalMatch = globalConfigs?.find((c) => c.id === id); - if (globalMatch) return globalMatch; - return userConfigs?.find((c) => c.id === id) ?? null; - }, [preferences, globalConfigs, userConfigs]); - - const isCurrentAutoMode = useMemo(() => { - return currentConfig && "is_auto_mode" in currentConfig && currentConfig.is_auto_mode; - }, [currentConfig]); - - const filteredGlobal = useMemo(() => { - if (!globalConfigs) return []; - if (!searchQuery) return globalConfigs; - const q = searchQuery.toLowerCase(); - return globalConfigs.filter( - (c) => - c.name.toLowerCase().includes(q) || - c.model_name.toLowerCase().includes(q) || - c.provider.toLowerCase().includes(q) - ); - }, [globalConfigs, searchQuery]); - - const filteredUser = useMemo(() => { - if (!userConfigs) return []; - if (!searchQuery) return userConfigs; - const q = searchQuery.toLowerCase(); - return userConfigs.filter( - (c) => - c.name.toLowerCase().includes(q) || - c.model_name.toLowerCase().includes(q) || - c.provider.toLowerCase().includes(q) - ); - }, [userConfigs, searchQuery]); - - const totalModels = (globalConfigs?.length ?? 0) + (userConfigs?.length ?? 0); - - const handleSelect = useCallback( - async (configId: number) => { - if (currentConfig?.id === configId) { - setOpen(false); - return; - } - if (!searchSpaceId) { - toast.error("No search space selected"); - return; - } - try { - await updatePreferences({ - search_space_id: Number(searchSpaceId), - data: { image_generation_config_id: configId }, - }); - toast.success("Image model updated"); - setOpen(false); - } catch { - toast.error("Failed to switch image model"); - } - }, - [currentConfig, searchSpaceId, updatePreferences] - ); - - // Don't render if no configs at all - if (!isLoading && totalModels === 0) { - return ( - - ); - } - - return ( - - - - - - - - {totalModels > 3 && ( -
- -
- )} - - -
- -

No image models found

-
-
- - {/* Global Image Gen Configs */} - {filteredGlobal.length > 0 && ( - -
- - Global Image Models -
- {filteredGlobal.map((config) => { - const isSelected = currentConfig?.id === config.id; - const isAuto = "is_auto_mode" in config && config.is_auto_mode; - return ( - handleSelect(config.id)} - className={cn( - "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50", - isSelected && "bg-accent/80", - isAuto && "border border-violet-200 dark:border-violet-800/50" - )} - > -
-
- {isAuto ? ( - - ) : ( - - )} -
-
-
- {config.name} - {isAuto && ( - - Recommended - - )} - {isSelected && } -
- - {isAuto ? "Auto load balancing" : config.model_name} - -
- {onEdit && ( - { - e.stopPropagation(); - setOpen(false); - onEdit(config, true); - }} - /> - )} -
-
- ); - })} -
- )} - - {/* User Image Gen Configs */} - {filteredUser.length > 0 && ( - <> - {filteredGlobal.length > 0 && } - -
- - Your Image Models -
- {filteredUser.map((config) => { - const isSelected = currentConfig?.id === config.id; - return ( - handleSelect(config.id)} - className={cn( - "mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50", - isSelected && "bg-accent/80" - )} - > -
-
- -
-
-
- {config.name} - {isSelected && } -
- - {config.model_name} - -
- {onEdit && ( - - )} -
-
- ); - })} -
- - )} - - {/* Add New */} - {onAddNew && ( -
- -
- )} -
-
-
-
- ); -} diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx index 988dc7209..d27594ee6 100644 --- a/surfsense_web/components/new-chat/model-selector.tsx +++ b/surfsense_web/components/new-chat/model-selector.tsx @@ -6,10 +6,16 @@ import { Check, ChevronDown, Edit3, + ImageIcon, Plus, + Zap, } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; +import { + globalImageGenConfigsAtom, + imageGenConfigsAtom, +} from "@/atoms/image-gen-config/image-gen-config-query.atoms"; import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms"; import { globalNewLLMConfigsAtom, @@ -30,102 +36,150 @@ import { } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Spinner } from "@/components/ui/spinner"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { + GlobalImageGenConfig, GlobalNewLLMConfig, + ImageGenerationConfig, NewLLMConfigPublic, } from "@/contracts/types/new-llm-config.types"; -import { cn } from "@/lib/utils"; import { getProviderIcon } from "@/lib/provider-icons"; +import { cn } from "@/lib/utils"; interface ModelSelectorProps { - onEdit: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void; - onAddNew: () => void; + onEditLLM: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void; + onAddNewLLM: () => void; + onEditImage?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void; + onAddNewImage?: () => void; className?: string; } -export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) { +export function ModelSelector({ + onEditLLM, + onAddNewLLM, + onEditImage, + onAddNewImage, + className, +}: ModelSelectorProps) { const [open, setOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); + const [activeTab, setActiveTab] = useState<"llm" | "image">("llm"); + const [llmSearchQuery, setLlmSearchQuery] = useState(""); + const [imageSearchQuery, setImageSearchQuery] = useState(""); - // Fetch configs - const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom); - const { data: globalConfigs, isLoading: globalConfigsLoading } = + // LLM data + const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom); + const { data: llmGlobalConfigs, isLoading: llmGlobalLoading } = useAtomValue(globalNewLLMConfigsAtom); - const { data: preferences, isLoading: preferencesLoading } = useAtomValue(llmPreferencesAtom); + const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); - const isLoading = userConfigsLoading || globalConfigsLoading || preferencesLoading; + // Image data + const { data: imageGlobalConfigs, isLoading: imageGlobalLoading } = + useAtomValue(globalImageGenConfigsAtom); + const { data: imageUserConfigs, isLoading: imageUserLoading } = + useAtomValue(imageGenConfigsAtom); - // Get current agent LLM config - const currentConfig = useMemo(() => { + const isLoading = llmUserLoading || llmGlobalLoading || prefsLoading || imageGlobalLoading || imageUserLoading; + + // ─── LLM current config ─── + const currentLLMConfig = useMemo(() => { if (!preferences) return null; - const agentLlmId = preferences.agent_llm_id; if (agentLlmId === null || agentLlmId === undefined) return null; - - // Check if it's Auto mode (ID 0) or global config (negative ID) if (agentLlmId <= 0) { - return globalConfigs?.find((c) => c.id === agentLlmId) ?? null; + return llmGlobalConfigs?.find((c) => c.id === agentLlmId) ?? null; } - // Otherwise, check user configs - return userConfigs?.find((c) => c.id === agentLlmId) ?? null; - }, [preferences, globalConfigs, userConfigs]); + return llmUserConfigs?.find((c) => c.id === agentLlmId) ?? null; + }, [preferences, llmGlobalConfigs, llmUserConfigs]); - // Check if current config is Auto mode - const isCurrentAutoMode = useMemo(() => { - return currentConfig && "is_auto_mode" in currentConfig && currentConfig.is_auto_mode; - }, [currentConfig]); + const isLLMAutoMode = useMemo(() => { + return currentLLMConfig && "is_auto_mode" in currentLLMConfig && currentLLMConfig.is_auto_mode; + }, [currentLLMConfig]); - // Filter configs based on search - const filteredGlobalConfigs = useMemo(() => { - if (!globalConfigs) return []; - if (!searchQuery) return globalConfigs; - const query = searchQuery.toLowerCase(); - return globalConfigs.filter( + // ─── 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; + }, [preferences, imageGlobalConfigs, imageUserConfigs]); + + const isImageAutoMode = useMemo(() => { + return currentImageConfig && "is_auto_mode" in currentImageConfig && currentImageConfig.is_auto_mode; + }, [currentImageConfig]); + + // ─── LLM filtering ─── + const filteredLLMGlobal = useMemo(() => { + if (!llmGlobalConfigs) return []; + if (!llmSearchQuery) return llmGlobalConfigs; + const q = llmSearchQuery.toLowerCase(); + return llmGlobalConfigs.filter( (c) => - c.name.toLowerCase().includes(query) || - c.model_name.toLowerCase().includes(query) || - c.provider.toLowerCase().includes(query) + c.name.toLowerCase().includes(q) || + c.model_name.toLowerCase().includes(q) || + c.provider.toLowerCase().includes(q) ); - }, [globalConfigs, searchQuery]); + }, [llmGlobalConfigs, llmSearchQuery]); - const filteredUserConfigs = useMemo(() => { - if (!userConfigs) return []; - if (!searchQuery) return userConfigs; - const query = searchQuery.toLowerCase(); - return userConfigs.filter( + const filteredLLMUser = useMemo(() => { + if (!llmUserConfigs) return []; + if (!llmSearchQuery) return llmUserConfigs; + const q = llmSearchQuery.toLowerCase(); + return llmUserConfigs.filter( (c) => - c.name.toLowerCase().includes(query) || - c.model_name.toLowerCase().includes(query) || - c.provider.toLowerCase().includes(query) + c.name.toLowerCase().includes(q) || + c.model_name.toLowerCase().includes(q) || + c.provider.toLowerCase().includes(q) ); - }, [userConfigs, searchQuery]); + }, [llmUserConfigs, llmSearchQuery]); - // Total model count for conditional search display - const totalModels = useMemo(() => { - return (globalConfigs?.length ?? 0) + (userConfigs?.length ?? 0); - }, [globalConfigs, userConfigs]); + const totalLLMModels = (llmGlobalConfigs?.length ?? 0) + (llmUserConfigs?.length ?? 0); - const handleSelectConfig = useCallback( + // ─── 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); + + // ─── Handlers ─── + const handleSelectLLM = useCallback( async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => { - // If already selected, just close - if (currentConfig?.id === config.id) { + if (currentLLMConfig?.id === config.id) { setOpen(false); return; } - if (!searchSpaceId) { toast.error("No search space selected"); return; } - try { await updatePreferences({ search_space_id: Number(searchSpaceId), - data: { - agent_llm_id: config.id, - }, + data: { agent_llm_id: config.id }, }); toast.success(`Switched to ${config.name}`); setOpen(false); @@ -134,16 +188,40 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp toast.error("Failed to switch model"); } }, - [currentConfig, searchSpaceId, updatePreferences] + [currentLLMConfig, searchSpaceId, updatePreferences] ); - const handleEditConfig = useCallback( + const handleEditLLMConfig = useCallback( (e: React.MouseEvent, config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => { e.stopPropagation(); - onEdit(config, isGlobal); + onEditLLM(config, isGlobal); setOpen(false); }, - [onEdit] + [onEditLLM] + ); + + const handleSelectImage = useCallback( + async (configId: number) => { + if (currentImageConfig?.id === configId) { + setOpen(false); + return; + } + if (!searchSpaceId) { + toast.error("No search space selected"); + return; + } + try { + await updatePreferences({ + search_space_id: Number(searchSpaceId), + data: { image_generation_config_id: configId }, + }); + toast.success("Image model updated"); + setOpen(false); + } catch { + toast.error("Failed to switch image model"); + } + }, + [currentImageConfig, searchSpaceId, updatePreferences] ); return ( @@ -161,30 +239,37 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp Loading - ) : currentConfig ? ( - <> - {getProviderIcon(currentConfig.provider, { isAutoMode: isCurrentAutoMode ?? false })} - - {currentConfig.name} - - {isCurrentAutoMode ? ( - - Balanced - - ) : ( - - {currentConfig.model_name.split("/").pop()?.slice(0, 10) || - currentConfig.model_name.slice(0, 10)} - - )} - ) : ( <> - - Select Model + {/* LLM section */} + {currentLLMConfig ? ( + <> + {getProviderIcon(currentLLMConfig.provider, { isAutoMode: isLLMAutoMode ?? false })} + + {currentLLMConfig.name} + + + ) : ( + <> + + Select Model + + )} + + {/* Divider */} +
+ + {/* Image section */} + {currentImageConfig ? ( + <> + {getProviderIcon(currentImageConfig.provider, { isAutoMode: isImageAutoMode ?? false })} + + {currentImageConfig.name} + + + ) : ( + + )} )} - setActiveTab(v as "llm" | "image")} + className="w-full" > - {totalModels > 3 && ( -
- -
- )} - - - -
- -

No models found

-

Try a different search term

-
-
- - {/* Global Configs Section */} - {filteredGlobalConfigs.length > 0 && ( - -
- Global Models -
- {filteredGlobalConfigs.map((config) => { - const isSelected = currentConfig?.id === config.id; - const isAutoMode = "is_auto_mode" in config && config.is_auto_mode; - return ( - handleSelectConfig(config)} - className={cn( - "mx-2 rounded-lg mb-1 cursor-pointer group transition-all", - "hover:bg-accent/50", - isSelected && "bg-accent/80", - isAutoMode && "border border-violet-200 dark:border-violet-800/50" - )} - > -
-
-
- {getProviderIcon(config.provider, { isAutoMode })} -
-
-
- {config.name} - {isAutoMode && ( - - Recommended - - )} - {isSelected && } -
-
- - {isAutoMode ? "Auto load balancing" : config.model_name} - - {!isAutoMode && config.citations_enabled && ( - - Citations - - )} -
-
-
- {!isAutoMode && ( - - )} -
-
- ); - })} -
- )} - - {filteredGlobalConfigs.length > 0 && filteredUserConfigs.length > 0 && ( - - )} - - {/* User Configs Section */} - {filteredUserConfigs.length > 0 && ( - -
- Your Configurations -
- {filteredUserConfigs.map((config) => { - const isSelected = currentConfig?.id === config.id; - return ( - handleSelectConfig(config)} - className={cn( - "mx-2 rounded-lg mb-1 cursor-pointer group transition-all", - "hover:bg-accent/50", - isSelected && "bg-accent/80" - )} - > -
-
-
{getProviderIcon(config.provider)}
-
-
- {config.name} - {isSelected && } -
-
- - {config.model_name} - - {config.citations_enabled && ( - - Citations - - )} -
-
-
- -
-
- ); - })} -
- )} - - {/* Add New Config Button */} -
- -
-
-
+ + LLM + + + + Image + + +
+ + {/* ─── 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", + isSelected && "bg-accent/80", + isAutoMode && "border border-violet-800" + )} + > +
+
+
+ {getProviderIcon(config.provider, { isAutoMode })} +
+
+
+ {config.name} + {isAutoMode && ( + + Recommended + + )} + {isSelected && } +
+
+ + {isAutoMode ? "Auto load balancing" : 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", + isSelected && "bg-accent/80" + )} + > +
+
+
{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

+
+
+ + {/* 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", + isSelected && "bg-accent/80", + isAuto && "border border-violet-800" + )} + > +
+
+ {getProviderIcon(config.provider, { isAutoMode: isAuto })} +
+
+
+ {config.name} + {isAuto && ( + + Recommended + + )} + {isSelected && } +
+ + {isAuto ? "Auto load balancing" : 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", + isSelected && "bg-accent/80" + )} + > +
+
+ {getProviderIcon(config.provider)} +
+
+
+ {config.name} + {isSelected && } +
+ + {config.model_name} + +
+ {onEditImage && ( + + )} +
+
+ ); + })} +
+ + )} + + {/* Add New Image Config */} + {onAddNewImage && ( +
+ +
+ )} +
+
+
+ ); diff --git a/surfsense_web/lib/provider-icons.tsx b/surfsense_web/lib/provider-icons.tsx index ce474c5a9..4a32f0df0 100644 --- a/surfsense_web/lib/provider-icons.tsx +++ b/surfsense_web/lib/provider-icons.tsx @@ -47,7 +47,7 @@ export function getProviderIcon( }: { isAutoMode?: boolean; className?: string } = {} ) { if (isAutoMode || provider?.toUpperCase() === "AUTO") { - return ; + return ; } switch (provider?.toUpperCase()) {