diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index 802297b3..ecd6db70 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -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(null); const [showApiKey, setShowApiKey] = useState(false); + const [selectedPreset, setSelectedPreset] = useState('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({ 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 ( - + Create API Profile - Configure a custom API endpoint for Claude Code. + + Choose a provider or configure a custom API endpoint. + -
- + + {/* Provider Preset Cards */} +
+ +
+ {PROVIDER_PRESETS.map((preset) => ( + handlePresetSelect(preset.id)} + /> + ))} + {/* Custom option */} + +
+
+ +
@@ -154,33 +263,31 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
-
- -
- {/* Name */} -
- - - {errors.name ? ( -

{errors.name.message}

- ) : ( -

- Used in CLI:{' '} - - ccs my-api "prompt" - -

- )} -
+ + + {/* Profile Name */} +
+ + + {errors.name ? ( +

{errors.name.message}

+ ) : ( +

+ Used in CLI:{' '} + ccs my-api "prompt" +

+ )} +
- {/* Base URL */} + {/* Base URL - only show for custom */} + {selectedPreset === 'custom' ? (
- - {/* API Key */} -
- -
- - + ) : ( + currentPreset && ( +
+ {currentPreset.icon ? ( + + ) : ( + + )} +
+

{currentPreset.name} API

+

{currentPreset.baseUrl}

+
- {errors.apiKey && ( -

{errors.apiKey.message}

- )} + ) + )} + + {/* API Key */} +
+ +
+ +
+ {errors.apiKey ? ( +

{errors.apiKey.message}

+ ) : ( + currentPreset?.apiKeyHint && ( +

{currentPreset.apiKeyHint}

+ ) + )}
- -
+ +

Model Mapping

- 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.

-
+ {/* OpenRouter Model Picker */} + {isOpenRouter && ( +
+ + setModelSearch(e.target.value)} + placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..." + /> +
+ {filteredModels.length === 0 ? ( +

+ {modelSearch + ? `No models found for "${modelSearch}"` + : 'Loading models...'} +

+ ) : ( +
+ {!modelSearch && ( +
+ + Newest Models +
+ )} + {filteredModels.map((model) => ( + handleModelSelect(model)} + showAge={!modelSearch} + /> + ))} +
+ )} +
+
+ )} + + {/* Model Inputs */} +
-
+
-
+ - + @@ -345,3 +505,79 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
); } + +/** Preset card component */ +function PresetCard({ + preset, + isSelected, + onClick, +}: { + preset: ProviderPreset; + isSelected: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +/** Model search result item */ +function ModelSearchItem({ + model, + onClick, + showAge, +}: { + model: CategorizedModel; + onClick: () => void; + showAge?: boolean; +}) { + return ( + + ); +}