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 e31a0ce07ef9..f68b43299c66 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 177b60274478..a0b1afdc9581 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 642fba5bf856..4a7ffcfd94f0 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 e35eb5829dce..3736d2d9582f 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 + } >