feat(extensions): add first-class WebUI management

This commit is contained in:
Xubin Ren 2026-07-26 18:08:52 +08:00
parent 55e497be14
commit 41bebdcdb5
23 changed files with 1897 additions and 38 deletions

View File

@ -5,6 +5,7 @@ from __future__ import annotations
import json import json
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from urllib.parse import unquote
from websockets.http11 import Request as WsRequest from websockets.http11 import Request as WsRequest
from websockets.http11 import Response from websockets.http11 import Response
@ -145,7 +146,7 @@ class WebUIExtensionsRouter:
if len(raw.encode("utf-8")) > _VALUES_MAX_BYTES: if len(raw.encode("utf-8")) > _VALUES_MAX_BYTES:
raise ValueError("Extension request is too large") raise ValueError("Extension request is too large")
try: try:
value = json.loads(raw) value = json.loads(unquote(raw))
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise ValueError("Invalid extension request") from exc raise ValueError("Invalid extension request") from exc
if not isinstance(value, dict): if not isinstance(value, dict):

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import json import json
from types import SimpleNamespace from types import SimpleNamespace
from urllib.parse import parse_qs, urlsplit from urllib.parse import parse_qs, quote, urlsplit
import pytest import pytest
from websockets.datastructures import Headers from websockets.datastructures import Headers
@ -54,10 +54,12 @@ def _request(
method: str = "GET", method: str = "GET",
values: dict[str, object] | None = None, values: dict[str, object] | None = None,
host: str = "127.0.0.1:8765", host: str = "127.0.0.1:8765",
encode_values: bool = False,
): ):
headers = Headers([("Host", host)]) headers = Headers([("Host", host)])
if values is not None: 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) return SimpleNamespace(path=path, method=method, headers=headers)
@ -112,14 +114,15 @@ async def test_extension_install_is_local_and_untrusted() -> None:
_request( _request(
"/api/extensions/install", "/api/extensions/install",
method="POST", method="POST",
values={"source": "pi-example", "kind": "npm"}, values={"source": "中文扩展", "kind": "npm"},
encode_values=True,
), ),
"/api/extensions/install", "/api/extensions/install",
) )
assert response is not None and response.status_code == 200 assert response is not None and response.status_code == 200
assert service.calls == [ assert service.calls == [
("install", ("pi-example", "npm", "", False)), ("install", ("中文扩展", "npm", "", False)),
] ]

View File

