feat(desktop): CLI/dashboard parity — skills hub, MCP test/toggle/catalog, maintenance ops, log filters (#57441)

* feat(desktop): CLI/dashboard parity — skills hub browser, MCP test/toggle/catalog, maintenance ops, log filters

Brings desktop GUI to parity with hermes skills/mcp/doctor/backup/debug-share/
curator/memory CLI commands and the dashboard's System + Skills-hub pages:

- Skills page: new Browse Hub tab (search official/GitHub/community sources,
  preview SKILL.md, security scan verdicts, install/update with live action log)
- MCP settings: connection test (tool listing), per-server enable/disable
  toggle, and a Catalog tab installing Nous-approved MCP servers with env prompts
- Command Center: new Maintenance section (doctor, security audit, backup,
  debug share links, curator status/pause/run, memory file status + reset)
- Command Center system logs: file (agent/errors/gateway/desktop), level, and
  substring filters instead of a fixed agent.log tail
- hermes.ts API client + types for all the above; en/zh locale strings (ja and
  zh-hant inherit via defineLocale)

* feat(desktop): backend model catalogs in toolset config — hermes tools parity

Completes the `hermes tools` parity gap: after picking an image/video
generation backend the CLI runs a model picker (e.g. FAL's multi-model
catalog with speed/strengths/price); the desktop toolset drawer now has the
same flow as a radio-card list.

- web_server: GET /api/tools/toolsets/{name}/models (catalog + current +
  default for the active or named provider row) and PUT .../model
  (validated write to image_gen.model / video_gen.model), reusing the CLI's
  plugin catalog helpers so GUI and `hermes tools` stay in lockstep
- desktop: ModelCatalogPicker in ToolsetConfigPanel — per-model cards with
  speed/strengths/price, in-use + default badges, disabled until the
  backend is the active one; provider selection now mirrors is_active
  locally so the catalog unlocks without a refetch
- tests: 3 backend endpoint tests (catalog shape invariants, persist +
  validation), 2 component tests, 2 API-contract tests; en/zh strings
This commit is contained in:
Teknium 2026-07-03 01:02:47 -07:00 committed by GitHub
parent 9e044cf795
commit c7103c637c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2711 additions and 112 deletions

View file

@ -17,7 +17,8 @@ import {
BookmarkFilled,
Download,
MessageCircle,
Trash2
Trash2,
Wrench
} from '@/lib/icons'
import { exportSession } from '@/lib/session-export'
import { cn } from '@/lib/utils'
@ -30,9 +31,14 @@ import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
export type CommandCenterSection = 'sessions' | 'system' | 'usage'
import { MaintenancePanel } from './maintenance'
const SECTIONS = ['sessions', 'system', 'usage'] as const satisfies readonly CommandCenterSection[]
export type CommandCenterSection = 'maintenance' | 'sessions' | 'system' | 'usage'
const SECTIONS = ['sessions', 'system', 'usage', 'maintenance'] as const satisfies readonly CommandCenterSection[]
const LOG_FILES = ['agent', 'errors', 'gateway', 'desktop'] as const
const LOG_LEVELS = ['ALL', 'INFO', 'WARNING', 'ERROR'] as const
const USAGE_PERIODS = [7, 30, 90] as const
type UsagePeriod = (typeof USAGE_PERIODS)[number]
@ -125,6 +131,9 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
const [query, setQuery] = useState('')
const [status, setStatus] = useState<StatusResponse | null>(null)
const [logs, setLogs] = useState<string[]>([])
const [logFile, setLogFile] = useState<(typeof LOG_FILES)[number]>('agent')
const [logLevel, setLogLevel] = useState<(typeof LOG_LEVELS)[number]>('ALL')
const [logQuery, setLogQuery] = useState('')
const [systemLoading, setSystemLoading] = useState(false)
const [systemError, setSystemError] = useState('')
const [systemAction, setSystemAction] = useState<ActionStatusResponse | null>(null)
@ -165,8 +174,9 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
const [nextStatus, nextLogs] = await Promise.all([
getStatus(),
getLogs({
file: 'agent',
lines: 120
file: logFile,
level: logLevel,
lines: 200
})
])
@ -177,7 +187,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
} finally {
setSystemLoading(false)
}
}, [])
}, [logFile, logLevel])
const refreshUsage = useCallback(async (days: UsagePeriod) => {
const requestId = usageRequestRef.current + 1
@ -203,10 +213,12 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
}, [])
useEffect(() => {
if (section === 'system' && !status && !systemLoading) {
// Refetch when the panel opens and whenever the log file/level filters
// change (refreshSystem's identity tracks them).
if (section === 'system') {
void refreshSystem()
}
}, [refreshSystem, section, status, systemLoading])
}, [refreshSystem, section])
useEffect(() => {
if (section === 'usage') {
@ -224,6 +236,17 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
const sessionListHasResults = filteredSessions.length > 0
// Client-side substring filter over the fetched tail (matches `hermes logs --search`).
const visibleLogs = useMemo(() => {
const needle = logQuery.trim().toLowerCase()
if (!needle) {
return logs
}
return logs.filter(line => line.toLowerCase().includes(needle))
}, [logQuery, logs])
const runSystemAction = useCallback(
async (kind: 'restart' | 'update') => {
setSystemError('')
@ -272,7 +295,15 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
{SECTIONS.map(value => (
<OverlayNavItem
active={section === value}
icon={value === 'sessions' ? MessageCircle : value === 'system' ? Activity : BarChart3}
icon={
value === 'sessions'
? MessageCircle
: value === 'system'
? Activity
: value === 'maintenance'
? Wrench
: BarChart3
}
key={value}
label={cc.sections[value]}
onClick={() => setSection(value)}
@ -368,6 +399,8 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
period={usagePeriod}
usage={usage}
/>
) : section === 'maintenance' ? (
<MaintenancePanel />
) : (
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-4">
<div>
@ -416,10 +449,31 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
</div>
<div className="flex min-h-0 flex-col pt-2">
<div className="mb-2 flex items-center justify-between">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<span className="text-[0.625rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
{cc.recentLogs}
</span>
<div className="flex items-center gap-2">
<SegmentedControl
onChange={id => setLogFile(id)}
options={LOG_FILES.map(value => ({ id: value, label: value }))}
value={logFile}
/>
<SegmentedControl
onChange={id => setLogLevel(id)}
options={LOG_LEVELS.map(value => ({
id: value,
label: value === 'ALL' ? 'all' : value.toLowerCase()
}))}
value={logLevel}
/>
<SearchField
containerClassName="w-44"
onChange={next => setLogQuery(next)}
placeholder={cc.logSearchPlaceholder}
value={logQuery}
/>
</div>
{systemError && (
<span className="inline-flex items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-destructive">
<AlertCircle className="size-3.5" />
@ -431,7 +485,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.65rem] leading-relaxed text-(--ui-text-tertiary)"
data-selectable-text="true"
>
{logs.length ? logs.join('\n') : cc.noLogs}
{visibleLogs.length ? visibleLogs.join('\n') : cc.noLogs}
</pre>
</div>
</div>

View file

@ -0,0 +1,416 @@
import { useCallback, useEffect, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
type ActionResponse,
type CuratorStatusResponse,
type DebugShareResponse,
getActionStatus,
getCuratorStatus,
getMemoryStatus,
type MemoryStatusResponse,
resetMemory,
runBackup,
runCurator,
runDebugShare,
runDoctor,
runSecurityAudit,
setCuratorPaused
} from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertCircle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { upsertDesktopActionTask } from '@/store/activity'
import { notify, notifyError } from '@/store/notifications'
import type { ActionStatusResponse } from '@/types/hermes'
const ACTION_POLL_MS = 1200
const ACTION_POLL_LIMIT = 240 // ~5 minutes of polling before giving up.
function formatBytes(size: number): string {
if (size <= 0) {
return ''
}
if (size >= 1024 * 1024) {
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
if (size >= 1024) {
return `${(size / 1024).toFixed(1)} KB`
}
return `${size} B`
}
/** Maintenance panel — desktop parity for `hermes doctor` / `security audit` /
* `backup` / `debug share` / `curator` / `memory` (the dashboard System page's
* ops section). Spawn-based actions tail their logs inline via the shared
* /api/actions status endpoint. */
export function MaintenancePanel() {
const { t } = useI18n()
const mm = t.commandCenter.maintenance
const [actionName, setActionName] = useState<null | string>(null)
const [actionStatus, setActionStatus] = useState<ActionStatusResponse | null>(null)
const [curator, setCurator] = useState<CuratorStatusResponse | null>(null)
const [curatorBusy, setCuratorBusy] = useState(false)
const [memory, setMemory] = useState<MemoryStatusResponse | null>(null)
const [memoryBusy, setMemoryBusy] = useState(false)
const [share, setShare] = useState<DebugShareResponse | null>(null)
const [sharing, setSharing] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
getCuratorStatus()
.then(next => !cancelled && setCurator(next))
.catch(() => {})
getMemoryStatus()
.then(next => !cancelled && setMemory(next))
.catch(() => {})
return () => void (cancelled = true)
}, [])
// Tail the most recently launched spawn action.
useEffect(() => {
if (!actionName) {
return
}
let cancelled = false
let polls = 0
let timer: null | number = null
const poll = async () => {
try {
const status = await getActionStatus(actionName, 200)
if (cancelled) {
return
}
setActionStatus(status)
upsertDesktopActionTask(status)
polls += 1
if (status.running && polls < ACTION_POLL_LIMIT) {
timer = window.setTimeout(() => void poll(), ACTION_POLL_MS)
}
} catch {
// Status endpoint hiccup — stop tailing; the activity rail still has the task.
}
}
void poll()
return () => {
cancelled = true
if (timer !== null) {
window.clearTimeout(timer)
}
}
}, [actionName])
const launch = useCallback(
async (label: string, start: () => Promise<ActionResponse>) => {
setError('')
try {
const started = await start()
setActionStatus(null)
setActionName(started.name)
notify({ kind: 'success', title: mm.actionStarted(label), message: '' })
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
notifyError(err, mm.actionFailed(label))
}
},
[mm]
)
const shareDebug = useCallback(async () => {
setSharing(true)
setShare(null)
setError('')
try {
setShare(await runDebugShare())
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
notifyError(err, mm.debugShareFailed)
} finally {
setSharing(false)
}
}, [mm])
const toggleCurator = useCallback(async () => {
if (!curator) {
return
}
setCuratorBusy(true)
try {
const next = !curator.paused
await setCuratorPaused(next)
setCurator({ ...curator, paused: next })
} catch (err) {
notifyError(err, mm.actionFailed(mm.curator))
} finally {
setCuratorBusy(false)
}
}, [curator, mm])
const doResetMemory = useCallback(
async (target: 'all' | 'memory' | 'user', label: string) => {
if (!window.confirm(mm.resetConfirm(label))) {
return
}
setMemoryBusy(true)
try {
const result = await resetMemory(target)
notify({ kind: 'success', title: mm.resetDone(result.deleted.join(', ') || label), message: '' })
setMemory(await getMemoryStatus())
} catch (err) {
notifyError(err, mm.resetFailed)
} finally {
setMemoryBusy(false)
}
},
[mm]
)
return (
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto pb-2">
{error && (
<span className="inline-flex items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-destructive">
<AlertCircle className="size-3.5" />
{error}
</span>
)}
<section>
<SectionLabel>{mm.runOps}</SectionLabel>
<OpRow
description={mm.doctorDesc}
disabled={actionStatus?.running === true}
label={mm.doctor}
onRun={() => void launch(mm.doctor, runDoctor)}
/>
<OpRow
description={mm.securityAuditDesc}
disabled={actionStatus?.running === true}
label={mm.securityAudit}
onRun={() => void launch(mm.securityAudit, runSecurityAudit)}
/>
<OpRow
description={mm.backupDesc}
disabled={actionStatus?.running === true}
label={mm.backup}
onRun={() => void launch(mm.backup, runBackup)}
/>
<OpRow
description={mm.debugShareDesc}
disabled={sharing}
label={sharing ? mm.debugShareRunning : mm.debugShare}
onRun={() => void shareDebug()}
/>
{share && Object.keys(share.urls).length > 0 && (
<div className="mt-2 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3">
<div className="mb-1.5 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{mm.debugShareLinks}
</div>
{Object.entries(share.urls).map(([key, url]) => (
<div className="flex items-center justify-between gap-2 py-1" key={key}>
<span className="min-w-0 truncate font-mono text-[0.7rem]">
{key}: {url}
</span>
<Button
onClick={() => {
void window.hermesDesktop.writeClipboard(url)
notify({ durationMs: 1500, kind: 'success', message: mm.linkCopied })
}}
size="xs"
variant="text"
>
{mm.copyLink}
</Button>
</div>
))}
</div>
)}
{actionStatus && (
<div className="mt-2">
<div className="mb-1.5 flex items-center gap-2 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{mm.viewLog}
{actionStatus.running && <span className="normal-case tracking-normal">{mm.running}</span>}
</div>
<pre
className="max-h-48 overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.65rem] leading-relaxed text-(--ui-text-tertiary)"
data-selectable-text="true"
>
{actionStatus.lines.join('\n')}
</pre>
</div>
)}
</section>
<section>
<SectionLabel>{mm.curator}</SectionLabel>
{!curator ? (
<PageLoader className="min-h-16" label={mm.curator} />
) : (
<div className="flex items-center justify-between gap-3 py-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[length:var(--conversation-text-font-size)] font-medium">{mm.curator}</span>
<Badge
className={cn(
!curator.enabled
? 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'
: curator.paused
? 'bg-amber-500/15 text-amber-400'
: 'bg-emerald-500/15 text-emerald-400'
)}
>
{!curator.enabled ? mm.curatorDisabled : curator.paused ? mm.curatorPaused : mm.curatorActive}
</Badge>
</div>
<div className="mt-0.5 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{mm.curatorDesc}
{' · '}
{curator.last_run_at ? mm.curatorLastRun(curator.last_run_at) : mm.curatorNeverRan}
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{curator.enabled && (
<Button disabled={curatorBusy} onClick={() => void toggleCurator()} size="xs" variant="text">
{curator.paused ? mm.resume : mm.pause}
</Button>
)}
<Button
disabled={actionStatus?.running === true}
onClick={() => void launch(mm.curator, runCurator)}
size="xs"
variant="textStrong"
>
{mm.runNow}
</Button>
</div>
</div>
)}
</section>
<section>
<SectionLabel>{mm.memoryData}</SectionLabel>
{!memory ? (
<PageLoader className="min-h-16" label={mm.memoryData} />
) : (
<div>
<div className="py-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{mm.memoryDataDesc}
{' · '}
{mm.memoryProvider(memory.active || mm.builtinMemory)}
</div>
<MemoryFileRow
busy={memoryBusy}
label={mm.memoryFile}
onReset={() => void doResetMemory('memory', mm.memoryFile)}
resetLabel={mm.resetMemory}
size={memory.builtin_files.memory}
sizeLabel={memory.builtin_files.memory > 0 ? formatBytes(memory.builtin_files.memory) : mm.empty}
/>
<MemoryFileRow
busy={memoryBusy}
label={mm.userFile}
onReset={() => void doResetMemory('user', mm.userFile)}
resetLabel={mm.resetUser}
size={memory.builtin_files.user}
sizeLabel={memory.builtin_files.user > 0 ? formatBytes(memory.builtin_files.user) : mm.empty}
/>
</div>
)}
</section>
</div>
)
}
function SectionLabel({ children }: { children: string }) {
return (
<div className="mb-1.5 text-[0.625rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
{children}
</div>
)
}
function OpRow({
description,
disabled,
label,
onRun
}: {
description: string
disabled?: boolean
label: string
onRun: () => void
}) {
return (
<div className="flex items-center justify-between gap-3 py-2">
<div className="min-w-0">
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{label}</div>
<div className="mt-0.5 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{description}
</div>
</div>
<Button disabled={disabled} onClick={onRun} size="xs" variant="textStrong">
{label}
</Button>
</div>
)
}
function MemoryFileRow({
busy,
label,
onReset,
resetLabel,
size,
sizeLabel
}: {
busy: boolean
label: string
onReset: () => void
resetLabel: string
size: number
sizeLabel: string
}) {
return (
<div className="flex items-center justify-between gap-3 py-2">
<div className="min-w-0">
<span className="text-[length:var(--conversation-text-font-size)] font-medium">{label}</span>
<span className="ml-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{sizeLabel}
</span>
</div>
<Button
className="text-destructive hover:text-destructive"
disabled={busy || size <= 0}
onClick={onReset}
size="xs"
variant="text"
>
{resetLabel}
</Button>
</div>
)
}

View file

@ -1,16 +1,27 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { getHermesConfigRecord, type HermesGateway, saveHermesConfig } from '@/hermes'
import {
getHermesConfigRecord,
getMcpCatalog,
type HermesGateway,
installMcpCatalogEntry,
type McpCatalogEntry,
saveHermesConfig,
setMcpServerEnabled,
testMcpServer
} from '@/hermes'
import { useI18n } from '@/i18n'
import { Wrench } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $activeSessionId } from '@/store/session'
import type { HermesConfigRecord } from '@/types/hermes'
import type { HermesConfigRecord, McpServerTestResponse } from '@/types/hermes'
import { EmptyState, LoadingState, Pill, SettingsContent } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
@ -21,6 +32,7 @@ interface McpSettingsProps {
}
type McpServers = Record<string, Record<string, unknown>>
type McpView = 'catalog' | 'servers'
const EMPTY_SERVER = {
command: '',
@ -47,12 +59,16 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
const { t } = useI18n()
const m = t.settings.mcp
const activeSessionId = useStore($activeSessionId)
const [view, setView] = useState<McpView>('servers')
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [selected, setSelected] = useState<string | null>(null)
const [name, setName] = useState('')
const [body, setBody] = useState('')
const [saving, setSaving] = useState(false)
const [reloading, setReloading] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<McpServerTestResponse | null>(null)
const [togglingEnabled, setTogglingEnabled] = useState(false)
useEffect(() => {
let cancelled = false
@ -89,8 +105,18 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
setName(selected ?? '')
setBody(JSON.stringify(server ?? EMPTY_SERVER, null, 2))
setTestResult(null)
}, [selected, servers])
const refreshConfig = useCallback(async () => {
try {
const next = await getHermesConfigRecord()
setConfig(next)
} catch (err) {
notifyError(err, m.failedLoad)
}
}, [m.failedLoad])
if (!config) {
return <LoadingState label={m.loading} />
}
@ -185,88 +211,324 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
}
}
const runTest = async (serverName: string) => {
setTesting(true)
setTestResult(null)
try {
const result = await testMcpServer(serverName)
setTestResult(result)
} catch (err) {
setTestResult({ ok: false, error: err instanceof Error ? err.message : String(err), tools: [] })
} finally {
setTesting(false)
}
}
const toggleEnabled = async (serverName: string, enabled: boolean) => {
setTogglingEnabled(true)
try {
await setMcpServerEnabled(serverName, enabled)
// Mirror the change locally so the editor and list stay in sync.
const nextServers = { ...servers, [serverName]: { ...servers[serverName], enabled } }
setConfig({ ...config, mcp_servers: nextServers })
notify({
kind: 'success',
title: enabled ? m.serverEnabled(serverName) : m.serverDisabled(serverName),
message: ''
})
} catch (err) {
notifyError(err, m.toggleFailed(serverName))
} finally {
setTogglingEnabled(false)
}
}
const selectedEnabled = selected ? servers[selected]?.enabled !== false : true
return (
<SettingsContent>
<div className="mb-4 flex items-center justify-end gap-4">
<Button onClick={() => setSelected(null)} size="xs" variant="text">
{m.newServer}
</Button>
<Button disabled={reloading} onClick={() => void reloadMcp()} size="xs" variant="text">
{reloading ? m.reloading : m.reload}
</Button>
</div>
<div className="grid min-h-0 gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">
<div className="min-h-64">
{names.length === 0 ? (
<EmptyState description={m.emptyDesc} title={m.emptyTitle} />
) : (
<div className="grid gap-0.5">
{names.map(serverName => {
const server = servers[serverName]
const active = selected === serverName
return (
<button
className={cn(
'scroll-mt-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover)',
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-muted-foreground'
)}
id={`mcp-server-${serverName}`}
key={serverName}
onClick={() => setSelected(serverName)}
type="button"
>
<div className="truncate text-sm font-medium">{serverName}</div>
<div className="mt-1 flex items-center gap-1.5">
<Pill>{transportLabel(server)}</Pill>
{server.disabled === true && <Pill>{m.disabled}</Pill>}
</div>
</button>
)
})}
</div>
)}
<div className="mb-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<TabButton active={view === 'servers'} label={m.tabServers} onClick={() => setView('servers')} />
<TabButton active={view === 'catalog'} label={m.tabCatalog} onClick={() => setView('catalog')} />
</div>
<div className="grid content-start gap-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Wrench className="size-4 text-muted-foreground" />
{selected ? m.editServer : m.newServer}
</div>
<label className="grid gap-1.5">
<span className="text-xs text-muted-foreground">{m.name}</span>
<Input onChange={event => setName(event.currentTarget.value)} placeholder="filesystem" value={name} />
</label>
<label className="grid gap-1.5">
<span className="text-xs text-muted-foreground">{m.serverJson}</span>
<Textarea
className="min-h-80 font-mono text-xs"
onChange={event => setBody(event.currentTarget.value)}
spellCheck={false}
value={body}
/>
</label>
<div className="flex items-center justify-between">
{selected ? (
<Button
className="text-destructive hover:text-destructive"
disabled={saving}
onClick={() => void removeServer(selected)}
size="xs"
variant="text"
>
{m.remove}
</Button>
) : (
<span />
)}
<Button disabled={saving} onClick={() => void saveServer()} size="sm">
{saving ? t.common.saving : m.saveServer}
{view === 'servers' && (
<div className="flex items-center gap-4">
<Button onClick={() => setSelected(null)} size="xs" variant="text">
{m.newServer}
</Button>
<Button disabled={reloading} onClick={() => void reloadMcp()} size="xs" variant="text">
{reloading ? m.reloading : m.reload}
</Button>
</div>
</div>
)}
</div>
{view === 'catalog' ? (
<McpCatalogBrowser onInstalled={() => void refreshConfig()} />
) : (
<div className="grid min-h-0 gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">
<div className="min-h-64">
{names.length === 0 ? (
<EmptyState description={m.emptyDesc} title={m.emptyTitle} />
) : (
<div className="grid gap-0.5">
{names.map(serverName => {
const server = servers[serverName]
const active = selected === serverName
return (
<button
className={cn(
'scroll-mt-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover)',
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-muted-foreground'
)}
id={`mcp-server-${serverName}`}
key={serverName}
onClick={() => setSelected(serverName)}
type="button"
>
<div className="truncate text-sm font-medium">{serverName}</div>
<div className="mt-1 flex items-center gap-1.5">
<Pill>{transportLabel(server)}</Pill>
{(server.enabled === false || server.disabled === true) && <Pill>{m.disabled}</Pill>}
</div>
</button>
)
})}
</div>
)}
</div>
<div className="grid content-start gap-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Wrench className="size-4 text-muted-foreground" />
{selected ? m.editServer : m.newServer}
</div>
{selected && (
<div className="flex items-center gap-2">
<Button disabled={testing} onClick={() => void runTest(selected)} size="xs" variant="text">
{testing ? m.testing : m.test}
</Button>
<Switch
aria-label={selectedEnabled ? m.disableServer(selected) : m.enableServer(selected)}
checked={selectedEnabled}
disabled={togglingEnabled}
onCheckedChange={checked => void toggleEnabled(selected, checked)}
/>
</div>
)}
</div>
{testResult && (
<div
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 text-xs',
testResult.ok ? 'text-emerald-400' : 'text-destructive'
)}
>
{testResult.ok ? m.testOk(testResult.tools.length) : `${m.testFailed}: ${testResult.error ?? ''}`}
{testResult.ok && testResult.tools.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{testResult.tools.map(tool => (
<span
className="rounded-md bg-(--ui-bg-quinary) px-1.5 py-0.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)"
key={tool.name}
title={tool.description}
>
{tool.name}
</span>
))}
</div>
)}
</div>
)}
<label className="grid gap-1.5">
<span className="text-xs text-muted-foreground">{m.name}</span>
<Input onChange={event => setName(event.currentTarget.value)} placeholder="filesystem" value={name} />
</label>
<label className="grid gap-1.5">
<span className="text-xs text-muted-foreground">{m.serverJson}</span>
<Textarea
className="min-h-80 font-mono text-xs"
onChange={event => setBody(event.currentTarget.value)}
spellCheck={false}
value={body}
/>
</label>
<div className="flex items-center justify-between">
{selected ? (
<Button
className="text-destructive hover:text-destructive"
disabled={saving}
onClick={() => void removeServer(selected)}
size="xs"
variant="text"
>
{m.remove}
</Button>
) : (
<span />
)}
<Button disabled={saving} onClick={() => void saveServer()} size="sm">
{saving ? t.common.saving : m.saveServer}
</Button>
</div>
</div>
</div>
)}
</SettingsContent>
)
}
function TabButton({ active, label, onClick }: { active: boolean; label: string; onClick: () => void }) {
return (
<button
className={cn(
'cursor-pointer text-sm font-medium transition-colors',
active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'
)}
onClick={onClick}
type="button"
>
{label}
</button>
)
}
/** Nous-approved MCP catalog browser the desktop counterpart of
* `hermes mcp catalog` / `hermes mcp install` and the dashboard MCP page. */
function McpCatalogBrowser({ onInstalled }: { onInstalled: () => void }) {
const { t } = useI18n()
const m = t.settings.mcp
const [entries, setEntries] = useState<McpCatalogEntry[] | null>(null)
const [installing, setInstalling] = useState<null | string>(null)
// Per-entry env var drafts for catalog entries that need credentials.
const [envDrafts, setEnvDrafts] = useState<Record<string, Record<string, string>>>({})
const [envOpenFor, setEnvOpenFor] = useState<null | string>(null)
useEffect(() => {
let cancelled = false
getMcpCatalog()
.then(response => {
if (!cancelled) {
setEntries(response.entries)
}
})
.catch(err => {
if (!cancelled) {
notifyError(err, m.catalogLoadFailed)
setEntries([])
}
})
return () => void (cancelled = true)
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
}, [])
const install = async (entry: McpCatalogEntry) => {
const required = entry.required_env.filter(env => env.required)
const draft = envDrafts[entry.name] ?? {}
if (required.some(env => !draft[env.name]?.trim())) {
if (envOpenFor !== entry.name) {
setEnvOpenFor(entry.name)
return
}
notify({ kind: 'error', title: m.catalogEnvPrompt(entry.name), message: m.catalogEnvRequired })
return
}
setInstalling(entry.name)
try {
await installMcpCatalogEntry(entry.name, draft)
notify({ kind: 'success', title: m.catalogInstallStarted(entry.name), message: '' })
setEntries(
current =>
current?.map(row => (row.name === entry.name ? { ...row, installed: true, enabled: true } : row)) ?? current
)
setEnvOpenFor(null)
onInstalled()
} catch (err) {
notifyError(err, m.catalogInstallFailed(entry.name))
} finally {
setInstalling(null)
}
}
if (entries === null) {
return <LoadingState label={m.catalogLoading} />
}
if (entries.length === 0) {
return <EmptyState description={m.catalogEmpty} title={m.tabCatalog} />
}
return (
<div>
{entries.map(entry => {
const envOpen = envOpenFor === entry.name
const draft = envDrafts[entry.name] ?? {}
return (
<div className="px-0 py-2.5" key={entry.name}>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{entry.name}</span>
<Pill>{entry.transport}</Pill>
{entry.installed && (
<Badge className="bg-emerald-500/15 text-emerald-400">
{entry.enabled ? m.catalogEnabled : m.catalogInstalled}
</Badge>
)}
{entry.needs_install && !entry.installed && <Pill>{m.catalogNeedsInstall}</Pill>}
</div>
<Button
disabled={entry.installed || installing !== null}
onClick={() => void install(entry)}
size="xs"
variant="textStrong"
>
{installing === entry.name
? m.catalogInstalling
: entry.installed
? m.catalogInstalled
: m.catalogInstall}
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">{entry.description}</p>
{envOpen && entry.required_env.length > 0 && (
<div className="mt-2 grid max-w-md gap-2">
{entry.required_env.map(env => (
<label className="grid gap-1" key={env.name}>
<span className="text-xs text-muted-foreground">
{env.prompt || env.name}
{env.required ? ' *' : ''}
</span>
<Input
onChange={event =>
setEnvDrafts(prev => ({
...prev,
[entry.name]: { ...prev[entry.name], [env.name]: event.currentTarget.value }
}))
}
type="password"
value={draft[env.name] ?? ''}
/>
</label>
))}
</div>
)}
</div>
)
})}
</div>
)
}

View file

@ -4,6 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ToolsetConfig } from '@/types/hermes'
const getToolsetConfig = vi.fn()
const getToolsetModels = vi.fn()
const selectToolsetModel = vi.fn()
const selectToolsetProvider = vi.fn()
const setEnvVar = vi.fn()
const deleteEnvVar = vi.fn()
@ -13,6 +15,8 @@ const getActionStatus = vi.fn()
vi.mock('@/hermes', () => ({
getToolsetConfig: (name: string) => getToolsetConfig(name),
getToolsetModels: (name: string, provider?: string) => getToolsetModels(name, provider),
selectToolsetModel: (name: string, model: string, provider?: string) => selectToolsetModel(name, model, provider),
selectToolsetProvider: (name: string, provider: string) => selectToolsetProvider(name, provider),
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
deleteEnvVar: (key: string) => deleteEnvVar(key),
@ -63,6 +67,14 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
beforeEach(() => {
getToolsetConfig.mockResolvedValue(config())
getToolsetModels.mockResolvedValue({
name: 'tts',
has_models: false,
models: [],
current: null,
default: null
})
selectToolsetModel.mockResolvedValue({ ok: true, name: 'image_gen', model: 'z-image-turbo' })
selectToolsetProvider.mockResolvedValue({ ok: true, name: 'tts', provider: 'ElevenLabs' })
setEnvVar.mockResolvedValue({ ok: true })
deleteEnvVar.mockResolvedValue({ ok: true })
@ -93,6 +105,58 @@ describe('ToolsetConfigPanel', () => {
await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('tts', 'ElevenLabs'))
})
it('shows a backend model catalog for image_gen and persists a pick', async () => {
getToolsetConfig.mockResolvedValue(
config({
name: 'image_gen',
active_provider: 'FAL.ai',
providers: [
{
name: 'FAL.ai',
badge: 'paid',
tag: 'Multi-model image generation',
env_vars: [],
post_setup: null,
requires_nous_auth: false,
is_active: true
}
]
})
)
getToolsetModels.mockResolvedValue({
name: 'image_gen',
has_models: true,
provider: 'FAL.ai',
plugin: 'fal',
models: [
{ id: 'z-image-turbo', display: 'Z-Image Turbo', speed: 'fast', strengths: 'cheap drafts', price: '$0.005' },
{ id: 'flux-2-pro', display: 'FLUX 2 Pro', speed: 'slow', strengths: 'quality', price: '$0.05' }
],
current: 'z-image-turbo',
default: 'z-image-turbo'
})
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="image_gen" />)
// Both catalog rows render with their picker metadata.
expect(await screen.findByText('Z-Image Turbo')).toBeTruthy()
expect(screen.getByText('FLUX 2 Pro')).toBeTruthy()
expect(getToolsetModels).toHaveBeenCalledWith('image_gen', 'FAL.ai')
// Picking a different model persists via the model endpoint.
fireEvent.click(screen.getByRole('button', { name: /FLUX 2 Pro/ }))
await waitFor(() => expect(selectToolsetModel).toHaveBeenCalledWith('image_gen', 'flux-2-pro', 'FAL.ai'))
})
it('does not fetch model catalogs for toolsets without them', async () => {
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
await screen.findByText('Microsoft Edge TTS')
expect(getToolsetModels).not.toHaveBeenCalled()
})
it('saves an API key for a provider env var', async () => {
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)

View file

@ -7,8 +7,10 @@ import {
deleteEnvVar,
getActionStatus,
getToolsetConfig,
getToolsetModels,
revealEnvVar,
runToolsetPostSetup,
selectToolsetModel,
selectToolsetProvider,
setEnvVar
} from '@/hermes'
@ -17,7 +19,13 @@ 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 { ActionStatusResponse, ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes'
import type {
ActionStatusResponse,
ToolEnvVar,
ToolProvider,
ToolsetConfig,
ToolsetModelsResponse
} from '@/types/hermes'
import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu'
import { Pill } from './primitives'
@ -29,6 +37,10 @@ interface ToolsetConfigPanelProps {
onConfiguredChange?: () => void
}
/** Toolsets whose backends expose a selectable model catalog (mirrors the
* backend's _MODEL_CATALOG_TOOLSETS map). */
const MODEL_CATALOG_TOOLSETS = new Set(['image_gen', 'video_gen'])
function providerConfigured(provider: ToolProvider, envState: Record<string, boolean>): boolean {
if (provider.env_vars.length === 0) {
return true
@ -283,6 +295,135 @@ function PostSetupRunner({ toolset, postSetupKey, onComplete }: PostSetupRunnerP
)
}
interface ModelCatalogPickerProps {
toolset: string
/** The picker-row name of the provider whose catalog to show. */
providerName: string
/** True when this provider is the one written to config selecting a model
* only makes sense for the active backend. */
isActiveBackend: boolean
}
/**
* Backend model catalog the GUI counterpart of the model picker `hermes
* tools` runs after you choose an image/video generation backend (e.g. FAL's
* multi-model catalog). Renders speed / strengths / price per model as a
* radio-card list and persists the choice to `image_gen.model` /
* `video_gen.model`.
*/
function ModelCatalogPicker({ toolset, providerName, isActiveBackend }: ModelCatalogPickerProps) {
const { t } = useI18n()
const copy = t.settings.toolsets
const [catalog, setCatalog] = useState<ToolsetModelsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
getToolsetModels(toolset, providerName)
.then(next => {
if (!cancelled) {
setCatalog(next)
}
})
.catch(() => {
// Backend predates the models endpoint or the provider has no
// catalog — hide the section entirely rather than erroring.
if (!cancelled) {
setCatalog(null)
}
})
.finally(() => {
if (!cancelled) {
setLoading(false)
}
})
return () => void (cancelled = true)
}, [toolset, providerName])
const pick = async (modelId: string) => {
setSaving(modelId)
try {
await selectToolsetModel(toolset, modelId, providerName)
setCatalog(current => (current ? { ...current, current: modelId } : current))
notify({ kind: 'success', title: copy.modelSelectedTitle, message: copy.modelSelectedMessage(modelId) })
} catch (err) {
notifyError(err, copy.failedSelectModel(modelId))
} finally {
setSaving(null)
}
}
if (loading) {
return (
<div className="flex items-center gap-2 px-1 py-2 text-[0.72rem] text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
{copy.loadingModels}
</div>
)
}
if (!catalog || !catalog.has_models || catalog.models.length === 0) {
return null
}
const selected = catalog.current ?? catalog.default
return (
<div className="grid gap-1.5">
<div className="flex items-baseline justify-between gap-2 px-0.5">
<span className="text-[0.72rem] font-medium">{copy.modelSectionTitle}</span>
<span className="text-[0.68rem] text-muted-foreground">{copy.modelCount(catalog.models.length)}</span>
</div>
{!isActiveBackend && <p className="px-0.5 text-[0.68rem] text-muted-foreground">{copy.modelInactiveHint}</p>}
<div className="grid gap-1">
{catalog.models.map(model => {
const isSelected = selected === model.id
const isDefault = catalog.default === model.id
return (
<button
aria-pressed={isSelected}
className={cn(
'grid gap-0.5 rounded-lg border px-2.5 py-2 text-left transition',
isSelected
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
: 'border-transparent bg-background/55 hover:bg-accent/40',
!isActiveBackend && 'opacity-60'
)}
disabled={saving !== null || !isActiveBackend}
key={model.id}
onClick={() => void pick(model.id)}
type="button"
>
<span className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{model.display || model.id}</span>
{isSelected && (
<Pill tone="primary">
<Check className="size-3" />
{copy.modelInUse}
</Pill>
)}
{!isSelected && isDefault && <Pill>{copy.modelDefault}</Pill>}
{saving === model.id && <Loader2 className="size-3 animate-spin" />}
</span>
<span className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[0.68rem] text-muted-foreground">
{model.speed && <span>{model.speed}</span>}
{model.strengths && <span>{model.strengths}</span>}
{model.price && <span className="font-mono">{model.price}</span>}
</span>
</button>
)
})}
</div>
</div>
)
}
export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfigPanelProps) {
const { t } = useI18n()
const copy = t.settings.toolsets
@ -346,6 +487,17 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
try {
await selectToolsetProvider(toolset, provider.name)
// Mirror the backend write locally so dependent UI (model catalog
// enablement) tracks the new active backend without a refetch.
setCfg(current =>
current
? {
...current,
active_provider: provider.name,
providers: current.providers.map(p => ({ ...p, is_active: p.name === provider.name }))
}
: current
)
notify({ kind: 'success', title: copy.selectedTitle, message: copy.selectedMessage(provider.name) })
onConfiguredChange?.()
} catch (err) {
@ -440,6 +592,13 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
toolset={toolset}
/>
)}
{MODEL_CATALOG_TOOLSETS.has(toolset) && (
<ModelCatalogPicker
isActiveBackend={provider.is_active || cfg?.active_provider === provider.name}
providerName={provider.name}
toolset={toolset}
/>
)}
</div>
)}
</div>

View file

@ -0,0 +1,461 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
getActionStatus,
getSkillHubSources,
installSkillFromHub,
previewSkillHub,
scanSkillHub,
searchSkillsHub,
type SkillHubInstalledEntry,
type SkillHubPreview,
type SkillHubResult,
type SkillHubScanResult,
type SkillHubSource,
updateSkillsFromHub
} from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { upsertDesktopActionTask } from '@/store/activity'
import { notify, notifyError } from '@/store/notifications'
const ACTION_POLL_MS = 1200
function trustTone(level: string): string {
switch (level) {
case 'builtin':
return 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)'
case 'trusted':
return 'bg-emerald-500/15 text-emerald-400'
default:
return 'bg-amber-500/15 text-amber-400'
}
}
function verdictTone(policy: string): string {
switch (policy) {
case 'allow':
return 'text-emerald-400'
case 'block':
return 'text-destructive'
default:
return 'text-amber-400'
}
}
interface SkillsHubProps {
/** Called after an install/uninstall/update finishes so the parent can refresh the installed-skills list. */
onInstalledChange?: () => void
query: string
}
export function SkillsHub({ onInstalledChange, query }: SkillsHubProps) {
const { t } = useI18n()
const h = t.skills.hub
const [sources, setSources] = useState<SkillHubSource[]>([])
const [featured, setFeatured] = useState<SkillHubResult[]>([])
const [installed, setInstalled] = useState<Record<string, SkillHubInstalledEntry>>({})
const [sourcesLoading, setSourcesLoading] = useState(true)
const [results, setResults] = useState<SkillHubResult[]>([])
const [searching, setSearching] = useState(false)
const [searched, setSearched] = useState(false)
const [timedOut, setTimedOut] = useState<string[]>([])
const [searchMs, setSearchMs] = useState<null | number>(null)
// Live log tail for the most recent install/uninstall/update action.
const [action, setAction] = useState<null | string>(null)
const [actionLog, setActionLog] = useState<string[]>([])
const [actionRunning, setActionRunning] = useState(false)
// Preview/scan dialog state.
const [detail, setDetail] = useState<null | SkillHubResult>(null)
const [preview, setPreview] = useState<null | SkillHubPreview>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [scan, setScan] = useState<null | SkillHubScanResult>(null)
const [scanning, setScanning] = useState(false)
const searchSeq = useRef(0)
useEffect(() => {
let cancelled = false
getSkillHubSources()
.then(response => {
if (cancelled) {
return
}
setSources(response.sources)
setFeatured(response.featured)
setInstalled(response.installed)
})
.catch(err => notifyError(err, h.loadFailed))
.finally(() => {
if (!cancelled) {
setSourcesLoading(false)
}
})
return () => void (cancelled = true)
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
}, [])
// Debounced hub search driven by the shared page search field.
useEffect(() => {
const trimmed = query.trim()
if (!trimmed) {
setResults([])
setSearched(false)
setSearching(false)
setTimedOut([])
setSearchMs(null)
return
}
const seq = searchSeq.current + 1
searchSeq.current = seq
setSearching(true)
const timer = window.setTimeout(() => {
const started = performance.now()
searchSkillsHub(trimmed)
.then(response => {
if (searchSeq.current !== seq) {
return
}
setResults(response.results)
setTimedOut(response.timed_out || [])
setInstalled(prev => ({ ...prev, ...(response.installed || {}) }))
setSearchMs(Math.round(performance.now() - started))
setSearched(true)
})
.catch(err => {
if (searchSeq.current === seq) {
notifyError(err, h.searchFailed)
setResults([])
setSearched(true)
}
})
.finally(() => {
if (searchSeq.current === seq) {
setSearching(false)
}
})
}, 350)
return () => window.clearTimeout(timer)
}, [h, query])
// Poll a spawned hub action's log until it exits, then refresh installed state.
useEffect(() => {
if (!action) {
return
}
let cancelled = false
let timer: null | number = null
const poll = async () => {
try {
const status = await getActionStatus(action, 200)
if (cancelled) {
return
}
setActionLog(status.lines)
setActionRunning(status.running)
upsertDesktopActionTask(status)
if (status.running) {
timer = window.setTimeout(() => void poll(), ACTION_POLL_MS)
} else {
getSkillHubSources()
.then(response => {
if (!cancelled) {
setInstalled(response.installed)
}
})
.catch(() => {})
onInstalledChange?.()
}
} catch {
if (!cancelled) {
setActionRunning(false)
}
}
}
void poll()
return () => {
cancelled = true
if (timer !== null) {
window.clearTimeout(timer)
}
}
}, [action, onInstalledChange])
const install = useCallback(
async (identifier: string, name: string) => {
try {
const started = await installSkillFromHub(identifier)
notify({ kind: 'success', title: h.installStarted(name), message: h.actionLog })
setActionLog([])
setActionRunning(true)
setAction(started.name)
setDetail(null)
} catch (err) {
notifyError(err, h.actionFailed)
}
},
[h]
)
const updateAll = useCallback(async () => {
try {
const started = await updateSkillsFromHub()
notify({ kind: 'success', title: h.updateStarted, message: h.actionLog })
setActionLog([])
setActionRunning(true)
setAction(started.name)
} catch (err) {
notifyError(err, h.actionFailed)
}
}, [h])
const openDetail = useCallback(
(skill: SkillHubResult) => {
setDetail(skill)
setPreview(null)
setScan(null)
setPreviewLoading(true)
previewSkillHub(skill.identifier)
.then(setPreview)
.catch(err => notifyError(err, h.previewFailed))
.finally(() => setPreviewLoading(false))
},
[h]
)
const runScan = useCallback(
(identifier: string) => {
setScanning(true)
scanSkillHub(identifier)
.then(setScan)
.catch(err => notifyError(err, h.scanFailed))
.finally(() => setScanning(false))
},
[h]
)
const isInstalled = useCallback((identifier: string) => Boolean(installed[identifier]), [installed])
const hasInstalled = Object.keys(installed).length > 0
const showLanding = !searched && !searching
const listed = showLanding ? featured : results
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
{sourcesLoading ? (
<span>{h.connectingHubs}</span>
) : (
<>
<span>{h.connectedHubs}</span>
{sources.map(source => {
const degraded = source.available === false || source.rate_limited === true
return (
<Badge
className={cn(
degraded ? 'bg-amber-500/15 text-amber-400' : 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)'
)}
key={source.id}
>
{source.label}
</Badge>
)
})}
</>
)}
</div>
{hasInstalled && (
<Button disabled={actionRunning} onClick={() => void updateAll()} size="xs" variant="text">
{actionRunning ? h.updating : h.updateAll}
</Button>
)}
</div>
{searched && !searching && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{h.resultCount(results.length, searchMs)}</span>
{timedOut.length > 0 && <span className="text-amber-400">{h.timedOut(timedOut.join(', '))}</span>}
</div>
)}
{searching ? (
<PageLoader className="min-h-40" label={h.searching} />
) : listed.length === 0 ? (
<div className="grid min-h-40 place-items-center text-center">
<div className="max-w-md text-xs text-muted-foreground">{searched ? h.noResults : h.landingHint}</div>
</div>
) : (
<div className="space-y-1">
{showLanding && (
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{h.featured}
</div>
)}
<div>
{listed.map(skill => (
<div
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
key={skill.identifier}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{skill.name}</span>
<Badge className={trustTone(skill.trust_level)}>
{h.trust[skill.trust_level] ?? skill.trust_level}
</Badge>
{isInstalled(skill.identifier) && (
<Badge className="bg-emerald-500/15 text-emerald-400">{h.installed}</Badge>
)}
</div>
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{skill.description}</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button onClick={() => openDetail(skill)} size="xs" variant="text">
{h.preview}
</Button>
<Button
disabled={actionRunning || isInstalled(skill.identifier)}
onClick={() => void install(skill.identifier, skill.name)}
size="xs"
variant="textStrong"
>
{isInstalled(skill.identifier) ? h.installed : h.install}
</Button>
</div>
</div>
))}
</div>
</div>
)}
{action && actionLog.length > 0 && (
<div>
<div className="mb-1.5 flex items-center gap-2 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{h.actionLog}
{actionRunning && <Codicon name="loading" size="0.75rem" spinning />}
</div>
<pre
className="max-h-48 overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.65rem] leading-relaxed text-(--ui-text-tertiary)"
data-selectable-text="true"
>
{actionLog.join('\n')}
</pre>
</div>
)}
<Dialog onOpenChange={open => !open && setDetail(null)} open={detail !== null}>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-hidden">
{detail && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span className="truncate">{detail.name}</span>
<Badge className={trustTone(detail.trust_level)}>
{h.trust[detail.trust_level] ?? detail.trust_level}
</Badge>
</DialogTitle>
<DialogDescription className="truncate">{detail.identifier}</DialogDescription>
</DialogHeader>
<div className="min-h-0 space-y-3 overflow-y-auto">
{scan && (
<div className="rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 text-xs">
<div className={cn('font-medium', verdictTone(scan.policy))}>
{scan.policy === 'allow' ? h.policyAllow : scan.policy === 'block' ? h.policyBlock : h.policyAsk}
{' · '}
{scan.verdict === 'safe'
? h.verdictSafe
: scan.verdict === 'dangerous'
? h.verdictDangerous
: h.verdictCaution}
</div>
<div className="mt-1 text-muted-foreground">
{scan.findings.length === 0 ? h.noFindings : h.findings(scan.findings.length)}
</div>
{scan.findings.slice(0, 12).map((finding, index) => (
<div className="mt-1.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)" key={index}>
[{finding.severity}] {finding.file}
{finding.line !== null ? `:${finding.line}` : ''} {finding.description}
</div>
))}
</div>
)}
{previewLoading ? (
<PageLoader className="min-h-32" label={h.searching} />
) : preview ? (
<>
<pre
className="max-h-72 overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.68rem] leading-relaxed"
data-selectable-text="true"
>
{preview.skill_md || h.noReadme}
</pre>
{preview.files.length > 0 && (
<div className="text-xs text-muted-foreground">
<span className="font-medium">{h.files}:</span> {preview.files.join(', ')}
</div>
)}
</>
) : null}
</div>
<DialogFooter>
<Button disabled={scanning} onClick={() => runScan(detail.identifier)} size="sm" variant="text">
{scanning ? h.scanning : h.scan}
</Button>
<Button
disabled={actionRunning || isInstalled(detail.identifier)}
onClick={() => void install(detail.identifier, detail.name)}
size="sm"
>
{isInstalled(detail.identifier) ? h.installed : h.install}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -23,7 +23,9 @@ import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } fro
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
const SKILLS_MODES = ['skills', 'toolsets'] as const
import { SkillsHub } from './hub'
const SKILLS_MODES = ['skills', 'toolsets', 'hub'] as const
type SkillsMode = (typeof SKILLS_MODES)[number]
function categoryFor(skill: SkillInfo): string {
@ -216,8 +218,16 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
) : undefined
}
onSearchChange={setQuery}
searchHidden={mode === 'skills' ? (skills?.length ?? 0) === 0 : (toolsets?.length ?? 0) === 0}
searchPlaceholder={mode === 'skills' ? t.skills.searchSkills : t.skills.searchToolsets}
searchHidden={
mode === 'skills' ? (skills?.length ?? 0) === 0 : mode === 'toolsets' && (toolsets?.length ?? 0) === 0
}
searchPlaceholder={
mode === 'skills'
? t.skills.searchSkills
: mode === 'hub'
? t.skills.hub.searchPlaceholder
: t.skills.searchToolsets
}
searchTrailingAction={
<Button
aria-label={refreshing ? t.skills.refreshing : t.skills.refresh}
@ -241,10 +251,17 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
{t.skills.tabToolsets}
</TextTab>
<TextTab active={mode === 'hub'} onClick={() => setMode('hub')}>
{t.skills.tabHub}
</TextTab>
</>
}
>
{!skills || !toolsets ? (
{mode === 'hub' ? (
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
<SkillsHub onInstalledChange={() => void refreshCapabilities()} query={query} />
</div>
) : !skills || !toolsets ? (
<PageLoader label={t.skills.loading} />
) : mode === 'skills' ? (
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>

View file

@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getCuratorStatus,
getMcpCatalog,
getMemoryStatus,
getSkillHubSources,
getToolsetModels,
installSkillFromHub,
resetMemory,
runDebugShare,
searchSkillsHub,
selectToolsetModel,
setCuratorPaused,
setMcpServerEnabled,
testMcpServer
} from './hermes'
describe('Hermes REST parity helpers (hub / mcp / maintenance)', () => {
let api: ReturnType<typeof vi.fn>
beforeEach(() => {
api = vi.fn().mockResolvedValue({})
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { api }
})
})
afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(window, 'hermesDesktop')
})
it('loads hub sources with a network-tolerant timeout', async () => {
await getSkillHubSources()
expect(api).toHaveBeenCalledWith(expect.objectContaining({ path: '/api/skills/hub/sources', timeoutMs: 45_000 }))
})
it('encodes hub search params', async () => {
await searchSkillsHub('gif search', 'official', 5)
expect(api).toHaveBeenCalledWith(
expect.objectContaining({ path: '/api/skills/hub/search?q=gif+search&source=official&limit=5' })
)
})
it('installs a hub skill by identifier', async () => {
await installSkillFromHub('official/gifs/gif-search')
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/skills/hub/install',
method: 'POST',
body: { identifier: 'official/gifs/gif-search' }
})
)
})
it('tests an MCP server with a boot-tolerant timeout and encoded name', async () => {
await testMcpServer('file system')
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/mcp/servers/file%20system/test',
method: 'POST',
timeoutMs: 60_000
})
)
})
it('toggles MCP server enablement', async () => {
await setMcpServerEnabled('filesystem', false)
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/mcp/servers/filesystem/enabled',
method: 'PUT',
body: { enabled: false }
})
)
})
it('reads the MCP catalog', async () => {
await getMcpCatalog()
expect(api).toHaveBeenCalledWith(expect.objectContaining({ path: '/api/mcp/catalog' }))
})
it('reads memory status and resets a specific target', async () => {
await getMemoryStatus()
await resetMemory('user')
expect(api).toHaveBeenNthCalledWith(1, expect.objectContaining({ path: '/api/memory' }))
expect(api).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ path: '/api/memory/reset', method: 'POST', body: { target: 'user' } })
)
})
it('manages the curator', async () => {
await getCuratorStatus()
await setCuratorPaused(true)
expect(api).toHaveBeenNthCalledWith(1, expect.objectContaining({ path: '/api/curator' }))
expect(api).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ path: '/api/curator/paused', method: 'PUT', body: { paused: true } })
)
})
it('runs debug share synchronously with an upload-tolerant timeout', async () => {
await runDebugShare()
expect(api).toHaveBeenCalledWith(
expect.objectContaining({ path: '/api/ops/debug-share', method: 'POST', timeoutMs: 120_000 })
)
})
it('reads a backend model catalog scoped to a provider row', async () => {
await getToolsetModels('image_gen', 'FAL.ai')
expect(api).toHaveBeenCalledWith(
expect.objectContaining({ path: '/api/tools/toolsets/image_gen/models?provider=FAL.ai' })
)
})
it('persists a backend model selection', async () => {
await selectToolsetModel('image_gen', 'z-image-turbo', 'FAL.ai')
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/tools/toolsets/image_gen/model',
method: 'PUT',
body: { model: 'z-image-turbo', provider: 'FAL.ai' }
})
)
})
})

