mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
feat(desktop): surface every provider + models from hermes model in the GUI menus (#40563)
* 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 <name> [--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 <brooklyn.bb.nicholson@gmail.com>
This commit is contained in:
parent
3606307339
commit
e8c837c921
8 changed files with 411 additions and 74 deletions
|
|
@ -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 <name> [--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<SlashExecResponse>('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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<StaleAuxAssignment[]>([])
|
||||
// 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<StaleAuxAssignment[]>(() => {
|
||||
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) {
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select onValueChange={setSelectedModel} value={selectedModel}>
|
||||
<SelectTrigger className={cn('min-w-60', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder={m.model} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(selectedProviderModels.length ? selectedProviderModels : []).map(model => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
disabled={!selectedProvider || !selectedModel || applying}
|
||||
onClick={() => void applyMainModel()}
|
||||
size="sm"
|
||||
>
|
||||
{applying && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{applying ? m.applying : t.common.apply}
|
||||
</Button>
|
||||
{needsSetup ? (
|
||||
setupIsApiKey ? (
|
||||
<>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className={cn('min-w-60 flex-1', CONTROL_TEXT)}
|
||||
onChange={event => setApiKeyDraft(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
void activateApiKeyProvider()
|
||||
}
|
||||
}}
|
||||
placeholder={`Paste ${selectedProviderRow?.key_env ?? 'API key'}`}
|
||||
type="password"
|
||||
value={apiKeyDraft}
|
||||
/>
|
||||
<Button
|
||||
disabled={!apiKeyDraft.trim() || activating}
|
||||
onClick={() => void activateApiKeyProvider()}
|
||||
size="sm"
|
||||
>
|
||||
{activating && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{activating ? 'Activating...' : 'Activate'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={startProviderSetup} size="sm" variant="textStrong">
|
||||
Set up {selectedProviderRow?.name ?? 'provider'}
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<Select onValueChange={setSelectedModel} value={selectedModel}>
|
||||
<SelectTrigger className={cn('min-w-60', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder={m.model} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(selectedProviderModels.length ? selectedProviderModels : []).map(model => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
disabled={!selectedProvider || !selectedModel || applying}
|
||||
onClick={() => void applyMainModel()}
|
||||
size="sm"
|
||||
>
|
||||
{applying && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{applying ? m.applying : t.common.apply}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{needsSetup && !setupIsApiKey && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{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.`}
|
||||
</p>
|
||||
)}
|
||||
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
|
||||
{switchStaleAux.length > 0 && (
|
||||
<div className="mt-2">
|
||||
|
|
|
|||
|
|
@ -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<ModelOptionProvider[]>([])
|
||||
|
||||
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<string>(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<string, { order: number; title: string }> = {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
|
||||
<div className="relative w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
|
||||
<div className="relative w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background)">
|
||||
<Header />
|
||||
{onboarding.manual ? (
|
||||
<Button
|
||||
|
|
@ -235,9 +303,7 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
|
|||
return (
|
||||
<div className="grid gap-3" role="status">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{installing
|
||||
? t.onboarding.preparingInstall
|
||||
: t.onboarding.starting}
|
||||
{installing ? t.onboarding.preparingInstall : t.onboarding.starting}
|
||||
</p>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
|
|
@ -304,6 +370,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
|||
const [showAll, setShowAll] = useState(readShowAll)
|
||||
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
|
||||
const hasOauth = ordered.length > 0
|
||||
const apiKeyOptions = useApiKeyCatalog()
|
||||
|
||||
if (mode === 'apikey' || !hasOauth) {
|
||||
return (
|
||||
|
|
@ -312,6 +379,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
|||
canGoBack={hasOauth}
|
||||
onBack={() => setOnboardingMode('oauth')}
|
||||
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
|
||||
options={apiKeyOptions}
|
||||
/>
|
||||
{manual ? null : (
|
||||
<div className="flex justify-center border-t border-(--ui-stroke-tertiary) pt-3">
|
||||
|
|
@ -336,15 +404,17 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
|||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{featured ? <FeaturedProviderRow onSelect={select} provider={featured} /> : null}
|
||||
{showRest ? (
|
||||
<>
|
||||
{rest.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
))}
|
||||
<KeyProviderRow onClick={() => setOnboardingMode('apikey')} />
|
||||
</>
|
||||
) : null}
|
||||
<div className="grid max-h-[60dvh] gap-2 overflow-y-auto p-1">
|
||||
{featured ? <FeaturedProviderRow onSelect={select} provider={featured} /> : null}
|
||||
{showRest ? (
|
||||
<>
|
||||
{rest.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
))}
|
||||
<KeyProviderRow onClick={() => setOnboardingMode('apikey')} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{collapsible ? (
|
||||
<button
|
||||
className="flex items-center justify-center gap-1.5 pt-1 text-xs font-medium text-muted-foreground transition hover:text-foreground"
|
||||
|
|
@ -481,9 +551,7 @@ export function ProviderRow({
|
|||
</span>
|
||||
{loggedIn ? <ConnectedTag /> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{t.onboarding.flowSubtitles[provider.flow]}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
|
||||
</div>
|
||||
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
||||
</button>
|
||||
|
|
@ -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({
|
|||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="grid max-h-[60dvh] gap-2 overflow-y-auto p-1 sm:grid-cols-2">
|
||||
{options.map(o => (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
|
||||
option.id === o.id ? 'border-primary ring-2 ring-primary/20' : 'border-border'
|
||||
option.envKey === o.envKey ? 'border-primary ring-2 ring-primary/20' : 'border-transparent'
|
||||
)}
|
||||
key={o.id}
|
||||
key={o.envKey}
|
||||
onClick={() => pick(o)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{o.name}</span>
|
||||
{option.id === o.id ? (
|
||||
<Check className="size-4 text-primary" />
|
||||
) : isSet?.(o.envKey) ? (
|
||||
<Check className="size-3.5 text-muted-foreground" />
|
||||
) : null}
|
||||
{isSet?.(o.envKey) ? <Check className="size-3.5 text-muted-foreground" /> : null}
|
||||
</div>
|
||||
{(t.onboarding.apiKeyOptions[o.id]?.short ?? o.short) ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t.onboarding.apiKeyOptions[o.id]?.short ?? o.short}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t.onboarding.apiKeyOptions[o.id]?.short ?? o.short}</p>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
|
|
@ -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 (
|
||||
<Step title={t.onboarding.signInWith(title)}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t.onboarding.autoBrowser(title)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t.onboarding.autoBrowser(title)}</p>
|
||||
<FlowFooter left={<DocsLink href={flow.start.auth_url}>{t.onboarding.reopenSignInPage}</DocsLink>}>
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
|
|
@ -734,12 +795,14 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
|
|||
if (flow.status === 'external_pending') {
|
||||
return (
|
||||
<Step title={t.onboarding.signInWith(title)}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t.onboarding.externalPending(title)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t.onboarding.externalPending(title)}</p>
|
||||
<CodeBlock copied={flow.copied} onCopy={() => void copyExternalCommand()} text={flow.provider.cli_command} />
|
||||
<FlowFooter
|
||||
left={flow.provider.docs_url ? <DocsLink href={flow.provider.docs_url}>{t.onboarding.docs(title)}</DocsLink> : null}
|
||||
left={
|
||||
flow.provider.docs_url ? (
|
||||
<DocsLink href={flow.provider.docs_url}>{t.onboarding.docs(title)}</DocsLink>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<CancelBtn />
|
||||
<Button onClick={() => void recheckExternalSignin(ctx)}>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import {
|
|||
desktopSlashUnavailableMessage,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashCommand,
|
||||
isDesktopSlashSuggestion
|
||||
isDesktopSlashSuggestion,
|
||||
isModelPickerCommand
|
||||
} from './desktop-slash-commands'
|
||||
|
||||
describe('desktop slash command curation', () => {
|
||||
|
|
@ -115,4 +116,11 @@ describe('desktop slash command curation', () => {
|
|||
expect(desktopSlashUnavailableMessage('/skills')).toContain('desktop sidebar')
|
||||
expect(desktopSlashUnavailableMessage('/clear')).toContain('terminal interface')
|
||||
})
|
||||
|
||||
it('flags /model as a picker-owned command so the desktop opens the overlay', () => {
|
||||
expect(isModelPickerCommand('/model')).toBe(true)
|
||||
expect(isModelPickerCommand('/model sonnet')).toBe(true)
|
||||
expect(isModelPickerCommand('/new')).toBe(false)
|
||||
expect(isModelPickerCommand('/skills')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -183,6 +183,18 @@ export function isDesktopSlashSuggestion(command: string): boolean {
|
|||
return DESKTOP_COMMANDS.has(canonical) && !DESKTOP_ALIASES.has(normalized)
|
||||
}
|
||||
|
||||
/**
|
||||
* True for commands the desktop fulfils by opening the model picker overlay
|
||||
* (e.g. `/model`) rather than executing a slash command. The caller opens the
|
||||
* picker UI instead of printing the "uses the desktop model picker" notice.
|
||||
*/
|
||||
export function isModelPickerCommand(command: string): boolean {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
|
||||
return PICKER_OWNED_COMMANDS.has(canonical)
|
||||
}
|
||||
|
||||
export function desktopSlashUnavailableMessage(command: string): string | null {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
|
|
|
|||
|
|
@ -213,6 +213,18 @@ export interface ModelOptionProvider {
|
|||
slug: string
|
||||
total_models?: number
|
||||
warning?: string
|
||||
/** True when the provider has usable credentials. False for canonical
|
||||
* providers surfaced by `include_unconfigured` that the user hasn't set up
|
||||
* yet — render these with a setup affordance instead of hiding them. */
|
||||
authenticated?: boolean
|
||||
/** Auth flow for an unconfigured provider: "api_key" can be activated inline
|
||||
* by pasting `key_env`; anything else (oauth_*, external, aws_sdk, …) needs
|
||||
* the `hermes model` CLI / onboarding OAuth flow. */
|
||||
auth_type?: string
|
||||
/** Env var to paste an API key into, for unconfigured `api_key` providers. */
|
||||
key_env?: string
|
||||
/** True for providers defined via the user's `providers:` config block. */
|
||||
is_user_defined?: boolean
|
||||
/** Per-model pricing keyed by model id (present when the picker requested
|
||||
* pricing and the provider supports live pricing). */
|
||||
pricing?: Record<string, ModelPricing>
|
||||
|
|
|
|||
|
|
@ -2067,8 +2067,22 @@ def get_model_options():
|
|||
try:
|
||||
from hermes_cli.inventory import build_models_payload, load_picker_context
|
||||
|
||||
# include_unconfigured + picker_hints + canonical_order mirror the
|
||||
# tui_gateway `model.options` JSON-RPC handler exactly, so every GUI
|
||||
# surface fed by this endpoint (Settings → Model, the first-run
|
||||
# onboarding picker) sees the SAME full provider universe `hermes model`
|
||||
# exposes — not just the authenticated subset. Unconfigured providers
|
||||
# come back as skeleton rows carrying `authenticated=False` +
|
||||
# `auth_type`/`key_env`/`warning` so the GUI can render a setup
|
||||
# affordance instead of hiding the provider entirely.
|
||||
return build_models_payload(
|
||||
load_picker_context(), max_models=50, pricing=True, capabilities=True
|
||||
load_picker_context(),
|
||||
max_models=50,
|
||||
include_unconfigured=True,
|
||||
picker_hints=True,
|
||||
canonical_order=True,
|
||||
pricing=True,
|
||||
capabilities=True,
|
||||
)
|
||||
except Exception:
|
||||
_log.exception("GET /api/model/options failed")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue