openclaw/ui/src/ui/app-gateway.ts

249 lines
8.3 KiB
TypeScript

import { loadChatHistory } from "./controllers/chat";
import { loadDevices } from "./controllers/devices";
import { loadNodes } from "./controllers/nodes";
import { loadAgents } from "./controllers/agents";
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway";
import { GatewayBrowserClient } from "./gateway";
import type { EventLogEntry } from "./app-events";
import type { AgentsListResult, PresenceEntry, HealthSnapshot, StatusSummary } from "./types";
import type { Tab } from "./navigation";
import type { UiSettings } from "./storage";
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream";
import { flushChatQueueForEvent } from "./app-chat";
import {
applySettings,
loadCron,
refreshActiveTab,
setLastActiveSessionKey,
} from "./app-settings";
import { handleChatEvent, type ChatEventPayload } from "./controllers/chat";
import {
addExecApproval,
parseExecApprovalRequested,
parseExecApprovalResolved,
removeExecApproval,
} from "./controllers/exec-approval";
import type { ClawdbotApp } from "./app";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
import { loadAssistantIdentity } from "./controllers/assistant-identity";
type GatewayHost = {
settings: UiSettings;
password: string;
client: GatewayBrowserClient | null;
connected: boolean;
hello: GatewayHelloOk | null;
lastError: string | null;
onboarding?: boolean;
eventLogBuffer: EventLogEntry[];
eventLog: EventLogEntry[];
tab: Tab;
presenceEntries: PresenceEntry[];
presenceError: string | null;
presenceStatus: StatusSummary | null;
agentsLoading: boolean;
agentsList: AgentsListResult | null;
agentsError: string | null;
debugHealth: HealthSnapshot | null;
assistantName: string;
assistantAvatar: string | null;
assistantAgentId: string | null;
sessionKey: string;
chatRunId: string | null;
execApprovalQueue: ExecApprovalRequest[];
execApprovalError: string | null;
};
type SessionDefaultsSnapshot = {
defaultAgentId?: string;
mainKey?: string;
mainSessionKey?: string;
scope?: string;
};
function normalizeSessionKeyForDefaults(
value: string | undefined,
defaults: SessionDefaultsSnapshot,
): string {
const raw = (value ?? "").trim();
const mainSessionKey = defaults.mainSessionKey?.trim();
if (!mainSessionKey) return raw;
if (!raw) return mainSessionKey;
const mainKey = defaults.mainKey?.trim() || "main";
const defaultAgentId = defaults.defaultAgentId?.trim();
const isAlias =
raw === "main" ||
raw === mainKey ||
(defaultAgentId &&
(raw === `agent:${defaultAgentId}:main` ||
raw === `agent:${defaultAgentId}:${mainKey}`));
return isAlias ? mainSessionKey : raw;
}
function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnapshot) {
if (!defaults?.mainSessionKey) return;
const resolvedSessionKey = normalizeSessionKeyForDefaults(host.sessionKey, defaults);
const resolvedSettingsSessionKey = normalizeSessionKeyForDefaults(
host.settings.sessionKey,
defaults,
);
const resolvedLastActiveSessionKey = normalizeSessionKeyForDefaults(
host.settings.lastActiveSessionKey,
defaults,
);
const nextSessionKey = resolvedSessionKey || resolvedSettingsSessionKey || host.sessionKey;
const nextSettings = {
...host.settings,
sessionKey: resolvedSettingsSessionKey || nextSessionKey,
lastActiveSessionKey: resolvedLastActiveSessionKey || nextSessionKey,
};
const shouldUpdateSettings =
nextSettings.sessionKey !== host.settings.sessionKey ||
nextSettings.lastActiveSessionKey !== host.settings.lastActiveSessionKey;
if (nextSessionKey !== host.sessionKey) {
host.sessionKey = nextSessionKey;
}
if (shouldUpdateSettings) {
applySettings(host as unknown as Parameters<typeof applySettings>[0], nextSettings);
}
}
export function connectGateway(host: GatewayHost) {
host.lastError = null;
host.hello = null;
host.connected = false;
host.execApprovalQueue = [];
host.execApprovalError = null;
host.client?.stop();
host.client = new GatewayBrowserClient({
url: host.settings.gatewayUrl,
token: host.settings.token.trim() ? host.settings.token : undefined,
password: host.password.trim() ? host.password : undefined,
clientName: "clawdbot-control-ui",
mode: "webchat",
onHello: (hello) => {
host.connected = true;
host.hello = hello;
applySnapshot(host, hello);
void loadAssistantIdentity(host as unknown as ClawdbotApp);
void loadAgents(host as unknown as ClawdbotApp);
void loadNodes(host as unknown as ClawdbotApp, { quiet: true });
void loadDevices(host as unknown as ClawdbotApp, { quiet: true });
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
},
onClose: ({ code, reason }) => {
host.connected = false;
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
},
onEvent: (evt) => handleGatewayEvent(host, evt),
onGap: ({ expected, received }) => {
host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`;
},
});
host.client.start();
}
export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {
try {
handleGatewayEventUnsafe(host, evt);
} catch (err) {
console.error("[gateway] handleGatewayEvent error:", evt.event, err);
}
}
function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
host.eventLogBuffer = [
{ ts: Date.now(), event: evt.event, payload: evt.payload },
...host.eventLogBuffer,
].slice(0, 250);
if (host.tab === "debug") {
host.eventLog = host.eventLogBuffer;
}
if (evt.event === "agent") {
if (host.onboarding) return;
handleAgentEvent(
host as unknown as Parameters<typeof handleAgentEvent>[0],
evt.payload as AgentEventPayload | undefined,
);
return;
}
if (evt.event === "chat") {
const payload = evt.payload as ChatEventPayload | undefined;
if (payload?.sessionKey) {
setLastActiveSessionKey(
host as unknown as Parameters<typeof setLastActiveSessionKey>[0],
payload.sessionKey,
);
}
const state = handleChatEvent(host as unknown as ClawdbotApp, payload);
if (state === "final" || state === "error" || state === "aborted") {
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
void flushChatQueueForEvent(
host as unknown as Parameters<typeof flushChatQueueForEvent>[0],
);
}
if (state === "final") void loadChatHistory(host as unknown as ClawdbotApp);
return;
}
if (evt.event === "presence") {
const payload = evt.payload as { presence?: PresenceEntry[] } | undefined;
if (payload?.presence && Array.isArray(payload.presence)) {
host.presenceEntries = payload.presence;
host.presenceError = null;
host.presenceStatus = null;
}
return;
}
if (evt.event === "cron" && host.tab === "cron") {
void loadCron(host as unknown as Parameters<typeof loadCron>[0]);
}
if (evt.event === "device.pair.requested" || evt.event === "device.pair.resolved") {
void loadDevices(host as unknown as ClawdbotApp, { quiet: true });
}
if (evt.event === "exec.approval.requested") {
const entry = parseExecApprovalRequested(evt.payload);
if (entry) {
host.execApprovalQueue = addExecApproval(host.execApprovalQueue, entry);
host.execApprovalError = null;
const delay = Math.max(0, entry.expiresAtMs - Date.now() + 500);
window.setTimeout(() => {
host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, entry.id);
}, delay);
}
return;
}
if (evt.event === "exec.approval.resolved") {
const resolved = parseExecApprovalResolved(evt.payload);
if (resolved) {
host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, resolved.id);
}
}
}
export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
const snapshot = hello.snapshot as
| {
presence?: PresenceEntry[];
health?: HealthSnapshot;
sessionDefaults?: SessionDefaultsSnapshot;
}
| undefined;
if (snapshot?.presence && Array.isArray(snapshot.presence)) {
host.presenceEntries = snapshot.presence;
}
if (snapshot?.health) {
host.debugHealth = snapshot.health;
}
if (snapshot?.sessionDefaults) {
applySessionDefaults(host, snapshot.sessionDefaults);
}
}