diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx
index 3263a2b07..0c5253c6c 100644
--- a/surfsense_web/components/new-chat/chat-header.tsx
+++ b/surfsense_web/components/new-chat/chat-header.tsx
@@ -3,11 +3,14 @@
import { useCallback, useState } from "react";
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
+import { VisionConfigDialog } from "@/components/shared/vision-config-dialog";
import type {
GlobalImageGenConfig,
GlobalNewLLMConfig,
+ GlobalVisionLLMConfig,
ImageGenerationConfig,
NewLLMConfigPublic,
+ VisionLLMConfig,
} from "@/contracts/types/new-llm-config.types";
import { ModelSelector } from "./model-selector";
@@ -33,6 +36,14 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
const [isImageGlobal, setIsImageGlobal] = useState(false);
const [imageDialogMode, setImageDialogMode] = useState<"create" | "edit" | "view">("view");
+ // Vision config dialog state
+ const [visionDialogOpen, setVisionDialogOpen] = useState(false);
+ const [selectedVisionConfig, setSelectedVisionConfig] = useState<
+ VisionLLMConfig | GlobalVisionLLMConfig | null
+ >(null);
+ const [isVisionGlobal, setIsVisionGlobal] = useState(false);
+ const [visionDialogMode, setVisionDialogMode] = useState<"create" | "edit" | "view">("view");
+
// LLM handlers
const handleEditLLMConfig = useCallback(
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
@@ -79,6 +90,29 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
if (!open) setSelectedImageConfig(null);
}, []);
+ // Vision model handlers
+ const handleAddVisionModel = useCallback(() => {
+ setSelectedVisionConfig(null);
+ setIsVisionGlobal(false);
+ setVisionDialogMode("create");
+ setVisionDialogOpen(true);
+ }, []);
+
+ const handleEditVisionConfig = useCallback(
+ (config: VisionLLMConfig | GlobalVisionLLMConfig, global: boolean) => {
+ setSelectedVisionConfig(config);
+ setIsVisionGlobal(global);
+ setVisionDialogMode(global ? "view" : "edit");
+ setVisionDialogOpen(true);
+ },
+ []
+ );
+
+ const handleVisionDialogClose = useCallback((open: boolean) => {
+ setVisionDialogOpen(open);
+ if (!open) setSelectedVisionConfig(null);
+ }, []);
+
return (
+
);
}
diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx
index 39f88f794..46b4a2c3a 100644
--- a/surfsense_web/components/new-chat/model-selector.tsx
+++ b/surfsense_web/components/new-chat/model-selector.tsx
@@ -1,7 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
-import { Bot, Check, ChevronDown, Edit3, ImageIcon, Plus, Search, Zap } from "lucide-react";
+import { Bot, Check, ChevronDown, Edit3, Eye, ImageIcon, Plus, Search, Zap } from "lucide-react";
import { type UIEvent, useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import {
@@ -15,6 +15,10 @@ import {
newLLMConfigsAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
+import {
+ globalVisionLLMConfigsAtom,
+ visionLLMConfigsAtom,
+} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -32,8 +36,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
GlobalImageGenConfig,
GlobalNewLLMConfig,
+ GlobalVisionLLMConfig,
ImageGenerationConfig,
NewLLMConfigPublic,
+ VisionLLMConfig,
} from "@/contracts/types/new-llm-config.types";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
@@ -43,6 +49,8 @@ interface ModelSelectorProps {
onAddNewLLM: () => void;
onEditImage?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void;
onAddNewImage?: () => void;
+ onEditVision?: (config: VisionLLMConfig | GlobalVisionLLMConfig, isGlobal: boolean) => void;
+ onAddNewVision?: () => void;
className?: string;
}
@@ -51,14 +59,18 @@ export function ModelSelector({
onAddNewLLM,
onEditImage,
onAddNewImage,
+ onEditVision,
+ onAddNewVision,
className,
}: ModelSelectorProps) {
const [open, setOpen] = useState(false);
- const [activeTab, setActiveTab] = useState<"llm" | "image">("llm");
+ 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;
@@ -82,8 +94,21 @@ export function ModelSelector({
useAtomValue(globalImageGenConfigsAtom);
const { data: imageUserConfigs, isLoading: imageUserLoading } = useAtomValue(imageGenConfigsAtom);
+ // Vision data
+ const { data: visionGlobalConfigs, isLoading: visionGlobalLoading } = useAtomValue(
+ globalVisionLLMConfigsAtom
+ );
+ const { data: visionUserConfigs, isLoading: visionUserLoading } =
+ useAtomValue(visionLLMConfigsAtom);
+
const isLoading =
- llmUserLoading || llmGlobalLoading || prefsLoading || imageGlobalLoading || imageUserLoading;
+ llmUserLoading ||
+ llmGlobalLoading ||
+ prefsLoading ||
+ imageGlobalLoading ||
+ imageUserLoading ||
+ visionGlobalLoading ||
+ visionUserLoading;
// ─── LLM current config ───
const currentLLMConfig = useMemo(() => {
@@ -116,6 +141,24 @@ export function ModelSelector({
);
}, [currentImageConfig]);
+ // ─── 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;
+ }, [preferences, visionGlobalConfigs, visionUserConfigs]);
+
+ const isVisionAutoMode = useMemo(() => {
+ return (
+ currentVisionConfig &&
+ "is_auto_mode" in currentVisionConfig &&
+ currentVisionConfig.is_auto_mode
+ );
+ }, [currentVisionConfig]);
+
// ─── LLM filtering ───
const filteredLLMGlobal = useMemo(() => {
if (!llmGlobalConfigs) return [];
@@ -170,6 +213,33 @@ export function ModelSelector({
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 ───
const handleSelectLLM = useCallback(
async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => {
@@ -229,6 +299,30 @@ export function ModelSelector({
[currentImageConfig, searchSpaceId, updatePreferences]
);
+ const handleSelectVision = useCallback(
+ async (configId: number) => {
+ if (currentVisionConfig?.id === configId) {
+ setOpen(false);
+ return;
+ }
+ if (!searchSpaceId) {
+ toast.error("No search space selected");
+ return;
+ }
+ try {
+ await updatePreferences({
+ search_space_id: Number(searchSpaceId),
+ data: { vision_llm_config_id: configId },
+ });
+ toast.success("Vision model updated");
+ setOpen(false);
+ } catch {
+ toast.error("Failed to switch vision model");
+ }
+ },
+ [currentVisionConfig, searchSpaceId, updatePreferences]
+ );
+
return (
@@ -282,6 +376,23 @@ export function ModelSelector({
) : (
)}
+
+ {/* Divider */}
+
+
+ {/* Vision section */}
+ {currentVisionConfig ? (
+ <>
+ {getProviderIcon(currentVisionConfig.provider, {
+ isAutoMode: isVisionAutoMode ?? false,
+ })}
+
+ {currentVisionConfig.name}
+
+ >
+ ) : (
+
+ )}
>
)}
@@ -295,25 +406,32 @@ export function ModelSelector({
>
setActiveTab(v as "llm" | "image")}
+ onValueChange={(v) => setActiveTab(v as "llm" | "image" | "vision")}
className="w-full"
>
-
+
-
+
LLM
-
+
Image
+
+
+ Vision
+
@@ -676,6 +794,174 @@ export function ModelSelector({
+
+ {/* ─── 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 && (
+
+
+
+ )}
+
+
+