This commit is contained in:
Eric Lammertsma 2026-02-05 21:42:26 -05:00
commit ae94dafdb1
52 changed files with 4833 additions and 230 deletions

View file

@ -4,16 +4,20 @@ import Image from "next/image";
import Link from "next/link";
import { cn } from "@/lib/utils";
export const Logo = ({ className }: { className?: string }) => {
return (
<Link href="/">
<Image
src="/icon-128.svg"
className={cn("dark:invert", className)}
alt="logo"
width={128}
height={128}
/>
</Link>
export const Logo = ({ className, disableLink = false }: { className?: string; disableLink?: boolean }) => {
const image = (
<Image
src="/icon-128.svg"
className={cn("dark:invert", className)}
alt="logo"
width={128}
height={128}
/>
);
if (disableLink) {
return image;
}
return <Link href="/">{image}</Link>;
};

View file

@ -64,7 +64,7 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
href="/"
className="flex flex-1 flex-row items-center gap-0.5 hover:opacity-80 transition-opacity"
>
<Logo className="h-8 w-8 rounded-md" />
<Logo className="h-8 w-8 rounded-md" disableLink />
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
</Link>
<div className="hidden flex-1 flex-row items-center justify-center space-x-2 text-sm font-medium text-zinc-600 transition duration-200 hover:text-zinc-800 lg:flex lg:space-x-2">
@ -145,7 +145,7 @@ const MobileNav = ({ navItems, isScrolled }: any) => {
href="/"
className="flex flex-row items-center gap-2 hover:opacity-80 transition-opacity"
>
<Logo className="h-8 w-8 rounded-md" />
<Logo className="h-8 w-8 rounded-md" disableLink />
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
</Link>
<button

View file

@ -2,9 +2,13 @@
import { useCallback, useState } from "react";
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";
@ -13,6 +17,7 @@ interface ChatHeaderProps {
}
export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
// LLM config sidebar state
const [sidebarOpen, setSidebarOpen] = useState(false);
const [selectedConfig, setSelectedConfig] = useState<
NewLLMConfigPublic | GlobalNewLLMConfig | null
@ -20,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);
@ -39,15 +53,36 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
const handleSidebarClose = useCallback((open: boolean) => {
setSidebarOpen(open);
if (!open) {
// Reset state when closing
setSelectedConfig(null);
}
if (!open) setSelectedConfig(null);
}, []);
// Image model handlers
const handleAddImageModel = useCallback(() => {
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 (
<div className="flex items-center gap-2">
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
<ImageModelSelector onEdit={handleEditImageConfig} onAddNew={handleAddImageModel} />
<ModelConfigSidebar
open={sidebarOpen}
onOpenChange={handleSidebarClose}
@ -56,6 +91,14 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
searchSpaceId={searchSpaceId}
mode={sidebarMode}
/>
<ImageConfigSidebar
open={imageSidebarOpen}
onOpenChange={handleImageSidebarClose}
config={selectedImageConfig}
isGlobal={isImageGlobal}
searchSpaceId={searchSpaceId}
mode={imageSidebarMode}
/>
</div>
);
}

View file

@ -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 = (
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm"
onClick={() => onOpenChange(false)}
/>
{/* Sidebar */}
<motion.div
initial={{ x: "100%", opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: "100%", opacity: 0 }}
transition={{ type: "spring", damping: 30, stiffness: 300 }}
className={cn(
"fixed right-0 top-0 z-50 h-full w-full sm:w-[480px] lg:w-[540px]",
"bg-background border-l border-border/50 shadow-2xl",
"flex flex-col"
)}
>
{/* Header */}
<div
className={cn(
"flex items-center justify-between px-6 py-4 border-b border-border/50",
isAutoMode
? "bg-gradient-to-r from-violet-500/10 to-purple-500/10"
: "bg-gradient-to-r from-teal-500/10 to-cyan-500/10"
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"flex items-center justify-center size-10 rounded-xl",
isAutoMode
? "bg-gradient-to-br from-violet-500 to-purple-600"
: "bg-gradient-to-br from-teal-500 to-cyan-600"
)}
>
{isAutoMode ? (
<Shuffle className="size-5 text-white" />
) : (
<ImageIcon className="size-5 text-white" />
)}
</div>
<div>
<h2 className="text-base sm:text-lg font-semibold">{getTitle()}</h2>
<div className="flex items-center gap-2 mt-0.5">
{isAutoMode ? (
<Badge
variant="secondary"
className="gap-1 text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
>
<Zap className="size-3" />
Recommended
</Badge>
) : isGlobal ? (
<Badge variant="secondary" className="gap-1 text-xs">
<Globe className="size-3" />
Global
</Badge>
) : null}
{config && !isAutoMode && (
<span className="text-xs text-muted-foreground">{config.model_name}</span>
)}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8 rounded-full"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="p-6">
{/* Auto mode */}
{isAutoMode && (
<>
<Alert className="mb-6 border-violet-500/30 bg-violet-500/5">
<Shuffle className="size-4 text-violet-500" />
<AlertDescription className="text-sm text-violet-700 dark:text-violet-400">
Auto mode distributes image generation requests across all configured providers for optimal performance and rate limit protection.
</AlertDescription>
</Alert>
<div className="flex gap-3 pt-4 border-t border-border/50">
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button
className="flex-1 gap-2 bg-gradient-to-r from-violet-500 to-purple-600 hover:from-violet-600 hover:to-purple-700"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Use Auto Mode"}
</Button>
</div>
</>
)}
{/* Global config (read-only) */}
{isGlobal && !isAutoMode && config && (
<>
<Alert className="mb-6 border-amber-500/30 bg-amber-500/5">
<AlertCircle className="size-4 text-amber-500" />
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
Global configurations are read-only. To customize, create a new model.
</AlertDescription>
</Alert>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Name</div>
<p className="text-sm font-medium">{config.name}</p>
</div>
{config.description && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Description</div>
<p className="text-sm text-muted-foreground">{config.description}</p>
</div>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Provider</div>
<p className="text-sm font-medium">{config.provider}</p>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</div>
<p className="text-sm font-medium font-mono">{config.model_name}</p>
</div>
</div>
</div>
<div className="flex gap-3 pt-6 border-t border-border/50 mt-6">
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button className="flex-1 gap-2" onClick={handleUseGlobalConfig} disabled={isSubmitting}>
{isSubmitting ? "Loading..." : "Use This Model"}
</Button>
</div>
</>
)}
{/* Create / Edit form */}
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
<div className="space-y-4">
{/* Name */}
<div className="space-y-2">
<Label className="text-sm font-medium">Name *</Label>
<Input
placeholder="e.g., My DALL-E 3"
value={formData.name}
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label className="text-sm font-medium">Description</Label>
<Input
placeholder="Optional description"
value={formData.description}
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
/>
</div>
<Separator />
{/* Provider */}
<div className="space-y-2">
<Label className="text-sm font-medium">Provider *</Label>
<Select
value={formData.provider}
onValueChange={(val) => setFormData((p) => ({ ...p, provider: val, model_name: "" }))}
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{IMAGE_GEN_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value}>
<div className="flex flex-col">
<span className="font-medium">{p.label}</span>
<span className="text-xs text-muted-foreground">{p.example}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Model Name */}
<div className="space-y-2">
<Label className="text-sm font-medium">Model Name *</Label>
{suggestedModels.length > 0 ? (
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between font-normal">
{formData.model_name || "Select or type a model..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput
placeholder="Search or type model..."
value={formData.model_name}
onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))}
/>
<CommandList>
<CommandEmpty>
<span className="text-xs text-muted-foreground">Type a custom model name</span>
</CommandEmpty>
<CommandGroup>
{suggestedModels.map((m) => (
<CommandItem
key={m.value}
value={m.value}
onSelect={() => {
setFormData((p) => ({ ...p, model_name: m.value }));
setModelComboboxOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", formData.model_name === m.value ? "opacity-100" : "opacity-0")} />
<span className="font-mono text-sm">{m.value}</span>
<span className="ml-2 text-xs text-muted-foreground">{m.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
placeholder="e.g., dall-e-3"
value={formData.model_name}
onChange={(e) => setFormData((p) => ({ ...p, model_name: e.target.value }))}
/>
)}
</div>
{/* API Key */}
<div className="space-y-2">
<Label className="text-sm font-medium flex items-center gap-1.5">
<Key className="h-3.5 w-3.5" /> API Key *
</Label>
<Input
type="password"
placeholder="sk-..."
value={formData.api_key}
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
/>
</div>
{/* API Base */}
<div className="space-y-2">
<Label className="text-sm font-medium">API Base URL</Label>
<Input
placeholder={selectedProvider?.apiBase || "Optional"}
value={formData.api_base}
onChange={(e) => setFormData((p) => ({ ...p, api_base: e.target.value }))}
/>
</div>
{/* Azure API Version */}
{formData.provider === "AZURE_OPENAI" && (
<div className="space-y-2">
<Label className="text-sm font-medium">API Version (Azure)</Label>
<Input
placeholder="2024-02-15-preview"
value={formData.api_version}
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
/>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-4 border-t">
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
className="flex-1 gap-2 bg-gradient-to-r from-teal-500 to-cyan-600 hover:from-teal-600 hover:to-cyan-700"
onClick={handleSubmit}
disabled={isSubmitting || !isFormValid}
>
{isSubmitting ? <Spinner size="sm" className="mr-2" /> : null}
{mode === "edit" ? "Save Changes" : "Create & Use"}
</Button>
</div>
</div>
)}
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null;
}

View file

@ -0,0 +1,364 @@
"use client";
import { useAtomValue } from "jotai";
import {
Check,
ChevronDown,
ChevronRight,
Edit3,
Globe,
ImageIcon,
Plus,
Shuffle,
User,
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import {
createImageGenConfigMutationAtom,
updateImageGenConfigMutationAtom,
} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
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 { llmPreferencesAtom } from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} 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, 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 (
<Button
variant="outline"
size="sm"
onClick={onAddNew}
className={cn("h-8 gap-2 px-3 text-sm border-border/60", className)}
>
<Plus className="size-4 text-teal-600" />
<span className="hidden md:inline">Add Image Model</span>
</Button>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
role="combobox"
aria-expanded={open}
className={cn("h-8 gap-2 px-3 text-sm border-border/60", className)}
>
{isLoading ? (
<Spinner size="sm" className="text-muted-foreground" />
) : currentConfig ? (
<>
{isCurrentAutoMode ? (
<Shuffle className="size-4 text-violet-500" />
) : (
<ImageIcon className="size-4 text-teal-500" />
)}
<span className="max-w-[100px] md:max-w-[120px] truncate hidden md:inline">
{currentConfig.name}
</span>
{isCurrentAutoMode ? (
<Badge
variant="secondary"
className="ml-1 text-[10px] px-1.5 py-0 h-4 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
>
Auto
</Badge>
) : (
<Badge
variant="secondary"
className="ml-1 text-[10px] px-1.5 py-0 h-4 bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300"
>
Image
</Badge>
)}
</>
) : (
<>
<ImageIcon className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground hidden md:inline">Image Model</span>
</>
)}
<ChevronDown
className={cn(
"h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0 transition-transform duration-200",
open && "rotate-180"
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60"
align="start"
sideOffset={8}
>
<Command shouldFilter={false} className="rounded-lg">
{totalModels > 3 && (
<div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1.5 md:py-2">
<CommandInput
placeholder="Search image models..."
value={searchQuery}
onValueChange={setSearchQuery}
className="h-7 md:h-8 text-xs md:text-sm border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
/>
</div>
)}
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto">
<CommandEmpty className="py-8 text-center">
<div className="flex flex-col items-center gap-2">
<ImageIcon className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No image models found</p>
</div>
</CommandEmpty>
{/* Global Image Gen Configs */}
{filteredGlobal.length > 0 && (
<CommandGroup>
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
<Globe className="size-3.5" />
Global Image Models
</div>
{filteredGlobal.map((config) => {
const isSelected = currentConfig?.id === config.id;
const isAuto = "is_auto_mode" in config && config.is_auto_mode;
return (
<CommandItem
key={`g-${config.id}`}
value={`g-${config.id}`}
onSelect={() => 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"
)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="shrink-0">
{isAuto ? (
<Shuffle className="size-4 text-violet-500" />
) : (
<ImageIcon className="size-4 text-teal-500" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{config.name}</span>
{isAuto && (
<Badge
variant="secondary"
className="text-[9px] px-1 py-0 h-3.5 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-0"
>
Recommended
</Badge>
)}
{isSelected && <Check className="size-3.5 text-primary shrink-0" />}
</div>
<span className="text-xs text-muted-foreground truncate block">
{isAuto ? "Auto load balancing" : config.model_name}
</span>
</div>
{onEdit && (
<ChevronRight
className="size-3.5 text-muted-foreground shrink-0 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setOpen(false);
onEdit(config, true);
}}
/>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
)}
{/* User Image Gen Configs */}
{filteredUser.length > 0 && (
<>
{filteredGlobal.length > 0 && <CommandSeparator className="my-1 bg-border/30" />}
<CommandGroup>
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
<User className="size-3.5" />
Your Image Models
</div>
{filteredUser.map((config) => {
const isSelected = currentConfig?.id === config.id;
return (
<CommandItem
key={`u-${config.id}`}
value={`u-${config.id}`}
onSelect={() => handleSelect(config.id)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="shrink-0">
<ImageIcon className="size-4 text-teal-500" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{config.name}</span>
{isSelected && (
<Check className="size-3.5 text-primary shrink-0" />
)}
</div>
<span className="text-xs text-muted-foreground truncate block">
{config.model_name}
</span>
</div>
{onEdit && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
setOpen(false);
onEdit(config, false);
}}
>
<Edit3 className="size-3.5 text-muted-foreground" />
</Button>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</>
)}
{/* Add New */}
{onAddNew && (
<div className="p-2 bg-muted/20">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50"
onClick={() => {
setOpen(false);
onAddNew();
}}
>
<Plus className="size-4 text-teal-600" />
<span className="text-sm font-medium">Add Image Model</span>
</Button>
</div>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View file

@ -392,8 +392,8 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
</CommandGroup>
)}
{/* Add New Config Button */}
<div className="p-2 bg-muted/20">
{/* Add New Config Button */}
<div className="p-2 bg-muted/20">
<Button
variant="ghost"
size="sm"

View file

@ -0,0 +1,692 @@
"use client";
import { useAtomValue } from "jotai";
import {
AlertCircle,
Check,
ChevronsUpDown,
Clock,
Edit3,
ImageIcon,
Key,
Plus,
RefreshCw,
Shuffle,
Sparkles,
Trash2,
Wand2,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
createImageGenConfigMutationAtom,
deleteImageGenConfigMutationAtom,
updateImageGenConfigMutationAtom,
} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
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 { llmPreferencesAtom } from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
IMAGE_GEN_PROVIDERS,
getImageGenModelsByProvider,
} from "@/contracts/enums/image-gen-providers";
import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
interface ImageModelManagerProps {
searchSpaceId: number;
}
const container = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
};
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 },
};
export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
// Image gen config atoms
const { mutateAsync: createConfig, isPending: isCreating, error: createError } =
useAtomValue(createImageGenConfigMutationAtom);
const { mutateAsync: updateConfig, isPending: isUpdating, error: updateError } =
useAtomValue(updateImageGenConfigMutationAtom);
const { mutateAsync: deleteConfig, isPending: isDeleting, error: deleteError } =
useAtomValue(deleteImageGenConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const { data: userConfigs, isFetching: configsLoading, error: fetchError, refetch: refreshConfigs } =
useAtomValue(imageGenConfigsAtom);
const { data: globalConfigs = [], isFetching: globalLoading } =
useAtomValue(globalImageGenConfigsAtom);
const { data: preferences = {}, isFetching: prefsLoading } = useAtomValue(llmPreferencesAtom);
// Local state
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingConfig, setEditingConfig] = useState<ImageGenerationConfig | null>(null);
const [configToDelete, setConfigToDelete] = useState<ImageGenerationConfig | null>(null);
// Preference state
const [selectedPrefId, setSelectedPrefId] = useState<string | number>(
preferences.image_generation_config_id ?? ""
);
const [hasPrefChanges, setHasPrefChanges] = useState(false);
const [isSavingPref, setIsSavingPref] = useState(false);
useEffect(() => {
setSelectedPrefId(preferences.image_generation_config_id ?? "");
setHasPrefChanges(false);
}, [preferences]);
const isSubmitting = isCreating || isUpdating;
const isLoading = configsLoading || globalLoading || prefsLoading;
const errors = [createError, updateError, deleteError, fetchError].filter(Boolean) as Error[];
// Form state for create/edit dialog
const [formData, setFormData] = useState({
name: "",
description: "",
provider: "",
custom_provider: "",
model_name: "",
api_key: "",
api_base: "",
api_version: "",
});
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
const resetForm = () => {
setFormData({
name: "",
description: "",
provider: "",
custom_provider: "",
model_name: "",
api_key: "",
api_base: "",
api_version: "",
});
};
const handleFormSubmit = useCallback(async () => {
if (!formData.name || !formData.provider || !formData.model_name || !formData.api_key) {
toast.error("Please fill in all required fields");
return;
}
try {
if (editingConfig) {
await updateConfig({
id: editingConfig.id,
data: {
name: formData.name,
description: formData.description || undefined,
provider: formData.provider as any,
custom_provider: formData.custom_provider || undefined,
model_name: formData.model_name,
api_key: formData.api_key,
api_base: formData.api_base || undefined,
api_version: formData.api_version || undefined,
},
});
} else {
const result = await createConfig({
name: formData.name,
description: formData.description || undefined,
provider: formData.provider as any,
custom_provider: formData.custom_provider || undefined,
model_name: formData.model_name,
api_key: formData.api_key,
api_base: formData.api_base || undefined,
api_version: formData.api_version || undefined,
search_space_id: searchSpaceId,
});
// Auto-assign newly created config
if (result?.id) {
await updatePreferences({
search_space_id: searchSpaceId,
data: { image_generation_config_id: result.id },
});
}
}
setIsDialogOpen(false);
setEditingConfig(null);
resetForm();
} catch {
// Error handled by mutation
}
}, [editingConfig, formData, searchSpaceId, createConfig, updateConfig, updatePreferences]);
const handleDelete = async () => {
if (!configToDelete) return;
try {
await deleteConfig(configToDelete.id);
setConfigToDelete(null);
} catch {
// Error handled by mutation
}
};
const openEditDialog = (config: ImageGenerationConfig) => {
setEditingConfig(config);
setFormData({
name: config.name,
description: config.description || "",
provider: config.provider,
custom_provider: config.custom_provider || "",
model_name: config.model_name,
api_key: config.api_key,
api_base: config.api_base || "",
api_version: config.api_version || "",
});
setIsDialogOpen(true);
};
const openNewDialog = () => {
setEditingConfig(null);
resetForm();
setIsDialogOpen(true);
};
const handlePrefChange = (value: string) => {
const newVal = value === "unassigned" ? "" : parseInt(value);
setSelectedPrefId(newVal);
setHasPrefChanges(newVal !== (preferences.image_generation_config_id ?? ""));
};
const handleSavePref = async () => {
setIsSavingPref(true);
try {
await updatePreferences({
search_space_id: searchSpaceId,
data: {
image_generation_config_id:
typeof selectedPrefId === "string"
? selectedPrefId ? parseInt(selectedPrefId) : undefined
: selectedPrefId,
},
});
setHasPrefChanges(false);
toast.success("Image generation model preference saved!");
} catch {
toast.error("Failed to save preference");
} finally {
setIsSavingPref(false);
}
};
const allConfigs = [
...globalConfigs.map((c) => ({ ...c, _source: "global" as const })),
...(userConfigs ?? []).map((c) => ({ ...c, _source: "user" as const })),
];
const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider);
const suggestedModels = getImageGenModelsByProvider(formData.provider);
return (
<div className="space-y-4 md:space-y-6">
{/* Header */}
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<Button
variant="outline"
size="sm"
onClick={() => refreshConfigs()}
disabled={isLoading}
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
>
<RefreshCw className={cn("h-3 w-3 md:h-4 md:w-4", configsLoading && "animate-spin")} />
Refresh
</Button>
</div>
{/* Errors */}
<AnimatePresence>
{errors.map((err) => (
<motion.div key={err?.message} initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
<Alert variant="destructive" className="py-3">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
</Alert>
</motion.div>
))}
</AnimatePresence>
{/* Global info */}
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
<Alert className="border-teal-500/30 bg-teal-500/5 py-3">
<Sparkles className="h-3 w-3 md:h-4 md:w-4 text-teal-600 dark:text-teal-400 shrink-0" />
<AlertDescription className="text-teal-800 dark:text-teal-200 text-xs md:text-sm">
<span className="font-medium">
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length} global image model(s)
</span>{" "}
available from your administrator.
</AlertDescription>
</Alert>
)}
{/* Active Preference Card */}
{!isLoading && allConfigs.length > 0 && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}>
<Card className="border-l-4 border-l-teal-500">
<CardHeader className="pb-2 px-3 md:px-6 pt-3 md:pt-6">
<div className="flex items-center gap-2 md:gap-3">
<div className="p-1.5 md:p-2 rounded-lg bg-teal-100 text-teal-800">
<ImageIcon className="w-4 h-4 md:w-5 md:h-5" />
</div>
<div>
<CardTitle className="text-base md:text-lg">Active Image Model</CardTitle>
<CardDescription className="text-xs md:text-sm">
Select which model to use for image generation
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 px-3 md:px-6 pb-3 md:pb-6">
<Select
value={selectedPrefId?.toString() || "unassigned"}
onValueChange={handlePrefChange}
>
<SelectTrigger className="h-9 md:h-10 text-xs md:text-sm">
<SelectValue placeholder="Select an image model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">
<span className="text-muted-foreground">Unassigned</span>
</SelectItem>
{globalConfigs.length > 0 && (
<>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">Global</div>
{globalConfigs.map((c) => {
const isAuto = "is_auto_mode" in c && c.is_auto_mode;
return (
<SelectItem key={`g-${c.id}`} value={c.id.toString()}>
<div className="flex items-center gap-2">
{isAuto ? (
<Badge variant="outline" className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-violet-200">
<Shuffle className="size-3 mr-1" />AUTO
</Badge>
) : (
<Badge variant="outline" className="text-xs bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 border-teal-200">
{c.provider}
</Badge>
)}
<span>{c.name}</span>
</div>
</SelectItem>
);
})}
</>
)}
{(userConfigs?.length ?? 0) > 0 && (
<>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">Your Models</div>
{userConfigs?.map((c) => (
<SelectItem key={`u-${c.id}`} value={c.id.toString()}>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">{c.provider}</Badge>
<span>{c.name}</span>
<span className="text-muted-foreground">({c.model_name})</span>
</div>
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
{hasPrefChanges && (
<div className="flex gap-2 pt-1">
<Button size="sm" onClick={handleSavePref} disabled={isSavingPref} className="text-xs h-8">
{isSavingPref ? "Saving..." : "Save"}
</Button>
<Button size="sm" variant="outline" onClick={() => { setSelectedPrefId(preferences.image_generation_config_id ?? ""); setHasPrefChanges(false); }} className="text-xs h-8">
Reset
</Button>
</div>
)}
</CardContent>
</Card>
</motion.div>
)}
{/* Loading */}
{isLoading && (
<Card>
<CardContent className="flex items-center justify-center py-10">
<Spinner size="md" className="text-muted-foreground" />
</CardContent>
</Card>
)}
{/* User Configs */}
{!isLoading && (
<div className="space-y-4 md:space-y-6">
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<h3 className="text-lg md:text-xl font-semibold tracking-tight">Your Image Models</h3>
<Button onClick={openNewDialog} className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9">
<Plus className="h-3 w-3 md:h-4 md:w-4" />
Add Image Model
</Button>
</div>
{(userConfigs?.length ?? 0) === 0 ? (
<Card className="border-dashed border-2 border-muted-foreground/25">
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
<div className="rounded-full bg-gradient-to-br from-teal-500/10 to-cyan-500/10 p-4 md:p-6 mb-4">
<Wand2 className="h-8 w-8 md:h-12 md:w-12 text-teal-600 dark:text-teal-400" />
</div>
<h3 className="text-lg font-semibold mb-2">No Image Models Yet</h3>
<p className="text-xs md:text-sm text-muted-foreground max-w-sm mb-4">
Add your own image generation model (DALL-E 3, GPT Image 1, etc.)
</p>
<Button onClick={openNewDialog} size="lg" className="gap-2 text-xs md:text-sm">
<Plus className="h-3 w-3 md:h-4 md:w-4" />
Add First Image Model
</Button>
</CardContent>
</Card>
) : (
<motion.div variants={container} initial="hidden" animate="show" className="grid gap-4">
<AnimatePresence mode="popLayout">
{userConfigs?.map((config) => (
<motion.div key={config.id} variants={item} layout exit={{ opacity: 0, scale: 0.95 }}>
<Card className="group overflow-hidden hover:shadow-lg transition-all duration-300 border-muted-foreground/10 hover:border-teal-500/30">
<CardContent className="p-0">
<div className="flex">
<div className="w-1 md:w-1.5 bg-gradient-to-b from-teal-500/50 to-cyan-500/50 group-hover:from-teal-500 group-hover:to-cyan-500 transition-colors" />
<div className="flex-1 p-3 md:p-5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 md:gap-4 flex-1 min-w-0">
<div className="flex h-10 w-10 md:h-12 md:w-12 items-center justify-center rounded-lg md:rounded-xl bg-gradient-to-br from-teal-500/10 to-cyan-500/10 shrink-0">
<ImageIcon className="h-5 w-5 md:h-6 md:w-6 text-teal-600 dark:text-teal-400" />
</div>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-1.5 flex-wrap">
<h4 className="text-sm md:text-base font-semibold truncate">{config.name}</h4>
<Badge variant="secondary" className="text-[9px] md:text-[10px] px-1.5 py-0.5 bg-teal-500/10 text-teal-700 dark:text-teal-300 border-teal-500/20">
{config.provider}
</Badge>
</div>
<code className="text-[10px] md:text-xs font-mono text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded-md inline-block">
{config.model_name}
</code>
{config.description && (
<p className="text-[10px] md:text-xs text-muted-foreground line-clamp-1">{config.description}</p>
)}
<div className="flex items-center gap-1 text-[10px] md:text-xs text-muted-foreground pt-1">
<Clock className="h-2.5 w-2.5 md:h-3 md:w-3" />
{new Date(config.created_at).toLocaleDateString()}
</div>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={() => openEditDialog(config)} className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground">
<Edit3 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={() => setConfigToDelete(config)} className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</AnimatePresence>
</motion.div>
)}
</div>
)}
{/* Create/Edit Dialog */}
<Dialog open={isDialogOpen} onOpenChange={(open) => { if (!open) { setIsDialogOpen(false); setEditingConfig(null); resetForm(); } }}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{editingConfig ? <Edit3 className="w-5 h-5 text-teal-600" /> : <Plus className="w-5 h-5 text-teal-600" />}
{editingConfig ? "Edit Image Model" : "Add Image Model"}
</DialogTitle>
<DialogDescription>
{editingConfig ? "Update your image generation model" : "Configure a new image generation model (DALL-E 3, GPT Image 1, etc.)"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
{/* Name */}
<div className="space-y-2">
<Label className="text-sm font-medium">Name *</Label>
<Input
placeholder="e.g., My DALL-E 3"
value={formData.name}
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label className="text-sm font-medium">Description</Label>
<Input
placeholder="Optional description"
value={formData.description}
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
/>
</div>
<Separator />
{/* Provider */}
<div className="space-y-2">
<Label className="text-sm font-medium">Provider *</Label>
<Select
value={formData.provider}
onValueChange={(val) => setFormData((p) => ({ ...p, provider: val, model_name: "" }))}
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{IMAGE_GEN_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value}>
<div className="flex flex-col">
<span className="font-medium">{p.label}</span>
<span className="text-xs text-muted-foreground">{p.example}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Model Name */}
<div className="space-y-2">
<Label className="text-sm font-medium">Model Name *</Label>
{suggestedModels.length > 0 ? (
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between font-normal">
{formData.model_name || "Select or type a model..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput
placeholder="Search or type model name..."
value={formData.model_name}
onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))}
/>
<CommandList>
<CommandEmpty>
<span className="text-xs text-muted-foreground">Type a custom model name</span>
</CommandEmpty>
<CommandGroup>
{suggestedModels.map((m) => (
<CommandItem
key={m.value}
value={m.value}
onSelect={() => {
setFormData((p) => ({ ...p, model_name: m.value }));
setModelComboboxOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", formData.model_name === m.value ? "opacity-100" : "opacity-0")} />
<span className="font-mono text-sm">{m.value}</span>
<span className="ml-2 text-xs text-muted-foreground">{m.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
placeholder="e.g., dall-e-3"
value={formData.model_name}
onChange={(e) => setFormData((p) => ({ ...p, model_name: e.target.value }))}
/>
)}
</div>
{/* API Key */}
<div className="space-y-2">
<Label className="text-sm font-medium flex items-center gap-1.5">
<Key className="h-3.5 w-3.5" /> API Key *
</Label>
<Input
type="password"
placeholder="sk-..."
value={formData.api_key}
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
/>
</div>
{/* API Base (optional) */}
<div className="space-y-2">
<Label className="text-sm font-medium">API Base URL</Label>
<Input
placeholder={selectedProvider?.apiBase || "Optional"}
value={formData.api_base}
onChange={(e) => setFormData((p) => ({ ...p, api_base: e.target.value }))}
/>
</div>
{/* API Version (Azure) */}
{formData.provider === "AZURE_OPENAI" && (
<div className="space-y-2">
<Label className="text-sm font-medium">API Version (Azure)</Label>
<Input
placeholder="2024-02-15-preview"
value={formData.api_version}
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
/>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-4 border-t">
<Button
variant="outline"
className="flex-1"
onClick={() => { setIsDialogOpen(false); setEditingConfig(null); resetForm(); }}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={handleFormSubmit}
disabled={isSubmitting || !formData.name || !formData.provider || !formData.model_name || !formData.api_key}
>
{isSubmitting ? <Spinner size="sm" className="mr-2" /> : null}
{editingConfig ? "Save Changes" : "Create & Use"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={!!configToDelete} onOpenChange={(open) => !open && setConfigToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
Delete Image Model
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete <span className="font-semibold text-foreground">{configToDelete?.name}</span>?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isDeleting} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{isDeleting ? <><Spinner size="sm" className="mr-2" />Deleting</> : <><Trash2 className="mr-2 h-4 w-4" />Delete</>}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -255,15 +255,15 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
</Alert>
)}
{/* Role Assignment Cards */}
{availableConfigs.length > 0 && (
<div className="grid gap-4 md:gap-6">
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
const IconComponent = role.icon;
const currentAssignment = assignments[`${key}_llm_id` as keyof typeof assignments];
const assignedConfig = availableConfigs.find(
(config) => config.id === currentAssignment
);
{/* Role Assignment Cards */}
{availableConfigs.length > 0 && (
<div className="grid gap-4 md:gap-6">
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
const IconComponent = role.icon;
const currentAssignment = assignments[`${key}_llm_id` as keyof typeof assignments];
const assignedConfig = availableConfigs.find(
(config) => config.id === currentAssignment
);
return (
<motion.div
@ -294,100 +294,100 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
</div>
</CardHeader>
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<div className="space-y-1.5 md:space-y-2">
<Label className="text-xs md:text-sm font-medium">
Assign LLM Configuration:
</Label>
<Select
value={currentAssignment?.toString() || "unassigned"}
onValueChange={(value) => handleRoleAssignment(`${key}_llm_id`, value)}
>
<SelectTrigger className="h-9 md:h-10 text-xs md:text-sm">
<SelectValue placeholder="Select an LLM configuration" />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">
<span className="text-muted-foreground">Unassigned</span>
</SelectItem>
<div className="space-y-1.5 md:space-y-2">
<Label className="text-xs md:text-sm font-medium">
Assign LLM Configuration:
</Label>
<Select
value={currentAssignment?.toString() || "unassigned"}
onValueChange={(value) => handleRoleAssignment(`${key}_llm_id`, value)}
>
<SelectTrigger className="h-9 md:h-10 text-xs md:text-sm">
<SelectValue placeholder="Select an LLM configuration" />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">
<span className="text-muted-foreground">Unassigned</span>
</SelectItem>
{/* Global Configurations */}
{globalConfigs.length > 0 && (
<>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Global Configurations
</div>
{globalConfigs.map((config) => {
const isAutoMode =
"is_auto_mode" in config && config.is_auto_mode;
return (
<SelectItem key={config.id} value={config.id.toString()}>
<div className="flex items-center gap-2">
{isAutoMode ? (
<Badge
variant="outline"
className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-violet-200 dark:border-violet-700"
>
<Shuffle className="size-3 mr-1" />
AUTO
</Badge>
) : (
<Badge variant="outline" className="text-xs">
{config.provider}
</Badge>
)}
<span>{config.name}</span>
{!isAutoMode && (
<span className="text-muted-foreground">
({config.model_name})
</span>
)}
{isAutoMode ? (
<Badge
variant="secondary"
className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
>
Recommended
</Badge>
) : (
<Badge variant="secondary" className="text-xs">
🌐 Global
</Badge>
)}
</div>
</SelectItem>
);
})}
</>
)}
{/* Global Configurations */}
{globalConfigs.length > 0 && (
<>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Global Configurations
</div>
{globalConfigs.map((config) => {
const isAutoMode =
"is_auto_mode" in config && config.is_auto_mode;
return (
<SelectItem key={config.id} value={config.id.toString()}>
<div className="flex items-center gap-2">
{isAutoMode ? (
<Badge
variant="outline"
className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 border-violet-200 dark:border-violet-700"
>
<Shuffle className="size-3 mr-1" />
AUTO
</Badge>
) : (
<Badge variant="outline" className="text-xs">
{config.provider}
</Badge>
)}
<span>{config.name}</span>
{!isAutoMode && (
<span className="text-muted-foreground">
({config.model_name})
</span>
)}
{isAutoMode ? (
<Badge
variant="secondary"
className="text-xs bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300"
>
Recommended
</Badge>
) : (
<Badge variant="secondary" className="text-xs">
🌐 Global
</Badge>
)}
</div>
</SelectItem>
);
})}
</>
)}
{/* Custom Configurations */}
{newLLMConfigs.length > 0 && (
<>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Your Configurations
</div>
{newLLMConfigs
.filter(
(config) => config.id && config.id.toString().trim() !== ""
)
.map((config) => (
<SelectItem key={config.id} value={config.id.toString()}>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{config.provider}
</Badge>
<span>{config.name}</span>
<span className="text-muted-foreground">
({config.model_name})
</span>
</div>
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
{/* Custom Configurations */}
{newLLMConfigs.length > 0 && (
<>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Your Configurations
</div>
{newLLMConfigs
.filter(
(config) => config.id && config.id.toString().trim() !== ""
)
.map((config) => (
<SelectItem key={config.id} value={config.id.toString()}>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{config.provider}
</Badge>
<span>{config.name}</span>
<span className="text-muted-foreground">
({config.model_name})
</span>
</div>
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
{assignedConfig && (
<div

View file

@ -88,7 +88,7 @@ function ImageCancelledState({ src }: { src: string }) {
function ParsedImage({ result }: { result: unknown }) {
const image = parseSerializableImage(result);
return <Image {...image} maxWidth="420px" />;
return <Image {...image} maxWidth="512px" />;
}
/**

View file

@ -1,6 +1,6 @@
"use client";
import { ExternalLinkIcon, ImageIcon } from "lucide-react";
import { ExternalLinkIcon, ImageIcon, SparklesIcon } from "lucide-react";
import NextImage from "next/image";
import { Component, type ReactNode, useState } from "react";
import { z } from "zod";
@ -25,7 +25,7 @@ const SerializableImageSchema = z.object({
id: z.string(),
assetId: z.string(),
src: z.string(),
alt: z.string().nullish(), // Made optional - will use fallback if missing
alt: z.string().nullish(),
title: z.string().nullish(),
description: z.string().nullish(),
href: z.string().nullish(),
@ -49,7 +49,7 @@ export interface ImageProps {
id: string;
assetId: string;
src: string;
alt?: string; // Optional with default fallback
alt?: string;
title?: string;
description?: string;
href?: string;
@ -71,10 +71,8 @@ export function parseSerializableImage(result: unknown): SerializableImage & { a
if (!parsed.success) {
console.warn("Invalid image data:", parsed.error.issues);
// Try to extract basic info and return a fallback object
const obj = (result && typeof result === "object" ? result : {}) as Record<string, unknown>;
// If we have at least id, assetId, and src, we can still render the image
if (
typeof obj.id === "string" &&
typeof obj.assetId === "string" &&
@ -89,7 +87,7 @@ export function parseSerializableImage(result: unknown): SerializableImage & { a
description: typeof obj.description === "string" ? obj.description : undefined,
href: typeof obj.href === "string" ? obj.href : undefined,
domain: typeof obj.domain === "string" ? obj.domain : undefined,
ratio: undefined, // Use default ratio
ratio: undefined,
source: undefined,
};
}
@ -97,7 +95,6 @@ export function parseSerializableImage(result: unknown): SerializableImage & { a
throw new Error(`Invalid image: ${parsed.error.issues.map((i) => i.message).join(", ")}`);
}
// Provide fallback for alt if it's null/undefined
return {
...parsed.data,
alt: parsed.data.alt ?? "Image",
@ -105,7 +102,7 @@ export function parseSerializableImage(result: unknown): SerializableImage & { a
}
/**
* Get aspect ratio class based on ratio prop
* Get aspect ratio class based on ratio prop (used for fixed-ratio images only)
*/
function getAspectRatioClass(ratio?: AspectRatio): string {
switch (ratio) {
@ -119,7 +116,6 @@ function getAspectRatioClass(ratio?: AspectRatio): string {
return "aspect-[9/16]";
case "21:9":
return "aspect-[21/9]";
case "auto":
default:
return "aspect-[4/3]";
}
@ -150,7 +146,7 @@ export class ImageErrorBoundary extends Component<
if (this.state.hasError) {
return (
<Card className="w-full max-w-md overflow-hidden">
<div className="aspect-[4/3] bg-muted flex items-center justify-center">
<div className="aspect-square bg-muted flex items-center justify-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<ImageIcon className="size-8" />
<p className="text-sm">Failed to load image</p>
@ -167,10 +163,10 @@ export class ImageErrorBoundary extends Component<
/**
* Loading skeleton for Image
*/
export function ImageSkeleton({ maxWidth = "420px" }: { maxWidth?: string }) {
export function ImageSkeleton({ maxWidth = "512px" }: { maxWidth?: string }) {
return (
<Card className="w-full overflow-hidden animate-pulse" style={{ maxWidth }}>
<div className="aspect-[4/3] bg-muted flex items-center justify-center">
<div className="aspect-square bg-muted flex items-center justify-center">
<ImageIcon className="size-12 text-muted-foreground/30" />
</div>
</Card>
@ -183,7 +179,7 @@ export function ImageSkeleton({ maxWidth = "420px" }: { maxWidth?: string }) {
export function ImageLoading({ title = "Loading image..." }: { title?: string }) {
return (
<Card className="w-full max-w-md overflow-hidden">
<div className="aspect-[4/3] bg-muted flex items-center justify-center">
<div className="aspect-square bg-muted flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Spinner size="lg" className="text-muted-foreground" />
<p className="text-muted-foreground text-sm">{title}</p>
@ -197,7 +193,9 @@ export function ImageLoading({ title = "Loading image..." }: { title?: string })
* Image Component
*
* Display images with metadata and attribution.
* Features hover overlay with title and source attribution.
* - For "auto" ratio: renders the image at natural dimensions (no cropping)
* - For fixed ratios: uses a fixed aspect container with object-cover
* - Features hover overlay with title, description, and source attribution.
*/
export function Image({
id,
@ -207,16 +205,18 @@ export function Image({
description,
href,
domain,
ratio = "4:3",
ratio = "auto",
fit = "cover",
source,
maxWidth = "420px",
maxWidth = "512px",
className,
}: ImageProps) {
const [isHovered, setIsHovered] = useState(false);
const [imageError, setImageError] = useState(false);
const aspectRatioClass = getAspectRatioClass(ratio);
const [imageLoaded, setImageLoaded] = useState(false);
const displayDomain = domain || source?.label;
const isGenerated = domain === "ai-generated";
const isAutoRatio = !ratio || ratio === "auto";
const handleClick = () => {
const targetUrl = href || source?.url || src;
@ -228,7 +228,7 @@ export function Image({
if (imageError) {
return (
<Card id={id} className={cn("w-full overflow-hidden", className)} style={{ maxWidth }}>
<div className={cn("bg-muted flex items-center justify-center", aspectRatioClass)}>
<div className="aspect-square bg-muted flex items-center justify-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<ImageIcon className="size-8" />
<p className="text-sm">Image not available</p>
@ -243,6 +243,7 @@ export function Image({
id={id}
className={cn(
"group w-full overflow-hidden cursor-pointer transition-shadow duration-200 hover:shadow-lg",
isGenerated && "ring-1 ring-primary/10",
className
)}
style={{ maxWidth }}
@ -258,71 +259,98 @@ export function Image({
role="button"
tabIndex={0}
>
<div className={cn("relative w-full overflow-hidden bg-muted", aspectRatioClass)}>
{/* Image */}
<NextImage
src={src}
alt={alt}
fill
className={cn(
"transition-transform duration-300",
fit === "cover" ? "object-cover" : "object-contain",
isHovered && "scale-105"
)}
unoptimized
onError={() => setImageError(true)}
/>
<div className="relative w-full overflow-hidden bg-muted">
{isAutoRatio ? (
/* Auto ratio: image renders at natural dimensions, no cropping */
<>
{!imageLoaded && (
<div className="aspect-square flex items-center justify-center">
<Spinner size="lg" className="text-muted-foreground" />
</div>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={alt}
className={cn(
"w-full h-auto transition-transform duration-300",
isHovered && "scale-[1.02]",
!imageLoaded && "hidden"
)}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
/* Fixed ratio: constrained aspect container with fill */
<div className={getAspectRatioClass(ratio)}>
<NextImage
src={src}
alt={alt}
fill
className={cn(
"transition-transform duration-300",
fit === "cover" ? "object-cover" : "object-contain",
isHovered && "scale-105"
)}
unoptimized
onError={() => setImageError(true)}
/>
</div>
)}
{/* Hover overlay - appears on hover */}
{/* Hover overlay */}
<div
className={cn(
"absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent",
"absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent",
"transition-opacity duration-200",
isHovered ? "opacity-100" : "opacity-0"
)}
>
{/* Content at bottom */}
<div className="absolute bottom-0 left-0 right-0 p-4">
{/* Title */}
<div className="absolute bottom-0 left-0 right-0 p-3">
{title && (
<h3 className="font-semibold text-white text-base leading-tight line-clamp-2 mb-1">
<h3 className="font-semibold text-white text-sm leading-tight line-clamp-2 mb-0.5">
{title}
</h3>
)}
{/* Description */}
{description && (
<p className="text-white/80 text-sm line-clamp-2 mb-2">{description}</p>
<p className="text-white/80 text-xs line-clamp-2 mb-1.5">{description}</p>
)}
{/* Source attribution */}
{displayDomain && (
<div className="flex items-center gap-1.5">
{source?.iconUrl ? (
{isGenerated ? (
<SparklesIcon className="size-3.5 text-white/70" />
) : source?.iconUrl ? (
<NextImage
src={source.iconUrl}
alt={source.label}
width={16}
height={16}
width={14}
height={14}
className="rounded"
unoptimized
/>
) : (
<ExternalLinkIcon className="size-4 text-white/70" />
<ExternalLinkIcon className="size-3.5 text-white/70" />
)}
<span className="text-white/70 text-sm">{displayDomain}</span>
<span className="text-white/70 text-xs">{displayDomain}</span>
</div>
)}
</div>
</div>
{/* Always visible domain badge (bottom right, shown when NOT hovered) */}
{/* Badge when not hovered */}
{displayDomain && !isHovered && (
<div className="absolute bottom-2 right-2">
<Badge
variant="secondary"
className="bg-black/60 text-white border-0 text-xs backdrop-blur-sm"
className={cn(
"border-0 text-xs backdrop-blur-sm",
isGenerated
? "bg-primary/80 text-primary-foreground"
: "bg-black/60 text-white"
)}
>
{isGenerated && <SparklesIcon className="size-3 mr-1" />}
{displayDomain}
</Badge>
</div>