mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
feat(ui): rewrite profile create dialog with provider presets
- add preset cards grid (OpenRouter, GLM, GLMT, Kimi, Custom) - add model search picker for OpenRouter with newest-first sorting - auto-fill form fields when preset selected - show API key hints from preset config - skip model prompts for preset profiles
This commit is contained in:
@@ -1,17 +1,17 @@
|
||||
/**
|
||||
* Profile Create Dialog Component
|
||||
* Modal dialog with tabbed interface for creating new API profiles
|
||||
* Includes Quick Start templates and advanced model configuration
|
||||
* Modal dialog with provider preset cards and model configuration
|
||||
*/
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -23,11 +23,19 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useCreateProfile } from '@/hooks/use-profiles';
|
||||
import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff } from 'lucide-react';
|
||||
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
|
||||
import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff, Settings2, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
|
||||
import { PROVIDER_PRESETS, type ProviderPreset } from '@/lib/provider-presets';
|
||||
import {
|
||||
searchModels,
|
||||
formatPricingPair,
|
||||
formatContextLength,
|
||||
formatModelAge,
|
||||
getNewestModelsPerProvider,
|
||||
} from '@/lib/openrouter-utils';
|
||||
import type { CategorizedModel } from '@/lib/openrouter-types';
|
||||
|
||||
const schema = z.object({
|
||||
name: z
|
||||
@@ -48,6 +56,7 @@ interface ProfileCreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: (name: string) => void;
|
||||
initialMode?: 'normal' | 'openrouter';
|
||||
}
|
||||
|
||||
// Common URL mistakes to warn about
|
||||
@@ -58,6 +67,11 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
||||
const [activeTab, setActiveTab] = useState('basic');
|
||||
const [urlWarning, setUrlWarning] = useState<string | null>(null);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | null>('openrouter');
|
||||
const [modelSearch, setModelSearch] = useState('');
|
||||
|
||||
// OpenRouter models for model picker
|
||||
const { models: openRouterModels } = useOpenRouterCatalog();
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -65,6 +79,7 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
||||
formState: { errors },
|
||||
control,
|
||||
reset,
|
||||
setValue,
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
@@ -80,21 +95,73 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
||||
|
||||
const baseUrlValue = useWatch({ control, name: 'baseUrl' });
|
||||
|
||||
// Reset form when dialog opens
|
||||
// Get current preset config
|
||||
const currentPreset = useMemo(() => {
|
||||
if (!selectedPreset || selectedPreset === 'custom') return null;
|
||||
return PROVIDER_PRESETS.find((p) => p.id === selectedPreset);
|
||||
}, [selectedPreset]);
|
||||
|
||||
// Filter models for OpenRouter search (newest first)
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!modelSearch.trim()) {
|
||||
// Show newest models when no search
|
||||
return getNewestModelsPerProvider(openRouterModels, 2);
|
||||
}
|
||||
// Search and sort by created date (newest first)
|
||||
const results = searchModels(openRouterModels, modelSearch);
|
||||
return [...results].sort((a, b) => (b.created ?? 0) - (a.created ?? 0)).slice(0, 20);
|
||||
}, [openRouterModels, modelSearch]);
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
reset();
|
||||
setActiveTab('basic');
|
||||
setUrlWarning(null);
|
||||
setShowApiKey(false);
|
||||
setSelectedPreset('openrouter');
|
||||
setModelSearch('');
|
||||
// Pre-fill with OpenRouter preset
|
||||
const openrouterPreset = PROVIDER_PRESETS.find((p) => p.id === 'openrouter');
|
||||
if (openrouterPreset) {
|
||||
setTimeout(() => {
|
||||
setValue('name', openrouterPreset.defaultProfileName);
|
||||
setValue('baseUrl', openrouterPreset.baseUrl);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}, [open, reset]);
|
||||
}, [open, reset, setValue]);
|
||||
|
||||
// Handle preset selection
|
||||
const handlePresetSelect = (presetId: string) => {
|
||||
setSelectedPreset(presetId);
|
||||
const preset = PROVIDER_PRESETS.find((p) => p.id === presetId);
|
||||
if (preset) {
|
||||
setValue('name', preset.defaultProfileName);
|
||||
setValue('baseUrl', preset.baseUrl);
|
||||
if (preset.defaultModel) {
|
||||
setValue('model', preset.defaultModel);
|
||||
setValue('opusModel', preset.defaultModel);
|
||||
setValue('sonnetModel', preset.defaultModel);
|
||||
setValue('haikuModel', preset.defaultModel);
|
||||
}
|
||||
} else {
|
||||
// Custom
|
||||
setValue('name', '');
|
||||
setValue('baseUrl', '');
|
||||
setValue('model', '');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle model selection from picker
|
||||
const handleModelSelect = (model: CategorizedModel) => {
|
||||
setValue('model', model.id);
|
||||
setModelSearch(model.name);
|
||||
};
|
||||
|
||||
// Check for common URL mistakes
|
||||
|
||||
useEffect(() => {
|
||||
if (baseUrlValue) {
|
||||
if (baseUrlValue && selectedPreset === 'custom') {
|
||||
const lowerUrl = baseUrlValue.toLowerCase();
|
||||
for (const path of PROBLEMATIC_PATHS) {
|
||||
if (lowerUrl.endsWith(path)) {
|
||||
@@ -107,13 +174,18 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
||||
}
|
||||
}
|
||||
setUrlWarning(null);
|
||||
}, [baseUrlValue]);
|
||||
}, [baseUrlValue, selectedPreset]);
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
const preset = currentPreset;
|
||||
const finalData = {
|
||||
...data,
|
||||
baseUrl: preset ? preset.baseUrl : data.baseUrl,
|
||||
};
|
||||
try {
|
||||
await createMutation.mutateAsync(data);
|
||||
toast.success(`Profile "${data.name}" created`);
|
||||
onSuccess(data.name);
|
||||
await createMutation.mutateAsync(finalData);
|
||||
toast.success(`Profile "${finalData.name}" created`);
|
||||
onSuccess(finalData.name);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || 'Failed to create profile');
|
||||
@@ -124,19 +196,56 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
||||
const hasModelErrors =
|
||||
!!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel;
|
||||
|
||||
const isOpenRouter = selectedPreset === 'openrouter';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] p-0 gap-0 overflow-hidden">
|
||||
<DialogContent className="sm:max-w-[700px] p-0 gap-0 overflow-hidden max-h-[90vh]">
|
||||
<DialogHeader className="p-6 pb-4 border-b">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="w-5 h-5 text-primary" />
|
||||
Create API Profile
|
||||
</DialogTitle>
|
||||
<DialogDescription>Configure a custom API endpoint for Claude Code.</DialogDescription>
|
||||
<DialogDescription>
|
||||
Choose a provider or configure a custom API endpoint.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col overflow-hidden">
|
||||
{/* Provider Preset Cards */}
|
||||
<div className="px-6 py-4 border-b bg-muted/30">
|
||||
<Label className="text-xs text-muted-foreground mb-2 block">Choose a provider</Label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{PROVIDER_PRESETS.map((preset) => (
|
||||
<PresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPreset === preset.id}
|
||||
onClick={() => handlePresetSelect(preset.id)}
|
||||
/>
|
||||
))}
|
||||
{/* Custom option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePresetSelect('custom')}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-all text-center',
|
||||
selectedPreset === 'custom'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted hover:border-muted-foreground/30'
|
||||
)}
|
||||
>
|
||||
<Settings2 className="w-5 h-5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Custom</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-col flex-1 overflow-hidden"
|
||||
>
|
||||
<div className="px-6 pt-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="basic" className="relative">
|
||||
@@ -154,33 +263,31 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto max-h-[60vh]">
|
||||
<TabsContent value="basic" className="p-6 space-y-6 mt-0">
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="name">
|
||||
Profile Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
{...register('name')}
|
||||
placeholder="my-api"
|
||||
className="font-mono"
|
||||
/>
|
||||
{errors.name ? (
|
||||
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used in CLI:{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px]">
|
||||
ccs my-api "prompt"
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<TabsContent value="basic" className="p-6 space-y-4 mt-0">
|
||||
{/* Profile Name */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="name">
|
||||
Profile Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
{...register('name')}
|
||||
placeholder="my-api"
|
||||
className="font-mono"
|
||||
/>
|
||||
{errors.name ? (
|
||||
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used in CLI:{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px]">ccs my-api "prompt"</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
{/* Base URL - only show for custom */}
|
||||
{selectedPreset === 'custom' ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="baseUrl">
|
||||
API Base URL <span className="text-destructive">*</span>
|
||||
@@ -203,52 +310,108 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="apiKey">
|
||||
API Key <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="apiKey"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
{...register('apiKey')}
|
||||
placeholder="sk-..."
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
<span className="sr-only">Toggle API key visibility</span>
|
||||
</Button>
|
||||
) : (
|
||||
currentPreset && (
|
||||
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-md border">
|
||||
{currentPreset.icon ? (
|
||||
<img src={currentPreset.icon} alt="" className="w-5 h-5" />
|
||||
) : (
|
||||
<Settings2 className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{currentPreset.name} API</p>
|
||||
<p className="text-xs text-muted-foreground">{currentPreset.baseUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
{errors.apiKey && (
|
||||
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
|
||||
)}
|
||||
)
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="apiKey">
|
||||
API Key <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="apiKey"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
{...register('apiKey')}
|
||||
placeholder={currentPreset?.apiKeyPlaceholder ?? 'sk-...'}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.apiKey ? (
|
||||
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
|
||||
) : (
|
||||
currentPreset?.apiKeyHint && (
|
||||
<p className="text-xs text-muted-foreground">{currentPreset.apiKeyHint}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="models" className="p-6 mt-0 space-y-6">
|
||||
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
|
||||
<TabsContent value="models" className="p-6 mt-0 space-y-4">
|
||||
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
|
||||
<Info className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium mb-1">Model Mapping</p>
|
||||
<p className="text-xs opacity-90">
|
||||
Claude Code requests specific model tiers (Opus/Sonnet/Haiku). Map these tiers
|
||||
to the specific models supported by your API provider.
|
||||
Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your
|
||||
provider.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* OpenRouter Model Picker */}
|
||||
{isOpenRouter && (
|
||||
<div className="space-y-2">
|
||||
<Label>Search Models</Label>
|
||||
<Input
|
||||
value={modelSearch}
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..."
|
||||
/>
|
||||
<div className="border rounded-md max-h-48 overflow-y-auto">
|
||||
{filteredModels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground p-3 text-center">
|
||||
{modelSearch
|
||||
? `No models found for "${modelSearch}"`
|
||||
: 'Loading models...'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{!modelSearch && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground">
|
||||
<Sparkles className="w-3 h-3 text-orange-500" />
|
||||
<span>Newest Models</span>
|
||||
</div>
|
||||
)}
|
||||
{filteredModels.map((model) => (
|
||||
<ModelSearchItem
|
||||
key={model.id}
|
||||
model={model}
|
||||
onClick={() => handleModelSelect(model)}
|
||||
showAge={!modelSearch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Inputs */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="model">
|
||||
Default Model
|
||||
@@ -259,18 +422,15 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
||||
<Input
|
||||
id="model"
|
||||
{...register('model')}
|
||||
placeholder={DEFAULT_MODEL}
|
||||
placeholder={currentPreset?.defaultModel ?? 'claude-sonnet-4'}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Fallback model if no specific tier is requested
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 pt-2 border-t">
|
||||
<div className="grid gap-3 pt-2 border-t">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sonnetModel" className="text-sm">
|
||||
Sonnet Mapping (Primary)
|
||||
Sonnet Mapping
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_SONNET
|
||||
</Badge>
|
||||
@@ -278,14 +438,14 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
||||
<Input
|
||||
id="sonnetModel"
|
||||
{...register('sonnetModel')}
|
||||
placeholder="e.g. gpt-4o, claude-3-5-sonnet"
|
||||
placeholder="e.g. gpt-4o, claude-sonnet-4"
|
||||
className="font-mono text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="opusModel" className="text-sm">
|
||||
Opus Mapping (Complex Tasks)
|
||||
Opus Mapping
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_OPUS
|
||||
</Badge>
|
||||
@@ -293,14 +453,14 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
||||
<Input
|
||||
id="opusModel"
|
||||
{...register('opusModel')}
|
||||
placeholder="e.g. o1-preview, claude-3-opus"
|
||||
placeholder="e.g. o1, claude-opus-4.5"
|
||||
className="font-mono text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="haikuModel" className="text-sm">
|
||||
Haiku Mapping (Fast Tasks)
|
||||
Haiku Mapping
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_HAIKU
|
||||
</Badge>
|
||||
@@ -308,16 +468,16 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
||||
<Input
|
||||
id="haikuModel"
|
||||
{...register('haikuModel')}
|
||||
placeholder="e.g. gpt-4o-mini, claude-3-haiku"
|
||||
placeholder="e.g. gpt-4o-mini, claude-3.5-haiku"
|
||||
className="font-mono text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="p-6 pt-2 border-t bg-muted/10">
|
||||
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -345,3 +505,79 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Preset card component */
|
||||
function PresetCard({
|
||||
preset,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
preset: ProviderPreset;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-all text-center',
|
||||
isSelected
|
||||
? preset.featured
|
||||
? 'border-orange-500 bg-orange-50 dark:bg-orange-950/20'
|
||||
: 'border-primary bg-primary/5'
|
||||
: 'border-muted hover:border-muted-foreground/30'
|
||||
)}
|
||||
>
|
||||
{preset.icon ? (
|
||||
<img src={preset.icon} alt="" className="w-5 h-5" />
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
{preset.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs font-medium">{preset.name}</span>
|
||||
{preset.badge && (
|
||||
<Badge variant="secondary" className="text-[9px] px-1 py-0">
|
||||
{preset.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Model search result item */
|
||||
function ModelSearchItem({
|
||||
model,
|
||||
onClick,
|
||||
showAge,
|
||||
}: {
|
||||
model: CategorizedModel;
|
||||
onClick: () => void;
|
||||
showAge?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||
>
|
||||
<span className="flex-1 truncate">{model.name}</span>
|
||||
<span className="text-muted-foreground ml-2 flex items-center gap-2 text-xs">
|
||||
{showAge && model.created && (
|
||||
<Badge variant="outline" className="text-[10px] text-orange-600">
|
||||
{formatModelAge(model.created)}
|
||||
</Badge>
|
||||
)}
|
||||
{model.isFree ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Free
|
||||
</Badge>
|
||||
) : (
|
||||
<span>{formatPricingPair(model.pricing)}</span>
|
||||
)}
|
||||
<span>{formatContextLength(model.context_length)}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user