Add optional Nanobot plugin controls (#4396)

* feat: add optional nanobot features

* test: update azure install hint expectation

* fix: validate optional feature extras

maintainer edit: verify requested dependency extras before treating optional features as installed, propagate restart state from feature enablement, and align docs with the new plugins enable command.

* fix: bound optional feature installs

maintainer edit: make optional feature installs time out as a normal install failure instead of leaving the WebUI or CLI action waiting indefinitely.

* feat: slim optional channel dependencies

* fix: log optional install commands

* fix(webui): gate remote feature installs

* docs: clarify webhook plugin example

* fix(webui): harden optional feature installs

* fix: install optional deps without package fallback

* fix(cli): refine plugin feature controls

* fix(webui): count enabled nanobot features

* fix(webui): allow slow feature install routes

* fix(webui): allow disabling websocket channel

* fix(plugins): simplify optional feature controls

* fix(webui): polish apps catalog states

* fix(webui): confirm nanobot support installs

* fix(webui): polish nanobot install dialog

* fix(webui): suppress empty websocket handshakes

* fix(webui): clarify apps plugin summary

* fix(webui): localize workspace access copy

* fix(plugins): polish optional feature controls (#4691)

---------

Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
This commit is contained in:
chengyongru
2026-07-03 18:17:52 +08:00
committed by GitHub
co-authored by Xubin Ren
parent 00cc0da530
commit 5283ceae85
61 changed files with 3061 additions and 258 deletions
+305 -13
View File
@@ -83,11 +83,14 @@ import { Textarea } from "@/components/ui/textarea";
import {
checkVersion,
createModelConfiguration,
disableNanobotFeature,
enableNanobotFeature,
fetchAutomations,
fetchSettings,
fetchSettingsUsage,
fetchCliApps,
fetchMcpPresets,
fetchNanobotFeatures,
fetchProviderModels,
importMcpConfig,
loginProviderOAuth,
@@ -127,6 +130,8 @@ import type {
ImageGenerationSettingsUpdate,
McpPresetInfo,
McpPresetsPayload,
NanobotFeatureInfo,
NanobotFeaturesPayload,
NetworkSafetySettingsUpdate,
ProviderModelsPayload,
SessionAutomationJob,
@@ -152,11 +157,12 @@ export type SettingsSectionKey =
type LocalDensity = "comfortable" | "compact";
type LocalActivityMode = "auto" | "expanded";
type AppsKindFilter = "all" | "cli" | "mcp";
type AppsKindFilter = "all" | "nanobot" | "cli" | "mcp";
type AutomationFilter = "all" | "active" | "paused" | "failed" | "system";
type AutomationSort = "next" | "last" | "updated" | "name";
type AutomationAction = "enable" | "disable" | "delete" | "run";
type AppsCatalogItem =
| { id: string; kind: "nanobot"; feature: NanobotFeatureInfo }
| { id: string; kind: "cli"; app: CliAppInfo }
| { id: string; kind: "mcp"; preset: McpPresetInfo };
@@ -259,7 +265,7 @@ const DEFAULT_LOCAL_PREFS: LocalPreferences = {
density: "comfortable",
activityMode: "auto",
codeWrap: true,
brandLogos: true,
brandLogos: false,
};
const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }> = [
{ value: "auto", label: "Auto" },
@@ -321,7 +327,7 @@ function readLocalPreferences(): LocalPreferences {
density: parsed.density === "compact" ? "compact" : "comfortable",
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
codeWrap: parsed.codeWrap !== false,
brandLogos: parsed.brandLogos !== false,
brandLogos: parsed.brandLogos === true,
};
} catch {
return DEFAULT_LOCAL_PREFS;
@@ -536,10 +542,12 @@ export function SettingsView({
const { token } = useClient();
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
const [nanobotFeatures, setNanobotFeatures] = useState<NanobotFeaturesPayload | null>(null);
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
const [automations, setAutomations] = useState<AutomationsPayload | null>(null);
const [loading, setLoading] = useState(() => initialSettings === null);
const [cliAppsLoading, setCliAppsLoading] = useState(true);
const [nanobotFeaturesLoading, setNanobotFeaturesLoading] = useState(true);
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
const [automationsLoading, setAutomationsLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -551,6 +559,8 @@ export function SettingsView({
model: "",
});
const [cliAppsAction, setCliAppsAction] = useState<string | null>(null);
const [nanobotFeatureAction, setNanobotFeatureAction] = useState<string | null>(null);
const [nanobotFeatureConfirm, setNanobotFeatureConfirm] = useState<NanobotFeatureInfo | null>(null);
const [mcpPresetAction, setMcpPresetAction] = useState<string | null>(null);
const [providerSaving, setProviderSaving] = useState<string | null>(null);
const [webSearchSaving, setWebSearchSaving] = useState(false);
@@ -568,6 +578,8 @@ export function SettingsView({
const [automationsSort, setAutomationsSort] = useState<AutomationSort>("next");
const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null);
const [cliAppsError, setCliAppsError] = useState<string | null>(null);
const [nanobotFeaturesMessage, setNanobotFeaturesMessage] = useState<string | null>(null);
const [nanobotFeaturesError, setNanobotFeaturesError] = useState<string | null>(null);
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
const [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("all");
const [mcpMessage, setMcpMessage] = useState<string | null>(null);
@@ -731,6 +743,29 @@ export function SettingsView({
};
}, [activeSection, token]);
useEffect(() => {
if (activeSection !== "apps") return;
let cancelled = false;
setNanobotFeaturesLoading(true);
fetchNanobotFeatures(token)
.then((payload) => {
if (!cancelled) {
setNanobotFeatures(payload);
setNanobotFeaturesError(null);
}
})
.catch((err) => {
const message = (err as Error).message;
if (!cancelled && message !== "HTTP 404") setNanobotFeaturesError(message);
})
.finally(() => {
if (!cancelled) setNanobotFeaturesLoading(false);
});
return () => {
cancelled = true;
};
}, [activeSection, token]);
useEffect(() => {
if (activeSection !== "apps") return;
let cancelled = false;
@@ -1330,6 +1365,42 @@ export function SettingsView({
}
};
const handleNanobotFeatureAction = async (
action: "enable" | "disable",
name: string,
confirmed = false,
) => {
const feature = nanobotFeatures?.features.find((item) => item.name === name);
if (action === "enable" && !confirmed && feature && !feature.installed && feature.install_supported) {
setNanobotFeaturesMessage(null);
setNanobotFeaturesError(null);
setNanobotFeatureConfirm(feature);
return;
}
const key = `${action}:${name}`;
setNanobotFeatureAction(key);
setNanobotFeatureConfirm(null);
setNanobotFeaturesMessage(null);
setNanobotFeaturesError(null);
try {
const payload = action === "enable"
? await enableNanobotFeature(token, name)
: await disableNanobotFeature(token, name);
setNanobotFeatures(payload);
setNanobotFeaturesMessage(payload.last_action?.message ?? null);
if (
payload.requires_restart ||
payload.features.some((feature) => feature.name === name && feature.requires_restart)
) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
} catch (err) {
setNanobotFeaturesError((err as Error).message);
} finally {
setNanobotFeatureAction(null);
}
};
const handleAutomationAction = async (
action: AutomationAction,
job: SessionAutomationJob,
@@ -1604,15 +1675,20 @@ export function SettingsView({
return (
<AppsCatalogSettings
cliApps={cliApps}
nanobotFeatures={nanobotFeatures}
mcpPresets={mcpPresets}
cliAppsLoading={cliAppsLoading}
nanobotFeaturesLoading={nanobotFeaturesLoading}
mcpPresetsLoading={mcpPresetsLoading}
query={appsQuery}
filter={appsKindFilter}
cliActionKey={cliAppsAction}
nanobotActionKey={nanobotFeatureAction}
mcpActionKey={mcpPresetAction}
cliMessage={cliAppsMessage}
cliError={cliAppsError}
nanobotMessage={nanobotFeaturesMessage}
nanobotError={nanobotFeaturesError}
cliFocusName={cliAppsFocusName}
mcpMessage={mcpMessage}
mcpError={mcpError}
@@ -1624,10 +1700,13 @@ export function SettingsView({
onQueryChange={setAppsQuery}
onFilterChange={setAppsKindFilter}
onCliAction={handleCliAppAction}
onNanobotAction={handleNanobotFeatureAction}
onMcpAction={handleMcpPresetAction}
onDismissStatus={() => {
setCliAppsMessage(null);
setCliAppsError(null);
setNanobotFeaturesMessage(null);
setNanobotFeaturesError(null);
setMcpMessage(null);
setMcpError(null);
}}
@@ -1733,6 +1812,15 @@ export function SettingsView({
onSave={handleCreateModelConfiguration}
/>
<NanobotFeatureInstallDialog
feature={nanobotFeatureConfirm}
installing={nanobotFeatureAction === `enable:${nanobotFeatureConfirm?.name ?? ""}`}
onOpenChange={(open) => {
if (!open) setNanobotFeatureConfirm(null);
}}
onConfirm={(feature) => handleNanobotFeatureAction("enable", feature.name, true)}
/>
<AutomationDeleteDialog
job={automationPendingDelete}
deleting={automationAction === `delete:${automationPendingDelete?.id ?? ""}`}
@@ -4340,6 +4428,64 @@ function AutomationDeleteDialog({
);
}
function NanobotFeatureInstallDialog({
feature,
installing,
onOpenChange,
onConfirm,
}: {
feature: NanobotFeatureInfo | null;
installing: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (feature: NanobotFeatureInfo) => void | Promise<void>;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
const name = feature?.display_name || feature?.name || "";
return (
<Dialog open={Boolean(feature)} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="w-[min(calc(100vw-2rem),24rem)] gap-0 rounded-[28px] border border-white/70 bg-card/95 p-5 text-center shadow-[0_24px_80px_rgba(15,23,42,0.20)] backdrop-blur-xl sm:rounded-[28px]"
>
<DialogHeader className="items-center space-y-0 text-center">
<DialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
{tx("settings.nanobotFeatures.installConfirmTitle", "Install support for {{name}}?", { name })}
</DialogTitle>
<DialogDescription className="mt-3 max-w-[20rem] text-center text-[14px] leading-6 text-muted-foreground">
{tx(
"settings.nanobotFeatures.installConfirmDescription",
"nanobot will add what {{name}} needs, then turn it on. Continue?",
{ name },
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-7 !grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2 sm:space-x-0">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={installing}
className="h-11 w-full min-w-0 rounded-full bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
>
{tx("settings.automations.cancel", "Cancel")}
</Button>
<Button
type="button"
onClick={() => feature && void onConfirm(feature)}
disabled={!feature || installing}
className="h-11 w-full min-w-0 !whitespace-normal rounded-full px-5 text-center text-[15px] font-semibold"
>
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
{tx("settings.nanobotFeatures.installConfirmAction", "Install and enable")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function isLocalTriggerAutomation(job: SessionAutomationJob | null): boolean {
if (!job) return false;
return job.kind === "local_trigger"
@@ -4906,15 +5052,20 @@ function formatAutomationInterval(ms: number, locale: string): string {
function AppsCatalogSettings({
cliApps,
nanobotFeatures,
mcpPresets,
cliAppsLoading,
nanobotFeaturesLoading,
mcpPresetsLoading,
query,
filter,
cliActionKey,
nanobotActionKey,
mcpActionKey,
cliMessage,
cliError,
nanobotMessage,
nanobotError,
cliFocusName,
mcpMessage,
mcpError,
@@ -4926,6 +5077,7 @@ function AppsCatalogSettings({
onQueryChange,
onFilterChange,
onCliAction,
onNanobotAction,
onMcpAction,
onDismissStatus,
onBackToChat,
@@ -4939,15 +5091,20 @@ function AppsCatalogSettings({
isRestarting,
}: {
cliApps: CliAppsPayload | null;
nanobotFeatures: NanobotFeaturesPayload | null;
mcpPresets: McpPresetsPayload | null;
cliAppsLoading: boolean;
nanobotFeaturesLoading: boolean;
mcpPresetsLoading: boolean;
query: string;
filter: AppsKindFilter;
cliActionKey: string | null;
nanobotActionKey: string | null;
mcpActionKey: string | null;
cliMessage: string | null;
cliError: string | null;
nanobotMessage: string | null;
nanobotError: string | null;
cliFocusName: string | null;
mcpMessage: string | null;
mcpError: string | null;
@@ -4959,6 +5116,7 @@ function AppsCatalogSettings({
onQueryChange: (value: string) => void;
onFilterChange: (value: AppsKindFilter) => void;
onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
onNanobotAction: (action: "enable" | "disable", name: string) => void;
onMcpAction: (action: "enable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
onDismissStatus: () => void;
onBackToChat: () => void;
@@ -4975,11 +5133,17 @@ function AppsCatalogSettings({
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const filterOptions = [
{ value: "all", label: tx("settings.apps.filterAll", "All") },
{ value: "nanobot", label: tx("settings.apps.filterPlugins", "Plugins") },
{ value: "cli", label: tx("settings.apps.filterCli", "App CLIs") },
{ value: "mcp", label: tx("settings.apps.filterMcp", "MCP services") },
];
const normalizedQuery = query.trim().toLowerCase();
const items: AppsCatalogItem[] = [
...(nanobotFeatures?.features ?? []).map((feature) => ({
id: `nanobot:${feature.name}`,
kind: "nanobot" as const,
feature,
})),
...(cliApps?.apps ?? []).map((app) => ({ id: `cli:${app.name}`, kind: "cli" as const, app })),
...(mcpPresets?.presets ?? []).map((preset) => ({
id: `mcp:${preset.name}`,
@@ -4996,13 +5160,22 @@ function AppsCatalogSettings({
const focusedApp = cliFocusName
? (cliApps?.apps ?? []).find((app) => app.name === cliFocusName && app.installed)
: null;
const loading = (cliAppsLoading || mcpPresetsLoading) && !cliApps && !mcpPresets;
const statusMessage = cliError || mcpError || (!focusedApp ? cliMessage || mcpMessage : null);
const statusIsError = Boolean(cliError || mcpError);
const loading =
(cliAppsLoading || nanobotFeaturesLoading || mcpPresetsLoading) &&
!cliApps &&
!nanobotFeatures &&
!mcpPresets;
const statusMessage =
cliError ||
nanobotError ||
mcpError ||
(!focusedApp ? cliMessage || nanobotMessage || mcpMessage : null);
const statusIsError = Boolean(cliError || nanobotError || mcpError);
const caption = t("settings.apps.caption", {
plugins: nanobotFeatures?.enabled_count ?? 0,
cli: cliApps?.installed_count ?? 0,
mcp: mcpPresets?.installed_count ?? 0,
defaultValue: "{{cli}} CLI · {{mcp}} MCP",
defaultValue: "{{plugins}} Plugin · {{cli}} CLI · {{mcp}} MCP",
});
return (
@@ -5012,7 +5185,7 @@ function AppsCatalogSettings({
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
{tx(
"settings.apps.description",
"Add local app adapters and connected tool servers that nanobot can use from chat.",
"Enable plugins, local app adapters, and connected tool servers.",
)}
</p>
<span className="text-[12px] font-medium text-muted-foreground">{caption}</span>
@@ -5068,7 +5241,7 @@ function AppsCatalogSettings({
{requiresRestartPending ? (
<div className="flex flex-col gap-3 rounded-[12px] border border-amber-500/20 bg-amber-500/8 px-4 py-3 text-[12.5px] text-amber-800 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between">
<span>{tx("settings.mcp.restartRequired", "Restart nanobot to connect updated MCP tools.")}</span>
<span>{tx("settings.apps.restartRequired", "Restart nanobot to apply updated apps and features.")}</span>
{onRestart ? (
<Button
type="button"
@@ -5091,7 +5264,7 @@ function AppsCatalogSettings({
<section>
<div className="flex items-center justify-between border-b border-border/45 pb-3">
<SettingsSectionTitle>{tx("settings.apps.featured", "Featured")}</SettingsSectionTitle>
<SettingsSectionTitle>{tx("settings.apps.featured", "Catalog")}</SettingsSectionTitle>
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
{items.length}
</span>
@@ -5104,7 +5277,14 @@ function AppsCatalogSettings({
) : items.length ? (
<div className="grid gap-x-10 gap-y-1 py-3 md:grid-cols-2">
{items.map((item) =>
item.kind === "cli" ? (
item.kind === "nanobot" ? (
<NanobotFeatureCatalogRow
key={item.id}
feature={item.feature}
actionKey={nanobotActionKey}
onAction={onNanobotAction}
/>
) : item.kind === "cli" ? (
<CliAppsCatalogRow
key={item.id}
app={item.app}
@@ -5133,7 +5313,7 @@ function AppsCatalogSettings({
)}
</section>
{filter !== "cli" ? (
{filter === "all" || filter === "mcp" ? (
<McpCustomServerPanel
form={customMcpForm}
configImport={mcpConfigImport}
@@ -5150,6 +5330,92 @@ function AppsCatalogSettings({
);
}
function NanobotFeatureCatalogRow({
feature,
actionKey,
onAction,
}: {
feature: NanobotFeatureInfo;
actionKey: string | null;
onAction: (action: "enable" | "disable", name: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const enableBusy = actionKey === `enable:${feature.name}`;
const disableBusy = actionKey === `disable:${feature.name}`;
const description = nanobotFeatureStatusLabel(feature, tx);
const missingSupport = feature.enabled && !feature.installed;
const installSupportLabel = tx("settings.nanobotFeatures.installSupport", "Install support");
const enabledLabel =
feature.type === "channel" && feature.name === "websocket"
? tx("settings.nanobotFeatures.websocketRequired", "Required for WebUI")
: tx("settings.nanobotFeatures.enabled", "Enabled");
const enableLabel = feature.installed
? tx("settings.nanobotFeatures.enable", "Enable")
: installSupportLabel;
return (
<article className="group flex min-w-0 items-center gap-3 rounded-[14px] px-3 py-3 transition-colors hover:bg-muted/45">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] border border-border/55 bg-card text-muted-foreground shadow-sm">
<Bot className="h-4 w-4" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
{feature.display_name}
</h3>
<AppsTypeBadge>
{feature.type === "channel"
? tx("settings.apps.channelLabel", "Channel")
: tx("settings.apps.featureLabel", "Feature")}
</AppsTypeBadge>
</div>
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{description}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{missingSupport && feature.install_supported ? (
<AppsActionButton
ariaLabel={installSupportLabel}
busy={enableBusy}
onClick={() => onAction("enable", feature.name)}
>
<Plus className="h-4 w-4" aria-hidden />
</AppsActionButton>
) : feature.enabled && feature.type === "channel" && feature.name !== "websocket" ? (
<AppsActionButton
ariaLabel={tx("settings.nanobotFeatures.disable", "Disable")}
busy={disableBusy}
tone="danger"
onClick={() => onAction("disable", feature.name)}
>
<X className="h-4 w-4" aria-hidden />
</AppsActionButton>
) : feature.enabled ? (
<AppsActionButton
ariaLabel={enabledLabel}
disabled
tone="installed"
>
<Check className="h-4 w-4" aria-hidden />
</AppsActionButton>
) : feature.install_supported ? (
<AppsActionButton
ariaLabel={enableLabel}
busy={enableBusy}
onClick={() => onAction("enable", feature.name)}
>
<Plus className="h-4 w-4" aria-hidden />
</AppsActionButton>
) : (
<AppsActionButton ariaLabel={tx("settings.cliApps.unavailable", "Unavailable")} disabled>
<Plus className="h-4 w-4" aria-hidden />
</AppsActionButton>
)}
</div>
</article>
);
}
function CliAppsCatalogRow({
app,
actionKey,
@@ -5553,14 +5819,27 @@ const AppsActionButton = forwardRef<HTMLButtonElement, {
});
function appsTitle(item: AppsCatalogItem): string {
if (item.kind === "nanobot") return item.feature.display_name;
return item.kind === "cli" ? item.app.display_name : item.preset.display_name;
}
function appsReady(item: AppsCatalogItem): boolean {
if (item.kind === "nanobot") return item.feature.enabled;
return item.kind === "cli" ? item.app.installed : item.preset.installed && item.preset.configured;
}
function appsSearchText(item: AppsCatalogItem): string {
if (item.kind === "nanobot") {
const feature = item.feature;
return [
feature.display_name,
feature.name,
feature.type,
feature.status,
]
.join(" ")
.toLowerCase();
}
if (item.kind === "cli") {
const app = item.app;
return [
@@ -5587,7 +5866,20 @@ function appsSearchText(item: AppsCatalogItem): string {
preset.source ?? "",
]
.join(" ")
.toLowerCase();
.toLowerCase();
}
function nanobotFeatureStatusLabel(
feature: NanobotFeatureInfo,
tx: (key: string, fallback: string) => string,
): string {
if (feature.ready && feature.type === "channel" && feature.name === "websocket") {
return tx("settings.nanobotFeatures.websocketRequired", "Required for WebUI");
}
if (feature.ready) return tx("settings.nanobotFeatures.ready", "Ready");
if (!feature.installed) return tx("settings.nanobotFeatures.missingDependency", "Support missing");
if (feature.type === "channel") return tx("settings.nanobotFeatures.channelDisabled", "Channel is disabled");
return tx("settings.nanobotFeatures.notEnabled", "Not enabled");
}
function McpCustomServerPanel({
+22 -4
View File
@@ -458,18 +458,36 @@
"missingCredential": "Configure this provider before enabling image generation."
},
"apps": {
"description": "Add app CLIs and MCP services nanobot can use from chat.",
"description": "Enable plugins, local app adapters, and connected tool servers.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"channelLabel": "Channel",
"featureLabel": "Feature",
"filterAll": "All",
"filterPlugins": "Plugins",
"filterCli": "CLI apps",
"filterMcp": "MCP services",
"enabledSummary": "{{count}} enabled",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"caption": "{{plugins}} Plugin · {{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Search Apps",
"featured": "Featured",
"featured": "Catalog",
"loading": "Loading Apps...",
"empty": "No apps match this filter."
"empty": "No apps match this filter.",
"restartRequired": "Restart nanobot to apply updated apps and features."
},
"nanobotFeatures": {
"enabled": "Enabled",
"enable": "Enable",
"disable": "Disable",
"ready": "Ready",
"missingDependency": "Support missing",
"installSupport": "Install support",
"installConfirmTitle": "Install support for {{name}}?",
"installConfirmDescription": "nanobot will add what {{name}} needs, then turn it on. Continue?",
"installConfirmAction": "Install and enable",
"websocketRequired": "Required for WebUI",
"channelDisabled": "Channel is disabled",
"notEnabled": "Not enabled"
},
"automations": {
"filters": {
+30 -12
View File
@@ -458,18 +458,36 @@
"thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo."
},
"apps": {
"description": "Agrega CLI de apps y servicios MCP que nanobot puede usar desde el chat.",
"description": "Activa complementos, adaptadores locales de apps y servidores de herramientas conectados.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"channelLabel": "Canal",
"featureLabel": "Función",
"filterAll": "Todo",
"filterPlugins": "Complementos",
"filterCli": "Apps CLI",
"filterMcp": "Servicios MCP",
"enabledSummary": "{{count}} activados",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"caption": "{{plugins}} complementos · {{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Buscar apps",
"featured": "Destacadas",
"featured": "Catálogo",
"loading": "Cargando apps...",
"empty": "Ninguna app coincide con este filtro."
"empty": "Ninguna app coincide con este filtro.",
"restartRequired": "Reinicia nanobot para aplicar apps y funciones actualizadas."
},
"nanobotFeatures": {
"enabled": "Activado",
"enable": "Activar",
"disable": "Desactivar",
"ready": "Listo",
"missingDependency": "Falta soporte",
"installSupport": "Instalar soporte",
"installConfirmTitle": "¿Instalar soporte para {{name}}?",
"installConfirmDescription": "nanobot añadirá lo que {{name}} necesita y luego lo activará. ¿Continuar?",
"installConfirmAction": "Instalar y activar",
"websocketRequired": "Requerido para WebUI",
"channelDisabled": "El canal está desactivado",
"notEnabled": "No activado"
},
"automations": {
"filters": {
@@ -965,11 +983,11 @@
"mcpDescription": "Usar @{{name}} como servidor MCP"
},
"workspace": {
"accessAria": "Workspace access mode",
"accessAria": "Modo de acceso al espacio de trabajo",
"projectAria": "Elegir proyecto",
"projectPlaceholder": "Seleccionar proyecto",
"default": "Default Permission",
"full": "Full Access"
"default": "Permiso predeterminado",
"full": "Acceso completo"
}
},
"scrollToBottom": "Desplazarse al final",
@@ -1053,17 +1071,17 @@
"body": "El servidor rechazó tu último mensaje por superar el tamaño permitido. Quita algunas imágenes o usa archivos más pequeños y vuelve a enviarlo."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
"title": "El espacio de trabajo no cambió",
"body": "El gateway rechazó el proyecto o modo de acceso solicitado, así que Nanobot conservó el espacio de trabajo anterior."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"defaultProject": "Espacio de trabajo predeterminado",
"manual": "Pegar ruta",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
"usePath": "Usar ruta",
"absolutePathRequired": "Introduce una ruta absoluta de carpeta en esta máquina."
}
}
}
+30 -12
View File
@@ -458,18 +458,36 @@
"thirdPartyBrands": "Les noms, logos et marques de produits appartiennent à leurs propriétaires respectifs. Leur utilisation sert uniquement à l'identification et n'implique aucune approbation."
},
"apps": {
"description": "Ajoutez des CLI dapps et services MCP utilisables par nanobot depuis le chat.",
"description": "Activez des extensions, des adaptateurs dapps locales et des serveurs doutils connectés.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"channelLabel": "Canal",
"featureLabel": "Fonction",
"filterAll": "Tout",
"filterPlugins": "Extensions",
"filterCli": "Apps CLI",
"filterMcp": "Services MCP",
"enabledSummary": "{{count}} activés",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"caption": "{{plugins}} extensions · {{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Rechercher des apps",
"featured": "À la une",
"featured": "Catalogue",
"loading": "Chargement des apps...",
"empty": "Aucune app ne correspond."
"empty": "Aucune app ne correspond.",
"restartRequired": "Redémarrez nanobot pour appliquer les apps et fonctions mises à jour."
},
"nanobotFeatures": {
"enabled": "Activé",
"enable": "Activer",
"disable": "Désactiver",
"ready": "Prêt",
"missingDependency": "Support manquant",
"installSupport": "Installer le support",
"installConfirmTitle": "Installer le support pour {{name}} ?",
"installConfirmDescription": "nanobot ajoutera ce dont {{name}} a besoin, puis l'activera. Continuer ?",
"installConfirmAction": "Installer et activer",
"websocketRequired": "Requis pour la WebUI",
"channelDisabled": "Canal désactivé",
"notEnabled": "Non activé"
},
"automations": {
"filters": {
@@ -965,11 +983,11 @@
"mcpDescription": "Utiliser @{{name}} comme serveur MCP"
},
"workspace": {
"accessAria": "Workspace access mode",
"accessAria": "Mode daccès à lespace de travail",
"projectAria": "Choisir un projet",
"projectPlaceholder": "Sélectionner un projet",
"default": "Default Permission",
"full": "Full Access"
"default": "Autorisation par défaut",
"full": "Accès complet"
}
},
"scrollToBottom": "Faire défiler vers le bas",
@@ -1053,17 +1071,17 @@
"body": "Le serveur a rejeté votre dernier message car il dépasse la taille autorisée. Retirez des images ou choisissez des fichiers plus légers, puis renvoyez-le."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
"title": "Lespace de travail na pas changé",
"body": "La passerelle a refusé le projet ou le mode daccès demandé ; Nanobot a conservé lespace de travail précédent."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"defaultProject": "Espace de travail par défaut",
"manual": "Coller un chemin",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
"usePath": "Utiliser le chemin",
"absolutePathRequired": "Saisissez un chemin absolu de dossier sur cette machine."
}
}
}
+30 -12
View File
@@ -458,18 +458,36 @@
"thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan."
},
"apps": {
"description": "Tambahkan CLI aplikasi dan layanan MCP yang dapat digunakan nanobot dari chat.",
"description": "Aktifkan plugin, adaptor aplikasi lokal, dan server alat terhubung.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"channelLabel": "Kanal",
"featureLabel": "Fitur",
"filterAll": "Semua",
"filterPlugins": "Plugin",
"filterCli": "Aplikasi CLI",
"filterMcp": "Layanan MCP",
"enabledSummary": "{{count}} aktif",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"caption": "{{plugins}} plugin · {{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Cari aplikasi",
"featured": "Unggulan",
"featured": "Katalog",
"loading": "Memuat aplikasi...",
"empty": "Tidak ada aplikasi yang cocok."
"empty": "Tidak ada aplikasi yang cocok.",
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
},
"nanobotFeatures": {
"enabled": "Aktif",
"enable": "Aktifkan",
"disable": "Nonaktifkan",
"ready": "Siap",
"missingDependency": "Dukungan belum terpasang",
"installSupport": "Instal dukungan",
"installConfirmTitle": "Instal dukungan untuk {{name}}?",
"installConfirmDescription": "nanobot akan menambahkan yang dibutuhkan {{name}}, lalu mengaktifkannya. Lanjutkan?",
"installConfirmAction": "Instal dan aktifkan",
"websocketRequired": "Wajib untuk WebUI",
"channelDisabled": "Kanal dinonaktifkan",
"notEnabled": "Belum aktif"
},
"automations": {
"filters": {
@@ -965,11 +983,11 @@
"mcpDescription": "Gunakan @{{name}} sebagai server MCP"
},
"workspace": {
"accessAria": "Workspace access mode",
"accessAria": "Mode akses workspace",
"projectAria": "Pilih proyek",
"projectPlaceholder": "Pilih proyek",
"default": "Default Permission",
"full": "Full Access"
"default": "Izin default",
"full": "Akses penuh"
}
},
"scrollToBottom": "Gulir ke bawah",
@@ -1053,17 +1071,17 @@
"body": "Server menolak pesan terakhir karena melebihi batas ukuran. Hapus beberapa gambar atau gunakan berkas yang lebih kecil, lalu coba lagi."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
"title": "Workspace tidak berubah",
"body": "Gateway menolak proyek atau mode akses yang diminta, jadi Nanobot tetap memakai workspace sebelumnya."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"defaultProject": "Workspace default",
"manual": "Tempel path",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
"usePath": "Gunakan path",
"absolutePathRequired": "Masukkan path folder absolut di mesin ini."
}
}
}
+30 -12
View File
@@ -458,18 +458,36 @@
"thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
},
"apps": {
"description": "nanobot がチャットで使用できる App CLI と MCP サービスを追加します。",
"description": "プラグイン、ローカルアプリアダプター、接続済みツールサーバーを有効にします。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"channelLabel": "チャンネル",
"featureLabel": "機能",
"filterAll": "すべて",
"filterPlugins": "プラグイン",
"filterCli": "CLI アプリ",
"filterMcp": "MCP サービス",
"enabledSummary": "{{count}} 件有効",
"caption": "CLI {{cli}} 件 · MCP {{mcp}} 件",
"caption": "{{plugins}} 件のプラグイン · CLI {{cli}} 件 · MCP {{mcp}} 件",
"searchPlaceholder": "アプリを検索",
"featured": "注目",
"featured": "カタログ",
"loading": "アプリを読み込み中...",
"empty": "一致するアプリはありません。"
"empty": "一致するアプリはありません。",
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
},
"nanobotFeatures": {
"enabled": "有効",
"enable": "有効化",
"disable": "無効化",
"ready": "準備完了",
"missingDependency": "サポート不足",
"installSupport": "サポートをインストール",
"installConfirmTitle": "{{name}} のサポートをインストールしますか?",
"installConfirmDescription": "nanobot が {{name}} に必要なものを追加し、その後有効化します。続けますか?",
"installConfirmAction": "インストールして有効化",
"websocketRequired": "WebUI に必須",
"channelDisabled": "チャンネルは無効",
"notEnabled": "未有効"
},
"automations": {
"filters": {
@@ -965,11 +983,11 @@
"mcpDescription": "@{{name}} を MCP サーバーとして使用"
},
"workspace": {
"accessAria": "Workspace access mode",
"accessAria": "ワークスペースのアクセスモード",
"projectAria": "プロジェクトを選択",
"projectPlaceholder": "プロジェクトを選択",
"default": "Default Permission",
"full": "Full Access"
"default": "既定の権限",
"full": "フルアクセス"
}
},
"scrollToBottom": "一番下へスクロール",
@@ -1053,17 +1071,17 @@
"body": "サイズ上限を超えたため、直前のメッセージはサーバーに拒否されました。画像を減らすか、より小さいファイルに差し替えて再送してください。"
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
"title": "ワークスペースは変更されませんでした",
"body": "要求されたプロジェクトまたはアクセスモードがゲートウェイで拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。"
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"defaultProject": "既定のワークスペース",
"manual": "パスを貼り付け",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
"usePath": "パスを使用",
"absolutePathRequired": "このマシン上の絶対フォルダーパスを入力してください。"
}
}
}
+30 -12
View File
@@ -458,18 +458,36 @@
"thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
},
"apps": {
"description": "nanobot이 채팅에서 사용할 수 있는 App CLI와 MCP 서비스를 추가합니다.",
"description": "플러그인, 로컬 앱 어댑터, 연결된 도구 서버를 활성화합니다.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"channelLabel": "채널",
"featureLabel": "기능",
"filterAll": "전체",
"filterPlugins": "플러그인",
"filterCli": "CLI 앱",
"filterMcp": "MCP 서비스",
"enabledSummary": "{{count}}개 활성화됨",
"caption": "CLI {{cli}}개 · MCP {{mcp}}개",
"caption": "플러그인 {{plugins}}개 · CLI {{cli}}개 · MCP {{mcp}}개",
"searchPlaceholder": "앱 검색",
"featured": "추천",
"featured": "카탈로그",
"loading": "앱을 불러오는 중...",
"empty": "일치하는 앱이 없습니다."
"empty": "일치하는 앱이 없습니다.",
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
},
"nanobotFeatures": {
"enabled": "활성화됨",
"enable": "활성화",
"disable": "비활성화",
"ready": "준비됨",
"missingDependency": "지원 패키지 없음",
"installSupport": "지원 패키지 설치",
"installConfirmTitle": "{{name}} 지원을 설치할까요?",
"installConfirmDescription": "nanobot이 {{name}}에 필요한 것을 추가한 뒤 활성화합니다. 계속할까요?",
"installConfirmAction": "설치하고 활성화",
"websocketRequired": "WebUI 필수",
"channelDisabled": "채널이 비활성화됨",
"notEnabled": "비활성화됨"
},
"automations": {
"filters": {
@@ -965,11 +983,11 @@
"mcpDescription": "@{{name}}을 MCP 서버로 사용"
},
"workspace": {
"accessAria": "Workspace access mode",
"accessAria": "작업공간 접근 모드",
"projectAria": "프로젝트 선택",
"projectPlaceholder": "프로젝트 선택",
"default": "Default Permission",
"full": "Full Access"
"default": "기본 권한",
"full": "전체 접근 권한"
}
},
"scrollToBottom": "맨 아래로 스크롤",
@@ -1053,17 +1071,17 @@
"body": "마지막 메시지가 서버의 크기 제한을 초과하여 거부되었습니다. 이미지를 줄이거나 더 작은 파일로 바꿔서 다시 보내 주세요."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
"title": "작업공간이 변경되지 않았습니다",
"body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"defaultProject": "기본 작업공간",
"manual": "경로 붙여넣기",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
"usePath": "경로 사용",
"absolutePathRequired": "이 머신의 절대 폴더 경로를 입력하세요."
}
}
}
+30 -12
View File
@@ -458,18 +458,36 @@
"thirdPartyBrands": "Tên sản phẩm, logo và thương hiệu thuộc về chủ sở hữu tương ứng. Việc sử dụng chỉ nhằm nhận diện và không ngụ ý được xác nhận."
},
"apps": {
"description": "Thêm CLI ứng dụng và dịch vụ MCP mà nanobot có thể dùng trong chat.",
"description": "Bật plugin, bộ chuyển đổi ứng dụng cục bộ và máy chủ công cụ đã kết nối.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"channelLabel": "Kênh",
"featureLabel": "Tính năng",
"filterAll": "Tất cả",
"filterPlugins": "Plugin",
"filterCli": "Ứng dụng CLI",
"filterMcp": "Dịch vụ MCP",
"enabledSummary": "{{count}} đã bật",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"caption": "{{plugins}} plugin · {{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Tìm ứng dụng",
"featured": "Nổi bật",
"featured": "Danh mục",
"loading": "Đang tải ứng dụng...",
"empty": "Không có ứng dụng phù hợp."
"empty": "Không có ứng dụng phù hợp.",
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
},
"nanobotFeatures": {
"enabled": "Đã bật",
"enable": "Bật",
"disable": "Tắt",
"ready": "Sẵn sàng",
"missingDependency": "Thiếu gói hỗ trợ",
"installSupport": "Cài gói hỗ trợ",
"installConfirmTitle": "Cài hỗ trợ cho {{name}}?",
"installConfirmDescription": "nanobot sẽ thêm những gì {{name}} cần, rồi bật tính năng này. Tiếp tục?",
"installConfirmAction": "Cài và bật",
"websocketRequired": "Bắt buộc cho WebUI",
"channelDisabled": "Kênh đang tắt",
"notEnabled": "Chưa bật"
},
"automations": {
"filters": {
@@ -965,11 +983,11 @@
"mcpDescription": "Dùng @{{name}} như máy chủ MCP"
},
"workspace": {
"accessAria": "Workspace access mode",
"accessAria": "Chế độ truy cập workspace",
"projectAria": "Chọn dự án",
"projectPlaceholder": "Chọn dự án",
"default": "Default Permission",
"full": "Full Access"
"default": "Quyền mặc định",
"full": "Toàn quyền truy cập"
}
},
"scrollToBottom": "Cuộn xuống cuối",
@@ -1053,17 +1071,17 @@
"body": "Máy chủ đã từ chối tin nhắn trước vì vượt quá giới hạn kích thước. Hãy bớt ảnh hoặc chọn tệp nhỏ hơn rồi thử lại."
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
"title": "Workspace không thay đổi",
"body": "Gateway đã từ chối dự án hoặc chế độ truy cập được yêu cầu, nên Nanobot giữ workspace trước đó."
}
},
"workspace": {
"dialog": {
"defaultProject": "Default workspace",
"defaultProject": "Workspace mặc định",
"manual": "Dán đường dẫn",
"manualPlaceholder": "/Users/name/project",
"usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine."
"usePath": "Dùng đường dẫn",
"absolutePathRequired": "Nhập đường dẫn thư mục tuyệt đối trên máy này."
}
}
}
+22 -4
View File
@@ -458,18 +458,36 @@
"missingCredential": "启用图片生成前请先配置此提供商。"
},
"apps": {
"description": "添加 nanobot 可在聊天中使用的 App CLI 和 MCP 服务。",
"description": "启用插件、本地应用适配器和已连接的工具服务。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"channelLabel": "渠道",
"featureLabel": "能力",
"filterAll": "全部",
"filterPlugins": "插件",
"filterCli": "CLI 应用",
"filterMcp": "MCP 服务",
"enabledSummary": "已启用 {{count}} 个",
"caption": "{{cli}} 个 CLI · {{mcp}} 个 MCP",
"caption": "{{plugins}} 个插件 · {{cli}} 个 CLI · {{mcp}} 个 MCP",
"searchPlaceholder": "搜索应用",
"featured": "精选",
"featured": "应用目录",
"loading": "正在加载应用...",
"empty": "没有匹配的应用。"
"empty": "没有匹配的应用。",
"restartRequired": "重启 nanobot 以应用更新后的应用和能力。"
},
"nanobotFeatures": {
"enabled": "已启用",
"enable": "启用",
"disable": "禁用",
"ready": "就绪",
"missingDependency": "缺少支持包",
"installSupport": "安装支持包",
"installConfirmTitle": "安装 {{name}} 支持?",
"installConfirmDescription": "将安装并启用 {{name}} 支持。是否继续?",
"installConfirmAction": "安装并启用",
"websocketRequired": "WebUI 必需",
"channelDisabled": "渠道已禁用",
"notEnabled": "未启用"
},
"automations": {
"filters": {
+22 -4
View File
@@ -458,18 +458,36 @@
"thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。"
},
"apps": {
"description": "新增 nanobot 可在聊天中使用的 App CLI 和 MCP 服務。",
"description": "啟用插件、本機應用適配器和已連接的工具服務。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"channelLabel": "通道",
"featureLabel": "能力",
"filterAll": "全部",
"filterPlugins": "插件",
"filterCli": "CLI 應用",
"filterMcp": "MCP 服務",
"enabledSummary": "已啟用 {{count}} 個",
"caption": "{{cli}} 個 CLI · {{mcp}} 個 MCP",
"caption": "{{plugins}} 個插件 · {{cli}} 個 CLI · {{mcp}} 個 MCP",
"searchPlaceholder": "搜尋應用",
"featured": "精選",
"featured": "應用目錄",
"loading": "正在載入應用...",
"empty": "沒有符合的應用。"
"empty": "沒有符合的應用。",
"restartRequired": "重新啟動 nanobot 以套用更新後的應用和能力。"
},
"nanobotFeatures": {
"enabled": "已啟用",
"enable": "啟用",
"disable": "停用",
"ready": "就緒",
"missingDependency": "缺少支援套件",
"installSupport": "安裝支援套件",
"installConfirmTitle": "安裝 {{name}} 支援?",
"installConfirmDescription": "將安裝並啟用 {{name}} 支援。是否繼續?",
"installConfirmAction": "安裝並啟用",
"websocketRequired": "WebUI 必需",
"channelDisabled": "通道已停用",
"notEnabled": "未啟用"
},
"automations": {
"filters": {
+39
View File
@@ -6,6 +6,7 @@ import type {
FilePreviewPayload,
ImageGenerationSettingsUpdate,
McpPresetsPayload,
NanobotFeaturesPayload,
ModelConfigurationCreate,
ModelConfigurationUpdate,
NetworkSafetySettingsUpdate,
@@ -358,6 +359,44 @@ export async function fetchInstalledCliApps(
);
}
export async function fetchNanobotFeatures(
token: string,
base: string = "",
): Promise<NanobotFeaturesPayload> {
return request<NanobotFeaturesPayload>(
`${base}/api/settings/nanobot-features`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function enableNanobotFeature(
token: string,
name: string,
base: string = "",
): Promise<NanobotFeaturesPayload> {
const query = new URLSearchParams();
query.set("name", name);
return request<NanobotFeaturesPayload>(
`${base}/api/settings/nanobot-features/enable?${query}`,
token,
);
}
export async function disableNanobotFeature(
token: string,
name: string,
base: string = "",
): Promise<NanobotFeaturesPayload> {
const query = new URLSearchParams();
query.set("name", name);
return request<NanobotFeaturesPayload>(
`${base}/api/settings/nanobot-features/disable?${query}`,
token,
);
}
export async function runCliAppAction(
token: string,
action: "install" | "update" | "uninstall" | "test",
+23
View File
@@ -624,6 +624,29 @@ export interface CliAppsPayload {
};
}
export interface NanobotFeatureInfo {
name: string;
display_name: string;
type: "channel" | "feature" | string;
enabled: boolean;
installed: boolean;
ready: boolean;
status: "enabled" | "missing_dependency" | "not_enabled" | string;
install_supported: boolean;
requires_restart: boolean;
}
export interface NanobotFeaturesPayload {
features: NanobotFeatureInfo[];
enabled_count: number;
requires_restart?: boolean;
last_action?: {
ok: boolean;
message: string;
enabled?: boolean;
};
}
export interface McpPresetField {
name: string;
label: string;
+37
View File
@@ -8,6 +8,7 @@ import {
fetchCliApps,
fetchInstalledCliApps,
fetchMcpPresets,
fetchNanobotFeatures,
fetchProviderModels,
fetchSessionAutomations,
fetchSettingsUsage,
@@ -21,6 +22,8 @@ import {
listSlashCommands,
loginProviderOAuth,
logoutProviderOAuth,
disableNanobotFeature,
enableNanobotFeature,
runAutomationAction,
runCliAppAction,
runMcpPresetAction,
@@ -441,6 +444,40 @@ describe("webui API helpers", () => {
);
});
it("reads and toggles nanobot optional features", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
features: [],
enabled_count: 0,
}),
} as Response);
await expect(fetchNanobotFeatures("tok")).resolves.toMatchObject({ features: [] });
expect(fetch).toHaveBeenCalledWith(
"/api/settings/nanobot-features",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await enableNanobotFeature("tok", "matrix");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/nanobot-features/enable?name=matrix",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await disableNanobotFeature("tok", "matrix");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/nanobot-features/disable?name=matrix",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("reads MCP presets and serializes actions", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
+4
View File
@@ -1527,6 +1527,10 @@ describe("App layout", () => {
}),
);
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ brandLogos: true }),
);
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
+23 -1
View File
@@ -64,6 +64,18 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.sections.webuiSafety",
"settings.sections.capabilities",
"settings.sections.apps",
"settings.apps.description",
"settings.apps.filterPlugins",
"settings.apps.caption",
"settings.apps.restartRequired",
"settings.nanobotFeatures.disable",
"settings.nanobotFeatures.ready",
"settings.nanobotFeatures.missingDependency",
"settings.nanobotFeatures.installConfirmTitle",
"settings.nanobotFeatures.installConfirmDescription",
"settings.nanobotFeatures.installConfirmAction",
"settings.nanobotFeatures.channelDisabled",
"settings.nanobotFeatures.notEnabled",
"settings.sections.about",
"settings.rows.theme",
"settings.rows.language",
@@ -105,6 +117,16 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.about.upToDate",
"settings.about.updateAvailable",
];
const LOCALIZED_WORKSPACE_COPY_KEYS = [
"thread.composer.workspace.accessAria",
"thread.composer.workspace.default",
"thread.composer.workspace.full",
"errors.workspaceScopeRejected.title",
"errors.workspaceScopeRejected.body",
"workspace.dialog.defaultProject",
"workspace.dialog.usePath",
"workspace.dialog.absolutePathRequired",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
@@ -271,7 +293,7 @@ describe("webui i18n", () => {
for (const [locale, resource] of Object.entries(resources)) {
if (locale === "en") continue;
const current = flattenResource(resource.common);
const leaked = LOCALIZED_SETTINGS_COPY_KEYS.filter(
const leaked = [...LOCALIZED_SETTINGS_COPY_KEYS, ...LOCALIZED_WORKSPACE_COPY_KEYS].filter(
(key) => current.get(key) === english.get(key),
);
+198
View File
@@ -263,6 +263,204 @@ describe("SettingsView Apps catalog", () => {
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
});
it("shows nanobot optional features and enables one", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [{
name: "matrix",
display_name: "Matrix",
type: "channel",
enabled: false,
installed: false,
ready: false,
status: "missing_dependency",
install_supported: true,
requires_restart: true,
}],
enabled_count: 0,
});
}
if (url === "/api/settings/nanobot-features/enable?name=matrix") {
return jsonResponse({
features: [{
name: "matrix",
display_name: "Matrix",
type: "channel",
enabled: true,
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: true,
}],
enabled_count: 1,
last_action: { ok: true, message: "Enabled channel 'matrix'", enabled: true },
});
}
if (url === "/api/settings/nanobot-features/disable?name=matrix") {
return jsonResponse({
features: [{
name: "matrix",
display_name: "Matrix",
type: "channel",
enabled: false,
installed: true,
ready: false,
status: "not_enabled",
install_supported: true,
requires_restart: true,
}],
enabled_count: 0,
requires_restart: true,
last_action: { ok: true, message: "Disabled channel 'matrix'", enabled: false },
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView();
expect(await screen.findByText("Matrix")).toBeInTheDocument();
expect(screen.queryByText(/Enabling Nanobot features may install Python packages/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install support" }));
expect(screen.getByRole("dialog", { name: "Install support for Matrix?" })).toBeInTheDocument();
expect(screen.getByText("nanobot will add what Matrix needs, then turn it on. Continue?")).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalledWith(
"/api/settings/nanobot-features/enable?name=matrix",
expect.anything(),
);
fireEvent.click(screen.getByRole("button", { name: "Install and enable" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/nanobot-features/enable?name=matrix",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
expect(await screen.findByText("Enabled channel 'matrix'")).toBeInTheDocument();
expect(screen.getByText("Restart nanobot to apply updated apps and features.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Disable" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/nanobot-features/disable?name=matrix",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
expect(await screen.findByText("Disabled channel 'matrix'")).toBeInTheDocument();
});
it("shows enabled nanobot channels with missing support as enabled", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [{
name: "matrix",
display_name: "Matrix",
type: "channel",
enabled: true,
installed: false,
ready: false,
status: "missing_dependency",
install_supported: true,
requires_restart: true,
}],
enabled_count: 1,
});
}
if (url === "/api/settings/nanobot-features/enable?name=matrix") {
return jsonResponse({
features: [{
name: "matrix",
display_name: "Matrix",
type: "channel",
enabled: true,
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: true,
}],
enabled_count: 1,
last_action: { ok: true, message: "Enabled channel 'matrix'", enabled: true },
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView();
expect(await screen.findByText("Matrix")).toBeInTheDocument();
expect(screen.getByText("1 Plugin · 0 CLI · 0 MCP")).toBeInTheDocument();
expect(screen.getByText("Support missing")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install support" }));
fireEvent.click(screen.getByRole("button", { name: "Install and enable" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/nanobot-features/enable?name=matrix",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
});
it("does not offer to disable the websocket channel", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/nanobot-features") {
return jsonResponse({
features: [{
name: "websocket",
display_name: "Websocket",
type: "channel",
enabled: true,
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: true,
}],
enabled_count: 1,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView();
expect(await screen.findByText("Websocket")).toBeInTheDocument();
expect(screen.getByText("Required for WebUI")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Disable" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Required for WebUI" })).toBeDisabled();
expect(fetchMock).not.toHaveBeenCalledWith(
"/api/settings/nanobot-features/disable?name=websocket",
expect.anything(),
);
});
it("publishes the latest settings payload to the shell", async () => {
const payload = settingsPayload();
const onSettingsChange = vi.fn();