perf(ui): window chat + lazy tool output
This commit is contained in:
@@ -54,6 +54,8 @@
|
||||
- Web UI: add Logs tab for gateway file logs with filtering, auto-follow, and export.
|
||||
- Web UI: cap tool output + large markdown rendering to avoid UI freezes on huge tool results.
|
||||
- Web UI: keep config form edits synced to raw JSON so form saves persist.
|
||||
- Web UI: window chat history rendering and throttle tool stream updates to reduce UI churn.
|
||||
- Web UI: collapse tool output by default and lazy-render tool markdown on expand.
|
||||
- ClawdbotKit: fix SwiftPM resource bundling path for `tool-display.json`. Thanks @fcatuhe for PR #398.
|
||||
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
|
||||
- Tools: flatten literal-union schemas for Claude on Vertex AI. Thanks @carlulsoe for PR #409.
|
||||
|
||||
@@ -883,6 +883,34 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-tool-card__details {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.chat-tool-card__summary {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-tool-card__summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-tool-card__summary-meta {
|
||||
color: var(--muted);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chat-tool-card__details[open] .chat-tool-card__summary {
|
||||
color: var(--chat-text);
|
||||
}
|
||||
|
||||
.chat-tool-card__output {
|
||||
margin-top: 6px;
|
||||
font-family: var(--font-mono);
|
||||
|
||||
@@ -440,6 +440,9 @@ export function renderApp(state: AppViewState) {
|
||||
disabledReason: chatDisabledReason,
|
||||
error: state.lastError,
|
||||
sessions: state.sessionsResult,
|
||||
isToolOutputExpanded: (id) => state.toolOutputExpanded.has(id),
|
||||
onToolOutputToggle: (id, expanded) =>
|
||||
state.toggleToolOutput(id, expanded),
|
||||
onRefresh: () => {
|
||||
state.resetToolStream();
|
||||
return loadChatHistory(state);
|
||||
|
||||
@@ -89,6 +89,7 @@ type EventLogEntry = {
|
||||
};
|
||||
|
||||
const TOOL_STREAM_LIMIT = 50;
|
||||
const TOOL_STREAM_THROTTLE_MS = 80;
|
||||
const TOOL_OUTPUT_CHAR_LIMIT = 120_000;
|
||||
const DEFAULT_LOG_LEVEL_FILTERS: Record<LogLevel, boolean> = {
|
||||
trace: true,
|
||||
@@ -200,6 +201,7 @@ export class ClawdbotApp extends LitElement {
|
||||
@state() lastError: string | null = null;
|
||||
@state() eventLog: EventLogEntry[] = [];
|
||||
private eventLogBuffer: EventLogEntry[] = [];
|
||||
private toolStreamSyncTimer: number | null = null;
|
||||
|
||||
@state() sessionKey = this.settings.sessionKey;
|
||||
@state() chatLoading = false;
|
||||
@@ -211,6 +213,7 @@ export class ClawdbotApp extends LitElement {
|
||||
@state() chatStreamStartedAt: number | null = null;
|
||||
@state() chatRunId: string | null = null;
|
||||
@state() chatThinkingLevel: string | null = null;
|
||||
@state() toolOutputExpanded = new Set<string>();
|
||||
|
||||
@state() nodesLoading = false;
|
||||
@state() nodes: Array<Record<string, unknown>> = [];
|
||||
@@ -608,12 +611,24 @@ export class ClawdbotApp extends LitElement {
|
||||
this.toolStreamById.clear();
|
||||
this.toolStreamOrder = [];
|
||||
this.chatToolMessages = [];
|
||||
this.toolOutputExpanded = new Set();
|
||||
this.flushToolStreamSync();
|
||||
}
|
||||
|
||||
resetChatScroll() {
|
||||
this.chatHasAutoScrolled = false;
|
||||
}
|
||||
|
||||
toggleToolOutput(id: string, expanded: boolean) {
|
||||
const next = new Set(this.toolOutputExpanded);
|
||||
if (expanded) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
this.toolOutputExpanded = next;
|
||||
}
|
||||
|
||||
private trimToolStream() {
|
||||
if (this.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return;
|
||||
const overflow = this.toolStreamOrder.length - TOOL_STREAM_LIMIT;
|
||||
@@ -627,6 +642,26 @@ export class ClawdbotApp extends LitElement {
|
||||
.filter((msg): msg is Record<string, unknown> => Boolean(msg));
|
||||
}
|
||||
|
||||
private scheduleToolStreamSync(force = false) {
|
||||
if (force) {
|
||||
this.flushToolStreamSync();
|
||||
return;
|
||||
}
|
||||
if (this.toolStreamSyncTimer != null) return;
|
||||
this.toolStreamSyncTimer = window.setTimeout(
|
||||
() => this.flushToolStreamSync(),
|
||||
TOOL_STREAM_THROTTLE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
private flushToolStreamSync() {
|
||||
if (this.toolStreamSyncTimer != null) {
|
||||
clearTimeout(this.toolStreamSyncTimer);
|
||||
this.toolStreamSyncTimer = null;
|
||||
}
|
||||
this.syncToolStreamMessages();
|
||||
}
|
||||
|
||||
private buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown> {
|
||||
const content: Array<Record<string, unknown>> = [];
|
||||
content.push({
|
||||
@@ -699,7 +734,7 @@ export class ClawdbotApp extends LitElement {
|
||||
|
||||
entry.message = this.buildToolStreamMessage(entry);
|
||||
this.trimToolStream();
|
||||
this.syncToolStreamMessages();
|
||||
this.scheduleToolStreamSync(phase === "result");
|
||||
}
|
||||
|
||||
private onEvent(evt: GatewayEventFrame) {
|
||||
|
||||
@@ -22,6 +22,8 @@ export type ChatProps = {
|
||||
disabledReason: string | null;
|
||||
error: string | null;
|
||||
sessions: SessionsListResult | null;
|
||||
isToolOutputExpanded: (id: string) => boolean;
|
||||
onToolOutputToggle: (id: string, expanded: boolean) => void;
|
||||
onRefresh: () => void;
|
||||
onDraftChange: (next: string) => void;
|
||||
onSend: () => void;
|
||||
@@ -88,10 +90,11 @@ export function renderChat(props: ChatProps) {
|
||||
content: [{ type: "text", text: item.text }],
|
||||
timestamp: item.startedAt,
|
||||
},
|
||||
props,
|
||||
{ streaming: true },
|
||||
);
|
||||
}
|
||||
return renderMessage(item.message);
|
||||
return renderMessage(item.message, props);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -131,12 +134,30 @@ type ChatItem =
|
||||
| { kind: "stream"; key: string; text: string; startedAt: number }
|
||||
| { kind: "reading-indicator"; key: string };
|
||||
|
||||
const CHAT_HISTORY_RENDER_LIMIT = 200;
|
||||
|
||||
function buildChatItems(props: ChatProps): ChatItem[] {
|
||||
const items: ChatItem[] = [];
|
||||
const history = Array.isArray(props.messages) ? props.messages : [];
|
||||
const tools = Array.isArray(props.toolMessages) ? props.toolMessages : [];
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
items.push({ kind: "message", key: messageKey(history[i], i), message: history[i] });
|
||||
const historyStart = Math.max(0, history.length - CHAT_HISTORY_RENDER_LIMIT);
|
||||
if (historyStart > 0) {
|
||||
items.push({
|
||||
kind: "message",
|
||||
key: "chat:history:notice",
|
||||
message: {
|
||||
role: "system",
|
||||
content: `Showing last ${CHAT_HISTORY_RENDER_LIMIT} messages (${historyStart} hidden).`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let i = historyStart; i < history.length; i++) {
|
||||
items.push({
|
||||
kind: "message",
|
||||
key: messageKey(history[i], i),
|
||||
message: history[i],
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < tools.length; i++) {
|
||||
items.push({
|
||||
@@ -260,7 +281,11 @@ function renderReadingIndicator() {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
|
||||
function renderMessage(
|
||||
message: unknown,
|
||||
props?: Pick<ChatProps, "isToolOutputExpanded" | "onToolOutputToggle">,
|
||||
opts?: { streaming?: boolean },
|
||||
) {
|
||||
const m = message as Record<string, unknown>;
|
||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||
const toolCards = extractToolCards(message);
|
||||
@@ -287,6 +312,12 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
|
||||
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
|
||||
const klass = role === "assistant" ? "assistant" : role === "user" ? "user" : "other";
|
||||
const who = role === "assistant" ? "Assistant" : role === "user" ? "You" : role;
|
||||
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
|
||||
const toolCardBase =
|
||||
toolCallId ||
|
||||
(typeof m.id === "string" ? m.id : "") ||
|
||||
(typeof m.messageId === "string" ? m.messageId : "") ||
|
||||
(typeof m.timestamp === "number" ? String(m.timestamp) : "tool-card");
|
||||
return html`
|
||||
<div class="chat-line ${klass}">
|
||||
<div class="chat-msg">
|
||||
@@ -294,7 +325,15 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
|
||||
${markdown
|
||||
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
||||
: nothing}
|
||||
${toolCards.map((card) => renderToolCard(card))}
|
||||
${toolCards.map((card, index) =>
|
||||
renderToolCard(card, {
|
||||
id: `${toolCardBase}:${index}`,
|
||||
expanded: props?.isToolOutputExpanded
|
||||
? props.isToolOutputExpanded(`${toolCardBase}:${index}`)
|
||||
: false,
|
||||
onToggle: props?.onToolOutputToggle,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
<div class="chat-stamp mono">
|
||||
${who}${timestamp ? html` · ${timestamp}` : nothing}
|
||||
@@ -368,19 +407,49 @@ function extractToolCards(message: unknown): ToolCard[] {
|
||||
return cards;
|
||||
}
|
||||
|
||||
function renderToolCard(card: ToolCard) {
|
||||
function renderToolCard(
|
||||
card: ToolCard,
|
||||
opts?: {
|
||||
id: string;
|
||||
expanded: boolean;
|
||||
onToggle?: (id: string, expanded: boolean) => void;
|
||||
},
|
||||
) {
|
||||
const display = resolveToolDisplay({ name: card.name, args: card.args });
|
||||
const detail = formatToolDetail(display);
|
||||
const hasOutput = typeof card.text === "string" && card.text.length > 0;
|
||||
const expanded = opts?.expanded ?? false;
|
||||
const id = opts?.id ?? `${card.name}-${Math.random()}`;
|
||||
return html`
|
||||
<div class="chat-tool-card">
|
||||
<div class="chat-tool-card__title">${display.emoji} ${display.label}</div>
|
||||
${detail
|
||||
? html`<div class="chat-tool-card__detail">${detail}</div>`
|
||||
: nothing}
|
||||
${card.text
|
||||
? html`<div class="chat-tool-card__output chat-text">
|
||||
${unsafeHTML(toSanitizedMarkdownHtml(card.text))}
|
||||
</div>`
|
||||
${hasOutput
|
||||
? html`
|
||||
<details
|
||||
class="chat-tool-card__details"
|
||||
?open=${expanded}
|
||||
@toggle=${(e: Event) => {
|
||||
if (!opts?.onToggle) return;
|
||||
const target = e.currentTarget as HTMLDetailsElement;
|
||||
opts.onToggle(id, target.open);
|
||||
}}
|
||||
>
|
||||
<summary class="chat-tool-card__summary">
|
||||
${expanded ? "Hide output" : "Show output"}
|
||||
<span class="chat-tool-card__summary-meta">
|
||||
(${card.text?.length ?? 0} chars)
|
||||
</span>
|
||||
</summary>
|
||||
${expanded
|
||||
? html`<div class="chat-tool-card__output chat-text">
|
||||
${unsafeHTML(toSanitizedMarkdownHtml(card.text ?? ""))}
|
||||
</div>`
|
||||
: nothing}
|
||||
</details>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user