Add VisionModelManager and VisionConfigDialog components

This commit is contained in:
CREDO23 2026-04-07 19:27:24 +02:00
parent 3369b8a832
commit 3bbe6c3037
2 changed files with 782 additions and 0 deletions

View file

@ -0,0 +1,401 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { deleteVisionLLMConfigMutationAtom } from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms";
import {
globalVisionLLMConfigsAtom,
visionLLMConfigsAtom,
} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
import { VisionConfigDialog } from "@/components/shared/vision-config-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { VisionLLMConfig } from "@/contracts/types/new-llm-config.types";
import { useMediaQuery } from "@/hooks/use-media-query";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
interface VisionModelManagerProps {
searchSpaceId: number;
}
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
const isDesktop = useMediaQuery("(min-width: 768px)");
const {
mutateAsync: deleteConfig,
isPending: isDeleting,
error: deleteError,
} = useAtomValue(deleteVisionLLMConfigMutationAtom);
const {
data: userConfigs,
isFetching: configsLoading,
error: fetchError,
refetch: refreshConfigs,
} = useAtomValue(visionLLMConfigsAtom);
const { data: globalConfigs = [], isFetching: globalLoading } = useAtomValue(
globalVisionLLMConfigsAtom
);
const { data: members } = useAtomValue(membersAtom);
const memberMap = useMemo(() => {
const map = new Map<string, { name: string; email?: string; avatarUrl?: string }>();
if (members) {
for (const m of members) {
map.set(m.user_id, {
name: m.user_display_name || m.user_email || "Unknown",
email: m.user_email || undefined,
avatarUrl: m.user_avatar_url || undefined,
});
}
}
return map;
}, [members]);
const { data: access } = useAtomValue(myAccessAtom);
const canCreate = useMemo(() => {
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes("vision_configs:create") ?? false;
}, [access]);
const canDelete = useMemo(() => {
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes("vision_configs:delete") ?? false;
}, [access]);
const canUpdate = canCreate;
const isReadOnly = !canCreate && !canDelete;
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingConfig, setEditingConfig] = useState<VisionLLMConfig | null>(null);
const [configToDelete, setConfigToDelete] = useState<VisionLLMConfig | null>(null);
const isLoading = configsLoading || globalLoading;
const errors = [deleteError, fetchError].filter(Boolean) as Error[];
const openEditDialog = (config: VisionLLMConfig) => {
setEditingConfig(config);
setIsDialogOpen(true);
};
const openNewDialog = () => {
setEditingConfig(null);
setIsDialogOpen(true);
};
const handleDelete = async () => {
if (!configToDelete) return;
try {
await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
setConfigToDelete(null);
} catch {
// Error handled by mutation
}
};
return (
<div className="space-y-4 md:space-y-6">
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<Button
variant="secondary"
size="sm"
onClick={() => refreshConfigs()}
disabled={isLoading}
className="gap-2"
>
<RefreshCw className={cn("h-3.5 w-3.5", configsLoading && "animate-spin")} />
Refresh
</Button>
{canCreate && (
<Button
variant="outline"
onClick={openNewDialog}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
Add Vision Model
</Button>
)}
</div>
{errors.map((err) => (
<div key={err?.message}>
<Alert variant="destructive" className="py-3">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
</Alert>
</div>
))}
{access && !isLoading && isReadOnly && (
<div>
<Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
You have <span className="font-medium">read-only</span> access to vision model
configurations. Contact a space owner to request additional permissions.
</AlertDescription>
</Alert>
</div>
)}
{access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
<div>
<Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
You can{" "}
{[canCreate && "create and edit", canDelete && "delete"]
.filter(Boolean)
.join(" and ")}{" "}
vision model configurations
{!canDelete && ", but cannot delete them"}.
</AlertDescription>
</Alert>
</div>
)}
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
<Alert className="bg-muted/50 py-3">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
<p>
<span className="font-medium">
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length}{" "}
global vision{" "}
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length === 1
? "model"
: "models"}
</span>{" "}
available from your administrator. Use the model selector to view and select them.
</p>
</AlertDescription>
</Alert>
)}
{isLoading && (
<div className="space-y-4 md:space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="h-6 md:h-7 w-40 md:w-48" />
<Skeleton className="h-8 md:h-9 w-32 md:w-36 rounded-md" />
</div>
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-border/60">
<CardContent className="p-4 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1.5 flex-1 min-w-0">
<Skeleton className="h-4 w-28 md:w-32" />
<Skeleton className="h-3 w-40 md:w-48" />
</div>
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-24 rounded-md" />
</div>
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-3 w-16" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
</div>
)}
{!isLoading && (
<div className="space-y-4 md:space-y-6">
{(userConfigs?.length ?? 0) === 0 ? (
<Card className="border-0 bg-transparent shadow-none">
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
<h3 className="text-sm md:text-base font-semibold mb-2">No Vision Models Yet</h3>
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm mb-4">
{canCreate
? "Add your own vision-capable model (GPT-4o, Claude, Gemini, etc.)"
: "No vision models have been added to this space yet. Contact a space owner to add one."}
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{userConfigs?.map((config) => {
const member = config.user_id ? memberMap.get(config.user_id) : null;
return (
<div key={config.id}>
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold tracking-tight truncate">
{config.name}
</h4>
{config.description && (
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
{config.description}
</p>
)}
</div>
{(canUpdate || canDelete) && (
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
{canUpdate && (
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(config)}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
>
<Edit3 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{canDelete && (
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setConfigToDelete(config)}
className="h-7 w-7 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{getProviderIcon(config.provider, {
className: "size-3.5 shrink-0",
})}
<code className="text-[11px] font-mono text-muted-foreground bg-muted/60 px-2 py-0.5 rounded-md truncate max-w-[160px]">
{config.model_name}
</code>
</div>
<div className="flex items-center gap-2 pt-2 border-t border-border/40 mt-auto">
<span className="text-[11px] text-muted-foreground/60">
{new Date(config.created_at).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
{member && (
<>
<Dot className="h-4 w-4 text-muted-foreground/30" />
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default">
<Avatar className="size-4.5 shrink-0">
{member.avatarUrl && (
<AvatarImage src={member.avatarUrl} alt={member.name} />
)}
<AvatarFallback className="text-[9px]">
{getInitials(member.name)}
</AvatarFallback>
</Avatar>
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
{member.name}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{member.email || member.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</div>
</CardContent>
</Card>
</div>
);
})}
</div>
)}
</div>
)}
<VisionConfigDialog
open={isDialogOpen}
onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) setEditingConfig(null);
}}
config={editingConfig}
isGlobal={false}
searchSpaceId={searchSpaceId}
mode={editingConfig ? "edit" : "create"}
/>
<AlertDialog
open={!!configToDelete}
onOpenChange={(open) => !open && setConfigToDelete(null)}
>
<AlertDialogContent className="select-none">
<AlertDialogHeader>
<AlertDialogTitle>Delete Vision Model</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{configToDelete?.name}</span>?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<span className={isDeleting ? "opacity-0" : ""}>Delete</span>
{isDeleting && <Spinner size="sm" className="absolute" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -0,0 +1,381 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle } from "lucide-react";
import { useCallback, useEffect, 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 { 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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";
interface VisionConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: VisionLLMConfig | GlobalVisionLLMConfig | null;
isGlobal: boolean;
searchSpaceId: number;
mode: "create" | "edit" | "view";
}
const INITIAL_FORM = {
name: "",
description: "",
provider: "",
model_name: "",
api_key: "",
api_base: "",
api_version: "",
};
export function VisionConfigDialog({
open,
onOpenChange,
config,
isGlobal,
searchSpaceId,
mode,
}: 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);
}
setScrollPos("top");
}
}, [open, mode, config, isGlobal]);
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 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 />
<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>
<Input
placeholder={selectedProvider?.example?.split(",")[0]?.trim() || "e.g., gpt-4o"}
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>
<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>
);
}