mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 12:21:20 +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 type PresetCategory = 'recommended' | 'alternative';
|
||||||
|
|
||||||
export const PROVIDER_PRESET_IDS = [
|
export const PROVIDER_PRESET_IDS = [
|
||||||
'anthropic',
|
|
||||||
'openrouter',
|
'openrouter',
|
||||||
'alibaba-coding-plan',
|
'alibaba-coding-plan',
|
||||||
'ollama',
|
'ollama',
|
||||||
'llamacpp',
|
'llamacpp',
|
||||||
|
'anthropic',
|
||||||
'glm',
|
'glm',
|
||||||
'glmt',
|
'glmt',
|
||||||
'km',
|
'km',
|
||||||
@@ -60,20 +60,6 @@ export const PROVIDER_PRESET_ALIASES: Readonly<Record<string, ProviderPresetId>>
|
|||||||
});
|
});
|
||||||
|
|
||||||
const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [
|
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',
|
id: 'openrouter',
|
||||||
name: 'OpenRouter',
|
name: 'OpenRouter',
|
||||||
@@ -133,6 +119,21 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [
|
|||||||
badge: 'Local',
|
badge: 'Local',
|
||||||
featured: true,
|
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',
|
id: 'glm',
|
||||||
name: 'GLM',
|
name: 'GLM',
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ describe('provider-presets', () => {
|
|||||||
expect(preset?.defaultProfileName).toBe('qwen-api');
|
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', () => {
|
it('only references provider preset icons that exist in ui/public', () => {
|
||||||
for (const preset of PROVIDER_PRESETS) {
|
for (const preset of PROVIDER_PRESETS) {
|
||||||
if (!preset.icon) continue;
|
if (!preset.icon) continue;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||||
import { useCreateProfile } from '@/hooks/use-profiles';
|
import { useCreateProfile } from '@/hooks/use-profiles';
|
||||||
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
|
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';
|
||||||
@@ -105,6 +106,12 @@ const QUICK_TEMPLATE_PRESETS = PROVIDER_PRESETS.filter(
|
|||||||
const QUICK_TEMPLATE_PRESET_IDS = new Set<string>(
|
const QUICK_TEMPLATE_PRESET_IDS = new Set<string>(
|
||||||
QUICK_TEMPLATE_PRESETS.map((preset) => preset.id)
|
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({
|
export function ProfileCreateDialog({
|
||||||
open,
|
open,
|
||||||
@@ -277,6 +284,7 @@ export function ProfileCreateDialog({
|
|||||||
const isQuickTemplateSelected =
|
const isQuickTemplateSelected =
|
||||||
selectedPreset !== CUSTOM_PRESET_ID && QUICK_TEMPLATE_PRESET_IDS.has(selectedPreset);
|
selectedPreset !== CUSTOM_PRESET_ID && QUICK_TEMPLATE_PRESET_IDS.has(selectedPreset);
|
||||||
const isOpenRouter = currentPreset?.id === DEFAULT_PRESET_ID;
|
const isOpenRouter = currentPreset?.id === DEFAULT_PRESET_ID;
|
||||||
|
const showQuickTemplates = selectedPreset === CUSTOM_PRESET_ID || isQuickTemplateSelected;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -295,57 +303,70 @@ export function ProfileCreateDialog({
|
|||||||
onSubmit={handleSubmit(onSubmit)}
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Provider Preset Cards - Compact horizontal layout */}
|
<div className="border-b bg-muted/20 px-6 py-4">
|
||||||
<div className="px-6 py-3 border-b bg-muted/30 space-y-2">
|
<div className="space-y-4">
|
||||||
{/* Main Options: OpenRouter + Custom */}
|
<div className="space-y-1">
|
||||||
<div>
|
<Label className="text-xs text-muted-foreground">
|
||||||
<Label className="text-xs text-muted-foreground mb-1.5 block">
|
{t('profileEditor.provider')}
|
||||||
{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
|
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<p className="text-xs text-muted-foreground">
|
||||||
{QUICK_TEMPLATE_PRESETS.map((preset) => (
|
{t('profileEditor.providerChooserHint')}
|
||||||
<CompactPresetCard
|
</p>
|
||||||
key={preset.id}
|
</div>
|
||||||
preset={preset}
|
|
||||||
isSelected={selectedPreset === preset.id}
|
<div className="space-y-2">
|
||||||
onClick={() => handlePresetSelect(preset.id)}
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -655,35 +676,78 @@ function CompactPresetCard({
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const isAnthropicDirect = preset.id === 'anthropic';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
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
|
isSelected
|
||||||
? preset.featured
|
? 'border-primary bg-primary/8 shadow-sm ring-1 ring-primary/10'
|
||||||
? 'border-accent bg-accent/10 dark:bg-accent/20 font-medium'
|
: 'border-border/60 bg-background hover:border-primary/40 hover:bg-accent/20'
|
||||||
: 'border-primary bg-primary/10 dark:bg-primary/20 font-medium'
|
|
||||||
: 'border-muted-foreground/20 hover:border-muted-foreground/40 hover:bg-muted/50'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{preset.icon ? (
|
<div className="flex w-full items-start justify-between gap-3">
|
||||||
<img src={preset.icon} alt="" className="w-4 h-4" />
|
{isAnthropicDirect ? (
|
||||||
) : (
|
<ProviderLogo
|
||||||
<div className="w-4 h-4 rounded-full bg-muted-foreground/20 flex items-center justify-center text-[9px] font-bold text-foreground/70">
|
provider="claude"
|
||||||
{preset.name.charAt(0)}
|
size="md"
|
||||||
</div>
|
className="rounded-lg border border-border/60 bg-background"
|
||||||
)}
|
/>
|
||||||
<span className="font-medium">{preset.name}</span>
|
) : preset.icon ? (
|
||||||
{preset.badge && (
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-background">
|
||||||
<Badge
|
<img src={preset.icon} alt="" className="h-5 w-5 object-contain" />
|
||||||
variant="secondary"
|
</div>
|
||||||
className="text-[10px] px-1.5 py-0 ml-0.5 bg-muted-foreground/10 text-muted-foreground"
|
) : (
|
||||||
>
|
<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.badge}
|
{preset.name.charAt(0)}
|
||||||
</Badge>
|
</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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -606,6 +606,14 @@ const resources = {
|
|||||||
setAsDefault: 'Set as default',
|
setAsDefault: 'Set as default',
|
||||||
provider: 'Provider',
|
provider: 'Provider',
|
||||||
custom: 'Custom',
|
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: {
|
settings: {
|
||||||
sectionLoadFailed: 'Failed to load section',
|
sectionLoadFailed: 'Failed to load section',
|
||||||
@@ -1753,6 +1761,12 @@ const resources = {
|
|||||||
setAsDefault: '设为默认',
|
setAsDefault: '设为默认',
|
||||||
provider: '提供商',
|
provider: '提供商',
|
||||||
custom: '自定义',
|
custom: '自定义',
|
||||||
|
providerChooserHint: '先选择一个预设,之后仍可调整 URL、API key 和模型映射。',
|
||||||
|
featuredProviders: '精选提供商',
|
||||||
|
scrollHint: '屏幕较窄时可横向滚动',
|
||||||
|
customEndpoint: '自定义端点',
|
||||||
|
morePresets: '更多预设',
|
||||||
|
customEndpointDescription: '从空白的 Anthropic 兼容 URL 开始,手动配置全部内容。',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
sectionLoadFailed: '加载分区失败',
|
sectionLoadFailed: '加载分区失败',
|
||||||
@@ -2924,6 +2938,14 @@ const resources = {
|
|||||||
setAsDefault: 'Đặt làm mặc định',
|
setAsDefault: 'Đặt làm mặc định',
|
||||||
provider: 'Nhà cung cấp',
|
provider: 'Nhà cung cấp',
|
||||||
custom: 'Tùy chỉnh',
|
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: {
|
settings: {
|
||||||
sectionLoadFailed: 'Không tải được phần',
|
sectionLoadFailed: 'Không tải được phần',
|
||||||
@@ -4117,6 +4139,13 @@ const resources = {
|
|||||||
setAsDefault: 'デフォルトに設定',
|
setAsDefault: 'デフォルトに設定',
|
||||||
provider: 'プロバイダー',
|
provider: 'プロバイダー',
|
||||||
custom: 'カスタム',
|
custom: 'カスタム',
|
||||||
|
providerChooserHint:
|
||||||
|
'まずプリセットを選び、その後で URL、API Key、モデルマッピングを調整できます。',
|
||||||
|
featuredProviders: '注目のプロバイダー',
|
||||||
|
scrollHint: '画面が狭い場合は横にスクロールできます',
|
||||||
|
customEndpoint: 'カスタムエンドポイント',
|
||||||
|
morePresets: 'その他のプリセット',
|
||||||
|
customEndpointDescription: '空の Anthropic 互換 URL から始め、すべて手動で設定します。',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
sectionLoadFailed: 'セクションの読み込みに失敗しました',
|
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 { describe, expect, it } from 'vitest';
|
||||||
import { getPresetById, resolvePresetApiKeyValue } from '@/lib/provider-presets';
|
import {
|
||||||
|
getPresetById,
|
||||||
|
getPresetsByCategory,
|
||||||
|
resolvePresetApiKeyValue,
|
||||||
|
} from '@/lib/provider-presets';
|
||||||
|
|
||||||
describe('resolvePresetApiKeyValue', () => {
|
describe('resolvePresetApiKeyValue', () => {
|
||||||
it('keeps an explicit API key when one is provided', () => {
|
it('keeps an explicit API key when one is provided', () => {
|
||||||
@@ -22,3 +26,14 @@ describe('resolvePresetApiKeyValue', () => {
|
|||||||
expect(resolvePresetApiKeyValue(preset, '')).toBe('');
|
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