feat(desktop): OAuth-first onboarding using existing dashboard provider API

Replace the engineer-flavored API key form with a Sign-in-first onboarding overlay that uses the dashboard's existing /api/providers/oauth catalog and PKCE/device-code endpoints (Anthropic, Nous, OpenAI Codex, etc.). API key entry is now a fallback tab with friendly provider names instead of env var prefixes, and the loud raw resolver error is gone in favor of a one-line welcome message.
This commit is contained in:
Brooklyn Nicholson 2026-05-07 23:30:51 -04:00
parent 7d652fc466
commit c5413c17ad
4 changed files with 735 additions and 192 deletions

View file

@ -1,14 +1,21 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { getEnvVars, setEnvVar } from '@/hermes'
import { AlertCircle, Check, ExternalLink, KeyRound, Loader2 } from '@/lib/icons'
import {
cancelOAuthSession,
listOAuthProviders,
pollOAuthSession,
setEnvVar,
startOAuthLogin,
submitOAuthCode
} from '@/hermes'
import { Check, ChevronRight, ExternalLink, KeyRound, Loader2, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $desktopOnboarding, completeDesktopOnboarding } from '@/store/onboarding'
import type { EnvVarInfo } from '@/types/hermes'
import type { OAuthProvider, OAuthStartResponse } from '@/types/hermes'
interface DesktopOnboardingOverlayProps {
enabled: boolean
@ -22,244 +29,503 @@ interface SetupStatus {
interface RuntimeCheck {
error?: string
model?: string
ok?: boolean
provider?: string
}
interface ProviderOption {
key: string
label: string
helper: string
interface ApiKeyOption {
description: string
docsUrl: string
envKey: string
id: string
name: string
placeholder?: string
short?: string
}
const PREFERRED_PROVIDER_KEYS: ProviderOption[] = [
const API_KEY_OPTIONS: ApiKeyOption[] = [
{
key: 'OPENROUTER_API_KEY',
label: 'OpenRouter',
helper: 'Works with many hosted models and is a good default for new installs.'
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'
},
{
key: 'ANTHROPIC_API_KEY',
label: 'Anthropic',
helper: 'Use Claude models directly.'
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'
},
{
key: 'OPENAI_API_KEY',
label: 'OpenAI',
helper: 'Use OpenAI models directly.'
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'
},
{
key: 'GEMINI_API_KEY',
label: 'Gemini',
helper: 'Use Google Gemini models.'
id: 'xai',
name: 'xAI Grok',
short: 'Grok models',
envKey: 'XAI_API_KEY',
description: 'Direct access to xAI Grok models.',
docsUrl: 'https://console.x.ai/'
},
{
key: 'XAI_API_KEY',
label: 'xAI',
helper: 'Use Grok models.'
},
{
key: 'OPENAI_BASE_URL',
label: 'Local / OpenAI-compatible',
helper: 'Use a local or self-hosted OpenAI-compatible endpoint. API key may not be required.'
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'
}
]
function optionLabel(option: ProviderOption, info?: EnvVarInfo) {
return info?.description ? `${option.label} (${option.key})` : option.label
interface FlowState {
copyState?: 'copied' | 'idle'
errorMessage?: string
expiresAt?: number
provider?: OAuthProvider
start?: OAuthStartResponse
status: 'awaiting_user' | 'error' | 'idle' | 'polling' | 'starting' | 'submitting' | 'success'
submitCode?: string
}
const POLL_INTERVAL_MS = 2000
export function DesktopOnboardingOverlay({
enabled,
onCompleted,
requestGateway
}: DesktopOnboardingOverlayProps) {
const onboarding = useStore($desktopOnboarding)
const [checking, setChecking] = useState(false)
const [envVars, setEnvVars] = useState<Record<string, EnvVarInfo> | null>(null)
const [error, setError] = useState<string | null>(null)
const [providerConfigured, setProviderConfigured] = useState(true)
const [saving, setSaving] = useState(false)
const [selectedKey, setSelectedKey] = useState(PREFERRED_PROVIDER_KEYS[0].key)
const [value, setValue] = useState('')
const shouldCheck = enabled || onboarding.requested
useEffect(() => {
if (!shouldCheck) {
return
}
let cancelled = false
async function checkSetup() {
setChecking(true)
setError(null)
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[] | null>(null)
const [mode, setMode] = useState<'apikey' | 'oauth'>('oauth')
const [flow, setFlow] = useState<FlowState>({ status: 'idle' })
const [apiKeyOption, setApiKeyOption] = useState<ApiKeyOption>(API_KEY_OPTIONS[0])
const [apiKeyValue, setApiKeyValue] = useState('')
const [apiKeySaving, setApiKeySaving] = useState(false)
const [apiKeyError, setApiKeyError] = useState<string | null>(null)
const cancelledRef = useRef(false)
const pollTimerRef = useRef<number | null>(null)
const shouldShow = enabled || onboarding.requested
const refreshSetupCheck = useMemo(
() => async () => {
try {
const [status, runtime, vars] = await Promise.all([
const [status, runtime] = await Promise.all([
requestGateway<SetupStatus>('setup.status').catch(() => ({}) as SetupStatus),
requestGateway<RuntimeCheck>('setup.runtime_check').catch(() => ({ ok: false }) as RuntimeCheck),
getEnvVars()
requestGateway<RuntimeCheck>('setup.runtime_check').catch(() => ({ ok: false }) as RuntimeCheck)
])
if (cancelled) {
return
}
// The strict runtime check is the source of truth: it runs the same
// resolver the agent uses, so it can't be fooled by fallbacks like
// gh-CLI Copilot tokens when the user's configured model isn't a
// Copilot model. setup.status is kept as a coarse sanity check
// for backends that don't expose setup.runtime_check yet.
const runtimeOk = runtime.ok !== undefined ? Boolean(runtime.ok) : Boolean(status.provider_configured)
setProviderConfigured(runtimeOk)
setEnvVars(vars)
if (runtimeOk) {
completeDesktopOnboarding()
} else if (runtime.error) {
setError(runtime.error)
}
const firstAvailable = PREFERRED_PROVIDER_KEYS.find(option => vars[option.key])
if (firstAvailable) {
setSelectedKey(current => (vars[current] ? current : firstAvailable.key))
}
} catch (err) {
if (!cancelled) {
setProviderConfigured(false)
setError(err instanceof Error ? err.message : String(err))
}
} finally {
if (!cancelled) {
setChecking(false)
}
return runtime.ok !== undefined ? Boolean(runtime.ok) : Boolean(status.provider_configured)
} catch {
return false
}
}
void checkSetup()
return () => void (cancelled = true)
}, [requestGateway, shouldCheck])
const providerOptions = useMemo(
() => PREFERRED_PROVIDER_KEYS.filter(option => !envVars || envVars[option.key]),
[envVars]
},
[requestGateway]
)
const selectedInfo = envVars?.[selectedKey]
const selectedOption = providerOptions.find(option => option.key === selectedKey) ?? PREFERRED_PROVIDER_KEYS[0]
const canSave = selectedKey === 'OPENAI_BASE_URL' ? value.trim().length > 0 : value.trim().length > 8
async function handleSave() {
if (!canSave || saving) {
useEffect(() => {
if (!shouldShow) {
return
}
setSaving(true)
setError(null)
cancelledRef.current = false
try {
await setEnvVar(selectedKey, value.trim())
await requestGateway('reload.env').catch(() => undefined)
const status = await requestGateway<SetupStatus>('setup.status')
void (async () => {
const ok = await refreshSetupCheck()
if (!status.provider_configured) {
setError('Credential was saved, but Hermes still does not see a configured provider.')
if (cancelledRef.current) {
return
}
setProviderConfigured(ok)
if (ok) {
completeDesktopOnboarding()
return
}
notify({ kind: 'success', title: 'Hermes is ready', message: `${selectedKey} saved.` })
setProviderConfigured(true)
setValue('')
completeDesktopOnboarding()
onCompleted?.()
} catch (err) {
notifyError(err, `Failed to save ${selectedKey}`)
setError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
try {
const providers = await listOAuthProviders()
if (cancelledRef.current) {
return
}
setOauthProviders(providers.providers)
setMode(providers.providers.length > 0 ? 'oauth' : 'apikey')
} catch {
if (!cancelledRef.current) {
setOauthProviders([])
setMode('apikey')
}
}
})()
return () => {
cancelledRef.current = true
}
}, [refreshSetupCheck, shouldShow])
useEffect(
() => () => {
if (pollTimerRef.current !== null) {
window.clearInterval(pollTimerRef.current)
pollTimerRef.current = null
}
if (flow.start?.session_id && flow.status !== 'success' && flow.status !== 'idle') {
cancelOAuthSession(flow.start.session_id).catch(() => undefined)
}
},
[flow.start?.session_id, flow.status]
)
function clearPollTimer() {
if (pollTimerRef.current !== null) {
window.clearInterval(pollTimerRef.current)
pollTimerRef.current = null
}
}
if (!shouldCheck || providerConfigured) {
async function finalizeSuccess(providerName: string) {
clearPollTimer()
notify({ kind: 'success', title: 'Hermes is ready', message: `${providerName} connected.` })
setFlow({ status: 'success' })
try {
await requestGateway('reload.env').catch(() => undefined)
} catch {
// best effort env reload
}
const ok = await refreshSetupCheck()
if (ok) {
setProviderConfigured(true)
completeDesktopOnboarding()
onCompleted?.()
} else {
setFlow({
status: 'error',
errorMessage: 'Connected, but Hermes still cannot resolve a usable provider. Try another provider.'
})
}
}
async function startProviderFlow(provider: OAuthProvider) {
if (provider.flow === 'external') {
notify({
kind: 'info',
title: `${provider.name} uses an external CLI`,
message: `Run \`${provider.cli_command}\` in a terminal, then come back to retry.`
})
return
}
clearPollTimer()
setFlow({ status: 'starting', provider })
try {
const start = await startOAuthLogin(provider.id)
const expiresAt = Date.now() + start.expires_in * 1000
if (start.flow === 'pkce') {
await window.hermesDesktop?.openExternal(start.auth_url).catch(() => undefined)
setFlow({ status: 'awaiting_user', provider, start, expiresAt })
return
}
await window.hermesDesktop?.openExternal(start.verification_url).catch(() => undefined)
setFlow({ status: 'polling', provider, start, expiresAt })
pollTimerRef.current = window.setInterval(() => void pollOAuth(provider, start), POLL_INTERVAL_MS)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setFlow({ status: 'error', provider, errorMessage: `Could not start sign-in: ${message}` })
}
}
async function pollOAuth(provider: OAuthProvider, start: OAuthStartResponse) {
if (start.flow !== 'device_code') {
return
}
try {
const resp = await pollOAuthSession(provider.id, start.session_id)
if (resp.status === 'approved') {
clearPollTimer()
await finalizeSuccess(provider.name)
} else if (resp.status !== 'pending') {
clearPollTimer()
setFlow({
status: 'error',
provider,
start,
errorMessage: resp.error_message || `Sign-in ${resp.status}.`
})
}
} catch (err) {
clearPollTimer()
const message = err instanceof Error ? err.message : String(err)
setFlow({ status: 'error', provider, start, errorMessage: `Polling failed: ${message}` })
}
}
async function submitPkce() {
if (flow.status !== 'awaiting_user' || !flow.provider || flow.start?.flow !== 'pkce') {
return
}
const code = (flow.submitCode || '').trim()
if (!code) {
return
}
const provider = flow.provider
const start = flow.start
setFlow(prev => ({ ...prev, status: 'submitting' }))
try {
const resp = await submitOAuthCode(provider.id, start.session_id, code)
if (resp.ok && resp.status === 'approved') {
await finalizeSuccess(provider.name)
} else {
setFlow({ status: 'error', provider, start, errorMessage: resp.message || 'Token exchange failed.' })
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setFlow({ status: 'error', provider, start, errorMessage: message })
}
}
function cancelFlow() {
clearPollTimer()
if (flow.start?.session_id) {
cancelOAuthSession(flow.start.session_id).catch(() => undefined)
}
setFlow({ status: 'idle' })
}
async function copyDeviceCode() {
if (flow.start?.flow !== 'device_code') {
return
}
try {
await navigator.clipboard.writeText(flow.start.user_code)
setFlow(prev => ({ ...prev, copyState: 'copied' }))
window.setTimeout(() => setFlow(prev => ({ ...prev, copyState: 'idle' })), 1500)
} catch {
// clipboard write blocked; user can still type the code manually
}
}
async function saveApiKey() {
const value = apiKeyValue.trim()
const minLen = apiKeyOption.envKey === 'OPENAI_BASE_URL' ? 1 : 8
if (!value || value.length < minLen || apiKeySaving) {
return
}
setApiKeySaving(true)
setApiKeyError(null)
try {
await setEnvVar(apiKeyOption.envKey, value)
await requestGateway('reload.env').catch(() => undefined)
const ok = await refreshSetupCheck()
if (ok) {
notify({ kind: 'success', title: 'Hermes is ready', message: `${apiKeyOption.name} connected.` })
setProviderConfigured(true)
completeDesktopOnboarding()
setApiKeyValue('')
onCompleted?.()
} else {
setApiKeyError(`Saved, but Hermes still cannot reach ${apiKeyOption.name}. Double-check the value.`)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
notifyError(err, `Could not save ${apiKeyOption.name}`)
setApiKeyError(message)
} finally {
setApiKeySaving(false)
}
}
if (!shouldShow || providerConfigured) {
return null
}
const oauthList = oauthProviders ?? []
return (
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-background/80 p-6 backdrop-blur-xl">
<div className="w-full max-w-2xl overflow-hidden rounded-3xl border border-border bg-card/95 shadow-2xl">
<div className="border-b border-border bg-muted/30 px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div className="flex gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<KeyRound className="size-5" />
</div>
<div>
<h2 className="text-lg font-semibold tracking-tight">Set up Hermes</h2>
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
Add one inference provider before starting your first chat. This writes to the current Hermes
profile's `.env` file and takes effect immediately.
</p>
</div>
<div className="flex items-start gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Sparkles className="size-5" />
</div>
<div>
<h2 className="text-lg font-semibold tracking-tight">Welcome to Hermes</h2>
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
Connect a model provider to start chatting. Most options take one click.
</p>
</div>
</div>
</div>
<div className="grid gap-5 p-6">
{checking ? (
{flow.status === 'idle' || flow.status === 'success' ? (
<ProviderPicker
apiKeyError={apiKeyError}
apiKeyOption={apiKeyOption}
apiKeySaving={apiKeySaving}
apiKeyValue={apiKeyValue}
loading={oauthProviders === null}
mode={mode}
oauthProviders={oauthList}
onApiKeySave={() => void saveApiKey()}
onApiKeySelect={setApiKeyOption}
onApiKeyValueChange={setApiKeyValue}
onModeChange={setMode}
onProviderSelect={provider => void startProviderFlow(provider)}
/>
) : (
<FlowPanel
flow={flow}
onCancel={cancelFlow}
onCopyCode={() => void copyDeviceCode()}
onSubmitCode={() => void submitPkce()}
onSubmitCodeChange={code => setFlow(prev => ({ ...prev, submitCode: code }))}
/>
)}
</div>
</div>
</div>
)
}
interface ProviderPickerProps {
apiKeyError: null | string
apiKeyOption: ApiKeyOption
apiKeySaving: boolean
apiKeyValue: string
loading: boolean
mode: 'apikey' | 'oauth'
oauthProviders: OAuthProvider[]
onApiKeySave: () => void
onApiKeySelect: (option: ApiKeyOption) => void
onApiKeyValueChange: (value: string) => void
onModeChange: (mode: 'apikey' | 'oauth') => void
onProviderSelect: (provider: OAuthProvider) => void
}
function ProviderPicker({
apiKeyError,
apiKeyOption,
apiKeySaving,
apiKeyValue,
loading,
mode,
oauthProviders,
onApiKeySave,
onApiKeySelect,
onApiKeyValueChange,
onModeChange,
onProviderSelect
}: ProviderPickerProps) {
const hasOauth = oauthProviders.length > 0
const minLen = apiKeyOption.envKey === 'OPENAI_BASE_URL' ? 1 : 8
const canSave = apiKeyValue.trim().length >= minLen
return (
<>
{hasOauth && (
<div className="flex gap-1 rounded-full border border-border bg-muted/40 p-1 self-start text-xs font-medium">
<button
className={cn(
'rounded-full px-3 py-1 transition',
mode === 'oauth' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
onClick={() => onModeChange('oauth')}
type="button"
>
Sign in
</button>
<button
className={cn(
'rounded-full px-3 py-1 transition',
mode === 'apikey' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
onClick={() => onModeChange('apikey')}
type="button"
>
Use an API key
</button>
</div>
)}
{mode === 'oauth' && hasOauth ? (
<div className="grid gap-2">
{loading ? (
<div className="flex items-center gap-2 rounded-2xl bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Checking provider setup...
</div>
) : null}
{onboarding.reason ? (
<div className="flex gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{onboarding.reason}</span>
</div>
) : null}
<div className="grid gap-2">
<label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Provider</label>
<div className="grid gap-2 sm:grid-cols-2">
{providerOptions.map(option => (
<button
className={cn(
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
selectedKey === option.key ? 'border-primary ring-2 ring-primary/20' : 'border-border'
)}
key={option.key}
onClick={() => {
setSelectedKey(option.key)
setValue('')
}}
type="button"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{optionLabel(option, envVars?.[option.key])}</span>
{selectedKey === option.key ? <Check className="size-4 text-primary" /> : null}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{option.helper}</p>
</button>
))}
Looking up providers...
</div>
) : (
oauthProviders.map(provider => (
<ProviderRow key={provider.id} onSelect={onProviderSelect} provider={provider} />
))
)}
</div>
) : (
<div className="grid gap-4">
<div className="grid gap-2 sm:grid-cols-2">
{API_KEY_OPTIONS.map(option => (
<button
className={cn(
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
apiKeyOption.id === option.id ? 'border-primary ring-2 ring-primary/20' : 'border-border'
)}
key={option.id}
onClick={() => onApiKeySelect(option)}
type="button"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{option.name}</span>
{apiKeyOption.id === option.id ? <Check className="size-4 text-primary" /> : null}
</div>
{option.short ? <p className="mt-1 text-xs text-muted-foreground">{option.short}</p> : null}
</button>
))}
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
{selectedKey}
</label>
{selectedInfo?.url ? (
<p className="text-sm leading-6 text-muted-foreground">{apiKeyOption.description}</p>
{apiKeyOption.docsUrl ? (
<Button asChild size="xs" variant="ghost">
<a href={selectedInfo.url} rel="noreferrer" target="_blank">
Docs
<a href={apiKeyOption.docsUrl} rel="noreferrer" target="_blank">
Get a key
<ExternalLink className="size-3" />
</a>
</Button>
@ -269,34 +535,212 @@ export function DesktopOnboardingOverlay({
autoComplete="off"
autoFocus
className="font-mono"
onChange={event => setValue(event.target.value)}
onChange={event => onApiKeyValueChange(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
void handleSave()
onApiKeySave()
}
}}
placeholder={selectedKey === 'OPENAI_BASE_URL' ? 'http://127.0.0.1:8000/v1' : 'Paste API key'}
type={selectedInfo?.is_password === false || selectedKey === 'OPENAI_BASE_URL' ? 'text' : 'password'}
value={value}
placeholder={apiKeyOption.placeholder || 'Paste API key'}
type={apiKeyOption.envKey === 'OPENAI_BASE_URL' ? 'text' : 'password'}
value={apiKeyValue}
/>
<p className="text-xs leading-5 text-muted-foreground">{selectedOption.helper}</p>
{apiKeyError ? <p className="text-xs text-destructive">{apiKeyError}</p> : null}
</div>
{error ? (
<div className="flex gap-2 rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
) : null}
<div className="flex justify-end">
<Button disabled={!canSave || apiKeySaving} onClick={onApiKeySave}>
{apiKeySaving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
{apiKeySaving ? 'Connecting' : 'Connect'}
</Button>
</div>
</div>
)}
</>
)
}
<div className="flex justify-end border-t border-border pt-5">
<Button disabled={!canSave || saving} onClick={() => void handleSave()}>
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
{saving ? 'Saving' : 'Save and continue'}
function ProviderRow({
provider,
onSelect
}: {
provider: OAuthProvider
onSelect: (provider: OAuthProvider) => void
}) {
const isExternal = provider.flow === 'external'
const loggedIn = provider.status?.logged_in
return (
<button
className={cn(
'group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-4 text-left transition hover:border-primary/40 hover:bg-accent/40',
loggedIn && 'border-primary/30'
)}
onClick={() => onSelect(provider)}
type="button"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">Sign in with {provider.name}</span>
{loggedIn ? (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Check className="size-3" />
Connected
</span>
) : null}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{isExternal ? `Use the ${provider.name} CLI: ${provider.cli_command}` : flowSubtitle(provider.flow)}
</p>
</div>
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
</button>
)
}
function flowSubtitle(flow: OAuthProvider['flow']) {
if (flow === 'pkce') {
return 'Opens your browser, asks you to paste a one-time code back here.'
}
if (flow === 'device_code') {
return 'Opens a verification page in your browser. Hermes connects automatically.'
}
return ''
}
interface FlowPanelProps {
flow: FlowState
onCancel: () => void
onCopyCode: () => void
onSubmitCode: () => void
onSubmitCodeChange: (code: string) => void
}
function FlowPanel({ flow, onCancel, onCopyCode, onSubmitCode, onSubmitCodeChange }: FlowPanelProps) {
if (flow.status === 'starting') {
return (
<div className="flex items-center gap-3 rounded-2xl bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Starting sign-in for {flow.provider?.name}...
</div>
)
}
if (flow.status === 'submitting') {
return (
<div className="flex items-center gap-3 rounded-2xl bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Verifying your code with {flow.provider?.name}...
</div>
)
}
if (flow.status === 'success') {
return (
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
<Check className="size-4" />
{flow.provider?.name} connected. You're ready to chat.
</div>
)
}
if (flow.status === 'error') {
return (
<div className="grid gap-3">
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{flow.errorMessage || 'Sign-in failed. Try again.'}
</div>
<div className="flex justify-end">
<Button onClick={onCancel} variant="outline">
Pick a different provider
</Button>
</div>
</div>
)
}
if (flow.status === 'awaiting_user' && flow.start?.flow === 'pkce' && flow.provider) {
return (
<div className="grid gap-4">
<div>
<h3 className="text-sm font-semibold">Sign in with {flow.provider.name}</h3>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
<li>We opened {flow.provider.name} in your browser.</li>
<li>Authorize Hermes there.</li>
<li>Copy the authorization code and paste it below.</li>
</ol>
</div>
<Input
autoFocus
onChange={event => onSubmitCodeChange(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
onSubmitCode()
}
}}
placeholder="Paste authorization code"
value={flow.submitCode || ''}
/>
<div className="flex items-center justify-between gap-3">
<Button asChild size="xs" variant="ghost">
<a href={flow.start.auth_url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3" />
Re-open authorization page
</a>
</Button>
<div className="flex gap-2">
<Button onClick={onCancel} variant="ghost">
Cancel
</Button>
<Button disabled={!(flow.submitCode || '').trim()} onClick={onSubmitCode}>
Continue
</Button>
</div>
</div>
</div>
)
}
if (flow.status === 'polling' && flow.start?.flow === 'device_code' && flow.provider) {
return (
<div className="grid gap-4">
<div>
<h3 className="text-sm font-semibold">Sign in with {flow.provider.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">
We opened {flow.provider.name} in your browser. Enter this code there:
</p>
</div>
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/30 px-4 py-3">
<code className="font-mono text-2xl tracking-[0.4em]">{flow.start.user_code}</code>
<Button onClick={onCopyCode} size="sm" variant="outline">
{flow.copyState === 'copied' ? <Check className="size-4" /> : 'Copy'}
</Button>
</div>
<div className="flex items-center justify-between gap-3">
<Button asChild size="xs" variant="ghost">
<a href={flow.start.verification_url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3" />
Re-open verification page
</a>
</Button>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Waiting for you to authorize...
</div>
<Button onClick={onCancel} size="sm" variant="ghost">
Cancel
</Button>
</div>
</div>
)
}
return (
<div className="flex items-center gap-3 rounded-2xl bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Working...
</div>
)
}

View file

@ -74,6 +74,7 @@ function widthToPx(value: WidthValue | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined
}
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/)
if (!match) {
@ -203,6 +204,7 @@ export function Pane({
if (registered.current) {
return
}
registered.current = true
ensurePaneRegistered(id, { open: defaultOpen })
}, [defaultOpen, id])
@ -221,6 +223,7 @@ export function Pane({
if (!canResize || paneWidth <= 0) {
return
}
event.preventDefault()
const handle = event.currentTarget

View file

@ -16,6 +16,10 @@ import type {
ModelAssignmentResponse,
ModelInfoResponse,
ModelOptionsResponse,
OAuthPollResponse,
OAuthProvidersResponse,
OAuthStartResponse,
OAuthSubmitResponse,
PaginatedSessions,
SessionMessagesResponse,
SessionSearchResponse,
@ -215,6 +219,45 @@ export function revealEnvVar(key: string): Promise<{ key: string; value: string
})
}
export function listOAuthProviders(): Promise<OAuthProvidersResponse> {
return window.hermesDesktop.api<OAuthProvidersResponse>({
path: '/api/providers/oauth'
})
}
export function startOAuthLogin(providerId: string): Promise<OAuthStartResponse> {
return window.hermesDesktop.api<OAuthStartResponse>({
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
method: 'POST',
body: {}
})
}
export function submitOAuthCode(
providerId: string,
sessionId: string,
code: string
): Promise<OAuthSubmitResponse> {
return window.hermesDesktop.api<OAuthSubmitResponse>({
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
method: 'POST',
body: { session_id: sessionId, code }
})
}
export function pollOAuthSession(providerId: string, sessionId: string): Promise<OAuthPollResponse> {
return window.hermesDesktop.api<OAuthPollResponse>({
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`
})
}
export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
method: 'DELETE'
})
}
export function getSkills(): Promise<SkillInfo[]> {
return window.hermesDesktop.api<SkillInfo[]>({
path: '/api/skills'

View file

@ -34,6 +34,59 @@ export interface ElevenLabsVoicesResponse {
voices: ElevenLabsVoice[]
}
export interface OAuthProviderStatus {
error?: string
expires_at?: null | string
has_refresh_token?: boolean
last_refresh?: null | string
logged_in: boolean
source?: null | string
source_label?: null | string
token_preview?: null | string
}
export interface OAuthProvider {
cli_command: string
docs_url: string
flow: 'device_code' | 'external' | 'pkce'
id: string
name: string
status: OAuthProviderStatus
}
export interface OAuthProvidersResponse {
providers: OAuthProvider[]
}
export type OAuthStartResponse =
| {
auth_url: string
expires_in: number
flow: 'pkce'
session_id: string
}
| {
expires_in: number
flow: 'device_code'
poll_interval: number
session_id: string
user_code: string
verification_url: string
}
export interface OAuthSubmitResponse {
message?: string
ok: boolean
status: 'approved' | 'error'
}
export interface OAuthPollResponse {
error_message?: null | string
expires_at?: null | number
session_id: string
status: 'approved' | 'denied' | 'error' | 'expired' | 'pending'
}
export interface EnvVarInfo {
advanced: boolean
category: string