fix(webui): move mutations to authenticated websocket requests

This commit is contained in:
chengyongru
2026-08-10 16:23:47 +08:00
committed by chengyongru
parent 71a99b0780
commit 5d733b1c7c
25 changed files with 2458 additions and 2056 deletions
+2 -2
View File
@@ -2081,7 +2081,7 @@ function Shell({
setPairingBusyCode(code);
setPairingError(null);
try {
const payload = await runPairingAction(getToken(), action, code);
const payload = await runPairingAction(client, action, code);
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
setSnoozedPairingCodes((current) => {
if (!current.has(code)) return current;
@@ -2096,7 +2096,7 @@ function Shell({
setPairingBusyCode(null);
}
},
[getToken, refreshPairingRequests],
[client, refreshPairingRequests],
);
const onDismissPairingRequest = useCallback((code: string) => {
+30 -31
View File
@@ -724,7 +724,7 @@ export function SettingsView({
hostChromeInset = false,
}: SettingsViewProps) {
const { t } = useTranslation();
const { getToken, token } = useClient();
const { client, getToken, token } = useClient();
const pageVisible = usePageVisibility();
const remoteBrowserAccess =
typeof window !== "undefined" && !isLoopbackHost(window.location.hostname);
@@ -872,7 +872,7 @@ export function SettingsView({
const poll = async () => {
try {
const payload = await completeProviderOAuth(
getToken(),
client,
providerOAuthFlow.provider,
providerOAuthFlow.flow_id,
);
@@ -902,7 +902,7 @@ export function SettingsView({
cancelled = true;
if (timer !== null) window.clearTimeout(timer);
};
}, [applyPayload, closeProviderOAuthFlow, getToken, providerOAuthFlow]);
}, [applyPayload, client, closeProviderOAuthFlow, providerOAuthFlow]);
useEffect(() => {
if (!initialSettings || settings !== null) return;
@@ -1301,7 +1301,7 @@ export function SettingsView({
}
setModelConfigurationSaving(true);
try {
const payload = await createModelConfiguration(token, {
const payload = await createModelConfiguration(client, {
label,
provider,
model,
@@ -1319,7 +1319,7 @@ export function SettingsView({
let finalPayload = payload;
if (nextOrder) {
const orderedPayload = await updateModelCallOrder(token, nextOrder);
const orderedPayload = await updateModelCallOrder(client, nextOrder);
applyPayload(orderedPayload);
finalPayload = orderedPayload;
}
@@ -1345,7 +1345,7 @@ export function SettingsView({
const reasoningEffort = form.reasoningEffort || null;
setSaving(true);
try {
const payload = await updateModelConfiguration(token, {
const payload = await updateModelConfiguration(client, {
name: selectedPreset.name,
label:
form.presetLabel.trim() !== selectedPreset.label
@@ -1431,7 +1431,7 @@ export function SettingsView({
setModelCallOrder(nextOrder);
setModelCallOrderSaving(true);
try {
const payload = await updateModelCallOrder(token, nextOrder);
const payload = await updateModelCallOrder(client, nextOrder);
applyPayload(payload, { preserveAgentForm: true });
onModelNameChange(payload.agent.model || null);
setError(null);
@@ -1447,7 +1447,7 @@ export function SettingsView({
if (modelMigrationSaving) return;
setModelMigrationSaving(true);
try {
const payload = await migrateModelConfigurations(token);
const payload = await migrateModelConfigurations(client);
applyPayload(payload);
onModelNameChange(payload.agent.model || null);
setError(null);
@@ -1469,7 +1469,7 @@ export function SettingsView({
}
setSaving(true);
try {
const payload = await deleteModelConfiguration(token, modelPresetPendingDelete.name);
const payload = await deleteModelConfiguration(client, modelPresetPendingDelete.name);
applyPayload(payload);
setModelPresetPendingDelete(null);
setError(null);
@@ -1484,7 +1484,7 @@ export function SettingsView({
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
setImageGenerationSaving(true);
try {
const payload = await updateImageGenerationSettings(token, imageGenerationForm);
const payload = await updateImageGenerationSettings(client, imageGenerationForm);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, image: true }));
@@ -1502,7 +1502,7 @@ export function SettingsView({
if (!settings || !transcriptionDirty || transcriptionSaving) return;
setTranscriptionSaving(true);
try {
const payload = await updateTranscriptionSettings(token, transcriptionForm);
const payload = await updateTranscriptionSettings(client, transcriptionForm);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
@@ -1520,7 +1520,7 @@ export function SettingsView({
if (!settings || !networkSafetyDirty || networkSafetySaving) return;
setNetworkSafetySaving(true);
try {
const payload = await updateNetworkSafetySettings(token, networkSafetyForm);
const payload = await updateNetworkSafetySettings(client, networkSafetyForm);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
@@ -1544,7 +1544,7 @@ export function SettingsView({
try {
let latest = nanobotFeatures;
for (const name of missing) {
latest = await enableNanobotFeature(token, name);
latest = await enableNanobotFeature(client, name);
if (latest.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
@@ -1568,8 +1568,8 @@ export function SettingsView({
setApiServiceError(null);
try {
const payload = action === "start"
? await startApiService(token, values!)
: await stopApiService(token);
? await startApiService(client, values!)
: await stopApiService(client);
setApiService(payload);
const refreshed = await fetchNanobotFeatures(token);
setNanobotFeatures(refreshed);
@@ -1622,7 +1622,7 @@ export function SettingsView({
if (field === "region") update.region = providerForm.region.trim();
if (field === "profile") update.profile = providerForm.profile.trim();
}
const payload = await updateProviderSettings(token, update);
const payload = await updateProviderSettings(client, update);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, image: true }));
@@ -1656,7 +1656,7 @@ export function SettingsView({
if (providerSaving) return false;
setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY);
try {
const payload = await createProviderSettings(token, {
const payload = await createProviderSettings(client, {
name: draft.name.trim(),
apiKey: draft.apiKey.trim() || undefined,
apiBase: draft.apiBase.trim(),
@@ -1698,12 +1698,11 @@ export function SettingsView({
const payload =
action === "login"
? await loginProviderOAuth(
token,
client,
providerName,
"",
providerName === "openai_codex" && remoteBrowserAccess,
)
: await logoutProviderOAuth(token, providerName);
: await logoutProviderOAuth(client, providerName);
if (isProviderOAuthAuthorizationRequired(payload)) {
try {
if (popup && !popup.closed) popup.location.href = payload.authorization_url;
@@ -1739,7 +1738,7 @@ export function SettingsView({
setProviderOAuthDialogError(null);
try {
const payload = await completeProviderOAuth(
token,
client,
flow.provider,
flow.flow_id,
authorizationResponse,
@@ -1798,7 +1797,7 @@ export function SettingsView({
update.apiKey = apiKey;
}
if (provider.credential === "base_url") update.baseUrl = baseUrl;
const payload = await updateWebSearchSettings(token, update);
const payload = await updateWebSearchSettings(client, update);
applyPayload(payload);
if (payload.requires_restart || webFetchRestartRequired) {
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
@@ -1903,7 +1902,7 @@ export function SettingsView({
setCliAppsMessage(null);
setCliAppsError(null);
try {
const payload = await runCliAppAction(token, action, name);
const payload = await runCliAppAction(client, action, name);
setCliApps(payload);
if (action !== "test") {
notifyCliAppsChanged(payload);
@@ -1934,8 +1933,8 @@ export function SettingsView({
setNanobotFeaturesError(null);
try {
const payload = action === "enable"
? await enableNanobotFeature(token, name)
: await disableNanobotFeature(token, name);
? await enableNanobotFeature(client, name)
: await disableNanobotFeature(client, name);
setNanobotFeatures(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
@@ -1955,7 +1954,7 @@ export function SettingsView({
setAutomationAction(key);
setAutomationsError(null);
try {
const payload = await runAutomationAction(token, action, job.id);
const payload = await runAutomationAction(client, action, job.id);
setAutomations(payload);
if (action === "delete") setAutomationPendingDelete(null);
if (action === "run") {
@@ -1977,7 +1976,7 @@ export function SettingsView({
setAutomationAction(key);
setAutomationsError(null);
try {
const payload = await updateAutomation(token, job.id, values);
const payload = await updateAutomation(client, job.id, values);
setAutomations(payload);
setAutomationPendingEdit(null);
} catch (err) {
@@ -1997,7 +1996,7 @@ export function SettingsView({
setMcpMessage(null);
setMcpError(null);
try {
const payload = await runMcpPresetAction(token, action, name, values);
const payload = await runMcpPresetAction(client, action, name, values);
setMcpPresets(payload);
setMcpMessage(payload.last_action?.message ?? null);
if (action !== "test") {
@@ -2024,7 +2023,7 @@ export function SettingsView({
setMcpMessage(null);
setMcpError(null);
try {
const payload = await saveCustomMcpServer(token, {
const payload = await saveCustomMcpServer(client, {
name,
transport: customMcpForm.transport,
command: customMcpForm.command,
@@ -2054,7 +2053,7 @@ export function SettingsView({
setMcpMessage(null);
setMcpError(null);
try {
const payload = await importMcpConfig(token, mcpConfigImport);
const payload = await importMcpConfig(client, mcpConfigImport);
setMcpPresets(payload);
setMcpMessage(payload.last_action?.message ?? null);
notifyMcpPresetsChanged(payload);
@@ -2075,7 +2074,7 @@ export function SettingsView({
setMcpMessage(null);
setMcpError(null);
try {
const payload = await updateMcpServerTools(token, name, enabledTools);
const payload = await updateMcpServerTools(client, name, enabledTools);
setMcpPresets(payload);
setMcpMessage(payload.last_action?.message ?? null);
notifyMcpPresetsChanged(payload);
@@ -269,7 +269,7 @@ function SkillDetailSheet({
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { getToken } = useClient();
const { client, getToken } = useClient();
const { t } = useTranslation();
const [detail, setDetail] = useState<SkillDetail | null>(null);
const [loading, setLoading] = useState(false);
@@ -321,7 +321,7 @@ function SkillDetailSheet({
setActionBusy(true);
setActionError("");
try {
const payload = await updateSkillEnabled(getToken(), activeSkill.name, !enabled);
const payload = await updateSkillEnabled(client, activeSkill.name, !enabled);
notifySkillsChanged(payload);
const updated = payload.skills.find((item) => item.name === activeSkill.name);
if (updated) {
@@ -345,7 +345,7 @@ function SkillDetailSheet({
setActionBusy(true);
setActionError("");
try {
const payload = await deleteSkill(getToken(), activeSkill.name);
const payload = await deleteSkill(client, activeSkill.name);
notifySkillsChanged(payload);
onOpenChange(false);
} catch (reason) {
@@ -46,7 +46,7 @@ export function SkillsMarketplace({
installing: string;
onInstallingChange: (skillId: string) => void;
}) {
const { getToken } = useClient();
const { client, getToken } = useClient();
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
@@ -161,7 +161,7 @@ export function SkillsMarketplace({
setError("");
try {
const payload = await installMarketplaceSkill(
getToken(),
client,
skill.provider,
skill.source,
skill.skill_id,
@@ -38,6 +38,7 @@ import type {
NanobotFeaturesPayload,
} from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
export type ChannelInstancesPanelCustomization = {
countLabel?: (runningCount: number) => string;
@@ -50,7 +51,6 @@ export type ChannelInstancesPanelCustomization = {
};
export function ChannelInstancesPanel({
token,
feature,
showBrandLogos,
chatAppsDocsUrl,
@@ -58,7 +58,6 @@ export function ChannelInstancesPanel({
onFeaturesUpdate,
customization = {},
}: {
token: string;
feature: NanobotFeatureInfo;
showBrandLogos: boolean;
chatAppsDocsUrl?: string;
@@ -66,6 +65,7 @@ export function ChannelInstancesPanel({
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
customization?: ChannelInstancesPanelCustomization;
}) {
const { client } = useClient();
const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const displayName = localizedChannelDisplayName(feature, t);
@@ -111,8 +111,8 @@ export function ChannelInstancesPanel({
setNotice(null);
try {
const payload = checked
? await enableNanobotFeature(token, feature.name, { instanceId: instance.id })
: await disableNanobotFeature(token, feature.name, { instanceId: instance.id });
? await enableNanobotFeature(client, feature.name, { instanceId: instance.id })
: await disableNanobotFeature(client, feature.name, { instanceId: instance.id });
onFeaturesUpdate(payload);
} catch (err) {
setNotice((err as Error).message);
@@ -127,7 +127,7 @@ export function ChannelInstancesPanel({
setNotice(null);
try {
const payload = await configureChannel(
token,
client,
feature.name,
channelValuesForSave(instanceFields, fieldValues),
{ enable: selected.enabled, instanceId: selected.id },
@@ -14,6 +14,7 @@ import type {
ChannelConnectPayload,
NanobotFeaturesPayload,
} from "@/lib/types";
import { useClient } from "@/providers/ClientProvider";
export type ChannelQrConnectLabels = {
qrAlt: string;
@@ -43,7 +44,6 @@ export type ChannelQrConnectPendingContext = {
};
export function ChannelQrConnectFlow({
token,
channelName,
startOptions = {},
idleLabel,
@@ -69,6 +69,7 @@ export function ChannelQrConnectFlow({
resolveMessage?: (payload: ChannelConnectPayload) => string | undefined;
suppressSucceeded?: boolean;
}) {
const { client } = useClient();
const pageVisible = usePageVisibility();
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
@@ -78,8 +79,6 @@ export function ChannelQrConnectFlow({
const [error, setError] = useState<string | null>(null);
const [handledRequestId, setHandledRequestId] = useState(0);
const pollInFlight = useRef(false);
const tokenRef = useRef(token);
tokenRef.current = token;
const startDomain = startOptions.domain;
const startInstanceId = startOptions.instanceId;
const startMode = startOptions.mode;
@@ -129,7 +128,7 @@ export function ChannelQrConnectFlow({
pollInFlight.current = true;
try {
const payload = await pollChannelConnect(
tokenRef.current,
client,
channelName,
sessionId,
);
@@ -163,6 +162,7 @@ export function ChannelQrConnectFlow({
};
}, [
channelName,
client,
connect?.interval_ms,
connect?.session_id,
connect?.status,
@@ -175,7 +175,7 @@ export function ChannelQrConnectFlow({
setBusy(true);
setError(null);
try {
const payload = await startChannelConnect(tokenRef.current, channelName, {
const payload = await startChannelConnect(client, channelName, {
domain: startDomain,
instanceId: startInstanceId,
mode: startMode,
@@ -187,7 +187,7 @@ export function ChannelQrConnectFlow({
} finally {
setBusy(false);
}
}, [channelName, startDomain, startForce, startInstanceId, startMode]);
}, [channelName, client, startDomain, startForce, startInstanceId, startMode]);
useEffect(() => {
if (!connectRequestId || connectRequestId === handledRequestId) return;
@@ -203,7 +203,7 @@ export function ChannelQrConnectFlow({
setBusy(true);
try {
const payload = await cancelChannelConnect(
tokenRef.current,
client,
channelName,
connect.session_id,
);
@@ -223,10 +223,9 @@ export function ChannelQrConnectFlow({
setError(null);
try {
const payload = await pollChannelConnect(
tokenRef.current,
client,
channelName,
connect.session_id,
"",
params,
);
setConnect((current) => ({
@@ -54,6 +54,7 @@ import type {
NanobotFeaturesPayload,
} from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
export function ChannelCatalogRow({
feature,
@@ -148,7 +149,6 @@ export function ChannelSetupPanel({
if (feature.instances !== undefined) {
return (
<ChannelInstancesPanel
token={token}
feature={feature}
showBrandLogos={showBrandLogos}
chatAppsDocsUrl={chatAppsDocsUrl}
@@ -269,6 +269,7 @@ function ChannelSetupSurface({
ConnectFlow?: ComponentType<ChannelPluginConnectFlowProps>;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { client } = useClient();
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [notice, setNotice] = useState<string | null>(null);
@@ -345,7 +346,7 @@ function ChannelSetupSurface({
setNotice(null);
const values = channelValuesForSubmit(fields, fieldValues, touchedFields);
try {
const validationPayload = await validateChannel(token, feature.name, values);
const validationPayload = await validateChannel(client, feature.name, values);
setValidation(validationPayload);
if (!validationPayload.can_enable) {
setNotice(
@@ -355,7 +356,7 @@ function ChannelSetupSurface({
return;
}
const payload = await configureChannel(
token,
client,
feature.name,
values,
{ enable: true },
@@ -377,7 +378,7 @@ function ChannelSetupSurface({
setNotice(null);
try {
const payload = await validateChannel(
token,
client,
feature.name,
channelValuesForSubmit(fields, fieldValues, touchedFields),
);
+2 -2
View File
@@ -257,13 +257,13 @@ export function useSessions(): {
const deleteChat = useCallback(
async (key: string, options?: { deleteAutomations?: boolean }) => {
const result = await apiDeleteSession(tokenRef.current, key, options);
const result = await apiDeleteSession(client, key, options);
if (!result.deleted) return result;
optimisticKeysRef.current.delete(key);
setSessions((prev) => prev.filter((s) => s.key !== key));
return result;
},
[],
[client],
);
const getSessionAutomations = useCallback(async (key: string) => {
+22 -2
View File
@@ -144,6 +144,8 @@ export function useSidebarState(
const { client, token } = useClient();
const tokenRef = useRef(token);
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
const connectionOpenRef = useRef(client.status === "open");
const pendingPersistenceRef = useRef<SidebarStatePayload | null>(null);
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
const [loading, setLoading] = useState(true);
tokenRef.current = token;
@@ -171,14 +173,32 @@ export function useSidebarState(
};
}, []);
const persist = useCallback((next: SidebarStatePayload) => {
if (!connectionOpenRef.current) {
pendingPersistenceRef.current = next;
return;
}
void client.setSidebarState(next).catch(() => {
// Sidebar persistence is best-effort; the optimistic local state remains usable.
});
}, [client]);
useEffect(() => client.onStatus((status) => {
connectionOpenRef.current = status === "open";
if (status !== "open" || pendingPersistenceRef.current === null) return;
const pending = pendingPersistenceRef.current;
pendingPersistenceRef.current = null;
persist(pending);
}), [client, persist]);
const update = useCallback(
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
const next = normalizeSidebarState(updater(stateRef.current));
stateRef.current = next;
setState(next);
client.setSidebarState(next);
persist(next);
},
[client],
[persist],
);
const pruned = useMemo(() => {
+264 -360
View File
@@ -44,6 +44,8 @@ import type {
import { fetchWithTimeout } from "./http";
const API_READ_TIMEOUT_MS = 20_000;
const API_MUTATION_TIMEOUT_MS = 20_000;
const PACKAGE_MUTATION_TIMEOUT_MS = 150_000;
const SLASH_COMMAND_LIFECYCLES = new Set<SlashCommandLifecycle>([
"side_channel",
"finalize_active_turn",
@@ -58,12 +60,6 @@ function isSlashCommandLifecycle(value: unknown): value is SlashCommandLifecycle
&& SLASH_COMMAND_LIFECYCLES.has(value as SlashCommandLifecycle)
);
}
const CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values";
const API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values";
const OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code";
const OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback";
const PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values";
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
@@ -73,6 +69,14 @@ export class ApiError extends Error {
}
}
export interface WebUIMutationTransport {
requestMutation<T>(
action: string,
payload?: Record<string, unknown>,
timeoutMs?: number,
): Promise<T>;
}
async function request<T>(
url: string,
token: string,
@@ -109,7 +113,27 @@ async function request<T>(
return (await res.json()) as T;
}
function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefined {
async function mutation<T>(
transport: WebUIMutationTransport,
action: string,
payload: Record<string, unknown> = {},
timeoutMs: number = API_MUTATION_TIMEOUT_MS,
): Promise<T> {
try {
return await transport.requestMutation<T>(action, payload, timeoutMs);
} catch (reason) {
const status = (
typeof reason === "object"
&& reason !== null
&& "status" in reason
&& typeof reason.status === "number"
) ? reason.status : 500;
const message = reason instanceof Error ? reason.message : "WebUI mutation failed";
throw new ApiError(status, message);
}
}
function compactMcpValues(values: Record<string, unknown>): Record<string, unknown> {
const payload: Record<string, unknown> = {};
Object.entries(values).forEach(([key, value]) => {
if (value === null || value === undefined) return;
@@ -120,12 +144,7 @@ function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefin
}
payload[key] = value;
});
if (!Object.keys(payload).length) return undefined;
return { "X-Nanobot-MCP-Values": JSON.stringify(payload) };
}
function automationValuesHeader(values: AutomationUpdatePayload): HeadersInit {
return { "X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)) };
return payload;
}
function splitKey(key: string): { channel: string; chatId: string } {
@@ -261,37 +280,19 @@ export async function fetchAutomations(
}
export async function runAutomationAction(
token: string,
transport: WebUIMutationTransport,
action: "enable" | "disable" | "delete" | "run",
id: string,
base: string = "",
): Promise<AutomationsPayload> {
const query = new URLSearchParams();
query.set("id", id);
return request<AutomationsPayload>(
`${base}/api/webui/automations/${action}?${query}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
return mutation<AutomationsPayload>(transport, `automation.${action}`, { id });
}
export async function updateAutomation(
token: string,
transport: WebUIMutationTransport,
id: string,
values: AutomationUpdatePayload,
base: string = "",
): Promise<AutomationsPayload> {
const query = new URLSearchParams();
query.set("id", id);
return request<AutomationsPayload>(
`${base}/api/webui/automations/update?${query}`,
token,
{
headers: automationValuesHeader(values),
},
API_READ_TIMEOUT_MS,
);
return mutation<AutomationsPayload>(transport, "automation.update", { id, values });
}
export async function fetchSkills(
@@ -320,28 +321,18 @@ export async function fetchSkillDetail(
}
export async function updateSkillEnabled(
token: string,
transport: WebUIMutationTransport,
name: string,
enabled: boolean,
base: string = "",
): Promise<SkillActionPayload> {
const params = new URLSearchParams({ name, enabled: String(enabled) });
return request<SkillActionPayload>(
`${base}/api/webui/skills/update?${params}`,
token,
);
return mutation<SkillActionPayload>(transport, "skill.update", { name, enabled });
}
export async function deleteSkill(
token: string,
transport: WebUIMutationTransport,
name: string,
base: string = "",
): Promise<SkillActionPayload> {
const params = new URLSearchParams({ name });
return request<SkillActionPayload>(
`${base}/api/webui/skills/delete?${params}`,
token,
);
return mutation<SkillActionPayload>(transport, "skill.delete", { name });
}
export async function searchMarketplaceSkills(
@@ -389,37 +380,33 @@ export async function fetchMarketplaceSkillTrends(
}
export async function installMarketplaceSkill(
token: string,
transport: WebUIMutationTransport,
provider: Exclude<MarketplaceProvider, "all">,
source: string,
skill: string,
version: string = "",
base: string = "",
): Promise<SkillInstallPayload> {
const params = new URLSearchParams({ provider, source, skill });
if (version) params.set("version", version);
return request<SkillInstallPayload>(
`${base}/api/webui/skills/install?${params}`,
token,
undefined,
150_000,
return mutation<SkillInstallPayload>(
transport,
"skill.install",
{ provider, source, skill, ...(version ? { version } : {}) },
PACKAGE_MUTATION_TIMEOUT_MS,
);
}
export async function deleteSession(
token: string,
transport: WebUIMutationTransport,
key: string,
optionsOrBase?: { deleteAutomations?: boolean } | string,
base: string = "",
): Promise<SessionDeleteResult> {
const options = typeof optionsOrBase === "string" ? undefined : optionsOrBase;
const resolvedBase = typeof optionsOrBase === "string" ? optionsOrBase : base;
const query = new URLSearchParams();
if (options?.deleteAutomations) query.set("delete_automations", "true");
const suffix = query.toString() ? `?${query}` : "";
return request<SessionDeleteResult>(
`${resolvedBase}/api/sessions/${encodeURIComponent(key)}/delete${suffix}`,
token,
return mutation<SessionDeleteResult>(
transport,
"session.delete",
{
key,
...(options?.deleteAutomations ? { delete_automations: true } : {}),
},
);
}
@@ -520,56 +507,50 @@ export async function fetchApiService(token: string, base: string = ""): Promise
}
export async function startApiService(
token: string,
transport: WebUIMutationTransport,
values: { host: string; port: number; timeout: number; apiKey?: string },
base: string = "",
): Promise<ApiServicePayload> {
const query = new URLSearchParams({
host: values.host,
port: String(values.port),
timeout: String(values.timeout),
});
const headers = values.apiKey === undefined
? undefined
: { [API_SERVICE_VALUES_HEADER]: JSON.stringify({ api_key: values.apiKey }) };
return request<ApiServicePayload>(
`${base}/api/settings/api-service/start?${query}`,
token,
{ headers },
return mutation<ApiServicePayload>(
transport,
"settings.api_service.start",
{
host: values.host,
port: values.port,
timeout: values.timeout,
...(values.apiKey !== undefined ? { api_key: values.apiKey } : {}),
},
PACKAGE_MUTATION_TIMEOUT_MS,
);
}
export async function stopApiService(token: string, base: string = ""): Promise<ApiServicePayload> {
return request<ApiServicePayload>(`${base}/api/settings/api-service/stop`, token);
export async function stopApiService(
transport: WebUIMutationTransport,
): Promise<ApiServicePayload> {
return mutation<ApiServicePayload>(transport, "settings.api_service.stop");
}
export async function enableNanobotFeature(
token: string,
transport: WebUIMutationTransport,
name: string,
options: { instanceId?: string } = {},
base: string = "",
): Promise<NanobotFeaturesPayload> {
const query = new URLSearchParams();
query.set("name", name);
if (options.instanceId) query.set("instance_id", options.instanceId);
return request<NanobotFeaturesPayload>(
`${base}/api/settings/nanobot-features/enable?${query}`,
token,
return mutation<NanobotFeaturesPayload>(
transport,
"settings.feature.enable",
{ name, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
PACKAGE_MUTATION_TIMEOUT_MS,
);
}
export async function disableNanobotFeature(
token: string,
transport: WebUIMutationTransport,
name: string,
options: { instanceId?: string } = {},
base: string = "",
): Promise<NanobotFeaturesPayload> {
const query = new URLSearchParams();
query.set("name", name);
if (options.instanceId) query.set("instance_id", options.instanceId);
return request<NanobotFeaturesPayload>(
`${base}/api/settings/nanobot-features/disable?${query}`,
token,
return mutation<NanobotFeaturesPayload>(
transport,
"settings.feature.disable",
{ name, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
);
}
@@ -586,21 +567,15 @@ export async function fetchPairingRequests(
}
export async function runPairingAction(
token: string,
transport: WebUIMutationTransport,
action: "approve" | "deny",
code: string,
base: string = "",
): Promise<PairingPayload> {
const query = new URLSearchParams();
query.set("code", code);
return request<PairingPayload>(
`${base}/api/settings/pairing/${action}?${query}`,
token,
);
return mutation<PairingPayload>(transport, `settings.pairing.${action}`, { code });
}
export async function startChannelConnect(
token: string,
transport: WebUIMutationTransport,
channel: string,
options: {
domain?: string;
@@ -608,104 +583,95 @@ export async function startChannelConnect(
mode?: "replace" | "create";
force?: boolean;
} = {},
base: string = "",
): Promise<ChannelConnectPayload> {
const query = new URLSearchParams();
if (options.domain) query.set("domain", options.domain);
if (options.instanceId) query.set("instance_id", options.instanceId);
if (options.mode) query.set("mode", options.mode);
if (options.force) query.set("force", "true");
const suffix = query.toString();
return request<ChannelConnectPayload>(
`${base}/api/settings/channels/${channel}/connect/start${suffix ? `?${suffix}` : ""}`,
token,
return mutation<ChannelConnectPayload>(
transport,
"settings.channel.connect.start",
{
channel,
...(options.domain ? { domain: options.domain } : {}),
...(options.instanceId ? { instance_id: options.instanceId } : {}),
...(options.mode ? { mode: options.mode } : {}),
...(options.force ? { force: true } : {}),
},
PACKAGE_MUTATION_TIMEOUT_MS,
);
}
export async function pollChannelConnect(
token: string,
transport: WebUIMutationTransport,
channel: string,
sessionId: string,
base: string = "",
params: Readonly<Record<string, string>> = {},
): Promise<ChannelConnectPayload> {
const query = new URLSearchParams();
query.set("session_id", sessionId);
Object.entries(params).forEach(([key, value]) => {
if (key !== "session_id") query.set(key, value);
});
return request<ChannelConnectPayload>(
`${base}/api/settings/channels/${channel}/connect/poll?${query}`,
token,
const values = Object.fromEntries(
Object.entries(params).filter(([key]) => key !== "session_id"),
);
return mutation<ChannelConnectPayload>(
transport,
"settings.channel.connect.poll",
{ channel, session_id: sessionId, ...values },
PACKAGE_MUTATION_TIMEOUT_MS,
);
}
export async function cancelChannelConnect(
token: string,
transport: WebUIMutationTransport,
channel: string,
sessionId: string,
base: string = "",
): Promise<ChannelConnectPayload> {
const query = new URLSearchParams();
query.set("session_id", sessionId);
return request<ChannelConnectPayload>(
`${base}/api/settings/channels/${channel}/connect/cancel?${query}`,
token,
return mutation<ChannelConnectPayload>(
transport,
"settings.channel.connect.cancel",
{ channel, session_id: sessionId },
);
}
export async function configureChannel(
token: string,
transport: WebUIMutationTransport,
name: string,
values: Record<string, string>,
options: { enable?: boolean; instanceId?: string } = {},
base: string = "",
): Promise<ChannelConfigurePayload> {
const query = new URLSearchParams();
query.set("name", name);
if (options.enable !== undefined) query.set("enable", String(options.enable));
if (options.instanceId) query.set("instance_id", options.instanceId);
return request<ChannelConfigurePayload>(
`${base}/api/settings/channels/configure?${query}`,
token,
return mutation<ChannelConfigurePayload>(
transport,
"settings.channel.configure",
{
headers: {
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
},
name,
values,
...(options.enable !== undefined ? { enable: options.enable } : {}),
...(options.instanceId ? { instance_id: options.instanceId } : {}),
},
PACKAGE_MUTATION_TIMEOUT_MS,
);
}
export async function validateChannel(
token: string,
transport: WebUIMutationTransport,
name: string,
values: Record<string, string> = {},
options: { instanceId?: string } = {},
base: string = "",
): Promise<ChannelValidationPayload> {
const query = new URLSearchParams();
query.set("name", name);
if (options.instanceId) query.set("instance_id", options.instanceId);
return request<ChannelValidationPayload>(
`${base}/api/settings/channels/validate?${query}`,
token,
{
headers: {
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
},
},
return mutation<ChannelValidationPayload>(
transport,
"settings.channel.validate",
{ name, values, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
);
}
export async function runCliAppAction(
token: string,
transport: WebUIMutationTransport,
action: "install" | "update" | "uninstall" | "test",
name: string,
base: string = "",
): Promise<CliAppsPayload> {
const query = new URLSearchParams();
query.set("name", name);
return request<CliAppsPayload>(`${base}/api/settings/cli-apps/${action}?${query}`, token);
return mutation<CliAppsPayload>(
transport,
`settings.cli_app.${action}`,
{ name },
action === "install" || action === "update"
? PACKAGE_MUTATION_TIMEOUT_MS
: API_MUTATION_TIMEOUT_MS,
);
}
export async function fetchMcpPresets(
@@ -736,55 +702,45 @@ export async function fetchProviderModels(
}
export async function runMcpPresetAction(
token: string,
transport: WebUIMutationTransport,
action: "enable" | "remove" | "test",
name: string,
values: Record<string, string> = {},
base: string = "",
): Promise<McpPresetsPayload> {
const query = new URLSearchParams();
query.set("name", name);
return request<McpPresetsPayload>(
`${base}/api/settings/mcp-presets/${action}?${query}`,
token,
{ headers: mcpValuesHeader(values) },
return mutation<McpPresetsPayload>(
transport,
`settings.mcp.${action}`,
{ name, ...compactMcpValues(values) },
);
}
export async function saveCustomMcpServer(
token: string,
transport: WebUIMutationTransport,
values: Record<string, string>,
base: string = "",
): Promise<McpPresetsPayload> {
return request<McpPresetsPayload>(
`${base}/api/settings/mcp-presets/custom`,
token,
{ headers: mcpValuesHeader(values) },
return mutation<McpPresetsPayload>(
transport,
"settings.mcp.custom",
compactMcpValues(values),
);
}
export async function importMcpConfig(
token: string,
transport: WebUIMutationTransport,
config: string,
base: string = "",
): Promise<McpPresetsPayload> {
return request<McpPresetsPayload>(
`${base}/api/settings/mcp-presets/import`,
token,
{ headers: mcpValuesHeader({ config }) },
);
return mutation<McpPresetsPayload>(transport, "settings.mcp.import", { config });
}
export async function updateMcpServerTools(
token: string,
transport: WebUIMutationTransport,
name: string,
enabledTools: string[],
base: string = "",
): Promise<McpPresetsPayload> {
return request<McpPresetsPayload>(
`${base}/api/settings/mcp-presets/tools`,
token,
{ headers: mcpValuesHeader({ name, enabled_tools: enabledTools }) },
return mutation<McpPresetsPayload>(
transport,
"settings.mcp.tools",
{ name, enabled_tools: enabledTools },
);
}
@@ -835,280 +791,228 @@ export async function fetchSidebarState(
}
export async function updateSidebarState(
token: string,
transport: WebUIMutationTransport,
state: SidebarStatePayload,
base: string = "",
): Promise<SidebarStatePayload> {
const query = new URLSearchParams();
query.set("state", JSON.stringify(state));
return request<SidebarStatePayload>(
`${base}/api/webui/sidebar-state/update?${query}`,
token,
);
return mutation<SidebarStatePayload>(transport, "sidebar.update", { state });
}
export async function updateSettings(
token: string,
transport: WebUIMutationTransport,
update: SettingsUpdate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
const payload: Record<string, unknown> = {};
if (update.modelPreset !== undefined) {
query.set("model_preset", update.modelPreset ?? "default");
payload.model_preset = update.modelPreset ?? "default";
}
if (update.model !== undefined) query.set("model", update.model);
if (update.provider !== undefined) query.set("provider", update.provider);
if (update.model !== undefined) payload.model = update.model;
if (update.provider !== undefined) payload.provider = update.provider;
if (update.contextWindowTokens !== undefined) {
query.set("context_window_tokens", String(update.contextWindowTokens));
payload.context_window_tokens = update.contextWindowTokens;
}
if (update.timezone !== undefined) query.set("timezone", update.timezone);
if (update.timezone !== undefined) payload.timezone = update.timezone;
if (update.toolHintMaxLength !== undefined) {
query.set("tool_hint_max_length", String(update.toolHintMaxLength));
payload.tool_hint_max_length = update.toolHintMaxLength;
}
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
return mutation<SettingsPayload>(transport, "settings.agent.update", payload);
}
function appendModelGenerationSettings(
query: URLSearchParams,
function modelGenerationSettingsPayload(
configuration: Pick<
ModelConfigurationCreate,
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
>,
): void {
): Record<string, unknown> {
const payload: Record<string, unknown> = {};
if (configuration.maxTokens !== undefined) {
query.set("max_tokens", String(configuration.maxTokens));
payload.max_tokens = configuration.maxTokens;
}
if (configuration.contextWindowTokens !== undefined) {
query.set("context_window_tokens", String(configuration.contextWindowTokens));
payload.context_window_tokens = configuration.contextWindowTokens;
}
if (configuration.temperature !== undefined) {
query.set("temperature", String(configuration.temperature));
payload.temperature = configuration.temperature;
}
if (configuration.reasoningEffort !== undefined) {
query.set("reasoning_effort", configuration.reasoningEffort ?? "");
payload.reasoning_effort = configuration.reasoningEffort ?? "";
}
return payload;
}
export async function createModelConfiguration(
token: string,
transport: WebUIMutationTransport,
configuration: ModelConfigurationCreate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
if (configuration.name !== undefined) query.set("name", configuration.name);
query.set("label", configuration.label);
query.set("provider", configuration.provider);
query.set("model", configuration.model);
appendModelGenerationSettings(query, configuration);
return request<SettingsPayload>(
`${base}/api/settings/model-configurations/create?${query}`,
token,
return mutation<SettingsPayload>(
transport,
"settings.model_configuration.create",
{
...(configuration.name !== undefined ? { name: configuration.name } : {}),
label: configuration.label,
provider: configuration.provider,
model: configuration.model,
...modelGenerationSettingsPayload(configuration),
},
);
}
export async function updateModelConfiguration(
token: string,
transport: WebUIMutationTransport,
configuration: ModelConfigurationUpdate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("name", configuration.name);
if (configuration.label !== undefined) query.set("label", configuration.label);
if (configuration.provider !== undefined) query.set("provider", configuration.provider);
if (configuration.model !== undefined) query.set("model", configuration.model);
appendModelGenerationSettings(query, configuration);
return request<SettingsPayload>(
`${base}/api/settings/model-configurations/update?${query}`,
token,
return mutation<SettingsPayload>(
transport,
"settings.model_configuration.update",
{
name: configuration.name,
...(configuration.label !== undefined ? { label: configuration.label } : {}),
...(configuration.provider !== undefined ? { provider: configuration.provider } : {}),
...(configuration.model !== undefined ? { model: configuration.model } : {}),
...modelGenerationSettingsPayload(configuration),
},
);
}
export async function deleteModelConfiguration(
token: string,
transport: WebUIMutationTransport,
name: string,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams({ name });
return request<SettingsPayload>(
`${base}/api/settings/model-configurations/delete?${query}`,
token,
return mutation<SettingsPayload>(
transport,
"settings.model_configuration.delete",
{ name },
);
}
export async function migrateModelConfigurations(
token: string,
base: string = "",
transport: WebUIMutationTransport,
): Promise<SettingsPayload> {
return request<SettingsPayload>(
`${base}/api/settings/model-configurations/migrate`,
token,
);
return mutation<SettingsPayload>(transport, "settings.model_configuration.migrate");
}
export async function updateModelCallOrder(
token: string,
transport: WebUIMutationTransport,
order: string[],
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams({ order: JSON.stringify(order) });
return request<SettingsPayload>(
`${base}/api/settings/model-call-order/update?${query}`,
token,
);
return mutation<SettingsPayload>(transport, "settings.model_call_order.update", { order });
}
export async function updateProviderSettings(
token: string,
transport: WebUIMutationTransport,
update: ProviderSettingsUpdate,
base: string = "",
): Promise<SettingsPayload> {
const { provider, ...values } = update;
const query = new URLSearchParams({ provider });
return request<SettingsPayload>(
`${base}/api/settings/provider/update?${query}`,
token,
{
headers: {
[PROVIDER_VALUES_HEADER]: encodeURIComponent(JSON.stringify(values)),
},
},
);
return mutation<SettingsPayload>(transport, "settings.provider.update", { ...update });
}
export async function createProviderSettings(
token: string,
transport: WebUIMutationTransport,
update: ProviderCreationUpdate,
base: string = "",
): Promise<SettingsPayload> {
return request<SettingsPayload>(
`${base}/api/settings/provider/create`,
token,
{
headers: {
[PROVIDER_VALUES_HEADER]: encodeURIComponent(JSON.stringify(update)),
},
},
);
return mutation<SettingsPayload>(transport, "settings.provider.create", { ...update });
}
export async function loginProviderOAuth(
token: string,
transport: WebUIMutationTransport,
provider: string,
base: string = "",
remoteBrowserAccess: boolean = false,
): Promise<ProviderOAuthLoginResult> {
const query = new URLSearchParams();
query.set("provider", provider);
if (remoteBrowserAccess) query.set("remote_browser", "true");
return request<ProviderOAuthLoginResult>(
`${base}/api/settings/provider/oauth-login?${query}`,
token,
{ cache: "no-store" },
return mutation<ProviderOAuthLoginResult>(
transport,
"settings.provider.oauth_login",
{ provider, ...(remoteBrowserAccess ? { remote_browser: true } : {}) },
);
}
export async function completeProviderOAuth(
token: string,
transport: WebUIMutationTransport,
provider: string,
flowId: string,
authorizationResponse?: string,
base: string = "",
): Promise<ProviderOAuthCompletionResult> {
const query = new URLSearchParams();
query.set("provider", provider);
query.set("flow_id", flowId);
const responseHeader = provider === "openai_codex"
? OAUTH_CALLBACK_HEADER
: OAUTH_CODE_HEADER;
const headers = authorizationResponse
? { [responseHeader]: authorizationResponse }
: undefined;
return request<ProviderOAuthCompletionResult>(
`${base}/api/settings/provider/oauth-login/complete?${query}`,
token,
{ cache: "no-store", ...(headers ? { headers } : {}) },
return mutation<ProviderOAuthCompletionResult>(
transport,
"settings.provider.oauth_complete",
{
provider,
flow_id: flowId,
...(authorizationResponse ? { authorization_response: authorizationResponse } : {}),
},
);
}
export async function logoutProviderOAuth(
token: string,
transport: WebUIMutationTransport,
provider: string,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("provider", provider);
return request<SettingsPayload>(
`${base}/api/settings/provider/oauth-logout?${query}`,
token,
);
return mutation<SettingsPayload>(transport, "settings.provider.oauth_logout", { provider });
}
export async function updateWebSearchSettings(
token: string,
transport: WebUIMutationTransport,
update: WebSearchSettingsUpdate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("provider", update.provider);
if (update.apiKey !== undefined) query.set("api_key", update.apiKey);
if (update.baseUrl !== undefined) query.set("base_url", update.baseUrl);
if (update.maxResults !== undefined) query.set("max_results", String(update.maxResults));
if (update.timeout !== undefined) query.set("timeout", String(update.timeout));
if (update.useJinaReader !== undefined) {
query.set("use_jina_reader", String(update.useJinaReader));
}
return request<SettingsPayload>(
`${base}/api/settings/web-search/update?${query}`,
token,
return mutation<SettingsPayload>(
transport,
"settings.web_search.update",
{
provider: update.provider,
...(update.apiKey !== undefined ? { api_key: update.apiKey } : {}),
...(update.baseUrl !== undefined ? { base_url: update.baseUrl } : {}),
...(update.maxResults !== undefined ? { max_results: update.maxResults } : {}),
...(update.timeout !== undefined ? { timeout: update.timeout } : {}),
...(update.useJinaReader !== undefined
? { use_jina_reader: update.useJinaReader }
: {}),
},
);
}
export async function updateNetworkSafetySettings(
token: string,
transport: WebUIMutationTransport,
update: NetworkSafetySettingsUpdate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("webui_allow_local_service_access", String(update.webuiAllowLocalServiceAccess));
query.set("webui_default_access_mode", update.webuiDefaultAccessMode);
return request<SettingsPayload>(
`${base}/api/settings/network-safety/update?${query}`,
token,
return mutation<SettingsPayload>(
transport,
"settings.network_safety.update",
{
webui_allow_local_service_access: update.webuiAllowLocalServiceAccess,
webui_default_access_mode: update.webuiDefaultAccessMode,
},
);
}
export async function updateImageGenerationSettings(
token: string,
transport: WebUIMutationTransport,
update: ImageGenerationSettingsUpdate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("enabled", String(update.enabled));
query.set("provider", update.provider);
query.set("model", update.model);
query.set("default_aspect_ratio", update.defaultAspectRatio);
query.set("default_image_size", update.defaultImageSize);
query.set("max_images_per_turn", String(update.maxImagesPerTurn));
return request<SettingsPayload>(
`${base}/api/settings/image-generation/update?${query}`,
token,
return mutation<SettingsPayload>(
transport,
"settings.image_generation.update",
{
enabled: update.enabled,
provider: update.provider,
model: update.model,
default_aspect_ratio: update.defaultAspectRatio,
default_image_size: update.defaultImageSize,
max_images_per_turn: update.maxImagesPerTurn,
},
);
}
export async function updateTranscriptionSettings(
token: string,
transport: WebUIMutationTransport,
update: TranscriptionSettingsUpdate,
base: string = "",
): Promise<SettingsPayload> {
const query = new URLSearchParams();
query.set("enabled", String(update.enabled));
query.set("provider", update.provider);
query.set("model", update.model);
query.set("language", update.language);
query.set("max_duration_sec", String(update.maxDurationSec));
query.set("max_upload_mb", String(update.maxUploadMb));
return request<SettingsPayload>(
`${base}/api/settings/transcription/update?${query}`,
token,
return mutation<SettingsPayload>(
transport,
"settings.transcription.update",
{
enabled: update.enabled,
provider: update.provider,
model: update.model,
language: update.language,
max_duration_sec: update.maxDurationSec,
max_upload_mb: update.maxUploadMb,
},
);
}
+91 -2
View File
@@ -108,6 +108,16 @@ interface PendingRequest<T> {
timer: ReturnType<typeof setTimeout>;
}
export class WebUIMutationError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "WebUIMutationError";
}
}
interface PendingChatRequest extends PendingRequest<string> {
temporary: boolean;
}
@@ -203,6 +213,7 @@ export class NanobotClient {
private pendingNewChat: PendingChatRequest | null = null;
private pendingTranscriptions = new Map<string, PendingRequest<string>>();
private pendingSystemCommands = new Map<string, PendingRequest<void>>();
private pendingWebUIRequests = new Map<string, PendingRequest<unknown>>();
// Frames queued while the socket is not yet OPEN
private sendQueue: Outbound[] = [];
private reconnectAttempts = 0;
@@ -807,6 +818,60 @@ export class NanobotClient {
});
}
/**
* Send one non-replayable WebUI mutation over the authenticated socket.
* A client-side timeout only abandons the reply; the server may finish work
* that already started, so timed-out requests are never retried automatically.
*/
requestMutation<T>(
action: string,
payload: Record<string, unknown> = {},
timeoutMs: number = 20_000,
): Promise<T> {
const socket = this.socket;
if (!socket || socket.readyState !== WS_OPEN) {
return Promise.reject(
new WebUIMutationError(503, "WebUI connection is not open"),
);
}
const requestId = crypto.randomUUID();
const frame: Outbound = {
type: "webui_request",
request_id: requestId,
action,
payload,
};
if (!this.frameFitsTransport(frame)) {
return Promise.reject(
new WebUIMutationError(413, "WebUI mutation payload is too large"),
);
}
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingWebUIRequests.delete(requestId);
reject(
new WebUIMutationError(
504,
`WebUI request timed out after ${timeoutMs}ms`,
),
);
}, timeoutMs);
this.pendingWebUIRequests.set(requestId, {
resolve: (value) => resolve(value as T),
reject,
timer,
});
try {
socket.send(JSON.stringify(frame));
} catch {
clearTimeout(timer);
this.pendingWebUIRequests.delete(requestId);
reject(new WebUIMutationError(503, "Could not send WebUI request"));
}
});
}
/** Ask the server to create a non-destructive fork before a user-message index. */
forkChat(
sourceChatId: string,
@@ -914,8 +979,8 @@ export class NanobotClient {
});
}
setSidebarState(state: SidebarStatePayload): void {
this.queueSend({ type: "set_sidebar_state", state });
setSidebarState(state: SidebarStatePayload): Promise<SidebarStatePayload> {
return this.requestMutation<SidebarStatePayload>("sidebar.update", { state });
}
// -- internals ---------------------------------------------------------
@@ -965,6 +1030,23 @@ export class NanobotClient {
console.log("[nanobot ws inbound]", summarizeInboundWsPayload(parsed));
}
if (parsed.event === "webui_response") {
const pending = this.pendingWebUIRequests.get(parsed.request_id);
if (!pending) return;
clearTimeout(pending.timer);
this.pendingWebUIRequests.delete(parsed.request_id);
if (parsed.ok) {
pending.resolve(parsed.result);
} else {
const status = Number.isFinite(parsed.error?.status)
? parsed.error.status
: 500;
const message = parsed.error?.message || "WebUI mutation failed";
pending.reject(new WebUIMutationError(status, message));
}
return;
}
if (parsed.event === "error" && !parsed.turn_id) {
const fallback = this.legacyRejectionTarget(parsed);
if (fallback) {
@@ -1151,6 +1233,13 @@ export class NanobotClient {
this.pendingNewChat = null;
}
this.rejectAllTranscriptions("socket closed");
for (const pending of this.pendingWebUIRequests.values()) {
clearTimeout(pending.timer);
pending.reject(
new WebUIMutationError(503, "Socket closed before WebUI response"),
);
}
this.pendingWebUIRequests.clear();
for (const pending of this.pendingSystemCommands.values()) {
clearTimeout(pending.timer);
pending.reject(new Error("socket closed"));
+18
View File
@@ -1261,6 +1261,18 @@ export type InboundEvent =
detail?: string;
provider?: string;
}
| {
event: "webui_response";
request_id: string;
ok: true;
result: unknown;
}
| {
event: "webui_response";
request_id: string;
ok: false;
error: { status: number; message: string };
}
| {
event: "error";
chat_id?: string;
@@ -1339,6 +1351,12 @@ export interface FilePreviewPayload {
export type Outbound =
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "new_temporary_chat" }
| {
type: "webui_request";
request_id: string;
action: string;
payload: Record<string, unknown>;
}
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
| { type: "attach"; chat_id: string }
| { type: "set_sidebar_state"; state: SidebarStatePayload }
+310 -359
View File
@@ -59,8 +59,19 @@ import {
validateChannel,
} from "@/lib/api";
const requestMutation = vi.fn();
const mutationTransport = {
requestMutation: <T>(
action: string,
payload?: Record<string, unknown>,
timeoutMs?: number,
) => requestMutation(action, payload, timeoutMs) as Promise<T>,
};
describe("webui API helpers", () => {
beforeEach(() => {
requestMutation.mockReset();
requestMutation.mockResolvedValue({});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
@@ -184,88 +195,74 @@ describe("webui API helpers", () => {
it("validates channel settings with form values", async () => {
await validateChannel(
"tok",
mutationTransport,
"slack",
{ "channels.slack.botToken": "xoxb-test" },
{ instanceId: "default" },
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/channels/validate?name=slack&instance_id=default",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer tok",
"X-Nanobot-Channel-Values": JSON.stringify({
"channels.slack.botToken": "xoxb-test",
}),
}),
}),
);
expect(fetch).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: "POST" }),
expect(requestMutation).toHaveBeenCalledWith(
"settings.channel.validate",
{
name: "slack",
instance_id: "default",
values: { "channels.slack.botToken": "xoxb-test" },
},
20_000,
);
expect(fetch).not.toHaveBeenCalled();
});
it("configures channels through the WebSocket HTTP shim", async () => {
it("configures channels through the authenticated WebSocket", async () => {
await configureChannel(
"tok",
mutationTransport,
"discord",
{ "channels.discord.token": "saved-secret" },
{ enable: true },
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/channels/configure?name=discord&enable=true",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer tok",
"X-Nanobot-Channel-Values": JSON.stringify({
"channels.discord.token": "saved-secret",
}),
}),
}),
);
expect(fetch).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: "POST" }),
expect(requestMutation).toHaveBeenCalledWith(
"settings.channel.configure",
{
name: "discord",
enable: true,
values: { "channels.discord.token": "saved-secret" },
},
150_000,
);
expect(fetch).not.toHaveBeenCalled();
});
it("serializes channel QR connect helpers", async () => {
await startChannelConnect("tok", "weixin", { force: true });
expect(fetch).toHaveBeenLastCalledWith(
"/api/settings/channels/weixin/connect/start?force=true",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
it("serializes channel QR connect request envelopes", async () => {
await startChannelConnect(mutationTransport, "weixin", { force: true });
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.channel.connect.start",
{ channel: "weixin", force: true },
150_000,
);
await pollChannelConnect("tok", "weixin", "session+/=");
expect(fetch).toHaveBeenLastCalledWith(
"/api/settings/channels/weixin/connect/poll?session_id=session%2B%2F%3D",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await pollChannelConnect(mutationTransport, "weixin", "session+/=");
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.channel.connect.poll",
{ channel: "weixin", session_id: "session+/=" },
150_000,
);
await cancelChannelConnect("tok", "weixin", "session+/=");
expect(fetch).toHaveBeenLastCalledWith(
"/api/settings/channels/weixin/connect/cancel?session_id=session%2B%2F%3D",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await cancelChannelConnect(mutationTransport, "weixin", "session+/=");
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.channel.connect.cancel",
{ channel: "weixin", session_id: "session+/=" },
20_000,
);
});
it("serializes workspace automation actions", async () => {
await runAutomationAction("tok", "disable", "job 1/2");
await runAutomationAction(mutationTransport, "disable", "job 1/2");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations/disable?id=job+1%2F2",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"automation.disable",
{ id: "job 1/2" },
20_000,
);
});
@@ -275,19 +272,14 @@ describe("webui API helpers", () => {
message: "Ask 今日 quiz",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
} as const;
await updateAutomation("tok", "job 1/2", values);
await updateAutomation(mutationTransport, "job 1/2", values);
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations/update?id=job+1%2F2",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)),
},
}),
expect(requestMutation).toHaveBeenCalledWith(
"automation.update",
{ id: "job 1/2", values },
20_000,
);
const header = vi.mocked(fetch).mock.calls[0][1]?.headers as Record<string, string>;
expect(header["X-Nanobot-Automation-Values"]).not.toContain("每日");
expect(fetch).not.toHaveBeenCalled();
});
it("fetches the WebUI skill summary", async () => {
@@ -348,66 +340,66 @@ describe("webui API helpers", () => {
);
});
it("encodes provider install coordinates", async () => {
it("sends provider install coordinates without placing them in a URL", async () => {
await installMarketplaceSkill(
"tok",
mutationTransport,
"skillhub",
"@tencent/skills",
"ima-skills",
"1.1.8",
);
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/install?provider=skillhub&source=%40tencent%2Fskills&skill=ima-skills&version=1.1.8",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"skill.install",
{
provider: "skillhub",
source: "@tencent/skills",
skill: "ima-skills",
version: "1.1.8",
},
150_000,
);
});
it("updates and deletes installed skills with encoded names", async () => {
await updateSkillEnabled("tok", "custom skill", false);
it("updates and deletes installed skills over the WebSocket", async () => {
await updateSkillEnabled(mutationTransport, "custom skill", false);
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/update?name=custom+skill&enabled=false",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenLastCalledWith(
"skill.update",
{ name: "custom skill", enabled: false },
20_000,
);
await deleteSkill("tok", "custom skill");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/delete?name=custom+skill",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await deleteSkill(mutationTransport, "custom skill");
expect(requestMutation).toHaveBeenLastCalledWith(
"skill.delete",
{ name: "custom skill" },
20_000,
);
});
it("percent-encodes websocket keys when deleting a session", async () => {
await deleteSession("tok", "websocket:chat-1");
it("sends the session key in a mutation payload", async () => {
await deleteSession(mutationTransport, "websocket:chat-1");
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/delete",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"session.delete",
{ key: "websocket:chat-1" },
20_000,
);
});
it("passes the automation cascade flag when deleting a session", async () => {
await deleteSession("tok", "websocket:chat-1", { deleteAutomations: true });
await deleteSession(mutationTransport, "websocket:chat-1", { deleteAutomations: true });
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/delete?delete_automations=true",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"session.delete",
{ key: "websocket:chat-1", delete_automations: true },
20_000,
);
});
it("serializes settings updates as a narrow query string", async () => {
await updateSettings("tok", {
it("serializes settings updates as a narrow mutation payload", async () => {
await updateSettings(mutationTransport, {
modelPreset: "default",
model: "openrouter/test",
provider: "openrouter",
@@ -416,11 +408,17 @@ describe("webui API helpers", () => {
toolHintMaxLength: 120,
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&context_window_tokens=262144&timezone=Asia%2FShanghai&tool_hint_max_length=120",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.agent.update",
{
model_preset: "default",
model: "openrouter/test",
provider: "openrouter",
context_window_tokens: 262144,
timezone: "Asia/Shanghai",
tool_hint_max_length: 120,
},
20_000,
);
});
@@ -436,7 +434,7 @@ describe("webui API helpers", () => {
});
it("serializes model configuration creation", async () => {
await createModelConfiguration("tok", {
await createModelConfiguration(mutationTransport, {
label: "Fast writing",
provider: "openai",
model: "openai/gpt-4.1-mini",
@@ -446,16 +444,23 @@ describe("webui API helpers", () => {
reasoningEffort: "high",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/model-configurations/create?label=Fast+writing&provider=openai&model=openai%2Fgpt-4.1-mini&max_tokens=4096&context_window_tokens=128000&temperature=0.4&reasoning_effort=high",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.model_configuration.create",
{
label: "Fast writing",
provider: "openai",
model: "openai/gpt-4.1-mini",
max_tokens: 4096,
context_window_tokens: 128000,
temperature: 0.4,
reasoning_effort: "high",
},
20_000,
);
});
it("serializes model configuration updates", async () => {
await updateModelConfiguration("tok", {
await updateModelConfiguration(mutationTransport, {
name: "codex",
label: "Codex",
provider: "openai_codex",
@@ -466,42 +471,47 @@ describe("webui API helpers", () => {
reasoningEffort: null,
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5&max_tokens=8192&context_window_tokens=65536&temperature=0&reasoning_effort=",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.model_configuration.update",
{
name: "codex",
label: "Codex",
provider: "openai_codex",
model: "openai-codex/gpt-5.5",
max_tokens: 8192,
context_window_tokens: 65536,
temperature: 0,
reasoning_effort: "",
},
20_000,
);
});
it("serializes model preset deletion and migration", async () => {
await deleteModelConfiguration("tok", "spare");
await migrateModelConfigurations("tok");
await deleteModelConfiguration(mutationTransport, "spare");
await migrateModelConfigurations(mutationTransport);
expect(fetch).toHaveBeenNthCalledWith(
expect(requestMutation).toHaveBeenNthCalledWith(
1,
"/api/settings/model-configurations/delete?name=spare",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
"settings.model_configuration.delete",
{ name: "spare" },
20_000,
);
expect(fetch).toHaveBeenNthCalledWith(
expect(requestMutation).toHaveBeenNthCalledWith(
2,
"/api/settings/model-configurations/migrate",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
"settings.model_configuration.migrate",
{},
20_000,
);
});
it("serializes model call order as an ordered JSON array", async () => {
await updateModelCallOrder("tok", ["backup", "primary"]);
await updateModelCallOrder(mutationTransport, ["backup", "primary"]);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/model-call-order/update?order=%5B%22backup%22%2C%22primary%22%5D",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.model_call_order.update",
{ order: ["backup", "primary"] },
20_000,
);
});
@@ -516,28 +526,20 @@ describe("webui API helpers", () => {
}),
);
await expect(
updateModelConfiguration("tok", {
name: "codex",
model: "openai-codex/gpt-5.5",
}),
).rejects.toMatchObject({
await expect(fetchApiService("tok")).rejects.toMatchObject({
status: 200,
message: "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again.",
});
});
it("surfaces API error response bodies", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: async () => "npm error ENOTEMPTY",
}),
it("surfaces correlated WebSocket mutation errors", async () => {
requestMutation.mockRejectedValueOnce(
Object.assign(new Error("npm error ENOTEMPTY"), { status: 500 }),
);
await expect(runCliAppAction("tok", "install", "hyperframes")).rejects.toMatchObject({
await expect(
runCliAppAction(mutationTransport, "install", "hyperframes"),
).rejects.toMatchObject({
status: 500,
message: "npm error ENOTEMPTY",
});
@@ -555,50 +557,45 @@ describe("webui API helpers", () => {
await pending;
});
it("serializes provider settings updates without returning secrets", async () => {
await updateProviderSettings("tok", {
it("keeps provider secrets in the WebSocket payload", async () => {
await updateProviderSettings(mutationTransport, {
provider: "openrouter",
apiKey: "sk-or-test",
apiBase: "https://openrouter.ai/api/v1",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/update?provider=openrouter",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
apiKey: "sk-or-test",
apiBase: "https://openrouter.ai/api/v1",
})),
},
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.provider.update",
{
provider: "openrouter",
apiKey: "sk-or-test",
apiBase: "https://openrouter.ai/api/v1",
},
20_000,
);
expect(fetch).not.toHaveBeenCalled();
});
it("serializes OAuth provider advanced settings", async () => {
await updateProviderSettings("tok", {
await updateProviderSettings(mutationTransport, {
provider: "xai_grok",
proxy: "http://127.0.0.1:7890",
extraBody: '{"tools":[]}',
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/update?provider=xai_grok",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
proxy: "http://127.0.0.1:7890",
extraBody: '{"tools":[]}',
})),
},
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.provider.update",
{
provider: "xai_grok",
proxy: "http://127.0.0.1:7890",
extraBody: '{"tools":[]}',
},
20_000,
);
});
it("serializes custom provider creation with advanced settings", async () => {
await createProviderSettings("tok", {
const update = {
name: "Company Gateway",
apiKey: "sk-company",
apiBase: "https://gateway.example/v1",
@@ -607,25 +604,13 @@ describe("webui API helpers", () => {
extraQuery: '{"api-version":"2026-01-01"}',
proxy: "http://127.0.0.1:7890",
thinkingStyle: "enable_thinking",
});
};
await createProviderSettings(mutationTransport, update);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/create",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
name: "Company Gateway",
apiKey: "sk-company",
apiBase: "https://gateway.example/v1",
extraHeaders: '{"X-Tenant":"engineering"}',
extraBody: '{"service_tier":"priority"}',
extraQuery: '{"api-version":"2026-01-01"}',
proxy: "http://127.0.0.1:7890",
thinkingStyle: "enable_thinking",
})),
},
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.provider.create",
update,
20_000,
);
});
@@ -641,74 +626,65 @@ describe("webui API helpers", () => {
});
it("serializes provider OAuth login and logout actions", async () => {
await loginProviderOAuth("tok", "openai_codex");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login?provider=openai_codex",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await loginProviderOAuth(mutationTransport, "openai_codex");
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.provider.oauth_login",
{ provider: "openai_codex" },
20_000,
);
await loginProviderOAuth("tok", "openai_codex", "", true);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await loginProviderOAuth(mutationTransport, "openai_codex", true);
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.provider.oauth_login",
{ provider: "openai_codex", remote_browser: true },
20_000,
);
await completeProviderOAuth("tok", "xai_grok", "flow-123");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await completeProviderOAuth(mutationTransport, "xai_grok", "flow-123");
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.provider.oauth_complete",
{ provider: "xai_grok", flow_id: "flow-123" },
20_000,
);
await completeProviderOAuth(
"tok",
mutationTransport,
"xai_grok",
"flow-123",
"secret",
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-OAuth-Code": "secret",
},
}),
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.provider.oauth_complete",
{ provider: "xai_grok", flow_id: "flow-123", authorization_response: "secret" },
20_000,
);
await completeProviderOAuth(
"tok",
mutationTransport,
"openai_codex",
"flow-codex",
"http://localhost:1455/auth/callback?code=secret&state=test",
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-OAuth-Callback":
"http://localhost:1455/auth/callback?code=secret&state=test",
},
}),
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.provider.oauth_complete",
{
provider: "openai_codex",
flow_id: "flow-codex",
authorization_response: "http://localhost:1455/auth/callback?code=secret&state=test",
},
20_000,
);
await logoutProviderOAuth("tok", "openai_codex");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-logout?provider=openai_codex",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await logoutProviderOAuth(mutationTransport, "openai_codex");
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.provider.oauth_logout",
{ provider: "openai_codex" },
20_000,
);
});
it("serializes web search settings updates", async () => {
await updateWebSearchSettings("tok", {
await updateWebSearchSettings(mutationTransport, {
provider: "searxng",
baseUrl: "https://search.example.com",
maxResults: 8,
@@ -716,30 +692,37 @@ describe("webui API helpers", () => {
useJinaReader: false,
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/web-search/update?provider=searxng&base_url=https%3A%2F%2Fsearch.example.com&max_results=8&timeout=45&use_jina_reader=false",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.web_search.update",
{
provider: "searxng",
base_url: "https://search.example.com",
max_results: 8,
timeout: 45,
use_jina_reader: false,
},
20_000,
);
});
it("serializes network safety settings updates", async () => {
await updateNetworkSafetySettings("tok", {
await updateNetworkSafetySettings(mutationTransport, {
webuiAllowLocalServiceAccess: false,
webuiDefaultAccessMode: "full",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.network_safety.update",
{
webui_allow_local_service_access: false,
webui_default_access_mode: "full",
},
20_000,
);
});
it("serializes image generation settings updates", async () => {
await updateImageGenerationSettings("tok", {
await updateImageGenerationSettings(mutationTransport, {
enabled: true,
provider: "openrouter",
model: "openai/gpt-5.4-image-2",
@@ -748,11 +731,17 @@ describe("webui API helpers", () => {
maxImagesPerTurn: 3,
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/image-generation/update?enabled=true&provider=openrouter&model=openai%2Fgpt-5.4-image-2&default_aspect_ratio=16%3A9&default_image_size=2K&max_images_per_turn=3",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.image_generation.update",
{
enabled: true,
provider: "openrouter",
model: "openai/gpt-5.4-image-2",
default_aspect_ratio: "16:9",
default_image_size: "2K",
max_images_per_turn: 3,
},
20_000,
);
});
@@ -774,12 +763,11 @@ describe("webui API helpers", () => {
}),
);
await runCliAppAction("tok", "install", "gimp");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/cli-apps/install?name=gimp",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await runCliAppAction(mutationTransport, "install", "gimp");
expect(requestMutation).toHaveBeenCalledWith(
"settings.cli_app.install",
{ name: "gimp" },
150_000,
);
});
@@ -819,20 +807,18 @@ describe("webui API helpers", () => {
}),
);
await enableNanobotFeature("tok", "matrix");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/nanobot-features/enable?name=matrix",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await enableNanobotFeature(mutationTransport, "matrix");
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.feature.enable",
{ name: "matrix" },
150_000,
);
await disableNanobotFeature("tok", "matrix");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/nanobot-features/disable?name=matrix",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
await disableNanobotFeature(mutationTransport, "matrix");
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.feature.disable",
{ name: "matrix" },
20_000,
);
});
@@ -843,34 +829,31 @@ describe("webui API helpers", () => {
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
await startApiService("tok", { host: "127.0.0.1", port: 8900, timeout: 120 });
expect(fetch).toHaveBeenCalledWith(
"/api/settings/api-service/start?host=127.0.0.1&port=8900&timeout=120",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
await startApiService(
mutationTransport,
{ host: "127.0.0.1", port: 8900, timeout: 120 },
);
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.api_service.start",
{ host: "127.0.0.1", port: 8900, timeout: 120 },
150_000,
);
await startApiService(
"tok",
mutationTransport,
{ host: "0.0.0.0", port: 8900, timeout: 120, apiKey: "secret-token" },
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-API-Service-Values": JSON.stringify({ api_key: "secret-token" }),
},
}),
);
expect(fetch).not.toHaveBeenCalledWith(
expect.stringContaining("secret-token"),
expect.anything(),
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.api_service.start",
{ host: "0.0.0.0", port: 8900, timeout: 120, api_key: "secret-token" },
150_000,
);
await stopApiService("tok");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/api-service/stop",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
await stopApiService(mutationTransport);
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.api_service.stop",
{},
20_000,
);
});
@@ -891,71 +874,46 @@ describe("webui API helpers", () => {
}),
);
await runMcpPresetAction("tok", "enable", "browserbase", {
await runMcpPresetAction(mutationTransport, "enable", "browserbase", {
browserbase_api_key: "bb_live_test",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/mcp-presets/enable?name=browserbase",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer tok",
"X-Nanobot-MCP-Values": JSON.stringify({
browserbase_api_key: "bb_live_test",
}),
}),
}),
expect(requestMutation).toHaveBeenCalledWith(
"settings.mcp.enable",
{ name: "browserbase", browserbase_api_key: "bb_live_test" },
20_000,
);
});
it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => {
await saveCustomMcpServer("tok", {
const custom = {
name: "docs",
transport: "stdio",
command: "npx",
args: '["-y","docs-mcp"]',
env: '{"API_KEY":"secret"}',
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/mcp-presets/custom",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer tok",
"X-Nanobot-MCP-Values": JSON.stringify({
name: "docs",
transport: "stdio",
command: "npx",
args: '["-y","docs-mcp"]',
env: '{"API_KEY":"secret"}',
}),
}),
}),
};
await saveCustomMcpServer(mutationTransport, custom);
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.mcp.custom",
custom,
20_000,
);
await importMcpConfig("tok", '{"mcpServers":{"docs":{"command":"npx"}}}');
expect(fetch).toHaveBeenCalledWith(
"/api/settings/mcp-presets/import",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer tok",
"X-Nanobot-MCP-Values": JSON.stringify({
config: '{"mcpServers":{"docs":{"command":"npx"}}}',
}),
}),
}),
await importMcpConfig(
mutationTransport,
'{"mcpServers":{"docs":{"command":"npx"}}}',
);
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.mcp.import",
{ config: '{"mcpServers":{"docs":{"command":"npx"}}}' },
20_000,
);
await updateMcpServerTools("tok", "docs", ["search", "fetch"]);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/mcp-presets/tools",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer tok",
"X-Nanobot-MCP-Values": JSON.stringify({
name: "docs",
enabled_tools: ["search", "fetch"],
}),
}),
}),
await updateMcpServerTools(mutationTransport, "docs", ["search", "fetch"]);
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.mcp.tools",
{ name: "docs", enabled_tools: ["search", "fetch"] },
20_000,
);
});
@@ -991,19 +949,12 @@ describe("webui API helpers", () => {
}),
);
await updateSidebarState("tok", state);
const [url, init] = vi.mocked(fetch).mock.calls.at(-1)!;
expect(String(url).startsWith("/api/webui/sidebar-state/update?")).toBe(true);
expect(init).toEqual(expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}));
const encodedState = new URLSearchParams(String(url).split("?", 2)[1]).get("state");
expect(encodedState).toBeTruthy();
expect(JSON.parse(encodedState ?? "{}")).toMatchObject({
pinned_keys: ["websocket:chat-1"],
title_overrides: { "websocket:chat-1": "Release" },
project_name_overrides: { "/Users/me/nanobot": "Core" },
});
await updateSidebarState(mutationTransport, state);
expect(requestMutation).toHaveBeenCalledWith(
"sidebar.update",
{ state },
20_000,
);
});
it("fetches workspace project state", async () => {
+67 -74
View File
@@ -19,6 +19,7 @@ const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const setSidebarStateSpy = vi.fn();
const requestMutationSpy = vi.fn();
const discardTemporaryChatSpy = vi.fn();
const newTemporaryChatSpy = vi.fn<() => Promise<string>>();
const sendMessageSpy = vi.fn();
@@ -242,6 +243,7 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
newTemporaryChat = newTemporaryChatSpy;
attach = attachSpy;
setSidebarState = setSidebarStateSpy;
requestMutation = requestMutationSpy;
discardTemporaryChat = discardTemporaryChatSpy;
close = vi.fn();
updateUrl = updateUrlSpy;
@@ -270,7 +272,8 @@ describe("App layout", () => {
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
toggleThemeSpy.mockReset();
attachSpy.mockReset();
setSidebarStateSpy.mockReset();
setSidebarStateSpy.mockReset().mockResolvedValue({});
requestMutationSpy.mockReset();
discardTemporaryChatSpy.mockReset();
let temporaryChatCounter = 0;
newTemporaryChatSpy.mockImplementation(async () => (
@@ -877,40 +880,36 @@ describe("App layout", () => {
}],
raw_markdown: "---\nname: github\n---\nUse GitHub CLI.",
},
"/api/webui/skills/update?name=github&enabled=false": {
skills: [
{
name: "cron",
description: "Schedule reminders.",
source: "builtin",
enabled: true,
deletable: false,
available: true,
},
{
name: "github",
description: "Work with GitHub.",
source: "builtin",
enabled: false,
deletable: false,
available: false,
unavailable_reason: "CLI: gh",
},
{
name: "custom-skill",
description: "A workspace skill.",
source: "workspace",
enabled: true,
deletable: true,
available: true,
},
],
last_action: {
name: "github",
enabled: false,
deleted: false,
});
requestMutationSpy.mockResolvedValueOnce({
skills: [
{
name: "cron",
description: "Schedule reminders.",
source: "builtin",
enabled: true,
deletable: false,
available: true,
},
},
{
name: "github",
description: "Work with GitHub.",
source: "builtin",
enabled: false,
deletable: false,
available: false,
unavailable_reason: "CLI: gh",
},
{
name: "custom-skill",
description: "A workspace skill.",
source: "workspace",
enabled: true,
deletable: true,
available: true,
},
],
last_action: { name: "github", enabled: false, deleted: false },
});
render(<App />);
@@ -1010,14 +1009,10 @@ describe("App layout", () => {
},
raw_markdown: "---\nname: custom-skill\n---\nWorkspace instructions.",
},
"/api/webui/skills/delete?name=custom-skill": {
skills: [],
last_action: {
name: "custom-skill",
enabled: false,
deleted: true,
},
},
});
requestMutationSpy.mockResolvedValueOnce({
skills: [],
last_action: { name: "custom-skill", enabled: false, deleted: true },
});
render(<App />);
@@ -1149,9 +1144,8 @@ describe("App layout", () => {
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
trends: { "acme/agent-skills/react-testing": [] },
},
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing":
() => pendingInstall,
});
requestMutationSpy.mockImplementationOnce(() => pendingInstall);
render(<App />);
@@ -1199,11 +1193,14 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("button", { name: "Install skill" }));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing",
expect.objectContaining({
headers: { Authorization: expect.any(String) },
}),
expect(requestMutationSpy).toHaveBeenCalledWith(
"skill.install",
{
provider: "skills_sh",
source: "acme/agent-skills",
skill: "react-testing",
},
150_000,
);
});
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
@@ -1361,14 +1358,12 @@ describe("App layout", () => {
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/webui/automations": { jobs: [pastOneShot] },
"/api/webui/automations/update?id=past-one-shot": {
jobs: [
{
...pastOneShot,
payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
},
],
},
});
requestMutationSpy.mockResolvedValueOnce({
jobs: [{
...pastOneShot,
payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
}],
});
render(<App />);
@@ -1394,20 +1389,18 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations/update?id=past-one-shot",
expect.any(Object),
expect(requestMutationSpy).toHaveBeenCalledWith(
"automation.update",
{
id: "past-one-shot",
values: {
name: "Past one-shot",
message: "Updated one-shot message",
},
},
20_000,
);
});
const updateCall = vi.mocked(fetch).mock.calls.find(
([url]) => String(url) === "/api/webui/automations/update?id=past-one-shot",
);
expect(updateCall).toBeTruthy();
const headers = updateCall?.[1]?.headers as Record<string, string>;
expect(JSON.parse(decodeURIComponent(headers["X-Nanobot-Automation-Values"]))).toEqual({
name: "Past one-shot",
message: "Updated one-shot message",
});
});
it("keeps long automation details expandable without nested scrolling", async () => {
@@ -1829,6 +1822,9 @@ describe("App layout", () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
act(() => {
statusHandlers.forEach((handler) => handler("open"));
});
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() =>
expect(within(sidebar).getByText("Pinned")).toBeInTheDocument(),
@@ -2581,17 +2577,14 @@ describe("App layout", () => {
mockFetchRoutes({
"/api/settings": initialSettings,
});
const fetchMock = vi.mocked(fetch);
window.history.replaceState(null, "", "/#/settings?section=runtime");
render(<App />);
expect(await screen.findByText("UTC")).toBeInTheDocument();
expect(
fetchMock.mock.calls.filter(([input]) =>
String(input).startsWith("/api/settings/update?timezone="),
),
).toHaveLength(0);
requestMutationSpy.mock.calls.some(([action]) => action === "settings.agent.update"),
).toBe(false);
expect(screen.queryByRole("heading", { name: "Regional" })).not.toBeInTheDocument();
expect(
screen.queryByText("Used for schedules and time-aware replies."),
+137 -3
View File
@@ -71,6 +71,122 @@ afterEach(() => {
});
describe("NanobotClient", () => {
it("correlates successful WebUI mutation replies by request id", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
const socket = lastSocket();
socket.fakeOpen();
const pending = client.requestMutation<{ saved: boolean }>(
"settings.provider.update",
{ provider: "openrouter", apiKey: "secret" },
);
const frame = JSON.parse(socket.sent.at(-1) as string);
expect(frame).toMatchObject({
type: "webui_request",
action: "settings.provider.update",
payload: { provider: "openrouter", apiKey: "secret" },
});
expect(frame.request_id).toEqual(expect.any(String));
socket.fakeMessage({
event: "webui_response",
request_id: frame.request_id,
ok: true,
result: { saved: true },
});
await expect(pending).resolves.toEqual({ saved: true });
});
it("surfaces correlated WebUI mutation errors with status", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
const socket = lastSocket();
socket.fakeOpen();
const pending = client.requestMutation("settings.channel.configure", {});
const requestId = JSON.parse(socket.sent.at(-1) as string).request_id;
socket.fakeMessage({
event: "webui_response",
request_id: requestId,
ok: false,
error: { status: 400, message: "missing channel name" },
});
await expect(pending).rejects.toMatchObject({
status: 400,
message: "missing channel name",
});
});
it("times out WebUI mutations without replaying them", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
const socket = lastSocket();
socket.fakeOpen();
const pending = expect(
client.requestMutation("skill.install", { skill: "docs" }, 25),
).rejects.toMatchObject({
status: 504,
message: "WebUI request timed out after 25ms",
});
expect(socket.sent).toHaveLength(1);
await vi.advanceTimersByTimeAsync(25);
await pending;
expect(socket.sent).toHaveLength(1);
});
it("rejects in-flight WebUI mutations when the socket closes", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
const socket = lastSocket();
socket.fakeOpen();
const pending = client.requestMutation("session.delete", {
key: "websocket:chat-1",
});
socket.fakeCloseWithCode(1006);
await expect(pending).rejects.toMatchObject({
status: 503,
message: "Socket closed before WebUI response",
});
});
it("does not queue WebUI mutations before the authenticated socket opens", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
await expect(client.requestMutation("settings.agent.update", {})).rejects.toMatchObject({
status: 503,
message: "WebUI connection is not open",
});
expect(lastSocket().sent).toEqual([]);
});
it("keeps temporary chats out of attachment and reconnect state", async () => {
const client = new NanobotClient({
url: "ws://test",
@@ -1071,7 +1187,7 @@ describe("NanobotClient", () => {
expect(client.hasUnsettledRun("chat-scope-control")).toBe(true);
});
it("sends large sidebar ordering state outside the HTTP request line", () => {
it("sends large sidebar ordering state as a correlated WebUI request", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
@@ -1102,11 +1218,29 @@ describe("NanobotClient", () => {
client.connect();
lastSocket().fakeOpen();
client.setSidebarState(state);
const pending = client.setSidebarState(state);
const [serialized] = lastSocket().sent;
expect(new TextEncoder().encode(serialized).byteLength).toBeGreaterThan(8_192);
expect(JSON.parse(serialized)).toEqual({ type: "set_sidebar_state", state });
const request = JSON.parse(serialized) as {
type: string;
request_id: string;
action: string;
payload: { state: SidebarStatePayload };
};
expect(request).toEqual({
type: "webui_request",
request_id: expect.any(String),
action: "sidebar.update",
payload: { state },
});
lastSocket().fakeMessage({
event: "webui_response",
request_id: request.request_id,
ok: true,
result: state,
});
await expect(pending).resolves.toEqual(state);
});
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -109,8 +109,9 @@ describe("useSessions", () => {
]);
vi.mocked(api.deleteSession).mockResolvedValue({ deleted: true });
const client = fakeClient();
const { result } = renderHook(() => useSessions(), {
wrapper: wrap(fakeClient()),
wrapper: wrap(client),
});
await waitFor(() => expect(result.current.sessions).toHaveLength(2));
@@ -119,7 +120,7 @@ describe("useSessions", () => {
await result.current.deleteChat("websocket:chat-a");
});
expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a", undefined);
expect(api.deleteSession).toHaveBeenCalledWith(client, "websocket:chat-a", undefined);
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
});