mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(desktop): plugin manager, runtime loader, and plugins settings
This commit is contained in:
parent
aefb36299a
commit
7ed7170960
4 changed files with 151 additions and 1 deletions
|
|
@ -6,7 +6,7 @@ import { Tip } from '@/components/ui/tooltip'
|
|||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Bell, Download, Globe, Info, KeyRound, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
|
||||
import { Archive, Bell, Download, Globe, Info, KeyRound, Package, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
|
|
@ -22,6 +22,7 @@ import { SECTIONS } from './constants'
|
|||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
|
||||
import { NotificationsSettings } from './notifications-settings'
|
||||
import { PluginsSettings } from './plugins-settings'
|
||||
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
|
||||
|
|
@ -32,6 +33,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
|||
'gateway',
|
||||
'keys',
|
||||
'notifications',
|
||||
'plugins',
|
||||
'sessions',
|
||||
'about'
|
||||
]
|
||||
|
|
@ -186,6 +188,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||
label: t.settings.nav.apiKeys,
|
||||
onSelect: () => setActiveView('keys')
|
||||
},
|
||||
{
|
||||
active: activeView === 'plugins',
|
||||
icon: Package,
|
||||
id: 'plugins',
|
||||
label: t.settings.nav.plugins,
|
||||
onSelect: () => setActiveView('plugins')
|
||||
},
|
||||
{
|
||||
active: activeView === 'sessions',
|
||||
icon: Archive,
|
||||
|
|
@ -259,6 +268,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||
<KeysSettings view={keysView} />
|
||||
) : activeView === 'notifications' ? (
|
||||
<NotificationsSettings />
|
||||
) : activeView === 'plugins' ? (
|
||||
<PluginsSettings />
|
||||
) : (
|
||||
<SessionsSettings />
|
||||
)}
|
||||
|
|
|
|||
124
apps/desktop/src/app/settings/plugins-settings.tsx
Normal file
124
apps/desktop/src/app/settings/plugins-settings.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
|
||||
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
||||
import { getStatus } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Package } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { EmptyState, ListRow, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
const KIND_ORDER: Record<PluginRecord['kind'], number> = { disk: 0, runtime: 1, bundled: 2 }
|
||||
|
||||
function reveal(file: string) {
|
||||
void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined)
|
||||
}
|
||||
|
||||
async function revealPluginsDir() {
|
||||
try {
|
||||
const { hermes_home } = await getStatus()
|
||||
// openDir (not reveal): the door often doesn't exist on first use, and
|
||||
// showItemInFolder on a missing path silently no-ops (esp. Windows).
|
||||
const result = await window.hermesDesktop?.openDir?.(`${hermes_home}/desktop-plugins`)
|
||||
|
||||
if (result && !result.ok) {
|
||||
notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder')
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not resolve the plugins folder')
|
||||
}
|
||||
}
|
||||
|
||||
function PluginRow({ record }: { record: PluginRecord }) {
|
||||
const { t } = useI18n()
|
||||
const p = t.settings.plugins
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{record.file && (
|
||||
<Tip label={p.reveal}>
|
||||
<Button onClick={() => reveal(record.file!)} size="icon" variant="ghost">
|
||||
<Codicon name="folder-opened" size="0.85rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
<Switch
|
||||
aria-label={`${record.status === 'disabled' ? p.enable : p.disable} ${record.name}`}
|
||||
checked={record.status !== 'disabled'}
|
||||
onCheckedChange={on => {
|
||||
triggerHaptic('selection')
|
||||
void setPluginEnabled(record.id, on)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
record.status === 'error' ? (
|
||||
<span className="text-(--ui-danger,#f87171)">{record.error}</span>
|
||||
) : (
|
||||
record.file ?? record.id
|
||||
)
|
||||
}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
{record.name}
|
||||
<Pill>{p.kinds[record.kind]}</Pill>
|
||||
{record.status === 'error' && <Pill tone="primary">{p.failed}</Pill>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginsSettings() {
|
||||
const { t } = useI18n()
|
||||
const p = t.settings.plugins
|
||||
const records = useStore($pluginRecords)
|
||||
|
||||
const rows = Object.values(records).sort(
|
||||
(a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.name.localeCompare(b.name)
|
||||
)
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<SectionHeading icon={Package} meta={p.count(rows.length)} title={p.title} />
|
||||
<p className="mb-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{p.blurb}</p>
|
||||
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Button onClick={() => void revealPluginsDir()} size="sm" variant="outline">
|
||||
<Codicon name="folder-opened" size="0.8rem" />
|
||||
{p.openFolder}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
void discoverRuntimePlugins()
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8rem" />
|
||||
{p.rescan}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState title={p.empty} />
|
||||
) : (
|
||||
<div className="divide-y divide-(--ui-stroke-tertiary)">
|
||||
{rows.map(record => (
|
||||
<PluginRow key={record.id} record={record} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ export type SettingsView =
|
|||
| 'gateway'
|
||||
| 'keys'
|
||||
| 'notifications'
|
||||
| 'plugins'
|
||||
| 'providers'
|
||||
| 'sessions'
|
||||
| `config:${string}`
|
||||
|
|
|
|||
14
apps/desktop/src/plugins/README.md
Normal file
14
apps/desktop/src/plugins/README.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Bundled plugins
|
||||
|
||||
Drop a `<name>/plugin.{ts,tsx}` here that default-exports a `HermesPlugin` and
|
||||
it registers automatically at boot (vite glob in `../contrib/plugins.ts`), with
|
||||
the same inventory + live enable/disable contract as runtime plugins.
|
||||
|
||||
None ship in-tree today — reference/demo plugins (the counter example, the
|
||||
gateway-pill 1:1 rebuild, the runtime-loader hello world) live in the companion
|
||||
[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins)
|
||||
repo so the shipped app stays uncluttered.
|
||||
|
||||
User- and agent-authored plugins load at runtime from
|
||||
`$HERMES_HOME/desktop-plugins/<name>/plugin.js` (the disk door) — see the
|
||||
`hermes-desktop-plugins` skill.
|
||||
Loading…
Add table
Add a link
Reference in a new issue