Files
kandev/apps/web/components/settings/model-combobox.tsx
T
2026-01-25 22:23:03 +00:00

265 lines
8.8 KiB
TypeScript

'use client';
import { useState } from 'react';
import { IconRefresh, IconAlertCircle, IconSelector } from '@tabler/icons-react';
import { Input } from '@kandev/ui/input';
import { Label } from '@kandev/ui/label';
import { Checkbox } from '@kandev/ui/checkbox';
import { Button } from '@kandev/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@kandev/ui/popover';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@kandev/ui/command';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { Skeleton } from '@kandev/ui/skeleton';
import { useDynamicModels } from '@/hooks/domains/settings/use-dynamic-models';
import type { ModelEntry } from '@/lib/types/http';
type ModelComboboxProps = {
value: string;
onChange: (value: string) => void;
models: ModelEntry[];
defaultModel?: string;
placeholder?: string;
agentName?: string;
supportsDynamicModels?: boolean;
};
export function ModelCombobox({
value,
onChange,
models: staticModels,
defaultModel,
placeholder = 'Select a model',
agentName,
supportsDynamicModels = false,
}: ModelComboboxProps) {
const {
models,
isLoading,
error,
refresh,
} = useDynamicModels(agentName, staticModels, supportsDynamicModels);
const [open, setOpen] = useState(false);
// Initialize customValue with the current value if it's not in the static models list
// (we use staticModels for init since dynamic models haven't loaded yet)
const [customValue, setCustomValue] = useState(() => {
if (value && !staticModels.some((m) => m.id === value)) {
return value;
}
return '';
});
// Determine if the current value is a custom model (not in the list)
const isValueCustom = Boolean(value) && !models.some((m) => m.id === value);
// Track user's explicit toggle choice; null means derive from value
const [userToggle, setUserToggle] = useState<boolean | null>(null);
// Derive custom mode: user toggle takes precedence, otherwise infer from value
const isCustomMode = userToggle ?? isValueCustom;
const handleModeChange = (custom: boolean) => {
setUserToggle(custom);
if (custom) {
// Switching to custom mode - use preserved custom value
if (customValue) {
onChange(customValue);
}
} else {
// Switching to select mode - preserve current value if it's custom
if (isValueCustom && value) {
setCustomValue(value);
}
onChange(defaultModel || '');
}
};
const handleCustomValueChange = (newValue: string) => {
setCustomValue(newValue);
// Ensure we stay in custom mode when user is typing in the custom input
if (userToggle !== true) {
setUserToggle(true);
}
onChange(newValue);
};
const handleRefresh = async () => {
await refresh();
};
const selectedModel = models.find((m) => m.id === (value || defaultModel));
// Render the dropdown/combobox for model selection
const renderModelSelector = () => {
// Use searchable combobox for dynamic models (typically many options)
if (supportsDynamicModels) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
disabled={isCustomMode}
>
{selectedModel ? (
<span className="flex items-center gap-2 truncate">
{selectedModel.name}
{selectedModel.id === defaultModel && (
<span className="text-muted-foreground">(default)</span>
)}
{selectedModel.provider && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
{selectedModel.provider}
</span>
)}
</span>
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
<IconSelector className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search models..." />
<CommandList>
<CommandEmpty>No model found.</CommandEmpty>
<CommandGroup>
{models.map((model) => (
<CommandItem
key={model.id}
value={model.id}
onSelect={(currentValue) => {
onChange(currentValue);
setOpen(false);
}}
data-checked={value === model.id || (!value && model.id === defaultModel)}
>
<span className="flex-1 truncate">
{model.name}
{model.id === defaultModel && (
<span className="text-muted-foreground text-xs ml-1">(default)</span>
)}
</span>
{model.provider && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium mr-1">
{model.provider}
</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// Use simple select for static models (fewer options)
return (
<Select
value={value || defaultModel}
onValueChange={onChange}
disabled={isCustomMode}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
<span className="flex items-center gap-2">
{model.name}
{model.id === defaultModel && (
<span className="text-muted-foreground">(default)</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
};
// Show full-row skeleton while loading dynamic models
if (supportsDynamicModels && isLoading) {
return (
<div className="space-y-2">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-4 w-32 ml-auto" />
</div>
);
}
return (
<div className="flex items-start gap-2">
{/* Left side: Model dropdown/combobox + refresh button */}
<div className="flex flex-1 items-center gap-2">
<div className="flex-1">
{renderModelSelector()}
</div>
{/* Refresh button for dynamic models */}
{supportsDynamicModels && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={isCustomMode}
>
<IconRefresh className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Refresh models from CLI</p>
</TooltipContent>
</Tooltip>
)}
{/* Error indicator */}
{error && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<IconAlertCircle className="h-4 w-4 text-amber-500" />
</div>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs">
Failed to fetch dynamic models: {error}
<br />
Showing static fallback models.
</p>
</TooltipContent>
</Tooltip>
)}
</div>
{/* Right side: Custom model input + checkbox */}
<div className="flex-1 space-y-2">
<Input
value={customValue}
onChange={(e) => handleCustomValueChange(e.target.value)}
placeholder="Custom model ID"
disabled={!isCustomMode}
/>
<div className="flex items-center space-x-2">
<Checkbox
id="custom-model-mode"
checked={isCustomMode}
onCheckedChange={(checked) => handleModeChange(checked === true)}
/>
<Label htmlFor="custom-model-mode" className="text-xs text-muted-foreground">
Use custom model
</Label>
</div>
</div>
</div>
);
}