mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 18:18:43 +00:00
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:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user