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 (
+
+ );
+}
+
+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",
+ });
+ });
+});