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
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):

View File

@ -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)),
]

View File

@ -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" && (
<div className="absolute inset-0 flex flex-col">
<Suspense fallback={<SurfaceLoadingFallback />}>
<SettingsView
theme={theme}
initialSection={settingsInitialSection}
initialSettings={settingsSnapshot}
showSidebar={view === "settings"}
onToggleTheme={toggle}
onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange}
onSettingsChange={setSettingsSnapshot}
skills={skills}
onWorkspaceSettingsChange={refreshWorkspaces}
onSectionChange={onSettingsSectionChange}
onLogout={onLogout}
onRestart={onRestart}
onNativeEngineRestart={onNativeEngineRestart}
isRestarting={isRestarting}
hostChromeInset={showHostChrome}
/>
{view === "extensions" ? (
<ExtensionsView
onBackToChat={onBackToChat}
hostChromeInset={showHostChrome}
/>
) : (
<SettingsView
theme={theme}
initialSection={settingsInitialSection}
initialSettings={settingsSnapshot}
showSidebar={view === "settings"}
onToggleTheme={toggle}
onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange}
onSettingsChange={setSettingsSnapshot}
skills={skills}
onWorkspaceSettingsChange={refreshWorkspaces}
onSectionChange={onSettingsSectionChange}
onLogout={onLogout}
onRestart={onRestart}
onNativeEngineRestart={onNativeEngineRestart}
isRestarting={isRestarting}
hostChromeInset={showHostChrome}
/>
)}
</Suspense>
</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,
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={<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
collapsed={collapsed}
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",
"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…"
}
}

View File

@ -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…"
}
}

View File

@ -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 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",
"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…"
}
}

View File

@ -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": "拡張機能を読み込み中…"
}
}

View File

@ -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": "확장 기능을 불러오는 중…"
}
}

View File

@ -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…"
}
}

View File

@ -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…"
}
}

View File

@ -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": "正在加载扩展…"
}
}

View File

@ -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": "正在載入擴充套件…"
}
}

View File

@ -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<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(
token: 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 {
name: string;
display_name: string;

View File

@ -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,

View File

@ -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(<App />);
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(<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 () => {

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