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; }; type ToolStreamEntry = { toolCallId: string; runId: string; sessionKey?: string; name: string; args?: unknown; output?: string; startedAt: number; updatedAt: number; message: Record; }; function extractToolOutputText(value: unknown): string | null { if (!value || typeof value !== "object") return null; const record = value as Record; 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; 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> = []; @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 | 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 = {}; @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(); 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) { 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 => Boolean(msg)); } private buildToolStreamMessage(entry: ToolStreamEntry): Record { const content: Array> = []; 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); } }