diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx index e19f97945..8a8fa11a0 100644 --- a/surfsense_web/components/new-chat/chat-header.tsx +++ b/surfsense_web/components/new-chat/chat-header.tsx @@ -1,11 +1,13 @@ "use client"; import { useCallback, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; import type { + GlobalImageGenConfig, GlobalNewLLMConfig, + ImageGenerationConfig, 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"; @@ -15,7 +17,7 @@ interface ChatHeaderProps { } export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { - const router = useRouter(); + // LLM config sidebar state const [sidebarOpen, setSidebarOpen] = useState(false); const [selectedConfig, setSelectedConfig] = useState< NewLLMConfigPublic | GlobalNewLLMConfig | null @@ -23,6 +25,15 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { const [isGlobal, setIsGlobal] = useState(false); const [sidebarMode, setSidebarMode] = useState<"create" | "edit" | "view">("view"); + // Image config sidebar state + const [imageSidebarOpen, setImageSidebarOpen] = useState(false); + const [selectedImageConfig, setSelectedImageConfig] = useState< + ImageGenerationConfig | GlobalImageGenConfig | null + >(null); + const [isImageGlobal, setIsImageGlobal] = useState(false); + const [imageSidebarMode, setImageSidebarMode] = useState<"create" | "edit" | "view">("view"); + + // LLM handlers const handleEditConfig = useCallback( (config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => { setSelectedConfig(config); @@ -42,20 +53,36 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { const handleSidebarClose = useCallback((open: boolean) => { setSidebarOpen(open); - if (!open) { - setSelectedConfig(null); - } + if (!open) setSelectedConfig(null); }, []); + // Image model handlers const handleAddImageModel = useCallback(() => { - // Navigate to settings image-models tab - router.push(`/dashboard/${searchSpaceId}/settings?tab=image-models`); - }, [router, searchSpaceId]); + setSelectedImageConfig(null); + setIsImageGlobal(false); + setImageSidebarMode("create"); + setImageSidebarOpen(true); + }, []); + + const handleEditImageConfig = useCallback( + (config: ImageGenerationConfig | GlobalImageGenConfig, global: boolean) => { + setSelectedImageConfig(config); + setIsImageGlobal(global); + setImageSidebarMode(global ? "view" : "edit"); + setImageSidebarOpen(true); + }, + [] + ); + + const handleImageSidebarClose = useCallback((open: boolean) => { + setImageSidebarOpen(open); + if (!open) setSelectedImageConfig(null); + }, []); return (
- + +
); } diff --git a/surfsense_web/components/new-chat/image-config-sidebar.tsx b/surfsense_web/components/new-chat/image-config-sidebar.tsx new file mode 100644 index 000000000..18f98acb7 --- /dev/null +++ b/surfsense_web/components/new-chat/image-config-sidebar.tsx @@ -0,0 +1,522 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { + AlertCircle, + Check, + ChevronsUpDown, + Globe, + ImageIcon, + Key, + Shuffle, + X, + Zap, +} from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { toast } from "sonner"; +import { + createImageGenConfigMutationAtom, + updateImageGenConfigMutationAtom, +} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms"; +import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +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 { Spinner } from "@/components/ui/spinner"; +import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-gen-providers"; +import type { + GlobalImageGenConfig, + ImageGenerationConfig, +} from "@/contracts/types/new-llm-config.types"; +import { cn } from "@/lib/utils"; + +interface ImageConfigSidebarProps { + open: boolean; + onOpenChange: (open: boolean) => void; + config: ImageGenerationConfig | GlobalImageGenConfig | null; + isGlobal: boolean; + searchSpaceId: number; + mode: "create" | "edit" | "view"; +} + +const INITIAL_FORM = { + name: "", + description: "", + provider: "", + model_name: "", + api_key: "", + api_base: "", + api_version: "", +}; + +export function ImageConfigSidebar({ + open, + onOpenChange, + config, + isGlobal, + searchSpaceId, + mode, +}: ImageConfigSidebarProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [mounted, setMounted] = useState(false); + const [formData, setFormData] = useState(INITIAL_FORM); + const [modelComboboxOpen, setModelComboboxOpen] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + // Reset form when opening + useEffect(() => { + if (open) { + if (mode === "edit" && config && !isGlobal) { + setFormData({ + name: config.name || "", + description: config.description || "", + provider: config.provider || "", + model_name: config.model_name || "", + api_key: (config as ImageGenerationConfig).api_key || "", + api_base: config.api_base || "", + api_version: config.api_version || "", + }); + } else if (mode === "create") { + setFormData(INITIAL_FORM); + } + } + }, [open, mode, config, isGlobal]); + + // Mutations + const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom); + const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom); + const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); + + // Escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && open) onOpenChange(false); + }; + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [open, onOpenChange]); + + const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode; + + const suggestedModels = useMemo(() => { + if (!formData.provider) return []; + return IMAGE_GEN_MODELS.filter((m) => m.provider === formData.provider); + }, [formData.provider]); + + const getTitle = () => { + if (mode === "create") return "Add Image Model"; + if (isAutoMode) return "Auto Mode (Load Balanced)"; + if (isGlobal) return "View Global Image Model"; + return "Edit Image Model"; + }; + + const handleSubmit = useCallback(async () => { + setIsSubmitting(true); + try { + if (mode === "create") { + const result = await createConfig({ + name: formData.name, + provider: formData.provider, + model_name: formData.model_name, + api_key: formData.api_key, + api_base: formData.api_base || undefined, + api_version: formData.api_version || undefined, + description: formData.description || undefined, + search_space_id: searchSpaceId, + }); + // Set as active image model + if (result?.id) { + await updatePreferences({ + search_space_id: searchSpaceId, + data: { image_generation_config_id: result.id }, + }); + } + toast.success("Image model created and assigned!"); + onOpenChange(false); + } else if (!isGlobal && config) { + await updateConfig({ + id: config.id, + data: { + name: formData.name, + description: formData.description || undefined, + provider: formData.provider, + model_name: formData.model_name, + api_key: formData.api_key, + api_base: formData.api_base || undefined, + api_version: formData.api_version || undefined, + }, + }); + toast.success("Image model updated!"); + onOpenChange(false); + } + } catch (error) { + console.error("Failed to save image config:", error); + toast.error("Failed to save image model"); + } finally { + setIsSubmitting(false); + } + }, [mode, isGlobal, config, formData, searchSpaceId, createConfig, updateConfig, updatePreferences, onOpenChange]); + + const handleUseGlobalConfig = useCallback(async () => { + if (!config || !isGlobal) return; + setIsSubmitting(true); + try { + await updatePreferences({ + search_space_id: searchSpaceId, + data: { image_generation_config_id: config.id }, + }); + toast.success(`Now using ${config.name}`); + onOpenChange(false); + } catch (error) { + console.error("Failed to set image model:", error); + toast.error("Failed to set image model"); + } finally { + setIsSubmitting(false); + } + }, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]); + + const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key; + const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider); + + if (!mounted) return null; + + const sidebarContent = ( + + {open && ( + <> + {/* Backdrop */} + onOpenChange(false)} + /> + + {/* Sidebar */} + + {/* Header */} +
+
+
+ {isAutoMode ? ( + + ) : ( + + )} +
+
+

{getTitle()}

+
+ {isAutoMode ? ( + + + Recommended + + ) : isGlobal ? ( + + + Global + + ) : null} + {config && !isAutoMode && ( + {config.model_name} + )} +
+
+
+ +
+ + {/* Content */} +
+
+ {/* Auto mode */} + {isAutoMode && ( + <> + + + + Auto mode distributes image generation requests across all configured providers for optimal performance and rate limit protection. + + +
+ + +
+ + )} + + {/* Global config (read-only) */} + {isGlobal && !isAutoMode && config && ( + <> + + + + Global configurations are read-only. To customize, create a new model. + + +
+
+
+
Name
+

{config.name}

+
+ {config.description && ( +
+
Description
+

{config.description}

+
+ )} +
+ +
+
+
Provider
+

{config.provider}

+
+
+
Model
+

{config.model_name}

+
+
+
+
+ + +
+ + )} + + {/* Create / Edit form */} + {(mode === "create" || (mode === "edit" && !isGlobal)) && ( +
+ {/* Name */} +
+ + setFormData((p) => ({ ...p, name: e.target.value }))} + /> +
+ + {/* Description */} +
+ + setFormData((p) => ({ ...p, description: e.target.value }))} + /> +
+ + + + {/* Provider */} +
+ + +
+ + {/* Model Name */} +
+ + {suggestedModels.length > 0 ? ( + + + + + + + setFormData((p) => ({ ...p, model_name: val }))} + /> + + + Type a custom model name + + + {suggestedModels.map((m) => ( + { + setFormData((p) => ({ ...p, model_name: m.value })); + setModelComboboxOpen(false); + }} + > + + {m.value} + {m.label} + + ))} + + + + + + ) : ( + setFormData((p) => ({ ...p, model_name: e.target.value }))} + /> + )} +
+ + {/* API Key */} +
+ + setFormData((p) => ({ ...p, api_key: e.target.value }))} + /> +
+ + {/* API Base */} +
+ + setFormData((p) => ({ ...p, api_base: e.target.value }))} + /> +
+ + {/* Azure API Version */} + {formData.provider === "AZURE_OPENAI" && ( +
+ + setFormData((p) => ({ ...p, api_version: e.target.value }))} + /> +
+ )} + + {/* Actions */} +
+ + +
+
+ )} +
+
+
+ + )} +
+ ); + + return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null; +} diff --git a/surfsense_web/components/new-chat/image-model-selector.tsx b/surfsense_web/components/new-chat/image-model-selector.tsx index 8cae10345..b3422b264 100644 --- a/surfsense_web/components/new-chat/image-model-selector.tsx +++ b/surfsense_web/components/new-chat/image-model-selector.tsx @@ -38,14 +38,19 @@ import { } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Spinner } from "@/components/ui/spinner"; +import type { + GlobalImageGenConfig, + ImageGenerationConfig, +} from "@/contracts/types/new-llm-config.types"; import { cn } from "@/lib/utils"; interface ImageModelSelectorProps { className?: string; onAddNew?: () => void; + onEdit?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void; } -export function ImageModelSelector({ className, onAddNew }: ImageModelSelectorProps) { +export function ImageModelSelector({ className, onAddNew, onEdit }: ImageModelSelectorProps) { const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -220,49 +225,59 @@ export function ImageModelSelector({ className, onAddNew }: ImageModelSelectorPr 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} - -
+ {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); + }} + /> + )} +
+
+ ); + })} )} @@ -275,37 +290,51 @@ export function ImageModelSelector({ className, onAddNew }: ImageModelSelectorPr 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} - -
+ {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 && ( + + )} +
+
+ ); + })} )}