mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
9e044cf795
commit
c7103c637c
15 changed files with 2711 additions and 112 deletions
|
|
@ -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>
|
||||
|
|
|
|||
416
apps/desktop/src/app/command-center/maintenance.tsx
Normal file
416
apps/desktop/src/app/command-center/maintenance.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
461
apps/desktop/src/app/skills/hub.tsx
Normal file
461
apps/desktop/src/app/skills/hub.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)}>
|
||||
|
|
|
|||
140
apps/desktop/src/hermes-parity.test.ts
Normal file
140
apps/desktop/src/hermes-parity.test.ts
Normal 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' }
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: '新建工作树',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue