refactor(ui): split app modules
This commit is contained in:
131
ui/src/ui/app-chat.ts
Normal file
131
ui/src/ui/app-chat.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat";
|
||||||
|
import { loadSessions } from "./controllers/sessions";
|
||||||
|
import { generateUUID } from "./uuid";
|
||||||
|
import { resetToolStream } from "./app-tool-stream";
|
||||||
|
import { scheduleChatScroll } from "./app-scroll";
|
||||||
|
import { setLastActiveSessionKey } from "./app-settings";
|
||||||
|
import type { ClawdbotApp } from "./app";
|
||||||
|
|
||||||
|
type ChatHost = {
|
||||||
|
connected: boolean;
|
||||||
|
chatMessage: string;
|
||||||
|
chatQueue: Array<{ id: string; text: string; createdAt: number }>;
|
||||||
|
chatRunId: string | null;
|
||||||
|
chatSending: boolean;
|
||||||
|
sessionKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isChatBusy(host: ChatHost) {
|
||||||
|
return host.chatSending || Boolean(host.chatRunId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isChatStopCommand(text: string) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
const normalized = trimmed.toLowerCase();
|
||||||
|
if (normalized === "/stop") return true;
|
||||||
|
return (
|
||||||
|
normalized === "stop" ||
|
||||||
|
normalized === "esc" ||
|
||||||
|
normalized === "abort" ||
|
||||||
|
normalized === "wait" ||
|
||||||
|
normalized === "exit"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAbortChat(host: ChatHost) {
|
||||||
|
if (!host.connected) return;
|
||||||
|
host.chatMessage = "";
|
||||||
|
await abortChatRun(host as unknown as ClawdbotApp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueChatMessage(host: ChatHost, text: string) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
host.chatQueue = [
|
||||||
|
...host.chatQueue,
|
||||||
|
{
|
||||||
|
id: generateUUID(),
|
||||||
|
text: trimmed,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendChatMessageNow(
|
||||||
|
host: ChatHost,
|
||||||
|
message: string,
|
||||||
|
opts?: { previousDraft?: string; restoreDraft?: boolean },
|
||||||
|
) {
|
||||||
|
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
||||||
|
const ok = await sendChatMessage(host as unknown as ClawdbotApp, message);
|
||||||
|
if (!ok && opts?.previousDraft != null) {
|
||||||
|
host.chatMessage = opts.previousDraft;
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
setLastActiveSessionKey(host as unknown as Parameters<typeof setLastActiveSessionKey>[0], host.sessionKey);
|
||||||
|
}
|
||||||
|
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
|
||||||
|
host.chatMessage = opts.previousDraft;
|
||||||
|
}
|
||||||
|
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
|
||||||
|
if (ok && !host.chatRunId) {
|
||||||
|
void flushChatQueue(host);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushChatQueue(host: ChatHost) {
|
||||||
|
if (!host.connected || isChatBusy(host)) return;
|
||||||
|
const [next, ...rest] = host.chatQueue;
|
||||||
|
if (!next) return;
|
||||||
|
host.chatQueue = rest;
|
||||||
|
const ok = await sendChatMessageNow(host, next.text);
|
||||||
|
if (!ok) {
|
||||||
|
host.chatQueue = [next, ...host.chatQueue];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeQueuedMessage(host: ChatHost, id: string) {
|
||||||
|
host.chatQueue = host.chatQueue.filter((item) => item.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleSendChat(
|
||||||
|
host: ChatHost,
|
||||||
|
messageOverride?: string,
|
||||||
|
opts?: { restoreDraft?: boolean },
|
||||||
|
) {
|
||||||
|
if (!host.connected) return;
|
||||||
|
const previousDraft = host.chatMessage;
|
||||||
|
const message = (messageOverride ?? host.chatMessage).trim();
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
if (isChatStopCommand(message)) {
|
||||||
|
await handleAbortChat(host);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageOverride == null) {
|
||||||
|
host.chatMessage = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isChatBusy(host)) {
|
||||||
|
enqueueChatMessage(host, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendChatMessageNow(host, message, {
|
||||||
|
previousDraft: messageOverride == null ? previousDraft : undefined,
|
||||||
|
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshChat(host: ChatHost) {
|
||||||
|
await Promise.all([
|
||||||
|
loadChatHistory(host as unknown as ClawdbotApp),
|
||||||
|
loadSessions(host as unknown as ClawdbotApp),
|
||||||
|
]);
|
||||||
|
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const flushChatQueueForEvent = flushChatQueue;
|
||||||
58
ui/src/ui/app-connections.ts
Normal file
58
ui/src/ui/app-connections.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
loadChannels,
|
||||||
|
logoutWhatsApp,
|
||||||
|
saveDiscordConfig,
|
||||||
|
saveIMessageConfig,
|
||||||
|
saveSlackConfig,
|
||||||
|
saveSignalConfig,
|
||||||
|
saveTelegramConfig,
|
||||||
|
startWhatsAppLogin,
|
||||||
|
waitWhatsAppLogin,
|
||||||
|
} from "./controllers/connections";
|
||||||
|
import { loadConfig } from "./controllers/config";
|
||||||
|
import type { ClawdbotApp } from "./app";
|
||||||
|
|
||||||
|
export async function handleWhatsAppStart(host: ClawdbotApp, force: boolean) {
|
||||||
|
await startWhatsAppLogin(host, force);
|
||||||
|
await loadChannels(host, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleWhatsAppWait(host: ClawdbotApp) {
|
||||||
|
await waitWhatsAppLogin(host);
|
||||||
|
await loadChannels(host, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleWhatsAppLogout(host: ClawdbotApp) {
|
||||||
|
await logoutWhatsApp(host);
|
||||||
|
await loadChannels(host, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleTelegramSave(host: ClawdbotApp) {
|
||||||
|
await saveTelegramConfig(host);
|
||||||
|
await loadConfig(host);
|
||||||
|
await loadChannels(host, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDiscordSave(host: ClawdbotApp) {
|
||||||
|
await saveDiscordConfig(host);
|
||||||
|
await loadConfig(host);
|
||||||
|
await loadChannels(host, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleSlackSave(host: ClawdbotApp) {
|
||||||
|
await saveSlackConfig(host);
|
||||||
|
await loadConfig(host);
|
||||||
|
await loadChannels(host, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleSignalSave(host: ClawdbotApp) {
|
||||||
|
await saveSignalConfig(host);
|
||||||
|
await loadConfig(host);
|
||||||
|
await loadChannels(host, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleIMessageSave(host: ClawdbotApp) {
|
||||||
|
await saveIMessageConfig(host);
|
||||||
|
await loadConfig(host);
|
||||||
|
await loadChannels(host, true);
|
||||||
|
}
|
||||||
33
ui/src/ui/app-defaults.ts
Normal file
33
ui/src/ui/app-defaults.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { LogLevel } from "./types";
|
||||||
|
import type { CronFormState } from "./ui-types";
|
||||||
|
|
||||||
|
export const DEFAULT_LOG_LEVEL_FILTERS: Record<LogLevel, boolean> = {
|
||||||
|
trace: true,
|
||||||
|
debug: true,
|
||||||
|
info: true,
|
||||||
|
warn: true,
|
||||||
|
error: true,
|
||||||
|
fatal: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_CRON_FORM: CronFormState = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
agentId: "",
|
||||||
|
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: "",
|
||||||
|
};
|
||||||
124
ui/src/ui/app-gateway.ts
Normal file
124
ui/src/ui/app-gateway.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { loadChatHistory } from "./controllers/chat";
|
||||||
|
import { loadNodes } from "./controllers/nodes";
|
||||||
|
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway";
|
||||||
|
import { GatewayBrowserClient } from "./gateway";
|
||||||
|
import type { EventLogEntry } from "./app-events";
|
||||||
|
import type { PresenceEntry, HealthSnapshot, StatusSummary } from "./types";
|
||||||
|
import type { Tab } from "./navigation";
|
||||||
|
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream";
|
||||||
|
import { flushChatQueueForEvent } from "./app-chat";
|
||||||
|
import { loadCron, refreshActiveTab, setLastActiveSessionKey } from "./app-settings";
|
||||||
|
import { handleChatEvent, type ChatEventPayload } from "./controllers/chat";
|
||||||
|
import type { ClawdbotApp } from "./app";
|
||||||
|
|
||||||
|
type GatewayHost = {
|
||||||
|
settings: { gatewayUrl: string; token: string };
|
||||||
|
password: string;
|
||||||
|
client: GatewayBrowserClient | null;
|
||||||
|
connected: boolean;
|
||||||
|
hello: GatewayHelloOk | null;
|
||||||
|
lastError: string | null;
|
||||||
|
eventLogBuffer: EventLogEntry[];
|
||||||
|
eventLog: EventLogEntry[];
|
||||||
|
tab: Tab;
|
||||||
|
presenceEntries: PresenceEntry[];
|
||||||
|
presenceError: string | null;
|
||||||
|
presenceStatus: StatusSummary | null;
|
||||||
|
debugHealth: HealthSnapshot | null;
|
||||||
|
sessionKey: string;
|
||||||
|
chatRunId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function connectGateway(host: GatewayHost) {
|
||||||
|
host.lastError = null;
|
||||||
|
host.hello = null;
|
||||||
|
host.connected = false;
|
||||||
|
|
||||||
|
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 loadNodes(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) {
|
||||||
|
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") {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
|
||||||
|
const snapshot = hello.snapshot as
|
||||||
|
| { presence?: PresenceEntry[]; health?: HealthSnapshot }
|
||||||
|
| undefined;
|
||||||
|
if (snapshot?.presence && Array.isArray(snapshot.presence)) {
|
||||||
|
host.presenceEntries = snapshot.presence;
|
||||||
|
}
|
||||||
|
if (snapshot?.health) {
|
||||||
|
host.debugHealth = snapshot.health;
|
||||||
|
}
|
||||||
|
}
|
||||||
105
ui/src/ui/app-lifecycle.ts
Normal file
105
ui/src/ui/app-lifecycle.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { Tab } from "./navigation";
|
||||||
|
import { connectGateway } from "./app-gateway";
|
||||||
|
import {
|
||||||
|
applySettingsFromUrl,
|
||||||
|
attachThemeListener,
|
||||||
|
detachThemeListener,
|
||||||
|
inferBasePath,
|
||||||
|
syncTabWithLocation,
|
||||||
|
syncThemeWithSettings,
|
||||||
|
} from "./app-settings";
|
||||||
|
import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
|
||||||
|
import {
|
||||||
|
startLogsPolling,
|
||||||
|
startNodesPolling,
|
||||||
|
stopLogsPolling,
|
||||||
|
stopNodesPolling,
|
||||||
|
} from "./app-polling";
|
||||||
|
|
||||||
|
type LifecycleHost = {
|
||||||
|
basePath: string;
|
||||||
|
tab: Tab;
|
||||||
|
chatHasAutoScrolled: boolean;
|
||||||
|
chatLoading: boolean;
|
||||||
|
chatMessages: unknown[];
|
||||||
|
chatToolMessages: unknown[];
|
||||||
|
chatStream: string;
|
||||||
|
logsAutoFollow: boolean;
|
||||||
|
logsAtBottom: boolean;
|
||||||
|
logsEntries: unknown[];
|
||||||
|
popStateHandler: () => void;
|
||||||
|
topbarObserver: ResizeObserver | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function handleConnected(host: LifecycleHost) {
|
||||||
|
host.basePath = inferBasePath();
|
||||||
|
syncTabWithLocation(
|
||||||
|
host as unknown as Parameters<typeof syncTabWithLocation>[0],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
syncThemeWithSettings(
|
||||||
|
host as unknown as Parameters<typeof syncThemeWithSettings>[0],
|
||||||
|
);
|
||||||
|
attachThemeListener(
|
||||||
|
host as unknown as Parameters<typeof attachThemeListener>[0],
|
||||||
|
);
|
||||||
|
window.addEventListener("popstate", host.popStateHandler);
|
||||||
|
applySettingsFromUrl(
|
||||||
|
host as unknown as Parameters<typeof applySettingsFromUrl>[0],
|
||||||
|
);
|
||||||
|
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
|
||||||
|
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
|
||||||
|
if (host.tab === "logs") {
|
||||||
|
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleFirstUpdated(host: LifecycleHost) {
|
||||||
|
observeTopbar(host as unknown as Parameters<typeof observeTopbar>[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleDisconnected(host: LifecycleHost) {
|
||||||
|
window.removeEventListener("popstate", host.popStateHandler);
|
||||||
|
stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
|
||||||
|
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||||
|
detachThemeListener(
|
||||||
|
host as unknown as Parameters<typeof detachThemeListener>[0],
|
||||||
|
);
|
||||||
|
host.topbarObserver?.disconnect();
|
||||||
|
host.topbarObserver = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleUpdated(
|
||||||
|
host: LifecycleHost,
|
||||||
|
changed: Map<PropertyKey, unknown>,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
host.tab === "chat" &&
|
||||||
|
(changed.has("chatMessages") ||
|
||||||
|
changed.has("chatToolMessages") ||
|
||||||
|
changed.has("chatStream") ||
|
||||||
|
changed.has("chatLoading") ||
|
||||||
|
changed.has("tab"))
|
||||||
|
) {
|
||||||
|
const forcedByTab = changed.has("tab");
|
||||||
|
const forcedByLoad =
|
||||||
|
changed.has("chatLoading") &&
|
||||||
|
changed.get("chatLoading") === true &&
|
||||||
|
host.chatLoading === false;
|
||||||
|
scheduleChatScroll(
|
||||||
|
host as unknown as Parameters<typeof scheduleChatScroll>[0],
|
||||||
|
forcedByTab || forcedByLoad || !host.chatHasAutoScrolled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
host.tab === "logs" &&
|
||||||
|
(changed.has("logsEntries") || changed.has("logsAutoFollow") || changed.has("tab"))
|
||||||
|
) {
|
||||||
|
if (host.logsAutoFollow && host.logsAtBottom) {
|
||||||
|
scheduleLogsScroll(
|
||||||
|
host as unknown as Parameters<typeof scheduleLogsScroll>[0],
|
||||||
|
changed.has("tab") || changed.has("logsAutoFollow"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
ui/src/ui/app-polling.ts
Normal file
37
ui/src/ui/app-polling.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { loadLogs } from "./controllers/logs";
|
||||||
|
import { loadNodes } from "./controllers/nodes";
|
||||||
|
import type { ClawdbotApp } from "./app";
|
||||||
|
|
||||||
|
type PollingHost = {
|
||||||
|
nodesPollInterval: number | null;
|
||||||
|
logsPollInterval: number | null;
|
||||||
|
tab: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function startNodesPolling(host: PollingHost) {
|
||||||
|
if (host.nodesPollInterval != null) return;
|
||||||
|
host.nodesPollInterval = window.setInterval(
|
||||||
|
() => void loadNodes(host as unknown as ClawdbotApp, { quiet: true }),
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopNodesPolling(host: PollingHost) {
|
||||||
|
if (host.nodesPollInterval == null) return;
|
||||||
|
clearInterval(host.nodesPollInterval);
|
||||||
|
host.nodesPollInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startLogsPolling(host: PollingHost) {
|
||||||
|
if (host.logsPollInterval != null) return;
|
||||||
|
host.logsPollInterval = window.setInterval(() => {
|
||||||
|
if (host.tab !== "logs") return;
|
||||||
|
void loadLogs(host as unknown as ClawdbotApp, { quiet: true });
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopLogsPolling(host: PollingHost) {
|
||||||
|
if (host.logsPollInterval == null) return;
|
||||||
|
clearInterval(host.logsPollInterval);
|
||||||
|
host.logsPollInterval = null;
|
||||||
|
}
|
||||||
122
ui/src/ui/app-scroll.ts
Normal file
122
ui/src/ui/app-scroll.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
type ScrollHost = {
|
||||||
|
updateComplete: Promise<unknown>;
|
||||||
|
querySelector: (selectors: string) => Element | null;
|
||||||
|
style: CSSStyleDeclaration;
|
||||||
|
chatScrollFrame: number | null;
|
||||||
|
chatScrollTimeout: number | null;
|
||||||
|
chatHasAutoScrolled: boolean;
|
||||||
|
chatUserNearBottom: boolean;
|
||||||
|
logsScrollFrame: number | null;
|
||||||
|
logsAtBottom: boolean;
|
||||||
|
topbarObserver: ResizeObserver | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function scheduleChatScroll(host: ScrollHost, force = false) {
|
||||||
|
if (host.chatScrollFrame) cancelAnimationFrame(host.chatScrollFrame);
|
||||||
|
if (host.chatScrollTimeout != null) {
|
||||||
|
clearTimeout(host.chatScrollTimeout);
|
||||||
|
host.chatScrollTimeout = null;
|
||||||
|
}
|
||||||
|
const pickScrollTarget = () => {
|
||||||
|
const container = host.querySelector(".chat-thread") as HTMLElement | null;
|
||||||
|
if (container) {
|
||||||
|
const overflowY = getComputedStyle(container).overflowY;
|
||||||
|
const canScroll =
|
||||||
|
overflowY === "auto" ||
|
||||||
|
overflowY === "scroll" ||
|
||||||
|
container.scrollHeight - container.clientHeight > 1;
|
||||||
|
if (canScroll) return container;
|
||||||
|
}
|
||||||
|
return (document.scrollingElement ?? document.documentElement) as HTMLElement | null;
|
||||||
|
};
|
||||||
|
// Wait for Lit render to complete, then scroll
|
||||||
|
void host.updateComplete.then(() => {
|
||||||
|
host.chatScrollFrame = requestAnimationFrame(() => {
|
||||||
|
host.chatScrollFrame = null;
|
||||||
|
const target = pickScrollTarget();
|
||||||
|
if (!target) return;
|
||||||
|
const distanceFromBottom =
|
||||||
|
target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||||
|
const shouldStick = force || host.chatUserNearBottom || distanceFromBottom < 200;
|
||||||
|
if (!shouldStick) return;
|
||||||
|
if (force) host.chatHasAutoScrolled = true;
|
||||||
|
target.scrollTop = target.scrollHeight;
|
||||||
|
host.chatUserNearBottom = true;
|
||||||
|
const retryDelay = force ? 150 : 120;
|
||||||
|
host.chatScrollTimeout = window.setTimeout(() => {
|
||||||
|
host.chatScrollTimeout = null;
|
||||||
|
const latest = pickScrollTarget();
|
||||||
|
if (!latest) return;
|
||||||
|
const latestDistanceFromBottom =
|
||||||
|
latest.scrollHeight - latest.scrollTop - latest.clientHeight;
|
||||||
|
const shouldStickRetry =
|
||||||
|
force || host.chatUserNearBottom || latestDistanceFromBottom < 200;
|
||||||
|
if (!shouldStickRetry) return;
|
||||||
|
latest.scrollTop = latest.scrollHeight;
|
||||||
|
host.chatUserNearBottom = true;
|
||||||
|
}, retryDelay);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleLogsScroll(host: ScrollHost, force = false) {
|
||||||
|
if (host.logsScrollFrame) cancelAnimationFrame(host.logsScrollFrame);
|
||||||
|
void host.updateComplete.then(() => {
|
||||||
|
host.logsScrollFrame = requestAnimationFrame(() => {
|
||||||
|
host.logsScrollFrame = null;
|
||||||
|
const container = host.querySelector(".log-stream") as HTMLElement | null;
|
||||||
|
if (!container) return;
|
||||||
|
const distanceFromBottom =
|
||||||
|
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
const shouldStick = force || distanceFromBottom < 80;
|
||||||
|
if (!shouldStick) return;
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleChatScroll(host: ScrollHost, event: Event) {
|
||||||
|
const container = event.currentTarget as HTMLElement | null;
|
||||||
|
if (!container) return;
|
||||||
|
const distanceFromBottom =
|
||||||
|
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
host.chatUserNearBottom = distanceFromBottom < 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleLogsScroll(host: ScrollHost, event: Event) {
|
||||||
|
const container = event.currentTarget as HTMLElement | null;
|
||||||
|
if (!container) return;
|
||||||
|
const distanceFromBottom =
|
||||||
|
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
host.logsAtBottom = distanceFromBottom < 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetChatScroll(host: ScrollHost) {
|
||||||
|
host.chatHasAutoScrolled = false;
|
||||||
|
host.chatUserNearBottom = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportLogs(lines: string[], label: string) {
|
||||||
|
if (lines.length === 0) return;
|
||||||
|
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `clawdbot-logs-${label}-${stamp}.log`;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function observeTopbar(host: ScrollHost) {
|
||||||
|
if (typeof ResizeObserver === "undefined") return;
|
||||||
|
const topbar = host.querySelector(".topbar");
|
||||||
|
if (!topbar) return;
|
||||||
|
const update = () => {
|
||||||
|
const { height } = topbar.getBoundingClientRect();
|
||||||
|
host.style.setProperty("--topbar-height", `${height}px`);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
host.topbarObserver = new ResizeObserver(() => update());
|
||||||
|
host.topbarObserver.observe(topbar);
|
||||||
|
}
|
||||||
271
ui/src/ui/app-settings.ts
Normal file
271
ui/src/ui/app-settings.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { loadConfig, loadConfigSchema } from "./controllers/config";
|
||||||
|
import { loadCronJobs, loadCronStatus } from "./controllers/cron";
|
||||||
|
import { loadChannels } from "./controllers/connections";
|
||||||
|
import { loadDebug } from "./controllers/debug";
|
||||||
|
import { loadLogs } from "./controllers/logs";
|
||||||
|
import { loadNodes } from "./controllers/nodes";
|
||||||
|
import { loadPresence } from "./controllers/presence";
|
||||||
|
import { loadSessions } from "./controllers/sessions";
|
||||||
|
import { loadSkills } from "./controllers/skills";
|
||||||
|
import { inferBasePathFromPathname, normalizeBasePath, normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation";
|
||||||
|
import { saveSettings, type UiSettings } from "./storage";
|
||||||
|
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
|
||||||
|
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
|
||||||
|
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
|
||||||
|
import { startLogsPolling, stopLogsPolling } from "./app-polling";
|
||||||
|
import { refreshChat } from "./app-chat";
|
||||||
|
import type { ClawdbotApp } from "./app";
|
||||||
|
|
||||||
|
type SettingsHost = {
|
||||||
|
settings: UiSettings;
|
||||||
|
theme: ThemeMode;
|
||||||
|
themeResolved: ResolvedTheme;
|
||||||
|
applySessionKey: string;
|
||||||
|
sessionKey: string;
|
||||||
|
tab: Tab;
|
||||||
|
connected: boolean;
|
||||||
|
chatHasAutoScrolled: boolean;
|
||||||
|
logsAtBottom: boolean;
|
||||||
|
eventLog: unknown[];
|
||||||
|
eventLogBuffer: unknown[];
|
||||||
|
basePath: string;
|
||||||
|
themeMedia: MediaQueryList | null;
|
||||||
|
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function applySettings(host: SettingsHost, next: UiSettings) {
|
||||||
|
const normalized = {
|
||||||
|
...next,
|
||||||
|
lastActiveSessionKey: next.lastActiveSessionKey?.trim() || next.sessionKey.trim() || "main",
|
||||||
|
};
|
||||||
|
host.settings = normalized;
|
||||||
|
saveSettings(normalized);
|
||||||
|
if (next.theme !== host.theme) {
|
||||||
|
host.theme = next.theme;
|
||||||
|
applyResolvedTheme(host, resolveTheme(next.theme));
|
||||||
|
}
|
||||||
|
host.applySessionKey = host.settings.lastActiveSessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLastActiveSessionKey(host: SettingsHost, next: string) {
|
||||||
|
const trimmed = next.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
if (host.settings.lastActiveSessionKey === trimmed) return;
|
||||||
|
applySettings(host, { ...host.settings, lastActiveSessionKey: trimmed });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySettingsFromUrl(host: SettingsHost) {
|
||||||
|
if (!window.location.search) return;
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const tokenRaw = params.get("token");
|
||||||
|
const passwordRaw = params.get("password");
|
||||||
|
const sessionRaw = params.get("session");
|
||||||
|
let shouldCleanUrl = false;
|
||||||
|
|
||||||
|
if (tokenRaw != null) {
|
||||||
|
const token = tokenRaw.trim();
|
||||||
|
if (token && !host.settings.token) {
|
||||||
|
applySettings(host, { ...host.settings, token });
|
||||||
|
}
|
||||||
|
params.delete("token");
|
||||||
|
shouldCleanUrl = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordRaw != null) {
|
||||||
|
const password = passwordRaw.trim();
|
||||||
|
if (password) {
|
||||||
|
(host as { password: string }).password = password;
|
||||||
|
}
|
||||||
|
params.delete("password");
|
||||||
|
shouldCleanUrl = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionRaw != null) {
|
||||||
|
const session = sessionRaw.trim();
|
||||||
|
if (session) {
|
||||||
|
host.sessionKey = session;
|
||||||
|
}
|
||||||
|
params.delete("session");
|
||||||
|
shouldCleanUrl = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldCleanUrl) return;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.search = params.toString();
|
||||||
|
window.history.replaceState({}, "", url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTab(host: SettingsHost, next: Tab) {
|
||||||
|
if (host.tab !== next) host.tab = next;
|
||||||
|
if (next === "chat") host.chatHasAutoScrolled = false;
|
||||||
|
if (next === "logs")
|
||||||
|
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||||
|
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||||
|
void refreshActiveTab(host);
|
||||||
|
syncUrlWithTab(host, next, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTheme(
|
||||||
|
host: SettingsHost,
|
||||||
|
next: ThemeMode,
|
||||||
|
context?: ThemeTransitionContext,
|
||||||
|
) {
|
||||||
|
const applyTheme = () => {
|
||||||
|
host.theme = next;
|
||||||
|
applySettings(host, { ...host.settings, theme: next });
|
||||||
|
applyResolvedTheme(host, resolveTheme(next));
|
||||||
|
};
|
||||||
|
startThemeTransition({
|
||||||
|
nextTheme: next,
|
||||||
|
applyTheme,
|
||||||
|
context,
|
||||||
|
currentTheme: host.theme,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshActiveTab(host: SettingsHost) {
|
||||||
|
if (host.tab === "overview") await loadOverview(host);
|
||||||
|
if (host.tab === "connections") await loadConnections(host);
|
||||||
|
if (host.tab === "instances") await loadPresence(host as unknown as ClawdbotApp);
|
||||||
|
if (host.tab === "sessions") await loadSessions(host as unknown as ClawdbotApp);
|
||||||
|
if (host.tab === "cron") await loadCron(host);
|
||||||
|
if (host.tab === "skills") await loadSkills(host as unknown as ClawdbotApp);
|
||||||
|
if (host.tab === "nodes") await loadNodes(host as unknown as ClawdbotApp);
|
||||||
|
if (host.tab === "chat") {
|
||||||
|
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);
|
||||||
|
scheduleChatScroll(
|
||||||
|
host as unknown as Parameters<typeof scheduleChatScroll>[0],
|
||||||
|
!host.chatHasAutoScrolled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (host.tab === "config") {
|
||||||
|
await loadConfigSchema(host as unknown as ClawdbotApp);
|
||||||
|
await loadConfig(host as unknown as ClawdbotApp);
|
||||||
|
}
|
||||||
|
if (host.tab === "debug") {
|
||||||
|
await loadDebug(host as unknown as ClawdbotApp);
|
||||||
|
host.eventLog = host.eventLogBuffer;
|
||||||
|
}
|
||||||
|
if (host.tab === "logs") {
|
||||||
|
host.logsAtBottom = true;
|
||||||
|
await loadLogs(host as unknown as ClawdbotApp, { reset: true });
|
||||||
|
scheduleLogsScroll(
|
||||||
|
host as unknown as Parameters<typeof scheduleLogsScroll>[0],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncThemeWithSettings(host: SettingsHost) {
|
||||||
|
host.theme = host.settings.theme ?? "system";
|
||||||
|
applyResolvedTheme(host, resolveTheme(host.theme));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
|
||||||
|
host.themeResolved = resolved;
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.dataset.theme = resolved;
|
||||||
|
root.style.colorScheme = resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachThemeListener(host: SettingsHost) {
|
||||||
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||||
|
host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
host.themeMediaHandler = (event) => {
|
||||||
|
if (host.theme !== "system") return;
|
||||||
|
applyResolvedTheme(host, event.matches ? "dark" : "light");
|
||||||
|
};
|
||||||
|
if (typeof host.themeMedia.addEventListener === "function") {
|
||||||
|
host.themeMedia.addEventListener("change", host.themeMediaHandler);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const legacy = host.themeMedia as MediaQueryList & {
|
||||||
|
addListener: (cb: (event: MediaQueryListEvent) => void) => void;
|
||||||
|
};
|
||||||
|
legacy.addListener(host.themeMediaHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detachThemeListener(host: SettingsHost) {
|
||||||
|
if (!host.themeMedia || !host.themeMediaHandler) return;
|
||||||
|
if (typeof host.themeMedia.removeEventListener === "function") {
|
||||||
|
host.themeMedia.removeEventListener("change", host.themeMediaHandler);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const legacy = host.themeMedia as MediaQueryList & {
|
||||||
|
removeListener: (cb: (event: MediaQueryListEvent) => void) => void;
|
||||||
|
};
|
||||||
|
legacy.removeListener(host.themeMediaHandler);
|
||||||
|
host.themeMedia = null;
|
||||||
|
host.themeMediaHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const resolved = tabFromPath(window.location.pathname, host.basePath) ?? "chat";
|
||||||
|
setTabFromRoute(host, resolved);
|
||||||
|
syncUrlWithTab(host, resolved, replace);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPopState(host: SettingsHost) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const resolved = tabFromPath(window.location.pathname, host.basePath);
|
||||||
|
if (!resolved) return;
|
||||||
|
setTabFromRoute(host, resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTabFromRoute(host: SettingsHost, next: Tab) {
|
||||||
|
if (host.tab !== next) host.tab = next;
|
||||||
|
if (next === "chat") host.chatHasAutoScrolled = false;
|
||||||
|
if (next === "logs")
|
||||||
|
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||||
|
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||||
|
if (host.connected) void refreshActiveTab(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const targetPath = normalizePath(pathForTab(tab, host.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadOverview(host: SettingsHost) {
|
||||||
|
await Promise.all([
|
||||||
|
loadChannels(host as unknown as ClawdbotApp, false),
|
||||||
|
loadPresence(host as unknown as ClawdbotApp),
|
||||||
|
loadSessions(host as unknown as ClawdbotApp),
|
||||||
|
loadCronStatus(host as unknown as ClawdbotApp),
|
||||||
|
loadDebug(host as unknown as ClawdbotApp),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadConnections(host: SettingsHost) {
|
||||||
|
await Promise.all([
|
||||||
|
loadChannels(host as unknown as ClawdbotApp, true),
|
||||||
|
loadConfig(host as unknown as ClawdbotApp),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCron(host: SettingsHost) {
|
||||||
|
await Promise.all([
|
||||||
|
loadCronStatus(host as unknown as ClawdbotApp),
|
||||||
|
loadCronJobs(host as unknown as ClawdbotApp),
|
||||||
|
]);
|
||||||
|
}
|
||||||
202
ui/src/ui/app-tool-stream.ts
Normal file
202
ui/src/ui/app-tool-stream.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { truncateText } from "./format";
|
||||||
|
|
||||||
|
const TOOL_STREAM_LIMIT = 50;
|
||||||
|
const TOOL_STREAM_THROTTLE_MS = 80;
|
||||||
|
const TOOL_OUTPUT_CHAR_LIMIT = 120_000;
|
||||||
|
|
||||||
|
export type AgentEventPayload = {
|
||||||
|
runId: string;
|
||||||
|
seq: number;
|
||||||
|
stream: string;
|
||||||
|
ts: number;
|
||||||
|
sessionKey?: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolStreamEntry = {
|
||||||
|
toolCallId: string;
|
||||||
|
runId: string;
|
||||||
|
sessionKey?: string;
|
||||||
|
name: string;
|
||||||
|
args?: unknown;
|
||||||
|
output?: string;
|
||||||
|
startedAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
message: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToolStreamHost = {
|
||||||
|
sessionKey: string;
|
||||||
|
chatRunId: string | null;
|
||||||
|
toolStreamById: Map<string, ToolStreamEntry>;
|
||||||
|
toolStreamOrder: string[];
|
||||||
|
chatToolMessages: Record<string, unknown>[];
|
||||||
|
toolOutputExpanded: Set<string>;
|
||||||
|
toolStreamSyncTimer: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 === "number" || typeof value === "boolean") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
const contentText = extractToolOutputText(value);
|
||||||
|
let text: string;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
text = value;
|
||||||
|
} else if (contentText) {
|
||||||
|
text = contentText;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
text = JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
text = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const truncated = truncateText(text, TOOL_OUTPUT_CHAR_LIMIT);
|
||||||
|
if (!truncated.truncated) return truncated.text;
|
||||||
|
return `${truncated.text}\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimToolStream(host: ToolStreamHost) {
|
||||||
|
if (host.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return;
|
||||||
|
const overflow = host.toolStreamOrder.length - TOOL_STREAM_LIMIT;
|
||||||
|
const removed = host.toolStreamOrder.splice(0, overflow);
|
||||||
|
for (const id of removed) host.toolStreamById.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncToolStreamMessages(host: ToolStreamHost) {
|
||||||
|
host.chatToolMessages = host.toolStreamOrder
|
||||||
|
.map((id) => host.toolStreamById.get(id)?.message)
|
||||||
|
.filter((msg): msg is Record<string, unknown> => Boolean(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flushToolStreamSync(host: ToolStreamHost) {
|
||||||
|
if (host.toolStreamSyncTimer != null) {
|
||||||
|
clearTimeout(host.toolStreamSyncTimer);
|
||||||
|
host.toolStreamSyncTimer = null;
|
||||||
|
}
|
||||||
|
syncToolStreamMessages(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleToolStreamSync(host: ToolStreamHost, force = false) {
|
||||||
|
if (force) {
|
||||||
|
flushToolStreamSync(host);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (host.toolStreamSyncTimer != null) return;
|
||||||
|
host.toolStreamSyncTimer = window.setTimeout(
|
||||||
|
() => flushToolStreamSync(host),
|
||||||
|
TOOL_STREAM_THROTTLE_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetToolStream(host: ToolStreamHost) {
|
||||||
|
host.toolStreamById.clear();
|
||||||
|
host.toolStreamOrder = [];
|
||||||
|
host.chatToolMessages = [];
|
||||||
|
host.toolOutputExpanded = new Set();
|
||||||
|
flushToolStreamSync(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleToolOutput(host: ToolStreamHost, id: string, expanded: boolean) {
|
||||||
|
const next = new Set(host.toolOutputExpanded);
|
||||||
|
if (expanded) {
|
||||||
|
next.add(id);
|
||||||
|
} else {
|
||||||
|
next.delete(id);
|
||||||
|
}
|
||||||
|
host.toolOutputExpanded = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
|
||||||
|
if (!payload || payload.stream !== "tool") return;
|
||||||
|
const sessionKey =
|
||||||
|
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
|
||||||
|
if (sessionKey && sessionKey !== host.sessionKey) return;
|
||||||
|
// Fallback: only accept session-less events for the active run.
|
||||||
|
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) return;
|
||||||
|
if (host.chatRunId && payload.runId !== host.chatRunId) return;
|
||||||
|
if (!host.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 = host.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: {},
|
||||||
|
};
|
||||||
|
host.toolStreamById.set(toolCallId, entry);
|
||||||
|
host.toolStreamOrder.push(toolCallId);
|
||||||
|
} else {
|
||||||
|
entry.name = name;
|
||||||
|
if (args !== undefined) entry.args = args;
|
||||||
|
if (output !== undefined) entry.output = output;
|
||||||
|
entry.updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.message = buildToolStreamMessage(entry);
|
||||||
|
trimToolStream(host);
|
||||||
|
scheduleToolStreamSync(host, phase === "result");
|
||||||
|
}
|
||||||
964
ui/src/ui/app.ts
964
ui/src/ui/app.ts
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user