feat(ui): improve API profile provider selector UX

This commit is contained in:
Tam Nhu Tran
2026-03-17 13:54:52 -04:00
parent eeb0378054
commit 0337a32d99
6 changed files with 260 additions and 84 deletions
+16 -15
View File
@@ -8,11 +8,11 @@
export type PresetCategory = 'recommended' | 'alternative';
export const PROVIDER_PRESET_IDS = [
'anthropic',
'openrouter',
'alibaba-coding-plan',
'ollama',
'llamacpp',
'anthropic',
'glm',
'glmt',
'km',
@@ -60,20 +60,6 @@ export const PROVIDER_PRESET_ALIASES: Readonly<Record<string, ProviderPresetId>>
});
const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [
{
id: 'anthropic',
name: 'Anthropic (Direct API)',
description: 'Use your own Anthropic API key (sk-ant-...)',
baseUrl: '',
defaultProfileName: 'anthropic',
defaultModel: 'claude-sonnet-4-5-20250929',
apiKeyPlaceholder: 'sk-ant-api03-...',
apiKeyHint: 'Get key at console.anthropic.com/settings/keys',
category: 'recommended',
requiresApiKey: true,
badge: 'Direct',
featured: true,
},
{
id: 'openrouter',
name: 'OpenRouter',
@@ -133,6 +119,21 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [
badge: 'Local',
featured: true,
},
{
id: 'anthropic',
name: 'Anthropic (Direct API)',
description: 'Use your own Anthropic API key (sk-ant-...)',
baseUrl: '',
defaultProfileName: 'anthropic',
defaultModel: 'claude-sonnet-4-5-20250929',
apiKeyPlaceholder: 'sk-ant-api03-...',
apiKeyHint: 'Get key at console.anthropic.com/settings/keys',
category: 'recommended',
requiresApiKey: true,
badge: 'Direct',
featured: true,
icon: '/assets/providers/claude.svg',
},
{
id: 'glm',
name: 'GLM',
+9
View File
@@ -61,6 +61,15 @@ describe('provider-presets', () => {
expect(preset?.defaultProfileName).toBe('qwen-api');
});
it('keeps Anthropic direct last in the recommended preset order and reuses the Claude logo', () => {
const recommendedPresetIds = PROVIDER_PRESETS.filter(
(preset) => preset.category === 'recommended'
).map((preset) => preset.id);
expect(recommendedPresetIds.at(-1)).toBe('anthropic');
expect(getPresetById('anthropic')?.icon).toBe('/assets/providers/claude.svg');
});
it('only references provider preset icons that exist in ui/public', () => {
for (const preset of PROVIDER_PRESETS) {
if (!preset.icon) continue;
@@ -28,6 +28,7 @@ import {
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
import { useCreateProfile } from '@/hooks/use-profiles';
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff, Settings2, Sparkles } from 'lucide-react';
@@ -105,6 +106,12 @@ const QUICK_TEMPLATE_PRESETS = PROVIDER_PRESETS.filter(
const QUICK_TEMPLATE_PRESET_IDS = new Set<string>(
QUICK_TEMPLATE_PRESETS.map((preset) => preset.id)
);
const CARD_META_CLAMP_STYLE = {
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
} as const;
export function ProfileCreateDialog({
open,
@@ -277,6 +284,7 @@ export function ProfileCreateDialog({
const isQuickTemplateSelected =
selectedPreset !== CUSTOM_PRESET_ID && QUICK_TEMPLATE_PRESET_IDS.has(selectedPreset);
const isOpenRouter = currentPreset?.id === DEFAULT_PRESET_ID;
const showQuickTemplates = selectedPreset === CUSTOM_PRESET_ID || isQuickTemplateSelected;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -295,57 +303,70 @@ export function ProfileCreateDialog({
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col flex-1 min-h-0 overflow-hidden"
>
{/* Provider Preset Cards - Compact horizontal layout */}
<div className="px-6 py-3 border-b bg-muted/30 space-y-2">
{/* Main Options: OpenRouter + Custom */}
<div>
<Label className="text-xs text-muted-foreground mb-1.5 block">
{t('profileEditor.provider')}
</Label>
<div className="flex gap-2">
{RECOMMENDED_PRESETS.map((preset) => (
<CompactPresetCard
key={preset.id}
preset={preset}
isSelected={selectedPreset === preset.id}
onClick={() => handlePresetSelect(preset.id)}
/>
))}
{/* Custom option */}
<button
type="button"
onClick={() => handlePresetSelect(CUSTOM_PRESET_ID)}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-md border-2 transition-all text-sm font-medium',
selectedPreset === CUSTOM_PRESET_ID || isQuickTemplateSelected
? 'border-primary bg-primary/10 text-primary dark:bg-primary/20'
: 'border-dashed border-muted-foreground/40 hover:border-primary/50 hover:bg-muted/50 text-muted-foreground hover:text-foreground'
)}
>
<Settings2 className="w-4 h-4" />
<span>{t('profileEditor.custom')}</span>
</button>
</div>
</div>
{/* Show quick templates when custom mode or non-recommended preset is selected */}
{(selectedPreset === CUSTOM_PRESET_ID || isQuickTemplateSelected) && (
<div className="pt-3 mt-2 border-t border-dashed border-muted-foreground/30">
<Label className="text-xs font-medium text-foreground/70 mb-2 block">
Quick Templates
<div className="border-b bg-muted/20 px-6 py-4">
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t('profileEditor.provider')}
</Label>
<div className="flex gap-2 flex-wrap">
{QUICK_TEMPLATE_PRESETS.map((preset) => (
<CompactPresetCard
key={preset.id}
preset={preset}
isSelected={selectedPreset === preset.id}
onClick={() => handlePresetSelect(preset.id)}
/>
))}
<p className="text-xs text-muted-foreground">
{t('profileEditor.providerChooserHint')}
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70">
{t('profileEditor.featuredProviders')}
</Label>
<span className="text-[11px] text-muted-foreground">
{t('profileEditor.scrollHint')}
</span>
</div>
<div className="-mx-1 overflow-x-auto pb-1">
<div className="flex min-w-max gap-2 px-1">
{RECOMMENDED_PRESETS.map((preset) => (
<CompactPresetCard
key={preset.id}
preset={preset}
isSelected={selectedPreset === preset.id}
onClick={() => handlePresetSelect(preset.id)}
/>
))}
</div>
</div>
</div>
)}
<div className="space-y-2">
<Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70">
{t('profileEditor.customEndpoint')}
</Label>
<CustomPresetCard
isSelected={selectedPreset === CUSTOM_PRESET_ID}
onClick={() => handlePresetSelect(CUSTOM_PRESET_ID)}
/>
</div>
{showQuickTemplates && (
<div className="space-y-2 border-t border-dashed border-muted-foreground/25 pt-4">
<Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70">
{t('profileEditor.morePresets')}
</Label>
<div className="-mx-1 overflow-x-auto pb-1">
<div className="flex min-w-max gap-2 px-1">
{QUICK_TEMPLATE_PRESETS.map((preset) => (
<CompactPresetCard
key={preset.id}
preset={preset}
isSelected={selectedPreset === preset.id}
onClick={() => handlePresetSelect(preset.id)}
/>
))}
</div>
</div>
</div>
)}
</div>
</div>
<Tabs
@@ -655,35 +676,78 @@ function CompactPresetCard({
isSelected: boolean;
onClick: () => void;
}) {
const isAnthropicDirect = preset.id === 'anthropic';
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-md border transition-all text-sm',
'flex min-h-[104px] w-[180px] flex-none flex-col items-start gap-3 rounded-xl border px-3 py-3 text-left transition-all',
isSelected
? preset.featured
? 'border-accent bg-accent/10 dark:bg-accent/20 font-medium'
: 'border-primary bg-primary/10 dark:bg-primary/20 font-medium'
: 'border-muted-foreground/20 hover:border-muted-foreground/40 hover:bg-muted/50'
? 'border-primary bg-primary/8 shadow-sm ring-1 ring-primary/10'
: 'border-border/60 bg-background hover:border-primary/40 hover:bg-accent/20'
)}
>
{preset.icon ? (
<img src={preset.icon} alt="" className="w-4 h-4" />
) : (
<div className="w-4 h-4 rounded-full bg-muted-foreground/20 flex items-center justify-center text-[9px] font-bold text-foreground/70">
{preset.name.charAt(0)}
</div>
)}
<span className="font-medium">{preset.name}</span>
{preset.badge && (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 ml-0.5 bg-muted-foreground/10 text-muted-foreground"
>
{preset.badge}
</Badge>
<div className="flex w-full items-start justify-between gap-3">
{isAnthropicDirect ? (
<ProviderLogo
provider="claude"
size="md"
className="rounded-lg border border-border/60 bg-background"
/>
) : preset.icon ? (
<div className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-background">
<img src={preset.icon} alt="" className="h-5 w-5 object-contain" />
</div>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-muted/70 text-xs font-semibold text-foreground/70">
{preset.name.charAt(0)}
</div>
)}
{preset.badge && (
<Badge
variant="secondary"
className="shrink-0 bg-muted px-1.5 py-0 text-[10px] text-muted-foreground"
>
{preset.badge}
</Badge>
)}
</div>
<div className="space-y-1">
<div className="text-sm font-semibold leading-tight">{preset.name}</div>
<p className="text-xs leading-4 text-muted-foreground" style={CARD_META_CLAMP_STYLE}>
{preset.description}
</p>
</div>
</button>
);
}
function CustomPresetCard({ isSelected, onClick }: { isSelected: boolean; onClick: () => void }) {
const { t } = useTranslation();
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex w-full items-center gap-3 rounded-xl border border-dashed px-4 py-3 text-left transition-all sm:max-w-[280px]',
isSelected
? 'border-primary bg-primary/8 shadow-sm ring-1 ring-primary/10'
: 'border-muted-foreground/30 bg-background hover:border-primary/40 hover:bg-accent/20'
)}
>
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-dashed border-current/30 bg-muted/70">
<Settings2 className="h-4 w-4 text-muted-foreground" />
</div>
<div className="space-y-1">
<div className="text-sm font-semibold">{t('profileEditor.customEndpoint')}</div>
<p className="text-xs text-muted-foreground">
{t('profileEditor.customEndpointDescription')}
</p>
</div>
</button>
);
}
+29
View File
@@ -606,6 +606,14 @@ const resources = {
setAsDefault: 'Set as default',
provider: 'Provider',
custom: 'Custom',
providerChooserHint:
'Start with a preset, then adjust the URL, API key, or model mapping as needed.',
featuredProviders: 'Featured Providers',
scrollHint: 'Scroll if your screen is narrow',
customEndpoint: 'Custom Endpoint',
morePresets: 'More Presets',
customEndpointDescription:
'Start from a blank Anthropic-compatible URL and configure everything manually.',
},
settings: {
sectionLoadFailed: 'Failed to load section',
@@ -1753,6 +1761,12 @@ const resources = {
setAsDefault: '设为默认',
provider: '提供商',
custom: '自定义',
providerChooserHint: '先选择一个预设,之后仍可调整 URL、API key 和模型映射。',
featuredProviders: '精选提供商',
scrollHint: '屏幕较窄时可横向滚动',
customEndpoint: '自定义端点',
morePresets: '更多预设',
customEndpointDescription: '从空白的 Anthropic 兼容 URL 开始,手动配置全部内容。',
},
settings: {
sectionLoadFailed: '加载分区失败',
@@ -2924,6 +2938,14 @@ const resources = {
setAsDefault: 'Đặt làm mặc định',
provider: 'Nhà cung cấp',
custom: 'Tùy chỉnh',
providerChooserHint:
'Bắt đầu với preset, sau đó bạn vẫn có thể chỉnh URL, API key và ánh xạ mô hình.',
featuredProviders: 'Nhà cung cấp nổi bật',
scrollHint: 'Có thể cuộn ngang trên màn hình hẹp',
customEndpoint: 'Endpoint tùy chỉnh',
morePresets: 'Thêm preset',
customEndpointDescription:
'Bắt đầu với URL tương thích Anthropic trống và tự cấu hình mọi thứ.',
},
settings: {
sectionLoadFailed: 'Không tải được phần',
@@ -4117,6 +4139,13 @@ const resources = {
setAsDefault: 'デフォルトに設定',
provider: 'プロバイダー',
custom: 'カスタム',
providerChooserHint:
'まずプリセットを選び、その後で URL、API Key、モデルマッピングを調整できます。',
featuredProviders: '注目のプロバイダー',
scrollHint: '画面が狭い場合は横にスクロールできます',
customEndpoint: 'カスタムエンドポイント',
morePresets: 'その他のプリセット',
customEndpointDescription: '空の Anthropic 互換 URL から始め、すべて手動で設定します。',
},
settings: {
sectionLoadFailed: 'セクションの読み込みに失敗しました',
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog';
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
const mutateAsync = vi.fn();
vi.mock('@/hooks/use-profiles', () => ({
useCreateProfile: () => ({
mutateAsync,
isPending: false,
}),
}));
vi.mock('@/hooks/use-openrouter-models', () => ({
useOpenRouterCatalog: () => ({
models: [],
}),
}));
describe('ProfileCreateDialog', () => {
beforeEach(() => {
mutateAsync.mockReset();
});
it('reveals more presets from custom mode and keeps custom unselected after choosing a template', async () => {
render(
<ProfileCreateDialog
open
onOpenChange={vi.fn()}
onSuccess={vi.fn()}
initialMode="openrouter"
/>
);
expect(screen.getByText('Featured Providers')).toBeInTheDocument();
expect(screen.queryByText('More Presets')).not.toBeInTheDocument();
expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(1);
const customButton = screen.getByRole('button', { name: /Custom Endpoint/i });
await userEvent.click(customButton);
expect(await screen.findByText('More Presets')).toBeInTheDocument();
expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(2);
const glmButton = screen.getByText('GLM').closest('button');
expect(glmButton).not.toBeNull();
if (!glmButton) {
throw new Error('GLM preset button not found');
}
await userEvent.click(glmButton);
await waitFor(() => {
expect(customButton).not.toHaveClass('border-primary');
});
expect(glmButton).toHaveClass('border-primary');
});
});
+16 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { getPresetById, resolvePresetApiKeyValue } from '@/lib/provider-presets';
import {
getPresetById,
getPresetsByCategory,
resolvePresetApiKeyValue,
} from '@/lib/provider-presets';
describe('resolvePresetApiKeyValue', () => {
it('keeps an explicit API key when one is provided', () => {
@@ -22,3 +26,14 @@ describe('resolvePresetApiKeyValue', () => {
expect(resolvePresetApiKeyValue(preset, '')).toBe('');
});
});
describe('provider preset metadata', () => {
it('keeps Anthropic direct last in the recommended order', () => {
const recommendedPresetIds = getPresetsByCategory('recommended').map((preset) => preset.id);
expect(recommendedPresetIds.at(-1)).toBe('anthropic');
});
it('reuses the Claude provider logo asset for Anthropic direct', () => {
expect(getPresetById('anthropic')?.icon).toBe('/assets/providers/claude.svg');
});
});