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:
@@ -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({
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 d’apps et services MCP utilisables par nanobot depuis le chat.",
|
||||
"description": "Activez des extensions, des adaptateurs d’apps locales et des serveurs d’outils 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 d’accès à l’espace 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": "L’espace de travail n’a pas changé",
|
||||
"body": "La passerelle a refusé le projet ou le mode d’accès demandé ; Nanobot a conservé l’espace 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "このマシン上の絶対フォルダーパスを入力してください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "이 머신의 절대 폴더 경로를 입력하세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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