openclaw/ui/src/ui/app.ts
2026-01-05 00:18:18 +00:00

833 lines
24 KiB
TypeScript

import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { GatewayBrowserClient, type GatewayEventFrame, type GatewayHelloOk } from "./gateway";
import { loadSettings, saveSettings, type UiSettings } from "./storage";
import { renderApp } from "./app-render";
import {
inferBasePathFromPathname,
normalizeBasePath,
normalizePath,
pathForTab,
tabFromPath,
type Tab,
} from "./navigation";
import {
resolveTheme,
type ResolvedTheme,
type ThemeMode,
} from "./theme";
import {
startThemeTransition,
type ThemeTransitionContext,
} from "./theme-transition";
import type {
ConfigSnapshot,
ConfigUiHints,
CronJob,
CronRunLogEntry,
CronStatus,
HealthSnapshot,
PresenceEntry,
ProvidersStatusSnapshot,
SessionsListResult,
SkillStatusReport,
StatusSummary,
} from "./types";
import {
defaultDiscordActions,
defaultSlackActions,
type CronFormState,
type DiscordForm,
type IMessageForm,
type SlackForm,
type SignalForm,
type TelegramForm,
} from "./ui-types";
import {
loadChatHistory,
sendChat,
handleChatEvent,
type ChatEventPayload,
} from "./controllers/chat";
import { loadNodes } from "./controllers/nodes";
import {
loadConfig,
loadConfigSchema,
updateConfigFormValue,
} from "./controllers/config";
import {
loadProviders,
logoutWhatsApp,
saveDiscordConfig,
saveIMessageConfig,
saveSlackConfig,
saveSignalConfig,
saveTelegramConfig,
startWhatsAppLogin,
waitWhatsAppLogin,
} from "./controllers/connections";
import { loadPresence } from "./controllers/presence";
import { loadSessions } from "./controllers/sessions";
import {
loadCronJobs,
loadCronStatus,
} from "./controllers/cron";
import {
loadSkills,
} from "./controllers/skills";
import { loadDebug } from "./controllers/debug";
type EventLogEntry = {
ts: number;
event: string;
payload?: unknown;
};
const TOOL_STREAM_LIMIT = 50;
type AgentEventPayload = {
runId: string;
seq: number;
stream: string;
ts: number;
sessionKey?: string;
data: Record<string, unknown>;
};
type ToolStreamEntry = {
toolCallId: string;
runId: string;
sessionKey?: string;
name: string;
args?: unknown;
output?: string;
startedAt: number;
updatedAt: number;
message: Record<string, unknown>;
};
function extractToolOutputText(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
if (typeof record.text === "string") return record.text;
const content = record.content;
if (!Array.isArray(content)) return null;
const parts = content
.map((item) => {
if (!item || typeof item !== "object") return null;
const entry = item as Record<string, unknown>;
if (entry.type === "text" && typeof entry.text === "string") return entry.text;
return null;
})
.filter((part): part is string => Boolean(part));
if (parts.length === 0) return null;
return parts.join("\n");
}
function formatToolOutput(value: unknown): string | null {
if (value === null || value === undefined) return null;
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
const contentText = extractToolOutputText(value);
if (contentText) return contentText;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
declare global {
interface Window {
__CLAWDBOT_CONTROL_UI_BASE_PATH__?: string;
}
}
const DEFAULT_CRON_FORM: CronFormState = {
name: "",
description: "",
enabled: true,
scheduleKind: "every",
scheduleAt: "",
everyAmount: "30",
everyUnit: "minutes",
cronExpr: "0 7 * * *",
cronTz: "",
sessionTarget: "main",
wakeMode: "next-heartbeat",
payloadKind: "systemEvent",
payloadText: "",
deliver: false,
channel: "last",
to: "",
timeoutSeconds: "",
postToMainPrefix: "",
};
@customElement("clawdbot-app")
export class ClawdbotApp extends LitElement {
@state() settings: UiSettings = loadSettings();
@state() password = "";
@state() tab: Tab = "chat";
@state() connected = false;
@state() theme: ThemeMode = this.settings.theme ?? "system";
@state() themeResolved: ResolvedTheme = "dark";
@state() hello: GatewayHelloOk | null = null;
@state() lastError: string | null = null;
@state() eventLog: EventLogEntry[] = [];
@state() sessionKey = this.settings.sessionKey;
@state() chatLoading = false;
@state() chatSending = false;
@state() chatMessage = "";
@state() chatMessages: unknown[] = [];
@state() chatToolMessages: unknown[] = [];
@state() chatStream: string | null = null;
@state() chatRunId: string | null = null;
@state() chatThinkingLevel: string | null = null;
@state() nodesLoading = false;
@state() nodes: Array<Record<string, unknown>> = [];
@state() configLoading = false;
@state() configRaw = "{\n}\n";
@state() configValid: boolean | null = null;
@state() configIssues: unknown[] = [];
@state() configSaving = false;
@state() configSnapshot: ConfigSnapshot | null = null;
@state() configSchema: unknown | null = null;
@state() configSchemaVersion: string | null = null;
@state() configSchemaLoading = false;
@state() configUiHints: ConfigUiHints = {};
@state() configForm: Record<string, unknown> | null = null;
@state() configFormDirty = false;
@state() configFormMode: "form" | "raw" = "form";
@state() providersLoading = false;
@state() providersSnapshot: ProvidersStatusSnapshot | null = null;
@state() providersError: string | null = null;
@state() providersLastSuccess: number | null = null;
@state() whatsappLoginMessage: string | null = null;
@state() whatsappLoginQrDataUrl: string | null = null;
@state() whatsappLoginConnected: boolean | null = null;
@state() whatsappBusy = false;
@state() telegramForm: TelegramForm = {
token: "",
requireMention: true,
allowFrom: "",
proxy: "",
webhookUrl: "",
webhookSecret: "",
webhookPath: "",
};
@state() telegramSaving = false;
@state() telegramTokenLocked = false;
@state() telegramConfigStatus: string | null = null;
@state() discordForm: DiscordForm = {
enabled: true,
token: "",
dmEnabled: true,
allowFrom: "",
groupEnabled: false,
groupChannels: "",
mediaMaxMb: "",
historyLimit: "",
textChunkLimit: "",
guilds: [],
actions: { ...defaultDiscordActions },
slashEnabled: false,
slashName: "",
slashSessionPrefix: "",
slashEphemeral: true,
};
@state() discordSaving = false;
@state() discordTokenLocked = false;
@state() discordConfigStatus: string | null = null;
@state() slackForm: SlackForm = {
enabled: true,
botToken: "",
appToken: "",
dmEnabled: true,
allowFrom: "",
groupEnabled: false,
groupChannels: "",
mediaMaxMb: "",
textChunkLimit: "",
reactionNotifications: "own",
reactionAllowlist: "",
slashEnabled: false,
slashName: "",
slashSessionPrefix: "",
slashEphemeral: true,
actions: { ...defaultSlackActions },
channels: [],
};
@state() slackSaving = false;
@state() slackTokenLocked = false;
@state() slackAppTokenLocked = false;
@state() slackConfigStatus: string | null = null;
@state() signalForm: SignalForm = {
enabled: true,
account: "",
httpUrl: "",
httpHost: "",
httpPort: "",
cliPath: "",
autoStart: true,
receiveMode: "",
ignoreAttachments: false,
ignoreStories: false,
sendReadReceipts: false,
allowFrom: "",
mediaMaxMb: "",
};
@state() signalSaving = false;
@state() signalConfigStatus: string | null = null;
@state() imessageForm: IMessageForm = {
enabled: true,
cliPath: "",
dbPath: "",
service: "auto",
region: "",
allowFrom: "",
includeAttachments: false,
mediaMaxMb: "",
};
@state() imessageSaving = false;
@state() imessageConfigStatus: string | null = null;
@state() presenceLoading = false;
@state() presenceEntries: PresenceEntry[] = [];
@state() presenceError: string | null = null;
@state() presenceStatus: string | null = null;
@state() sessionsLoading = false;
@state() sessionsResult: SessionsListResult | null = null;
@state() sessionsError: string | null = null;
@state() sessionsFilterActive = "";
@state() sessionsFilterLimit = "120";
@state() sessionsIncludeGlobal = true;
@state() sessionsIncludeUnknown = false;
@state() cronLoading = false;
@state() cronJobs: CronJob[] = [];
@state() cronStatus: CronStatus | null = null;
@state() cronError: string | null = null;
@state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM };
@state() cronRunsJobId: string | null = null;
@state() cronRuns: CronRunLogEntry[] = [];
@state() cronBusy = false;
@state() skillsLoading = false;
@state() skillsReport: SkillStatusReport | null = null;
@state() skillsError: string | null = null;
@state() skillsFilter = "";
@state() skillEdits: Record<string, string> = {};
@state() skillsBusyKey: string | null = null;
@state() debugLoading = false;
@state() debugStatus: StatusSummary | null = null;
@state() debugHealth: HealthSnapshot | null = null;
@state() debugModels: unknown[] = [];
@state() debugHeartbeat: unknown | null = null;
@state() debugCallMethod = "";
@state() debugCallParams = "{}";
@state() debugCallResult: string | null = null;
@state() debugCallError: string | null = null;
client: GatewayBrowserClient | null = null;
private chatScrollFrame: number | null = null;
private chatScrollTimeout: number | null = null;
private nodesPollInterval: number | null = null;
private toolStreamById = new Map<string, ToolStreamEntry>();
private toolStreamOrder: string[] = [];
basePath = "";
private popStateHandler = () => this.onPopState();
private themeMedia: MediaQueryList | null = null;
private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
private topbarObserver: ResizeObserver | null = null;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.basePath = this.inferBasePath();
this.syncTabWithLocation(true);
this.syncThemeWithSettings();
this.attachThemeListener();
window.addEventListener("popstate", this.popStateHandler);
this.applySettingsFromUrl();
this.connect();
this.startNodesPolling();
}
protected firstUpdated() {
this.observeTopbar();
}
disconnectedCallback() {
window.removeEventListener("popstate", this.popStateHandler);
this.stopNodesPolling();
this.detachThemeListener();
this.topbarObserver?.disconnect();
this.topbarObserver = null;
super.disconnectedCallback();
}
protected updated(changed: Map<PropertyKey, unknown>) {
if (
this.tab === "chat" &&
(changed.has("chatMessages") ||
changed.has("chatToolMessages") ||
changed.has("chatStream") ||
changed.has("chatLoading") ||
changed.has("chatMessage") ||
changed.has("tab"))
) {
this.scheduleChatScroll();
}
}
connect() {
this.lastError = null;
this.hello = null;
this.connected = false;
this.client?.stop();
this.client = new GatewayBrowserClient({
url: this.settings.gatewayUrl,
token: this.settings.token.trim() ? this.settings.token : undefined,
password: this.password.trim() ? this.password : undefined,
clientName: "clawdbot-control-ui",
mode: "webchat",
onHello: (hello) => {
this.connected = true;
this.hello = hello;
this.applySnapshot(hello);
void loadNodes(this, { quiet: true });
void this.refreshActiveTab();
},
onClose: ({ code, reason }) => {
this.connected = false;
this.lastError = `disconnected (${code}): ${reason || "no reason"}`;
},
onEvent: (evt) => this.onEvent(evt),
onGap: ({ expected, received }) => {
this.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`;
},
});
this.client.start();
}
private scheduleChatScroll() {
if (this.chatScrollFrame) cancelAnimationFrame(this.chatScrollFrame);
if (this.chatScrollTimeout != null) {
clearTimeout(this.chatScrollTimeout);
this.chatScrollTimeout = null;
}
this.chatScrollFrame = requestAnimationFrame(() => {
this.chatScrollFrame = null;
const container = this.querySelector(".chat-thread") as HTMLElement | null;
if (!container) return;
container.scrollTop = container.scrollHeight;
this.chatScrollTimeout = window.setTimeout(() => {
this.chatScrollTimeout = null;
const latest = this.querySelector(".chat-thread") as HTMLElement | null;
if (!latest) return;
latest.scrollTop = latest.scrollHeight;
}, 120);
});
}
private observeTopbar() {
if (typeof ResizeObserver === "undefined") return;
const topbar = this.querySelector(".topbar");
if (!topbar) return;
const update = () => {
const { height } = topbar.getBoundingClientRect();
this.style.setProperty("--topbar-height", `${height}px`);
};
update();
this.topbarObserver = new ResizeObserver(() => update());
this.topbarObserver.observe(topbar);
}
private startNodesPolling() {
if (this.nodesPollInterval != null) return;
this.nodesPollInterval = window.setInterval(
() => void loadNodes(this, { quiet: true }),
5000,
);
}
private stopNodesPolling() {
if (this.nodesPollInterval == null) return;
clearInterval(this.nodesPollInterval);
this.nodesPollInterval = null;
}
resetToolStream() {
this.toolStreamById.clear();
this.toolStreamOrder = [];
this.chatToolMessages = [];
}
private trimToolStream() {
if (this.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return;
const overflow = this.toolStreamOrder.length - TOOL_STREAM_LIMIT;
const removed = this.toolStreamOrder.splice(0, overflow);
for (const id of removed) this.toolStreamById.delete(id);
}
private syncToolStreamMessages() {
this.chatToolMessages = this.toolStreamOrder
.map((id) => this.toolStreamById.get(id)?.message)
.filter((msg): msg is Record<string, unknown> => Boolean(msg));
}
private buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown> {
const content: Array<Record<string, unknown>> = [];
content.push({
type: "toolcall",
name: entry.name,
arguments: entry.args ?? {},
});
if (entry.output) {
content.push({
type: "toolresult",
name: entry.name,
text: entry.output,
});
}
return {
role: "assistant",
toolCallId: entry.toolCallId,
runId: entry.runId,
content,
timestamp: entry.startedAt,
};
}
private handleAgentEvent(payload?: AgentEventPayload) {
if (!payload || payload.stream !== "tool") return;
const sessionKey =
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
if (sessionKey && sessionKey !== this.sessionKey) return;
// Fallback: only accept session-less events for the active run.
if (!sessionKey && this.chatRunId && payload.runId !== this.chatRunId) return;
const data = payload.data ?? {};
const toolCallId =
typeof data.toolCallId === "string" ? data.toolCallId : "";
if (!toolCallId) return;
const name = typeof data.name === "string" ? data.name : "tool";
const phase = typeof data.phase === "string" ? data.phase : "";
const args = phase === "start" ? data.args : undefined;
const output =
phase === "update"
? formatToolOutput(data.partialResult)
: phase === "result"
? formatToolOutput(data.result)
: undefined;
const now = Date.now();
let entry = this.toolStreamById.get(toolCallId);
if (!entry) {
entry = {
toolCallId,
runId: payload.runId,
sessionKey,
name,
args,
output,
startedAt: typeof payload.ts === "number" ? payload.ts : now,
updatedAt: now,
message: {},
};
this.toolStreamById.set(toolCallId, entry);
this.toolStreamOrder.push(toolCallId);
} else {
entry.name = name;
if (args !== undefined) entry.args = args;
if (output !== undefined) entry.output = output;
entry.updatedAt = now;
}
entry.message = this.buildToolStreamMessage(entry);
this.trimToolStream();
this.syncToolStreamMessages();
}
private onEvent(evt: GatewayEventFrame) {
this.eventLog = [
{ ts: Date.now(), event: evt.event, payload: evt.payload },
...this.eventLog,
].slice(0, 250);
if (evt.event === "agent") {
this.handleAgentEvent(evt.payload as AgentEventPayload | undefined);
return;
}
if (evt.event === "chat") {
const payload = evt.payload as ChatEventPayload | undefined;
const state = handleChatEvent(this, payload);
if (state === "final") void loadChatHistory(this);
return;
}
if (evt.event === "presence") {
const payload = evt.payload as { presence?: PresenceEntry[] } | undefined;
if (payload?.presence && Array.isArray(payload.presence)) {
this.presenceEntries = payload.presence;
this.presenceError = null;
this.presenceStatus = null;
}
return;
}
if (evt.event === "cron" && this.tab === "cron") {
void this.loadCron();
}
}
private applySnapshot(hello: GatewayHelloOk) {
const snapshot = hello.snapshot as
| { presence?: PresenceEntry[]; health?: HealthSnapshot }
| undefined;
if (snapshot?.presence && Array.isArray(snapshot.presence)) {
this.presenceEntries = snapshot.presence;
}
if (snapshot?.health) {
this.debugHealth = snapshot.health;
}
}
applySettings(next: UiSettings) {
this.settings = next;
saveSettings(next);
if (next.theme !== this.theme) {
this.theme = next.theme;
this.applyResolvedTheme(resolveTheme(next.theme));
}
}
private applySettingsFromUrl() {
if (!window.location.search) return;
const params = new URLSearchParams(window.location.search);
const token = params.get("token")?.trim();
if (!token) return;
if (!this.settings.token) {
this.applySettings({ ...this.settings, token });
}
params.delete("token");
const url = new URL(window.location.href);
url.search = params.toString();
window.history.replaceState({}, "", url.toString());
}
setTab(next: Tab) {
if (this.tab !== next) this.tab = next;
void this.refreshActiveTab();
this.syncUrlWithTab(next, false);
}
setTheme(next: ThemeMode, context?: ThemeTransitionContext) {
const applyTheme = () => {
this.theme = next;
this.applySettings({ ...this.settings, theme: next });
this.applyResolvedTheme(resolveTheme(next));
};
startThemeTransition({
nextTheme: next,
applyTheme,
context,
currentTheme: this.theme,
});
}
private async refreshActiveTab() {
if (this.tab === "overview") await this.loadOverview();
if (this.tab === "connections") await this.loadConnections();
if (this.tab === "instances") await loadPresence(this);
if (this.tab === "sessions") await loadSessions(this);
if (this.tab === "cron") await this.loadCron();
if (this.tab === "skills") await loadSkills(this);
if (this.tab === "nodes") await loadNodes(this);
if (this.tab === "chat") {
await Promise.all([loadChatHistory(this), loadSessions(this)]);
this.scheduleChatScroll();
}
if (this.tab === "config") {
await loadConfigSchema(this);
await loadConfig(this);
}
if (this.tab === "debug") await loadDebug(this);
}
private inferBasePath() {
if (typeof window === "undefined") return "";
const configured = window.__CLAWDBOT_CONTROL_UI_BASE_PATH__;
if (typeof configured === "string" && configured.trim()) {
return normalizeBasePath(configured);
}
return inferBasePathFromPathname(window.location.pathname);
}
private syncThemeWithSettings() {
this.theme = this.settings.theme ?? "system";
this.applyResolvedTheme(resolveTheme(this.theme));
}
private applyResolvedTheme(resolved: ResolvedTheme) {
this.themeResolved = resolved;
if (typeof document === "undefined") return;
const root = document.documentElement;
root.dataset.theme = resolved;
root.style.colorScheme = resolved;
}
private attachThemeListener() {
if (typeof window === "undefined" || typeof window.matchMedia !== "function")
return;
this.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
this.themeMediaHandler = (event) => {
if (this.theme !== "system") return;
this.applyResolvedTheme(event.matches ? "dark" : "light");
};
if (typeof this.themeMedia.addEventListener === "function") {
this.themeMedia.addEventListener("change", this.themeMediaHandler);
return;
}
const legacy = this.themeMedia as MediaQueryList & {
addListener: (cb: (event: MediaQueryListEvent) => void) => void;
};
legacy.addListener(this.themeMediaHandler);
}
private detachThemeListener() {
if (!this.themeMedia || !this.themeMediaHandler) return;
if (typeof this.themeMedia.removeEventListener === "function") {
this.themeMedia.removeEventListener("change", this.themeMediaHandler);
return;
}
const legacy = this.themeMedia as MediaQueryList & {
removeListener: (cb: (event: MediaQueryListEvent) => void) => void;
};
legacy.removeListener(this.themeMediaHandler);
this.themeMedia = null;
this.themeMediaHandler = null;
}
private syncTabWithLocation(replace: boolean) {
if (typeof window === "undefined") return;
const resolved = tabFromPath(window.location.pathname, this.basePath) ?? "chat";
this.setTabFromRoute(resolved);
this.syncUrlWithTab(resolved, replace);
}
private onPopState() {
if (typeof window === "undefined") return;
const resolved = tabFromPath(window.location.pathname, this.basePath);
if (!resolved) return;
this.setTabFromRoute(resolved);
}
private setTabFromRoute(next: Tab) {
if (this.tab !== next) this.tab = next;
if (this.connected) void this.refreshActiveTab();
}
private syncUrlWithTab(tab: Tab, replace: boolean) {
if (typeof window === "undefined") return;
const targetPath = normalizePath(pathForTab(tab, this.basePath));
const currentPath = normalizePath(window.location.pathname);
if (currentPath === targetPath) return;
const url = new URL(window.location.href);
url.pathname = targetPath;
if (replace) {
window.history.replaceState({}, "", url.toString());
} else {
window.history.pushState({}, "", url.toString());
}
}
async loadOverview() {
await Promise.all([
loadProviders(this, false),
loadPresence(this),
loadSessions(this),
loadCronStatus(this),
loadDebug(this),
]);
}
private async loadConnections() {
await Promise.all([loadProviders(this, true), loadConfig(this)]);
}
async loadCron() {
await Promise.all([loadCronStatus(this), loadCronJobs(this)]);
}
async handleSendChat() {
if (!this.connected) return;
const ok = await sendChat(this);
if (ok) void loadChatHistory(this);
this.scheduleChatScroll();
}
async handleWhatsAppStart(force: boolean) {
await startWhatsAppLogin(this, force);
await loadProviders(this, true);
}
async handleWhatsAppWait() {
await waitWhatsAppLogin(this);
await loadProviders(this, true);
}
async handleWhatsAppLogout() {
await logoutWhatsApp(this);
await loadProviders(this, true);
}
async handleTelegramSave() {
await saveTelegramConfig(this);
await loadConfig(this);
await loadProviders(this, true);
}
async handleDiscordSave() {
await saveDiscordConfig(this);
await loadConfig(this);
await loadProviders(this, true);
}
async handleSlackSave() {
await saveSlackConfig(this);
await loadConfig(this);
await loadProviders(this, true);
}
async handleSignalSave() {
await saveSignalConfig(this);
await loadConfig(this);
await loadProviders(this, true);
}
async handleIMessageSave() {
await saveIMessageConfig(this);
await loadConfig(this);
await loadProviders(this, true);
}
render() {
return renderApp(this);
}
}