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({