From 41bebdcdb531326d9cc609df0068ac43cd6ac6a5 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:08:52 +0800 Subject: [PATCH] feat(extensions): add first-class WebUI management --- nanobot/webui/extensions_routes.py | 3 +- tests/webui/test_extensions_routes.py | 11 +- webui/src/App.tsx | 79 +++- webui/src/components/ExtensionsView.tsx | 41 ++ webui/src/components/Sidebar.tsx | 11 +- .../extensions/ExtensionDetailSheet.tsx | 280 +++++++++++++ .../extensions/ExtensionsCatalog.tsx | 393 ++++++++++++++++++ .../components/extensions/extension-ui.tsx | 157 +++++++ webui/src/i18n/locales/en/common.json | 60 ++- webui/src/i18n/locales/es/common.json | 60 ++- webui/src/i18n/locales/fr/common.json | 60 ++- webui/src/i18n/locales/id/common.json | 60 ++- webui/src/i18n/locales/ja/common.json | 60 ++- webui/src/i18n/locales/ko/common.json | 60 ++- webui/src/i18n/locales/pt-BR/common.json | 60 ++- webui/src/i18n/locales/vi/common.json | 60 ++- webui/src/i18n/locales/zh-CN/common.json | 60 ++- webui/src/i18n/locales/zh-TW/common.json | 60 ++- webui/src/lib/api.ts | 57 +++ webui/src/lib/types.ts | 70 ++++ webui/src/tests/api.test.ts | 27 ++ webui/src/tests/app-layout.test.tsx | 58 ++- webui/src/tests/extensions-view.test.tsx | 148 +++++++ 23 files changed, 1897 insertions(+), 38 deletions(-) create mode 100644 webui/src/components/ExtensionsView.tsx create mode 100644 webui/src/components/extensions/ExtensionDetailSheet.tsx create mode 100644 webui/src/components/extensions/ExtensionsCatalog.tsx create mode 100644 webui/src/components/extensions/extension-ui.tsx create mode 100644 webui/src/tests/extensions-view.test.tsx diff --git a/nanobot/webui/extensions_routes.py b/nanobot/webui/extensions_routes.py index e0704362e..246b91367 100644 --- a/nanobot/webui/extensions_routes.py +++ b/nanobot/webui/extensions_routes.py @@ -5,6 +5,7 @@ from __future__ import annotations import json from collections.abc import Callable from typing import Any +from urllib.parse import unquote from websockets.http11 import Request as WsRequest from websockets.http11 import Response @@ -145,7 +146,7 @@ class WebUIExtensionsRouter: if len(raw.encode("utf-8")) > _VALUES_MAX_BYTES: raise ValueError("Extension request is too large") try: - value = json.loads(raw) + value = json.loads(unquote(raw)) except json.JSONDecodeError as exc: raise ValueError("Invalid extension request") from exc if not isinstance(value, dict): diff --git a/tests/webui/test_extensions_routes.py b/tests/webui/test_extensions_routes.py index a64a8d48b..dc2e654bc 100644 --- a/tests/webui/test_extensions_routes.py +++ b/tests/webui/test_extensions_routes.py @@ -2,7 +2,7 @@ from __future__ import annotations import json from types import SimpleNamespace -from urllib.parse import parse_qs, urlsplit +from urllib.parse import parse_qs, quote, urlsplit import pytest from websockets.datastructures import Headers @@ -54,10 +54,12 @@ def _request( method: str = "GET", values: dict[str, object] | None = None, host: str = "127.0.0.1:8765", + encode_values: bool = False, ): headers = Headers([("Host", host)]) if values is not None: - headers["X-Nanobot-Extension-Values"] = json.dumps(values) + payload = json.dumps(values) + headers["X-Nanobot-Extension-Values"] = quote(payload) if encode_values else payload return SimpleNamespace(path=path, method=method, headers=headers) @@ -112,14 +114,15 @@ async def test_extension_install_is_local_and_untrusted() -> None: _request( "/api/extensions/install", method="POST", - values={"source": "pi-example", "kind": "npm"}, + values={"source": "中文扩展", "kind": "npm"}, + encode_values=True, ), "/api/extensions/install", ) assert response is not None and response.status_code == 200 assert service.calls == [ - ("install", ("pi-example", "npm", "", False)), + ("install", ("中文扩展", "npm", "", False)), ] diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 2e9e74e95..55f5a5a5f 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -90,7 +90,13 @@ const TOKEN_REFRESH_MIN_DELAY_MS = 5_000; const PAIRING_POLL_INTERVAL_MS = 5_000; const PAIRING_IDLE_POLL_INTERVAL_MS = 15_000; const PAIRING_DISMISS_SNOOZE_MS = 30_000; -type ShellView = "chat" | "settings" | "apps" | "automations" | "skills"; +type ShellView = + | "chat" + | "settings" + | "apps" + | "automations" + | "skills" + | "extensions"; type ShellRoute = { view: ShellView; activeKey: string | null; @@ -102,6 +108,10 @@ const SettingsView = lazy(async () => { const module = await loadSettingsView(); return { default: module.SettingsView }; }); +const ExtensionsView = lazy(async () => { + const module = await import("@/components/ExtensionsView"); + return { default: module.ExtensionsView }; +}); const SessionSearchDialog = lazy(async () => { const module = await import("@/components/SessionSearchDialog"); return { default: module.SessionSearchDialog }; @@ -225,6 +235,9 @@ function readShellRoute(): ShellRoute { if (path === "/skills") { return { view: "skills", activeKey, settingsSection: "skills" }; } + if (path === "/extensions") { + return { view: "extensions", activeKey, settingsSection: "overview" }; + } if (path.startsWith("/chat/")) { const encoded = path.slice("/chat/".length); try { @@ -1653,6 +1666,12 @@ function Shell({ setMobileSidebarOpen(false); }, [activeKey, navigate]); + const onOpenExtensions = useCallback(() => { + setSessionSearchOpen(false); + navigate({ view: "extensions", activeKey, settingsSection: "overview" }); + setMobileSidebarOpen(false); + }, [activeKey, navigate]); + const onSettingsSectionChange = useCallback( (section: SettingsSectionKey) => { navigate({ @@ -1880,6 +1899,12 @@ function Shell({ }); return; } + if (view === "extensions") { + document.title = t("app.documentTitle.chat", { + title: t("extensions.title", { defaultValue: "Extensions" }), + }); + return; + } document.title = activeSession ? t("app.documentTitle.chat", { title: headerTitle }) : t("app.documentTitle.base"); @@ -1902,9 +1927,16 @@ function Shell({ onOpenApps, onOpenAutomations, onOpenSkills, + onOpenExtensions, onSettingsIntent, onOpenSearch: onOpenSessionSearch, - activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null, + activeUtility: + view === "apps" + || view === "automations" + || view === "skills" + || view === "extensions" + ? view + : null, onToggleArchived, pinnedKeys: sidebarState.pinned_keys, archivedKeys: sidebarState.archived_keys, @@ -2097,24 +2129,31 @@ function Shell({ {view !== "chat" && (
}> - + {view === "extensions" ? ( + + ) : ( + + )}
)} diff --git a/webui/src/components/ExtensionsView.tsx b/webui/src/components/ExtensionsView.tsx new file mode 100644 index 000000000..d9b457a8b --- /dev/null +++ b/webui/src/components/ExtensionsView.tsx @@ -0,0 +1,41 @@ +import { ChevronLeft } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { ExtensionsCatalog } from "@/components/extensions/ExtensionsCatalog"; +import { cn } from "@/lib/utils"; + +interface ExtensionsViewProps { + hostChromeInset?: boolean; + onBackToChat: () => void; +} + +export function ExtensionsView({ + hostChromeInset = false, + onBackToChat, +}: ExtensionsViewProps) { + const { t } = useTranslation(); + + return ( +
+
+ +

+ {t("extensions.title")} +

+ +
+
+ ); +} diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index 95ae6f304..3b06bed5a 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { Brain, CalendarClock, Menu, + PackageOpen, Search, Settings, SquarePen, @@ -36,10 +37,11 @@ interface SidebarProps { onOpenSettings: () => void; onOpenApps: () => void; onOpenSkills: () => void; + onOpenExtensions: () => void; onOpenAutomations: () => void; onSettingsIntent?: () => void; onOpenSearch: () => void; - activeUtility?: "apps" | "skills" | "automations" | null; + activeUtility?: "apps" | "skills" | "extensions" | "automations" | null; onToggleArchived: () => void; onCollapse: () => void; onExpand?: () => void; @@ -169,6 +171,13 @@ export function Sidebar(props: SidebarProps) { active={props.activeUtility === "skills"} icon={} /> + } + /> void; + onAction: ( + action: ExtensionAction, + values: Record, + ) => Promise; +} + +export function ExtensionDetailSheet({ + extension, + diagnostics, + busy, + open, + onOpenChange, + onAction, +}: ExtensionDetailSheetProps) { + const { t } = useTranslation(); + const [uninstallOpen, setUninstallOpen] = useState(false); + if (!extension) return null; + + const external = extension.scope !== "builtin"; + const requested = new Set(extension.requested_permissions); + const granted = new Set(extension.granted_permissions); + const allGranted = [...requested].every((permission) => granted.has(permission)); + + return ( + <> + + +
+
+ +
+ + {extension.name} + + + {extension.description || extension.id} + +
+ + {extension.version} + +
+
+
+ +
+ +
+ + + + +
+ {extension.homepage ? ( + + {t("extensions.details.homepage")} + + + ) : null} +
+ + ({ + name: item.name, + meta: item.kind, + }))} + /> + ({ + name: item.name, + meta: `${item.kind}${item.specifier ? ` ${item.specifier}` : ""}`, + }))} + /> + + + {extension.permissions.length ? ( +
+ {extension.permissions.map((permission) => ( +
+
+
+ {permission.name} +
+ {permission.reason ? ( +

+ {permission.reason} +

+ ) : null} +
+ + {granted.has(permission.name) + ? t("extensions.permissionGranted") + : t("extensions.permissionPending")} + +
+ ))} + {external ? ( + + ) : null} +
+ ) : ( +

+ {t("extensions.noPermissions")} +

+ )} +
+ + {diagnostics.length ? ( + +
+ {diagnostics.map((diagnostic, index) => ( +
+
+ {diagnostic.code} +
+

+ {diagnostic.message} +

+
+ ))} +
+
+ ) : null} +
+
+ + {external ? ( +
+ + + +
+ ) : null} +
+
+ + + + + {t("extensions.uninstallTitle")} + + {t("extensions.uninstallDescription", { name: extension.name })} + + + + {t("extensions.cancel")} + void onAction("uninstall", { id: extension.id })} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t("extensions.uninstall")} + + + + + + ); +} diff --git a/webui/src/components/extensions/ExtensionsCatalog.tsx b/webui/src/components/extensions/ExtensionsCatalog.tsx new file mode 100644 index 000000000..4aaf064f8 --- /dev/null +++ b/webui/src/components/extensions/ExtensionsCatalog.tsx @@ -0,0 +1,393 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Check, CircleAlert, Download, Loader2, Search } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + fetchExtensions, + runExtensionAction, + searchExtensions, + type ExtensionAction, +} from "@/lib/api"; +import type { + ExtensionDiagnosticInfo, + ExtensionInfo, + ExtensionMarketPackage, +} from "@/lib/types"; +import { cn } from "@/lib/utils"; +import { useClient } from "@/providers/ClientProvider"; + +import { ExtensionDetailSheet } from "./ExtensionDetailSheet"; +import { + EmptyState, + type ExtensionEcosystem, + ExtensionMark, + type ExtensionTab, + filterExtensions, + filterPackages, + LoadingState, + RuntimeBadge, + StatusBadge, +} from "./extension-ui"; + +export function ExtensionsCatalog() { + const { t } = useTranslation(); + const { token } = useClient(); + const [tab, setTab] = useState("installed"); + const [ecosystem, setEcosystem] = useState("all"); + const [query, setQuery] = useState(""); + const [extensions, setExtensions] = useState([]); + const [diagnostics, setDiagnostics] = useState([]); + const [packages, setPackages] = useState([]); + const [selected, setSelected] = useState(null); + const [loading, setLoading] = useState(true); + const [searching, setSearching] = useState(false); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const payload = await fetchExtensions(token); + setExtensions(payload.extensions); + setDiagnostics(payload.diagnostics); + setError(null); + setSelected((current) => + current + ? payload.extensions.find((item) => item.id === current.id) ?? null + : null, + ); + } catch (reason) { + setError((reason as Error).message); + } finally { + setLoading(false); + } + }, [token]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + if (tab !== "discover") return; + let cancelled = false; + const timer = window.setTimeout(() => { + setSearching(true); + searchExtensions(token, query, ecosystem) + .then((payload) => { + if (!cancelled) { + setPackages(payload.packages); + setError(null); + } + }) + .catch((reason) => { + if (!cancelled) setError((reason as Error).message); + }) + .finally(() => { + if (!cancelled) setSearching(false); + }); + }, 220); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [ecosystem, query, tab, token]); + + const installed = useMemo( + () => extensions.filter((extension) => extension.scope !== "builtin"), + [extensions], + ); + const builtin = useMemo( + () => extensions.filter((extension) => extension.scope === "builtin"), + [extensions], + ); + const installedIds = useMemo( + () => + new Set( + installed.flatMap((extension) => + [extension.id, extension.source_ref].filter(Boolean), + ), + ), + [installed], + ); + + const mutate = useCallback( + async ( + action: ExtensionAction, + values: Record, + key: string, + ) => { + setBusy(key); + try { + await runExtensionAction(token, action, values); + await refresh(); + setError(null); + } catch (reason) { + setError((reason as Error).message); + } finally { + setBusy(null); + } + }, + [refresh, token], + ); + + const tabs: Array<{ key: ExtensionTab; count?: number }> = [ + { key: "installed", count: installed.length }, + { key: "discover" }, + { key: "builtin", count: builtin.length }, + ]; + + return ( +
+
+
+ {tabs.map((item) => ( + + ))} +
+ {tab === "discover" ? ( + + ) : null} +
+ + + + {error ? ( +
+ {error} +
+ ) : null} + + {tab === "discover" ? ( + + void mutate( + "install", + { source: item.name, kind: "npm" }, + `install:${item.name}`, + ) + } + /> + ) : ( + + )} + + diagnostic.extension_id === selected?.id, + )} + busy={busy} + open={selected !== null} + onOpenChange={(open) => { + if (!open) setSelected(null); + }} + onAction={(action, values) => + mutate(action, values, `${action}:${selected?.id ?? ""}`) + } + /> +
+ ); +} + +function EcosystemFilter({ + value, + onChange, +}: { + value: ExtensionEcosystem; + onChange: (value: ExtensionEcosystem) => void; +}) { + const { t } = useTranslation(); + const items: ExtensionEcosystem[] = ["all", "nanobot", "pi", "openclaw"]; + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +function ExtensionList({ + extensions, + diagnostics, + loading, + emptyKey, + onSelect, +}: { + extensions: ExtensionInfo[]; + diagnostics: ExtensionDiagnosticInfo[]; + loading: boolean; + emptyKey: "installed" | "builtin"; + onSelect: (extension: ExtensionInfo) => void; +}) { + const { t } = useTranslation(); + if (loading) return ; + if (!extensions.length) { + return ; + } + const diagnosticIds = new Set(diagnostics.map((item) => item.extension_id)); + return ( +
+ {extensions.map((extension, index) => ( + + ))} +
+ ); +} + +function MarketList({ + packages, + installedIds, + loading, + busy, + onInstall, +}: { + packages: ExtensionMarketPackage[]; + installedIds: Set; + loading: boolean; + busy: string | null; + onInstall: (item: ExtensionMarketPackage) => void; +}) { + const { t } = useTranslation(); + if (loading) return ; + if (!packages.length) return ; + return ( +
+ {packages.map((item, index) => { + const installed = installedIds.has(item.name); + const actionKey = `install:${item.name}`; + return ( +
0 && "border-t border-border/40", + )} + > + +
+
+

+ {item.name} +

+ + + {item.version} + +
+

+ {item.description} +

+
+ +
+ ); + })} +
+ ); +} diff --git a/webui/src/components/extensions/extension-ui.tsx b/webui/src/components/extensions/extension-ui.tsx new file mode 100644 index 000000000..1834dcf6d --- /dev/null +++ b/webui/src/components/extensions/extension-ui.tsx @@ -0,0 +1,157 @@ +import type { ReactNode } from "react"; +import { Box, Loader2, PackageOpen } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import type { ExtensionInfo, ExtensionMarketPackage } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +export type ExtensionTab = "installed" | "discover" | "builtin"; +export type ExtensionEcosystem = "all" | "nanobot" | "pi" | "openclaw"; + +export function ExtensionMark({ + runtime, + large = false, +}: { + runtime: string; + large?: boolean; +}) { + const Icon = runtime === "pi" || runtime === "openclaw" ? Box : PackageOpen; + return ( +
+ +
+ ); +} + +export function RuntimeBadge({ runtime }: { runtime: string }) { + return ( + + {runtime} + + ); +} + +export function StatusBadge({ extension }: { extension: ExtensionInfo }) { + const { t } = useTranslation(); + const [key, tone] = extension.active + ? ["active", "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"] + : !extension.enabled + ? ["disabled", "bg-muted text-muted-foreground"] + : !extension.trusted + ? ["untrusted", "bg-amber-500/10 text-amber-700 dark:text-amber-300"] + : ["inactive", "bg-muted text-muted-foreground"]; + return ( + + {t(`extensions.status.${key}`)} + + ); +} + +export function DetailSection({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export function NamedItems({ + title, + rows, +}: { + title: string; + rows: Array<{ name: string; meta: string }>; +}) { + const { t } = useTranslation(); + return ( + + {rows.length ? ( +
+ {rows.map((row, index) => ( +
+ + {row.name} + + {row.meta} +
+ ))} +
+ ) : ( +

{t("extensions.none")}

+ )} +
+ ); +} + +export function MetaItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function DetailPill({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function LoadingState() { + const { t } = useTranslation(); + return ( +
+ + {t("extensions.loading")} +
+ ); +} + +export function EmptyState({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} + +export function filterExtensions( + items: ExtensionInfo[], + query: string, +): ExtensionInfo[] { + const term = query.trim().toLowerCase(); + if (!term) return items; + return items.filter((item) => + [item.name, item.id, item.description, item.runtime].some((value) => + value.toLowerCase().includes(term), + ), + ); +} + +export function filterPackages( + items: ExtensionMarketPackage[], + query: string, +): ExtensionMarketPackage[] { + const term = query.trim().toLowerCase(); + if (!term) return items; + return items.filter((item) => + [item.name, item.description, item.publisher].some((value) => + value.toLowerCase().includes(term), + ), + ); +} diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index bf3260be3..891b8c627 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -58,7 +58,8 @@ "automations": "Automations", "skills": { "title": "Skills" - } + }, + "extensions": "Extensions" }, "settings": { "backToChat": "Back to chat", @@ -1261,5 +1262,62 @@ "usePath": "Use Path", "absolutePathRequired": "Enter an absolute folder path on this machine." } + }, + "extensions": { + "backToChat": "Back to chat", + "title": "Extensions", + "tabs": { + "label": "Extension views", + "installed": "Installed", + "discover": "Discover", + "builtin": "Built in" + }, + "ecosystem": { + "all": "All", + "nanobot": "nanobot", + "pi": "Pi", + "openclaw": "OpenClaw" + }, + "searchMarket": "Search extension packages", + "searchInstalled": "Search extensions", + "empty": { + "installed": "No external extensions installed.", + "discover": "No matching extension packages.", + "builtin": "No built-in capabilities found." + }, + "installed": "Installed", + "install": "Install {{name}}", + "details": { + "identity": "Identity", + "source": "Source", + "scope": "Scope", + "license": "License", + "homepage": "Homepage", + "contributions": "Contributions", + "dependencies": "Dependencies", + "permissions": "Permissions", + "diagnostics": "Diagnostics" + }, + "permissionGranted": "Granted", + "permissionPending": "Not granted", + "revokePermissions": "Revoke permissions", + "grantPermissions": "Grant permissions", + "noPermissions": "No host permissions requested.", + "revokeTrust": "Revoke trust", + "trust": "Trust", + "disable": "Disable", + "enable": "Enable", + "uninstall": "Uninstall", + "uninstallTitle": "Uninstall extension?", + "uninstallDescription": "This removes {{name}} and its installed files from nanobot.", + "cancel": "Cancel", + "status": { + "active": "Active", + "disabled": "Disabled", + "untrusted": "Untrusted", + "inactive": "Inactive" + }, + "none": "None", + "loading": "Loading extensions…" } } diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index b86bcda50..e9eacc692 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -58,7 +58,8 @@ "automations": "Automatizaciones", "skills": { "title": "Habilidades" - } + }, + "extensions": "Extensiones" }, "settings": { "backToChat": "Volver al chat", @@ -1248,5 +1249,62 @@ "usePath": "Usar ruta", "absolutePathRequired": "Introduce una ruta absoluta de carpeta en esta máquina." } + }, + "extensions": { + "backToChat": "Volver al chat", + "title": "Extensiones", + "tabs": { + "label": "Vistas de extensiones", + "installed": "Instaladas", + "discover": "Descubrir", + "builtin": "Integradas" + }, + "ecosystem": { + "all": "Todas", + "nanobot": "nanobot", + "pi": "Pi", + "openclaw": "OpenClaw" + }, + "searchMarket": "Buscar paquetes de extensiones", + "searchInstalled": "Buscar extensiones", + "empty": { + "installed": "No hay extensiones externas instaladas.", + "discover": "No hay paquetes de extensiones coincidentes.", + "builtin": "No se encontraron capacidades integradas." + }, + "installed": "Instalada", + "install": "Instalar {{name}}", + "details": { + "identity": "Identidad", + "source": "Origen", + "scope": "Ámbito", + "license": "Licencia", + "homepage": "Página principal", + "contributions": "Contribuciones", + "dependencies": "Dependencias", + "permissions": "Permisos", + "diagnostics": "Diagnóstico" + }, + "permissionGranted": "Concedido", + "permissionPending": "No concedido", + "revokePermissions": "Revocar permisos", + "grantPermissions": "Conceder permisos", + "noPermissions": "No solicita permisos del host.", + "revokeTrust": "Revocar confianza", + "trust": "Confiar", + "disable": "Desactivar", + "enable": "Activar", + "uninstall": "Desinstalar", + "uninstallTitle": "¿Desinstalar la extensión?", + "uninstallDescription": "Esto elimina {{name}} y sus archivos instalados de nanobot.", + "cancel": "Cancelar", + "status": { + "active": "Activa", + "disabled": "Desactivada", + "untrusted": "No confiable", + "inactive": "Inactiva" + }, + "none": "Ninguna", + "loading": "Cargando extensiones…" } } diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 0cc55a48c..7ac2dbbf0 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -58,7 +58,8 @@ "automations": "Automatisations", "skills": { "title": "Compétences" - } + }, + "extensions": "Extensions" }, "settings": { "backToChat": "Retour au chat", @@ -1247,5 +1248,62 @@ "usePath": "Utiliser le chemin", "absolutePathRequired": "Saisissez un chemin absolu de dossier sur cette machine." } + }, + "extensions": { + "backToChat": "Retour au chat", + "title": "Extensions", + "tabs": { + "label": "Vues des extensions", + "installed": "Installées", + "discover": "Découvrir", + "builtin": "Intégrées" + }, + "ecosystem": { + "all": "Toutes", + "nanobot": "nanobot", + "pi": "Pi", + "openclaw": "OpenClaw" + }, + "searchMarket": "Rechercher des paquets d’extension", + "searchInstalled": "Rechercher des extensions", + "empty": { + "installed": "Aucune extension externe installée.", + "discover": "Aucun paquet d’extension correspondant.", + "builtin": "Aucune capacité intégrée trouvée." + }, + "installed": "Installée", + "install": "Installer {{name}}", + "details": { + "identity": "Identité", + "source": "Source", + "scope": "Portée", + "license": "Licence", + "homepage": "Site web", + "contributions": "Contributions", + "dependencies": "Dépendances", + "permissions": "Autorisations", + "diagnostics": "Diagnostic" + }, + "permissionGranted": "Accordée", + "permissionPending": "Non accordée", + "revokePermissions": "Révoquer les autorisations", + "grantPermissions": "Accorder les autorisations", + "noPermissions": "Aucune autorisation hôte demandée.", + "revokeTrust": "Révoquer la confiance", + "trust": "Faire confiance", + "disable": "Désactiver", + "enable": "Activer", + "uninstall": "Désinstaller", + "uninstallTitle": "Désinstaller l’extension ?", + "uninstallDescription": "Cette action supprime {{name}} et ses fichiers installés de nanobot.", + "cancel": "Annuler", + "status": { + "active": "Active", + "disabled": "Désactivée", + "untrusted": "Non approuvée", + "inactive": "Inactive" + }, + "none": "Aucune", + "loading": "Chargement des extensions…" } } diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 21d75275e..a70bbc7e9 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -58,7 +58,8 @@ "automations": "Otomasi", "skills": { "title": "Skill" - } + }, + "extensions": "Ekstensi" }, "settings": { "backToChat": "Kembali ke chat", @@ -1247,5 +1248,62 @@ "usePath": "Gunakan path", "absolutePathRequired": "Masukkan path folder absolut di mesin ini." } + }, + "extensions": { + "backToChat": "Kembali ke obrolan", + "title": "Ekstensi", + "tabs": { + "label": "Tampilan ekstensi", + "installed": "Terpasang", + "discover": "Temukan", + "builtin": "Bawaan" + }, + "ecosystem": { + "all": "Semua", + "nanobot": "nanobot", + "pi": "Pi", + "openclaw": "OpenClaw" + }, + "searchMarket": "Cari paket ekstensi", + "searchInstalled": "Cari ekstensi", + "empty": { + "installed": "Belum ada ekstensi eksternal terpasang.", + "discover": "Tidak ada paket ekstensi yang cocok.", + "builtin": "Kemampuan bawaan tidak ditemukan." + }, + "installed": "Terpasang", + "install": "Pasang {{name}}", + "details": { + "identity": "Identitas", + "source": "Sumber", + "scope": "Cakupan", + "license": "Lisensi", + "homepage": "Beranda", + "contributions": "Kontribusi", + "dependencies": "Dependensi", + "permissions": "Izin", + "diagnostics": "Diagnostik" + }, + "permissionGranted": "Diberikan", + "permissionPending": "Belum diberikan", + "revokePermissions": "Cabut izin", + "grantPermissions": "Berikan izin", + "noPermissions": "Tidak meminta izin host.", + "revokeTrust": "Cabut kepercayaan", + "trust": "Percayai", + "disable": "Nonaktifkan", + "enable": "Aktifkan", + "uninstall": "Copot", + "uninstallTitle": "Copot ekstensi?", + "uninstallDescription": "Tindakan ini menghapus {{name}} dan berkas terpasangnya dari nanobot.", + "cancel": "Batal", + "status": { + "active": "Aktif", + "disabled": "Dinonaktifkan", + "untrusted": "Belum dipercaya", + "inactive": "Tidak aktif" + }, + "none": "Tidak ada", + "loading": "Memuat ekstensi…" } } diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 901bc9d1b..bd6d7b2c6 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -58,7 +58,8 @@ "automations": "自動タスク", "skills": { "title": "スキル" - } + }, + "extensions": "拡張機能" }, "settings": { "backToChat": "チャットに戻る", @@ -1247,5 +1248,62 @@ "usePath": "パスを使用", "absolutePathRequired": "このマシン上の絶対フォルダーパスを入力してください。" } + }, + "extensions": { + "backToChat": "チャットに戻る", + "title": "拡張機能", + "tabs": { + "label": "拡張機能ビュー", + "installed": "インストール済み", + "discover": "探す", + "builtin": "組み込み" + }, + "ecosystem": { + "all": "すべて", + "nanobot": "nanobot", + "pi": "Pi", + "openclaw": "OpenClaw" + }, + "searchMarket": "拡張パッケージを検索", + "searchInstalled": "拡張機能を検索", + "empty": { + "installed": "外部拡張機能はありません。", + "discover": "一致する拡張パッケージはありません。", + "builtin": "組み込み機能が見つかりません。" + }, + "installed": "インストール済み", + "install": "{{name}} をインストール", + "details": { + "identity": "識別情報", + "source": "ソース", + "scope": "スコープ", + "license": "ライセンス", + "homepage": "ホームページ", + "contributions": "提供機能", + "dependencies": "依存関係", + "permissions": "権限", + "diagnostics": "診断" + }, + "permissionGranted": "許可済み", + "permissionPending": "未許可", + "revokePermissions": "権限を取り消す", + "grantPermissions": "権限を許可", + "noPermissions": "ホスト権限の要求はありません。", + "revokeTrust": "信頼を取り消す", + "trust": "信頼する", + "disable": "無効化", + "enable": "有効化", + "uninstall": "アンインストール", + "uninstallTitle": "拡張機能をアンインストールしますか?", + "uninstallDescription": "{{name}} とインストール済みファイルを nanobot から削除します。", + "cancel": "キャンセル", + "status": { + "active": "実行中", + "disabled": "無効", + "untrusted": "未信頼", + "inactive": "停止中" + }, + "none": "なし", + "loading": "拡張機能を読み込み中…" } } diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index dd2ad5de4..5e7f2c756 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -58,7 +58,8 @@ "automations": "자동화", "skills": { "title": "스킬" - } + }, + "extensions": "확장 기능" }, "settings": { "backToChat": "채팅으로 돌아가기", @@ -1247,5 +1248,62 @@ "usePath": "경로 사용", "absolutePathRequired": "이 머신의 절대 폴더 경로를 입력하세요." } + }, + "extensions": { + "backToChat": "채팅으로 돌아가기", + "title": "확장 기능", + "tabs": { + "label": "확장 기능 보기", + "installed": "설치됨", + "discover": "찾기", + "builtin": "내장" + }, + "ecosystem": { + "all": "전체", + "nanobot": "nanobot", + "pi": "Pi", + "openclaw": "OpenClaw" + }, + "searchMarket": "확장 패키지 검색", + "searchInstalled": "확장 기능 검색", + "empty": { + "installed": "설치된 외부 확장 기능이 없습니다.", + "discover": "일치하는 확장 패키지가 없습니다.", + "builtin": "내장 기능을 찾지 못했습니다." + }, + "installed": "설치됨", + "install": "{{name}} 설치", + "details": { + "identity": "식별 정보", + "source": "소스", + "scope": "범위", + "license": "라이선스", + "homepage": "홈페이지", + "contributions": "제공 기능", + "dependencies": "종속성", + "permissions": "권한", + "diagnostics": "진단" + }, + "permissionGranted": "허용됨", + "permissionPending": "허용되지 않음", + "revokePermissions": "권한 취소", + "grantPermissions": "권한 허용", + "noPermissions": "요청한 호스트 권한이 없습니다.", + "revokeTrust": "신뢰 취소", + "trust": "신뢰", + "disable": "비활성화", + "enable": "활성화", + "uninstall": "제거", + "uninstallTitle": "확장 기능을 제거할까요?", + "uninstallDescription": "nanobot에서 {{name}} 및 설치된 파일을 제거합니다.", + "cancel": "취소", + "status": { + "active": "실행 중", + "disabled": "비활성", + "untrusted": "신뢰 안 함", + "inactive": "중지됨" + }, + "none": "없음", + "loading": "확장 기능을 불러오는 중…" } } diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index 0692324e2..767ba0d49 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -58,7 +58,8 @@ "automations": "Automações", "skills": { "title": "Skills" - } + }, + "extensions": "Extensões" }, "settings": { "backToChat": "Voltar para a conversa", @@ -1261,5 +1262,62 @@ "usePath": "Usar caminho", "absolutePathRequired": "Informe um caminho absoluto de pasta nesta máquina." } + }, + "extensions": { + "backToChat": "Voltar ao chat", + "title": "Extensões", + "tabs": { + "label": "Visualizações de extensões", + "installed": "Instaladas", + "discover": "Descobrir", + "builtin": "Integradas" + }, + "ecosystem": { + "all": "Todas", + "nanobot": "nanobot", + "pi": "Pi", + "openclaw": "OpenClaw" + }, + "searchMarket": "Buscar pacotes de extensão", + "searchInstalled": "Buscar extensões", + "empty": { + "installed": "Nenhuma extensão externa instalada.", + "discover": "Nenhum pacote de extensão correspondente.", + "builtin": "Nenhum recurso integrado encontrado." + }, + "installed": "Instalada", + "install": "Instalar {{name}}", + "details": { + "identity": "Identidade", + "source": "Origem", + "scope": "Escopo", + "license": "Licença", + "homepage": "Página inicial", + "contributions": "Contribuições", + "dependencies": "Dependências", + "permissions": "Permissões", + "diagnostics": "Diagnóstico" + }, + "permissionGranted": "Concedida", + "permissionPending": "Não concedida", + "revokePermissions": "Revogar permissões", + "grantPermissions": "Conceder permissões", + "noPermissions": "Nenhuma permissão do host solicitada.", + "revokeTrust": "Revogar confiança", + "trust": "Confiar", + "disable": "Desativar", + "enable": "Ativar", + "uninstall": "Desinstalar", + "uninstallTitle": "Desinstalar a extensão?", + "uninstallDescription": "Isso remove {{name}} e seus arquivos instalados do nanobot.", + "cancel": "Cancelar", + "status": { + "active": "Ativa", + "disabled": "Desativada", + "untrusted": "Não confiável", + "inactive": "Inativa" + }, + "none": "Nenhuma", + "loading": "Carregando extensões…" } } diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 4541861da..c9c4f487b 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -58,7 +58,8 @@ "automations": "Tự động hóa", "skills": { "title": "Kỹ năng" - } + }, + "extensions": "Tiện ích" }, "settings": { "backToChat": "Quay lại chat", @@ -1247,5 +1248,62 @@ "usePath": "Dùng đường dẫn", "absolutePathRequired": "Nhập đường dẫn thư mục tuyệt đối trên máy này." } + }, + "extensions": { + "backToChat": "Quay lại trò chuyện", + "title": "Tiện ích mở rộng", + "tabs": { + "label": "Chế độ xem tiện ích", + "installed": "Đã cài", + "discover": "Khám phá", + "builtin": "Tích hợp" + }, + "ecosystem": { + "all": "Tất cả", + "nanobot": "nanobot", + "pi": "Pi", + "openclaw": "OpenClaw" + }, + "searchMarket": "Tìm gói tiện ích", + "searchInstalled": "Tìm tiện ích", + "empty": { + "installed": "Chưa cài tiện ích bên ngoài.", + "discover": "Không có gói tiện ích phù hợp.", + "builtin": "Không tìm thấy khả năng tích hợp." + }, + "installed": "Đã cài", + "install": "Cài {{name}}", + "details": { + "identity": "Định danh", + "source": "Nguồn", + "scope": "Phạm vi", + "license": "Giấy phép", + "homepage": "Trang chủ", + "contributions": "Khả năng cung cấp", + "dependencies": "Phụ thuộc", + "permissions": "Quyền", + "diagnostics": "Chẩn đoán" + }, + "permissionGranted": "Đã cấp", + "permissionPending": "Chưa cấp", + "revokePermissions": "Thu hồi quyền", + "grantPermissions": "Cấp quyền", + "noPermissions": "Không yêu cầu quyền máy chủ.", + "revokeTrust": "Thu hồi tin cậy", + "trust": "Tin cậy", + "disable": "Tắt", + "enable": "Bật", + "uninstall": "Gỡ cài đặt", + "uninstallTitle": "Gỡ tiện ích?", + "uninstallDescription": "Thao tác này xóa {{name}} và các tệp đã cài khỏi nanobot.", + "cancel": "Hủy", + "status": { + "active": "Đang chạy", + "disabled": "Đã tắt", + "untrusted": "Chưa tin cậy", + "inactive": "Không hoạt động" + }, + "none": "Không có", + "loading": "Đang tải tiện ích…" } } diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 1540bbff8..cbdeb16eb 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -58,7 +58,8 @@ "automations": "自动任务", "skills": { "title": "技能" - } + }, + "extensions": "扩展" }, "settings": { "backToChat": "返回聊天", @@ -1261,5 +1262,62 @@ "usePath": "使用路径", "absolutePathRequired": "请输入这台机器上的绝对文件夹路径。" } + }, + "extensions": { + "backToChat": "返回聊天", + "title": "扩展", + "tabs": { + "label": "扩展视图", + "installed": "已安装", + "discover": "发现", + "builtin": "内置" + }, + "ecosystem": { + "all": "全部", + "nanobot": "nanobot", + "pi": "Pi", + "openclaw": "OpenClaw" + }, + "searchMarket": "搜索扩展包", + "searchInstalled": "搜索扩展", + "empty": { + "installed": "尚未安装外部扩展。", + "discover": "没有匹配的扩展包。", + "builtin": "未发现内置能力。" + }, + "installed": "已安装", + "install": "安装 {{name}}", + "details": { + "identity": "标识", + "source": "来源", + "scope": "范围", + "license": "许可证", + "homepage": "主页", + "contributions": "提供的能力", + "dependencies": "依赖", + "permissions": "权限", + "diagnostics": "诊断" + }, + "permissionGranted": "已授予", + "permissionPending": "未授予", + "revokePermissions": "撤销权限", + "grantPermissions": "授予权限", + "noPermissions": "未请求宿主权限。", + "revokeTrust": "撤销信任", + "trust": "信任", + "disable": "停用", + "enable": "启用", + "uninstall": "卸载", + "uninstallTitle": "卸载扩展?", + "uninstallDescription": "这会从 nanobot 中移除 {{name}} 及其已安装文件。", + "cancel": "取消", + "status": { + "active": "运行中", + "disabled": "已停用", + "untrusted": "未信任", + "inactive": "未运行" + }, + "none": "无", + "loading": "正在加载扩展…" } } diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 5dca74778..253f93198 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -58,7 +58,8 @@ "automations": "自動任務", "skills": { "title": "技能" - } + }, + "extensions": "擴充套件" }, "settings": { "backToChat": "返回聊天", @@ -1247,5 +1248,62 @@ "usePath": "使用路徑", "absolutePathRequired": "請輸入這台機器上的絕對資料夾路徑。" } + }, + "extensions": { + "backToChat": "返回聊天", + "title": "擴充套件", + "tabs": { + "label": "擴充套件檢視", + "installed": "已安裝", + "discover": "探索", + "builtin": "內建" + }, + "ecosystem": { + "all": "全部", + "nanobot": "nanobot", + "pi": "Pi", + "openclaw": "OpenClaw" + }, + "searchMarket": "搜尋擴充套件", + "searchInstalled": "搜尋擴充套件", + "empty": { + "installed": "尚未安裝外部擴充套件。", + "discover": "沒有相符的擴充套件。", + "builtin": "找不到內建能力。" + }, + "installed": "已安裝", + "install": "安裝 {{name}}", + "details": { + "identity": "識別資訊", + "source": "來源", + "scope": "範圍", + "license": "授權條款", + "homepage": "首頁", + "contributions": "提供的能力", + "dependencies": "相依項目", + "permissions": "權限", + "diagnostics": "診斷" + }, + "permissionGranted": "已授予", + "permissionPending": "未授予", + "revokePermissions": "撤銷權限", + "grantPermissions": "授予權限", + "noPermissions": "未要求主機權限。", + "revokeTrust": "撤銷信任", + "trust": "信任", + "disable": "停用", + "enable": "啟用", + "uninstall": "解除安裝", + "uninstallTitle": "解除安裝擴充套件?", + "uninstallDescription": "這會從 nanobot 移除 {{name}} 及其已安裝檔案。", + "cancel": "取消", + "status": { + "active": "執行中", + "disabled": "已停用", + "untrusted": "未信任", + "inactive": "未執行" + }, + "none": "無", + "loading": "正在載入擴充套件…" } } diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 898b6a668..410a7e751 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -7,6 +7,8 @@ import type { ChannelValidationPayload, ChatSummary, CliAppsPayload, + ExtensionMarketPayload, + ExtensionsPayload, FilePreviewPayload, ImageGenerationSettingsUpdate, McpPresetsPayload, @@ -56,6 +58,7 @@ const CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values"; const API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"; const OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code"; const PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values"; +const EXTENSION_VALUES_HEADER = "X-Nanobot-Extension-Values"; export class ApiError extends Error { status: number; @@ -296,6 +299,60 @@ export async function fetchSkills( ); } +export async function fetchExtensions( + token: string, + base: string = "", +): Promise { + return request( + `${base}/api/extensions`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); +} + +export async function searchExtensions( + token: string, + query: string, + ecosystem: string, + base: string = "", +): Promise { + const params = new URLSearchParams({ q: query, ecosystem }); + return request( + `${base}/api/extensions/market?${params}`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); +} + +export type ExtensionAction = + | "install" + | "enable" + | "disable" + | "trust" + | "untrust" + | "permissions" + | "uninstall"; + +export async function runExtensionAction( + token: string, + action: ExtensionAction, + values: Record, + base: string = "", +): Promise> { + return request>( + `${base}/api/extensions/${action}`, + token, + { + method: "POST", + headers: { + [EXTENSION_VALUES_HEADER]: encodeURIComponent(JSON.stringify(values)), + }, + }, + ); +} + export async function fetchSkillDetail( token: string, name: string, diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 5a9ce09ef..3f1dc8a5b 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -734,6 +734,76 @@ export interface CliAppsPayload { }; } +export interface ExtensionContributionInfo { + kind: string; + name: string; + description: string; +} + +export interface ExtensionDependencyInfo { + kind: string; + name: string; + specifier: string; + optional: boolean; +} + +export interface ExtensionPermissionInfo { + name: string; + reason: string; +} + +export interface ExtensionInfo { + id: string; + name: string; + version: string; + runtime: "python" | "pi" | "openclaw" | "declarative" | string; + description: string; + homepage: string; + license: string; + scope: "builtin" | "user" | "workspace" | string; + location: string | null; + enabled: boolean; + trusted: boolean; + active: boolean; + requested_permissions: string[]; + granted_permissions: string[]; + source: string; + source_ref: string; + integrity: string; + installed_at: string; + contributions: ExtensionContributionInfo[]; + dependencies: ExtensionDependencyInfo[]; + permissions: ExtensionPermissionInfo[]; +} + +export interface ExtensionDiagnosticInfo { + extension_id: string; + code: string; + message: string; + severity: string; +} + +export interface ExtensionsPayload { + extensions: ExtensionInfo[]; + diagnostics: ExtensionDiagnosticInfo[]; +} + +export interface ExtensionMarketPackage { + name: string; + version: string; + description: string; + ecosystem: "nanobot" | "pi" | "openclaw" | string; + publisher: string; + license: string; + homepage: string; + repository: string; + published_at: string; +} + +export interface ExtensionMarketPayload { + packages: ExtensionMarketPackage[]; +} + export interface NanobotFeatureInfo { name: string; display_name: string; diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index e2e6103a3..59ffd5420 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -9,6 +9,7 @@ import { deleteSession, fetchFilePreview, fetchFilePreviewAvailability, + fetchExtensions, fetchAutomations, fetchApiService, fetchCliApps, @@ -32,6 +33,7 @@ import { disableNanobotFeature, enableNanobotFeature, runAutomationAction, + runExtensionAction, runCliAppAction, runMcpPresetAction, saveCustomMcpServer, @@ -81,6 +83,31 @@ describe("webui API helpers", () => { ); }); + it("reads extension status and encodes extension mutation values", async () => { + await fetchExtensions("tok"); + await runExtensionAction("tok", "install", { + source: "本地扩展", + kind: "npm", + }); + + expect(fetch).toHaveBeenNthCalledWith( + 1, + "/api/extensions", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + const [, init] = vi.mocked(fetch).mock.calls[1]; + const headers = new Headers(init?.headers); + expect(init?.method).toBe("POST"); + expect(headers.get("Authorization")).toBe("Bearer tok"); + expect( + JSON.parse( + decodeURIComponent(headers.get("X-Nanobot-Extension-Values") ?? ""), + ), + ).toEqual({ source: "本地扩展", kind: "npm" }); + }); + it("passes pagination params when fetching a WebUI thread page", async () => { await fetchWebuiThread("tok", "websocket:chat-1", { limit: 120, diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index f6a513568..78f8860c9 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -330,21 +330,75 @@ describe("App layout", () => { expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true); }); - it("places Automations after Skills in the main sidebar", async () => { + it("places Extensions between Skills and Automations in the main sidebar", async () => { render(); await waitFor(() => expect(connectSpy).toHaveBeenCalled()); const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); const appsButton = within(sidebar).getByRole("button", { name: "Apps" }); const skillsButton = within(sidebar).getByRole("button", { name: "Skills" }); + const extensionsButton = within(sidebar).getByRole("button", { + name: "Extensions", + }); const automationsButton = within(sidebar).getByRole("button", { name: "Automations" }); expect(appsButton.compareDocumentPosition(skillsButton) & Node.DOCUMENT_POSITION_FOLLOWING) .toBeTruthy(); expect( - skillsButton.compareDocumentPosition(automationsButton) & + skillsButton.compareDocumentPosition(extensionsButton) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); + expect( + extensionsButton.compareDocumentPosition(automationsButton) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("opens Extensions as a standalone utility", async () => { + mockFetchRoutes({ + "/api/extensions": { + extensions: [{ + id: "nanobot.shell", + name: "Shell", + version: "1", + runtime: "python", + description: "Run shell commands.", + homepage: "", + license: "", + scope: "builtin", + location: null, + enabled: true, + trusted: true, + active: true, + requested_permissions: [], + granted_permissions: [], + source: "native", + source_ref: "", + integrity: "", + installed_at: "", + contributions: [{ kind: "tool", name: "shell", description: "" }], + dependencies: [], + permissions: [], + }], + diagnostics: [], + }, + }); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + fireEvent.click(within(sidebar).getByRole("button", { name: "Extensions" })); + + expect(await screen.findByRole("heading", { name: "Extensions" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /Built in/ })); + expect(await screen.findByText("Shell")).toBeInTheDocument(); + expect(within(sidebar).getByRole("button", { name: "Extensions" })).toHaveAttribute( + "aria-current", + "page", + ); + expect(window.location.hash).toBe("#/extensions"); + expect(document.title).toBe("Extensions · nanobot"); }); it("restores the Settings route after a restart fallback hash", async () => { diff --git a/webui/src/tests/extensions-view.test.tsx b/webui/src/tests/extensions-view.test.tsx new file mode 100644 index 000000000..6fac16b8b --- /dev/null +++ b/webui/src/tests/extensions-view.test.tsx @@ -0,0 +1,148 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ExtensionsView } from "@/components/ExtensionsView"; +import type { NanobotClient } from "@/lib/nanobot-client"; +import type { ExtensionInfo } from "@/lib/types"; +import { ClientProvider } from "@/providers/ClientProvider"; + +function response(body: unknown): Response { + return { + ok: true, + status: 200, + headers: { get: () => "application/json" }, + json: async () => body, + text: async () => "", + } as unknown as Response; +} + +function extension(overrides: Partial = {}): ExtensionInfo { + return { + id: "sample.pi", + name: "Sample Pi", + version: "1.0.0", + runtime: "pi", + description: "A compatible Pi extension.", + homepage: "", + license: "MIT", + scope: "user", + location: "/tmp/extensions/sample.pi", + enabled: true, + trusted: false, + active: false, + requested_permissions: ["process.spawn"], + granted_permissions: [], + source: "npm", + source_ref: "@sample/pi-extension", + integrity: "sha512-example", + installed_at: "2026-07-26T00:00:00Z", + contributions: [{ kind: "tool", name: "sample", description: "" }], + dependencies: [], + permissions: [{ name: "process.spawn", reason: "Runs the extension host." }], + ...overrides, + }; +} + +function renderView() { + return render( + + {}} /> + , + ); +} + +describe("ExtensionsView", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("requires permission grants before an extension can be trusted", async () => { + let current = extension(); + const requests: Array<{ url: string; init?: RequestInit }> = []; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + requests.push({ url, init }); + if (url === "/api/extensions/permissions") { + current = extension({ granted_permissions: ["process.spawn"] }); + } + if (url === "/api/extensions/trust") { + current = extension({ + granted_permissions: ["process.spawn"], + trusted: true, + active: true, + }); + } + return url === "/api/extensions" + ? response({ extensions: [current], diagnostics: [] }) + : response({}); + })); + + renderView(); + fireEvent.click(await screen.findByRole("button", { name: /Sample Pi/ })); + + const trust = screen.getByRole("button", { name: "Trust" }); + expect(trust).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Grant permissions" })); + + await waitFor(() => expect(trust).toBeEnabled()); + fireEvent.click(trust); + + await waitFor(() => { + expect(requests.some(({ url }) => url === "/api/extensions/trust")).toBe(true); + }); + const permissionRequest = requests.find( + ({ url }) => url === "/api/extensions/permissions", + ); + const encoded = new Headers(permissionRequest?.init?.headers).get( + "X-Nanobot-Extension-Values", + ); + expect(JSON.parse(decodeURIComponent(encoded ?? ""))).toEqual({ + id: "sample.pi", + permissions: ["process.spawn"], + }); + }); + + it("discovers and installs a package without granting trust", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + requests.push({ url, init }); + if (url.startsWith("/api/extensions/market?")) { + return response({ + packages: [{ + name: "@sample/pi-extension", + version: "1.0.0", + description: "A compatible Pi extension.", + ecosystem: "pi", + publisher: "sample", + license: "MIT", + homepage: "", + repository: "", + published_at: "", + }], + }); + } + return url === "/api/extensions" + ? response({ extensions: [], diagnostics: [] }) + : response({}); + })); + + renderView(); + fireEvent.click(screen.getByRole("button", { name: "Discover" })); + fireEvent.click(await screen.findByRole("button", { + name: "Install @sample/pi-extension", + })); + + await waitFor(() => { + expect(requests.some(({ url }) => url === "/api/extensions/install")).toBe(true); + }); + const installRequest = requests.find(({ url }) => url === "/api/extensions/install"); + const encoded = new Headers(installRequest?.init?.headers).get( + "X-Nanobot-Extension-Values", + ); + expect(JSON.parse(decodeURIComponent(encoded ?? ""))).toEqual({ + source: "@sample/pi-extension", + kind: "npm", + }); + }); +});