From e8c837c921e04c573c0d313bf8539c283a9fba49 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 6 Jun 2026 09:31:34 -0700 Subject: [PATCH] feat(desktop): surface every provider + models from `hermes model` in the GUI menus (#40563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): surface every provider + models from `hermes model` in the GUI The desktop GUI's model/provider choices were starved relative to the `hermes model` CLI. Onboarding listed ~8 providers, Settings → Model only showed authenticated ones, because the global `/api/model/options` endpoint called build_models_payload() without the full-universe flags the TUI's model.options JSON-RPC already used. - web_server.py: `/api/model/options` now passes include_unconfigured + picker_hints + canonical_order (matching the TUI handler), so every GUI surface fed by it sees all 37 canonical providers with auth hints. - Settings → Model: provider dropdown lists every provider; picking an unconfigured api_key provider shows an inline 'paste key → Activate' flow (auto-selects the recommended default); OAuth/external route to onboarding. - Onboarding: the API-key form is now driven by the full provider catalog (curated five first, then the rest), not a hand-maintained list of five. - types/hermes.ts: ModelOptionProvider gains authenticated/auth_type/key_env. - Tests: model-settings covers the full-universe list + inline activation; fixed a pre-existing stale assertion (nous / hermes-4 was never rendered). * feat(desktop): /model in GUI chat opens the model picker instead of a dead-end notice Typing /model in a desktop chat session printed "/model uses the desktop model picker instead of a slash command" and did nothing — it never opened the picker. (The slash worker can't render the prompt_toolkit modal /model opens in the CLI, so the desktop just showed the unavailable-notice.) - use-prompt-actions.ts: intercept /model client-side. No args → open the desktop model picker overlay (setModelPickerOpen) — the same full provider+model picker as the status-bar button. With args (/model [--provider ...]) → run the switch directly via slash.exec so power users can still type it. - desktop-slash-commands.ts: export isModelPickerCommand() so the hook can detect picker-owned commands without duplicating the PICKER_OWNED_COMMANDS set. - Test: covers isModelPickerCommand for /model (+ args) vs non-picker commands. * fix(desktop): make onboarding provider lists scrollable + clean up card styling The full-catalog onboarding picker could overflow the modal with no way to scroll — the OAuth provider list and the api-key grid both grew past the viewport, hiding the key input and the bottom action row (overflow-hidden card, no scroll container). - Scope a `max-h-[60dvh] overflow-y-auto` region to just the provider list / api-key card grid; the "other providers" disclosure, key input, and action row stay pinned and reachable. - Inner `p-1` so card borders / focus rings aren't clipped by the scroll viewport. - Flatter card styling: drop the persistent border, the redundant selected-state checkmark, and the modal shadow — selection now reads from the ring alone (the muted "already configured" check stays). - Remove the " — set up" suffix from the Settings → Model provider dropdown; the inline setup flow already signals unconfigured providers. * fix(desktop): identify api-key onboarding cards by env var, not id Selecting "Google Gemini" also highlighted "Google AI Studio": the curated catalog and the backend-derived providers can collide on `id` (a provider slug can equal a curated id like `gemini`), so `option.id === o.id` matched two cards at once. Key selection (and the React key + snap-back effect) on `envKey` instead, which the catalog dedups and is therefore unique per card. --------- Co-authored-by: Brooklyn Nicholson --- .../app/session/hooks/use-prompt-actions.ts | 53 +++++- .../src/app/settings/model-settings.test.tsx | 69 ++++++- .../src/app/settings/model-settings.tsx | 174 +++++++++++++++--- .../components/desktop-onboarding-overlay.tsx | 139 ++++++++++---- .../src/lib/desktop-slash-commands.test.ts | 10 +- .../desktop/src/lib/desktop-slash-commands.ts | 12 ++ apps/desktop/src/types/hermes.ts | 12 ++ hermes_cli/web_server.py | 16 +- 8 files changed, 411 insertions(+), 74 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index e31a0ce07..f68b43299 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -2,8 +2,8 @@ import type { AppendMessage, ThreadMessage } from '@assistant-ui/react' import { type MutableRefObject, useCallback } from 'react' import { getProfiles, transcribeAudio } from '@/hermes' -import { type Translations, translateNow, useI18n } from '@/i18n' -import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages' +import { translateNow, type Translations, useI18n } from '@/i18n' +import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages' import { attachmentDisplayText, parseCommandDispatch, @@ -15,7 +15,8 @@ import { type CommandsCatalogLike, desktopSlashUnavailableMessage, filterDesktopCommandsCatalog, - isDesktopSlashCommand + isDesktopSlashCommand, + isModelPickerCommand } from '@/lib/desktop-slash-commands' import { triggerHaptic } from '@/lib/haptics' import { setMutableRef } from '@/lib/mutable-ref' @@ -38,6 +39,7 @@ import { setAwaitingResponse, setBusy, setMessages, + setModelPickerOpen, setSessions, setYoloActive } from '@/store/session' @@ -159,6 +161,7 @@ export function usePromptActions({ }: PromptActionsOptions) { const { t } = useI18n() const copy = t.desktop + const appendSessionTextMessage = useCallback( (sessionId: string, role: ChatMessage['role'], text: string) => { const body = text.trim() @@ -454,6 +457,49 @@ export function usePromptActions({ return } + // /model opens the desktop model picker overlay — the same full + // provider+model picker reachable from the status-bar model button — + // instead of the headless prompt_toolkit modal the slash worker can't + // render. With explicit args (`/model [--provider ...]`) run the + // switch directly through slash.exec so power users can still type it. + if (isModelPickerCommand(`/${normalizedName}`)) { + if (!arg.trim()) { + setModelPickerOpen(true) + + return + } + + const sid = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend()) + + if (!sid) { + notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' }) + + return + } + + try { + const result = await requestGateway('slash.exec', { + session_id: sid, + command: command.replace(/^\/+/, '') + }) + + const body = result?.output || `/${name}: model switched` + appendSessionTextMessage( + sid, + 'system', + recordInput ? slashStatusText(command, body) : body + ) + } catch (err) { + appendSessionTextMessage( + sid, + 'system', + `error: ${err instanceof Error ? err.message : String(err)}` + ) + } + + return + } + if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) { notify({ kind: 'success', message: handleSkinCommand(arg) }) @@ -547,6 +593,7 @@ export function usePromptActions({ session_id: sessionId, title: arg }) + const finalTitle = (result?.title || arg).trim() const queued = result?.pending === true diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index 177b60274..a0b1afdc9 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -1,28 +1,52 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +// Radix Select calls scrollIntoView on its items when the content opens; jsdom +// doesn't implement it (nor hasPointerCapture / releasePointerCapture), so stub +// them to let the dropdown open in tests. +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn() + Element.prototype.hasPointerCapture = vi.fn(() => false) + Element.prototype.releasePointerCapture = vi.fn() +}) const getGlobalModelInfo = vi.fn() const getGlobalModelOptions = vi.fn() const getAuxiliaryModels = vi.fn() const setModelAssignment = vi.fn() +const getRecommendedDefaultModel = vi.fn() +const setEnvVar = vi.fn() +const startManualProviderOAuth = vi.fn() vi.mock('@/hermes', () => ({ getGlobalModelInfo: () => getGlobalModelInfo(), getGlobalModelOptions: () => getGlobalModelOptions(), getAuxiliaryModels: () => getAuxiliaryModels(), - setModelAssignment: (body: unknown) => setModelAssignment(body) + setModelAssignment: (body: unknown) => setModelAssignment(body), + getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug), + setEnvVar: (key: string, value: string) => setEnvVar(key, value) +})) + +vi.mock('@/store/onboarding', () => ({ + startManualProviderOAuth: (slug: string) => startManualProviderOAuth(slug) })) beforeEach(() => { getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' }) getGlobalModelOptions.mockResolvedValue({ - providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'] }] + providers: [ + { name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'], authenticated: true }, + // An unconfigured api_key provider — surfaced by the full-universe payload. + { name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' } + ] }) getAuxiliaryModels.mockResolvedValue({ main: { provider: 'nous', model: 'hermes-4' }, tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }] }) setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] }) + getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null }) + setEnvVar.mockResolvedValue({ ok: true }) }) afterEach(() => { @@ -37,14 +61,43 @@ async function renderModelSettings() { } describe('ModelSettings', () => { - it('loads and shows the current main model', async () => { + it('loads the current main model and lists the full provider universe', async () => { await renderModelSettings() await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled()) - // The current model is loaded into the main-slot selectors (provider name - // + model id), not a standalone label. - expect(await screen.findByText('Nous')).toBeTruthy() - expect(screen.getByText('hermes-4')).toBeTruthy() + await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled()) + + // Open the provider Select — every provider from the full payload should be + // listed, including the unconfigured one with its "set up" hint. + const triggers = await screen.findAllByRole('combobox') + fireEvent.click(triggers[0]) + + // "Nous" shows in both the trigger and the open list; the unconfigured + // provider + its setup hint are the unique signal of the full universe. + expect((await screen.findAllByText('Nous')).length).toBeGreaterThan(0) + expect(await screen.findByText(/DeepSeek/)).toBeTruthy() + expect(await screen.findByText(/set up/)).toBeTruthy() + }) + + it('activates an unconfigured api_key provider inline by saving its key', async () => { + await renderModelSettings() + + await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled()) + + // Open the provider Select and pick the unconfigured provider. + const triggers = screen.getAllByRole('combobox') + fireEvent.click(triggers[0]) + const deepseekOption = await screen.findByText(/DeepSeek/) + fireEvent.click(deepseekOption) + + // The inline key input appears for an api_key provider that needs setup. + const keyInput = await screen.findByPlaceholderText(/Paste DEEPSEEK_API_KEY/) + fireEvent.change(keyInput, { target: { value: 'sk-test-123' } }) + + const activate = await screen.findByRole('button', { name: /Activate/ }) + fireEvent.click(activate) + + await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123')) }) it('renders the auxiliary task rows', async () => { diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 642fba5bf..4a7ffcfd9 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -1,16 +1,33 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes' +import { + getAuxiliaryModels, + getGlobalModelInfo, + getGlobalModelOptions, + getRecommendedDefaultModel, + setEnvVar, + setModelAssignment +} from '@/hermes' import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment } from '@/hermes' import { useI18n } from '@/i18n' import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons' import { cn } from '@/lib/utils' +import { startManualProviderOAuth } from '@/store/onboarding' import { CONTROL_TEXT } from './constants' import { ListRow, LoadingState, Pill, SectionHeading } from './primitives' +// A provider row is "ready" to pick a model from when it reports models. The +// backend now surfaces the full `hermes model` universe (every canonical +// provider), so unconfigured providers come back with `authenticated:false` +// and an empty `models` list — those need a setup step before a model exists. +function isProviderReady(p?: ModelOptionProvider): boolean { + return !!p && (p.authenticated !== false || (p.models?.length ?? 0) > 0) +} + // Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and // hints make the assignments readable; raw task keys (vision, mcp, …) are // opaque to most users. @@ -86,6 +103,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { // Aux slots reported stale by the backend immediately after a main-model // switch (provider differs from the new main). Cleared on next switch/reset. const [switchStaleAux, setSwitchStaleAux] = useState([]) + // Inline API-key entry for picking an unconfigured `api_key` provider in + // place — mirrors the onboarding ApiKeyForm but scoped to the model picker. + const [apiKeyDraft, setApiKeyDraft] = useState('') + const [activating, setActivating] = useState(false) const refresh = useCallback(async () => { setLoading(true) @@ -116,11 +137,24 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { const providerOptions = providers.length ? providers : NO_PROVIDERS - const selectedProviderModels = useMemo( - () => providers.find(provider => provider.slug === selectedProvider)?.models ?? [], + const selectedProviderRow = useMemo( + () => providers.find(provider => provider.slug === selectedProvider), [providers, selectedProvider] ) + const selectedProviderModels = selectedProviderRow?.models ?? [] + + // An unconfigured provider was picked: no credentials yet, so there are no + // models to choose. `api_key` providers can be activated inline (paste key); + // OAuth / external flows hand off to the onboarding sign-in. + const needsSetup = !!selectedProvider && !isProviderReady(selectedProviderRow) + const setupIsApiKey = needsSetup && selectedProviderRow?.auth_type === 'api_key' && !!selectedProviderRow?.key_env + + // Clear any half-typed key when switching provider so it can't leak across. + useEffect(() => { + setApiKeyDraft('') + }, [selectedProvider]) + const auxDraftProviderModels = useMemo( () => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [], [auxDraft.provider, providers] @@ -133,17 +167,70 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { // "I pinned aux months ago and forgot, now it bills a dead provider" case. const persistentStaleAux = useMemo(() => { const mainProvider = (mainModel?.provider ?? '').toLowerCase() + if (!mainProvider || !auxiliary) { return [] } + return auxiliary.tasks .filter(entry => { const p = (entry.provider ?? '').toLowerCase() + return p && p !== 'auto' && p !== mainProvider }) .map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model })) }, [auxiliary, mainModel]) + // Paste an API key for the selected `api_key` provider, persist it, then + // refresh so the now-authenticated provider's models populate. Auto-selects + // the recommended default model so the user can Apply in one more click. + const activateApiKeyProvider = useCallback(async () => { + const keyEnv = selectedProviderRow?.key_env + const slug = selectedProviderRow?.slug + + if (!keyEnv || !slug || !apiKeyDraft.trim()) { + return + } + + setActivating(true) + setError('') + + try { + await setEnvVar(keyEnv, apiKeyDraft.trim()) + setApiKeyDraft('') + + // Pick a sensible default for the freshly-activated provider (mirrors + // `hermes model` curation). Best-effort — fall through to the refreshed + // model list if it fails. + let nextModel = '' + + try { + const rec = await getRecommendedDefaultModel(slug) + nextModel = rec.model || '' + } catch { + nextModel = '' + } + + const options = await getGlobalModelOptions() + setProviders(options.providers || []) + const refreshedRow = options.providers?.find(p => p.slug === slug) + const fallbackModel = refreshedRow?.models?.[0] ?? '' + setSelectedModel(nextModel || fallbackModel) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setActivating(false) + } + }, [apiKeyDraft, selectedProviderRow]) + + // OAuth / external providers can't be activated with a pasted key — hand off + // to the shared onboarding flow scoped to this provider's real sign-in. + const startProviderSetup = useCallback(() => { + if (selectedProviderRow?.slug) { + startManualProviderOAuth(selectedProviderRow.slug) + } + }, [selectedProviderRow]) + const applyMainModel = useCallback(async () => { if (!selectedProvider || !selectedModel) { return @@ -271,27 +358,68 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { ))} - - + {needsSetup ? ( + setupIsApiKey ? ( + <> + setApiKeyDraft(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + void activateApiKeyProvider() + } + }} + placeholder={`Paste ${selectedProviderRow?.key_env ?? 'API key'}`} + type="password" + value={apiKeyDraft} + /> + + + ) : ( + + ) + ) : ( + <> + + + + )} + {needsSetup && !setupIsApiKey && ( +

+ {selectedProviderRow?.auth_type === 'api_key' + ? `${selectedProviderRow?.name} needs an API key — set it up to choose a model.` + : `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`} +

+ )} {error &&
{error}
} {switchStaleAux.length > 0 && (
diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index e35eb5829..3736d2d95 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -43,7 +43,7 @@ import { startProviderOAuth, submitOnboardingCode } from '@/store/onboarding' -import type { OAuthProvider } from '@/types/hermes' +import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes' interface DesktopOnboardingOverlayProps { enabled: boolean @@ -95,6 +95,74 @@ const API_KEY_OPTIONS: ApiKeyOption[] = [ } ] +// Build the FULL API-key provider catalog from the backend model options so the +// onboarding / Providers key form lists every `api_key` provider `hermes model` +// knows about — not just the hand-curated five. Curated entries keep their +// richer copy + placeholders and float to the top (recommended defaults); every +// other api_key provider is appended with a generic "paste {KEY}" affordance. +// OAuth / external providers are intentionally excluded here — they go through +// the OAuth picker / sign-in flow, not a pasted key. +function useApiKeyCatalog(): ApiKeyOption[] { + const [rows, setRows] = useState([]) + + useEffect(() => { + let cancelled = false + + // Best-effort — on failure the curated defaults still render. Wrapped in + // Promise.resolve().then so a synchronous throw (e.g. no desktop bridge in + // tests) is funneled into the same .catch instead of escaping. + void Promise.resolve() + .then(() => getGlobalModelOptions()) + .then(res => { + if (!cancelled) { + setRows(res.providers ?? []) + } + }) + .catch(() => { + // Ignore — fall back to the curated API_KEY_OPTIONS only. + }) + + return () => { + cancelled = true + } + }, []) + + return useMemo(() => { + const curatedByEnv = new Map(API_KEY_OPTIONS.map(o => [o.envKey, o])) + const derived: ApiKeyOption[] = [] + const seenEnv = new Set(API_KEY_OPTIONS.map(o => o.envKey)) + + for (const row of rows) { + // Only api_key providers can be activated with a pasted key. Skip OAuth / + // external / managed flows and anything missing an env var to write to. + if (row.auth_type && row.auth_type !== 'api_key') { + continue + } + + const envKey = row.key_env + + if (!envKey || seenEnv.has(envKey)) { + continue + } + + seenEnv.add(envKey) + derived.push({ + id: row.slug, + name: row.name, + envKey, + description: `Direct API access to ${row.name}.`, + docsUrl: '' + }) + } + + // Curated first (recommended order), then the rest alphabetically so the + // long tail is scannable. + derived.sort((a, b) => a.name.localeCompare(b.name)) + + return [...API_KEY_OPTIONS.filter(o => curatedByEnv.has(o.envKey)), ...derived] + }, [rows]) +} + const PROVIDER_DISPLAY: Record = { nous: { order: 0, title: 'Nous Portal' }, 'openai-codex': { order: 1, title: 'OpenAI OAuth (ChatGPT)' }, @@ -193,7 +261,7 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway return (
-
+
{onboarding.manual ? (
-

- {t.onboarding.flowSubtitles[provider.flow]} -

+

{t.onboarding.flowSubtitles[provider.flow]}

@@ -521,12 +589,12 @@ export function ApiKeyForm({ // Providers page wiring its search into this grid). Keep the selection valid // by snapping back to the first remaining option when the current one drops. useEffect(() => { - if (options.length > 0 && !options.some(o => o.id === option.id)) { + if (options.length > 0 && !options.some(o => o.envKey === option.envKey)) { setOption(options[0]) setValue('') setError(null) } - }, [option.id, options]) + }, [option.envKey, options]) // The catalog grid can be tall, leaving the entry field far below the fold. // On selection we scroll the field into view and focus it so it's always // obvious where to paste next. @@ -584,29 +652,23 @@ export function ApiKeyForm({ ) : null} -
+
{options.map(o => ( ))} @@ -624,7 +686,8 @@ export function ApiKeyForm({ onChange={e => setValue(e.target.value)} onKeyDown={e => e.key === 'Enter' && void submit()} placeholder={ - currentRedacted ?? (alreadySet ? t.onboarding.replaceCurrent : option.placeholder || t.onboarding.pasteApiKey) + currentRedacted ?? + (alreadySet ? t.onboarding.replaceCurrent : option.placeholder || t.onboarding.pasteApiKey) } type={isLocal ? 'text' : 'password'} value={value} @@ -717,9 +780,7 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow if (flow.status === 'awaiting_browser') { return ( -

- {t.onboarding.autoBrowser(title)} -

+

{t.onboarding.autoBrowser(title)}

{t.onboarding.reopenSignInPage}}> @@ -734,12 +795,14 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow if (flow.status === 'external_pending') { return ( -

- {t.onboarding.externalPending(title)} -

+

{t.onboarding.externalPending(title)}

void copyExternalCommand()} text={flow.provider.cli_command} /> {t.onboarding.docs(title)} : null} + left={ + flow.provider.docs_url ? ( + {t.onboarding.docs(title)} + ) : null + } >