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:
@@ -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,
|
||||
|
||||
@@ -1527,6 +1527,10 @@ describe("App layout", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
localStorage.setItem(
|
||||
"nanobot-webui.settings-preferences",
|
||||
JSON.stringify({ brandLogos: true }),
|
||||
);
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user