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:
co-authored by
Brooklyn Nicholson
parent
3606307339
commit
e8c837c921
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user