fix(ui): cache control ui markdown
This commit is contained in:
@@ -17,6 +17,7 @@ Docs: https://docs.clawd.bot
|
||||
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
||||
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
||||
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
|
||||
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.
|
||||
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
|
||||
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
|
||||
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { MessageGroup } from "../types/chat-types";
|
||||
import { renderCopyAsMarkdownButton } from "./copy-as-markdown";
|
||||
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer";
|
||||
import {
|
||||
extractText,
|
||||
extractThinking,
|
||||
extractTextCached,
|
||||
extractThinkingCached,
|
||||
formatReasoningMarkdown,
|
||||
} from "./message-extract";
|
||||
import { extractToolCards, renderToolCardSidebar } from "./tool-cards";
|
||||
@@ -180,9 +180,11 @@ function renderGroupedMessage(
|
||||
const toolCards = extractToolCards(message);
|
||||
const hasToolCards = toolCards.length > 0;
|
||||
|
||||
const extractedText = extractText(message);
|
||||
const extractedText = extractTextCached(message);
|
||||
const extractedThinking =
|
||||
opts.showReasoning && role === "assistant" ? extractThinking(message) : null;
|
||||
opts.showReasoning && role === "assistant"
|
||||
? extractThinkingCached(message)
|
||||
: null;
|
||||
const markdownBase = extractedText?.trim() ? extractedText : null;
|
||||
const reasoningMarkdown = extractedThinking
|
||||
? formatReasoningMarkdown(extractedThinking)
|
||||
|
||||
@@ -1,34 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stripEnvelope } from "./message-extract";
|
||||
|
||||
describe("stripEnvelope", () => {
|
||||
it("strips UTC envelope", () => {
|
||||
const text = "[WebChat agent:main:main 2026-01-18T05:19Z] hello world";
|
||||
expect(stripEnvelope(text)).toBe("hello world");
|
||||
import {
|
||||
extractText,
|
||||
extractTextCached,
|
||||
extractThinking,
|
||||
extractThinkingCached,
|
||||
} from "./message-extract";
|
||||
|
||||
describe("extractTextCached", () => {
|
||||
it("matches extractText output", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello there" }],
|
||||
};
|
||||
expect(extractTextCached(message)).toBe(extractText(message));
|
||||
});
|
||||
|
||||
it("strips local-time envelope", () => {
|
||||
const text = "[Telegram Ada Lovelace (@ada) id:1234 2026-01-18 19:29 GMT+1] test";
|
||||
expect(stripEnvelope(text)).toBe("test");
|
||||
it("returns consistent output for repeated calls", () => {
|
||||
const message = {
|
||||
role: "user",
|
||||
content: "plain text",
|
||||
};
|
||||
expect(extractTextCached(message)).toBe("plain text");
|
||||
expect(extractTextCached(message)).toBe("plain text");
|
||||
});
|
||||
});
|
||||
|
||||
it("strips envelopes without timestamps for known channels", () => {
|
||||
const text = "[WhatsApp +1234567890] hi there";
|
||||
expect(stripEnvelope(text)).toBe("hi there");
|
||||
describe("extractThinkingCached", () => {
|
||||
it("matches extractThinking output", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "Plan A" }],
|
||||
};
|
||||
expect(extractThinkingCached(message)).toBe(extractThinking(message));
|
||||
});
|
||||
|
||||
it("handles multi-line messages", () => {
|
||||
const text = "[Slack #general 2026-01-18T05:19Z] first line\nsecond line";
|
||||
expect(stripEnvelope(text)).toBe("first line\nsecond line");
|
||||
});
|
||||
|
||||
it("returns text as-is when no envelope present", () => {
|
||||
const text = "just a regular message";
|
||||
expect(stripEnvelope(text)).toBe("just a regular message");
|
||||
});
|
||||
|
||||
it("does not strip non-envelope brackets", () => {
|
||||
expect(stripEnvelope("[OK] hello")).toBe("[OK] hello");
|
||||
expect(stripEnvelope("[1/2] step one")).toBe("[1/2] step one");
|
||||
it("returns consistent output for repeated calls", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "Plan A" }],
|
||||
};
|
||||
expect(extractThinkingCached(message)).toBe("Plan A");
|
||||
expect(extractThinkingCached(message)).toBe("Plan A");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,9 @@ const ENVELOPE_CHANNELS = [
|
||||
"BlueBubbles",
|
||||
];
|
||||
|
||||
const textCache = new WeakMap<object, string | null>();
|
||||
const thinkingCache = new WeakMap<object, string | null>();
|
||||
|
||||
function looksLikeEnvelopeHeader(header: string): boolean {
|
||||
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true;
|
||||
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true;
|
||||
@@ -59,6 +62,15 @@ export function extractText(message: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractTextCached(message: unknown): string | null {
|
||||
if (!message || typeof message !== "object") return extractText(message);
|
||||
const obj = message as object;
|
||||
if (textCache.has(obj)) return textCache.get(obj) ?? null;
|
||||
const value = extractText(message);
|
||||
textCache.set(obj, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function extractThinking(message: unknown): string | null {
|
||||
const m = message as Record<string, unknown>;
|
||||
const content = m.content;
|
||||
@@ -88,6 +100,15 @@ export function extractThinking(message: unknown): string | null {
|
||||
return extracted.length > 0 ? extracted.join("\n") : null;
|
||||
}
|
||||
|
||||
export function extractThinkingCached(message: unknown): string | null {
|
||||
if (!message || typeof message !== "object") return extractThinking(message);
|
||||
const obj = message as object;
|
||||
if (thinkingCache.has(obj)) return thinkingCache.get(obj) ?? null;
|
||||
const value = extractThinking(message);
|
||||
thinkingCache.set(obj, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function extractRawText(message: unknown): string | null {
|
||||
const m = message as Record<string, unknown>;
|
||||
const content = m.content;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
getTruncatedPreview,
|
||||
} from "./tool-helpers";
|
||||
import { isToolResultMessage } from "./message-normalizer";
|
||||
import { extractText } from "./message-extract";
|
||||
import { extractTextCached } from "./message-extract";
|
||||
|
||||
export function extractToolCards(message: unknown): ToolCard[] {
|
||||
const m = message as Record<string, unknown>;
|
||||
@@ -45,7 +45,7 @@ export function extractToolCards(message: unknown): ToolCard[] {
|
||||
(typeof m.toolName === "string" && m.toolName) ||
|
||||
(typeof m.tool_name === "string" && m.tool_name) ||
|
||||
"tool";
|
||||
const text = extractText(message) ?? undefined;
|
||||
const text = extractTextCached(message) ?? undefined;
|
||||
cards.push({ kind: "result", name, text });
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,24 @@ const allowedAttrs = ["class", "href", "rel", "target", "title", "start"];
|
||||
let hooksInstalled = false;
|
||||
const MARKDOWN_CHAR_LIMIT = 140_000;
|
||||
const MARKDOWN_PARSE_LIMIT = 40_000;
|
||||
const MARKDOWN_CACHE_LIMIT = 200;
|
||||
const MARKDOWN_CACHE_MAX_CHARS = 50_000;
|
||||
const markdownCache = new Map<string, string>();
|
||||
|
||||
function getCachedMarkdown(key: string): string | null {
|
||||
const cached = markdownCache.get(key);
|
||||
if (cached === undefined) return null;
|
||||
markdownCache.delete(key);
|
||||
markdownCache.set(key, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
function setCachedMarkdown(key: string, value: string) {
|
||||
markdownCache.set(key, value);
|
||||
if (markdownCache.size <= MARKDOWN_CACHE_LIMIT) return;
|
||||
const oldest = markdownCache.keys().next().value;
|
||||
if (oldest) markdownCache.delete(oldest);
|
||||
}
|
||||
|
||||
function installHooks() {
|
||||
if (hooksInstalled) return;
|
||||
@@ -59,6 +77,10 @@ export function toSanitizedMarkdownHtml(markdown: string): string {
|
||||
const input = markdown.trim();
|
||||
if (!input) return "";
|
||||
installHooks();
|
||||
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
|
||||
const cached = getCachedMarkdown(input);
|
||||
if (cached !== null) return cached;
|
||||
}
|
||||
const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
|
||||
const suffix = truncated.truncated
|
||||
? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`
|
||||
@@ -66,16 +88,24 @@ export function toSanitizedMarkdownHtml(markdown: string): string {
|
||||
if (truncated.text.length > MARKDOWN_PARSE_LIMIT) {
|
||||
const escaped = escapeHtml(`${truncated.text}${suffix}`);
|
||||
const html = `<pre class="code-block">${escaped}</pre>`;
|
||||
return DOMPurify.sanitize(html, {
|
||||
const sanitized = DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: allowedTags,
|
||||
ALLOWED_ATTR: allowedAttrs,
|
||||
});
|
||||
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
|
||||
setCachedMarkdown(input, sanitized);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
const rendered = marked.parse(`${truncated.text}${suffix}`) as string;
|
||||
return DOMPurify.sanitize(rendered, {
|
||||
const sanitized = DOMPurify.sanitize(rendered, {
|
||||
ALLOWED_TAGS: allowedTags,
|
||||
ALLOWED_ATTR: allowedAttrs,
|
||||
});
|
||||
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
|
||||
setCachedMarkdown(input, sanitized);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { guard } from "lit/directives/guard.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import type { SessionsListResult } from "../types";
|
||||
import type { ChatQueueItem } from "../ui-types";
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
normalizeMessage,
|
||||
normalizeRoleForGrouping,
|
||||
} from "../chat/message-normalizer";
|
||||
import { extractText } from "../chat/message-extract";
|
||||
import { extractTextCached } from "../chat/message-extract";
|
||||
import {
|
||||
renderMessageGroup,
|
||||
renderReadingIndicatorGroup,
|
||||
@@ -114,6 +115,56 @@ export function renderChat(props: ChatProps) {
|
||||
|
||||
const splitRatio = props.splitRatio ?? 0.6;
|
||||
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
|
||||
const thread = guard(
|
||||
[
|
||||
props.loading,
|
||||
props.messages,
|
||||
props.toolMessages,
|
||||
props.stream,
|
||||
props.streamStartedAt,
|
||||
props.sessionKey,
|
||||
props.showThinking,
|
||||
reasoningLevel,
|
||||
props.assistantName,
|
||||
props.assistantAvatar,
|
||||
props.assistantAvatarUrl,
|
||||
],
|
||||
() => html`
|
||||
<div
|
||||
class="chat-thread"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
@scroll=${props.onChatScroll}
|
||||
>
|
||||
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
|
||||
${repeat(buildChatItems(props), (item) => item.key, (item) => {
|
||||
if (item.kind === "reading-indicator") {
|
||||
return renderReadingIndicatorGroup(assistantIdentity);
|
||||
}
|
||||
|
||||
if (item.kind === "stream") {
|
||||
return renderStreamingGroup(
|
||||
item.text,
|
||||
item.startedAt,
|
||||
props.onOpenSidebar,
|
||||
assistantIdentity,
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === "group") {
|
||||
return renderMessageGroup(item, {
|
||||
onOpenSidebar: props.onOpenSidebar,
|
||||
showReasoning,
|
||||
assistantName: props.assistantName,
|
||||
assistantAvatar: assistantIdentity.avatar,
|
||||
});
|
||||
}
|
||||
|
||||
return nothing;
|
||||
})}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
|
||||
return html`
|
||||
<section class="card chat">
|
||||
@@ -148,41 +199,7 @@ export function renderChat(props: ChatProps) {
|
||||
class="chat-main"
|
||||
style="flex: ${sidebarOpen ? `0 0 ${splitRatio * 100}%` : "1 1 100%"}"
|
||||
>
|
||||
<div
|
||||
class="chat-thread"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
@scroll=${props.onChatScroll}
|
||||
>
|
||||
${props.loading
|
||||
? html`<div class="muted">Loading chat…</div>`
|
||||
: nothing}
|
||||
${repeat(buildChatItems(props), (item) => item.key, (item) => {
|
||||
if (item.kind === "reading-indicator") {
|
||||
return renderReadingIndicatorGroup(assistantIdentity);
|
||||
}
|
||||
|
||||
if (item.kind === "stream") {
|
||||
return renderStreamingGroup(
|
||||
item.text,
|
||||
item.startedAt,
|
||||
props.onOpenSidebar,
|
||||
assistantIdentity,
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === "group") {
|
||||
return renderMessageGroup(item, {
|
||||
onOpenSidebar: props.onOpenSidebar,
|
||||
showReasoning,
|
||||
assistantName: props.assistantName,
|
||||
assistantAvatar: assistantIdentity.avatar,
|
||||
});
|
||||
}
|
||||
|
||||
return nothing;
|
||||
})}
|
||||
</div>
|
||||
${thread}
|
||||
</div>
|
||||
|
||||
${sidebarOpen
|
||||
@@ -379,7 +396,8 @@ function messageKey(message: unknown, index: number): string {
|
||||
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
|
||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||
const fingerprint =
|
||||
extractText(message) ?? (typeof m.content === "string" ? m.content : null);
|
||||
extractTextCached(message) ??
|
||||
(typeof m.content === "string" ? m.content : null);
|
||||
const seed = fingerprint ?? safeJson(message) ?? String(index);
|
||||
const hash = fnv1a(seed);
|
||||
return timestamp ? `msg:${role}:${timestamp}:${hash}` : `msg:${role}:${hash}`;
|
||||
|
||||
Reference in New Issue
Block a user