@ -90,7 +90,13 @@ const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
const PAIRING_POLL_INTERVAL_MS = 5_000; const PAIRING_POLL_INTERVAL_MS = 5_000;
const PAIRING_IDLE_POLL_INTERVAL_MS = 15_000; const PAIRING_IDLE_POLL_INTERVAL_MS = 15_000;
const PAIRING_DISMISS_SNOOZE_MS = 30_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 = { type ShellRoute = {
view: ShellView; view: ShellView;
activeKey: string | null; activeKey: string | null;
@ -102,6 +108,10 @@ const SettingsView = lazy(async () => {
const module = await loadSettingsView(); const module = await loadSettingsView();
return { default: module.SettingsView }; return { default: module.SettingsView };
}); });
const ExtensionsView = lazy(async () => {
const module = await import("@/components/ExtensionsView");
return { default: module.ExtensionsView };
});
const SessionSearchDialog = lazy(async () => { const SessionSearchDialog = lazy(async () => {
const module = await import("@/components/SessionSearchDialog"); const module = await import("@/components/SessionSearchDialog");
return { default: module.SessionSearchDialog }; return { default: module.SessionSearchDialog };
@ -225,6 +235,9 @@ function readShellRoute(): ShellRoute {
if (path === "/skills") { if (path === "/skills") {
return { view: "skills", activeKey, settingsSection: "skills" }; return { view: "skills", activeKey, settingsSection: "skills" };
} }
if (path === "/extensions") {
return { view: "extensions", activeKey, settingsSection: "overview" };
}
if (path.startsWith("/chat/")) { if (path.startsWith("/chat/")) {
const encoded = path.slice("/chat/".length); const encoded = path.slice("/chat/".length);
try { try {
@ -1653,6 +1666,12 @@ function Shell({
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
}, [activeKey, navigate]); }, [activeKey, navigate]);
const onOpenExtensions = useCallback(() => {
setSessionSearchOpen(false);
navigate({ view: "extensions", activeKey, settingsSection: "overview" });
setMobileSidebarOpen(false);
}, [activeKey, navigate]);
const onSettingsSectionChange = useCallback( const onSettingsSectionChange = useCallback(
(section: SettingsSectionKey) => { (section: SettingsSectionKey) => {
navigate({ navigate({
@ -1880,6 +1899,12 @@ function Shell({
}); });
return; return;
} }
if (view === "extensions") {
document.title = t("app.documentTitle.chat", {
title: t("extensions.title", { defaultValue: "Extensions" }),
});
return;
}
document.title = activeSession document.title = activeSession
? t("app.documentTitle.chat", { title: headerTitle }) ? t("app.documentTitle.chat", { title: headerTitle })
: t("app.documentTitle.base"); : t("app.documentTitle.base");
@ -1902,9 +1927,16 @@ function Shell({
onOpenApps, onOpenApps,
onOpenAutomations, onOpenAutomations,
onOpenSkills, onOpenSkills,
onOpenExtensions,
onSettingsIntent, onSettingsIntent,
onOpenSearch: onOpenSessionSearch, onOpenSearch: onOpenSessionSearch,
activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null, activeUtility:
view === "apps"
|| view === "automations"
|| view === "skills"
|| view === "extensions"
? view
: null,
onToggleArchived, onToggleArchived,
pinnedKeys: sidebarState.pinned_keys, pinnedKeys: sidebarState.pinned_keys,
archivedKeys: sidebarState.archived_keys, archivedKeys: sidebarState.archived_keys,
@ -2097,24 +2129,31 @@ function Shell({
{view !== "chat" && ( {view !== "chat" && (
<div className="absolute inset-0 flex flex-col"> <div className="absolute inset-0 flex flex-col">
<Suspense fallback={<SurfaceLoadingFallback />}> <Suspense fallback={<SurfaceLoadingFallback />}>
<SettingsView {view === "extensions" ? (
theme={theme} <ExtensionsView
initialSection={settingsInitialSection} onBackToChat={onBackToChat}
initialSettings={settingsSnapshot} hostChromeInset={showHostChrome}
showSidebar={view === "settings"} />
onToggleTheme={toggle} ) : (
onBackToChat={onBackToChat} <SettingsView
onModelNameChange={onModelNameChange} theme={theme}
onSettingsChange={setSettingsSnapshot} initialSection={settingsInitialSection}
skills={skills} initialSettings={settingsSnapshot}
onWorkspaceSettingsChange={refreshWorkspaces} showSidebar={view === "settings"}
onSectionChange={onSettingsSectionChange} onToggleTheme={toggle}
onLogout={onLogout} onBackToChat={onBackToChat}
onRestart={onRestart} onModelNameChange={onModelNameChange}
onNativeEngineRestart={onNativeEngineRestart} onSettingsChange={setSettingsSnapshot}
isRestarting={isRestarting} skills={skills}
hostChromeInset={showHostChrome} onWorkspaceSettingsChange={refreshWorkspaces}
/> onSectionChange={onSettingsSectionChange}
onLogout={onLogout}
onRestart={onRestart}
onNativeEngineRestart={onNativeEngineRestart}
isRestarting={isRestarting}
hostChromeInset={showHostChrome}
/>
)}
</Suspense> </Suspense>
</div> </div>
)} )}

View File

@ -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 (
<main className="h-full min-w-0 overflow-y-auto bg-settings-canvas [scrollbar-gutter:stable]">
<div
className={cn(
"mx-auto w-full max-w-[920px] px-4 py-6 sm:px-8 sm:py-8 lg:py-12",
hostChromeInset && "pt-[4.25rem] sm:pt-[4.25rem] lg:pt-[4.75rem]",
)}
>
<button
type="button"
onClick={onBackToChat}
className="touch-target mb-4 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:hidden"
>
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
{t("extensions.backToChat")}
</button>
<h1 className="mb-7 text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]">
{t("extensions.title")}
</h1>
<ExtensionsCatalog />
</div>
</main>
);
}

View File

@ -4,6 +4,7 @@ import {
Brain, Brain,
CalendarClock, CalendarClock,
Menu, Menu,
PackageOpen,
Search, Search,
Settings, Settings,
SquarePen, SquarePen,
@ -36,10 +37,11 @@ interface SidebarProps {
onOpenSettings: () => void; onOpenSettings: () => void;
onOpenApps: () => void; onOpenApps: () => void;
onOpenSkills: () => void; onOpenSkills: () => void;
onOpenExtensions: () => void;
onOpenAutomations: () => void; onOpenAutomations: () => void;
onSettingsIntent?: () => void; onSettingsIntent?: () => void;
onOpenSearch: () => void; onOpenSearch: () => void;
activeUtility?: "apps" | "skills" | "automations" | null; activeUtility?: "apps" | "skills" | "extensions" | "automations" | null;
onToggleArchived: () => void; onToggleArchived: () => void;
onCollapse: () => void; onCollapse: () => void;
onExpand?: () => void; onExpand?: () => void;
@ -169,6 +171,13 @@ export function Sidebar(props: SidebarProps) {
active={props.activeUtility === "skills"} active={props.activeUtility === "skills"}
icon={<Brain className="h-4 w-4" />} icon={<Brain className="h-4 w-4" />}
/> />
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.extensions")}
onClick={props.onOpenExtensions}
active={props.activeUtility === "extensions"}
icon={<PackageOpen className="h-4 w-4" />}
/>
<SidebarActionButton <SidebarActionButton
collapsed={collapsed} collapsed={collapsed}
label={t("sidebar.automations", { defaultValue: "Automations" })} label={t("sidebar.automations", { defaultValue: "Automations" })}

View File

@ -0,0 +1,280 @@
import { useState } from "react";
import { ExternalLink, ShieldCheck, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
import type { ExtensionAction } from "@/lib/api";
import type { ExtensionDiagnosticInfo, ExtensionInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
import {
DetailPill,
DetailSection,
ExtensionMark,
MetaItem,
NamedItems,
RuntimeBadge,
StatusBadge,
} from "./extension-ui";
interface ExtensionDetailSheetProps {
extension: ExtensionInfo | null;
diagnostics: ExtensionDiagnosticInfo[];
busy: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onAction: (
action: ExtensionAction,
values: Record<string, unknown>,
) => Promise<void>;
}
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 (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-[min(36rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="flex items-start gap-3 pr-8">
<ExtensionMark runtime={extension.runtime} large />
<div className="min-w-0 flex-1">
<SheetTitle className="truncate text-[20px] font-semibold">
{extension.name}
</SheetTitle>
<SheetDescription className="mt-1 line-clamp-2 text-[13px]">
{extension.description || extension.id}
</SheetDescription>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<RuntimeBadge runtime={extension.runtime} />
<DetailPill>{extension.version}</DetailPill>
<StatusBadge extension={extension} />
</div>
</div>
</div>
<div className="mt-7 space-y-6">
<DetailSection title={t("extensions.details.identity")}>
<dl className="grid grid-cols-2 gap-2">
<MetaItem label="ID" value={extension.id} />
<MetaItem
label={t("extensions.details.source")}
value={extension.source}
/>
<MetaItem
label={t("extensions.details.scope")}
value={extension.scope}
/>
<MetaItem
label={t("extensions.details.license")}
value={extension.license || "—"}
/>
</dl>
{extension.homepage ? (
<a
href={extension.homepage}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-[12px] text-link hover:underline"
>
{t("extensions.details.homepage")}
<ExternalLink className="h-3 w-3" aria-hidden />
</a>
) : null}
</DetailSection>
<NamedItems
title={t("extensions.details.contributions")}
rows={extension.contributions.map((item) => ({
name: item.name,
meta: item.kind,
}))}
/>
<NamedItems
title={t("extensions.details.dependencies")}
rows={extension.dependencies.map((item) => ({
name: item.name,
meta: `${item.kind}${item.specifier ? ` ${item.specifier}` : ""}`,
}))}
/>
<DetailSection title={t("extensions.details.permissions")}>
{extension.permissions.length ? (
<div className="space-y-2">
{extension.permissions.map((permission) => (
<div
key={permission.name}
className="flex items-start justify-between gap-3 rounded-[14px] bg-muted/35 px-3 py-2.5"
>
<div className="min-w-0">
<div className="text-[13px] font-medium text-foreground">
{permission.name}
</div>
{permission.reason ? (
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
{permission.reason}
</p>
) : null}
</div>
<span
className={cn(
"shrink-0 text-[11px]",
granted.has(permission.name)
? "text-emerald-600 dark:text-emerald-300"
: "text-muted-foreground",
)}
>
{granted.has(permission.name)
? t("extensions.permissionGranted")
: t("extensions.permissionPending")}
</span>
</div>
))}
{external ? (
<Button
variant="outline"
size="sm"
disabled={busy !== null}
onClick={() =>
void onAction("permissions", {
id: extension.id,
permissions: allGranted ? [] : [...requested],
})
}
className="rounded-full"
>
{allGranted
? t("extensions.revokePermissions")
: t("extensions.grantPermissions")}
</Button>
) : null}
</div>
) : (
<p className="text-[13px] text-muted-foreground">
{t("extensions.noPermissions")}
</p>
)}
</DetailSection>
{diagnostics.length ? (
<DetailSection title={t("extensions.details.diagnostics")}>
<div className="space-y-2">
{diagnostics.map((diagnostic, index) => (
<div
key={`${diagnostic.code}:${index}`}
className="rounded-[14px] bg-amber-500/10 px-3 py-2.5"
>
<div className="text-[12px] font-medium text-amber-700 dark:text-amber-300">
{diagnostic.code}
</div>
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
{diagnostic.message}
</p>
</div>
))}
</div>
</DetailSection>
) : null}
</div>
</div>
{external ? (
<div className="flex flex-wrap items-center gap-2 border-t border-border/45 bg-background/95 px-5 py-4">
<Button
size="sm"
variant={extension.trusted ? "outline" : "default"}
disabled={busy !== null || (!extension.trusted && !allGranted)}
onClick={() =>
void onAction(extension.trusted ? "untrust" : "trust", {
id: extension.id,
})
}
className="rounded-full"
>
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{extension.trusted
? t("extensions.revokeTrust")
: t("extensions.trust")}
</Button>
<Button
size="sm"
variant="outline"
disabled={busy !== null || !extension.trusted}
onClick={() =>
void onAction(extension.enabled ? "disable" : "enable", {
id: extension.id,
})
}
className="rounded-full"
>
{extension.enabled
? t("extensions.disable")
: t("extensions.enable")}
</Button>
<Button
size="icon"
variant="ghost"
disabled={busy !== null}
aria-label={t("extensions.uninstall")}
title={t("extensions.uninstall")}
onClick={() => setUninstallOpen(true)}
className="ml-auto h-8 w-8 rounded-full text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" aria-hidden />
</Button>
</div>
) : null}
</SheetContent>
</Sheet>
<AlertDialog open={uninstallOpen} onOpenChange={setUninstallOpen}>
<AlertDialogContent className="max-w-[26rem] rounded-[18px]">
<AlertDialogHeader>
<AlertDialogTitle>{t("extensions.uninstallTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("extensions.uninstallDescription", { name: extension.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("extensions.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => void onAction("uninstall", { id: extension.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("extensions.uninstall")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -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<ExtensionTab>("installed");
const [ecosystem, setEcosystem] = useState<ExtensionEcosystem>("all");
const [query, setQuery] = useState("");
const [extensions, setExtensions] = useState<ExtensionInfo[]>([]);
const [diagnostics, setDiagnostics] = useState<ExtensionDiagnosticInfo[]>([]);
const [packages, setPackages] = useState<ExtensionMarketPackage[]>([]);
const [selected, setSelected] = useState<ExtensionInfo | null>(null);
const [loading, setLoading] = useState(true);
const [searching, setSearching] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<string, unknown>,
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 (
<div className="space-y-5">
<section className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div
className="inline-flex w-fit rounded-[12px] bg-muted/55 p-1"
aria-label={t("extensions.tabs.label")}
>
{tabs.map((item) => (
<button
key={item.key}
type="button"
aria-pressed={tab === item.key}
onClick={() => setTab(item.key)}
className={cn(
"h-8 rounded-[9px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
tab === item.key && "bg-background text-foreground shadow-sm",
)}
>
{t(`extensions.tabs.${item.key}`)}
{item.count === undefined ? null : (
<span className="ml-1.5 text-muted-foreground">{item.count}</span>
)}
</button>
))}
</div>
{tab === "discover" ? (
<EcosystemFilter value={ecosystem} onChange={setEcosystem} />
) : null}
</section>
<label className="relative block">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t(
tab === "discover"
? "extensions.searchMarket"
: "extensions.searchInstalled",
)}
className="h-11 rounded-[14px] border-border/55 bg-settings-surface pl-10 text-[13px] shadow-none"
/>
</label>
{error ? (
<div className="rounded-[14px] bg-destructive/10 px-3.5 py-3 text-[13px] text-destructive">
{error}
</div>
) : null}
{tab === "discover" ? (
<MarketList
packages={filterPackages(packages, query)}
installedIds={installedIds}
loading={searching}
busy={busy}
onInstall={(item) =>
void mutate(
"install",
{ source: item.name, kind: "npm" },
`install:${item.name}`,
)
}
/>
) : (
<ExtensionList
extensions={filterExtensions(tab === "builtin" ? builtin : installed, query)}
diagnostics={diagnostics}
loading={loading}
emptyKey={tab}
onSelect={setSelected}
/>
)}
<ExtensionDetailSheet
extension={selected}
diagnostics={diagnostics.filter(
(diagnostic) => 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 ?? ""}`)
}
/>
</div>
);
}
function EcosystemFilter({
value,
onChange,
}: {
value: ExtensionEcosystem;
onChange: (value: ExtensionEcosystem) => void;
}) {
const { t } = useTranslation();
const items: ExtensionEcosystem[] = ["all", "nanobot", "pi", "openclaw"];
return (
<div className="flex items-center gap-1 overflow-x-auto">
{items.map((item) => (
<button
key={item}
type="button"
aria-pressed={value === item}
onClick={() => onChange(item)}
className={cn(
"h-8 shrink-0 rounded-full px-2.5 text-[12px] text-muted-foreground transition-colors",
value === item && "bg-muted text-foreground",
)}
>
{t(`extensions.ecosystem.${item}`)}
</button>
))}
</div>
);
}
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 <LoadingState />;
if (!extensions.length) {
return <EmptyState label={t(`extensions.empty.${emptyKey}`)} />;
}
const diagnosticIds = new Set(diagnostics.map((item) => item.extension_id));
return (
<section className="overflow-hidden rounded-[18px] bg-settings-surface">
{extensions.map((extension, index) => (
<button
key={`${extension.scope}:${extension.id}`}
type="button"
onClick={() => onSelect(extension)}
className={cn(
"group flex w-full min-w-0 items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-muted/35",
index > 0 && "border-t border-border/40",
)}
>
<ExtensionMark runtime={extension.runtime} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-[14px] font-medium text-foreground">
{extension.name}
</h3>
<RuntimeBadge runtime={extension.runtime} />
</div>
<p className="mt-0.5 truncate text-[12px] text-muted-foreground">
{extension.description || extension.id}
</p>
</div>
{diagnosticIds.has(extension.id) ? (
<CircleAlert className="h-4 w-4 shrink-0 text-amber-500" aria-hidden />
) : null}
<StatusBadge extension={extension} />
</button>
))}
</section>
);
}
function MarketList({
packages,
installedIds,
loading,
busy,
onInstall,
}: {
packages: ExtensionMarketPackage[];
installedIds: Set<string>;
loading: boolean;
busy: string | null;
onInstall: (item: ExtensionMarketPackage) => void;
}) {
const { t } = useTranslation();
if (loading) return <LoadingState />;
if (!packages.length) return <EmptyState label={t("extensions.empty.discover")} />;
return (
<section className="overflow-hidden rounded-[18px] bg-settings-surface">
{packages.map((item, index) => {
const installed = installedIds.has(item.name);
const actionKey = `install:${item.name}`;
return (
<div
key={`${item.ecosystem}:${item.name}`}
className={cn(
"flex min-w-0 items-center gap-3 px-4 py-3.5",
index > 0 && "border-t border-border/40",
)}
>
<ExtensionMark runtime={item.ecosystem} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-[14px] font-medium text-foreground">
{item.name}
</h3>
<RuntimeBadge runtime={item.ecosystem} />
<span className="shrink-0 text-[11px] text-muted-foreground">
{item.version}
</span>
</div>
<p className="mt-0.5 line-clamp-1 text-[12px] text-muted-foreground">
{item.description}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
disabled={installed || busy === actionKey}
aria-label={
installed
? t("extensions.installed")
: t("extensions.install", { name: item.name })
}
title={
installed
? t("extensions.installed")
: t("extensions.install", { name: item.name })
}
onClick={() => onInstall(item)}
className="h-9 w-9 shrink-0 rounded-full bg-muted/55"
>
{busy === actionKey ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : installed ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Download className="h-4 w-4" aria-hidden />
)}
</Button>
</div>
);
})}
</section>
);
}

View File

@ -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 (
<div
className={cn(
"flex shrink-0 items-center justify-center rounded-[13px] bg-muted/65 text-muted-foreground",
large ? "h-12 w-12" : "h-10 w-10",
)}
>
<Icon className={large ? "h-5 w-5" : "h-4 w-4"} strokeWidth={1.8} aria-hidden />
</div>
);
}
export function RuntimeBadge({ runtime }: { runtime: string }) {
return (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{runtime}
</span>
);
}
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 (
<span className={cn("shrink-0 rounded-full px-2 py-1 text-[11px] font-medium", tone)}>
{t(`extensions.status.${key}`)}
</span>
);
}
export function DetailSection({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<section>
<h3 className="mb-2 text-[12px] font-medium text-muted-foreground">{title}</h3>
{children}
</section>
);
}
export function NamedItems({
title,
rows,
}: {
title: string;
rows: Array<{ name: string; meta: string }>;
}) {
const { t } = useTranslation();
return (
<DetailSection title={title}>
{rows.length ? (
<div className="divide-y divide-border/35 overflow-hidden rounded-[14px] bg-muted/30">
{rows.map((row, index) => (
<div key={`${row.meta}:${row.name}:${index}`} className="flex gap-3 px-3 py-2.5">
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
{row.name}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{row.meta}</span>
</div>
))}
</div>
) : (
<p className="text-[13px] text-muted-foreground">{t("extensions.none")}</p>
)}
</DetailSection>
);
}
export function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-[14px] bg-muted/35 px-3 py-2.5">
<dt className="text-[11px] text-muted-foreground">{label}</dt>
<dd className="mt-0.5 truncate text-[13px] text-foreground">{value}</dd>
</div>
);
}
export function DetailPill({ children }: { children: ReactNode }) {
return (
<span className="rounded-full bg-muted px-2 py-1 text-[11px] text-muted-foreground">
{children}
</span>
);
}
export function LoadingState() {
const { t } = useTranslation();
return (
<div className="flex min-h-48 items-center justify-center gap-2 text-[13px] text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("extensions.loading")}
</div>
);
}
export function EmptyState({ label }: { label: string }) {
return (
<div className="flex min-h-48 items-center justify-center text-[13px] text-muted-foreground">
{label}
</div>
);
}
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),
),
);
}

View File

@ -58,7 +58,8 @@
"automations": "Automations", "automations": "Automations",
"skills": { "skills": {
"title": "Skills" "title": "Skills"
} },
"extensions": "Extensions"
}, },
"settings": { "settings": {
"backToChat": "Back to chat", "backToChat": "Back to chat",
@ -1261,5 +1262,62 @@
"usePath": "Use Path", "usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine." "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…"
} }
} }

View File

@ -58,7 +58,8 @@
"automations": "Automatizaciones", "automations": "Automatizaciones",
"skills": { "skills": {
"title": "Habilidades" "title": "Habilidades"
} },
"extensions": "Extensiones"
}, },
"settings": { "settings": {
"backToChat": "Volver al chat", "backToChat": "Volver al chat",
@ -1248,5 +1249,62 @@
"usePath": "Usar ruta", "usePath": "Usar ruta",
"absolutePathRequired": "Introduce una ruta absoluta de carpeta en esta máquina." "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…"
} }
} }

View File

@ -58,7 +58,8 @@
"automations": "Automatisations", "automations": "Automatisations",
"skills": { "skills": {
"title": "Compétences" "title": "Compétences"
} },
"extensions": "Extensions"
}, },
"settings": { "settings": {
"backToChat": "Retour au chat", "backToChat": "Retour au chat",
@ -1247,5 +1248,62 @@
"usePath": "Utiliser le chemin", "usePath": "Utiliser le chemin",
"absolutePathRequired": "Saisissez un chemin absolu de dossier sur cette machine." "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 dextension",
"searchInstalled": "Rechercher des extensions",
"empty": {
"installed": "Aucune extension externe installée.",
"discover": "Aucun paquet dextension 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 lextension ?",
"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…"
} }
} }

View File

@ -58,7 +58,8 @@
"automations": "Otomasi", "automations": "Otomasi",
"skills": { "skills": {
"title": "Skill" "title": "Skill"
} },
"extensions": "Ekstensi"
}, },
"settings": { "settings": {
"backToChat": "Kembali ke chat", "backToChat": "Kembali ke chat",
@ -1247,5 +1248,62 @@
"usePath": "Gunakan path", "usePath": "Gunakan path",
"absolutePathRequired": "Masukkan path folder absolut di mesin ini." "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…"
} }
} }

View File

@ -58,7 +58,8 @@
"automations": "自動タスク", "automations": "自動タスク",
"skills": { "skills": {
"title": "スキル" "title": "スキル"
} },
"extensions": "拡張機能"
}, },
"settings": { "settings": {
"backToChat": "チャットに戻る", "backToChat": "チャットに戻る",
@ -1247,5 +1248,62 @@
"usePath": "パスを使用", "usePath": "パスを使用",
"absolutePathRequired": "このマシン上の絶対フォルダーパスを入力してください。" "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": "拡張機能を読み込み中…"
} }
} }

View File

@ -58,7 +58,8 @@
"automations": "자동화", "automations": "자동화",
"skills": { "skills": {
"title": "스킬" "title": "스킬"
} },
"extensions": "확장 기능"
}, },
"settings": { "settings": {
"backToChat": "채팅으로 돌아가기", "backToChat": "채팅으로 돌아가기",
@ -1247,5 +1248,62 @@
"usePath": "경로 사용", "usePath": "경로 사용",
"absolutePathRequired": "이 머신의 절대 폴더 경로를 입력하세요." "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": "확장 기능을 불러오는 중…"
} }
} }

View File

@ -58,7 +58,8 @@
"automations": "Automações", "automations": "Automações",
"skills": { "skills": {
"title": "Skills" "title": "Skills"
} },
"extensions": "Extensões"
}, },
"settings": { "settings": {
"backToChat": "Voltar para a conversa", "backToChat": "Voltar para a conversa",
@ -1261,5 +1262,62 @@
"usePath": "Usar caminho", "usePath": "Usar caminho",
"absolutePathRequired": "Informe um caminho absoluto de pasta nesta máquina." "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…"
} }
} }

View File

@ -58,7 +58,8 @@
"automations": "Tự động hóa", "automations": "Tự động hóa",
"skills": { "skills": {
"title": "Kỹ năng" "title": "Kỹ năng"
} },
"extensions": "Tiện ích"
}, },
"settings": { "settings": {
"backToChat": "Quay lại chat", "backToChat": "Quay lại chat",
@ -1247,5 +1248,62 @@
"usePath": "Dùng đường dẫn", "usePath": "Dùng đường dẫn",
"absolutePathRequired": "Nhập đường dẫn thư mục tuyệt đối trên máy này." "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…"
} }
} }

View File

@ -58,7 +58,8 @@
"automations": "自动任务", "automations": "自动任务",
"skills": { "skills": {
"title": "技能" "title": "技能"
} },
"extensions": "扩展"
}, },
"settings": { "settings": {
"backToChat": "返回聊天", "backToChat": "返回聊天",
@ -1261,5 +1262,62 @@
"usePath": "使用路径", "usePath": "使用路径",
"absolutePathRequired": "请输入这台机器上的绝对文件夹路径。" "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": "正在加载扩展…"
} }
} }

View File

@ -58,7 +58,8 @@
"automations": "自動任務", "automations": "自動任務",
"skills": { "skills": {
"title": "技能" "title": "技能"
} },
"extensions": "擴充套件"
}, },
"settings": { "settings": {
"backToChat": "返回聊天", "backToChat": "返回聊天",
@ -1247,5 +1248,62 @@
"usePath": "使用路徑", "usePath": "使用路徑",
"absolutePathRequired": "請輸入這台機器上的絕對資料夾路徑。" "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": "正在載入擴充套件…"
} }
} }

View File

@ -7,6 +7,8 @@ import type {
ChannelValidationPayload, ChannelValidationPayload,
ChatSummary, ChatSummary,
CliAppsPayload, CliAppsPayload,
ExtensionMarketPayload,
ExtensionsPayload,
FilePreviewPayload, FilePreviewPayload,
ImageGenerationSettingsUpdate, ImageGenerationSettingsUpdate,
McpPresetsPayload, McpPresetsPayload,
@ -56,6 +58,7 @@ const CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values";
const API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"; const API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values";
const OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code"; const OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code";
const PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values"; const PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values";
const EXTENSION_VALUES_HEADER = "X-Nanobot-Extension-Values";
export class ApiError extends Error { export class ApiError extends Error {
status: number; status: number;
@ -296,6 +299,60 @@ export async function fetchSkills(
); );
} }
export async function fetchExtensions(
token: string,
base: string = "",
): Promise<ExtensionsPayload> {
return request<ExtensionsPayload>(
`${base}/api/extensions`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function searchExtensions(
token: string,
query: string,
ecosystem: string,
base: string = "",
): Promise<ExtensionMarketPayload> {
const params = new URLSearchParams({ q: query, ecosystem });
return request<ExtensionMarketPayload>(
`${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<string, unknown>,
base: string = "",
): Promise<Record<string, unknown>> {
return request<Record<string, unknown>>(
`${base}/api/extensions/${action}`,
token,
{
method: "POST",
headers: {
[EXTENSION_VALUES_HEADER]: encodeURIComponent(JSON.stringify(values)),
},
},
);
}
export async function fetchSkillDetail( export async function fetchSkillDetail(
token: string, token: string,
name: string, name: string,

View File

@ -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 { export interface NanobotFeatureInfo {
name: string; name: string;
display_name: string; display_name: string;

View File

@ -9,6 +9,7 @@ import {
deleteSession, deleteSession,
fetchFilePreview, fetchFilePreview,
fetchFilePreviewAvailability, fetchFilePreviewAvailability,
fetchExtensions,
fetchAutomations, fetchAutomations,
fetchApiService, fetchApiService,
fetchCliApps, fetchCliApps,
@ -32,6 +33,7 @@ import {
disableNanobotFeature, disableNanobotFeature,
enableNanobotFeature, enableNanobotFeature,
runAutomationAction, runAutomationAction,
runExtensionAction,
runCliAppAction, runCliAppAction,
runMcpPresetAction, runMcpPresetAction,
saveCustomMcpServer, 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 () => { it("passes pagination params when fetching a WebUI thread page", async () => {
await fetchWebuiThread("tok", "websocket:chat-1", { await fetchWebuiThread("tok", "websocket:chat-1", {
limit: 120, limit: 120,

View File

@ -330,21 +330,75 @@ describe("App layout", () => {
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true); 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(<App />); render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled()); await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const appsButton = within(sidebar).getByRole("button", { name: "Apps" }); const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
const skillsButton = within(sidebar).getByRole("button", { name: "Skills" }); const skillsButton = within(sidebar).getByRole("button", { name: "Skills" });
const extensionsButton = within(sidebar).getByRole("button", {
name: "Extensions",
});
const automationsButton = within(sidebar).getByRole("button", { name: "Automations" }); const automationsButton = within(sidebar).getByRole("button", { name: "Automations" });
expect(appsButton.compareDocumentPosition(skillsButton) & Node.DOCUMENT_POSITION_FOLLOWING) expect(appsButton.compareDocumentPosition(skillsButton) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBeTruthy(); .toBeTruthy();
expect( expect(
skillsButton.compareDocumentPosition(automationsButton) & skillsButton.compareDocumentPosition(extensionsButton) &
Node.DOCUMENT_POSITION_FOLLOWING, Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy(); ).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(<App />);
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 () => { it("restores the Settings route after a restart fallback hash", async () => {

View File

@ -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> = {}): 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(
<ClientProvider client={{} as NanobotClient} token="tok">
<ExtensionsView onBackToChat={() => {}} />
</ClientProvider>,
);
}
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",
});
});
});