mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +00:00
feat(dashboard): add Plugins page with enable/disable, auth status, install/remove
- New PluginsPage.tsx: full plugin management UI (list, enable/disable, install from git, remove, git pull updates, provider picker) - Backend: dashboard_set_agent_plugin_enabled now also toggles the plugin's toolset in platform_toolsets so enabling actually makes tools visible in agent sessions - Backend: /api/dashboard/plugins/hub returns auth_required + auth_command per plugin (checks tool registry check_fn) - Frontend: auth_required shown as Badge + CommandBlock with copy-able auth command - Fix: Select overflow in providers card (min-w-0 grid cells, removed truncate/overflow-hidden that clipped dropdown) - Refactor: _install_plugin_core extracted for non-interactive reuse, PluginOperationError for structured error handling - i18n: en/zh/types updated with all new plugin page strings
This commit is contained in:
parent
e5dad4ac57
commit
e2a4905606
10 changed files with 1521 additions and 189 deletions
169
web/src/App.tsx
169
web/src/App.tsx
|
|
@ -65,10 +65,12 @@ import ModelsPage from "@/pages/ModelsPage";
|
|||
import CronPage from "@/pages/CronPage";
|
||||
import ProfilesPage from "@/pages/ProfilesPage";
|
||||
import SkillsPage from "@/pages/SkillsPage";
|
||||
import PluginsPage from "@/pages/PluginsPage";
|
||||
import ChatPage from "@/pages/ChatPage";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||
import { useI18n } from "@/i18n";
|
||||
import type { Translations } from "@/i18n/types";
|
||||
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
|
||||
import type { PluginManifest } from "@/plugins";
|
||||
import { useTheme } from "@/themes";
|
||||
|
|
@ -102,6 +104,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
|||
"/logs": LogsPage,
|
||||
"/cron": CronPage,
|
||||
"/skills": SkillsPage,
|
||||
"/plugins": PluginsPage,
|
||||
"/profiles": ProfilesPage,
|
||||
"/config": ConfigPage,
|
||||
"/env": EnvPage,
|
||||
|
|
@ -138,6 +141,7 @@ const BUILTIN_NAV_REST: NavItem[] = [
|
|||
{ path: "/logs", labelKey: "logs", label: "Logs", icon: FileText },
|
||||
{ path: "/cron", labelKey: "cron", label: "Cron", icon: Clock },
|
||||
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
|
||||
{ path: "/plugins", labelKey: "plugins", label: "Plugins", icon: Puzzle },
|
||||
{ path: "/profiles", labelKey: "profiles", label: "Profiles", icon: Users },
|
||||
{ path: "/config", labelKey: "config", label: "Config", icon: Settings },
|
||||
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
|
||||
|
|
@ -213,6 +217,22 @@ function buildNavItems(
|
|||
return items;
|
||||
}
|
||||
|
||||
/** Split merged nav into built-in sidebar entries vs plugin tabs, preserving plugin order hints. */
|
||||
function partitionSidebarNav(
|
||||
builtIn: NavItem[],
|
||||
manifests: PluginManifest[],
|
||||
): { coreItems: NavItem[]; pluginItems: NavItem[] } {
|
||||
const merged = buildNavItems(builtIn, manifests);
|
||||
const builtinPaths = new Set(builtIn.map((i) => i.path));
|
||||
const coreItems: NavItem[] = [];
|
||||
const pluginItems: NavItem[] = [];
|
||||
for (const item of merged) {
|
||||
if (builtinPaths.has(item.path)) coreItems.push(item);
|
||||
else pluginItems.push(item);
|
||||
}
|
||||
return { coreItems, pluginItems };
|
||||
}
|
||||
|
||||
function buildRoutes(
|
||||
builtinRoutes: Record<string, ComponentType>,
|
||||
manifests: PluginManifest[],
|
||||
|
|
@ -253,6 +273,7 @@ function buildRoutes(
|
|||
|
||||
for (const m of addons) {
|
||||
if (m.tab.hidden) continue;
|
||||
if (m.tab.path === "/plugins") continue;
|
||||
if (builtinRoutes[m.tab.path]) continue;
|
||||
routes.push({
|
||||
key: `plugin:${m.name}`,
|
||||
|
|
@ -263,6 +284,7 @@ function buildRoutes(
|
|||
|
||||
for (const m of manifests) {
|
||||
if (!m.tab.hidden) continue;
|
||||
if (m.tab.path === "/plugins") continue;
|
||||
if (builtinRoutes[m.tab.path] || m.tab.override) continue;
|
||||
routes.push({
|
||||
key: `plugin:hidden:${m.name}`,
|
||||
|
|
@ -322,8 +344,8 @@ export default function App() {
|
|||
[embeddedChat],
|
||||
);
|
||||
|
||||
const navItems = useMemo(
|
||||
() => buildNavItems(builtinNav, manifests),
|
||||
const sidebarNav = useMemo(
|
||||
() => partitionSidebarNav(builtinNav, manifests),
|
||||
[builtinNav, manifests],
|
||||
);
|
||||
const routes = useMemo(
|
||||
|
|
@ -476,56 +498,44 @@ export default function App() {
|
|||
aria-label={t.app.navigation}
|
||||
>
|
||||
<ul className="flex flex-col">
|
||||
{navItems.map(({ path, label, labelKey, icon: Icon }) => {
|
||||
const navLabel = labelKey
|
||||
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
|
||||
: label;
|
||||
return (
|
||||
<li key={path}>
|
||||
<NavLink
|
||||
to={path}
|
||||
end={path === "/sessions"}
|
||||
onClick={closeMobile}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-3",
|
||||
"px-5 py-2.5",
|
||||
"font-mondwest text-[0.8rem] tracking-[0.12em]",
|
||||
"whitespace-nowrap transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
isActive
|
||||
? "text-midground"
|
||||
: "opacity-60 hover:opacity-100",
|
||||
)
|
||||
}
|
||||
style={{
|
||||
clipPath: "var(--component-tab-clip-path)",
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{navLabel}</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
||||
/>
|
||||
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{sidebarNav.coreItems.map((item) => (
|
||||
<SidebarNavLink
|
||||
closeMobile={closeMobile}
|
||||
item={item}
|
||||
key={item.path}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{sidebarNav.pluginItems.length > 0 && (
|
||||
<div
|
||||
aria-labelledby="hermes-sidebar-plugin-nav-heading"
|
||||
className="flex flex-col border-t border-current/10 pb-2"
|
||||
role="group"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"px-5 pt-2.5 pb-1",
|
||||
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30",
|
||||
)}
|
||||
id="hermes-sidebar-plugin-nav-heading"
|
||||
>
|
||||
{t.app.pluginNavSection}
|
||||
</span>
|
||||
|
||||
<ul className="flex flex-col">
|
||||
{sidebarNav.pluginItems.map((item) => (
|
||||
<SidebarNavLink
|
||||
closeMobile={closeMobile}
|
||||
item={item}
|
||||
key={item.path}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<SidebarSystemActions onNavigate={closeMobile} />
|
||||
|
|
@ -615,6 +625,57 @@ export default function App() {
|
|||
);
|
||||
}
|
||||
|
||||
function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
|
||||
const { path, label, labelKey, icon: Icon } = item;
|
||||
|
||||
const navLabel = labelKey
|
||||
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
|
||||
: label;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<NavLink
|
||||
to={path}
|
||||
end={path === "/sessions"}
|
||||
onClick={closeMobile}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-3",
|
||||
"px-5 py-2.5",
|
||||
"font-mondwest text-[0.8rem] tracking-[0.12em]",
|
||||
"whitespace-nowrap transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
isActive ? "text-midground" : "opacity-60 hover:opacity-100",
|
||||
)
|
||||
}
|
||||
style={{
|
||||
clipPath: "var(--component-tab-clip-path)",
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{navLabel}</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
||||
/>
|
||||
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -733,6 +794,12 @@ interface NavItem {
|
|||
path: string;
|
||||
}
|
||||
|
||||
interface SidebarNavLinkProps {
|
||||
closeMobile: () => void;
|
||||
item: NavItem;
|
||||
t: Translations;
|
||||
}
|
||||
|
||||
interface SystemActionItem {
|
||||
action: SystemAction;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export const en: Translations = {
|
|||
logs: "Logs",
|
||||
models: "Models",
|
||||
profiles: "profiles : multi agents",
|
||||
plugins: "Plugins",
|
||||
sessions: "Sessions",
|
||||
skills: "Skills",
|
||||
},
|
||||
|
|
@ -84,6 +85,7 @@ export const en: Translations = {
|
|||
navigation: "Navigation",
|
||||
openDocumentation: "Open documentation in a new tab",
|
||||
openNavigation: "Open navigation",
|
||||
pluginNavSection: "Plugins",
|
||||
sessionsActiveCount: "{count} active",
|
||||
statusOverview: "Status overview",
|
||||
system: "System",
|
||||
|
|
@ -256,6 +258,45 @@ export const en: Translations = {
|
|||
renamed: "Renamed",
|
||||
},
|
||||
|
||||
pluginsPage: {
|
||||
contextEngineLabel: "Context engine",
|
||||
dashboardSlots: "Dashboard slots",
|
||||
disableRuntime: "Disable",
|
||||
enableAfterInstall: "Enable after install",
|
||||
enableRuntime: "Enable",
|
||||
forceReinstall: "Force reinstall (delete existing folder first)",
|
||||
headline:
|
||||
"Discover, install, enable, and update Hermes plugins (`hermes plugins` parity).",
|
||||
identifierLabel: "Git URL or owner/repo",
|
||||
inactive: "inactive",
|
||||
installBtn: "Install from Git",
|
||||
installHeading: "Install from GitHub / Git URL",
|
||||
installHint: "Use owner/repo shorthand or a full https:// or git@ clone URL.",
|
||||
memoryProviderLabel: "Memory provider",
|
||||
missingEnvWarn: "Set these in Keys before the plugin can run:",
|
||||
noDashboardTab: "No dashboard tab",
|
||||
openTab: "Open",
|
||||
orphanHeading: "Dashboard-only extensions (no agent plugin.yaml match)",
|
||||
pluginListHeading: "Installed plugins",
|
||||
providerDefaults: "built-in / default",
|
||||
providersHeading: "Runtime provider plugins",
|
||||
providersHint:
|
||||
"Writes memory.provider (empty = built-in) and context.engine to config.yaml. Takes effect next session.",
|
||||
refreshDashboard: "Rescan dashboard extensions",
|
||||
removeConfirm: "Remove this plugin from ~/.hermes/plugins/?",
|
||||
removeHint: "Only user-installed plugins under ~/.hermes/plugins can be removed.",
|
||||
rescanHeading: "SPA plugin registry",
|
||||
rescanHint: "Rescan after adding files on disk so the dashboard sidebar picks up new manifests.",
|
||||
runtimeHeading: "Gateway runtime (YAML plugins)",
|
||||
saveProviders: "Save provider settings",
|
||||
savedProviders: "Provider settings saved.",
|
||||
sourceBadge: "Source",
|
||||
authRequired: "Auth required",
|
||||
authRequiredHint: "Run this command to authenticate:",
|
||||
updateGit: "Git pull",
|
||||
versionBadge: "Version",
|
||||
},
|
||||
|
||||
skills: {
|
||||
title: "Skills",
|
||||
searchPlaceholder: "Search skills and toolsets...",
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export interface Translations {
|
|||
logs: string;
|
||||
models: string;
|
||||
profiles: string;
|
||||
plugins: string;
|
||||
sessions: string;
|
||||
skills: string;
|
||||
};
|
||||
|
|
@ -84,6 +85,7 @@ export interface Translations {
|
|||
navigation: string;
|
||||
openDocumentation: string;
|
||||
openNavigation: string;
|
||||
pluginNavSection: string;
|
||||
sessionsActiveCount: string;
|
||||
statusOverview: string;
|
||||
system: string;
|
||||
|
|
@ -228,6 +230,44 @@ export interface Translations {
|
|||
};
|
||||
};
|
||||
|
||||
// ── Plugins page ──
|
||||
pluginsPage: {
|
||||
contextEngineLabel: string;
|
||||
dashboardSlots: string;
|
||||
disableRuntime: string;
|
||||
enableAfterInstall: string;
|
||||
enableRuntime: string;
|
||||
forceReinstall: string;
|
||||
headline: string;
|
||||
identifierLabel: string;
|
||||
inactive: string;
|
||||
installBtn: string;
|
||||
installHeading: string;
|
||||
installHint: string;
|
||||
memoryProviderLabel: string;
|
||||
missingEnvWarn: string;
|
||||
noDashboardTab: string;
|
||||
openTab: string;
|
||||
orphanHeading: string;
|
||||
pluginListHeading: string;
|
||||
providerDefaults: string;
|
||||
providersHeading: string;
|
||||
providersHint: string;
|
||||
refreshDashboard: string;
|
||||
removeConfirm: string;
|
||||
removeHint: string;
|
||||
rescanHeading: string;
|
||||
rescanHint: string;
|
||||
runtimeHeading: string;
|
||||
saveProviders: string;
|
||||
savedProviders: string;
|
||||
sourceBadge: string;
|
||||
authRequired: string;
|
||||
authRequiredHint: string;
|
||||
updateGit: string;
|
||||
versionBadge: string;
|
||||
};
|
||||
|
||||
// ── Profiles page ──
|
||||
profiles: {
|
||||
newProfile: string;
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export const zh: Translations = {
|
|||
logs: "日志",
|
||||
models: "模型",
|
||||
profiles: "多Agent配置",
|
||||
plugins: "插件管理",
|
||||
sessions: "会话",
|
||||
skills: "技能",
|
||||
},
|
||||
|
|
@ -83,6 +84,7 @@ export const zh: Translations = {
|
|||
navigation: "导航",
|
||||
openDocumentation: "在新标签页中打开文档",
|
||||
openNavigation: "打开导航",
|
||||
pluginNavSection: "插件",
|
||||
sessionsActiveCount: "{count} 个活跃",
|
||||
statusOverview: "状态概览",
|
||||
system: "系统",
|
||||
|
|
@ -253,6 +255,44 @@ export const zh: Translations = {
|
|||
renamed: "已重命名",
|
||||
},
|
||||
|
||||
pluginsPage: {
|
||||
contextEngineLabel: "上下文引擎",
|
||||
dashboardSlots: "面板插槽",
|
||||
disableRuntime: "禁用",
|
||||
enableAfterInstall: "安装后启用",
|
||||
enableRuntime: "启用",
|
||||
forceReinstall: "强制重装(先删除已有目录)",
|
||||
headline: "发现、安装、启用和更新 Hermes 插件(对齐 `hermes plugins` CLI)。",
|
||||
identifierLabel: "Git 地址或 owner/repo",
|
||||
inactive: "未启用",
|
||||
installBtn: "从 Git 安装",
|
||||
installHeading: "从 GitHub / Git 地址安装",
|
||||
installHint: "使用 owner/repo 简写或完整的 https:// / git@ 克隆地址。",
|
||||
memoryProviderLabel: "记忆提供方",
|
||||
missingEnvWarn: "在「密钥」页面设置以下变量后再运行插件:",
|
||||
noDashboardTab: "无仪表盘标签",
|
||||
openTab: "打开",
|
||||
orphanHeading: "仅仪表盘扩展(无匹配的 agent plugin.yaml)",
|
||||
pluginListHeading: "已安装插件",
|
||||
providerDefaults: "内置 / 默认",
|
||||
providersHeading: "运行时提供方插件",
|
||||
providersHint:
|
||||
"写入 config.yaml:memory.provider(留空为内置)、context.engine。下次会话生效。",
|
||||
refreshDashboard: "重新扫描仪表盘扩展",
|
||||
removeConfirm: "从 ~/.hermes/plugins/ 删除此插件?",
|
||||
removeHint: "仅可移除用户安装在 ~/.hermes/plugins 下的插件。",
|
||||
rescanHeading: "SPA 插件注册表",
|
||||
rescanHint: "在磁盘新增文件后扫描,使侧边栏载入新 manifest。",
|
||||
runtimeHeading: "网关运行时(YAML 插件)",
|
||||
saveProviders: "保存提供方设置",
|
||||
savedProviders: "提供方设置已保存。",
|
||||
sourceBadge: "来源",
|
||||
authRequired: "需要认证",
|
||||
authRequiredHint: "运行此命令以完成认证:",
|
||||
updateGit: "git pull",
|
||||
versionBadge: "版本",
|
||||
},
|
||||
|
||||
skills: {
|
||||
title: "技能",
|
||||
searchPlaceholder: "搜索技能和工具集...",
|
||||
|
|
|
|||
|
|
@ -259,6 +259,46 @@ export const api = {
|
|||
rescanPlugins: () =>
|
||||
fetchJSON<{ ok: boolean; count: number }>("/api/dashboard/plugins/rescan"),
|
||||
|
||||
getPluginsHub: () => fetchJSON<PluginsHubResponse>("/api/dashboard/plugins/hub"),
|
||||
|
||||
installAgentPlugin: (body: AgentPluginInstallRequest) =>
|
||||
fetchJSON<AgentPluginInstallResponse>("/api/dashboard/agent-plugins/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...body }),
|
||||
}),
|
||||
|
||||
enableAgentPlugin: (name: string) =>
|
||||
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
|
||||
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/enable`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
|
||||
disableAgentPlugin: (name: string) =>
|
||||
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
|
||||
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/disable`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
|
||||
updateAgentPlugin: (name: string) =>
|
||||
fetchJSON<AgentPluginUpdateResponse>(
|
||||
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/update`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
|
||||
removeAgentPlugin: (name: string) =>
|
||||
fetchJSON<{ ok: boolean; name: string }>(
|
||||
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
|
||||
savePluginProviders: (body: PluginProvidersPutRequest) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/dashboard/plugin-providers", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
// Dashboard themes
|
||||
getThemes: () =>
|
||||
fetchJSON<DashboardThemesResponse>("/api/dashboard/themes"),
|
||||
|
|
@ -668,8 +708,66 @@ export interface PluginManifestResponse {
|
|||
override?: string;
|
||||
hidden?: boolean;
|
||||
};
|
||||
slots?: string[];
|
||||
entry: string;
|
||||
css?: string | null;
|
||||
has_api: boolean;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface HubAgentPluginRow {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
source: string;
|
||||
runtime_status: "disabled" | "enabled" | "inactive";
|
||||
has_dashboard_manifest: boolean;
|
||||
dashboard_manifest: PluginManifestResponse | null;
|
||||
path: string;
|
||||
can_remove: boolean;
|
||||
can_update_git: boolean;
|
||||
auth_required: boolean;
|
||||
auth_command: string;
|
||||
}
|
||||
|
||||
export interface PluginsHubProviders {
|
||||
memory_provider: string;
|
||||
memory_options: Array<{ name: string; description: string }>;
|
||||
context_engine: string;
|
||||
context_options: Array<{ name: string; description: string }>;
|
||||
}
|
||||
|
||||
export interface PluginsHubResponse {
|
||||
plugins: HubAgentPluginRow[];
|
||||
orphan_dashboard_plugins: PluginManifestResponse[];
|
||||
providers: PluginsHubProviders;
|
||||
}
|
||||
|
||||
export interface AgentPluginInstallRequest {
|
||||
identifier: string;
|
||||
force?: boolean;
|
||||
enable?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentPluginInstallResponse {
|
||||
ok: boolean;
|
||||
plugin_name?: string;
|
||||
warnings?: string[];
|
||||
missing_env?: string[];
|
||||
after_install_path?: string | null;
|
||||
enabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AgentPluginUpdateResponse {
|
||||
ok: boolean;
|
||||
name?: string;
|
||||
output?: string;
|
||||
unchanged?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PluginProvidersPutRequest {
|
||||
memory_provider?: string;
|
||||
context_engine?: string;
|
||||
}
|
||||
|
|
|
|||
569
web/src/pages/PluginsPage.tsx
Normal file
569
web/src/pages/PluginsPage.tsx
Normal file
|
|
@ -0,0 +1,569 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ExternalLink, RefreshCw, Puzzle, Trash2 } from "lucide-react";
|
||||
import type { Translations } from "@/i18n/types";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "@/lib/api";
|
||||
import type { HubAgentPluginRow, PluginsHubResponse } from "@/lib/api";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
import { Switch } from "@nous-research/ui/ui/components/switch";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { CommandBlock } from "@nous-research/ui/ui/components/command-block";
|
||||
import { H2 } from "@/components/NouiTypography";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Select value for built-in memory (`config` uses empty string). Never use `""` — UI Select maps empty value to an empty label. */
|
||||
const MEMORY_PROVIDER_BUILTIN = "__hermes_memory_builtin__";
|
||||
|
||||
export default function PluginsPage() {
|
||||
const [hub, setHub] = useState<PluginsHubResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [installId, setInstallId] = useState("");
|
||||
const [installForce, setInstallForce] = useState(false);
|
||||
const [installEnable, setInstallEnable] = useState(true);
|
||||
const [installBusy, setInstallBusy] = useState(false);
|
||||
const [rescanBusy, setRescanBusy] = useState(false);
|
||||
const [memorySel, setMemorySel] = useState(MEMORY_PROVIDER_BUILTIN);
|
||||
const [contextSel, setContextSel] = useState("compressor");
|
||||
const [providerBusy, setProviderBusy] = useState(false);
|
||||
const [rowBusy, setRowBusy] = useState<string | null>(null);
|
||||
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const loadHub = useCallback(() => {
|
||||
return api
|
||||
.getPluginsHub()
|
||||
.then((h) => {
|
||||
setHub(h);
|
||||
const p = h.providers;
|
||||
setMemorySel(p.memory_provider ? p.memory_provider : MEMORY_PROVIDER_BUILTIN);
|
||||
setContextSel(p.context_engine || "compressor");
|
||||
})
|
||||
.catch(() => showToast(t.common.loading, "error"));
|
||||
}, [showToast, t.common.loading]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
void loadHub().finally(() => setLoading(false));
|
||||
}, [loadHub]);
|
||||
|
||||
const onInstall = async () => {
|
||||
const id = installId.trim();
|
||||
if (!id) {
|
||||
showToast(t.pluginsPage.installHint, "error");
|
||||
return;
|
||||
}
|
||||
setInstallBusy(true);
|
||||
try {
|
||||
const r = await api.installAgentPlugin({
|
||||
identifier: id,
|
||||
force: installForce,
|
||||
enable: installEnable,
|
||||
});
|
||||
showToast(`${r.plugin_name ?? id} installed`, "success");
|
||||
if ((r.warnings?.length ?? 0) > 0) showToast(r.warnings!.join(" "), "error");
|
||||
if ((r.missing_env?.length ?? 0) > 0)
|
||||
showToast(`${t.pluginsPage.missingEnvWarn} ${r.missing_env!.join(", ")}`, "error");
|
||||
setInstallId("");
|
||||
await loadHub();
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : "Install failed", "error");
|
||||
} finally {
|
||||
setInstallBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onRescan = async () => {
|
||||
setRescanBusy(true);
|
||||
try {
|
||||
const rc = await api.rescanPlugins();
|
||||
showToast(
|
||||
`${t.pluginsPage.refreshDashboard} (${rc.count})`,
|
||||
"success",
|
||||
);
|
||||
await loadHub();
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : "Rescan failed", "error");
|
||||
} finally {
|
||||
setRescanBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSaveProviders = async () => {
|
||||
setProviderBusy(true);
|
||||
try {
|
||||
await api.savePluginProviders({
|
||||
memory_provider:
|
||||
memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel,
|
||||
context_engine: contextSel,
|
||||
});
|
||||
showToast(t.pluginsPage.savedProviders, "success");
|
||||
await loadHub();
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : "Save failed", "error");
|
||||
} finally {
|
||||
setProviderBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setRuntimeLoading = async (name: string, fn: () => Promise<unknown>) => {
|
||||
setRowBusy(name);
|
||||
try {
|
||||
await fn();
|
||||
await loadHub();
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : "Failed", "error");
|
||||
} finally {
|
||||
setRowBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const rows = hub?.plugins ?? [];
|
||||
const providers = hub?.providers;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PluginSlot name="plugins:top" />
|
||||
|
||||
<div className={cn("mx-auto flex w-full max-w-5xl flex-col gap-8")}>
|
||||
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
|
||||
<div>
|
||||
|
||||
|
||||
<H2>{t.app.nav.plugins}</H2>
|
||||
|
||||
|
||||
<p className="mt-1 max-w-xl text-[0.75rem] tracking-[0.06em] text-midground/55 normal-case">
|
||||
{t.pluginsPage.headline}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
ghost
|
||||
size="sm"
|
||||
className="shrink-0 gap-2"
|
||||
disabled={loading || rescanBusy}
|
||||
onClick={() => void onRescan()}
|
||||
>
|
||||
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
{t.pluginsPage.refreshDashboard}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{providers && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t.pluginsPage.providersHeading}</CardTitle>
|
||||
<p className="text-[0.7rem] tracking-[0.08em] text-midground/55 normal-case">
|
||||
{t.pluginsPage.providersHint}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 max-w-full">
|
||||
<div className="grid gap-2 min-w-0">
|
||||
<Label htmlFor="mem-provider">{t.pluginsPage.memoryProviderLabel}</Label>
|
||||
|
||||
<Select
|
||||
id="mem-provider"
|
||||
className="w-full"
|
||||
value={memorySel}
|
||||
onValueChange={setMemorySel}
|
||||
>
|
||||
<SelectOption value={MEMORY_PROVIDER_BUILTIN}>
|
||||
{`(${t.pluginsPage.providerDefaults})`}
|
||||
</SelectOption>
|
||||
|
||||
{providers.memory_options.map((o) => (
|
||||
<SelectOption key={o.name} value={o.name}>
|
||||
{o.name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 min-w-0">
|
||||
<Label htmlFor="ctx-engine">{t.pluginsPage.contextEngineLabel}</Label>
|
||||
|
||||
<Select
|
||||
id="ctx-engine"
|
||||
className="w-full"
|
||||
value={contextSel}
|
||||
onValueChange={setContextSel}
|
||||
>
|
||||
<SelectOption value="compressor">compressor</SelectOption>
|
||||
|
||||
{providers.context_options
|
||||
.filter((o) => o.name !== "compressor")
|
||||
.map((o) => (
|
||||
<SelectOption key={o.name} value={o.name}>
|
||||
{o.name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-fit gap-2"
|
||||
size="sm"
|
||||
disabled={providerBusy}
|
||||
onClick={() => void onSaveProviders()}
|
||||
>
|
||||
{providerBusy ? <Spinner /> : null}
|
||||
{t.pluginsPage.saveProviders}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t.pluginsPage.installHeading}</CardTitle>
|
||||
<p className="text-[0.7rem] tracking-[0.08em] text-midground/55 normal-case">
|
||||
{t.pluginsPage.installHint}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
<Label htmlFor="install-url">{t.pluginsPage.identifierLabel}</Label>
|
||||
|
||||
<Input
|
||||
className="normal-case font-sans lowercase"
|
||||
id="install-url"
|
||||
placeholder="owner/repo or https://..."
|
||||
spellCheck={false}
|
||||
value={installId}
|
||||
onChange={(e) => setInstallId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-wrap items-center gap-8">
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
<Switch checked={installForce} onCheckedChange={setInstallForce} />
|
||||
|
||||
<span className="text-[0.7rem] tracking-[0.06em] text-midforeground/85 normal-case">
|
||||
{t.pluginsPage.forceReinstall}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
<Switch checked={installEnable} onCheckedChange={setInstallEnable} />
|
||||
|
||||
<span className="text-[0.7rem] tracking-[0.06em] text-midforeground/85 normal-case">
|
||||
{t.pluginsPage.enableAfterInstall}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-fit gap-2"
|
||||
size="sm"
|
||||
disabled={installBusy}
|
||||
onClick={() => void onInstall()}
|
||||
>
|
||||
{installBusy ? <Spinner /> : <Puzzle className="h-3.5 w-3.5" />}
|
||||
{t.pluginsPage.installBtn}
|
||||
</Button>
|
||||
|
||||
<p className="text-[0.65rem] tracking-[0.06em] text-midforeground/55 normal-case">
|
||||
{t.pluginsPage.rescanHint}
|
||||
</p>
|
||||
|
||||
<p className="text-[0.65rem] tracking-[0.06em] text-midforeground/55 normal-case">
|
||||
{t.pluginsPage.removeHint}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
<h3 className="font-mondwest text-[0.75rem] tracking-[0.12em] text-midground/85">
|
||||
{t.pluginsPage.pluginListHeading}
|
||||
</h3>
|
||||
|
||||
{loading ? (
|
||||
|
||||
<div className="flex items-center gap-2 py-8 text-[0.8rem] text-midforeground/65">
|
||||
|
||||
<Spinner />
|
||||
<span>{t.common.loading}</span>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
|
||||
<p className="text-[0.75rem] text-midforeground/55 normal-case">{t.common.noResults}</p>
|
||||
) : (
|
||||
|
||||
<ul className="flex flex-col gap-3">
|
||||
|
||||
{rows.map((row: HubAgentPluginRow) => (
|
||||
|
||||
<li key={row.name}>
|
||||
|
||||
|
||||
<PluginRowCard
|
||||
{...{ row, rowBusy, setRuntimeLoading, showToast, t }}
|
||||
/>
|
||||
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(hub?.orphan_dashboard_plugins?.length ?? 0) > 0 ? (
|
||||
|
||||
|
||||
<div className="flex flex-col gap-3 opacity-95">
|
||||
|
||||
<h3 className="font-mondwest text-[0.75rem] tracking-[0.12em] text-midforeground/85">
|
||||
{t.pluginsPage.orphanHeading}
|
||||
</h3>
|
||||
|
||||
<ul className="flex flex-col gap-2 rounded border border-current/15 p-4">
|
||||
|
||||
{hub!.orphan_dashboard_plugins.map((m) => (
|
||||
|
||||
<li className="text-[0.7rem] normal-case opacity-85" key={m.name}>
|
||||
|
||||
|
||||
{m.label ?? m.name} — {m.description || m.tab?.path}
|
||||
|
||||
|
||||
{!m.tab?.hidden ? (
|
||||
|
||||
|
||||
<Link className="ml-3 inline-flex items-center gap-1 underline" to={m.tab.path}>
|
||||
|
||||
|
||||
<ExternalLink className="h-3 w-3 opacity-65" />
|
||||
|
||||
{t.pluginsPage.openTab}
|
||||
</Link>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Toast toast={toast} />
|
||||
<PluginSlot name="plugins:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PluginRowCardProps {
|
||||
|
||||
row: HubAgentPluginRow;
|
||||
rowBusy: string | null;
|
||||
setRuntimeLoading: (
|
||||
name: string,
|
||||
fn: () => Promise<unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
showToast: (msg: string, variant: "success" | "error") => void;
|
||||
t: Translations;
|
||||
}
|
||||
|
||||
function PluginRowCard(props: PluginRowCardProps) {
|
||||
const {
|
||||
row,
|
||||
rowBusy,
|
||||
setRuntimeLoading,
|
||||
showToast,
|
||||
t,
|
||||
} = props;
|
||||
|
||||
const dm = row.dashboard_manifest;
|
||||
|
||||
const tabPath = dm?.tab && !dm.tab.hidden ? dm.tab.override ?? dm.tab.path : null;
|
||||
|
||||
const busy = rowBusy === row.name;
|
||||
|
||||
const badgeTone =
|
||||
row.runtime_status === "enabled"
|
||||
? "success"
|
||||
: row.runtime_status === "disabled"
|
||||
? "destructive"
|
||||
: "outline";
|
||||
|
||||
return (
|
||||
|
||||
<Card className={cn(busy ? "opacity-70" : undefined)}>
|
||||
|
||||
|
||||
<CardContent className="flex flex-col gap-4 px-6 py-4">
|
||||
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
||||
<span className="truncate font-semibold">{row.name}</span>
|
||||
|
||||
<Badge tone="outline">
|
||||
{t.pluginsPage.sourceBadge}: {row.source}
|
||||
</Badge>
|
||||
|
||||
|
||||
<Badge tone="outline">v{row.version || "—"}</Badge>
|
||||
|
||||
<Badge tone={badgeTone}>{row.runtime_status}</Badge>
|
||||
|
||||
{row.auth_required ? (
|
||||
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{row.description ? (
|
||||
|
||||
<p className="mt-2 max-w-2xl text-[0.7rem] tracking-[0.06em] text-midforeground/75 normal-case">
|
||||
{row.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
|
||||
|
||||
<Button
|
||||
disabled={busy || row.runtime_status === "enabled"}
|
||||
ghost
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void setRuntimeLoading(row.name, async () => {
|
||||
await api.enableAgentPlugin(row.name);
|
||||
showToast(t.pluginsPage.enableRuntime, "success");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t.pluginsPage.enableRuntime}
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
disabled={busy || row.runtime_status === "disabled"}
|
||||
ghost
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void setRuntimeLoading(row.name, async () => {
|
||||
await api.disableAgentPlugin(row.name);
|
||||
showToast(t.pluginsPage.disableRuntime, "success");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t.pluginsPage.disableRuntime}
|
||||
</Button>
|
||||
|
||||
{tabPath ? (
|
||||
|
||||
<Link
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-none px-3 py-1.5",
|
||||
"border border-current/25 hover:bg-current/10",
|
||||
"font-mondwest text-[0.65rem] tracking-[0.1em] uppercase",
|
||||
)}
|
||||
to={tabPath}
|
||||
>
|
||||
{t.pluginsPage.openTab}
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{row.can_update_git ? (
|
||||
|
||||
<Button
|
||||
disabled={busy}
|
||||
ghost
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void setRuntimeLoading(row.name, async () => {
|
||||
await api.updateAgentPlugin(row.name);
|
||||
showToast(t.pluginsPage.updateGit, "success");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{busy ? <Spinner /> : null}
|
||||
{t.pluginsPage.updateGit}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{row.can_remove ? (
|
||||
|
||||
|
||||
<Button
|
||||
destructive
|
||||
disabled={busy}
|
||||
ghost
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const ok =
|
||||
typeof window !== "undefined"
|
||||
? window.confirm(t.pluginsPage.removeConfirm)
|
||||
: false;
|
||||
if (!ok) return;
|
||||
|
||||
void setRuntimeLoading(row.name, async () => {
|
||||
await api.removeAgentPlugin(row.name);
|
||||
showToast(`${row.name} removed`, "success");
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
{busy ? <Spinner /> : <Trash2 className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dm?.slots?.length ? (
|
||||
|
||||
<p className="text-[0.65rem] tracking-[0.05em] text-midforeground/55 normal-case">
|
||||
{t.pluginsPage.dashboardSlots}: {dm.slots.join(", ")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{row.auth_required ? (
|
||||
<CommandBlock
|
||||
label={t.pluginsPage.authRequiredHint}
|
||||
code={row.auth_command}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!row.has_dashboard_manifest && !dm ? (
|
||||
|
||||
|
||||
<p className="text-[0.65rem] italic text-midforeground/45 normal-case">
|
||||
{t.pluginsPage.noDashboardTab}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -46,6 +46,8 @@ import React, { Fragment, useEffect, useState } from "react";
|
|||
* - `cron:bottom` — bottom of /cron page
|
||||
* - `skills:top` — top of /skills page
|
||||
* - `skills:bottom` — bottom of /skills page
|
||||
* - `plugins:top` — top of /plugins page
|
||||
* - `plugins:bottom` — bottom of /plugins page
|
||||
* - `config:top` — top of /config page
|
||||
* - `config:bottom` — bottom of /config page
|
||||
* - `env:top` — top of /env (Keys) page
|
||||
|
|
@ -78,6 +80,8 @@ export const KNOWN_SLOT_NAMES = [
|
|||
"cron:bottom",
|
||||
"skills:top",
|
||||
"skills:bottom",
|
||||
"plugins:top",
|
||||
"plugins:bottom",
|
||||
"config:top",
|
||||
"config:bottom",
|
||||
"env:top",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue