mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
feat(ui): reposition providers around quality and local lanes
This commit is contained in:
@@ -3,12 +3,16 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
|
||||
import { useLocalRuntimeReadiness } from '@/hooks/use-local-runtime-readiness';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ArrowRight,
|
||||
BrainCircuit,
|
||||
CloudCog,
|
||||
ExternalLink,
|
||||
HardDriveDownload,
|
||||
KeyRound,
|
||||
Laptop,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Zap,
|
||||
@@ -22,6 +26,8 @@ interface OpenRouterQuickStartProps {
|
||||
onAlibabaCodingPlanClick: () => void;
|
||||
onCliproxyClick: () => void;
|
||||
onCustomClick: () => void;
|
||||
onOllamaClick: () => void;
|
||||
onLlamacppClick: () => void;
|
||||
}
|
||||
|
||||
interface QuickStartCardProps {
|
||||
@@ -38,6 +44,24 @@ interface QuickStartCardProps {
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
type LocalRuntimeStatus = 'ready' | 'missing-model' | 'offline';
|
||||
|
||||
type LocalRuntimeView = {
|
||||
id: 'ollama' | 'llamacpp';
|
||||
endpoint: string;
|
||||
status: LocalRuntimeStatus;
|
||||
commandHint: string;
|
||||
detectedModelCount: number;
|
||||
};
|
||||
|
||||
type LocalRuntimeCardState = {
|
||||
badge: string;
|
||||
badgeClassName: string;
|
||||
actionLabel: string;
|
||||
description: string;
|
||||
footer: string;
|
||||
};
|
||||
|
||||
function QuickStartCard({
|
||||
badge,
|
||||
badgeClassName,
|
||||
@@ -84,6 +108,52 @@ function QuickStartCard({
|
||||
);
|
||||
}
|
||||
|
||||
function getLocalRuntimeCardState(
|
||||
runtime: LocalRuntimeView | undefined,
|
||||
label: string
|
||||
): LocalRuntimeCardState {
|
||||
if (!runtime) {
|
||||
return {
|
||||
badge: 'Checking',
|
||||
badgeClassName: 'bg-muted text-muted-foreground',
|
||||
actionLabel: `Set up ${label}`,
|
||||
description: 'Checking the local runtime status before showing setup guidance.',
|
||||
footer: 'Checking the local runtime...',
|
||||
};
|
||||
}
|
||||
|
||||
if (runtime.status === 'ready') {
|
||||
return {
|
||||
badge: 'Ready',
|
||||
badgeClassName:
|
||||
'bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200',
|
||||
actionLabel: `Use ${label}`,
|
||||
description: 'Best for private prompts, offline workflows, and cheaper batch transforms.',
|
||||
footer: `Endpoint ready at ${runtime.endpoint}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (runtime.status === 'missing-model') {
|
||||
return {
|
||||
badge: 'Needs model',
|
||||
badgeClassName: 'bg-amber-500/10 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200',
|
||||
actionLabel: `Finish ${label} setup`,
|
||||
description:
|
||||
'The runtime is up, but the recommended local model still needs to be pulled or loaded.',
|
||||
footer: `Run \`${runtime.commandHint}\` to finish local setup.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
badge: 'Offline',
|
||||
badgeClassName: 'bg-muted text-muted-foreground',
|
||||
actionLabel: `Set up ${label}`,
|
||||
description:
|
||||
'Use this lane for privacy-sensitive work, cheap local transforms, and offline sessions.',
|
||||
footer: `Run \`${runtime.commandHint}\` to bring the local endpoint online.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function OpenRouterQuickStart({
|
||||
hasProfiles,
|
||||
profileCount,
|
||||
@@ -91,31 +161,37 @@ export function OpenRouterQuickStart({
|
||||
onAlibabaCodingPlanClick,
|
||||
onCliproxyClick,
|
||||
onCustomClick,
|
||||
onOllamaClick,
|
||||
onLlamacppClick,
|
||||
}: OpenRouterQuickStartProps) {
|
||||
const { t } = useTranslation();
|
||||
const { modelCount, isLoading } = useOpenRouterReady();
|
||||
const { data: localRuntimeData } = useLocalRuntimeReadiness();
|
||||
const modelCountLabel = isLoading ? '300+' : `${modelCount}+`;
|
||||
const profileSummaryLabel = hasProfiles
|
||||
? t('openrouterQuickStart.profileCount', { count: profileCount })
|
||||
: t('openrouterQuickStart.recommended');
|
||||
? `${profileCount} ${profileCount === 1 ? 'profile' : 'profiles'}`
|
||||
: 'Premium quality + local control';
|
||||
const summaryTitle = hasProfiles
|
||||
? t('openrouterQuickStart.selectProfileTitle')
|
||||
: t('apiProfiles.noProfilesYet');
|
||||
? 'Choose a profile or add another lane'
|
||||
: 'Choose your first profile lane';
|
||||
const summaryDescription = hasProfiles
|
||||
? t('openrouterQuickStart.summaryDescriptionWithProfiles', { count: profileCount })
|
||||
: t('openrouterQuickStart.summaryDescriptionNoProfiles');
|
||||
? `You already have ${profileCount} profile${profileCount === 1 ? '' : 's'} in this workspace. Keep premium-quality providers for serious coding and add local runtimes when privacy, cost, or offline work matters.`
|
||||
: 'Pick the lane that matches the work. Premium providers stay the default for reliable coding. Local runtimes are best for privacy, cheaper transforms, and experimentation.';
|
||||
const ollamaRuntime = localRuntimeData?.runtimes.find((runtime) => runtime.id === 'ollama');
|
||||
const llamacppRuntime = localRuntimeData?.runtimes.find((runtime) => runtime.id === 'llamacpp');
|
||||
const ollamaCardState = getLocalRuntimeCardState(ollamaRuntime, 'Ollama');
|
||||
const llamacppCardState = getLocalRuntimeCardState(llamacppRuntime, 'llama.cpp');
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-auto bg-muted/20 p-4 sm:p-6">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||
<Card className="border-dashed bg-background/90 shadow-sm">
|
||||
<CardContent className="flex flex-col gap-4 p-5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{profileSummaryLabel}</Badge>
|
||||
<Badge variant="outline">
|
||||
{t('openrouterQuickStart.openrouterModelsBadge', { modelCountLabel })}
|
||||
</Badge>
|
||||
<Badge variant="outline">Best quality by default</Badge>
|
||||
<Badge variant="outline">Local lane when task fit wins</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold">{summaryTitle}</h2>
|
||||
@@ -130,50 +206,171 @@ export function OpenRouterQuickStart({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<QuickStartCard
|
||||
badge={t('openrouterQuickStart.recommended')}
|
||||
title={t('openrouterQuickStart.title')}
|
||||
description={t('openrouterQuickStart.description', { modelCountLabel })}
|
||||
visual={
|
||||
<div className="rounded-lg bg-accent/10 p-2">
|
||||
<img src="/icons/openrouter.svg" alt="OpenRouter" className="h-5 w-5" />
|
||||
</div>
|
||||
}
|
||||
highlights={[
|
||||
{
|
||||
icon: <Zap className="h-3.5 w-3.5 text-accent" />,
|
||||
label: t('openrouterQuickStart.featureOneApi'),
|
||||
},
|
||||
{
|
||||
icon: <Sparkles className="h-3.5 w-3.5 text-accent" />,
|
||||
label: t('openrouterQuickStart.featureTierMapping'),
|
||||
},
|
||||
]}
|
||||
actionLabel={t('openrouterQuickStart.createOpenRouterProfile')}
|
||||
actionClassName="w-full bg-accent text-white hover:bg-accent/90"
|
||||
onAction={onOpenRouterClick}
|
||||
footer={
|
||||
<>
|
||||
{t('openrouterQuickStart.getApiKeyAt')}{' '}
|
||||
<a
|
||||
href="https://openrouter.ai/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-accent hover:underline"
|
||||
>
|
||||
openrouter.ai/keys
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BrainCircuit className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.12em] text-foreground/70">
|
||||
Best quality lanes
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<QuickStartCard
|
||||
badge={t('openrouterQuickStart.recommended')}
|
||||
title={t('openrouterQuickStart.title')}
|
||||
description={t('openrouterQuickStart.description', { modelCountLabel })}
|
||||
visual={
|
||||
<div className="rounded-lg bg-accent/10 p-2">
|
||||
<img src="/icons/openrouter.svg" alt="OpenRouter" className="h-5 w-5" />
|
||||
</div>
|
||||
}
|
||||
highlights={[
|
||||
{
|
||||
icon: <Zap className="h-3.5 w-3.5 text-accent" />,
|
||||
label: t('openrouterQuickStart.featureOneApi'),
|
||||
},
|
||||
{
|
||||
icon: <Sparkles className="h-3.5 w-3.5 text-accent" />,
|
||||
label: 'Best default lane for high-stakes coding quality',
|
||||
},
|
||||
]}
|
||||
actionLabel={t('openrouterQuickStart.createOpenRouterProfile')}
|
||||
actionClassName="w-full bg-accent text-white hover:bg-accent/90"
|
||||
onAction={onOpenRouterClick}
|
||||
footer={
|
||||
<>
|
||||
{t('openrouterQuickStart.getApiKeyAt')}{' '}
|
||||
<a
|
||||
href="https://openrouter.ai/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-accent hover:underline"
|
||||
>
|
||||
openrouter.ai/keys
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<QuickStartCard
|
||||
badge={t('alibabaCodingPlanQuickStart.recommended')}
|
||||
badgeClassName="bg-orange-500/10 text-orange-700 dark:bg-orange-500/20 dark:text-orange-200"
|
||||
title={t('alibabaCodingPlanQuickStart.title')}
|
||||
description="Strong direct coding profile when you want a dedicated premium lane outside the OpenRouter catalog."
|
||||
visual={
|
||||
<div className="rounded-lg bg-orange-500/10 p-2">
|
||||
<img
|
||||
src="/assets/providers/alibabacloud-color.svg"
|
||||
alt="Alibaba Coding Plan"
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
highlights={[
|
||||
{
|
||||
icon: <CloudCog className="h-3.5 w-3.5 text-orange-600" />,
|
||||
label: t('alibabaCodingPlanQuickStart.featureEndpoint'),
|
||||
},
|
||||
{
|
||||
icon: <KeyRound className="h-3.5 w-3.5 text-orange-600" />,
|
||||
label: 'Good when you want premium quality with a dedicated endpoint',
|
||||
},
|
||||
]}
|
||||
actionLabel={t('alibabaCodingPlanQuickStart.createAlibabaProfile')}
|
||||
actionClassName="w-full bg-orange-600 text-white hover:bg-orange-600/90"
|
||||
onAction={onAlibabaCodingPlanClick}
|
||||
footer={
|
||||
<>
|
||||
{t('alibabaCodingPlanQuickStart.readGuideAt')}{' '}
|
||||
<a
|
||||
href="https://www.alibabacloud.com/help/en/model-studio/coding-plan"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-orange-700 hover:underline dark:text-orange-400"
|
||||
>
|
||||
Alibaba Cloud Model Studio
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Laptop className="h-4 w-4 text-emerald-600" />
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.12em] text-foreground/70">
|
||||
Local runtimes
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<QuickStartCard
|
||||
badge={ollamaCardState.badge}
|
||||
badgeClassName={ollamaCardState.badgeClassName}
|
||||
title="Ollama + Gemma 4"
|
||||
description={ollamaCardState.description}
|
||||
visual={
|
||||
<div className="rounded-lg bg-emerald-500/10 p-2">
|
||||
<img src="/icons/ollama.svg" alt="Ollama" className="h-5 w-5" />
|
||||
</div>
|
||||
}
|
||||
highlights={[
|
||||
{
|
||||
icon: <HardDriveDownload className="h-3.5 w-3.5 text-emerald-600" />,
|
||||
label: 'Best for private prompts, local cleanup, and cheap batch transforms',
|
||||
},
|
||||
{
|
||||
icon: <Sparkles className="h-3.5 w-3.5 text-emerald-600" />,
|
||||
label:
|
||||
ollamaRuntime?.detectedModelCount && ollamaRuntime.detectedModelCount > 0
|
||||
? `${ollamaRuntime.detectedModelCount} local model${ollamaRuntime.detectedModelCount === 1 ? '' : 's'} detected`
|
||||
: 'No local models detected yet',
|
||||
},
|
||||
]}
|
||||
actionLabel={ollamaCardState.actionLabel}
|
||||
actionClassName="w-full bg-emerald-600 text-white hover:bg-emerald-600/90"
|
||||
onAction={onOllamaClick}
|
||||
footer={<span>{ollamaCardState.footer}</span>}
|
||||
/>
|
||||
|
||||
<QuickStartCard
|
||||
badge={llamacppCardState.badge}
|
||||
badgeClassName={llamacppCardState.badgeClassName}
|
||||
title="llama.cpp"
|
||||
description={llamacppCardState.description}
|
||||
visual={
|
||||
<div className="rounded-lg bg-sky-500/10 p-2">
|
||||
<img src="/assets/providers/llama-cpp.svg" alt="llama.cpp" className="h-5 w-5" />
|
||||
</div>
|
||||
}
|
||||
highlights={[
|
||||
{
|
||||
icon: <SlidersHorizontal className="h-3.5 w-3.5 text-sky-600" />,
|
||||
label: 'Best for custom GGUF setups and advanced self-hosted local workflows',
|
||||
},
|
||||
{
|
||||
icon: <Laptop className="h-3.5 w-3.5 text-sky-600" />,
|
||||
label:
|
||||
llamacppRuntime?.detectedModelCount && llamacppRuntime.detectedModelCount > 0
|
||||
? `${llamacppRuntime.detectedModelCount} local model${llamacppRuntime.detectedModelCount === 1 ? '' : 's'} detected`
|
||||
: 'Waiting for a local server and model list',
|
||||
},
|
||||
]}
|
||||
actionLabel={llamacppCardState.actionLabel}
|
||||
actionClassName="w-full bg-sky-600 text-white hover:bg-sky-600/90"
|
||||
onAction={onLlamacppClick}
|
||||
footer={<span>{llamacppCardState.footer}</span>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<QuickStartCard
|
||||
badge={t('openrouterQuickStart.runtimeProviderBadge')}
|
||||
badgeClassName="bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200"
|
||||
title={t('openrouterQuickStart.runtimeProviderTitle')}
|
||||
description={t('openrouterQuickStart.runtimeProviderDescription')}
|
||||
description="Use this when you need CLIProxy-managed connectors, provider secrets, and advanced routing controls."
|
||||
visual={
|
||||
<div className="rounded-lg bg-emerald-500/10 p-2">
|
||||
<SlidersHorizontal className="h-5 w-5 text-emerald-700 dark:text-emerald-300" />
|
||||
@@ -194,50 +391,6 @@ export function OpenRouterQuickStart({
|
||||
onAction={onCliproxyClick}
|
||||
footer={<span>{t('openrouterQuickStart.runtimeProviderFooter')}</span>}
|
||||
/>
|
||||
|
||||
<QuickStartCard
|
||||
badge={t('alibabaCodingPlanQuickStart.recommended')}
|
||||
badgeClassName="bg-orange-500/10 text-orange-700 dark:bg-orange-500/20 dark:text-orange-200"
|
||||
className="lg:col-span-2"
|
||||
title={t('alibabaCodingPlanQuickStart.title')}
|
||||
description={t('alibabaCodingPlanQuickStart.description')}
|
||||
visual={
|
||||
<div className="rounded-lg bg-orange-500/10 p-2">
|
||||
<img
|
||||
src="/assets/providers/alibabacloud-color.svg"
|
||||
alt="Alibaba Coding Plan"
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
highlights={[
|
||||
{
|
||||
icon: <CloudCog className="h-3.5 w-3.5 text-orange-600" />,
|
||||
label: t('alibabaCodingPlanQuickStart.featureEndpoint'),
|
||||
},
|
||||
{
|
||||
icon: <KeyRound className="h-3.5 w-3.5 text-orange-600" />,
|
||||
label: t('alibabaCodingPlanQuickStart.featureKeyFormat'),
|
||||
},
|
||||
]}
|
||||
actionLabel={t('alibabaCodingPlanQuickStart.createAlibabaProfile')}
|
||||
actionClassName="w-full bg-orange-600 text-white hover:bg-orange-600/90"
|
||||
onAction={onAlibabaCodingPlanClick}
|
||||
footer={
|
||||
<>
|
||||
{t('alibabaCodingPlanQuickStart.readGuideAt')}{' '}
|
||||
<a
|
||||
href="https://www.alibabacloud.com/help/en/model-studio/coding-plan"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-orange-700 hover:underline dark:text-orange-400"
|
||||
>
|
||||
Alibaba Cloud Model Studio
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,13 +80,14 @@ interface ProfileCreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: (name: string) => void;
|
||||
initialMode?: 'normal' | 'openrouter' | 'alibaba-coding-plan';
|
||||
initialMode?: ProviderPreset['id'] | 'normal';
|
||||
}
|
||||
|
||||
// Common URL mistakes to warn about
|
||||
const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions'];
|
||||
const CUSTOM_PRESET_ID = 'custom';
|
||||
const DEFAULT_PRESET_ID: ProviderPreset['id'] = 'openrouter';
|
||||
const LOCAL_PRESET_IDS = new Set<ProviderPreset['id']>(['ollama', 'llamacpp']);
|
||||
|
||||
const EMPTY_FORM_VALUES: FormData = {
|
||||
name: '',
|
||||
@@ -100,6 +101,12 @@ const EMPTY_FORM_VALUES: FormData = {
|
||||
};
|
||||
|
||||
const RECOMMENDED_PRESETS = getPresetsByCategory('recommended');
|
||||
const FEATURED_PREMIUM_PRESETS = RECOMMENDED_PRESETS.filter(
|
||||
(preset) => !LOCAL_PRESET_IDS.has(preset.id)
|
||||
);
|
||||
const LOCAL_RUNTIME_PRESETS = RECOMMENDED_PRESETS.filter((preset) =>
|
||||
LOCAL_PRESET_IDS.has(preset.id)
|
||||
);
|
||||
const QUICK_TEMPLATE_PRESETS = PROVIDER_PRESETS.filter(
|
||||
(preset) => preset.category !== 'recommended'
|
||||
);
|
||||
@@ -314,7 +321,7 @@ export function ProfileCreateDialog({
|
||||
</Label>
|
||||
<div className="-mx-1 overflow-x-auto pb-1">
|
||||
<div className="flex min-w-max items-stretch gap-2 px-1">
|
||||
{RECOMMENDED_PRESETS.map((preset) => (
|
||||
{FEATURED_PREMIUM_PRESETS.map((preset) => (
|
||||
<CompactPresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
@@ -350,6 +357,23 @@ export function ProfileCreateDialog({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70">
|
||||
Local Runtimes
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{LOCAL_RUNTIME_PRESETS.map((preset) => (
|
||||
<CompactPresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPreset === preset.id}
|
||||
onClick={() => handlePresetSelect(preset.id)}
|
||||
density="compact"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ const resources = {
|
||||
home: 'Home',
|
||||
analytics: 'Analytics',
|
||||
identityAccess: 'Identity & Access',
|
||||
apiProfiles: 'API Profiles',
|
||||
apiProfiles: 'Profiles',
|
||||
cliproxyPlus: 'CLIProxy Plus',
|
||||
cliproxyOverview: 'Overview',
|
||||
controlPanel: 'Control Panel',
|
||||
@@ -1017,7 +1017,7 @@ const resources = {
|
||||
tokensSubtitle: '{{value}} tokens',
|
||||
},
|
||||
apiProfiles: {
|
||||
title: 'API Profiles',
|
||||
title: 'Profiles',
|
||||
new: 'New',
|
||||
searchPlaceholder: 'Search profiles...',
|
||||
loadingProfiles: 'Loading profiles...',
|
||||
|
||||
+19
-6
@@ -34,6 +34,7 @@ import {
|
||||
import { useOpenRouterModels } from '@/hooks/use-openrouter-models';
|
||||
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
||||
import type { ApiProfileExportBundle, Profile } from '@/lib/api-client';
|
||||
import type { ProviderPreset } from '@/lib/provider-presets';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -53,9 +54,7 @@ export function ApiPage() {
|
||||
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [createMode, setCreateMode] = useState<'normal' | 'openrouter' | 'alibaba-coding-plan'>(
|
||||
'normal'
|
||||
);
|
||||
const [createMode, setCreateMode] = useState<ProviderPreset['id'] | 'normal'>('normal');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [editorHasChanges, setEditorHasChanges] = useState(false);
|
||||
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
|
||||
@@ -211,12 +210,14 @@ export function ApiPage() {
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
<div className="w-80 border-r flex flex-col bg-muted/30">
|
||||
<div className="p-4 border-b bg-background">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-5 h-5 text-primary" />
|
||||
<h1 className="font-semibold">{t('apiProfiles.title')}</h1>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold">Profiles</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -251,6 +252,10 @@ export function ApiPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mb-3 text-xs leading-4 text-muted-foreground">
|
||||
Premium APIs, local runtimes, custom endpoints
|
||||
</p>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -400,6 +405,14 @@ export function ApiPage() {
|
||||
setCreateMode('alibaba-coding-plan');
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
onOllamaClick={() => {
|
||||
setCreateMode('ollama');
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
onLlamacppClick={() => {
|
||||
setCreateMode('llamacpp');
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
onCustomClick={() => {
|
||||
setCreateMode('normal');
|
||||
setCreateDialogOpen(true);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start';
|
||||
import { render, screen } from '@tests/setup/test-utils';
|
||||
import { render, screen, userEvent } from '@tests/setup/test-utils';
|
||||
|
||||
vi.mock('@/hooks/use-openrouter-models', () => ({
|
||||
useOpenRouterReady: () => ({
|
||||
@@ -10,12 +10,40 @@ vi.mock('@/hooks/use-openrouter-models', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-local-runtime-readiness', () => ({
|
||||
useLocalRuntimeReadiness: () => ({
|
||||
data: {
|
||||
runtimes: [
|
||||
{
|
||||
id: 'ollama',
|
||||
status: 'offline',
|
||||
endpoint: 'http://127.0.0.1:11434',
|
||||
commandHint: 'ollama serve',
|
||||
recommendedModel: 'gemma4:e4b',
|
||||
recommendedModelInstalled: false,
|
||||
},
|
||||
{
|
||||
id: 'llamacpp',
|
||||
status: 'ready',
|
||||
endpoint: 'http://127.0.0.1:8080',
|
||||
commandHint: './server --host 0.0.0.0 --port 8080 -m model.gguf',
|
||||
recommendedModel: null,
|
||||
recommendedModelInstalled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('OpenRouterQuickStart', () => {
|
||||
const props = {
|
||||
onOpenRouterClick: vi.fn(),
|
||||
onAlibabaCodingPlanClick: vi.fn(),
|
||||
onCliproxyClick: vi.fn(),
|
||||
onCustomClick: vi.fn(),
|
||||
onOllamaClick: vi.fn(),
|
||||
onLlamacppClick: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -25,16 +53,28 @@ describe('OpenRouterQuickStart', () => {
|
||||
it('prompts the user to select an existing API profile instead of showing the empty-state copy', () => {
|
||||
render(<OpenRouterQuickStart hasProfiles profileCount={1} {...props} />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Select an API profile' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Choose a profile or add another lane' })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('1 profile')).toBeInTheDocument();
|
||||
expect(screen.getByText(/You already have 1 profile in this workspace\./)).toBeInTheDocument();
|
||||
expect(screen.queryByText('No API profiles yet')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the original empty-state title when no API profiles exist', () => {
|
||||
it('shows the new local lane with readiness-aware calls to action', async () => {
|
||||
render(<OpenRouterQuickStart hasProfiles={false} profileCount={0} {...props} />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'No API profiles yet' })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Recommended')).not.toHaveLength(0);
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Choose your first profile lane' })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Ollama + Gemma 4')).toBeInTheDocument();
|
||||
expect(screen.getByText('llama.cpp')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Run `ollama serve` to bring the local endpoint online.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Endpoint ready at http://127.0.0.1:8080')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Set up Ollama' }));
|
||||
expect(props.onOllamaClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('ProfileCreateDialog', () => {
|
||||
mutateAsync.mockReset();
|
||||
});
|
||||
|
||||
it('keeps advanced presets collapsed until explicitly opened and deselects custom after choosing a template', async () => {
|
||||
it('keeps More Presets visible by default and deselects custom after choosing a template', async () => {
|
||||
render(
|
||||
<ProfileCreateDialog
|
||||
open
|
||||
@@ -44,22 +44,14 @@ describe('ProfileCreateDialog', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText('Featured Providers')).toBeInTheDocument();
|
||||
expect(screen.getByText('More Presets')).toBeInTheDocument();
|
||||
expect(screen.getByText('Local Runtimes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alibaba Coding Plan')).toBeVisible();
|
||||
const morePresetsToggle = screen.getByRole('button', { name: /More Presets/i });
|
||||
expect(morePresetsToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(1);
|
||||
expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(2);
|
||||
|
||||
const customButton = screen.getByRole('button', { name: /Custom Endpoint/i });
|
||||
await userEvent.click(customButton);
|
||||
|
||||
expect(morePresetsToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(1);
|
||||
|
||||
await userEvent.click(morePresetsToggle);
|
||||
|
||||
expect(morePresetsToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(2);
|
||||
|
||||
const glmButton = screen.getByText('GLM').closest('button');
|
||||
expect(glmButton).not.toBeNull();
|
||||
if (!glmButton) {
|
||||
@@ -67,10 +59,19 @@ describe('ProfileCreateDialog', () => {
|
||||
}
|
||||
await userEvent.click(glmButton);
|
||||
|
||||
expect(morePresetsToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await waitFor(() => {
|
||||
expect(customButton).not.toHaveClass('border-primary');
|
||||
});
|
||||
expect(glmButton).toHaveClass('border-primary');
|
||||
});
|
||||
|
||||
it('supports opening directly into the Ollama preset from the providers landing page', async () => {
|
||||
render(
|
||||
<ProfileCreateDialog open onOpenChange={vi.fn()} onSuccess={vi.fn()} initialMode="ollama" />
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue('ollama')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('http://localhost:11434')).toBeInTheDocument();
|
||||
expect(screen.getByText('Local Runtimes')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user