mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
feat(ui): improve API profile provider selector UX
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user