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 }>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue