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
+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 }