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.
+
+
+
+
+
+ {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 && (
+
+ )}
+
+
+ );
+ })}
>
)}