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)
|
- 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.
|
- 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: 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.
|
- 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.
|
- 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.
|
- 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 { renderCopyAsMarkdownButton } from "./copy-as-markdown";
|
||||||
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer";
|
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer";
|
||||||
import {
|
import {
|
||||||
extractText,
|
extractTextCached,
|
||||||
extractThinking,
|
extractThinkingCached,
|
||||||
formatReasoningMarkdown,
|
formatReasoningMarkdown,
|
||||||
} from "./message-extract";
|
} from "./message-extract";
|
||||||
import { extractToolCards, renderToolCardSidebar } from "./tool-cards";
|
import { extractToolCards, renderToolCardSidebar } from "./tool-cards";
|
||||||
@@ -180,9 +180,11 @@ function renderGroupedMessage(
|
|||||||
const toolCards = extractToolCards(message);
|
const toolCards = extractToolCards(message);
|
||||||
const hasToolCards = toolCards.length > 0;
|
const hasToolCards = toolCards.length > 0;
|
||||||
|
|
||||||
const extractedText = extractText(message);
|
const extractedText = extractTextCached(message);
|
||||||
const extractedThinking =
|
const extractedThinking =
|
||||||
opts.showReasoning && role === "assistant" ? extractThinking(message) : null;
|
opts.showReasoning && role === "assistant"
|
||||||
|
? extractThinkingCached(message)
|
||||||
|
: null;
|
||||||
const markdownBase = extractedText?.trim() ? extractedText : null;
|
const markdownBase = extractedText?.trim() ? extractedText : null;
|
||||||
const reasoningMarkdown = extractedThinking
|
const reasoningMarkdown = extractedThinking
|
||||||
? formatReasoningMarkdown(extractedThinking)
|
? formatReasoningMarkdown(extractedThinking)
|
||||||
|
|||||||
@@ -1,34 +1,46 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { stripEnvelope } from "./message-extract";
|
|
||||||
|
|
||||||
describe("stripEnvelope", () => {
|
import {
|
||||||
it("strips UTC envelope", () => {
|
extractText,
|
||||||
const text = "[WebChat agent:main:main 2026-01-18T05:19Z] hello world";
|
extractTextCached,
|
||||||
expect(stripEnvelope(text)).toBe("hello world");
|
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", () => {
|
it("returns consistent output for repeated calls", () => {
|
||||||
const text = "[Telegram Ada Lovelace (@ada) id:1234 2026-01-18 19:29 GMT+1] test";
|
const message = {
|
||||||
expect(stripEnvelope(text)).toBe("test");
|
role: "user",
|
||||||
});
|
content: "plain text",
|
||||||
|
};
|
||||||
it("strips envelopes without timestamps for known channels", () => {
|
expect(extractTextCached(message)).toBe("plain text");
|
||||||
const text = "[WhatsApp +1234567890] hi there";
|
expect(extractTextCached(message)).toBe("plain text");
|
||||||
expect(stripEnvelope(text)).toBe("hi there");
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles multi-line messages", () => {
|
describe("extractThinkingCached", () => {
|
||||||
const text = "[Slack #general 2026-01-18T05:19Z] first line\nsecond line";
|
it("matches extractThinking output", () => {
|
||||||
expect(stripEnvelope(text)).toBe("first line\nsecond line");
|
const message = {
|
||||||
});
|
role: "assistant",
|
||||||
|
content: [{ type: "thinking", thinking: "Plan A" }],
|
||||||
it("returns text as-is when no envelope present", () => {
|
};
|
||||||
const text = "just a regular message";
|
expect(extractThinkingCached(message)).toBe(extractThinking(message));
|
||||||
expect(stripEnvelope(text)).toBe("just a regular message");
|
});
|
||||||
});
|
|
||||||
|
it("returns consistent output for repeated calls", () => {
|
||||||
it("does not strip non-envelope brackets", () => {
|
const message = {
|
||||||
expect(stripEnvelope("[OK] hello")).toBe("[OK] hello");
|
role: "assistant",
|
||||||
expect(stripEnvelope("[1/2] step one")).toBe("[1/2] step one");
|
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",
|
"BlueBubbles",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const textCache = new WeakMap<object, string | null>();
|
||||||
|
const thinkingCache = new WeakMap<object, string | null>();
|
||||||
|
|
||||||
function looksLikeEnvelopeHeader(header: string): boolean {
|
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}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;
|
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;
|
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 {
|
export function extractThinking(message: unknown): string | null {
|
||||||
const m = message as Record<string, unknown>;
|
const m = message as Record<string, unknown>;
|
||||||
const content = m.content;
|
const content = m.content;
|
||||||
@@ -88,6 +100,15 @@ export function extractThinking(message: unknown): string | null {
|
|||||||
return extracted.length > 0 ? extracted.join("\n") : 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 {
|
export function extractRawText(message: unknown): string | null {
|
||||||
const m = message as Record<string, unknown>;
|
const m = message as Record<string, unknown>;
|
||||||
const content = m.content;
|
const content = m.content;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
getTruncatedPreview,
|
getTruncatedPreview,
|
||||||
} from "./tool-helpers";
|
} from "./tool-helpers";
|
||||||
import { isToolResultMessage } from "./message-normalizer";
|
import { isToolResultMessage } from "./message-normalizer";
|
||||||
import { extractText } from "./message-extract";
|
import { extractTextCached } from "./message-extract";
|
||||||
|
|
||||||
export function extractToolCards(message: unknown): ToolCard[] {
|
export function extractToolCards(message: unknown): ToolCard[] {
|
||||||
const m = message as Record<string, unknown>;
|
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.toolName === "string" && m.toolName) ||
|
||||||
(typeof m.tool_name === "string" && m.tool_name) ||
|
(typeof m.tool_name === "string" && m.tool_name) ||
|
||||||
"tool";
|
"tool";
|
||||||
const text = extractText(message) ?? undefined;
|
const text = extractTextCached(message) ?? undefined;
|
||||||
cards.push({ kind: "result", name, text });
|
cards.push({ kind: "result", name, text });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ const allowedAttrs = ["class", "href", "rel", "target", "title", "start"];
|
|||||||
let hooksInstalled = false;
|
let hooksInstalled = false;
|
||||||
const MARKDOWN_CHAR_LIMIT = 140_000;
|
const MARKDOWN_CHAR_LIMIT = 140_000;
|
||||||
const MARKDOWN_PARSE_LIMIT = 40_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() {
|
function installHooks() {
|
||||||
if (hooksInstalled) return;
|
if (hooksInstalled) return;
|
||||||
@@ -59,6 +77,10 @@ export function toSanitizedMarkdownHtml(markdown: string): string {
|
|||||||
const input = markdown.trim();
|
const input = markdown.trim();
|
||||||
if (!input) return "";
|
if (!input) return "";
|
||||||
installHooks();
|
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 truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
|
||||||
const suffix = truncated.truncated
|
const suffix = truncated.truncated
|
||||||
? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`
|
? `\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) {
|
if (truncated.text.length > MARKDOWN_PARSE_LIMIT) {
|
||||||
const escaped = escapeHtml(`${truncated.text}${suffix}`);
|
const escaped = escapeHtml(`${truncated.text}${suffix}`);
|
||||||
const html = `<pre class="code-block">${escaped}</pre>`;
|
const html = `<pre class="code-block">${escaped}</pre>`;
|
||||||
return DOMPurify.sanitize(html, {
|
const sanitized = DOMPurify.sanitize(html, {
|
||||||
ALLOWED_TAGS: allowedTags,
|
ALLOWED_TAGS: allowedTags,
|
||||||
ALLOWED_ATTR: allowedAttrs,
|
ALLOWED_ATTR: allowedAttrs,
|
||||||
});
|
});
|
||||||
|
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
|
||||||
|
setCachedMarkdown(input, sanitized);
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
}
|
}
|
||||||
const rendered = marked.parse(`${truncated.text}${suffix}`) as string;
|
const rendered = marked.parse(`${truncated.text}${suffix}`) as string;
|
||||||
return DOMPurify.sanitize(rendered, {
|
const sanitized = DOMPurify.sanitize(rendered, {
|
||||||
ALLOWED_TAGS: allowedTags,
|
ALLOWED_TAGS: allowedTags,
|
||||||
ALLOWED_ATTR: allowedAttrs,
|
ALLOWED_ATTR: allowedAttrs,
|
||||||
});
|
});
|
||||||
|
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
|
||||||
|
setCachedMarkdown(input, sanitized);
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(value: string): string {
|
function escapeHtml(value: string): string {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
|
import { guard } from "lit/directives/guard.js";
|
||||||
import { repeat } from "lit/directives/repeat.js";
|
import { repeat } from "lit/directives/repeat.js";
|
||||||
import type { SessionsListResult } from "../types";
|
import type { SessionsListResult } from "../types";
|
||||||
import type { ChatQueueItem } from "../ui-types";
|
import type { ChatQueueItem } from "../ui-types";
|
||||||
@@ -7,7 +8,7 @@ import {
|
|||||||
normalizeMessage,
|
normalizeMessage,
|
||||||
normalizeRoleForGrouping,
|
normalizeRoleForGrouping,
|
||||||
} from "../chat/message-normalizer";
|
} from "../chat/message-normalizer";
|
||||||
import { extractText } from "../chat/message-extract";
|
import { extractTextCached } from "../chat/message-extract";
|
||||||
import {
|
import {
|
||||||
renderMessageGroup,
|
renderMessageGroup,
|
||||||
renderReadingIndicatorGroup,
|
renderReadingIndicatorGroup,
|
||||||
@@ -114,6 +115,56 @@ export function renderChat(props: ChatProps) {
|
|||||||
|
|
||||||
const splitRatio = props.splitRatio ?? 0.6;
|
const splitRatio = props.splitRatio ?? 0.6;
|
||||||
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
|
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`
|
return html`
|
||||||
<section class="card chat">
|
<section class="card chat">
|
||||||
@@ -148,41 +199,7 @@ export function renderChat(props: ChatProps) {
|
|||||||
class="chat-main"
|
class="chat-main"
|
||||||
style="flex: ${sidebarOpen ? `0 0 ${splitRatio * 100}%` : "1 1 100%"}"
|
style="flex: ${sidebarOpen ? `0 0 ${splitRatio * 100}%` : "1 1 100%"}"
|
||||||
>
|
>
|
||||||
<div
|
${thread}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${sidebarOpen
|
${sidebarOpen
|
||||||
@@ -379,7 +396,8 @@ function messageKey(message: unknown, index: number): string {
|
|||||||
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
|
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
|
||||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||||
const fingerprint =
|
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 seed = fingerprint ?? safeJson(message) ?? String(index);
|
||||||
const hash = fnv1a(seed);
|
const hash = fnv1a(seed);
|
||||||
return timestamp ? `msg:${role}:${timestamp}:${hash}` : `msg:${role}:${hash}`;
|
return timestamp ? `msg:${role}:${timestamp}:${hash}` : `msg:${role}:${hash}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user