View file

@ -13,13 +13,19 @@ import type {
CronJob,
CronJobCreatePayload,
CronJobUpdates,
CuratorStatusResponse,
DebugShareResponse,
ElevenLabsVoicesResponse,
EnvVarInfo,
HermesConfig,
HermesConfigRecord,
LogsResponse,
McpCatalogResponse,
McpServerSummary,
McpServerTestResponse,
MemoryProviderConfig,
MemoryProviderOAuthStatus,
MemoryStatusResponse,
MessagingPlatformsResponse,
MessagingPlatformTestResponse,
MessagingPlatformUpdate,
@ -40,11 +46,16 @@ import type {
SessionInfo,
SessionMessagesResponse,
SessionSearchResponse,
SkillHubPreview,
SkillHubScanResult,
SkillHubSearchResponse,
SkillHubSourcesResponse,
SkillInfo,
StarmapGraph,
StatusResponse,
ToolsetConfig,
ToolsetInfo
ToolsetInfo,
ToolsetModelsResponse
} from '@/types/hermes'
// Desktop startup fires a burst of read-only data calls (config, profiles,
@ -93,6 +104,8 @@ export type {
CronJobCreatePayload,
CronJobSchedule,
CronJobUpdates,
CuratorStatusResponse,
DebugShareResponse,
ElevenLabsVoice,
ElevenLabsVoicesResponse,
EnvVarInfo,
@ -100,8 +113,13 @@ export type {
HermesConfig,
HermesConfigRecord,
LogsResponse,
McpCatalogEntry,
McpCatalogResponse,
McpServerSummary,
McpServerTestResponse,
MemoryProviderConfig,
MemoryProviderOAuthStatus,
MemoryStatusResponse,
MessagingEnvVarInfo,
MessagingHomeChannel,
MessagingPlatformInfo,
@ -133,12 +151,21 @@ export type {
SessionRuntimeInfo,
SessionSearchResponse,
SessionSearchResult,
SkillHubInstalledEntry,
SkillHubPreview,
SkillHubResult,
SkillHubScanResult,
SkillHubSearchResponse,
SkillHubSource,
SkillHubSourcesResponse,
SkillInfo,
StaleAuxAssignment,
StarmapGraph,
StatusResponse,
ToolsetConfig,
ToolsetInfo
ToolsetInfo,
ToolsetModel,
ToolsetModelsResponse
} from '@/types/hermes'
export class HermesGateway extends JsonRpcGatewayClient {
@ -591,6 +618,28 @@ export function getToolsetConfig(name: string): Promise<ToolsetConfig> {
})
}
export function getToolsetModels(name: string, provider?: string): Promise<ToolsetModelsResponse> {
const suffix = provider ? `?provider=${encodeURIComponent(provider)}` : ''
return window.hermesDesktop.api<ToolsetModelsResponse>({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/models${suffix}`
})
}
export function selectToolsetModel(
name: string,
model: string,
provider?: string
): Promise<{ ok: boolean; name: string; model: string }> {
return window.hermesDesktop.api<{ ok: boolean; name: string; model: string }>({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/model`,
method: 'PUT',
body: { model, provider }
})
}
export function selectToolsetProvider(
name: string,
provider: string
@ -903,3 +952,200 @@ export function getElevenLabsVoices(): Promise<ElevenLabsVoicesResponse> {
path: '/api/audio/elevenlabs/voices'
})
}
// ---------------------------------------------------------------------------
// Skills hub — search / preview / scan / install (parity with `hermes skills`
// and the dashboard's Browse-hub tab). Installs spawn background actions whose
// logs are tailed via getActionStatus().
// ---------------------------------------------------------------------------
const HUB_REQUEST_TIMEOUT_MS = 45_000
export function getSkillHubSources(): Promise<SkillHubSourcesResponse> {
return window.hermesDesktop.api<SkillHubSourcesResponse>({
...profileScoped(),
path: '/api/skills/hub/sources',
timeoutMs: HUB_REQUEST_TIMEOUT_MS
})
}
export function searchSkillsHub(query: string, source = 'all', limit = 20): Promise<SkillHubSearchResponse> {
const params = new URLSearchParams({ q: query, source, limit: String(limit) })
return window.hermesDesktop.api<SkillHubSearchResponse>({
...profileScoped(),
path: `/api/skills/hub/search?${params.toString()}`,
timeoutMs: HUB_REQUEST_TIMEOUT_MS
})
}
export function previewSkillHub(identifier: string): Promise<SkillHubPreview> {
return window.hermesDesktop.api<SkillHubPreview>({
path: `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`,
timeoutMs: HUB_REQUEST_TIMEOUT_MS
})
}
export function scanSkillHub(identifier: string): Promise<SkillHubScanResult> {
return window.hermesDesktop.api<SkillHubScanResult>({
path: `/api/skills/hub/scan?identifier=${encodeURIComponent(identifier)}`,
timeoutMs: HUB_REQUEST_TIMEOUT_MS
})
}
export function installSkillFromHub(identifier: string): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...profileScoped(),
path: '/api/skills/hub/install',
method: 'POST',
body: { identifier }
})
}
export function uninstallSkillFromHub(name: string): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...profileScoped(),
path: '/api/skills/hub/uninstall',
method: 'POST',
body: { name }
})
}
export function updateSkillsFromHub(): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...profileScoped(),
path: '/api/skills/hub/update',
method: 'POST',
body: {}
})
}
// ---------------------------------------------------------------------------
// MCP servers — structured list / test / enable toggle / catalog (parity with
// `hermes mcp` and the dashboard MCP page). Raw JSON editing stays in
// config.yaml via saveHermesConfig.
// ---------------------------------------------------------------------------
export function listMcpServers(): Promise<{ servers: McpServerSummary[] }> {
return window.hermesDesktop.api<{ servers: McpServerSummary[] }>({
...profileScoped(),
path: '/api/mcp/servers'
})
}
export function testMcpServer(name: string): Promise<McpServerTestResponse> {
return window.hermesDesktop.api<McpServerTestResponse>({
...profileScoped(),
path: `/api/mcp/servers/${encodeURIComponent(name)}/test`,
method: 'POST',
// Connect + list tools can be slow for stdio servers that boot a process.
timeoutMs: 60_000
})
}
export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: `/api/mcp/servers/${encodeURIComponent(name)}/enabled`,
method: 'PUT',
body: { enabled }
})
}
export function getMcpCatalog(): Promise<McpCatalogResponse> {
return window.hermesDesktop.api<McpCatalogResponse>({
...profileScoped(),
path: '/api/mcp/catalog'
})
}
export function installMcpCatalogEntry(
name: string,
env: Record<string, string> = {}
): Promise<{ ok: boolean; name?: string; pid?: number; action?: string }> {
return window.hermesDesktop.api<{ ok: boolean; name?: string; pid?: number; action?: string }>({
...profileScoped(),
path: '/api/mcp/catalog/install',
method: 'POST',
body: { name, env, enable: true },
timeoutMs: 60_000
})
}
// ---------------------------------------------------------------------------
// Memory data + curator (parity with `hermes memory` / `hermes curator`).
// ---------------------------------------------------------------------------
export function getMemoryStatus(): Promise<MemoryStatusResponse> {
return window.hermesDesktop.api<MemoryStatusResponse>({
...profileScoped(),
path: '/api/memory'
})
}
export function resetMemory(target: 'all' | 'memory' | 'user'): Promise<{ ok: boolean; deleted: string[] }> {
return window.hermesDesktop.api<{ ok: boolean; deleted: string[] }>({
...profileScoped(),
path: '/api/memory/reset',
method: 'POST',
body: { target }
})
}
export function getCuratorStatus(): Promise<CuratorStatusResponse> {
return window.hermesDesktop.api<CuratorStatusResponse>({
...profileScoped(),
path: '/api/curator'
})
}
export function setCuratorPaused(paused: boolean): Promise<{ ok: boolean; paused: boolean }> {
return window.hermesDesktop.api<{ ok: boolean; paused: boolean }>({
...profileScoped(),
path: '/api/curator/paused',
method: 'PUT',
body: { paused }
})
}
export function runCurator(): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...profileScoped(),
path: '/api/curator/run',
method: 'POST',
body: {}
})
}
// ---------------------------------------------------------------------------
// Maintenance operations (parity with `hermes doctor` / `hermes security
// audit` / `hermes backup` / `hermes debug share` and the dashboard System
// page). All except debug share are spawn-based background actions tailed via
// getActionStatus().
// ---------------------------------------------------------------------------
export function runDoctor(): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({ path: '/api/ops/doctor', method: 'POST', body: {} })
}
export function runSecurityAudit(): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({ path: '/api/ops/security-audit', method: 'POST', body: {} })
}
export function runBackup(): Promise<ActionResponse & { archive?: string }> {
return window.hermesDesktop.api<ActionResponse & { archive?: string }>({
path: '/api/ops/backup',
method: 'POST',
body: {}
})
}
export function runDebugShare(): Promise<DebugShareResponse> {
return window.hermesDesktop.api<DebugShareResponse>({
path: '/api/ops/debug-share',
method: 'POST',
body: {},
// Synchronous upload of report + logs to the paste service.
timeoutMs: 120_000
})
}

