refactor(ui): reorganize profile create dialog with preset categories

- Split presets into recommended (OpenRouter) + alternative (GLM/GLMT/Kimi)
- Show alternative presets only when Custom is selected
- Replace PresetCard with CompactPresetCard (horizontal layout)
- Make baseUrl always editable (pre-filled from preset but customizable)
- Apply model selection to all 4 model tiers (opus/sonnet/haiku)
- Only show URL path warnings for truly custom URLs, not preset URLs
- Use accent tokens for consistent theming
This commit is contained in:
kaitranntt
2025-12-20 21:09:01 -05:00
parent b9f6823fc9
commit 96310dd1ac
@@ -27,7 +27,11 @@ import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff, Settings2, Sparkles } from 'lucide-react'; 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'; import {
PROVIDER_PRESETS,
getPresetsByCategory,
type ProviderPreset,
} from '@/lib/provider-presets';
import { import {
searchModels, searchModels,
formatPricingPair, formatPricingPair,
@@ -153,15 +157,21 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
} }
}; };
// Handle model selection from picker // Handle model selection from picker - applies to all 4 model tiers
const handleModelSelect = (model: CategorizedModel) => { const handleModelSelect = (model: CategorizedModel) => {
setValue('model', model.id); setValue('model', model.id);
setValue('opusModel', model.id);
setValue('sonnetModel', model.id);
setValue('haikuModel', model.id);
setModelSearch(model.name); setModelSearch(model.name);
}; };
// Check for common URL mistakes // Check for common URL mistakes - only for truly custom URLs
// Presets (OpenRouter, GLM, GLMT, Kimi) have vetted URLs that may require full paths
useEffect(() => { useEffect(() => {
if (baseUrlValue && selectedPreset === 'custom') { // Only warn for custom URLs, not preset-selected ones
const isCustomUrl = selectedPreset === 'custom';
if (baseUrlValue && isCustomUrl) {
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)) {
@@ -177,10 +187,9 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
}, [baseUrlValue, selectedPreset]); }, [baseUrlValue, selectedPreset]);
const onSubmit = async (data: FormData) => { const onSubmit = async (data: FormData) => {
const preset = currentPreset; // Use user-provided baseUrl (allows customization of preset URLs)
const finalData = { const finalData = {
...data, ...data,
baseUrl: preset ? preset.baseUrl : data.baseUrl,
}; };
try { try {
await createMutation.mutateAsync(finalData); await createMutation.mutateAsync(finalData);
@@ -212,33 +221,57 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col overflow-hidden"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col overflow-hidden">
{/* Provider Preset Cards */} {/* Provider Preset Cards - Compact horizontal layout */}
<div className="px-6 py-4 border-b bg-muted/30"> <div className="px-6 py-3 border-b bg-muted/30 space-y-2">
<Label className="text-xs text-muted-foreground mb-2 block">Choose a provider</Label> {/* Main Options: OpenRouter + Custom */}
<div className="grid grid-cols-5 gap-2"> <div>
{PROVIDER_PRESETS.map((preset) => ( <Label className="text-xs text-muted-foreground mb-1.5 block">Provider</Label>
<PresetCard <div className="flex gap-2">
key={preset.id} {getPresetsByCategory('recommended').map((preset) => (
preset={preset} <CompactPresetCard
isSelected={selectedPreset === preset.id} key={preset.id}
onClick={() => handlePresetSelect(preset.id)} preset={preset}
/> isSelected={selectedPreset === preset.id}
))} onClick={() => handlePresetSelect(preset.id)}
{/* Custom option */} />
<button ))}
type="button" {/* Custom option */}
onClick={() => handlePresetSelect('custom')} <button
className={cn( type="button"
'flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-all text-center', onClick={() => handlePresetSelect('custom')}
selectedPreset === 'custom' className={cn(
? 'border-primary bg-primary/5' 'flex items-center gap-1.5 px-3 py-1.5 rounded-md border transition-all text-sm',
: 'border-muted hover:border-muted-foreground/30' selectedPreset === 'custom' ||
)} getPresetsByCategory('alternative').some((p) => p.id === selectedPreset)
> ? 'border-primary bg-primary/5 font-medium'
<Settings2 className="w-5 h-5 text-muted-foreground" /> : 'border-muted hover:border-muted-foreground/30'
<span className="text-xs font-medium">Custom</span> )}
</button> >
<Settings2 className="w-3.5 h-3.5 text-muted-foreground" />
<span>Custom</span>
</button>
</div>
</div> </div>
{/* Show alternative presets when Custom is selected or an alternative is selected */}
{(selectedPreset === 'custom' ||
getPresetsByCategory('alternative').some((p) => p.id === selectedPreset)) && (
<div className="pt-2 border-t border-dashed">
<Label className="text-xs text-muted-foreground mb-1.5 block">
Quick Templates
</Label>
<div className="flex gap-2 flex-wrap">
{getPresetsByCategory('alternative').map((preset) => (
<CompactPresetCard
key={preset.id}
preset={preset}
isSelected={selectedPreset === preset.id}
onClick={() => handlePresetSelect(preset.id)}
/>
))}
</div>
</div>
)}
</div> </div>
<Tabs <Tabs
@@ -286,45 +319,33 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
)} )}
</div> </div>
{/* Base URL - only show for custom */} {/* Base URL - always editable, pre-filled from preset */}
{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> </Label>
</Label> <Input
<Input id="baseUrl"
id="baseUrl" {...register('baseUrl')}
{...register('baseUrl')} placeholder="https://api.example.com/v1"
placeholder="https://api.example.com/v1" />
/> {errors.baseUrl ? (
{errors.baseUrl ? ( <p className="text-xs text-destructive">{errors.baseUrl.message}</p>
<p className="text-xs text-destructive">{errors.baseUrl.message}</p> ) : urlWarning ? (
) : urlWarning ? ( <div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded"> <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" /> <span>{urlWarning}</span>
<span>{urlWarning}</span>
</div>
) : (
<p className="text-xs text-muted-foreground">
The endpoint that accepts OpenAI-compatible and Anthropic requests
</p>
)}
</div>
) : (
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> </div>
) ) : currentPreset ? (
)} <p className="text-xs text-muted-foreground">
Pre-filled from {currentPreset.name}. You can customize if needed.
</p>
) : (
<p className="text-xs text-muted-foreground">
The endpoint that accepts OpenAI-compatible and Anthropic requests
</p>
)}
</div>
{/* API Key */} {/* API Key */}
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -392,7 +413,7 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
<div className="p-1"> <div className="p-1">
{!modelSearch && ( {!modelSearch && (
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground"> <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" /> <Sparkles className="w-3 h-3 text-accent" />
<span>Newest Models</span> <span>Newest Models</span>
</div> </div>
)} )}
@@ -506,8 +527,8 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
); );
} }
/** Preset card component */ /** Compact preset card component - horizontal layout */
function PresetCard({ function CompactPresetCard({
preset, preset,
isSelected, isSelected,
onClick, onClick,
@@ -521,24 +542,24 @@ function PresetCard({
type="button" type="button"
onClick={onClick} onClick={onClick}
className={cn( className={cn(
'flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-all text-center', 'flex items-center gap-1.5 px-3 py-1.5 rounded-md border transition-all text-sm',
isSelected isSelected
? preset.featured ? preset.featured
? 'border-orange-500 bg-orange-50 dark:bg-orange-950/20' ? 'border-accent bg-accent/10 dark:bg-accent/20 font-medium'
: 'border-primary bg-primary/5' : 'border-primary bg-primary/5 font-medium'
: 'border-muted hover:border-muted-foreground/30' : 'border-muted hover:border-muted-foreground/30'
)} )}
> >
{preset.icon ? ( {preset.icon ? (
<img src={preset.icon} alt="" className="w-5 h-5" /> <img src={preset.icon} alt="" className="w-3.5 h-3.5" />
) : ( ) : (
<div className="w-5 h-5 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold"> <div className="w-3.5 h-3.5 rounded-full bg-muted flex items-center justify-center text-[8px] font-bold">
{preset.name.charAt(0)} {preset.name.charAt(0)}
</div> </div>
)} )}
<span className="text-xs font-medium">{preset.name}</span> <span>{preset.name}</span>
{preset.badge && ( {preset.badge && (
<Badge variant="secondary" className="text-[9px] px-1 py-0"> <Badge variant="secondary" className="text-[9px] px-1 py-0 ml-0.5">
{preset.badge} {preset.badge}
</Badge> </Badge>
)} )}
@@ -565,7 +586,7 @@ function ModelSearchItem({
<span className="flex-1 truncate">{model.name}</span> <span className="flex-1 truncate">{model.name}</span>
<span className="text-muted-foreground ml-2 flex items-center gap-2 text-xs"> <span className="text-muted-foreground ml-2 flex items-center gap-2 text-xs">
{showAge && model.created && ( {showAge && model.created && (
<Badge variant="outline" className="text-[10px] text-orange-600"> <Badge variant="outline" className="text-[10px] text-accent">
{formatModelAge(model.created)} {formatModelAge(model.created)}
</Badge> </Badge>
)} )}