mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
refactor: remove ImageModelSelector component and update ModelSelector and ChatHeader to handle LLM and image model configurations more efficiently
This commit is contained in:
parent
8dd2b15796
commit
312fedd6a2
4 changed files with 532 additions and 618 deletions
|
|
@ -8,7 +8,6 @@ import type {
|
|||
NewLLMConfigPublic,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { ImageConfigSidebar } from "./image-config-sidebar";
|
||||
import { ImageModelSelector } from "./image-model-selector";
|
||||
import { ModelConfigSidebar } from "./model-config-sidebar";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
|
||||
|
|
@ -34,7 +33,7 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
|||
const [imageSidebarMode, setImageSidebarMode] = useState<"create" | "edit" | "view">("view");
|
||||
|
||||
// LLM handlers
|
||||
const handleEditConfig = useCallback(
|
||||
const handleEditLLMConfig = useCallback(
|
||||
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
|
||||
setSelectedConfig(config);
|
||||
setIsGlobal(global);
|
||||
|
|
@ -44,7 +43,7 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
|||
[]
|
||||
);
|
||||
|
||||
const handleAddNew = useCallback(() => {
|
||||
const handleAddNewLLM = useCallback(() => {
|
||||
setSelectedConfig(null);
|
||||
setIsGlobal(false);
|
||||
setSidebarMode("create");
|
||||
|
|
@ -81,8 +80,12 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
|||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
|
||||
<ImageModelSelector onEdit={handleEditImageConfig} onAddNew={handleAddImageModel} />
|
||||
<ModelSelector
|
||||
onEditLLM={handleEditLLMConfig}
|
||||
onAddNewLLM={handleAddNewLLM}
|
||||
onEditImage={handleEditImageConfig}
|
||||
onAddNewImage={handleAddImageModel}
|
||||
/>
|
||||
<ModelConfigSidebar
|
||||
open={sidebarOpen}
|
||||
onOpenChange={handleSidebarClose}
|
||||
|
|
|
|||
|
|
@ -1,361 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,10 +6,16 @@ import {
|
|||
Check,
|
||||
ChevronDown,
|
||||
Edit3,
|
||||
ImageIcon,
|
||||
Plus,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
globalImageGenConfigsAtom,
|
||||
imageGenConfigsAtom,
|
||||
} from "@/atoms/image-gen-config/image-gen-config-query.atoms";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
|
|
@ -30,102 +36,150 @@ import {
|
|||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type {
|
||||
GlobalImageGenConfig,
|
||||
GlobalNewLLMConfig,
|
||||
ImageGenerationConfig,
|
||||
NewLLMConfigPublic,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModelSelectorProps {
|
||||
onEdit: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void;
|
||||
onAddNew: () => void;
|
||||
onEditLLM: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void;
|
||||
onAddNewLLM: () => void;
|
||||
onEditImage?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void;
|
||||
onAddNewImage?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) {
|
||||
export function ModelSelector({
|
||||
onEditLLM,
|
||||
onAddNewLLM,
|
||||
onEditImage,
|
||||
onAddNewImage,
|
||||
className,
|
||||
}: ModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"llm" | "image">("llm");
|
||||
const [llmSearchQuery, setLlmSearchQuery] = useState("");
|
||||
const [imageSearchQuery, setImageSearchQuery] = useState("");
|
||||
|
||||
// Fetch configs
|
||||
const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom);
|
||||
const { data: globalConfigs, isLoading: globalConfigsLoading } =
|
||||
// LLM data
|
||||
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
|
||||
const { data: llmGlobalConfigs, isLoading: llmGlobalLoading } =
|
||||
useAtomValue(globalNewLLMConfigsAtom);
|
||||
const { data: preferences, isLoading: preferencesLoading } = useAtomValue(llmPreferencesAtom);
|
||||
const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom);
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const isLoading = userConfigsLoading || globalConfigsLoading || preferencesLoading;
|
||||
// Image data
|
||||
const { data: imageGlobalConfigs, isLoading: imageGlobalLoading } =
|
||||
useAtomValue(globalImageGenConfigsAtom);
|
||||
const { data: imageUserConfigs, isLoading: imageUserLoading } =
|
||||
useAtomValue(imageGenConfigsAtom);
|
||||
|
||||
// Get current agent LLM config
|
||||
const currentConfig = useMemo(() => {
|
||||
const isLoading = llmUserLoading || llmGlobalLoading || prefsLoading || imageGlobalLoading || imageUserLoading;
|
||||
|
||||
// ─── LLM current config ───
|
||||
const currentLLMConfig = useMemo(() => {
|
||||
if (!preferences) return null;
|
||||
|
||||
const agentLlmId = preferences.agent_llm_id;
|
||||
if (agentLlmId === null || agentLlmId === undefined) return null;
|
||||
|
||||
// Check if it's Auto mode (ID 0) or global config (negative ID)
|
||||
if (agentLlmId <= 0) {
|
||||
return globalConfigs?.find((c) => c.id === agentLlmId) ?? null;
|
||||
return llmGlobalConfigs?.find((c) => c.id === agentLlmId) ?? null;
|
||||
}
|
||||
// Otherwise, check user configs
|
||||
return userConfigs?.find((c) => c.id === agentLlmId) ?? null;
|
||||
}, [preferences, globalConfigs, userConfigs]);
|
||||
return llmUserConfigs?.find((c) => c.id === agentLlmId) ?? null;
|
||||
}, [preferences, llmGlobalConfigs, llmUserConfigs]);
|
||||
|
||||
// Check if current config is Auto mode
|
||||
const isCurrentAutoMode = useMemo(() => {
|
||||
return currentConfig && "is_auto_mode" in currentConfig && currentConfig.is_auto_mode;
|
||||
}, [currentConfig]);
|
||||
const isLLMAutoMode = useMemo(() => {
|
||||
return currentLLMConfig && "is_auto_mode" in currentLLMConfig && currentLLMConfig.is_auto_mode;
|
||||
}, [currentLLMConfig]);
|
||||
|
||||
// Filter configs based on search
|
||||
const filteredGlobalConfigs = useMemo(() => {
|
||||
if (!globalConfigs) return [];
|
||||
if (!searchQuery) return globalConfigs;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return globalConfigs.filter(
|
||||
// ─── Image current config ───
|
||||
const currentImageConfig = useMemo(() => {
|
||||
if (!preferences) return null;
|
||||
const id = preferences.image_generation_config_id;
|
||||
if (id === null || id === undefined) return null;
|
||||
const globalMatch = imageGlobalConfigs?.find((c) => c.id === id);
|
||||
if (globalMatch) return globalMatch;
|
||||
return imageUserConfigs?.find((c) => c.id === id) ?? null;
|
||||
}, [preferences, imageGlobalConfigs, imageUserConfigs]);
|
||||
|
||||
const isImageAutoMode = useMemo(() => {
|
||||
return currentImageConfig && "is_auto_mode" in currentImageConfig && currentImageConfig.is_auto_mode;
|
||||
}, [currentImageConfig]);
|
||||
|
||||
// ─── LLM filtering ───
|
||||
const filteredLLMGlobal = useMemo(() => {
|
||||
if (!llmGlobalConfigs) return [];
|
||||
if (!llmSearchQuery) return llmGlobalConfigs;
|
||||
const q = llmSearchQuery.toLowerCase();
|
||||
return llmGlobalConfigs.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(query) ||
|
||||
c.model_name.toLowerCase().includes(query) ||
|
||||
c.provider.toLowerCase().includes(query)
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.model_name.toLowerCase().includes(q) ||
|
||||
c.provider.toLowerCase().includes(q)
|
||||
);
|
||||
}, [globalConfigs, searchQuery]);
|
||||
}, [llmGlobalConfigs, llmSearchQuery]);
|
||||
|
||||
const filteredUserConfigs = useMemo(() => {
|
||||
if (!userConfigs) return [];
|
||||
if (!searchQuery) return userConfigs;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return userConfigs.filter(
|
||||
const filteredLLMUser = useMemo(() => {
|
||||
if (!llmUserConfigs) return [];
|
||||
if (!llmSearchQuery) return llmUserConfigs;
|
||||
const q = llmSearchQuery.toLowerCase();
|
||||
return llmUserConfigs.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(query) ||
|
||||
c.model_name.toLowerCase().includes(query) ||
|
||||
c.provider.toLowerCase().includes(query)
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.model_name.toLowerCase().includes(q) ||
|
||||
c.provider.toLowerCase().includes(q)
|
||||
);
|
||||
}, [userConfigs, searchQuery]);
|
||||
}, [llmUserConfigs, llmSearchQuery]);
|
||||
|
||||
// Total model count for conditional search display
|
||||
const totalModels = useMemo(() => {
|
||||
return (globalConfigs?.length ?? 0) + (userConfigs?.length ?? 0);
|
||||
}, [globalConfigs, userConfigs]);
|
||||
const totalLLMModels = (llmGlobalConfigs?.length ?? 0) + (llmUserConfigs?.length ?? 0);
|
||||
|
||||
const handleSelectConfig = useCallback(
|
||||
// ─── Image filtering ───
|
||||
const filteredImageGlobal = useMemo(() => {
|
||||
if (!imageGlobalConfigs) return [];
|
||||
if (!imageSearchQuery) return imageGlobalConfigs;
|
||||
const q = imageSearchQuery.toLowerCase();
|
||||
return imageGlobalConfigs.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.model_name.toLowerCase().includes(q) ||
|
||||
c.provider.toLowerCase().includes(q)
|
||||
);
|
||||
}, [imageGlobalConfigs, imageSearchQuery]);
|
||||
|
||||
const filteredImageUser = useMemo(() => {
|
||||
if (!imageUserConfigs) return [];
|
||||
if (!imageSearchQuery) return imageUserConfigs;
|
||||
const q = imageSearchQuery.toLowerCase();
|
||||
return imageUserConfigs.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.model_name.toLowerCase().includes(q) ||
|
||||
c.provider.toLowerCase().includes(q)
|
||||
);
|
||||
}, [imageUserConfigs, imageSearchQuery]);
|
||||
|
||||
const totalImageModels = (imageGlobalConfigs?.length ?? 0) + (imageUserConfigs?.length ?? 0);
|
||||
|
||||
// ─── Handlers ───
|
||||
const handleSelectLLM = useCallback(
|
||||
async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => {
|
||||
// If already selected, just close
|
||||
if (currentConfig?.id === config.id) {
|
||||
if (currentLLMConfig?.id === config.id) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!searchSpaceId) {
|
||||
toast.error("No search space selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
data: {
|
||||
agent_llm_id: config.id,
|
||||
},
|
||||
data: { agent_llm_id: config.id },
|
||||
});
|
||||
toast.success(`Switched to ${config.name}`);
|
||||
setOpen(false);
|
||||
|
|
@ -134,16 +188,40 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
toast.error("Failed to switch model");
|
||||
}
|
||||
},
|
||||
[currentConfig, searchSpaceId, updatePreferences]
|
||||
[currentLLMConfig, searchSpaceId, updatePreferences]
|
||||
);
|
||||
|
||||
const handleEditConfig = useCallback(
|
||||
const handleEditLLMConfig = useCallback(
|
||||
(e: React.MouseEvent, config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => {
|
||||
e.stopPropagation();
|
||||
onEdit(config, isGlobal);
|
||||
onEditLLM(config, isGlobal);
|
||||
setOpen(false);
|
||||
},
|
||||
[onEdit]
|
||||
[onEditLLM]
|
||||
);
|
||||
|
||||
const handleSelectImage = useCallback(
|
||||
async (configId: number) => {
|
||||
if (currentImageConfig?.id === configId) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!searchSpaceId) {
|
||||
toast.error("No search space selected");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
data: { image_generation_config_id: configId },
|
||||
});
|
||||
toast.success("Image model updated");
|
||||
setOpen(false);
|
||||
} catch {
|
||||
toast.error("Failed to switch image model");
|
||||
}
|
||||
},
|
||||
[currentImageConfig, searchSpaceId, updatePreferences]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -161,30 +239,37 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
<Spinner size="sm" className="text-muted-foreground" />
|
||||
<span className="text-muted-foreground hidden md:inline">Loading</span>
|
||||
</>
|
||||
) : currentConfig ? (
|
||||
<>
|
||||
{getProviderIcon(currentConfig.provider, { isAutoMode: isCurrentAutoMode ?? false })}
|
||||
<span className="max-w-[100px] md:max-w-[150px] 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"
|
||||
>
|
||||
Balanced
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="ml-1 text-[10px] px-1.5 py-0 h-4 bg-muted/80">
|
||||
{currentConfig.model_name.split("/").pop()?.slice(0, 10) ||
|
||||
currentConfig.model_name.slice(0, 10)}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground hidden md:inline">Select Model</span>
|
||||
{/* LLM section */}
|
||||
{currentLLMConfig ? (
|
||||
<>
|
||||
{getProviderIcon(currentLLMConfig.provider, { isAutoMode: isLLMAutoMode ?? false })}
|
||||
<span className="max-w-[100px] md:max-w-[120px] truncate hidden md:inline">
|
||||
{currentLLMConfig.name}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bot className="size-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground hidden md:inline">Select Model</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-4 w-px bg-border/60 mx-0.5" />
|
||||
|
||||
{/* Image section */}
|
||||
{currentImageConfig ? (
|
||||
<>
|
||||
{getProviderIcon(currentImageConfig.provider, { isAutoMode: isImageAutoMode ?? false })}
|
||||
<span className="max-w-[80px] md:max-w-[100px] truncate hidden md:inline">
|
||||
{currentImageConfig.name}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<ImageIcon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ChevronDown
|
||||
|
|
@ -201,179 +286,366 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="rounded-lg relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "llm" | "image")}
|
||||
className="w-full"
|
||||
>
|
||||
{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 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">
|
||||
<Bot className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No models found</p>
|
||||
<p className="text-xs text-muted-foreground/60">Try a different search term</p>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Global Configs Section */}
|
||||
{filteredGlobalConfigs.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
Global Models
|
||||
</div>
|
||||
{filteredGlobalConfigs.map((config) => {
|
||||
const isSelected = currentConfig?.id === config.id;
|
||||
const isAutoMode = "is_auto_mode" in config && config.is_auto_mode;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`global-${config.id}`}
|
||||
value={`global-${config.id}`}
|
||||
onSelect={() => handleSelectConfig(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80",
|
||||
isAutoMode && "border border-violet-200 dark:border-violet-800/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(config.provider, { isAutoMode })}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{config.name}</span>
|
||||
{isAutoMode && (
|
||||
<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>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{isAutoMode ? "Auto load balancing" : config.model_name}
|
||||
</span>
|
||||
{!isAutoMode && config.citations_enabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-primary/10 text-primary border-primary/20"
|
||||
>
|
||||
Citations
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isAutoMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => handleEditConfig(e, config, true)}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{filteredGlobalConfigs.length > 0 && filteredUserConfigs.length > 0 && (
|
||||
<CommandSeparator className="my-1 bg-border/60" />
|
||||
)}
|
||||
|
||||
{/* User Configs Section */}
|
||||
{filteredUserConfigs.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
Your Configurations
|
||||
</div>
|
||||
{filteredUserConfigs.map((config) => {
|
||||
const isSelected = currentConfig?.id === config.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`user-${config.id}`}
|
||||
value={`user-${config.id}`}
|
||||
onSelect={() => handleSelectConfig(config)}
|
||||
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 justify-between w-full gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">{getProviderIcon(config.provider)}</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>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{config.model_name}
|
||||
</span>
|
||||
{config.citations_enabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-primary/10 text-primary border-primary/20"
|
||||
>
|
||||
Citations
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => handleEditConfig(e, config, false)}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Add New Config Button */}
|
||||
<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();
|
||||
}}
|
||||
<div className="border-b border-border/40">
|
||||
<TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-card h-11 p-0 gap-0">
|
||||
<TabsTrigger
|
||||
value="llm"
|
||||
className="relative gap-2 text-sm font-medium rounded-none text-muted-foreground/60 transition-all duration-200 h-full data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:bottom-0 data-[state=active]:after:left-3 data-[state=active]:after:right-3 data-[state=active]:after:h-[2px] data-[state=active]:after:bg-gray-500 data-[state=active]:after:rounded-full"
|
||||
>
|
||||
<Plus className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium">Add New Configuration</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<Zap className="size-4" />
|
||||
LLM
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="image"
|
||||
className="relative gap-2 text-sm font-medium rounded-none text-muted-foreground/60 transition-all duration-200 h-full data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:bottom-0 data-[state=active]:after:left-3 data-[state=active]:after:right-3 data-[state=active]:after:h-[2px] data-[state=active]:after:bg-gray-500 data-[state=active]:after:rounded-full"
|
||||
>
|
||||
<ImageIcon className="size-4" />
|
||||
Image
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* ─── LLM Tab ─── */}
|
||||
<TabsContent value="llm" className="mt-0">
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="rounded-none rounded-b-lg relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
>
|
||||
{totalLLMModels > 3 && (
|
||||
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
||||
<CommandInput
|
||||
placeholder="Search models"
|
||||
value={llmSearchQuery}
|
||||
onValueChange={setLlmSearchQuery}
|
||||
className="h-7 md:h-8 w-full 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">
|
||||
<Bot className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No models found</p>
|
||||
<p className="text-xs text-muted-foreground/60">Try a different search term</p>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Global LLM Configs */}
|
||||
{filteredLLMGlobal.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
Global Models
|
||||
</div>
|
||||
{filteredLLMGlobal.map((config) => {
|
||||
const isSelected = currentLLMConfig?.id === config.id;
|
||||
const isAutoMode = "is_auto_mode" in config && config.is_auto_mode;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`llm-g-${config.id}`}
|
||||
value={`llm-g-${config.id}`}
|
||||
onSelect={() => handleSelectLLM(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80",
|
||||
isAutoMode && "border border-violet-800"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(config.provider, { isAutoMode })}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{config.name}</span>
|
||||
{isAutoMode && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300 border-0"
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
{isSelected && <Check className="size-3.5 text-primary shrink-0" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{isAutoMode ? "Auto load balancing" : config.model_name}
|
||||
</span>
|
||||
{!isAutoMode && config.citations_enabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-primary/10 text-primary border-primary/20"
|
||||
>
|
||||
Citations
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isAutoMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => handleEditLLMConfig(e, config, true)}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{filteredLLMGlobal.length > 0 && filteredLLMUser.length > 0 && (
|
||||
<CommandSeparator className="my-1 mx-4 bg-border/60" />
|
||||
)}
|
||||
|
||||
{/* User LLM Configs */}
|
||||
{filteredLLMUser.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
Your Configurations
|
||||
</div>
|
||||
{filteredLLMUser.map((config) => {
|
||||
const isSelected = currentLLMConfig?.id === config.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`llm-u-${config.id}`}
|
||||
value={`llm-u-${config.id}`}
|
||||
onSelect={() => handleSelectLLM(config)}
|
||||
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 justify-between w-full gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">{getProviderIcon(config.provider)}</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>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{config.model_name}
|
||||
</span>
|
||||
{config.citations_enabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-primary/10 text-primary border-primary/20"
|
||||
>
|
||||
Citations
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => handleEditLLMConfig(e, config, false)}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Add New LLM Config */}
|
||||
<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);
|
||||
onAddNewLLM();
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium">Add New Configuration</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</TabsContent>
|
||||
|
||||
{/* ─── Image Tab ─── */}
|
||||
<TabsContent value="image" className="mt-0">
|
||||
<Command shouldFilter={false} className="rounded-none rounded-b-lg [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2">
|
||||
{totalImageModels > 3 && (
|
||||
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
||||
<CommandInput
|
||||
placeholder="Search models"
|
||||
value={imageSearchQuery}
|
||||
onValueChange={setImageSearchQuery}
|
||||
className="h-7 md:h-8 w-full 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 Configs */}
|
||||
{filteredImageGlobal.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
Global Image Models
|
||||
</div>
|
||||
{filteredImageGlobal.map((config) => {
|
||||
const isSelected = currentImageConfig?.id === config.id;
|
||||
const isAuto = "is_auto_mode" in config && config.is_auto_mode;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`img-g-${config.id}`}
|
||||
value={`img-g-${config.id}`}
|
||||
onSelect={() => handleSelectImage(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80",
|
||||
isAuto && "border border-violet-800"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(config.provider, { isAutoMode: isAuto })}
|
||||
</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-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-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>
|
||||
{onEditImage && !isAuto && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
onEditImage(config, true);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* User Image Configs */}
|
||||
{filteredImageUser.length > 0 && (
|
||||
<>
|
||||
{filteredImageGlobal.length > 0 && <CommandSeparator className="my-1 mx-4 bg-border/60" />}
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
Your Image Models
|
||||
</div>
|
||||
{filteredImageUser.map((config) => {
|
||||
const isSelected = currentImageConfig?.id === config.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`img-u-${config.id}`}
|
||||
value={`img-u-${config.id}`}
|
||||
onSelect={() => handleSelectImage(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">
|
||||
{getProviderIcon(config.provider)}
|
||||
</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>
|
||||
{onEditImage && (
|
||||
<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);
|
||||
onEditImage(config, false);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add New Image Config */}
|
||||
{onAddNewImage && (
|
||||
<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);
|
||||
onAddNewImage();
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium">Add Image Model</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function getProviderIcon(
|
|||
}: { isAutoMode?: boolean; className?: string } = {}
|
||||
) {
|
||||
if (isAutoMode || provider?.toUpperCase() === "AUTO") {
|
||||
return <Shuffle className={cn(className, "text-violet-500")} />;
|
||||
return <Shuffle className={cn(className, "text-violet-800")} />;
|
||||
}
|
||||
|
||||
switch (provider?.toUpperCase()) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue