import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useRef, useState } from 'react' import { ModelPickerDialog } from '@/components/model-picker' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { getGlobalModelOptions } from '@/hermes' import { Check, ChevronDown, ChevronLeft, ChevronRight, ExternalLink, KeyRound, Loader2, Sparkles, Terminal } from '@/lib/icons' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { cn } from '@/lib/utils' import { $desktopBoot, type DesktopBootState } from '@/store/boot' import { $desktopOnboarding, cancelOnboardingFlow, closeManualOnboarding, confirmOnboardingModel, copyDeviceCode, copyExternalCommand, type OnboardingContext, type OnboardingFlow, recheckExternalSignin, refreshOnboarding, saveOnboardingApiKey, setOnboardingCode, setOnboardingMode, setOnboardingModel, startProviderOAuth, submitOnboardingCode } from '@/store/onboarding' import type { OAuthProvider } from '@/types/hermes' interface DesktopOnboardingOverlayProps { enabled: boolean onCompleted?: () => void requestGateway: OnboardingContext['requestGateway'] } interface ApiKeyOption { description: string docsUrl: string envKey: string id: string name: string placeholder?: string short?: string } const MIN_KEY_LENGTH = 8 const API_KEY_OPTIONS: ApiKeyOption[] = [ { id: 'openrouter', name: 'OpenRouter', short: 'one key, many models', envKey: 'OPENROUTER_API_KEY', description: 'Hosts hundreds of models behind a single key. Good default for new installs.', docsUrl: 'https://openrouter.ai/keys' }, { id: 'openai', name: 'OpenAI', short: 'GPT-class models', envKey: 'OPENAI_API_KEY', description: 'Direct access to OpenAI models.', docsUrl: 'https://platform.openai.com/api-keys' }, { id: 'gemini', name: 'Google Gemini', short: 'Gemini models', envKey: 'GEMINI_API_KEY', description: 'Direct access to Google Gemini models.', docsUrl: 'https://aistudio.google.com/app/apikey' }, { id: 'xai', name: 'xAI Grok', short: 'Grok models', envKey: 'XAI_API_KEY', description: 'Direct access to xAI Grok models.', docsUrl: 'https://console.x.ai/' }, { id: 'local', name: 'Local / custom endpoint', short: 'self-hosted', envKey: 'OPENAI_BASE_URL', description: 'Point Hermes at a local or self-hosted OpenAI-compatible endpoint (vLLM, llama.cpp, Ollama, etc).', docsUrl: 'https://github.com/NousResearch/hermes-agent#bring-your-own-endpoint', placeholder: 'http://127.0.0.1:8000/v1' } ] const PROVIDER_DISPLAY: Record = { nous: { order: 0, title: 'Nous Portal' }, anthropic: { order: 1, title: 'Anthropic Claude' }, 'openai-codex': { order: 2, title: 'OpenAI Codex / ChatGPT' }, 'minimax-oauth': { order: 3, title: 'MiniMax' }, 'claude-code': { order: 4, title: 'Claude Code' }, 'qwen-oauth': { order: 5, title: 'Qwen Code' } } const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}` const FLOW_SUBTITLES: Record = { pkce: 'Opens your browser to sign in, then continues here', device_code: 'Opens a verification page in your browser — Hermes connects automatically', external: 'Sign in once in your terminal, then come back to chat' } const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99 const sortProviders = (providers: OAuthProvider[]) => [...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name)) export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) { const onboarding = useStore($desktopOnboarding) const boot = useStore($desktopBoot) const ctxRef = useRef({ requestGateway, onCompleted }) ctxRef.current = { requestGateway, onCompleted } const ctx = useMemo( () => ({ requestGateway: (...args) => ctxRef.current.requestGateway(...args), onCompleted: () => ctxRef.current.onCompleted?.() }), [] ) useEffect(() => { if (enabled || onboarding.requested) { void refreshOnboarding(ctx) } }, [ctx, enabled, onboarding.requested]) // Mount from frame 1 so we replace the boot overlay seamlessly. The // configured field stays null until the runtime check resolves; only then // do we know whether to dismiss (true) or surface the picker (false). // EXCEPTION: manual mode (user opened the selector from a working app to // add/switch a provider) shows the overlay regardless of configured state. if (onboarding.configured === true && !onboarding.manual) { return null } const { flow } = onboarding const rawReason = onboarding.reason?.trim() || null const reason = rawReason && !isProviderSetupErrorMessage(rawReason) ? rawReason : null // In manual mode the app is already configured, so the flow is "ready" // immediately — no runtime gate needed. Otherwise wait for the readiness // check (configured === false) before showing the picker. const ready = onboarding.manual || (enabled && onboarding.configured === false) const showPicker = flow.status === 'idle' || flow.status === 'success' return (
{onboarding.manual ? (
) : null} {reason ? : null} {ready ? showPicker ? : : }
) } function ReasonNotice({ reason }: { reason: string }) { return (
{reason}
) } function Preparing({ boot }: { boot: DesktopBootState }) { const progress = Math.max(2, Math.min(100, Math.round(boot.progress))) const hasError = Boolean(boot.error) const installing = boot.phase.startsWith('runtime.') return (

{installing ? 'Hermes is finishing install. This usually takes under a minute on first run.' : 'Starting Hermes…'}

{boot.message} {progress}%
{hasError ?

{boot.error}

: null}
) } function Header() { return (

Let's get you setup with Hermes Agent

Connect a model provider to start chatting. Most options take one click.

) } const FEATURED_ID = 'nous' const FEATURED_PITCH = 'One subscription, 300+ frontier models — the recommended way to run Hermes' const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1' const readShowAll = () => { try { return window.localStorage.getItem(SHOW_ALL_KEY) === '1' } catch { return false } } const persistShowAll = (value: boolean) => { try { window.localStorage.setItem(SHOW_ALL_KEY, value ? '1' : '0') } catch { // localStorage unavailable — degrade silently. } return value } export function Picker({ ctx }: { ctx: OnboardingContext }) { const { mode, providers } = useStore($desktopOnboarding) const [showAll, setShowAll] = useState(readShowAll) const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers]) const hasOauth = ordered.length > 0 if (mode === 'apikey' || !hasOauth) { return } if (providers === null) { return Looking up providers... } const select = (p: OAuthProvider) => void startProviderOAuth(p, ctx) const featured = ordered.find(p => p.id === FEATURED_ID) ?? null const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered // Collapse the secondary providers behind a disclosure only when Nous // Portal is present to anchor the choice — otherwise show the full list. const collapsible = Boolean(featured) && rest.length > 0 const showRest = !collapsible || showAll return (
{featured ? : null} {showRest ? ( <> {rest.map(p => ( ))} setOnboardingMode('apikey')} /> ) : null} {collapsible ? ( ) : null}
) } function FeaturedProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvider) => void provider: OAuthProvider }) { const loggedIn = provider.status?.logged_in return ( ) } function ConnectedTag() { return ( Connected ) } function KeyProviderRow({ onClick }: { onClick: () => void }) { return ( ) } function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvider) => void; provider: OAuthProvider }) { const loggedIn = provider.status?.logged_in const Trail = provider.flow === 'external' ? Terminal : ChevronRight return ( ) } function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingContext }) { const [option, setOption] = useState(API_KEY_OPTIONS[0]) const [value, setValue] = useState('') const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const isLocal = option.envKey === 'OPENAI_BASE_URL' const canSave = value.trim().length >= (isLocal ? 1 : MIN_KEY_LENGTH) const submit = async () => { if (!canSave || saving) { return } setSaving(true) setError(null) const result = await saveOnboardingApiKey(option.envKey, value, option.name, ctx) if (result.ok) { setValue('') } else { setError(result.message ?? 'Could not save credential.') } setSaving(false) } return (
{canGoBack ? ( ) : null}
{API_KEY_OPTIONS.map(o => ( ))}

{option.description}

{option.docsUrl ? Get a key : null}
setValue(e.target.value)} onKeyDown={e => e.key === 'Enter' && void submit()} placeholder={option.placeholder || 'Paste API key'} type={isLocal ? 'text' : 'password'} value={value} /> {error ?

{error}

: null}
) } function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow }) { const title = 'provider' in flow && flow.provider ? providerTitle(flow.provider) : '' if (flow.status === 'starting') { return Starting sign-in for {title}... } if (flow.status === 'submitting') { return Verifying your code with {title}... } if (flow.status === 'success') { return (
{title} connected. Picking a default model...
) } if (flow.status === 'confirming_model') { return } if (flow.status === 'error') { return (
{flow.message || 'Sign-in failed. Try again.'}
) } if (flow.status === 'awaiting_user') { return (
  1. We opened {title} in your browser.
  2. Authorize Hermes there.
  3. Copy the authorization code and paste it below.
setOnboardingCode(e.target.value)} onKeyDown={e => e.key === 'Enter' && void submitOnboardingCode(ctx)} placeholder="Paste authorization code" value={flow.code} /> Re-open authorization page}>
) } if (flow.status === 'external_pending') { return (

{title} signs in through its own CLI. Run this command in a terminal, then come back and pick "I've signed in":

void copyExternalCommand()} text={flow.provider.cli_command} /> {title} docs : null} >
) } if (flow.status !== 'polling') { return null } return (

We opened {title} in your browser. Enter this code there:

void copyDeviceCode()} text={flow.start.user_code} /> Re-open verification page}> Waiting for you to authorize...
) } function Step({ children, title }: { children: React.ReactNode; title: string }) { return (

{title}

{children}
) } function CodeBlock({ copied, large, onCopy, text }: { copied: boolean large?: boolean onCopy: () => void text: string }) { return (
{text}
) } function FlowFooter({ children, left }: { children: React.ReactNode; left?: React.ReactNode }) { return (
{left}
{children}
) } function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) { return ( ) } function ConfirmingModelPanel({ ctx, flow }: { ctx: OnboardingContext flow: Extract }) { // Local state controls whether the model picker dialog is open. // We reuse the existing ModelPickerDialog component (the same picker // available from the chat shell) rather than building an inline // dropdown — gives us search, multi-provider listing if relevant, and // a familiar UI for users who'll see this picker again later. const [pickerOpen, setPickerOpen] = useState(false) // Pull pricing + tier for the just-picked default so the confirm card // shows the same $/Mtok + Free/Pro info the picker and CLI do. const options = useQuery({ queryKey: ['onboarding-model-options', flow.providerSlug], queryFn: () => getGlobalModelOptions() }) const providerRow = options.data?.providers?.find( p => String(p.slug).toLowerCase() === flow.providerSlug.toLowerCase() ) const price = providerRow?.pricing?.[flow.currentModel] const freeTier = providerRow?.free_tier return (
{flow.label} connected.

Default model

{freeTier === true && ( Free tier )} {freeTier === false && ( Pro )}

{flow.currentModel}

{price && (price.input || price.output) && (

{price.free ? 'Free' : `${price.input || '?'} in / ${price.output || '?'} out per Mtok`}

)}
{/* ModelPickerDialog defaults to z-130 on its content, which renders UNDER the onboarding overlay (z-1300) and breaks pointer events. Bump it above with z-[1310] so the picker sits on top of the onboarding panel. The dialog's own dim-backdrop layer stays at its default z-120 — the onboarding overlay is already dimming the rest of the screen, so we don't want a second backdrop. */} { void setOnboardingModel(model) setPickerOpen(false) }} open={pickerOpen} />
) } function DocsLink({ children, href }: { children: React.ReactNode; href: string }) { return ( ) } function Status({ children }: { children: React.ReactNode }) { return (
{children}
) }