mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
fix(desktop): gate prompts on provider setup
Show the desktop provider onboarding flow before prompt submission when no inference provider is configured, preventing fresh installs from falling through to backend credential errors.
This commit is contained in:
parent
89d5ee4b10
commit
8d95e006b8
4 changed files with 76 additions and 52 deletions
|
|
@ -405,7 +405,6 @@ export function DesktopController() {
|
|||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
onOpenSettings={openSettings}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
type ComposerAttachment
|
||||
} from '@/store/composer'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $busy, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types'
|
||||
|
|
@ -45,6 +46,16 @@ function blobToDataUrl(blob: Blob): Promise<string> {
|
|||
})
|
||||
}
|
||||
|
||||
interface SetupStatus {
|
||||
provider_configured?: boolean
|
||||
}
|
||||
|
||||
function isProviderSetupError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return /No inference provider configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY/i.test(message)
|
||||
}
|
||||
|
||||
interface PromptActionsOptions {
|
||||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
|
|
@ -216,6 +227,15 @@ export function usePromptActions({
|
|||
setAwaitingResponse(true)
|
||||
clearNotifications()
|
||||
|
||||
const setup = await requestGateway<SetupStatus>('setup.status').catch(() => null)
|
||||
|
||||
if (setup?.provider_configured === false) {
|
||||
releaseBusy()
|
||||
requestDesktopOnboarding('Add a provider credential before sending your first message.')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let sessionId = activeSessionId
|
||||
|
||||
if (!sessionId) {
|
||||
|
|
@ -257,6 +277,13 @@ export function usePromptActions({
|
|||
} catch (err) {
|
||||
releaseBusy()
|
||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
||||
|
||||
if (isProviderSetupError(err)) {
|
||||
requestDesktopOnboarding('Add a provider credential before sending your first message.')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notifyError(err, 'Prompt failed')
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, 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, Settings2, X } from '@/lib/icons'
|
||||
import { AlertCircle, Check, ExternalLink, KeyRound, Loader2 } 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'
|
||||
|
||||
interface DesktopOnboardingOverlayProps {
|
||||
enabled: boolean
|
||||
onCompleted?: () => void
|
||||
onOpenSettings?: () => void
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
|
|
@ -25,8 +26,6 @@ interface ProviderOption {
|
|||
helper: string
|
||||
}
|
||||
|
||||
const DISMISS_KEY = 'desktop.onboarding.dismissed_until_reload'
|
||||
|
||||
const PREFERRED_PROVIDER_KEYS: ProviderOption[] = [
|
||||
{
|
||||
key: 'OPENROUTER_API_KEY',
|
||||
|
|
@ -60,22 +59,6 @@ const PREFERRED_PROVIDER_KEYS: ProviderOption[] = [
|
|||
}
|
||||
]
|
||||
|
||||
function isDismissedForSession() {
|
||||
try {
|
||||
return window.sessionStorage.getItem(DISMISS_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function dismissForSession() {
|
||||
try {
|
||||
window.sessionStorage.setItem(DISMISS_KEY, '1')
|
||||
} catch {
|
||||
// Ignore storage failures; in-memory state still dismisses the overlay.
|
||||
}
|
||||
}
|
||||
|
||||
function optionLabel(option: ProviderOption, info?: EnvVarInfo) {
|
||||
return info?.description ? `${option.label} (${option.key})` : option.label
|
||||
}
|
||||
|
|
@ -83,20 +66,20 @@ function optionLabel(option: ProviderOption, info?: EnvVarInfo) {
|
|||
export function DesktopOnboardingOverlay({
|
||||
enabled,
|
||||
onCompleted,
|
||||
onOpenSettings,
|
||||
requestGateway
|
||||
}: DesktopOnboardingOverlayProps) {
|
||||
const onboarding = useStore($desktopOnboarding)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [dismissed, setDismissed] = useState(isDismissedForSession)
|
||||
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 (!enabled || dismissed) {
|
||||
if (!shouldCheck) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -116,6 +99,10 @@ export function DesktopOnboardingOverlay({
|
|||
setProviderConfigured(Boolean(status.provider_configured))
|
||||
setEnvVars(vars)
|
||||
|
||||
if (status.provider_configured) {
|
||||
completeDesktopOnboarding()
|
||||
}
|
||||
|
||||
const firstAvailable = PREFERRED_PROVIDER_KEYS.find(option => vars[option.key])
|
||||
|
||||
if (firstAvailable) {
|
||||
|
|
@ -136,7 +123,7 @@ export function DesktopOnboardingOverlay({
|
|||
void checkSetup()
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [dismissed, enabled, requestGateway])
|
||||
}, [requestGateway, shouldCheck])
|
||||
|
||||
const providerOptions = useMemo(
|
||||
() => PREFERRED_PROVIDER_KEYS.filter(option => !envVars || envVars[option.key]),
|
||||
|
|
@ -169,6 +156,7 @@ export function DesktopOnboardingOverlay({
|
|||
notify({ kind: 'success', title: 'Hermes is ready', message: `${selectedKey} saved.` })
|
||||
setProviderConfigured(true)
|
||||
setValue('')
|
||||
completeDesktopOnboarding()
|
||||
onCompleted?.()
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${selectedKey}`)
|
||||
|
|
@ -178,17 +166,7 @@ export function DesktopOnboardingOverlay({
|
|||
}
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
dismissForSession()
|
||||
setDismissed(true)
|
||||
}
|
||||
|
||||
function handleOpenSettings() {
|
||||
handleDismiss()
|
||||
onOpenSettings?.()
|
||||
}
|
||||
|
||||
if (!enabled || dismissed || providerConfigured) {
|
||||
if (!shouldCheck || providerConfigured) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -209,9 +187,6 @@ export function DesktopOnboardingOverlay({
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleDismiss} size="icon-sm" title="Configure later" variant="ghost">
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -223,6 +198,13 @@ export function DesktopOnboardingOverlay({
|
|||
</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">
|
||||
|
|
@ -287,20 +269,11 @@ export function DesktopOnboardingOverlay({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-5">
|
||||
<Button onClick={handleOpenSettings} variant="outline">
|
||||
<Settings2 className="size-4" />
|
||||
Open full settings
|
||||
<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'}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleDismiss} variant="ghost">
|
||||
Configure later
|
||||
</Button>
|
||||
<Button disabled={!canSave || saving} onClick={() => void handleSave()}>
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
|
||||
{saving ? 'Saving' : 'Save and continue'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
25
apps/desktop/src/store/onboarding.ts
Normal file
25
apps/desktop/src/store/onboarding.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
interface DesktopOnboardingState {
|
||||
reason: null | string
|
||||
requested: boolean
|
||||
}
|
||||
|
||||
export const $desktopOnboarding = atom<DesktopOnboardingState>({
|
||||
reason: null,
|
||||
requested: false
|
||||
})
|
||||
|
||||
export function requestDesktopOnboarding(reason = 'No inference provider is configured.') {
|
||||
$desktopOnboarding.set({
|
||||
reason,
|
||||
requested: true
|
||||
})
|
||||
}
|
||||
|
||||
export function completeDesktopOnboarding() {
|
||||
$desktopOnboarding.set({
|
||||
reason: null,
|
||||
requested: false
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue