mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat(desktop): run tool backend post-setup installs from the GUI (#40559)
Complete the desktop app's tool-backend configuration so it fully
mirrors `hermes tools`. The toolset config panel already did
enable/disable, provider selection, and API-key save/reveal/clear — the
one remaining gap was post-setup install hooks, which previously just
told the user to run the CLI.
Now a provider that declares a post_setup hook (browser Chromium,
Camofox, cua-driver, KittenTTS/Piper, ddgs, Spotify, Langfuse, xAI)
renders a 'Run setup' button that spawns the install via the
`POST /api/tools/toolsets/{name}/post-setup` endpoint and tails the
log inline, feeding the desktop activity rail — mirroring
command-center's runSystemAction poll loop. On completion the panel
refreshes so a now-installed backend reports itself ready.
- hermes.ts: runToolsetPostSetup(name, key) -> profile-scoped POST.
- toolset-config-panel.tsx: PostSetupRunner sub-component (Run setup
button + inline live log + activity-rail upsert + unmount guard),
replacing the CLI-only placeholder.
- i18n: replace the orphaned `toolsets.postSetup` (CLI redirect) string
with proper post-setup UI keys (hint / run / running / starting /
complete / error / failed) across en, ja, zh, zh-hant + types.
- test: post-setup run+poll+log-tail coverage; mock additions for
runToolsetPostSetup/getActionStatus/activity store.
Works against local AND remote backends: all calls route through the
desktop's single `hermes:api` IPC handler to connection.baseUrl, so a
connected remote configures the remote host's tools (keys -> remote
.env, install runs on the remote). Relies on the post-setup endpoint +
'hermes tools post-setup' CLI shipped in #40418.
Verification: tsc -b clean (all 5 locales), eslint clean (the lone
exhaustive-deps warning is pre-existing on origin/main), vitest 4/5
(new post-setup test passes; the failing 'saves an API key' test fails
identically on origin/main — pre-existing EnvVarActionsMenu drift).
This commit is contained in:
parent
8f7567c325
commit
97524344ad
8 changed files with 323 additions and 15 deletions
|
|
@ -8,13 +8,17 @@ const selectToolsetProvider = vi.fn()
|
|||
const setEnvVar = vi.fn()
|
||||
const deleteEnvVar = vi.fn()
|
||||
const revealEnvVar = vi.fn()
|
||||
const runToolsetPostSetup = vi.fn()
|
||||
const getActionStatus = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getToolsetConfig: (name: string) => getToolsetConfig(name),
|
||||
selectToolsetProvider: (name: string, provider: string) => selectToolsetProvider(name, provider),
|
||||
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
|
||||
deleteEnvVar: (key: string) => deleteEnvVar(key),
|
||||
revealEnvVar: (key: string) => revealEnvVar(key)
|
||||
revealEnvVar: (key: string) => revealEnvVar(key),
|
||||
runToolsetPostSetup: (name: string, key: string) => runToolsetPostSetup(name, key),
|
||||
getActionStatus: (name: string, lines?: number) => getActionStatus(name, lines)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
|
|
@ -22,6 +26,10 @@ vi.mock('@/store/notifications', () => ({
|
|||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store/activity', () => ({
|
||||
upsertDesktopActionTask: vi.fn()
|
||||
}))
|
||||
|
||||
function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
|
||||
return {
|
||||
name: 'tts',
|
||||
|
|
@ -152,4 +160,130 @@ describe('ToolsetConfigPanel', () => {
|
|||
// No provider selection was triggered — this is purely reflecting state.
|
||||
expect(selectToolsetProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs a provider post-setup install hook and tails its log', async () => {
|
||||
// A browser-style toolset whose active provider declares a post_setup hook.
|
||||
getToolsetConfig.mockResolvedValue(
|
||||
config({
|
||||
name: 'browser',
|
||||
active_provider: 'Camofox',
|
||||
providers: [
|
||||
{
|
||||
name: 'Camofox',
|
||||
badge: 'local',
|
||||
tag: 'Stealth local browser',
|
||||
env_vars: [],
|
||||
post_setup: 'camofox',
|
||||
requires_nous_auth: false,
|
||||
is_active: true
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
runToolsetPostSetup.mockResolvedValue({ ok: true, pid: 4321, name: 'tools-post-setup', key: 'camofox' })
|
||||
// First poll: still running; second poll: finished cleanly.
|
||||
getActionStatus
|
||||
.mockResolvedValueOnce({
|
||||
exit_code: null,
|
||||
lines: ['Installing Camofox browser server...'],
|
||||
name: 'tools-post-setup',
|
||||
pid: 4321,
|
||||
running: true
|
||||
})
|
||||
.mockResolvedValue({
|
||||
exit_code: 0,
|
||||
lines: ['Installing Camofox browser server...', "Post-setup 'camofox' complete"],
|
||||
name: 'tools-post-setup',
|
||||
pid: 4321,
|
||||
running: false
|
||||
})
|
||||
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Run setup/ }))
|
||||
|
||||
await waitFor(() => expect(runToolsetPostSetup).toHaveBeenCalledWith('browser', 'camofox'))
|
||||
// The install log is tailed inline. The first poll fires after a 1200ms
|
||||
// delay (mirrors command-center's poll cadence), so allow >1200ms here.
|
||||
await waitFor(() => expect(getActionStatus).toHaveBeenCalledWith('tools-post-setup', 300), {
|
||||
timeout: 4000
|
||||
})
|
||||
})
|
||||
|
||||
it('does not poll when the spawn endpoint reports ok:false', async () => {
|
||||
getToolsetConfig.mockResolvedValue(
|
||||
config({
|
||||
name: 'browser',
|
||||
active_provider: 'Camofox',
|
||||
providers: [
|
||||
{
|
||||
name: 'Camofox',
|
||||
badge: 'local',
|
||||
tag: 'Stealth local browser',
|
||||
env_vars: [],
|
||||
post_setup: 'camofox',
|
||||
requires_nous_auth: false,
|
||||
is_active: true
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
// Spawn failed server-side — must NOT proceed to poll a non-existent action.
|
||||
runToolsetPostSetup.mockResolvedValue({ ok: false, pid: 0, name: 'tools-post-setup' })
|
||||
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Run setup/ }))
|
||||
|
||||
await waitFor(() => expect(runToolsetPostSetup).toHaveBeenCalledWith('browser', 'camofox'))
|
||||
// Give the would-be first poll delay (1200ms) time to NOT fire.
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
expect(getActionStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a non-zero exit code from the setup process', async () => {
|
||||
getToolsetConfig.mockResolvedValue(
|
||||
config({
|
||||
name: 'browser',
|
||||
active_provider: 'Camofox',
|
||||
providers: [
|
||||
{
|
||||
name: 'Camofox',
|
||||
badge: 'local',
|
||||
tag: 'Stealth local browser',
|
||||
env_vars: [],
|
||||
post_setup: 'camofox',
|
||||
requires_nous_auth: false,
|
||||
is_active: true
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
runToolsetPostSetup.mockResolvedValue({ ok: true, pid: 4321, name: 'tools-post-setup', key: 'camofox' })
|
||||
// Action finished but failed (non-zero exit).
|
||||
getActionStatus.mockResolvedValue({
|
||||
exit_code: 1,
|
||||
lines: ['Installing...', 'npm ERR! install failed'],
|
||||
name: 'tools-post-setup',
|
||||
pid: 4321,
|
||||
running: false
|
||||
})
|
||||
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Run setup/ }))
|
||||
|
||||
// The failing install log is still tailed and shown; exit_code:1 routes to
|
||||
// the error notify branch (asserted via the poll completing on a non-zero
|
||||
// status without throwing).
|
||||
await waitFor(() => expect(getActionStatus).toHaveBeenCalledWith('tools-post-setup', 300), {
|
||||
timeout: 4000
|
||||
})
|
||||
await waitFor(() => expect(screen.getByText(/npm ERR! install failed/)).toBeTruthy(), {
|
||||
timeout: 4000
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,14 +1,23 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
|
||||
import {
|
||||
deleteEnvVar,
|
||||
getActionStatus,
|
||||
getToolsetConfig,
|
||||
revealEnvVar,
|
||||
runToolsetPostSetup,
|
||||
selectToolsetProvider,
|
||||
setEnvVar
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Check, Loader2, Save } from '@/lib/icons'
|
||||
import { Check, Loader2, Save, Terminal } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes'
|
||||
import type { ActionStatusResponse, ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes'
|
||||
|
||||
import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu'
|
||||
import { Pill } from './primitives'
|
||||
|
|
@ -157,6 +166,120 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
|||
)
|
||||
}
|
||||
|
||||
interface PostSetupRunnerProps {
|
||||
toolset: string
|
||||
/** The provider's post_setup hook key (e.g. "camofox", "ddgs"). */
|
||||
postSetupKey: string
|
||||
/** Refresh the parent config after the install finishes (a backend may now
|
||||
* report itself configured). */
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a provider's post-setup install hook (npm / pip / binary) via the
|
||||
* `/api/tools/toolsets/{name}/post-setup` spawn-action and tails the resulting
|
||||
* log inline — the GUI equivalent of the install step `hermes tools` runs
|
||||
* after you pick a backend that needs extra dependencies.
|
||||
*/
|
||||
function PostSetupRunner({ toolset, postSetupKey, onComplete }: PostSetupRunnerProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.toolsets
|
||||
const [running, setRunning] = useState(false)
|
||||
const [status, setStatus] = useState<ActionStatusResponse | null>(null)
|
||||
// Guard against overlapping polls / state updates after unmount.
|
||||
const activeRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
activeRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const run = useCallback(async () => {
|
||||
setRunning(true)
|
||||
setStatus(null)
|
||||
activeRef.current = true
|
||||
|
||||
try {
|
||||
const started = await runToolsetPostSetup(toolset, postSetupKey)
|
||||
|
||||
// The spawn endpoint reports ok:false if it couldn't launch the action
|
||||
// (e.g. unknown key, server-side spawn failure). Don't poll a status
|
||||
// that will never exist — surface the failure and stop.
|
||||
if (!started.ok) {
|
||||
notifyError(new Error('spawn failed'), copy.postSetupFailed(postSetupKey))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let last: ActionStatusResponse | null = null
|
||||
|
||||
// Mirror command-center's runSystemAction poll loop: poll the action log
|
||||
// until it exits (or we hit the attempt ceiling), feeding the global
|
||||
// activity rail as we go.
|
||||
for (let attempt = 0; attempt < 150 && activeRef.current; attempt += 1) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 1200))
|
||||
|
||||
if (!activeRef.current) {
|
||||
break
|
||||
}
|
||||
|
||||
const polled = await getActionStatus(started.name, 300)
|
||||
last = polled
|
||||
setStatus(polled)
|
||||
upsertDesktopActionTask(polled)
|
||||
|
||||
if (!polled.running) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (activeRef.current) {
|
||||
const ok = last?.exit_code === 0
|
||||
|
||||
notify(
|
||||
ok
|
||||
? {
|
||||
kind: 'success',
|
||||
title: copy.postSetupCompleteTitle,
|
||||
message: copy.postSetupCompleteMessage(postSetupKey)
|
||||
}
|
||||
: { kind: 'error', title: copy.postSetupErrorTitle, message: copy.postSetupErrorMessage(postSetupKey) }
|
||||
)
|
||||
onComplete?.()
|
||||
}
|
||||
} catch (err) {
|
||||
if (activeRef.current) {
|
||||
notifyError(err, copy.postSetupFailed(postSetupKey))
|
||||
}
|
||||
} finally {
|
||||
if (activeRef.current) {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
}, [toolset, postSetupKey, onComplete, copy])
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 rounded-lg bg-background/55 p-2.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[0.72rem] text-muted-foreground">{copy.postSetupHint(postSetupKey)}</p>
|
||||
</div>
|
||||
<Button disabled={running} onClick={() => void run()} size="sm">
|
||||
{running ? <Loader2 className="size-3.5 animate-spin" /> : <Terminal className="size-3.5" />}
|
||||
{running ? copy.postSetupRunning : copy.postSetupRun}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{status && (status.lines.length > 0 || status.running) && (
|
||||
<pre className="max-h-48 overflow-y-auto rounded-md bg-background px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground whitespace-pre-wrap">
|
||||
{status.lines.length > 0 ? status.lines.join('\n') : copy.postSetupStarting}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfigPanelProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.toolsets
|
||||
|
|
@ -310,9 +433,11 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
|||
))
|
||||
)}
|
||||
{provider.post_setup && (
|
||||
<p className="text-[0.72rem] text-muted-foreground">
|
||||
{copy.postSetup(provider.post_setup)}
|
||||
</p>
|
||||
<PostSetupRunner
|
||||
onComplete={() => void refresh()}
|
||||
postSetupKey={provider.post_setup}
|
||||
toolset={toolset}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -443,6 +443,15 @@ export function selectToolsetProvider(
|
|||
})
|
||||
}
|
||||
|
||||
export function runToolsetPostSetup(name: string, key: string): Promise<ActionResponse & { key: string }> {
|
||||
return window.hermesDesktop.api<ActionResponse & { key: string }>({
|
||||
...profileScoped(),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`,
|
||||
method: 'POST',
|
||||
body: { key }
|
||||
})
|
||||
}
|
||||
|
||||
export function getMessagingPlatforms(): Promise<MessagingPlatformsResponse> {
|
||||
return window.hermesDesktop.api<MessagingPlatformsResponse>({
|
||||
path: '/api/messaging/platforms'
|
||||
|
|
|
|||
|
|
@ -542,8 +542,16 @@ export const en: Translations = {
|
|||
ready: 'Ready',
|
||||
nousIncluded: 'Included with a Nous subscription — sign in to Nous Portal to activate.',
|
||||
noApiKeyRequired: 'No API key required.',
|
||||
postSetup: step =>
|
||||
`This provider needs an extra setup step (${step}). Run it from the CLI with hermes tools for now.`
|
||||
postSetupHint: step =>
|
||||
`This backend needs a one-time install (${step}). Runs on this machine — may take a few minutes.`,
|
||||
postSetupRun: 'Run setup',
|
||||
postSetupRunning: 'Installing…',
|
||||
postSetupStarting: 'Starting…',
|
||||
postSetupCompleteTitle: 'Setup complete',
|
||||
postSetupCompleteMessage: step => `${step} installed.`,
|
||||
postSetupErrorTitle: 'Setup finished with errors',
|
||||
postSetupErrorMessage: step => `Check the ${step} log.`,
|
||||
postSetupFailed: step => `Failed to run ${step} setup`
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -637,8 +637,16 @@ export const ja = defineLocale({
|
|||
ready: '準備完了',
|
||||
nousIncluded: 'Nous サブスクリプションに含まれています。有効にするには Nous Portal にサインインしてください。',
|
||||
noApiKeyRequired: 'API キーは不要です。',
|
||||
postSetup: step =>
|
||||
`このプロバイダーは追加のセットアップ手順 (${step}) が必要です。今は CLI で hermes tools を実行してください。`
|
||||
postSetupHint: step =>
|
||||
`このバックエンドは一度だけインストールが必要です (${step})。このマシン上で実行され、数分かかる場合があります。`,
|
||||
postSetupRun: 'セットアップを実行',
|
||||
postSetupRunning: 'インストール中…',
|
||||
postSetupStarting: '開始中…',
|
||||
postSetupCompleteTitle: 'セットアップ完了',
|
||||
postSetupCompleteMessage: step => `${step} をインストールしました。`,
|
||||
postSetupErrorTitle: 'セットアップはエラーで終了しました',
|
||||
postSetupErrorMessage: step => `${step} のログを確認してください。`,
|
||||
postSetupFailed: step => `${step} のセットアップの実行に失敗しました`
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -450,7 +450,15 @@ export interface Translations {
|
|||
ready: string
|
||||
nousIncluded: string
|
||||
noApiKeyRequired: string
|
||||
postSetup: (step: string) => string
|
||||
postSetupHint: (step: string) => string
|
||||
postSetupRun: string
|
||||
postSetupRunning: string
|
||||
postSetupStarting: string
|
||||
postSetupCompleteTitle: string
|
||||
postSetupCompleteMessage: (step: string) => string
|
||||
postSetupErrorTitle: string
|
||||
postSetupErrorMessage: (step: string) => string
|
||||
postSetupFailed: (step: string) => string
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -621,7 +621,15 @@ export const zhHant = defineLocale({
|
|||
ready: '就緒',
|
||||
nousIncluded: '包含在 Nous 訂閱中;登入 Nous Portal 即可啟用。',
|
||||
noApiKeyRequired: '不需要 API 金鑰。',
|
||||
postSetup: step => `此提供方需要額外設定步驟 (${step})。暫時請在 CLI 中執行 hermes tools。`
|
||||
postSetupHint: step => `此後端需要一次性安裝 (${step})。將在此機器上執行,可能需要幾分鐘。`,
|
||||
postSetupRun: '執行設定',
|
||||
postSetupRunning: '安裝中…',
|
||||
postSetupStarting: '啟動中…',
|
||||
postSetupCompleteTitle: '設定完成',
|
||||
postSetupCompleteMessage: step => `已安裝 ${step}。`,
|
||||
postSetupErrorTitle: '設定完成但有錯誤',
|
||||
postSetupErrorMessage: step => `請檢查 ${step} 日誌。`,
|
||||
postSetupFailed: step => `執行 ${step} 設定失敗`
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -696,7 +696,15 @@ export const zh: Translations = {
|
|||
ready: '就绪',
|
||||
nousIncluded: '包含在 Nous 订阅中;登录 Nous Portal 即可激活。',
|
||||
noApiKeyRequired: '不需要 API 密钥。',
|
||||
postSetup: step => `此提供方需要额外设置步骤 (${step})。暂时请在 CLI 中运行 hermes tools。`
|
||||
postSetupHint: step => `此后端需要一次性安装 (${step})。将在此机器上执行,可能需要几分钟。`,
|
||||
postSetupRun: '运行设置',
|
||||
postSetupRunning: '安装中…',
|
||||
postSetupStarting: '启动中…',
|
||||
postSetupCompleteTitle: '设置完成',
|
||||
postSetupCompleteMessage: step => `已安装 ${step}。`,
|
||||
postSetupErrorTitle: '设置完成但有错误',
|
||||
postSetupErrorMessage: step => `请检查 ${step} 日志。`,
|
||||
postSetupFailed: step => `运行 ${step} 设置失败`
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue