mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 14:19:56 +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
|
* Profile Create Dialog Component
|
||||||
* Modal dialog with tabbed interface for creating new API profiles
|
* Modal dialog with provider preset cards and model configuration
|
||||||
* Includes Quick Start templates and advanced model configuration
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
/* 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 { useForm, useWatch } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -23,11 +23,19 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useCreateProfile } from '@/hooks/use-profiles';
|
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 { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { PROVIDER_PRESETS, type ProviderPreset } from '@/lib/provider-presets';
|
||||||
const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
|
import {
|
||||||
|
searchModels,
|
||||||
|
formatPricingPair,
|
||||||
|
formatContextLength,
|
||||||
|
formatModelAge,
|
||||||
|
getNewestModelsPerProvider,
|
||||||
|
} from '@/lib/openrouter-utils';
|
||||||
|
import type { CategorizedModel } from '@/lib/openrouter-types';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z
|
name: z
|
||||||
@@ -48,6 +56,7 @@ interface ProfileCreateDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onSuccess: (name: string) => void;
|
onSuccess: (name: string) => void;
|
||||||
|
initialMode?: 'normal' | 'openrouter';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Common URL mistakes to warn about
|
// Common URL mistakes to warn about
|
||||||
@@ -58,6 +67,11 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
|||||||
const [activeTab, setActiveTab] = useState('basic');
|
const [activeTab, setActiveTab] = useState('basic');
|
||||||
const [urlWarning, setUrlWarning] = useState<string | null>(null);
|
const [urlWarning, setUrlWarning] = useState<string | null>(null);
|
||||||
const [showApiKey, setShowApiKey] = useState(false);
|
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 {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -65,6 +79,7 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
|||||||
formState: { errors },
|
formState: { errors },
|
||||||
control,
|
control,
|
||||||
reset,
|
reset,
|
||||||
|
setValue,
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -80,21 +95,73 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
|||||||
|
|
||||||
const baseUrlValue = useWatch({ control, name: 'baseUrl' });
|
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(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
reset();
|
reset();
|
||||||
setActiveTab('basic');
|
setActiveTab('basic');
|
||||||
setUrlWarning(null);
|
setUrlWarning(null);
|
||||||
setShowApiKey(false);
|
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
|
// Check for common URL mistakes
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (baseUrlValue) {
|
if (baseUrlValue && selectedPreset === 'custom') {
|
||||||
const lowerUrl = baseUrlValue.toLowerCase();
|
const lowerUrl = baseUrlValue.toLowerCase();
|
||||||
for (const path of PROBLEMATIC_PATHS) {
|
for (const path of PROBLEMATIC_PATHS) {
|
||||||
if (lowerUrl.endsWith(path)) {
|
if (lowerUrl.endsWith(path)) {
|
||||||
@@ -107,13 +174,18 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setUrlWarning(null);
|
setUrlWarning(null);
|
||||||
}, [baseUrlValue]);
|
}, [baseUrlValue, selectedPreset]);
|
||||||
|
|
||||||
const onSubmit = async (data: FormData) => {
|
const onSubmit = async (data: FormData) => {
|
||||||
|
const preset = currentPreset;
|
||||||
|
const finalData = {
|
||||||
|
...data,
|
||||||
|
baseUrl: preset ? preset.baseUrl : data.baseUrl,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
await createMutation.mutateAsync(data);
|
await createMutation.mutateAsync(finalData);
|
||||||
toast.success(`Profile "${data.name}" created`);
|
toast.success(`Profile "${finalData.name}" created`);
|
||||||
onSuccess(data.name);
|
onSuccess(finalData.name);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error((error as Error).message || 'Failed to create profile');
|
toast.error((error as Error).message || 'Failed to create profile');
|
||||||
@@ -124,19 +196,56 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
|||||||
const hasModelErrors =
|
const hasModelErrors =
|
||||||
!!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel;
|
!!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel;
|
||||||
|
|
||||||
|
const isOpenRouter = selectedPreset === 'openrouter';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<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">
|
<DialogHeader className="p-6 pb-4 border-b">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Plus className="w-5 h-5 text-primary" />
|
<Plus className="w-5 h-5 text-primary" />
|
||||||
Create API Profile
|
Create API Profile
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>Configure a custom API endpoint for Claude Code.</DialogDescription>
|
<DialogDescription>
|
||||||
|
Choose a provider or configure a custom API endpoint.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col overflow-hidden">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col">
|
{/* 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">
|
<div className="px-6 pt-4">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="basic" className="relative">
|
<TabsTrigger value="basic" className="relative">
|
||||||
@@ -154,33 +263,31 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto max-h-[60vh]">
|
<ScrollArea className="flex-1">
|
||||||
<TabsContent value="basic" className="p-6 space-y-6 mt-0">
|
<TabsContent value="basic" className="p-6 space-y-4 mt-0">
|
||||||
<div className="space-y-4">
|
{/* Profile Name */}
|
||||||
{/* Name */}
|
<div className="space-y-1.5">
|
||||||
<div className="space-y-1.5">
|
<Label htmlFor="name">
|
||||||
<Label htmlFor="name">
|
Profile Name <span className="text-destructive">*</span>
|
||||||
Profile Name <span className="text-destructive">*</span>
|
</Label>
|
||||||
</Label>
|
<Input
|
||||||
<Input
|
id="name"
|
||||||
id="name"
|
{...register('name')}
|
||||||
{...register('name')}
|
placeholder="my-api"
|
||||||
placeholder="my-api"
|
className="font-mono"
|
||||||
className="font-mono"
|
/>
|
||||||
/>
|
{errors.name ? (
|
||||||
{errors.name ? (
|
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||||
<p className="text-xs text-destructive">{errors.name.message}</p>
|
) : (
|
||||||
) : (
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">
|
Used in CLI:{' '}
|
||||||
Used in CLI:{' '}
|
<code className="bg-muted px-1 rounded text-[10px]">ccs my-api "prompt"</code>
|
||||||
<code className="bg-muted px-1 rounded text-[10px]">
|
</p>
|
||||||
ccs my-api "prompt"
|
)}
|
||||||
</code>
|
</div>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Base URL */}
|
{/* Base URL - only show for custom */}
|
||||||
|
{selectedPreset === 'custom' ? (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="baseUrl">
|
<Label htmlFor="baseUrl">
|
||||||
API Base URL <span className="text-destructive">*</span>
|
API Base URL <span className="text-destructive">*</span>
|
||||||
@@ -203,52 +310,108 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
{/* API Key */}
|
currentPreset && (
|
||||||
<div className="space-y-1.5">
|
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-md border">
|
||||||
<Label htmlFor="apiKey">
|
{currentPreset.icon ? (
|
||||||
API Key <span className="text-destructive">*</span>
|
<img src={currentPreset.icon} alt="" className="w-5 h-5" />
|
||||||
</Label>
|
) : (
|
||||||
<div className="relative">
|
<Settings2 className="w-5 h-5 text-muted-foreground" />
|
||||||
<Input
|
)}
|
||||||
id="apiKey"
|
<div className="flex-1">
|
||||||
type={showApiKey ? 'text' : 'password'}
|
<p className="text-sm font-medium">{currentPreset.name} API</p>
|
||||||
{...register('apiKey')}
|
<p className="text-xs text-muted-foreground">{currentPreset.baseUrl}</p>
|
||||||
placeholder="sk-..."
|
</div>
|
||||||
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>
|
|
||||||
</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>
|
</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>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="models" className="p-6 mt-0 space-y-6">
|
<TabsContent value="models" className="p-6 mt-0 space-y-4">
|
||||||
<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">
|
<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" />
|
<Info className="w-5 h-5 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium mb-1">Model Mapping</p>
|
<p className="font-medium mb-1">Model Mapping</p>
|
||||||
<p className="text-xs opacity-90">
|
<p className="text-xs opacity-90">
|
||||||
Claude Code requests specific model tiers (Opus/Sonnet/Haiku). Map these tiers
|
Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your
|
||||||
to the specific models supported by your API provider.
|
provider.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="model">
|
<Label htmlFor="model">
|
||||||
Default Model
|
Default Model
|
||||||
@@ -259,18 +422,15 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
|||||||
<Input
|
<Input
|
||||||
id="model"
|
id="model"
|
||||||
{...register('model')}
|
{...register('model')}
|
||||||
placeholder={DEFAULT_MODEL}
|
placeholder={currentPreset?.defaultModel ?? 'claude-sonnet-4'}
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Fallback model if no specific tier is requested
|
|
||||||
</p>
|
|
||||||
</div>
|
</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">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="sonnetModel" className="text-sm">
|
<Label htmlFor="sonnetModel" className="text-sm">
|
||||||
Sonnet Mapping (Primary)
|
Sonnet Mapping
|
||||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||||
DEFAULT_SONNET
|
DEFAULT_SONNET
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -278,14 +438,14 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
|||||||
<Input
|
<Input
|
||||||
id="sonnetModel"
|
id="sonnetModel"
|
||||||
{...register('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"
|
className="font-mono text-sm h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="opusModel" className="text-sm">
|
<Label htmlFor="opusModel" className="text-sm">
|
||||||
Opus Mapping (Complex Tasks)
|
Opus Mapping
|
||||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||||
DEFAULT_OPUS
|
DEFAULT_OPUS
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -293,14 +453,14 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
|||||||
<Input
|
<Input
|
||||||
id="opusModel"
|
id="opusModel"
|
||||||
{...register('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"
|
className="font-mono text-sm h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="haikuModel" className="text-sm">
|
<Label htmlFor="haikuModel" className="text-sm">
|
||||||
Haiku Mapping (Fast Tasks)
|
Haiku Mapping
|
||||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||||
DEFAULT_HAIKU
|
DEFAULT_HAIKU
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -308,16 +468,16 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
|||||||
<Input
|
<Input
|
||||||
id="haikuModel"
|
id="haikuModel"
|
||||||
{...register('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"
|
className="font-mono text-sm h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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)}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -345,3 +505,79 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
|
|||||||
</Dialog>
|
</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