View file

@ -608,7 +608,30 @@ export const en: Translations = {
name: 'Name',
serverJson: 'Server JSON',
remove: 'Remove',
saveServer: 'Save server'
saveServer: 'Save server',
test: 'Test connection',
testing: 'Testing...',
testOk: count => `Connected — ${count} tool${count === 1 ? '' : 's'} available`,
testFailed: 'Connection failed',
enableServer: name => `Enable ${name}`,
disableServer: name => `Disable ${name}`,
serverEnabled: name => `${name} enabled — applies to new sessions.`,
serverDisabled: name => `${name} disabled — applies to new sessions.`,
toggleFailed: name => `Failed to toggle ${name}`,
tabServers: 'Servers',
tabCatalog: 'Catalog',
catalogLoading: 'Loading MCP catalog...',
catalogLoadFailed: 'MCP catalog failed to load',
catalogEmpty: 'No catalog entries available.',
catalogInstalled: 'Installed',
catalogEnabled: 'Enabled',
catalogNeedsInstall: 'Needs build',
catalogInstall: 'Install',
catalogInstalling: 'Installing...',
catalogInstallStarted: name => `Installing ${name}... applies to new sessions when done.`,
catalogInstallFailed: name => `Failed to install ${name}`,
catalogEnvPrompt: name => `${name} requires credentials`,
catalogEnvRequired: 'Fill in the required values before installing.'
},
model: {
loading: 'Loading model configuration...',
@ -720,13 +743,23 @@ export const en: Translations = {
postSetupCompleteMessage: step => `${step} installed.`,
postSetupErrorTitle: 'Setup finished with errors',
postSetupErrorMessage: step => `Check the ${step} log.`,
postSetupFailed: step => `Failed to run ${step} setup`
postSetupFailed: step => `Failed to run ${step} setup`,
loadingModels: 'Loading model catalog...',
modelSectionTitle: 'Model',
modelCount: count => `${count} model${count === 1 ? '' : 's'}`,
modelInUse: 'In use',
modelDefault: 'default',
modelInactiveHint: 'Select this backend first to change its model.',
modelSelectedTitle: 'Model selected',
modelSelectedMessage: model => `${model} applies to new sessions.`,
failedSelectModel: model => `Failed to select ${model}`
}
},
skills: {
tabSkills: 'Skills',
tabToolsets: 'Toolsets',
tabHub: 'Browse Hub',
all: 'All',
searchSkills: 'Search skills...',
searchToolsets: 'Search toolsets...',
@ -750,7 +783,54 @@ export const en: Translations = {
toolsetEnabled: 'Toolset enabled',
toolsetDisabled: 'Toolset disabled',
appliesToNewSessions: name => `${name} applies to new sessions.`,
failedToUpdate: name => `Failed to update ${name}`
failedToUpdate: name => `Failed to update ${name}`,
hub: {
searchPlaceholder: 'Search the skill hub (official, GitHub, community)...',
search: 'Search',
searching: 'Searching...',
connectingHubs: 'Connecting to skill hubs...',
connectedHubs: 'Connected hubs:',
featured: 'Featured skills',
landingHint:
'Search the hub to browse installable skills from the official index, GitHub, and community sources.',
noResults: 'No matching skills found in the hub.',
resultCount: (count, ms) => `${count} result${count === 1 ? '' : 's'}${ms !== null ? ` in ${ms}ms` : ''}`,
timedOut: sources => `Timed out: ${sources}`,
installed: 'Installed',
install: 'Install',
installing: 'Installing...',
uninstall: 'Uninstall',
updateAll: 'Update installed',
updating: 'Updating...',
preview: 'Preview',
scan: 'Scan',
scanning: 'Scanning...',
close: 'Close',
files: 'Files',
noReadme: 'This skill has no SKILL.md preview.',
trust: {
builtin: 'builtin',
trusted: 'trusted',
community: 'community'
},
verdictSafe: 'Safe',
verdictCaution: 'Caution',
verdictDangerous: 'Dangerous',
policyAllow: 'Install allowed',
policyAsk: 'Review before installing',
policyBlock: 'Install blocked by policy',
findings: count => `${count} finding${count === 1 ? '' : 's'}`,
noFindings: 'No security findings.',
installStarted: name => `Installing ${name}...`,
uninstallStarted: name => `Uninstalling ${name}...`,
updateStarted: 'Updating installed skills...',
actionFailed: 'Skill action failed',
actionLog: 'Action log',
loadFailed: 'Skill hub failed to load',
previewFailed: 'Skill preview failed',
scanFailed: 'Security scan failed',
searchFailed: 'Hub search failed'
}
},
starmap: {
@ -768,7 +848,8 @@ export const en: Translations = {
emptyTitle: 'Nothing learned yet',
emptyDesc: 'As Hermes builds skills and memories for your work, they appear here.',
share: 'Share map',
shareHint: 'Copy the code to share this map, or paste one to load. It only includes the layout, not your memory or skill text.',
shareHint:
'Copy the code to share this map, or paste one to load. It only includes the layout, not your memory or skill text.',
shareTitle: 'Import / export map',
sharePlaceholder: 'Paste a map code…',
copy: 'Copy map code',
@ -884,8 +965,9 @@ export const en: Translations = {
settingsFields: 'Settings fields',
mcpServers: 'MCP servers',
archivedChats: 'Archived chats',
sections: { sessions: 'Sessions', system: 'System', usage: 'Usage' },
sections: { maintenance: 'Maintenance', sessions: 'Sessions', system: 'System', usage: 'Usage' },
sectionDescriptions: {
maintenance: 'Diagnostics, backups, curator, and memory data',
sessions: 'Search and manage sessions',
system: 'Status, logs, and system actions',
usage: 'Token, cost, and skill activity over time'
@ -942,7 +1024,54 @@ export const en: Translations = {
noModelUsage: 'No model usage yet.',
topSkills: 'Top skills',
noSkillActivity: 'No skill activity yet.',
actions: count => `${count} actions`
actions: count => `${count} actions`,
logFile: 'Log file',
logLevel: 'Level',
logSearchPlaceholder: 'Filter log lines...',
maintenance: {
runOps: 'Diagnostics',
doctor: 'Run doctor',
doctorDesc: 'Health-check the install, config, and providers',
securityAudit: 'Security audit',
securityAuditDesc: 'Scan config and skills for risky settings',
backup: 'Create backup',
backupDesc: 'Zip config, memories, skills, and sessions',
debugShare: 'Debug share',
debugShareDesc: 'Upload a redacted report + logs, get shareable links (auto-deletes in 6h)',
debugShareRunning: 'Uploading debug report...',
debugShareLinks: 'Share links',
debugShareFailed: 'Debug share failed',
copyLink: 'Copy link',
linkCopied: 'Link copied',
curator: 'Skill curator',
curatorDesc: 'Background review that archives stale agent-created skills',
curatorPaused: 'Paused',
curatorActive: 'Active',
curatorDisabled: 'Disabled',
curatorLastRun: when => `Last run ${when}`,
curatorNeverRan: 'Never ran',
pause: 'Pause',
resume: 'Resume',
runNow: 'Run now',
memoryData: 'Memory data',
memoryDataDesc: 'Built-in memory files injected into every session',
memoryProvider: name => `Active provider: ${name}`,
builtinMemory: 'built-in',
memoryFile: 'Agent memory (MEMORY.md)',
userFile: 'User profile (USER.md)',
bytes: size => size,
empty: 'empty',
resetMemory: 'Reset memory',
resetUser: 'Reset profile',
resetAll: 'Reset both',
resetConfirm: target => `Delete ${target}? This cannot be undone.`,
resetDone: files => `Deleted ${files}.`,
resetFailed: 'Memory reset failed',
actionStarted: name => `${name} started — tailing log...`,
actionFailed: name => `${name} failed to start`,
running: 'Running...',
viewLog: 'Action log'
}
},
messaging: {

View file

@ -520,6 +520,29 @@ export interface Translations {
serverJson: string
remove: string
saveServer: string
test: string
testing: string
testOk: (count: number) => string
testFailed: string
enableServer: (name: string) => string
disableServer: (name: string) => string
serverEnabled: (name: string) => string
serverDisabled: (name: string) => string
toggleFailed: (name: string) => string
tabServers: string
tabCatalog: string
catalogLoading: string
catalogLoadFailed: string
catalogEmpty: string
catalogInstalled: string
catalogEnabled: string
catalogNeedsInstall: string
catalogInstall: string
catalogInstalling: string
catalogInstallStarted: (name: string) => string
catalogInstallFailed: (name: string) => string
catalogEnvPrompt: (name: string) => string
catalogEnvRequired: string
}
model: {
loading: string
@ -618,12 +641,22 @@ export interface Translations {
postSetupErrorTitle: string
postSetupErrorMessage: (step: string) => string
postSetupFailed: (step: string) => string
loadingModels: string
modelSectionTitle: string
modelCount: (count: number) => string
modelInUse: string
modelDefault: string
modelInactiveHint: string
modelSelectedTitle: string
modelSelectedMessage: (model: string) => string
failedSelectModel: (model: string) => string
}
}
skills: {
tabSkills: string
tabToolsets: string
tabHub: string
all: string
searchSkills: string
searchToolsets: string
@ -648,6 +681,48 @@ export interface Translations {
toolsetDisabled: string
appliesToNewSessions: (name: string) => string
failedToUpdate: (name: string) => string
hub: {
searchPlaceholder: string
search: string
searching: string
connectingHubs: string
connectedHubs: string
featured: string
landingHint: string
noResults: string
resultCount: (count: number, ms: number | null) => string
timedOut: (sources: string) => string
installed: string
install: string
installing: string
uninstall: string
updateAll: string
updating: string
preview: string
scan: string
scanning: string
close: string
files: string
noReadme: string
trust: Record<string, string>
verdictSafe: string
verdictCaution: string
verdictDangerous: string
policyAllow: string
policyAsk: string
policyBlock: string
findings: (count: number) => string
noFindings: string
installStarted: (name: string) => string
uninstallStarted: (name: string) => string
updateStarted: string
actionFailed: string
actionLog: string
loadFailed: string
previewFailed: string
scanFailed: string
searchFailed: string
}
}
starmap: {
@ -780,8 +855,8 @@ export interface Translations {
settingsFields: string
mcpServers: string
archivedChats: string
sections: Record<'sessions' | 'system' | 'usage', string>
sectionDescriptions: Record<'sessions' | 'system' | 'usage', string>
sections: Record<'maintenance' | 'sessions' | 'system' | 'usage', string>
sectionDescriptions: Record<'maintenance' | 'sessions' | 'system' | 'usage', string>
nav: Record<'newChat' | 'settings' | 'skills' | 'messaging' | 'artifacts', { title: string; detail: string }>
sectionEntries: Record<'sessions' | 'system' | 'usage', { title: string; detail: string }>
providerNavigate: string
@ -825,6 +900,53 @@ export interface Translations {
topSkills: string
noSkillActivity: string
actions: (count: string) => string
logFile: string
logLevel: string
logSearchPlaceholder: string
maintenance: {
runOps: string
doctor: string
doctorDesc: string
securityAudit: string
securityAuditDesc: string
backup: string
backupDesc: string
debugShare: string
debugShareDesc: string
debugShareRunning: string
debugShareLinks: string
debugShareFailed: string
copyLink: string
linkCopied: string
curator: string
curatorDesc: string
curatorPaused: string
curatorActive: string
curatorDisabled: string
curatorLastRun: (when: string) => string
curatorNeverRan: string
pause: string
resume: string
runNow: string
memoryData: string
memoryDataDesc: string
memoryProvider: (name: string) => string
builtinMemory: string
memoryFile: string
userFile: string
bytes: (size: string) => string
empty: string
resetMemory: string
resetUser: string
resetAll: string
resetConfirm: (target: string) => string
resetDone: (files: string) => string
resetFailed: string
actionStarted: (name: string) => string
actionFailed: (name: string) => string
running: string
viewLog: string
}
}
messaging: {

View file

@ -797,7 +797,30 @@ export const zh: Translations = {
name: '名称',
serverJson: '服务器 JSON',
remove: '移除',
saveServer: '保存服务器'
saveServer: '保存服务器',
test: '测试连接',
testing: '测试中…',
testOk: count => `已连接 — ${count} 个工具可用`,
testFailed: '连接失败',
enableServer: name => `启用 ${name}`,
disableServer: name => `禁用 ${name}`,
serverEnabled: name => `${name} 已启用 — 对新会话生效。`,
serverDisabled: name => `${name} 已禁用 — 对新会话生效。`,
toggleFailed: name => `切换 ${name} 失败`,
tabServers: '服务器',
tabCatalog: '目录',
catalogLoading: '正在加载 MCP 目录…',
catalogLoadFailed: 'MCP 目录加载失败',
catalogEmpty: '没有可用的目录条目。',
catalogInstalled: '已安装',
catalogEnabled: '已启用',
catalogNeedsInstall: '需要构建',
catalogInstall: '安装',
catalogInstalling: '安装中…',
catalogInstallStarted: name => `正在安装 ${name}… 完成后对新会话生效。`,
catalogInstallFailed: name => `安装 ${name} 失败`,
catalogEnvPrompt: name => `${name} 需要凭据`,
catalogEnvRequired: '安装前请填写必需的值。'
},
model: {
loading: '正在加载模型配置...',
@ -904,13 +927,23 @@ export const zh: Translations = {
postSetupCompleteMessage: step => `已安装 ${step}`,
postSetupErrorTitle: '设置完成但有错误',
postSetupErrorMessage: step => `请检查 ${step} 日志。`,
postSetupFailed: step => `运行 ${step} 设置失败`
postSetupFailed: step => `运行 ${step} 设置失败`,
loadingModels: '正在加载模型目录…',
modelSectionTitle: '模型',
modelCount: count => `${count} 个模型`,
modelInUse: '使用中',
modelDefault: '默认',
modelInactiveHint: '请先选择此后端,然后再更改其模型。',
modelSelectedTitle: '模型已选择',
modelSelectedMessage: model => `${model} 将应用于新会话。`,
failedSelectModel: model => `选择 ${model} 失败`
}
},
skills: {
tabSkills: '技能',
tabToolsets: '工具集',
tabHub: '浏览技能中心',
all: '全部',
searchSkills: '搜索技能…',
searchToolsets: '搜索工具集…',
@ -934,7 +967,53 @@ export const zh: Translations = {
toolsetEnabled: '工具集已启用',
toolsetDisabled: '工具集已禁用',
appliesToNewSessions: name => `${name} 将应用于新会话。`,
failedToUpdate: name => `更新 ${name} 失败`
failedToUpdate: name => `更新 ${name} 失败`,
hub: {
searchPlaceholder: '搜索技能中心官方、GitHub、社区…',
search: '搜索',
searching: '搜索中…',
connectingHubs: '正在连接技能中心…',
connectedHubs: '已连接的来源:',
featured: '精选技能',
landingHint: '搜索技能中心浏览来自官方索引、GitHub 和社区来源的可安装技能。',
noResults: '技能中心没有匹配的技能。',
resultCount: (count, ms) => `${count} 个结果${ms !== null ? `${ms}ms` : ''}`,
timedOut: sources => `超时:${sources}`,
installed: '已安装',
install: '安装',
installing: '安装中…',
uninstall: '卸载',
updateAll: '更新已安装',
updating: '更新中…',
preview: '预览',
scan: '扫描',
scanning: '扫描中…',
close: '关闭',
files: '文件',
noReadme: '该技能没有 SKILL.md 预览。',
trust: {
builtin: '内置',
trusted: '可信',
community: '社区'
},
verdictSafe: '安全',
verdictCaution: '注意',
verdictDangerous: '危险',
policyAllow: '允许安装',
policyAsk: '安装前请复查',
policyBlock: '安装被策略阻止',
findings: count => `${count} 项发现`,
noFindings: '无安全发现。',
installStarted: name => `正在安装 ${name}`,
uninstallStarted: name => `正在卸载 ${name}`,
updateStarted: '正在更新已安装技能…',
actionFailed: '技能操作失败',
actionLog: '操作日志',
loadFailed: '技能中心加载失败',
previewFailed: '技能预览失败',
scanFailed: '安全扫描失败',
searchFailed: '技能中心搜索失败'
}
},
starmap: {
@ -1067,8 +1146,9 @@ export const zh: Translations = {
settingsFields: '设置字段',
mcpServers: 'MCP 服务器',
archivedChats: '已归档对话',
sections: { sessions: '会话', system: '系统', usage: '用量' },
sections: { maintenance: '维护', sessions: '会话', system: '系统', usage: '用量' },
sectionDescriptions: {
maintenance: '诊断、备份、维护器与记忆数据',
sessions: '搜索与管理会话',
system: '状态、日志与系统操作',
usage: '一段时间内的词元、成本与技能活动'
@ -1125,7 +1205,54 @@ export const zh: Translations = {
noModelUsage: '暂无模型用量。',
topSkills: '常用技能',
noSkillActivity: '暂无技能活动。',
actions: count => `${count} 次操作`
actions: count => `${count} 次操作`,
logFile: '日志文件',
logLevel: '级别',
logSearchPlaceholder: '筛选日志行…',
maintenance: {
runOps: '诊断',
doctor: '运行体检',
doctorDesc: '检查安装、配置与提供方的健康状态',
securityAudit: '安全审计',
securityAuditDesc: '扫描配置与技能中的风险设置',
backup: '创建备份',
backupDesc: '打包配置、记忆、技能与会话',
debugShare: '调试分享',
debugShareDesc: '上传脱敏报告与日志获取可分享链接6 小时后自动删除)',
debugShareRunning: '正在上传调试报告…',
debugShareLinks: '分享链接',
debugShareFailed: '调试分享失败',
copyLink: '复制链接',
linkCopied: '链接已复制',
curator: '技能维护器',
curatorDesc: '后台审查并归档过期的智能体自建技能',
curatorPaused: '已暂停',
curatorActive: '运行中',
curatorDisabled: '已禁用',
curatorLastRun: when => `上次运行 ${when}`,
curatorNeverRan: '从未运行',
pause: '暂停',
resume: '恢复',
runNow: '立即运行',
memoryData: '记忆数据',
memoryDataDesc: '注入每个会话的内置记忆文件',
memoryProvider: name => `当前提供方:${name}`,
builtinMemory: '内置',
memoryFile: '智能体记忆MEMORY.md',
userFile: '用户画像USER.md',
bytes: size => size,
empty: '空',
resetMemory: '重置记忆',
resetUser: '重置画像',
resetAll: '全部重置',
resetConfirm: target => `删除 ${target}?此操作不可撤销。`,
resetDone: files => `已删除 ${files}`,
resetFailed: '记忆重置失败',
actionStarted: name => `${name} 已启动 — 正在跟踪日志…`,
actionFailed: name => `${name} 启动失败`,
running: '运行中…',
viewLog: '操作日志'
}
},
messaging: {
@ -1538,8 +1665,7 @@ export const zh: Translations = {
copyPath: '复制路径',
removeFromSidebar: '从侧边栏移除',
createFailed: '无法创建项目',
staleBackend:
'请更新 Hermes 后端以创建项目——当前后端比桌面应用旧(设置 → 更新 → 后端)。',
staleBackend: '请更新 Hermes 后端以创建项目——当前后端比桌面应用旧(设置 → 更新 → 后端)。',
deleteConfirm: '这会从 Hermes 中移除已保存的项目。文件、git 仓库和工作树保持不变。',
startWork: '新建工作树',
newWorktreeTitle: '新建工作树',

View file

@ -679,6 +679,26 @@ export interface ToolsetConfig {
active_provider: string | null
}
/** One model row from a toolset backend's catalog (image/video gen). */
export interface ToolsetModel {
id: string
display: string
speed: string
strengths: string
price: string
}
/** Shape of `GET /api/tools/toolsets/{name}/models`. */
export interface ToolsetModelsResponse {
name: string
has_models: boolean
provider?: string | null
plugin?: string | null
models: ToolsetModel[]
current: string | null
default: string | null
}
/** Shape of `GET /api/tools/computer-use/status`.
*
* cua-driver runs on macOS, Windows, and Linux. `ready` is the single OS-aware
@ -867,6 +887,151 @@ export interface StaleAuxAssignment {
model: string
}
/** One skill-hub source (official index, GitHub, skills.sh, ) as reported by
* `GET /api/skills/hub/sources`. */
export interface SkillHubSource {
id: string
label: string
available?: boolean
rate_limited?: boolean
}
/** A searchable/installable hub skill from `GET /api/skills/hub/search`. */
export interface SkillHubResult {
name: string
description: string
source: string
identifier: string
trust_level: string
repo: string | null
tags: string[]
}
export interface SkillHubInstalledEntry {
name: string | null
trust_level: string | null
scan_verdict: string | null
}
export interface SkillHubSourcesResponse {
sources: SkillHubSource[]
index_available: boolean
featured: SkillHubResult[]
installed: Record<string, SkillHubInstalledEntry>
}
export interface SkillHubSearchResponse {
results: SkillHubResult[]
source_counts: Record<string, number>
timed_out: string[]
installed: Record<string, SkillHubInstalledEntry>
}
/** `GET /api/skills/hub/preview` — SKILL.md + manifest without installing. */
export interface SkillHubPreview {
name: string
description: string
source: string
identifier: string
trust_level: string
repo: string | null
tags: string[]
skill_md: string
files: string[]
}
export interface SkillHubScanFinding {
severity: string
category: string
file: string
line: number | null
description: string
}
/** `GET /api/skills/hub/scan` — install-time security scan verdict. */
export interface SkillHubScanResult {
name: string
identifier: string
source: string
trust_level: string
verdict: string
summary: string
policy: 'allow' | 'ask' | 'block'
policy_reason: string | null
findings: SkillHubScanFinding[]
severity_counts: Record<string, number>
}
/** One configured MCP server row from `GET /api/mcp/servers`. */
export interface McpServerSummary {
name: string
transport: string
command: string | null
args: string[]
url: string | null
enabled: boolean
tools: string[] | null
}
export interface McpServerTestResponse {
ok: boolean
error?: string
tools: { name: string; description: string }[]
}
/** One Nous-approved MCP catalog entry from `GET /api/mcp/catalog`. */
export interface McpCatalogEntry {
name: string
description: string
source: string
transport: string
auth_type: string
required_env: { name: string; prompt: string; required: boolean }[]
command: string | null
args: string[]
url: string | null
install_url: string | null
install_ref: string | null
bootstrap: string[]
default_enabled: string[] | null
post_install: string
needs_install: boolean
installed: boolean
enabled: boolean
}
export interface McpCatalogResponse {
entries: McpCatalogEntry[]
diagnostics: { name: string; kind: string; message: string }[]
}
/** `GET /api/memory` — active provider + built-in memory file sizes. */
export interface MemoryStatusResponse {
active: string
providers: { name: string; description: string; configured: boolean }[]
builtin_files: { memory: number; user: number }
}
/** `GET /api/curator` — background skill-curator status. */
export interface CuratorStatusResponse {
enabled: boolean
paused: boolean
interval_hours: number | null
last_run_at: string | null
min_idle_hours: number | null
stale_after_days: number | null
archive_after_days: number | null
}
/** `POST /api/ops/debug-share` — shareable diagnostics upload result. */
export interface DebugShareResponse {
ok: boolean
urls: Record<string, string>
failures: Record<string, string>
redacted: boolean
auto_delete_seconds: number | null
}
export interface ModelAssignmentResponse {
/** Persisted endpoint URL for custom/local providers (echoed back). */
base_url?: string

View file

@ -11428,6 +11428,176 @@ class ToolsetProviderSelect(BaseModel):
profile: Optional[str] = None
# Toolsets whose backends carry a selectable model catalog, mapped to the
# config.yaml section their `model` key lives in. Mirrors the CLI's
# post-selection model pickers (`_configure_imagegen_model_for_plugin` /
# `_configure_videogen_model_for_plugin` in tools_config.py).
_MODEL_CATALOG_TOOLSETS = {
"image_gen": "image_gen",
"video_gen": "video_gen",
}
def _resolve_toolset_model_plugin(ts_key: str, provider_row: dict) -> Optional[str]:
"""Map a provider picker row to its model-catalog plugin name.
Plugin-backed rows carry ``image_gen_plugin_name`` / ``video_gen_plugin_name``;
the managed "Nous Subscription" image row instead carries the legacy
``imagegen_backend: "fal"`` marker (same underlying FAL catalog).
"""
if ts_key == "image_gen":
return provider_row.get("image_gen_plugin_name") or (
"fal" if provider_row.get("imagegen_backend") else None
)
if ts_key == "video_gen":
return provider_row.get("video_gen_plugin_name")
return None
def _toolset_model_catalog(ts_key: str, plugin_name: str):
"""Return ``(catalog_dict, default_model)`` for a toolset's plugin backend."""
from hermes_cli.tools_config import (
_plugin_image_gen_catalog,
_plugin_video_gen_catalog,
)
if ts_key == "image_gen":
return _plugin_image_gen_catalog(plugin_name)
return _plugin_video_gen_catalog(plugin_name)
def _find_toolset_provider_row(ts_key: str, config: dict, provider: Optional[str]) -> Optional[dict]:
"""Resolve a provider picker row by name, or the active row when omitted."""
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
_is_provider_active,
_visible_providers,
)
cat = TOOL_CATEGORIES.get(ts_key)
if cat is None:
return None
rows = _visible_providers(cat, config, force_fresh=True)
if provider:
return next((p for p in rows if p.get("name") == provider), None)
return next(
(p for p in rows if _is_provider_active(p, config, force_fresh=True)), None
)
@app.get("/api/tools/toolsets/{name}/models")
async def get_toolset_models(
name: str, provider: Optional[str] = None, profile: Optional[str] = None
):
"""Return the model catalog for a toolset backend (image/video gen).
The GUI counterpart of the model picker `hermes tools` runs after a
backend is selected e.g. FAL's multi-model catalog (speed / strengths /
price per model). ``provider`` names a picker row; omitted, the currently
active provider is used. Toolsets without model catalogs return
``has_models: false``.
"""
section = _MODEL_CATALOG_TOOLSETS.get(name)
if section is None:
return {"name": name, "has_models": False, "models": [], "current": None, "default": None}
with _profile_scope(profile):
config = load_config()
row = _find_toolset_provider_row(name, config, provider)
plugin = _resolve_toolset_model_plugin(name, row) if row else None
if not plugin:
return {
"name": name,
"has_models": False,
"models": [],
"current": None,
"default": None,
}
catalog, default_model = _toolset_model_catalog(name, plugin)
section_cfg = config.get(section)
current = None
if isinstance(section_cfg, dict):
raw = section_cfg.get("model")
if isinstance(raw, str) and raw.strip():
current = raw.strip()
if current not in catalog:
current = default_model if default_model in catalog else None
models = [
{
"id": model_id,
"display": meta.get("display", model_id),
"speed": meta.get("speed", ""),
"strengths": meta.get("strengths", ""),
"price": meta.get("price", ""),
}
for model_id, meta in catalog.items()
]
return {
"name": name,
"has_models": bool(models),
"provider": row.get("name") if row else None,
"plugin": plugin,
"models": models,
"current": current,
"default": default_model,
}
class ToolsetModelSelect(BaseModel):
model: str
provider: Optional[str] = None
profile: Optional[str] = None
@app.put("/api/tools/toolsets/{name}/model")
async def select_toolset_model(
name: str, body: ToolsetModelSelect, profile: Optional[str] = None
):
"""Persist a backend model selection (``image_gen.model`` / ``video_gen.model``).
Validates the model against the resolved backend's catalog — the same
write the CLI's post-selection model picker performs. Returns 400 for
toolsets without model catalogs or unknown model ids.
"""
section = _MODEL_CATALOG_TOOLSETS.get(name)
if section is None:
raise HTTPException(
status_code=400, detail=f"Toolset has no model catalog: {name}"
)
model_id = (body.model or "").strip()
if not model_id:
raise HTTPException(status_code=400, detail="model is required")
with _profile_scope(body.profile or profile):
config = load_config()
row = _find_toolset_provider_row(name, config, body.provider)
plugin = _resolve_toolset_model_plugin(name, row) if row else None
if not plugin:
raise HTTPException(
status_code=400,
detail=f"No model-capable backend is active for {name}",
)
catalog, _default = _toolset_model_catalog(name, plugin)
if model_id not in catalog:
raise HTTPException(
status_code=400,
detail=f"Unknown model {model_id!r} for backend {plugin!r}",
)
section_cfg = config.setdefault(section, {})
if not isinstance(section_cfg, dict):
section_cfg = {}
config[section] = section_cfg
section_cfg["model"] = model_id
save_config(config)
return {"ok": True, "name": name, "model": model_id, "plugin": plugin}
@app.put("/api/tools/toolsets/{name}/provider")
async def select_toolset_provider(
name: str, body: ToolsetProviderSelect, profile: Optional[str] = None

View file

@ -4013,6 +4013,74 @@ class TestNewEndpoints:
)
assert resp.status_code == 400
def test_get_toolset_models_no_catalog_toolset(self):
"""Toolsets without a model catalog report has_models: false."""
resp = self.client.get("/api/tools/toolsets/web/models")
assert resp.status_code == 200
body = resp.json()
assert body["has_models"] is False
assert body["models"] == []
def test_get_toolset_models_fal_catalog(self):
"""image_gen with the FAL backend returns its model catalog."""
resp = self.client.get(
"/api/tools/toolsets/image_gen/models", params={"provider": "FAL.ai"}
)
assert resp.status_code == 200
body = resp.json()
# Behavior contract, not a snapshot: FAL always has >= 1 model and
# each row carries the picker columns.
assert body["has_models"] is True
assert body["plugin"] == "fal"
assert len(body["models"]) >= 1
for row in body["models"]:
assert "id" in row
assert "speed" in row
assert "strengths" in row
assert "price" in row
# current resolves to a real catalog entry (default when unset).
ids = {row["id"] for row in body["models"]}
assert body["current"] in ids
assert body["default"] in ids
def test_select_toolset_model_persists_and_validates(self):
"""PUT .../model writes image_gen.model; bad ids/toolsets are 400."""
catalog = self.client.get(
"/api/tools/toolsets/image_gen/models", params={"provider": "FAL.ai"}
).json()
model_id = catalog["models"][0]["id"]
resp = self.client.put(
"/api/tools/toolsets/image_gen/model",
json={"model": model_id, "provider": "FAL.ai"},
)
assert resp.status_code == 200
assert resp.json()["ok"] is True
from hermes_cli.config import load_config
cfg = load_config()
assert cfg["image_gen"]["model"] == model_id
# The next catalog read reflects the persisted choice.
after = self.client.get(
"/api/tools/toolsets/image_gen/models", params={"provider": "FAL.ai"}
).json()
assert after["current"] == model_id
# Unknown model id → 400.
resp = self.client.put(
"/api/tools/toolsets/image_gen/model",
json={"model": "not-a-real-model", "provider": "FAL.ai"},
)
assert resp.status_code == 400
# Toolset without a model catalog → 400.
resp = self.client.put(
"/api/tools/toolsets/web/model", json={"model": model_id}
)
assert resp.status_code == 400
def test_config_raw_get(self):
resp = self.client.get("/api/config/raw")
assert resp.status_code == 200