From ad70f93b1b95edb8d6f029a7613a44f3d2db83ab Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 05:50:26 -0400 Subject: [PATCH] feat(ui): reposition providers around quality and local lanes --- .../profiles/openrouter-quick-start.tsx | 339 +++++++++++++----- .../profiles/profile-create-dialog.tsx | 28 +- ui/src/lib/i18n.ts | 4 +- ui/src/pages/api.tsx | 25 +- .../profiles/openrouter-quick-start.test.tsx | 50 ++- .../profiles/profile-create-dialog.test.tsx | 27 +- 6 files changed, 352 insertions(+), 121 deletions(-) diff --git a/ui/src/components/profiles/openrouter-quick-start.tsx b/ui/src/components/profiles/openrouter-quick-start.tsx index 9fc4e746..4e273f6e 100644 --- a/ui/src/components/profiles/openrouter-quick-start.tsx +++ b/ui/src/components/profiles/openrouter-quick-start.tsx @@ -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 (
-
+
{profileSummaryLabel} - - {t('openrouterQuickStart.openrouterModelsBadge', { modelCountLabel })} - + Best quality by default + Local lane when task fit wins

{summaryTitle}

@@ -130,50 +206,171 @@ export function OpenRouterQuickStart({ -
- - OpenRouter -
- } - highlights={[ - { - icon: , - label: t('openrouterQuickStart.featureOneApi'), - }, - { - icon: , - 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')}{' '} - - openrouter.ai/keys - - - - } - /> +
+
+ +

+ Best quality lanes +

+
+
+ + OpenRouter +
+ } + highlights={[ + { + icon: , + label: t('openrouterQuickStart.featureOneApi'), + }, + { + icon: , + 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')}{' '} + + openrouter.ai/keys + + + + } + /> + + Alibaba Coding Plan +
+ } + highlights={[ + { + icon: , + label: t('alibabaCodingPlanQuickStart.featureEndpoint'), + }, + { + icon: , + 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')}{' '} + + Alibaba Cloud Model Studio + + + + } + /> +
+
+ +
+
+ +

+ Local runtimes +

+
+
+ + Ollama +
+ } + highlights={[ + { + icon: , + label: 'Best for private prompts, local cleanup, and cheap batch transforms', + }, + { + icon: , + 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={{ollamaCardState.footer}} + /> + + + llama.cpp +
+ } + highlights={[ + { + icon: , + label: 'Best for custom GGUF setups and advanced self-hosted local workflows', + }, + { + icon: , + 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={{llamacppCardState.footer}} + /> +
+
+ +
@@ -194,50 +391,6 @@ export function OpenRouterQuickStart({ onAction={onCliproxyClick} footer={{t('openrouterQuickStart.runtimeProviderFooter')}} /> - - - Alibaba Coding Plan -
- } - highlights={[ - { - icon: , - label: t('alibabaCodingPlanQuickStart.featureEndpoint'), - }, - { - icon: , - 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')}{' '} - - Alibaba Cloud Model Studio - - - - } - />
diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index 8a67a7cf..eabf55f6 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -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(['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({
- {RECOMMENDED_PRESETS.map((preset) => ( + {FEATURED_PREMIUM_PRESETS.map((preset) => (
+ +
+ +
+ {LOCAL_RUNTIME_PRESETS.map((preset) => ( + handlePresetSelect(preset.id)} + density="compact" + /> + ))} +
+
diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 4d695cd7..e707c9da 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -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...', diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 2a653f87..425b1316 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -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(null); const [searchQuery, setSearchQuery] = useState(''); const [isCreateDialogOpen, setCreateDialogOpen] = useState(false); - const [createMode, setCreateMode] = useState<'normal' | 'openrouter' | 'alibaba-coding-plan'>( - 'normal' - ); + const [createMode, setCreateMode] = useState('normal'); const [deleteConfirm, setDeleteConfirm] = useState(null); const [editorHasChanges, setEditorHasChanges] = useState(false); const [pendingSwitch, setPendingSwitch] = useState(null); @@ -211,12 +210,14 @@ export function ApiPage() {
-
+
-

{t('apiProfiles.title')}

+
+

Profiles

+
-
+
+

+ Premium APIs, local runtimes, custom endpoints +

+
{ + setCreateMode('ollama'); + setCreateDialogOpen(true); + }} + onLlamacppClick={() => { + setCreateMode('llamacpp'); + setCreateDialogOpen(true); + }} onCustomClick={() => { setCreateMode('normal'); setCreateDialogOpen(true); diff --git a/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx b/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx index 2fdd1394..feb98f66 100644 --- a/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx +++ b/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx @@ -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(); - 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(); - 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); }); }); diff --git a/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx b/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx index 2a1fdd9e..2919fe84 100644 --- a/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx +++ b/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx @@ -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( { ); 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( + + ); + + expect(screen.getByDisplayValue('ollama')).toBeInTheDocument(); + expect(screen.getByDisplayValue('http://localhost:11434')).toBeInTheDocument(); + expect(screen.getByText('Local Runtimes')).toBeInTheDocument(); + }); });