mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-05 08:22:29 +00:00
feat(ui): add OpenRouter model picker and tier mapping components
- add openrouter-badge.tsx with orange-themed badge - add openrouter-model-picker.tsx with search and category filters - add model-tier-mapping.tsx with collapsible tier editor - update barrel exports in profiles/index.ts
This commit is contained in:
@@ -12,3 +12,9 @@ export { ProfilesTable } from './profiles-table';
|
|||||||
// Profile editor (from subdirectory)
|
// Profile editor (from subdirectory)
|
||||||
export { ProfileEditor } from './editor';
|
export { ProfileEditor } from './editor';
|
||||||
export type { Settings, SettingsResponse, ProfileEditorProps } from './editor';
|
export type { Settings, SettingsResponse, ProfileEditorProps } from './editor';
|
||||||
|
|
||||||
|
// OpenRouter components
|
||||||
|
export { OpenRouterBadge } from './openrouter-badge';
|
||||||
|
export { OpenRouterModelPicker } from './openrouter-model-picker';
|
||||||
|
export { ModelTierMapping } from './model-tier-mapping';
|
||||||
|
export type { TierMapping } from './model-tier-mapping';
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Model Tier Mapping Editor
|
||||||
|
* Configure opus/sonnet/haiku model overrides
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Wand2, ChevronRight } from 'lucide-react';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
|
||||||
|
import { suggestTierMappings } from '@/lib/openrouter-utils';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface TierMapping {
|
||||||
|
opus?: string;
|
||||||
|
sonnet?: string;
|
||||||
|
haiku?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelTierMappingProps {
|
||||||
|
selectedModel?: string;
|
||||||
|
value: TierMapping;
|
||||||
|
onChange: (mapping: TierMapping) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelTierMapping({
|
||||||
|
selectedModel,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: ModelTierMappingProps) {
|
||||||
|
const { models } = useOpenRouterCatalog();
|
||||||
|
|
||||||
|
const suggestions = useMemo(() => {
|
||||||
|
if (!selectedModel) return {};
|
||||||
|
return suggestTierMappings(selectedModel, models);
|
||||||
|
}, [selectedModel, models]);
|
||||||
|
|
||||||
|
const handleAutoSuggest = () => {
|
||||||
|
onChange(suggestions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTier = (tier: keyof TierMapping, modelId: string) => {
|
||||||
|
onChange({ ...value, [tier]: modelId || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasSuggestions = selectedModel && Object.keys(suggestions).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible className={cn('group', className)}>
|
||||||
|
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium hover:underline">
|
||||||
|
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]:rotate-90" />
|
||||||
|
Model Tier Mapping
|
||||||
|
<span className="text-muted-foreground font-normal">(Advanced)</span>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="space-y-3 pt-3">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Configure different models for Claude Code's opus/sonnet/haiku tiers.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{hasSuggestions && (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleAutoSuggest}>
|
||||||
|
<Wand2 className="mr-1 h-4 w-4" />
|
||||||
|
Auto-suggest based on {selectedModel?.split('/')[0]}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
|
||||||
|
<Label htmlFor="tier-opus" className="text-right">
|
||||||
|
Opus
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="tier-opus"
|
||||||
|
value={value.opus ?? ''}
|
||||||
|
onChange={(e) => updateTier('opus', e.target.value)}
|
||||||
|
placeholder="e.g., anthropic/claude-opus-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
|
||||||
|
<Label htmlFor="tier-sonnet" className="text-right">
|
||||||
|
Sonnet
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="tier-sonnet"
|
||||||
|
value={value.sonnet ?? ''}
|
||||||
|
onChange={(e) => updateTier('sonnet', e.target.value)}
|
||||||
|
placeholder="e.g., anthropic/claude-sonnet-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[80px_1fr] items-center gap-2">
|
||||||
|
<Label htmlFor="tier-haiku" className="text-right">
|
||||||
|
Haiku
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="tier-haiku"
|
||||||
|
value={value.haiku ?? ''}
|
||||||
|
onChange={(e) => updateTier('haiku', e.target.value)}
|
||||||
|
placeholder="e.g., anthropic/claude-3.5-haiku"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
These set ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL,
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL.
|
||||||
|
</p>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* OpenRouter Badge Component
|
||||||
|
* Visual indicator for OpenRouter-configured profiles
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface OpenRouterBadgeProps {
|
||||||
|
className?: string;
|
||||||
|
showTooltip?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenRouterBadge({ className, showTooltip = true }: OpenRouterBadgeProps) {
|
||||||
|
const badge = (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'bg-orange-50 border-orange-200 text-orange-700',
|
||||||
|
'dark:bg-orange-950 dark:border-orange-800 dark:text-orange-300',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img src="/icons/openrouter.svg" alt="OpenRouter" className="mr-1 h-3 w-3" />
|
||||||
|
OpenRouter
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!showTooltip) return badge;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{badge}</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Access 349+ models via OpenRouter</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* OpenRouter Model Picker Component
|
||||||
|
* Searchable model selector with categories and pricing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Search, RefreshCw, Loader2 } from 'lucide-react';
|
||||||
|
import { useOpenRouterCatalog, useRefreshOpenRouterModels } from '@/hooks/use-openrouter-models';
|
||||||
|
import {
|
||||||
|
searchModels,
|
||||||
|
formatPricingPair,
|
||||||
|
formatContextLength,
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
} from '@/lib/openrouter-utils';
|
||||||
|
import type { CategorizedModel, ModelCategory } from '@/lib/openrouter-types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface OpenRouterModelPickerProps {
|
||||||
|
value?: string;
|
||||||
|
onChange: (modelId: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenRouterModelPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Search models...',
|
||||||
|
className,
|
||||||
|
}: OpenRouterModelPickerProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<ModelCategory | null>(null);
|
||||||
|
|
||||||
|
const { models, isLoading, isError, isFetching } = useOpenRouterCatalog();
|
||||||
|
const refreshModels = useRefreshOpenRouterModels();
|
||||||
|
|
||||||
|
// Filter and group models
|
||||||
|
const filteredModels = useMemo(() => {
|
||||||
|
return searchModels(models, search, {
|
||||||
|
category: selectedCategory ?? undefined,
|
||||||
|
});
|
||||||
|
}, [models, search, selectedCategory]);
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const groupedModels = useMemo(() => {
|
||||||
|
const groups: Record<ModelCategory, CategorizedModel[]> = {
|
||||||
|
anthropic: [],
|
||||||
|
openai: [],
|
||||||
|
google: [],
|
||||||
|
meta: [],
|
||||||
|
mistral: [],
|
||||||
|
opensource: [],
|
||||||
|
other: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
filteredModels.forEach((model) => {
|
||||||
|
groups[model.category].push(model);
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [filteredModels]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
refreshModels();
|
||||||
|
}, [refreshModels]);
|
||||||
|
|
||||||
|
const selectedModel = models.find((m) => m.id === value);
|
||||||
|
|
||||||
|
if (isLoading && models.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-2', className)}>
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-2', className)}>
|
||||||
|
{/* Search Header */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isFetching}
|
||||||
|
title="Refresh models"
|
||||||
|
>
|
||||||
|
{isFetching ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Filters */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<Badge
|
||||||
|
variant={selectedCategory === null ? 'default' : 'outline'}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setSelectedCategory(null)}
|
||||||
|
>
|
||||||
|
All ({models.length})
|
||||||
|
</Badge>
|
||||||
|
{(Object.keys(CATEGORY_LABELS) as ModelCategory[]).map((cat) => {
|
||||||
|
const count = groupedModels[cat].length;
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={cat}
|
||||||
|
variant={selectedCategory === cat ? 'default' : 'outline'}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setSelectedCategory(cat)}
|
||||||
|
>
|
||||||
|
{CATEGORY_LABELS[cat]} ({count})
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Model Display */}
|
||||||
|
{selectedModel && (
|
||||||
|
<div className="bg-muted rounded-md p-2 text-sm">
|
||||||
|
<span className="font-medium">{selectedModel.name}</span>
|
||||||
|
<span className="text-muted-foreground ml-2">
|
||||||
|
{formatPricingPair(selectedModel.pricing)} |{' '}
|
||||||
|
{formatContextLength(selectedModel.context_length)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Model List */}
|
||||||
|
<ScrollArea className="h-64 rounded-md border">
|
||||||
|
{isError ? (
|
||||||
|
<div className="text-destructive p-4 text-center">
|
||||||
|
Failed to load models.{' '}
|
||||||
|
<Button variant="link" onClick={handleRefresh}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : filteredModels.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground p-4 text-center">
|
||||||
|
No models found matching "{search}"
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 p-2">
|
||||||
|
{(Object.keys(CATEGORY_LABELS) as ModelCategory[]).map((category) => {
|
||||||
|
const categoryModels = groupedModels[category];
|
||||||
|
if (categoryModels.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={category}>
|
||||||
|
<div className="text-muted-foreground bg-background sticky top-0 mb-1 py-1 text-xs font-semibold">
|
||||||
|
{CATEGORY_LABELS[category]}
|
||||||
|
</div>
|
||||||
|
{categoryModels.map((model) => (
|
||||||
|
<ModelItem
|
||||||
|
key={model.id}
|
||||||
|
model={model}
|
||||||
|
isSelected={model.id === value}
|
||||||
|
onClick={() => onChange(model.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelItem({
|
||||||
|
model,
|
||||||
|
isSelected,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
model: CategorizedModel;
|
||||||
|
isSelected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'hover:bg-accent flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-sm',
|
||||||
|
isSelected && 'bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{model.name}</span>
|
||||||
|
<span className="text-muted-foreground ml-2 flex items-center gap-2 text-xs">
|
||||||
|
{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