mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
586 lines
20 KiB
TypeScript
586 lines
20 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
import { BrandMark } from '@/components/brand-mark'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Codicon } from '@/components/ui/codicon'
|
|
import { ErrorIcon } from '@/components/ui/error-state'
|
|
import { Loader } from '@/components/ui/loader'
|
|
import { LogView } from '@/components/ui/log-view'
|
|
import type {
|
|
DesktopBootstrapEvent,
|
|
DesktopBootstrapStageDescriptor,
|
|
DesktopBootstrapStageResult,
|
|
DesktopBootstrapStageState,
|
|
DesktopBootstrapState
|
|
} from '@/global'
|
|
import { useI18n } from '@/i18n'
|
|
import { ChevronDown, ChevronRight, iconSize } from '@/lib/icons'
|
|
import { capitalize } from '@/lib/text'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
/**
|
|
* DesktopInstallOverlay
|
|
*
|
|
* Renders the first-launch install progress for Hermes Agent. Mounted always;
|
|
* shows itself only when main.ts reports an in-flight bootstrap (state.active)
|
|
* OR an error from a completed-failed bootstrap (state.error). When the
|
|
* bootstrap finishes successfully the overlay fades out and the rest of the
|
|
* app (existing onboarding overlay -> main UI) takes over.
|
|
*
|
|
* Subscribes to two channels:
|
|
* - getBootstrapState() -- initial snapshot on mount
|
|
* - onBootstrapEvent(callback) -- live event stream
|
|
*
|
|
* The reducer is intentionally simple: every event mutates an in-component
|
|
* snapshot the same way main.ts mutates its server-side snapshot. We don't
|
|
* try to reconcile -- if we miss an event (shouldn't happen) the initial
|
|
* getBootstrapState() call will resync the picture on the next render.
|
|
*
|
|
* Stages flagged needs_user_input render with a deliberately subdued style:
|
|
* they're expected to come back as skipped=true (install.ps1 short-circuits
|
|
* them under -NonInteractive). The post-install configuration flow that
|
|
* those stages cover (API key, model, persona, gateway autostart) is handled
|
|
* by the existing DesktopOnboardingOverlay, NOT by the install overlay.
|
|
*/
|
|
|
|
interface DesktopInstallOverlayProps {
|
|
/** When false, the overlay never renders -- useful for dev when we want
|
|
* to suppress it entirely. */
|
|
enabled?: boolean
|
|
}
|
|
|
|
interface StageRowProps {
|
|
descriptor: DesktopBootstrapStageDescriptor
|
|
result: DesktopBootstrapStageResult | undefined
|
|
now: number
|
|
}
|
|
|
|
function formatStageName(name: string): string {
|
|
// 'system-packages' -> 'System packages'; 'uv' stays 'uv'
|
|
if (name.length <= 3) {
|
|
return name
|
|
}
|
|
|
|
return name
|
|
.split('-')
|
|
.map((word, i) => (i === 0 ? capitalize(word) : word))
|
|
.join(' ')
|
|
}
|
|
|
|
function formatDuration(ms: number | null | undefined): string {
|
|
if (typeof ms !== 'number' || !Number.isFinite(ms)) {
|
|
return ''
|
|
}
|
|
|
|
if (ms < 1000) {
|
|
return `${ms} ms`
|
|
}
|
|
|
|
const s = ms / 1000
|
|
|
|
if (s < 60) {
|
|
return `${s.toFixed(1)}s`
|
|
}
|
|
|
|
const m = Math.floor(s / 60)
|
|
const rs = Math.round(s - m * 60)
|
|
|
|
return `${m}m ${rs}s`
|
|
}
|
|
|
|
// Live elapsed for a running stage, as m:ss (or s for sub-minute).
|
|
function formatElapsed(ms: number): string {
|
|
const s = Math.max(0, Math.floor(ms / 1000))
|
|
|
|
if (s < 60) {
|
|
return `${s}s`
|
|
}
|
|
|
|
const m = Math.floor(s / 60)
|
|
|
|
return `${m}:${String(s - m * 60).padStart(2, '0')}`
|
|
}
|
|
|
|
function StageRow({ descriptor, result, now }: StageRowProps) {
|
|
const { t } = useI18n()
|
|
const copy = t.install
|
|
const state: DesktopBootstrapStageState = result?.state || 'pending'
|
|
|
|
const elapsed =
|
|
state === 'running' && typeof result?.startedAt === 'number' ? formatElapsed(now - result.startedAt) : ''
|
|
|
|
const icon = useMemo(() => {
|
|
switch (state) {
|
|
case 'running':
|
|
return <Loader className="size-6" type="fourier-flow" />
|
|
|
|
case 'succeeded':
|
|
|
|
case 'skipped':
|
|
return <Codicon className="text-muted-foreground" name="check" size="0.8125rem" />
|
|
|
|
case 'failed':
|
|
return <ErrorIcon size="1rem" />
|
|
|
|
case 'pending':
|
|
|
|
default:
|
|
return <div className="size-1.5 rounded-full border border-(--ui-stroke-secondary)" />
|
|
}
|
|
}, [state])
|
|
|
|
const reason = result?.json?.reason || result?.error || null
|
|
|
|
return (
|
|
<li className="flex items-center gap-3 px-3 py-1">
|
|
{state === 'running' && (
|
|
<div className="-mr-2 -ml-4 flex size-6 flex-shrink-0 items-center justify-center">{icon}</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className={cn('truncate text-sm', state === 'running' ? 'font-medium' : 'text-muted-foreground')}>
|
|
{formatStageName(descriptor.name)}
|
|
</span>
|
|
{state !== 'running' && <span className="flex size-4 shrink-0 items-center justify-center">{icon}</span>}
|
|
</div>
|
|
{reason && state !== 'pending' && <p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>}
|
|
</div>
|
|
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
|
|
{state === 'running' ? (elapsed ? `${copy.stageStates[state]} · ${elapsed}` : copy.stageStates[state]) : null}
|
|
{state === 'succeeded' || state === 'skipped' ? formatDuration(result?.durationMs) : null}
|
|
{state === 'failed' ? copy.stageStates[state] : null}
|
|
</span>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
const EMPTY_STATE: DesktopBootstrapState = {
|
|
active: false,
|
|
manifest: null,
|
|
stages: {},
|
|
error: null,
|
|
log: [],
|
|
startedAt: null,
|
|
completedAt: null,
|
|
unsupportedPlatform: null
|
|
}
|
|
|
|
function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): DesktopBootstrapState {
|
|
if (ev.type === 'manifest') {
|
|
const stages: Record<string, DesktopBootstrapStageResult> = {}
|
|
|
|
for (const stage of ev.stages) {
|
|
stages[stage.name] = { state: 'pending', durationMs: null, startedAt: null, json: null, error: null }
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
active: true,
|
|
manifest: { type: 'manifest', stages: ev.stages, protocolVersion: ev.protocolVersion },
|
|
stages,
|
|
error: null,
|
|
startedAt: state.startedAt || Date.now()
|
|
}
|
|
}
|
|
|
|
if (ev.type === 'stage') {
|
|
const prev = state.stages[ev.name]
|
|
|
|
return {
|
|
...state,
|
|
stages: {
|
|
...state.stages,
|
|
[ev.name]: {
|
|
state: ev.state,
|
|
durationMs: ev.durationMs ?? null,
|
|
// Stamp the start time on the running transition so the UI can show
|
|
// a live elapsed timer; preserve it across repeated running events.
|
|
startedAt: ev.state === 'running' ? (prev?.startedAt ?? Date.now()) : (prev?.startedAt ?? null),
|
|
json: ev.json ?? null,
|
|
error: ev.error ?? null
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (ev.type === 'log') {
|
|
const next = state.log.concat({ ts: Date.now(), stage: ev.stage ?? null, line: ev.line, stream: ev.stream })
|
|
|
|
while (next.length > 500) {
|
|
next.shift()
|
|
}
|
|
|
|
return { ...state, log: next }
|
|
}
|
|
|
|
if (ev.type === 'complete') {
|
|
return { ...state, active: false, completedAt: Date.now(), error: null }
|
|
}
|
|
|
|
if (ev.type === 'failed') {
|
|
return { ...state, active: false, error: ev.error || 'unknown error' }
|
|
}
|
|
|
|
if (ev.type === 'unsupported-platform') {
|
|
return {
|
|
...state,
|
|
active: false,
|
|
unsupportedPlatform: {
|
|
platform: ev.platform,
|
|
activeRoot: ev.activeRoot,
|
|
installCommand: ev.installCommand,
|
|
docsUrl: ev.docsUrl
|
|
}
|
|
}
|
|
}
|
|
|
|
return state
|
|
}
|
|
|
|
export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayProps) {
|
|
const { t } = useI18n()
|
|
const copy = t.install
|
|
|
|
const [state, setState] = useState<DesktopBootstrapState>(EMPTY_STATE)
|
|
const [logOpen, setLogOpen] = useState(false)
|
|
const [copied, setCopied] = useState(false)
|
|
const [cancelling, setCancelling] = useState(false)
|
|
const [now, setNow] = useState(() => Date.now())
|
|
const logEndRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
// Tick once a second while a bootstrap is in flight so running steps show a
|
|
// live elapsed timer. Stops when nothing is active to avoid idle renders.
|
|
useEffect(() => {
|
|
if (!state.active) {
|
|
return
|
|
}
|
|
|
|
const id = window.setInterval(() => setNow(Date.now()), 1000)
|
|
|
|
return () => window.clearInterval(id)
|
|
}, [state.active])
|
|
|
|
// Subscribe to bootstrap events + load initial snapshot
|
|
useEffect(() => {
|
|
if (!enabled) {
|
|
return
|
|
}
|
|
|
|
const desktop = window.hermesDesktop
|
|
|
|
if (!desktop || typeof desktop.onBootstrapEvent !== 'function') {
|
|
return
|
|
}
|
|
|
|
let cancelled = false
|
|
|
|
desktop
|
|
.getBootstrapState()
|
|
.then(snapshot => {
|
|
if (!cancelled && snapshot) {
|
|
setState(snapshot)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Older Electron build without the IPC handler -- bootstrap UI just
|
|
// stays empty, app falls through to existing onboarding flow.
|
|
})
|
|
|
|
const off = desktop.onBootstrapEvent(ev => setState(prev => applyEvent(prev, ev)))
|
|
|
|
return () => {
|
|
cancelled = true
|
|
off?.()
|
|
}
|
|
}, [enabled])
|
|
|
|
// Autoscroll log to bottom when new lines arrive AND the log is open
|
|
useEffect(() => {
|
|
if (logOpen && logEndRef.current) {
|
|
logEndRef.current.scrollIntoView({ behavior: 'auto', block: 'end' })
|
|
}
|
|
}, [state.log.length, logOpen])
|
|
|
|
// Auto-expand the log panel when a bootstrap fails so the user immediately
|
|
// sees the install.ps1 output. Without this, the failure block shows just
|
|
// the top-level error message and the user has to click "Show installer
|
|
// output" to see WHY the stage failed.
|
|
useEffect(() => {
|
|
if (state.error) {
|
|
setLogOpen(true)
|
|
}
|
|
}, [state.error])
|
|
|
|
// Mount logic: show whenever a bootstrap is in flight, completed-with-error,
|
|
// or actively running with a manifest. Hide entirely after a successful
|
|
// completion so the rest of the UI can take over.
|
|
const shouldShow = useMemo(() => {
|
|
if (!enabled) {
|
|
return false
|
|
}
|
|
|
|
if (state.active) {
|
|
return true
|
|
}
|
|
|
|
if (state.error) {
|
|
return true
|
|
}
|
|
|
|
if (state.unsupportedPlatform) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}, [enabled, state.active, state.error, state.unsupportedPlatform])
|
|
|
|
if (!shouldShow) {
|
|
return null
|
|
}
|
|
|
|
// Unsupported-platform branch: macOS/Linux packaged builds hit this when
|
|
// there's no Hermes Agent installed yet and we can't drive install.sh
|
|
// (no stage protocol equivalent yet). Show a copy-paste install command
|
|
// and the docs URL; user runs it from Terminal and relaunches the app.
|
|
if (state.unsupportedPlatform) {
|
|
const ups = state.unsupportedPlatform
|
|
const platformLabel = ups.platform === 'darwin' ? 'macOS' : ups.platform === 'linux' ? 'Linux' : ups.platform
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md">
|
|
<div className="w-full max-w-xl rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
|
|
<h2 className="text-xl font-semibold tracking-tight">{copy.oneTimeTitle}</h2>
|
|
<p className="mt-2 text-sm text-muted-foreground">{copy.unsupportedDesc(platformLabel)}</p>
|
|
|
|
<div className="mt-4">
|
|
<div className="mb-1.5 text-xs font-medium text-muted-foreground">{copy.installCommand}</div>
|
|
<pre className="overflow-x-auto rounded-md border border-(--stroke-nous) px-3 py-2.5 font-mono text-[12px]">
|
|
<code>{ups.installCommand}</code>
|
|
</pre>
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<Button
|
|
onClick={() => {
|
|
void navigator.clipboard?.writeText(ups.installCommand).catch(() => {})
|
|
}}
|
|
size="sm"
|
|
variant="secondary"
|
|
>
|
|
{copy.copyCommand}
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
window.hermesDesktop?.openExternal?.(ups.docsUrl)
|
|
}}
|
|
size="sm"
|
|
variant="ghost"
|
|
>
|
|
{copy.viewDocs}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 flex items-center justify-between pt-2">
|
|
<span className="text-xs text-muted-foreground">
|
|
{copy.installTo} <code className="font-mono text-(--ui-text-secondary)">{ups.activeRoot}</code>
|
|
</span>
|
|
<Button onClick={() => window.location.reload()} size="sm" variant="default">
|
|
{copy.retryAfterRun}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const stages = state.manifest?.stages || []
|
|
const currentStage = stages.find(s => state.stages[s.name]?.state === 'running')?.name
|
|
|
|
const completedCount = stages.filter(
|
|
s => state.stages[s.name]?.state === 'succeeded' || state.stages[s.name]?.state === 'skipped'
|
|
).length
|
|
|
|
const totalCount = stages.length
|
|
const failed = Boolean(state.error)
|
|
// Count the running stage as half-done so the bar advances *during* a long
|
|
// stage instead of sitting frozen at the last completed step while its logs
|
|
// stream (e.g. "0 of 2" pinned at 0% for the whole first stage).
|
|
const progressUnits = completedCount + (!failed && currentStage ? 0.5 : 0)
|
|
const progressPct = totalCount > 0 ? Math.round((progressUnits / totalCount) * 100) : 0
|
|
const currentStartedAt = currentStage ? state.stages[currentStage]?.startedAt : null
|
|
const currentElapsed = typeof currentStartedAt === 'number' ? formatElapsed(now - currentStartedAt) : ''
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md p-4">
|
|
<div className="flex w-full max-w-2xl max-h-[90vh] flex-col rounded-xl border border-(--stroke-nous) bg-card shadow-nous">
|
|
{/* Header -- always visible, never scrolls */}
|
|
<div className="flex flex-shrink-0 items-start gap-4 p-8 pb-4">
|
|
{!failed && <BrandMark className="size-11 shrink-0" />}
|
|
<div className="min-w-0">
|
|
<h2 className="text-xl font-semibold tracking-tight">
|
|
{failed ? copy.failedTitle : state.active ? copy.settingUpTitle : copy.finishingTitle}
|
|
</h2>
|
|
<p className="mt-1.5 text-sm text-muted-foreground">{failed ? copy.failedDesc : copy.activeDesc}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scrollable middle: progress, stages, error block, log */}
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-8 pb-2">
|
|
{totalCount > 0 && (
|
|
<div className="mb-4">
|
|
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
|
|
<span>
|
|
{copy.progress(completedCount, totalCount)}
|
|
{currentStage && copy.currentStage(formatStageName(currentStage))}
|
|
{currentElapsed && ` (${currentElapsed})`}
|
|
</span>
|
|
<span className="tabular-nums">{progressPct}%</span>
|
|
</div>
|
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
|
|
<div
|
|
className={cn('h-full transition-all duration-300', failed ? 'bg-destructive' : 'bg-primary')}
|
|
style={{ width: `${progressPct}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{totalCount === 0 && state.active && (
|
|
<div className="mb-4 flex items-center gap-2.5 text-sm text-muted-foreground">
|
|
<Loader className="size-5" type="fourier-flow" />
|
|
<span>{copy.fetchingManifest}</span>
|
|
</div>
|
|
)}
|
|
|
|
{failed && state.error && (
|
|
<div className="mb-4 flex items-start gap-2 text-sm">
|
|
<ErrorIcon className="mt-0.5 shrink-0" size="1rem" />
|
|
<div className="min-w-0">
|
|
<div className="font-medium text-destructive">{copy.error}</div>
|
|
<p className="mt-0.5 whitespace-pre-wrap break-words text-foreground/90">{state.error}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{stages.length > 0 && (
|
|
<ol className="mb-4 space-y-0.5">
|
|
{stages.map(stage => (
|
|
<StageRow descriptor={stage} key={stage.name} now={now} result={state.stages[stage.name]} />
|
|
))}
|
|
</ol>
|
|
)}
|
|
|
|
<div className="pt-3">
|
|
<Button
|
|
className="-ml-2 text-muted-foreground hover:text-foreground"
|
|
onClick={() => setLogOpen(v => !v)}
|
|
size="xs"
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
{logOpen ? <ChevronDown className={iconSize.sm} /> : <ChevronRight className={iconSize.sm} />}
|
|
<span>{logOpen ? copy.hideOutput : copy.showOutput}</span>
|
|
<span className="ml-1 tabular-nums">({copy.lines(state.log.length)})</span>
|
|
</Button>
|
|
|
|
{logOpen && (
|
|
<LogView className={cn('mt-2', failed ? 'max-h-96' : 'max-h-64')}>
|
|
{state.log.length === 0 ? (
|
|
<div>{copy.noOutput}</div>
|
|
) : (
|
|
<>
|
|
{state.log.map((entry, i) => (
|
|
<div className={cn(entry.stream === 'stderr' && 'text-muted-foreground/70')} key={i}>
|
|
{entry.stage ? <span className="text-muted-foreground/60">[{entry.stage}] </span> : null}
|
|
<span>{entry.line}</span>
|
|
</div>
|
|
))}
|
|
<div ref={logEndRef} />
|
|
</>
|
|
)}
|
|
</LogView>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Active footer: let the user actually cancel a running install. */}
|
|
{state.active && !failed && (
|
|
<div className="flex-shrink-0 bg-card p-4">
|
|
<div className="flex items-center justify-end">
|
|
<Button
|
|
disabled={cancelling}
|
|
onClick={async () => {
|
|
setCancelling(true)
|
|
|
|
try {
|
|
await window.hermesDesktop?.cancelBootstrap?.()
|
|
} catch {
|
|
// ignore -- the failed/cancelled event will surface the result
|
|
}
|
|
}}
|
|
size="sm"
|
|
variant="ghost"
|
|
>
|
|
{cancelling ? <Loader className="size-4" type="fourier-flow" /> : null}
|
|
{cancelling ? copy.cancelling : copy.cancelInstall}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer -- always visible, never scrolls; only renders on failure */}
|
|
{failed && (
|
|
<div className="flex-shrink-0 bg-card p-4">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-xs text-muted-foreground">
|
|
{copy.transcriptSaved}{' '}
|
|
<code className="font-mono text-(--ui-text-secondary)">%LOCALAPPDATA%\hermes\logs\</code>
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={async () => {
|
|
const text = state.log
|
|
.map(entry => (entry.stage ? `[${entry.stage}] ${entry.line}` : entry.line))
|
|
.join('\n')
|
|
|
|
const fullText = state.error ? `Error: ${state.error}\n\n${text}` : text
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(fullText)
|
|
setCopied(true)
|
|
window.setTimeout(() => setCopied(false), 1500)
|
|
} catch {
|
|
// ignore -- some environments forbid clipboard writes
|
|
}
|
|
}}
|
|
size="sm"
|
|
variant="secondary"
|
|
>
|
|
{copied ? copy.copiedOutput : copy.copyOutput}
|
|
</Button>
|
|
<Button
|
|
onClick={async () => {
|
|
// Tell main.ts to clear its latched failure BEFORE we
|
|
// reload. Otherwise the renderer reload calls getConnection
|
|
// and main short-circuits to the latched error without
|
|
// re-running install.ps1.
|
|
try {
|
|
await window.hermesDesktop?.resetBootstrap?.()
|
|
} catch {
|
|
// best-effort -- continue with reload regardless
|
|
}
|
|
|
|
window.location.reload()
|
|
}}
|
|
size="sm"
|
|
variant="default"
|
|
>
|
|
{copy.reloadRetry}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|