mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
feat(extensions): add first-class WebUI management
This commit is contained in:
parent
55e497be14
commit
41bebdcdb5
@ -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):
|
||||
|
||||
@ -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)),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
41
webui/src/components/ExtensionsView.tsx
Normal file
41
webui/src/components/ExtensionsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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" })}
|
||||
|
||||
280
webui/src/components/extensions/ExtensionDetailSheet.tsx
Normal file
280
webui/src/components/extensions/ExtensionDetailSheet.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
393
webui/src/components/extensions/ExtensionsCatalog.tsx
Normal file
393
webui/src/components/extensions/ExtensionsCatalog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
webui/src/components/extensions/extension-ui.tsx
Normal file
157
webui/src/components/extensions/extension-ui.tsx
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,7 +58,8 @@
|
||||
"automations": "Automatisations",
|
||||
"skills": {
|
||||
"title": "Compétences"
|
||||
}
|
||||
},
|
||||
"extensions": "Extensions"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Retour au chat",
|
||||
@ -1247,5 +1248,62 @@
|
||||
"usePath": "Utiliser le chemin",
|
||||
"absolutePathRequired": "Saisissez un chemin absolu de dossier sur cette machine."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"backToChat": "Retour au chat",
|
||||
"title": "Extensions",
|
||||
"tabs": {
|
||||
"label": "Vues des extensions",
|
||||
"installed": "Installées",
|
||||
"discover": "Découvrir",
|
||||
"builtin": "Intégrées"
|
||||
},
|
||||
"ecosystem": {
|
||||
"all": "Toutes",
|
||||
"nanobot": "nanobot",
|
||||
"pi": "Pi",
|
||||
"openclaw": "OpenClaw"
|
||||
},
|
||||
"searchMarket": "Rechercher des paquets d’extension",
|
||||
"searchInstalled": "Rechercher des extensions",
|
||||
"empty": {
|
||||
"installed": "Aucune extension externe installée.",
|
||||
"discover": "Aucun paquet d’extension correspondant.",
|
||||
"builtin": "Aucune capacité intégrée trouvée."
|
||||
},
|
||||
"installed": "Installée",
|
||||
"install": "Installer {{name}}",
|
||||
"details": {
|
||||
"identity": "Identité",
|
||||
"source": "Source",
|
||||
"scope": "Portée",
|
||||
"license": "Licence",
|
||||
"homepage": "Site web",
|
||||
"contributions": "Contributions",
|
||||
"dependencies": "Dépendances",
|
||||
"permissions": "Autorisations",
|
||||
"diagnostics": "Diagnostic"
|
||||
},
|
||||
"permissionGranted": "Accordée",
|
||||
"permissionPending": "Non accordée",
|
||||
"revokePermissions": "Révoquer les autorisations",
|
||||
"grantPermissions": "Accorder les autorisations",
|
||||
"noPermissions": "Aucune autorisation hôte demandée.",
|
||||
"revokeTrust": "Révoquer la confiance",
|
||||
"trust": "Faire confiance",
|
||||
"disable": "Désactiver",
|
||||
"enable": "Activer",
|
||||
"uninstall": "Désinstaller",
|
||||
"uninstallTitle": "Désinstaller l’extension ?",
|
||||
"uninstallDescription": "Cette action supprime {{name}} et ses fichiers installés de nanobot.",
|
||||
"cancel": "Annuler",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"disabled": "Désactivée",
|
||||
"untrusted": "Non approuvée",
|
||||
"inactive": "Inactive"
|
||||
},
|
||||
"none": "Aucune",
|
||||
"loading": "Chargement des extensions…"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": "拡張機能を読み込み中…"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": "확장 기능을 불러오는 중…"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": "正在加载扩展…"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": "正在載入擴充套件…"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
148
webui/src/tests/extensions-view.test.tsx
Normal file
148
webui/src/tests/extensions-view.test.tsx
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user