mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
After OAuth/API-key login completes, onboarding now shows a confirmation
card with the curated default model and a Change button before dropping
the user into chat. Closes the gap where the desktop's `model.default`
was empty after first launch and the agent had to fall back to whatever
heuristic happened to fire — leaving users wondering "why am I getting
sonnet-4 when I logged into Nous Portal?"
Why
- Desktop onboarding only persisted credentials, never `model.default`.
The CLI's `hermes model` command pairs provider + model selection,
but the desktop's onboarding skipped the model step entirely.
- Result: users saw whichever model the agent's auto-fallback picked,
unpredictably and undocumented.
- For the BUILD demo we want users to land on the model they expect
for their provider, with a clear "this is what you're getting" UI
and a one-click path to change it before chatting.
How
- New `confirming_model` flow status carries the just-authenticated
provider slug, current default model, label, and a saving flag.
- `completeWithModelConfirm()` runs after credentials succeed: reloads
env, verifies runtime, fetches /api/model/options to find the curated
first-model for the provider, persists it via /api/model/set, then
transitions into `confirming_model`.
- If anything fails (no providers returned, network error), falls
through to the previous behaviour — onboarding completes without
the confirm step. Polish, not a hard requirement.
- All four credential paths (device_code OAuth, PKCE OAuth, external
CLI flow, API key) now use completeWithModelConfirm instead of
reloadAndConnect.
UI
- `ConfirmingModelPanel` shows: green "<provider> connected" banner,
card with "Default model: <name>" + Change button, and a "Start
chatting" CTA that finalises onboarding.
- Reuses the existing `ModelPickerDialog` (the same picker available
from the chat shell) for the change-model UX. Search, filtering,
multi-provider listing — all already built.
- Stacking: ModelPickerDialog defaults to z-130, which renders UNDER
the onboarding overlay (z-1300) and breaks pointer events. Added
optional `contentClassName` prop to ModelPickerDialog so callers
can override; onboarding passes `z-[1310]`.
Provider-slug matching
- For OAuth flows: pass `provider.id` directly as the preferred slug.
- For API-key flows: `OPENROUTER_API_KEY` → "openrouter" via env-key
prefix strip. Also includes the user-visible label as a fallback
candidate.
- fetchProviderDefaultModel falls back to the first authenticated
provider in the response if no preferred slug matches — so even a
miss still surfaces a reasonable default.
Files
- apps/desktop/src/store/onboarding.ts:
+ new `confirming_model` flow variant
+ fetchProviderDefaultModel + completeWithModelConfirm helpers
+ setOnboardingModel (optimistic update + revert on failure)
+ confirmOnboardingModel (finalises onboarding from the card)
- reloadAndConnect (replaced; the four call sites now go through
completeWithModelConfirm)
- apps/desktop/src/components/desktop-onboarding-overlay.tsx:
+ ConfirmingModelPanel component
+ new branch in FlowPanel for status `confirming_model`
+ ModelPickerDialog usage with z-[1310] content class
- apps/desktop/src/components/model-picker.tsx:
+ optional `contentClassName` prop on ModelPickerDialog so the
dialog can be stacked on top of other fixed overlays
Tested
- `npm run type-check` passes
- `npx eslint` clean on touched files
- Live test in `npm run dev`: cleared onboarding cache, walked
through Nous device-code flow, saw confirm card with curated
default, clicked Change → ModelPickerDialog rendered above the
onboarding overlay with working pointer events, picked a different
model, "Start chatting" persisted to ~/.hermes/config.yaml.
222 lines
7.2 KiB
TypeScript
222 lines
7.2 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
|
import { useState } from 'react'
|
|
|
|
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
|
|
|
import type { HermesGateway } from '../hermes'
|
|
import { getGlobalModelOptions } from '../hermes'
|
|
import { cn } from '../lib/utils'
|
|
|
|
import { InlineNotice } from './notifications'
|
|
import { Button } from './ui/button'
|
|
import { Checkbox } from './ui/checkbox'
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command'
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
|
|
import { Skeleton } from './ui/skeleton'
|
|
|
|
interface ModelPickerDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
gw?: HermesGateway
|
|
sessionId?: string | null
|
|
currentModel: string
|
|
currentProvider: string
|
|
onSelect: (selection: { provider: string; model: string; persistGlobal: boolean }) => void
|
|
/**
|
|
* Optional class to apply to DialogContent. Use to override z-index when
|
|
* stacking the picker on top of another fixed overlay (e.g. the desktop
|
|
* onboarding overlay, which sits at z-1300; the default Dialog z-130 ends
|
|
* up rendering underneath and blocks pointer events).
|
|
*/
|
|
contentClassName?: string
|
|
}
|
|
|
|
export function ModelPickerDialog({
|
|
open,
|
|
onOpenChange,
|
|
gw,
|
|
sessionId,
|
|
currentModel,
|
|
currentProvider,
|
|
onSelect,
|
|
contentClassName
|
|
}: ModelPickerDialogProps) {
|
|
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
|
|
|
|
const modelOptions = useQuery({
|
|
queryKey: ['model-options', sessionId || 'global'],
|
|
queryFn: () => {
|
|
if (gw && sessionId) {
|
|
return gw.request<ModelOptionsResponse>('model.options', {
|
|
session_id: sessionId
|
|
})
|
|
}
|
|
|
|
return getGlobalModelOptions()
|
|
},
|
|
enabled: open
|
|
})
|
|
|
|
const providers = modelOptions.data?.providers ?? []
|
|
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
|
|
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
|
|
const loading = modelOptions.isPending && !modelOptions.data
|
|
|
|
const error = modelOptions.error
|
|
? modelOptions.error instanceof Error
|
|
? modelOptions.error.message
|
|
: String(modelOptions.error)
|
|
: null
|
|
|
|
const selectModel = (provider: ModelOptionProvider, model: string) => {
|
|
onSelect({
|
|
provider: provider.slug,
|
|
model,
|
|
persistGlobal: persistGlobal || !sessionId
|
|
})
|
|
onOpenChange(false)
|
|
}
|
|
|
|
return (
|
|
<Dialog onOpenChange={onOpenChange} open={open}>
|
|
<DialogContent className={cn('max-h-[85vh] max-w-2xl gap-0 overflow-hidden p-0', contentClassName)}>
|
|
<DialogHeader className="border-b border-border px-4 py-3">
|
|
<DialogTitle>Switch model</DialogTitle>
|
|
<DialogDescription className="font-mono text-xs leading-relaxed">
|
|
current: {optionsModel || currentModel || '(unknown)'}
|
|
{optionsProvider || currentProvider ? ` · ${optionsProvider || currentProvider}` : ''}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<Command className="rounded-none bg-card">
|
|
<CommandInput autoFocus placeholder="Filter providers and models..." />
|
|
<CommandList className="max-h-96">
|
|
{!loading && !error && <CommandEmpty>No models found.</CommandEmpty>}
|
|
<ModelResults
|
|
currentModel={optionsModel || currentModel}
|
|
currentProvider={optionsProvider || currentProvider}
|
|
error={error}
|
|
loading={loading}
|
|
onSelectModel={selectModel}
|
|
providers={providers}
|
|
/>
|
|
</CommandList>
|
|
</Command>
|
|
|
|
<DialogFooter className="flex-row items-center justify-between gap-3 border-t border-border bg-card p-3 sm:justify-between">
|
|
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
|
|
<Checkbox
|
|
checked={persistGlobal || !sessionId}
|
|
disabled={!sessionId}
|
|
onCheckedChange={checked => setPersistGlobal(checked === true)}
|
|
/>
|
|
{sessionId ? 'Persist globally (otherwise this session only)' : 'Persist globally'}
|
|
</label>
|
|
|
|
<Button onClick={() => onOpenChange(false)} variant="outline">
|
|
Cancel
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
function ModelResults({
|
|
loading,
|
|
error,
|
|
providers,
|
|
currentModel,
|
|
currentProvider,
|
|
onSelectModel
|
|
}: {
|
|
loading: boolean
|
|
error: string | null
|
|
providers: ModelOptionProvider[]
|
|
currentModel: string
|
|
currentProvider: string
|
|
onSelectModel: (provider: ModelOptionProvider, model: string) => void
|
|
}) {
|
|
if (loading) {
|
|
return <LoadingResults />
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="px-3 py-3">
|
|
<InlineNotice kind="error" title="Could not load models">
|
|
{error}
|
|
</InlineNotice>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (providers.length === 0) {
|
|
return <div className="px-4 py-6 text-sm text-muted-foreground">No authenticated providers.</div>
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{providers.map(provider => {
|
|
const models = provider.models ?? []
|
|
|
|
if (models.length === 0) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<CommandGroup heading={<ProviderHeading provider={provider} />} key={provider.slug}>
|
|
{provider.warning && (
|
|
<div className="px-2 pb-2">
|
|
<InlineNotice className="px-2.5 py-1.5 text-xs" kind="warning">
|
|
{provider.warning}
|
|
</InlineNotice>
|
|
</div>
|
|
)}
|
|
{models.map(model => {
|
|
const isCurrent = model === currentModel && provider.slug === currentProvider
|
|
|
|
return (
|
|
<CommandItem
|
|
className={cn(
|
|
'pl-6 font-mono',
|
|
isCurrent &&
|
|
'bg-primary text-primary-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground'
|
|
)}
|
|
key={`${provider.slug}:${model}`}
|
|
onSelect={() => onSelectModel(provider, model)}
|
|
value={`${provider.name} ${provider.slug} ${model}`}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">{model}</span>
|
|
</CommandItem>
|
|
)
|
|
})}
|
|
</CommandGroup>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function LoadingResults() {
|
|
return (
|
|
<CommandGroup heading={<Skeleton className="h-3 w-32" />}>
|
|
{Array.from({ length: 4 }, (_, rowIndex) => (
|
|
<div className="rounded-sm py-1.5 pl-6 pr-2" key={rowIndex}>
|
|
<Skeleton className={cn('h-5', rowIndex % 3 === 0 ? 'w-3/5' : rowIndex % 3 === 1 ? 'w-4/5' : 'w-1/2')} />
|
|
</div>
|
|
))}
|
|
</CommandGroup>
|
|
)
|
|
}
|
|
|
|
function ProviderHeading({ provider }: { provider: ModelOptionProvider }) {
|
|
return (
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<span className="truncate">{provider.name}</span>
|
|
<span className="font-mono text-xs font-normal normal-case tracking-normal text-muted-foreground">
|
|
{provider.slug} · {provider.total_models ?? provider.models?.length ?? 0}
|
|
</span>
|
|
</span>
|
|
)
|
|
}
|