feat(database-migrations): add migration to remove legacy model config tables and remove stale model connection code

This commit is contained in:
Anish Sarkar 2026-06-13 12:45:43 +05:30
parent 50668775f8
commit bd4a04f2e7
93 changed files with 956 additions and 11442 deletions

View file

@ -1,456 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Check, ChevronsUpDown } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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 { Dialog, DialogContent, 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 { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-gen-providers";
import type {
GlobalImageGenConfig,
ImageGenerationConfig,
ImageGenProvider,
} from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
interface ImageConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: ImageGenerationConfig | GlobalImageGenConfig | null;
isGlobal: boolean;
searchSpaceId: number;
mode: "create" | "edit" | "view";
defaultProvider?: string;
}
const INITIAL_FORM = {
name: "",
description: "",
provider: "",
model_name: "",
api_key: "",
api_base: "",
api_version: "",
};
export function ImageConfigDialog({
open,
onOpenChange,
config,
isGlobal,
searchSpaceId,
mode,
defaultProvider,
}: ImageConfigDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState(INITIAL_FORM);
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const scrollRef = useRef<HTMLDivElement>(null);
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, provider: defaultProvider ?? "" });
}
setScrollPos("top");
}
}, [open, mode, config, isGlobal, defaultProvider]);
const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
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 (isGlobal) return "View Global Image Model";
return "Edit Image Model";
};
const getSubtitle = () => {
if (mode === "create") return "Set up a new image generation provider";
if (isGlobal) return "Read-only global configuration";
return "Update your image model settings";
};
const handleSubmit = useCallback(async () => {
setIsSubmitting(true);
try {
if (mode === "create") {
const result = await createConfig({
name: formData.name,
provider: formData.provider as ImageGenProvider,
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,
});
if (result?.id) {
await updatePreferences({
search_space_id: searchSpaceId,
data: { image_generation_config_id: result.id },
});
}
onOpenChange(false);
} else if (!isGlobal && config) {
await updateConfig({
id: config.id,
data: {
name: formData.name,
description: formData.description || undefined,
provider: formData.provider as ImageGenProvider,
model_name: formData.model_name,
api_key: formData.api_key,
api_base: formData.api_base || undefined,
api_version: formData.api_version || undefined,
},
});
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);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-lg h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">{getTitle()}</DialogTitle>
{/* Header */}
<div className="flex items-start justify-between px-6 pt-6 pb-4 pr-14">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
{isGlobal && mode !== "create" && (
<Badge variant="secondary" className="text-[10px]">
Global
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
{config && mode !== "create" && (
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
)}
</div>
</div>
{/* Scrollable content */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-5"
style={{
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{isGlobal && config && (
<>
<Alert className="mb-5 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>
</>
)}
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
<div className="space-y-4">
<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>
<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 className="bg-popover-border" />
<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} description={p.example}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<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 bg-transparent"
>
{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 className="bg-transparent">
<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>
<div className="space-y-2">
<Label className="text-sm font-medium">API Key *</Label>
<Input
type="password"
placeholder="sk-..."
value={formData.api_key}
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
/>
</div>
<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>
{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>
)}
</div>
)}
</div>
{/* Fixed footer */}
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="text-sm h-9"
>
Cancel
</Button>
{mode === "create" || (mode === "edit" && !isGlobal) ? (
<Button
onClick={handleSubmit}
disabled={isSubmitting || !isFormValid}
className="relative text-sm h-9 min-w-[120px]"
>
<span className={isSubmitting ? "opacity-0" : ""}>
{mode === "edit" ? "Save Changes" : "Add Model"}
</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : isGlobal && config ? (
<Button
className="relative text-sm h-9"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : null}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -1,527 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { type Resolver, useForm } from "react-hook-form";
import { z } from "zod";
import {
defaultSystemInstructionsAtom,
modelListAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
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 { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
import type { CreateNewLLMConfigRequest } from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
import InferenceParamsEditor from "../inference-params-editor";
// Form schema with zod
const formSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
description: z.string().max(500).optional().nullable(),
provider: z.string().min(1, "Provider is required"),
custom_provider: z.string().max(100).optional().nullable(),
model_name: z.string().min(1, "Model name is required").max(100),
api_key: z.string().min(1, "API key is required"),
api_base: z.string().max(500).optional().nullable(),
litellm_params: z.record(z.string(), z.any()).optional().nullable(),
system_instructions: z.string().default(""),
use_default_system_instructions: z.boolean().default(true),
citations_enabled: z.boolean().default(true),
search_space_id: z.number(),
});
type FormValues = z.infer<typeof formSchema>;
export type LLMConfigFormData = CreateNewLLMConfigRequest;
interface LLMConfigFormProps {
initialData?: Partial<LLMConfigFormData>;
searchSpaceId: number;
onSubmit: (data: LLMConfigFormData) => Promise<void>;
mode?: "create" | "edit";
showAdvanced?: boolean;
formId?: string;
}
export function LLMConfigForm({
initialData,
searchSpaceId,
onSubmit,
mode = "create",
showAdvanced = true,
formId,
}: LLMConfigFormProps) {
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
defaultSystemInstructionsAtom
);
const { data: dynamicModels } = useAtomValue(modelListAtom);
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [systemInstructionsOpen, setSystemInstructionsOpen] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema) as Resolver<FormValues>,
defaultValues: {
name: initialData?.name ?? "",
description: initialData?.description ?? "",
provider: initialData?.provider ?? "",
custom_provider: initialData?.custom_provider ?? "",
model_name: initialData?.model_name ?? "",
api_key: initialData?.api_key ?? "",
api_base: initialData?.api_base ?? "",
litellm_params: initialData?.litellm_params ?? {},
system_instructions: initialData?.system_instructions ?? "",
use_default_system_instructions: initialData?.use_default_system_instructions ?? true,
citations_enabled: initialData?.citations_enabled ?? true,
search_space_id: searchSpaceId,
},
});
// Load default instructions when available (only for new configs)
useEffect(() => {
if (
mode === "create" &&
defaultInstructionsLoaded &&
defaultInstructions?.default_system_instructions &&
!form.getValues("system_instructions")
) {
form.setValue("system_instructions", defaultInstructions.default_system_instructions);
}
}, [defaultInstructionsLoaded, defaultInstructions, mode, form]);
const watchProvider = form.watch("provider");
const selectedProvider = LLM_PROVIDERS.find((p) => p.value === watchProvider);
const availableModels = useMemo(
() => (dynamicModels ?? []).filter((m) => m.provider === watchProvider),
[dynamicModels, watchProvider]
);
const handleProviderChange = (value: string) => {
form.setValue("provider", value);
form.setValue("model_name", "");
// Auto-fill API base for certain providers
const provider = LLM_PROVIDERS.find((p) => p.value === value);
if (provider?.apiBase) {
form.setValue("api_base", provider.apiBase);
}
};
const handleFormSubmit = async (values: FormValues) => {
await onSubmit(values as LLMConfigFormData);
};
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Model Configuration Section */}
<div className="space-y-4">
<div className="text-xs sm:text-sm font-medium text-muted-foreground">
Model Configuration
</div>
{/* Name & Description */}
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Configuration Name</FormLabel>
<FormControl>
<Input placeholder="e.g., My GPT-4 Agent" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground text-xs sm:text-sm">
Description
<Badge variant="outline" className="ml-2 text-[10px]">
Optional
</Badge>
</FormLabel>
<FormControl>
<Input placeholder="Brief description" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Provider Selection */}
<FormField
control={form.control}
name="provider"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">LLM Provider</FormLabel>
<Select value={field.value} onValueChange={handleProviderChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
</FormControl>
<SelectContent className="max-h-[300px]">
{LLM_PROVIDERS.map((provider) => (
<SelectItem
key={provider.value}
value={provider.value}
description={provider.description}
>
{provider.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Custom Provider (conditional) */}
{watchProvider === "CUSTOM" && (
<FormField
control={form.control}
name="custom_provider"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Custom Provider Name</FormLabel>
<FormControl>
<Input placeholder="my-custom-provider" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Model Name with Combobox */}
<FormField
control={form.control}
name="model_name"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-xs sm:text-sm">Model Name</FormLabel>
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
aria-expanded={modelComboboxOpen}
className={cn(
"w-full justify-between border-popover-border bg-transparent font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value || "Select a model"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false} className="bg-transparent">
<CommandInput
placeholder={selectedProvider?.example || "Search model name"}
value={field.value}
onValueChange={field.onChange}
/>
<CommandList className="max-h-[300px]">
<CommandEmpty>
<div className="py-3 text-center text-sm text-muted-foreground">
{field.value ? `Using: "${field.value}"` : "Type your model name"}
</div>
</CommandEmpty>
{availableModels.length > 0 && (
<CommandGroup heading="Suggested Models">
{availableModels
.filter(
(model) =>
!field.value ||
model.value.toLowerCase().includes(field.value.toLowerCase()) ||
model.label.toLowerCase().includes(field.value.toLowerCase())
)
.slice(0, 50)
.map((model) => (
<CommandItem
key={model.value}
value={model.value}
onSelect={(value) => {
field.onChange(value);
setModelComboboxOpen(false);
}}
className="py-2"
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === model.value ? "opacity-100" : "opacity-0"
)}
/>
<div>
<div className="font-medium">{model.label}</div>
{model.contextWindow && (
<div className="text-xs text-muted-foreground">
Context: {model.contextWindow}
</div>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedProvider?.example && (
<FormDescription className="text-[10px] sm:text-xs">
Example: {selectedProvider.example}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
{/* API Credentials */}
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder={watchProvider === "OLLAMA" ? "Any value" : "sk-..."}
{...field}
/>
</FormControl>
{watchProvider === "OLLAMA" && (
<FormDescription className="text-[10px] sm:text-xs">
Ollama doesn&apos;t require auth enter any value
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_base"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2 text-xs sm:text-sm">
API Base URL
{selectedProvider?.apiBase && (
<Badge variant="secondary" className="text-[10px]">
Auto-filled
</Badge>
)}
</FormLabel>
<FormControl>
<Input
placeholder={selectedProvider?.apiBase || "https://api.example.com/v1"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Ollama Quick Actions */}
{watchProvider === "OLLAMA" && (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => form.setValue("api_base", "http://localhost:11434")}
>
localhost:11434
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => form.setValue("api_base", "http://host.docker.internal:11434")}
>
Docker
</Button>
</div>
)}
</div>
{/* Advanced Parameters */}
{showAdvanced && (
<>
<Separator className="bg-popover-border" />
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-between px-0 py-2 text-xs font-medium text-muted-foreground hover:bg-transparent hover:text-accent-foreground sm:text-sm"
>
<span>Advanced Parameters</span>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform duration-200",
advancedOpen && "rotate-180"
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
<FormField
control={form.control}
name="litellm_params"
render={({ field }) => (
<FormItem>
<FormControl>
<InferenceParamsEditor
params={field.value || {}}
setParams={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</>
)}
{/* System Instructions & Citations Section */}
<Separator className="bg-popover-border" />
<Collapsible open={systemInstructionsOpen} onOpenChange={setSystemInstructionsOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-between px-0 py-2 text-xs font-medium text-muted-foreground hover:bg-transparent hover:text-accent-foreground sm:text-sm"
>
<span>System Instructions</span>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform duration-200",
systemInstructionsOpen && "rotate-180"
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{/* System Instructions */}
<FormField
control={form.control}
name="system_instructions"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel className="text-xs sm:text-sm">Instructions for the AI</FormLabel>
{defaultInstructions && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
field.onChange(defaultInstructions.default_system_instructions)
}
className="h-7 text-[10px] sm:text-xs text-muted-foreground hover:text-accent-foreground"
>
Reset to Default
</Button>
)}
</div>
<FormControl>
<Textarea
placeholder="Enter system instructions for the AI..."
rows={6}
className="font-mono text-[11px] sm:text-xs resize-none"
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Use {"{resolved_today}"} to include today&apos;s date dynamically
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Citations Toggle */}
<FormField
control={form.control}
name="citations_enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
<div className="space-y-0.5">
<FormLabel className="text-xs sm:text-sm font-medium">
Enable Citations
</FormLabel>
<FormDescription className="text-[10px] sm:text-xs">
Include [citation:id] references to source documents
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</form>
</Form>
);
}

View file

@ -1,339 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import {
createNewLLMConfigMutationAtom,
updateLLMPreferencesMutationAtom,
updateNewLLMConfigMutationAtom,
} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import type {
GlobalNewLLMConfig,
LiteLLMProvider,
NewLLMConfigPublic,
} from "@/contracts/types/new-llm-config.types";
interface ModelConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
isGlobal: boolean;
searchSpaceId: number;
mode: "create" | "edit" | "view";
defaultProvider?: string;
}
export function ModelConfigDialog({
open,
onOpenChange,
config,
isGlobal,
searchSpaceId,
mode,
defaultProvider,
}: ModelConfigDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const scrollRef = useRef<HTMLDivElement>(null);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const getTitle = () => {
if (mode === "create") return "Add New Configuration";
if (isGlobal) return "View Global Configuration";
return "Edit Configuration";
};
const getSubtitle = () => {
if (mode === "create") return "Set up a new LLM provider for this search space";
if (isGlobal) return "Read-only global configuration";
return "Update your configuration settings";
};
const handleSubmit = useCallback(
async (data: LLMConfigFormData) => {
setIsSubmitting(true);
try {
if (mode === "create") {
const result = await createConfig({
...data,
search_space_id: searchSpaceId,
});
if (result?.id) {
await updatePreferences({
search_space_id: searchSpaceId,
data: {
agent_llm_id: result.id,
},
});
}
onOpenChange(false);
} else if (!isGlobal && config) {
await updateConfig({
id: config.id,
data: {
name: data.name,
description: data.description,
provider: data.provider,
custom_provider: data.custom_provider,
model_name: data.model_name,
api_key: data.api_key,
api_base: data.api_base,
litellm_params: data.litellm_params,
system_instructions: data.system_instructions,
use_default_system_instructions: data.use_default_system_instructions,
citations_enabled: data.citations_enabled,
},
});
onOpenChange(false);
}
} catch (error) {
console.error("Failed to save configuration:", error);
} finally {
setIsSubmitting(false);
}
},
[
mode,
isGlobal,
config,
searchSpaceId,
createConfig,
updateConfig,
updatePreferences,
onOpenChange,
]
);
const handleUseGlobalConfig = useCallback(async () => {
if (!config || !isGlobal) return;
setIsSubmitting(true);
try {
await updatePreferences({
search_space_id: searchSpaceId,
data: {
agent_llm_id: config.id,
},
});
toast.success(`Now using ${config.name}`);
onOpenChange(false);
} catch (error) {
console.error("Failed to set model:", error);
} finally {
setIsSubmitting(false);
}
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-lg h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">{getTitle()}</DialogTitle>
{/* Header */}
<div className="flex items-start justify-between px-6 pt-6 pb-4 pr-14">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
{isGlobal && mode !== "create" && (
<Badge variant="secondary" className="text-[10px]">
Global
</Badge>
)}
{!isGlobal && mode !== "create" && (
<Badge variant="outline" className="text-[10px]">
Custom
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
{config && mode !== "create" && (
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
)}
</div>
</div>
{/* Scrollable content */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-5"
style={{
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{isGlobal && mode !== "create" && (
<Alert className="mb-5 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 settings, create a new
configuration based on this template.
</AlertDescription>
</Alert>
)}
{mode === "create" ? (
<LLMConfigForm
key={defaultProvider ?? "no-provider"}
searchSpaceId={searchSpaceId}
onSubmit={handleSubmit}
mode="create"
formId="model-config-form"
initialData={
defaultProvider ? { provider: defaultProvider as LiteLLMProvider } : undefined
}
/>
) : isGlobal && config ? (
<div className="space-y-6">
<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">
Configuration 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>
<div className="h-px bg-border/50" />
<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 className="h-px bg-border/50" />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Citations
</div>
<Badge
variant={config.citations_enabled ? "default" : "secondary"}
className="w-fit"
>
{config.citations_enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</div>
{config.system_instructions && (
<>
<div className="h-px bg-border/50" />
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
System Instructions
</div>
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs font-mono text-muted-foreground whitespace-pre-wrap line-clamp-10">
{config.system_instructions}
</p>
</div>
</div>
</>
)}
</div>
</div>
) : config ? (
<LLMConfigForm
searchSpaceId={searchSpaceId}
initialData={{
name: config.name,
description: config.description,
provider: config.provider as LiteLLMProvider,
custom_provider: config.custom_provider,
model_name: config.model_name,
api_key: "api_key" in config ? (config.api_key as string) : "",
api_base: config.api_base,
litellm_params: config.litellm_params,
system_instructions: config.system_instructions,
use_default_system_instructions: config.use_default_system_instructions,
citations_enabled: config.citations_enabled,
search_space_id: searchSpaceId,
}}
onSubmit={handleSubmit}
mode="edit"
formId="model-config-form"
/>
) : null}
</div>
{/* Fixed footer */}
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="text-sm h-9"
>
Cancel
</Button>
{mode === "create" || (!isGlobal && config) ? (
<Button
type="submit"
form="model-config-form"
disabled={isSubmitting}
className="relative text-sm h-9 min-w-[120px]"
>
<span className={isSubmitting ? "opacity-0" : ""}>
{mode === "edit" ? "Save Changes" : "Add Model"}
</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : isGlobal && config ? (
<Button
className="relative text-sm h-9"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : null}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -1,478 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Check, ChevronsUpDown } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
import {
createVisionLLMConfigMutationAtom,
updateVisionLLMConfigMutationAtom,
} from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms";
import { visionModelListAtom } from "@/atoms/vision-llm-config/vision-llm-config-query.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 { Dialog, DialogContent, 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 { VISION_PROVIDERS } from "@/contracts/enums/vision-providers";
import type {
GlobalVisionLLMConfig,
VisionLLMConfig,
VisionProvider,
} from "@/contracts/types/new-llm-config.types";
import { cn } from "@/lib/utils";
interface VisionConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: VisionLLMConfig | GlobalVisionLLMConfig | null;
isGlobal: boolean;
searchSpaceId: number;
mode: "create" | "edit" | "view";
defaultProvider?: string;
}
const INITIAL_FORM = {
name: "",
description: "",
provider: "",
model_name: "",
api_key: "",
api_base: "",
api_version: "",
};
export function VisionConfigDialog({
open,
onOpenChange,
config,
isGlobal,
searchSpaceId,
mode,
defaultProvider,
}: VisionConfigDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState(INITIAL_FORM);
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const scrollRef = useRef<HTMLDivElement>(null);
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 VisionLLMConfig).api_key || "",
api_base: config.api_base || "",
api_version: (config as VisionLLMConfig).api_version || "",
});
} else if (mode === "create") {
setFormData({ ...INITIAL_FORM, provider: defaultProvider ?? "" });
}
setScrollPos("top");
}
}, [open, mode, config, isGlobal, defaultProvider]);
const { mutateAsync: createConfig } = useAtomValue(createVisionLLMConfigMutationAtom);
const { mutateAsync: updateConfig } = useAtomValue(updateVisionLLMConfigMutationAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atTop = el.scrollTop <= 2;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []);
const getTitle = () => {
if (mode === "create") return "Add Vision Model";
if (isGlobal) return "View Global Vision Model";
return "Edit Vision Model";
};
const getSubtitle = () => {
if (mode === "create") return "Set up a new vision-capable LLM provider";
if (isGlobal) return "Read-only global configuration";
return "Update your vision model settings";
};
const handleSubmit = useCallback(async () => {
setIsSubmitting(true);
try {
if (mode === "create") {
const result = await createConfig({
name: formData.name,
provider: formData.provider as VisionProvider,
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,
});
if (result?.id) {
await updatePreferences({
search_space_id: searchSpaceId,
data: { vision_llm_config_id: result.id },
});
}
onOpenChange(false);
} else if (!isGlobal && config) {
await updateConfig({
id: config.id,
data: {
name: formData.name,
description: formData.description || undefined,
provider: formData.provider as VisionProvider,
model_name: formData.model_name,
api_key: formData.api_key,
api_base: formData.api_base || undefined,
api_version: formData.api_version || undefined,
},
});
onOpenChange(false);
}
} catch (error) {
console.error("Failed to save vision config:", error);
toast.error("Failed to save vision 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: { vision_llm_config_id: config.id },
});
toast.success(`Now using ${config.name}`);
onOpenChange(false);
} catch (error) {
console.error("Failed to set vision model:", error);
toast.error("Failed to set vision model");
} finally {
setIsSubmitting(false);
}
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
const { data: dynamicModels } = useAtomValue(visionModelListAtom);
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
const availableModels = useMemo(
() => (dynamicModels ?? []).filter((m) => m.provider === formData.provider),
[dynamicModels, formData.provider]
);
const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key;
const selectedProvider = VISION_PROVIDERS.find((p) => p.value === formData.provider);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-lg h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">{getTitle()}</DialogTitle>
<div className="flex items-start justify-between px-6 pt-6 pb-4 pr-14">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
{isGlobal && mode !== "create" && (
<Badge variant="secondary" className="text-[10px]">
Global
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
{config && mode !== "create" && (
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
)}
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-5"
style={{
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{isGlobal && config && (
<>
<Alert className="mb-5 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>
</>
)}
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Name *</Label>
<Input
placeholder="e.g., My GPT-4o Vision"
value={formData.name}
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
/>
</div>
<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 className="bg-popover-border" />
<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>
{VISION_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value} description={p.example}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Model Name *</Label>
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modelComboboxOpen}
className={cn(
"w-full justify-between font-normal bg-transparent",
!formData.model_name && "text-muted-foreground"
)}
>
{formData.model_name || "Select 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 shouldFilter={false} className="bg-transparent">
<CommandInput
placeholder={selectedProvider?.example || "Search model name"}
value={formData.model_name}
onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))}
/>
<CommandList className="max-h-[300px]">
<CommandEmpty>
<div className="py-3 text-center text-sm text-muted-foreground">
{formData.model_name
? `Using: "${formData.model_name}"`
: "Type your model name"}
</div>
</CommandEmpty>
{availableModels.length > 0 && (
<CommandGroup heading="Suggested Models">
{availableModels
.filter(
(model) =>
!formData.model_name ||
model.value
.toLowerCase()
.includes(formData.model_name.toLowerCase()) ||
model.label
.toLowerCase()
.includes(formData.model_name.toLowerCase())
)
.slice(0, 50)
.map((model) => (
<CommandItem
key={model.value}
value={model.value}
onSelect={(value) => {
setFormData((p) => ({
...p,
model_name: value,
}));
setModelComboboxOpen(false);
}}
className="py-2"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.model_name === model.value
? "opacity-100"
: "opacity-0"
)}
/>
<div>
<div className="font-medium">{model.label}</div>
{model.contextWindow && (
<div className="text-xs text-muted-foreground">
Context: {model.contextWindow}
</div>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">API Key *</Label>
<Input
type="password"
placeholder="sk-..."
value={formData.api_key}
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
/>
</div>
<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>
{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>
)}
</div>
)}
</div>
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
className="text-sm h-9"
>
Cancel
</Button>
{mode === "create" || (mode === "edit" && !isGlobal) ? (
<Button
onClick={handleSubmit}
disabled={isSubmitting || !isFormValid}
className="relative text-sm h-9 min-w-[120px]"
>
<span className={isSubmitting ? "opacity-0" : ""}>
{mode === "edit" ? "Save Changes" : "Add Model"}
</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : isGlobal && config ? (
<Button
className="relative text-sm h-9"
onClick={handleUseGlobalConfig}
disabled={isSubmitting}
>
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
) : null}
</div>
</DialogContent>
</Dialog>
);
}