ui: rendering performance follow-up (#26097)

This commit is contained in:
Aleksander Grygier
2026-07-28 17:13:25 +02:00
committed by GitHub
parent ad77bd31a6
commit 6e2bc65fb2
10 changed files with 538 additions and 134 deletions
@@ -11,8 +11,7 @@
classifyToolResult, classifyToolResult,
formatJsonPretty, formatJsonPretty,
parseToolResultWithImages, parseToolResultWithImages,
type AgenticSection, type AgenticSection
type ToolResultLine
} from '$lib/utils'; } from '$lib/utils';
import { getBuiltinToolUi } from '$lib/constants/built-in-tools'; import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
import type { DatabaseMessageExtra } from '$lib/types'; import type { DatabaseMessageExtra } from '$lib/types';
@@ -29,11 +28,10 @@
let { section, open, isStreaming, attachments, onToggle }: Props = $props(); let { section, open, isStreaming, attachments, onToggle }: Props = $props();
const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? ''); const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? '');
const outputKind = $derived(classifyToolResult(section.toolResult));
const parsedLines: ToolResultLine[] = $derived( const parsedLines = $derived(
section.toolResult ? parseToolResultWithImages(section.toolResult, attachments) : [] section.toolResult ? parseToolResultWithImages(section.toolResult, attachments) : []
); );
const outputKind = $derived(classifyToolResult(section.toolResult));
</script> </script>
<ToolCallBlock {section} {open} {isStreaming} meta={null} {title} {onToggle}> <ToolCallBlock {section} {open} {isStreaming} meta={null} {title} {onToggle}>
@@ -15,7 +15,6 @@
let { section, open, isStreaming, onToggle }: Props = $props(); let { section, open, isStreaming, onToggle }: Props = $props();
const editFileMeta = $derived(parseEditFileMeta(section)); const editFileMeta = $derived(parseEditFileMeta(section));
const editDiffs = $derived( const editDiffs = $derived(
(editFileMeta?.edits ?? []).map((edit) => computeLineDiff(edit.oldText, edit.newText)) (editFileMeta?.edits ?? []).map((edit) => computeLineDiff(edit.oldText, edit.newText))
); );
@@ -27,7 +27,7 @@
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING); const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
const showSpinner = $derived(isPending || (isStreamingCall && isStreaming)); const showSpinner = $derived(isPending || (isStreamingCall && isStreaming));
const results: SearchResult[] = $derived(extractSearchResults(section.toolResult)); const results = $derived(extractSearchResults(section.toolResult));
const query = $derived(extractSearchQuery(section.toolArgs)); const query = $derived(extractSearchQuery(section.toolArgs));
// Same icon-resolution chain as ChatMessageToolCallBlockDefault so // Same icon-resolution chain as ChatMessageToolCallBlockDefault so
@@ -28,6 +28,18 @@ export const LATEX_MATH_AND_CODE_PATTERN =
/** Regex to capture the content of a $$...\\\\...$$ block (display-formula with line-break) */ /** Regex to capture the content of a $$...\\\\...$$ block (display-formula with line-break) */
export const LATEX_LINEBREAK_REGEXP = /\$\$([\s\S]*?\\\\[\s\S]*?)\$\$/; export const LATEX_LINEBREAK_REGEXP = /\$\$([\s\S]*?\\\\[\s\S]*?)\$\$/;
/**
* Matches the unescaped `\[...\]` display-math delimiter and surrounding
* context so callers can insert line-breaks around the placeholder or convert
* to inline when the formula has a non-empty trailing context (e.g. a table
* cell that opens with `\[` and closes with content after `\]`).
*
* group 1: prefix before `\[`
* group 2: formula body
* group 3: trailing context after `\]`
*/
export const LATEX_DISPLAY_BLOCK_REGEXP = /([\S].*?)\\\[([\s\S]*?)\\\](.*)/g;
/** /**
* Cheap gate for `preprocessLaTeX`. Every transformation it performs is triggered * Cheap gate for `preprocessLaTeX`. Every transformation it performs is triggered
* by a `$` (inline/display math, currency escaping) or a backslash escape * by a `$` (inline/display math, currency escaping) or a backslash escape
@@ -36,6 +48,76 @@ export const LATEX_LINEBREAK_REGEXP = /\$\$([\s\S]*?\\\\[\s\S]*?)\$\$/;
*/ */
export const LATEX_TRIGGER_REGEXP = /[$\\]/; export const LATEX_TRIGGER_REGEXP = /[$\\]/;
/** Inline LaTeX math delimiter (the dollar sign). */
export const LATEX_INLINE_DELIMITER = '$';
/** Display LaTeX math delimiter (paired dollar signs). */
export const LATEX_DISPLAY_DELIMITER = '$$';
/** Matches a single non-whitespace character. */
export const LATEX_NON_WHITESPACE_REGEXP = /\S/;
/** Matches a character that may appear adjacent to `$`, indicating a non-TeX
* context such as an identifier (`var$`, `$var`), currency ($5), or code. */
export const LATEX_NEIGHBOR_CHAR_REGEXP = /[A-Za-z0-9_$-]/;
/** Matches a single digit (used to detect currency-like `$5`). */
export const LATEX_DIGIT_REGEXP = /[0-9]/;
/** Matches the leading blockquote prefix (`> ` or `>`) on a markdown line. */
export const LATEX_BLOCKQUOTE_PREFIX_REGEXP = /^(>\s*)/;
/** Matches the placeholder inserted by the protect/restore pipeline for a
* protected LaTeX expression. Group 1 is the index into `latexExpressions`. */
export const LATEX_PLACEHOLDER_REGEXP = /<<LATEX_(\d+)>>/g;
/** Matches the placeholder inserted by the protect/restore pipeline for a
* protected code block. Group 1 is the index into `codeBlocks`. */
export const CODE_BLOCK_PLACEHOLDER_REGEXP = /<<CODE_BLOCK_(\d+)>>/g;
/** Matches a `$` immediately followed by a digit, which is treated as a
* currency amount (e.g. `$5`) and escaped to `\$5` so it isn't parsed as math. */
export const LATEX_CURRENCY_DOLLAR_REGEXP = /\$(?=\d)/g;
/** Captures remaining `$$...$$`, `\[...\]`, `\(...\)` (only unescaped via
* `(?<!\\)`) after the display-block pass has run. Group 1 holds the
* matched formula. */
export const LATEX_PROTECT_REGEXP =
/(\$\$[\s\S]*?\$\$|(?<!\\)\\\[[\s\S]*?\\\]|(?<!\\)\\\(.*?\\\))/g;
/** Matches unescaped inline `\(...\)` (at least one char inside) used to
* convert `\(` → `$` after the protect pass. */
export const LATEX_INLINE_CONVERT_REGEXP = /(?<!\\)\\\((.+?)\\\)/g;
/** Matches unescaped display `\[...\]` used to convert `\[` → `$$`
* after the protect pass. */
export const LATEX_DISPLAY_CONVERT_REGEXP = /(?<!\\)\\\[([\s\S]*?)\\\]/g;
/** `\(` — opens an inline LaTeX math block. */
export const LATEX_INLINE_OPEN = '\\(';
/** `\)` — closes an inline LaTeX math block. */
export const LATEX_INLINE_CLOSE = '\\)';
/** `\[` — opens a display LaTeX math block. */
export const LATEX_DISPLAY_OPEN = '\\[';
/** `\]` — closes a display LaTeX math block. */
export const LATEX_DISPLAY_CLOSE = '\\]';
/** `\` — the LaTeX escape character. */
export const LATEX_BACKSLASH = '\\';
/** `\$` — dollar sign escaped so it isn't parsed as math (used to disambiguate
* currency amounts like `$5`). */
export const LATEX_CURRENCY_ESCAPE = '\\$';
/** `\ce{` — mhchem chemistry command prefix. */
export const LATEX_MHCHEM_CE = '\\ce{';
/** `\pu{` — mhchem physics-unit command prefix. */
export const LATEX_MHCHEM_PU = '\\pu{';
/** map from mchem-regexp to replacement */ /** map from mchem-regexp to replacement */
export const MHCHEM_PATTERN_MAP: readonly [RegExp, string][] = [ export const MHCHEM_PATTERN_MAP: readonly [RegExp, string][] = [
[/(\s)\$\\ce{/g, '$1$\\\\ce{'], [/(\s)\$\\ce{/g, '$1$\\\\ce{'],
+76 -9
View File
@@ -92,8 +92,17 @@ function deriveSingleTurnSections(
// 3. Persisted tool calls (from message.toolCalls field) // 3. Persisted tool calls (from message.toolCalls field)
const toolCalls = parseToolCalls(message.toolCalls); const toolCalls = parseToolCalls(message.toolCalls);
// Index tool messages by toolCallId for O(1) lookup instead of O(n) find()
const toolMsgById = new Map<string, DatabaseMessage>();
for (const tm of toolMessages) {
if (tm.toolCallId && !toolMsgById.has(tm.toolCallId)) {
toolMsgById.set(tm.toolCallId, tm);
}
}
for (const tc of toolCalls) { for (const tc of toolCalls) {
const resultMsg = toolMessages.find((m) => m.toolCallId === tc.id); const resultMsg = tc.id ? toolMsgById.get(tc.id) : undefined;
// Only show as pending/loading if we're actively streaming; otherwise it's just a tool call without result // Only show as pending/loading if we're actively streaming; otherwise it's just a tool call without result
const type = resultMsg const type = resultMsg
? AgenticSectionType.TOOL_CALL ? AgenticSectionType.TOOL_CALL
@@ -112,9 +121,10 @@ function deriveSingleTurnSections(
} }
// 4. Streaming tool calls (not yet persisted - currently being received) // 4. Streaming tool calls (not yet persisted - currently being received)
const persistedIds = new Set(toolCalls.map((t) => t.id).filter(Boolean));
for (const tc of streamingToolCalls) { for (const tc of streamingToolCalls) {
// Skip if already in persisted tool calls // Skip if already in persisted tool calls
if (tc.id && toolCalls.find((t) => t.id === tc.id)) continue; if (tc.id && persistedIds.has(tc.id)) continue;
sections.push({ sections.push({
type: AgenticSectionType.TOOL_CALL_STREAMING, type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '', content: '',
@@ -281,15 +291,31 @@ export function splitSearchSummaryList(
return { lines }; return { lines };
} }
/** Bounded cache for parseToolResultWithImages results. */
const TOOL_RESULT_LINES_CACHE_MAX_SIZE = 32;
const toolResultLinesCache = new Map<string, ToolResultLine[]>();
/** /**
* Parse tool result text into lines, matching image attachments by name. * Parse tool result text into lines, matching image attachments by name.
* Memoized: called per render during streaming on unchanged tool result
* strings with unchanged extras.
*/ */
export function parseToolResultWithImages( export function parseToolResultWithImages(
toolResult: string, toolResult: string,
extras?: DatabaseMessageExtra[] extras?: DatabaseMessageExtra[]
): ToolResultLine[] { ): ToolResultLine[] {
// Cache key includes image attachment names so we recompute when
// attachments change, even if the count stays the same.
const imageNames = (extras ?? [])
.filter((e): e is DatabaseMessageExtraImageFile => e.type === AttachmentType.IMAGE)
.map((e) => e.name)
.join(NEWLINE);
const cacheKey = `${imageNames}:${toolResult}`;
const cached = toolResultLinesCache.get(cacheKey);
if (cached !== undefined) return cached;
const lines = toolResult.split(NEWLINE); const lines = toolResult.split(NEWLINE);
return lines.map((line) => { const result = lines.map((line) => {
const match = line.match(ATTACHMENT_SAVED_REGEX); const match = line.match(ATTACHMENT_SAVED_REGEX);
if (!match || !extras) return { text: line }; if (!match || !extras) return { text: line };
@@ -301,8 +327,19 @@ export function parseToolResultWithImages(
return { text: line, image }; return { text: line, image };
}); });
if (toolResultLinesCache.size >= TOOL_RESULT_LINES_CACHE_MAX_SIZE) {
toolResultLinesCache.delete(toolResultLinesCache.keys().next().value!);
}
toolResultLinesCache.set(cacheKey, result);
return result;
} }
/** Bounded cache for classifyToolResult results. */
const CLASSIFY_CACHE_MAX_SIZE = 32;
const classifyCache = new Map<string, ToolResultKind>();
/** /**
* Pick a renderer tier for a tool's result content. * Pick a renderer tier for a tool's result content.
* *
@@ -312,25 +349,39 @@ export function parseToolResultWithImages(
* through MarkdownContent for proper formatting. * through MarkdownContent for proper formatting.
* text - everything else, rendered as plain text lines (with image * text - everything else, rendered as plain text lines (with image
* attachment resolution as a side effect). * attachment resolution as a side effect).
* Memoized: called per render during streaming on unchanged content.
*/ */
export function classifyToolResult(content: string | undefined): ToolResultKind { export function classifyToolResult(content: string | undefined): ToolResultKind {
if (!content) return ToolResultKind.TEXT; if (!content) return ToolResultKind.TEXT;
const cached = classifyCache.get(content);
if (cached !== undefined) return cached;
const trimmed = content.trim(); const trimmed = content.trim();
if (!trimmed) return ToolResultKind.TEXT; if (!trimmed) return ToolResultKind.TEXT;
let result: ToolResultKind = ToolResultKind.TEXT;
// Strongest signal: JSON object/array round-trips through JSON.parse. // Strongest signal: JSON object/array round-trips through JSON.parse.
if (TOOL_RESULT_JSON_OPEN_REGEX.test(trimmed)) { if (TOOL_RESULT_JSON_OPEN_REGEX.test(trimmed)) {
try { try {
JSON.parse(trimmed); JSON.parse(trimmed);
return ToolResultKind.JSON; result = ToolResultKind.JSON;
} catch (error) { } catch (error) {
console.error('[agentic] tool result looked like JSON but failed to parse:', error); console.error('[agentic] tool result looked like JSON but failed to parse:', error);
} }
} }
if (looksLikeMarkdown(trimmed)) return ToolResultKind.MARKDOWN; if (result === ToolResultKind.TEXT && looksLikeMarkdown(trimmed)) {
result = ToolResultKind.MARKDOWN;
}
return ToolResultKind.TEXT; if (classifyCache.size >= CLASSIFY_CACHE_MAX_SIZE) {
classifyCache.delete(classifyCache.keys().next().value!);
}
classifyCache.set(content, result);
return result;
} }
/** /**
@@ -370,19 +421,35 @@ function looksLikeMarkdown(content: string): boolean {
return false; return false;
} }
/** Bounded cache for parsed tool-call JSON blobs. */
const TOOL_CALLS_CACHE_MAX_SIZE = 64;
const toolCallsParseCache = new Map<string, ApiChatCompletionToolCall[]>();
/** /**
* Safely parse the toolCalls JSON string from a DatabaseMessage. * Safely parse the toolCalls JSON string from a DatabaseMessage.
* Memoized: the same JSON string is re-parsed on every render during
* streaming, which is wasted CPU since tool calls don't change mid-stream.
*/ */
function parseToolCalls(toolCallsJson?: string): ApiChatCompletionToolCall[] { function parseToolCalls(toolCallsJson?: string): ApiChatCompletionToolCall[] {
if (!toolCallsJson) return []; if (!toolCallsJson) return [];
const cached = toolCallsParseCache.get(toolCallsJson);
if (cached) return cached;
let result: ApiChatCompletionToolCall[];
try { try {
const parsed = JSON.parse(toolCallsJson); const parsed = JSON.parse(toolCallsJson);
result = Array.isArray(parsed) ? parsed : [];
return Array.isArray(parsed) ? parsed : [];
} catch { } catch {
return []; result = [];
} }
if (toolCallsParseCache.size >= TOOL_CALLS_CACHE_MAX_SIZE) {
toolCallsParseCache.delete(toolCallsParseCache.keys().next().value!);
}
toolCallsParseCache.set(toolCallsJson, result);
return result;
} }
/** /**
+23 -5
View File
@@ -34,6 +34,10 @@ function escapeCode(code: string): string {
return code.replace(AMPERSAND_REGEX, '&amp;').replace(LT_REGEX, '&lt;').replace(GT_REGEX, '&gt;'); return code.replace(AMPERSAND_REGEX, '&amp;').replace(LT_REGEX, '&lt;').replace(GT_REGEX, '&gt;');
} }
/** Bounded cache for highlightCode results. */
const HIGHLIGHT_CACHE_MAX_SIZE = 64;
const highlightCache = new Map<string, string>();
/** /**
* Highlights code using highlight.js * Highlights code using highlight.js
* @param code - The code to highlight * @param code - The code to highlight
@@ -47,23 +51,37 @@ function escapeCode(code: string): string {
export function highlightCode(code: string, language: string, autoDetect = true): string { export function highlightCode(code: string, language: string, autoDetect = true): string {
if (!code) return ''; if (!code) return '';
// Cache key includes language and autoDetect flag since results differ.
// During streaming, the same code string may be highlighted repeatedly
// (e.g., when text after a code block changes but the code itself doesn't).
const cacheKey = `${language}:${autoDetect}:${code}`;
const cached = highlightCache.get(cacheKey);
if (cached) return cached;
const trimmed = trimCodePadding(code); const trimmed = trimCodePadding(code);
let result: string;
try { try {
const lang = language.toLowerCase(); const lang = language.toLowerCase();
const isSupported = hljs.getLanguage(lang); const isSupported = hljs.getLanguage(lang);
if (isSupported) { if (isSupported) {
return hljs.highlight(trimmed, { language: lang }).value; result = hljs.highlight(trimmed, { language: lang }).value;
} else if (autoDetect) { } else if (autoDetect) {
return hljs.highlightAuto(trimmed).value; result = hljs.highlightAuto(trimmed).value;
} else { } else {
return escapeCode(trimmed); result = escapeCode(trimmed);
} }
} catch { } catch {
// Fallback to escaped plain text result = escapeCode(trimmed);
return escapeCode(trimmed);
} }
if (highlightCache.size >= HIGHLIGHT_CACHE_MAX_SIZE) {
highlightCache.delete(highlightCache.keys().next().value!);
}
highlightCache.set(cacheKey, result);
return result;
} }
export { trimCodePadding }; export { trimCodePadding };
+84 -44
View File
@@ -1,9 +1,31 @@
import { import {
CODE_BLOCK_PLACEHOLDER_REGEXP,
CODE_BLOCK_REGEXP, CODE_BLOCK_REGEXP,
LATEX_BACKSLASH,
LATEX_BLOCKQUOTE_PREFIX_REGEXP,
LATEX_CURRENCY_DOLLAR_REGEXP,
LATEX_CURRENCY_ESCAPE,
LATEX_DIGIT_REGEXP,
LATEX_DISPLAY_BLOCK_REGEXP,
LATEX_DISPLAY_CLOSE,
LATEX_DISPLAY_CONVERT_REGEXP,
LATEX_DISPLAY_DELIMITER,
LATEX_DISPLAY_OPEN,
LATEX_INLINE_CLOSE,
LATEX_INLINE_CONVERT_REGEXP,
LATEX_INLINE_DELIMITER,
LATEX_INLINE_OPEN,
LATEX_MATH_AND_CODE_PATTERN, LATEX_MATH_AND_CODE_PATTERN,
LATEX_MHCHEM_CE,
LATEX_MHCHEM_PU,
LATEX_LINEBREAK_REGEXP, LATEX_LINEBREAK_REGEXP,
LATEX_NEIGHBOR_CHAR_REGEXP,
LATEX_NON_WHITESPACE_REGEXP,
LATEX_PLACEHOLDER_REGEXP,
LATEX_PROTECT_REGEXP,
LATEX_TRIGGER_REGEXP, LATEX_TRIGGER_REGEXP,
MHCHEM_PATTERN_MAP MHCHEM_PATTERN_MAP,
NEWLINE
} from '$lib/constants'; } from '$lib/constants';
/** /**
@@ -20,13 +42,13 @@ import {
* @returns The processed string with LaTeX replaced by placeholders. * @returns The processed string with LaTeX replaced by placeholders.
*/ */
export function maskInlineLaTeX(content: string, latexExpressions: string[]): string { export function maskInlineLaTeX(content: string, latexExpressions: string[]): string {
if (!content.includes('$')) { if (!content.includes(LATEX_INLINE_DELIMITER)) {
return content; return content;
} }
return content return content
.split('\n') .split(NEWLINE)
.map((line) => { .map((line) => {
if (line.indexOf('$') == -1) { if (line.indexOf(LATEX_INLINE_DELIMITER) == -1) {
return line; return line;
} }
@@ -34,7 +56,7 @@ export function maskInlineLaTeX(content: string, latexExpressions: string[]): st
let currentPosition = 0; let currentPosition = 0;
while (currentPosition < line.length) { while (currentPosition < line.length) {
const openDollarIndex = line.indexOf('$', currentPosition); const openDollarIndex = line.indexOf(LATEX_INLINE_DELIMITER, currentPosition);
if (openDollarIndex == -1) { if (openDollarIndex == -1) {
processedLine += line.slice(currentPosition); processedLine += line.slice(currentPosition);
@@ -42,7 +64,7 @@ export function maskInlineLaTeX(content: string, latexExpressions: string[]): st
} }
// Is there a next $-sign? // Is there a next $-sign?
const closeDollarIndex = line.indexOf('$', openDollarIndex + 1); const closeDollarIndex = line.indexOf(LATEX_INLINE_DELIMITER, openDollarIndex + 1);
if (closeDollarIndex == -1) { if (closeDollarIndex == -1) {
processedLine += line.slice(currentPosition); processedLine += line.slice(currentPosition);
@@ -62,14 +84,14 @@ export function maskInlineLaTeX(content: string, latexExpressions: string[]): st
shouldSkipAsNonLatex = true; shouldSkipAsNonLatex = true;
} }
if (/[A-Za-z0-9_$-]/.test(charBeforeOpen)) { if (LATEX_NEIGHBOR_CHAR_REGEXP.test(charBeforeOpen)) {
// Character, digit, $, _ or - before first '$', no TeX. // Character, digit, $, _ or - before first '$', no TeX.
shouldSkipAsNonLatex = true; shouldSkipAsNonLatex = true;
} }
if ( if (
/[0-9]/.test(charAfterOpen) && LATEX_DIGIT_REGEXP.test(charAfterOpen) &&
(/[A-Za-z0-9_$-]/.test(charAfterClose) || ' ' == charBeforeClose) (LATEX_NEIGHBOR_CHAR_REGEXP.test(charAfterClose) || ' ' == charBeforeClose)
) { ) {
// First $ seems to belong to an amount. // First $ seems to belong to an amount.
shouldSkipAsNonLatex = true; shouldSkipAsNonLatex = true;
@@ -92,7 +114,7 @@ export function maskInlineLaTeX(content: string, latexExpressions: string[]): st
return processedLine; return processedLine;
}) })
.join('\n'); .join(NEWLINE);
} }
function escapeBrackets(text: string): string { function escapeBrackets(text: string): string {
@@ -107,9 +129,9 @@ function escapeBrackets(text: string): string {
if (codeBlock != null) { if (codeBlock != null) {
return codeBlock; return codeBlock;
} else if (squareBracket != null) { } else if (squareBracket != null) {
return `$$${squareBracket}$$`; return `${LATEX_DISPLAY_DELIMITER}${squareBracket}${LATEX_DISPLAY_DELIMITER}`;
} else if (roundBracket != null) { } else if (roundBracket != null) {
return `$${roundBracket}$`; return `${LATEX_INLINE_DELIMITER}${roundBracket}${LATEX_INLINE_DELIMITER}`;
} }
return match; return match;
@@ -145,32 +167,49 @@ const doEscapeMhchem = false;
* preprocessLaTeX("Price: $10. The equation is \\(x^2\\).") * preprocessLaTeX("Price: $10. The equation is \\(x^2\\).")
* // → "Price: $10. The equation is $x^2$." * // → "Price: $10. The equation is $x^2$."
*/ */
/** Bounded cache for preprocessLaTeX results. */
const LATEX_CACHE_MAX_SIZE = 64;
const latexCache = new Map<string, string>();
export function preprocessLaTeX(content: string): string { export function preprocessLaTeX(content: string): string {
// See also: // See also:
// https://github.com/danny-avila/LibreChat/blob/main/client/src/utils/latex.ts // https://github.com/danny-avila/LibreChat/blob/main/client/src/utils/latex.ts
// Memoize on the input string. During streaming the prefix before an
// incomplete code block stays the same across multiple tokens, so the
// full protect/restore pipeline would re-run unnecessarily.
const cached = latexCache.get(content);
if (cached !== undefined) return cached;
// Save original before the function mutates `content` through steps 0-8
const originalContent = content;
// Every step below keys off a `$` or a backslash escape (\[ \] \( \) \ce{ \pu{). // Every step below keys off a `$` or a backslash escape (\[ \] \( \) \ce{ \pu{).
// With neither present the protect/restore passes round-trip the input // With neither present the protect/restore passes round-trip the input
// unchanged, so skip them: the step 2 scan is O(n^2) in line length and costs // unchanged, so skip them: the step 2 scan is O(n^2) in line length and costs
// ~90ms on a 26KB single-line message that contains no math at all. This // ~90ms on a 26KB single-line message that contains no math at all. This
// matters during streaming, where the whole message is reprocessed per frame. // matters during streaming, where the whole message is reprocessed per frame.
if (!LATEX_TRIGGER_REGEXP.test(content)) { if (!LATEX_TRIGGER_REGEXP.test(content)) {
if (latexCache.size >= LATEX_CACHE_MAX_SIZE) {
latexCache.delete(latexCache.keys().next().value!);
}
latexCache.set(originalContent, content);
return content; return content;
} }
// Step 0: Temporarily remove blockquote markers (>) to process LaTeX correctly // Step 0: Temporarily remove blockquote markers (>) to process LaTeX correctly
// Store the structure so we can restore it later // Store the structure so we can restore it later
const blockquoteMarkers: Map<number, string> = new Map(); const blockquoteMarkers: Map<number, string> = new Map();
const lines = content.split('\n'); const lines = content.split(NEWLINE);
const processedLines = lines.map((line, index) => { const processedLines = lines.map((line, index) => {
const match = line.match(/^(>\s*)/); const match = line.match(LATEX_BLOCKQUOTE_PREFIX_REGEXP);
if (match) { if (match) {
blockquoteMarkers.set(index, match[1]); blockquoteMarkers.set(index, match[1]);
return line.slice(match[1].length); return line.slice(match[1].length);
} }
return line; return line;
}); });
content = processedLines.join('\n'); content = processedLines.join(NEWLINE);
// Step 1: Protect code blocks // Step 1: Protect code blocks
const codeBlocks: string[] = []; const codeBlocks: string[] = [];
@@ -187,58 +226,52 @@ export function preprocessLaTeX(content: string): string {
// Match \S...\[...\] and protect them and insert a line-break. // Match \S...\[...\] and protect them and insert a line-break.
// Guarded: with no `\[` present this pattern still probes every start offset, // Guarded: with no `\[` present this pattern still probes every start offset,
// expanding `.*?` to the end of each line before failing - O(n^2) for nothing. // expanding `.*?` to the end of each line before failing - O(n^2) for nothing.
if (content.includes('\\[')) { if (content.includes(LATEX_DISPLAY_OPEN)) {
content = content.replace( content = content.replace(LATEX_DISPLAY_BLOCK_REGEXP, (match, group1, group2, group3) => {
/([\S].*?)\\\[([\s\S]*?)\\\](.*)/g,
(match, group1, group2, group3) => {
// Check if there are characters following the formula (display-formula in a table-cell?) // Check if there are characters following the formula (display-formula in a table-cell?)
if (group1.endsWith('\\')) { if (group1.endsWith(LATEX_BACKSLASH)) {
return match; // Backslash before \[, do nothing. return match; // Backslash before \[, do nothing.
} }
const hasSuffix = /\S/.test(group3); const hasSuffix = LATEX_NON_WHITESPACE_REGEXP.test(group3);
let optBreak; let optBreak;
if (hasSuffix) { if (hasSuffix) {
latexExpressions.push(`\\(${group2.trim()}\\)`); // Convert into inline. latexExpressions.push(`${LATEX_INLINE_OPEN}${group2.trim()}${LATEX_INLINE_CLOSE}`); // Convert into inline.
optBreak = ''; optBreak = '';
} else { } else {
latexExpressions.push(`\\[${group2}\\]`); latexExpressions.push(`${LATEX_DISPLAY_OPEN}${group2}${LATEX_DISPLAY_CLOSE}`);
optBreak = '\n'; optBreak = NEWLINE;
} }
return `${group1}${optBreak}<<LATEX_${latexExpressions.length - 1}>>${optBreak}${group3}`; return `${group1}${optBreak}<<LATEX_${latexExpressions.length - 1}>>${optBreak}${group3}`;
} });
);
} }
// Match \(...\), \[...\], $$...$$ and protect them // Match \(...\), \[...\], $$...$$ and protect them
content = content.replace( content = content.replace(LATEX_PROTECT_REGEXP, (match) => {
/(\$\$[\s\S]*?\$\$|(?<!\\)\\\[[\s\S]*?\\\]|(?<!\\)\\\(.*?\\\))/g,
(match) => {
latexExpressions.push(match); latexExpressions.push(match);
return `<<LATEX_${latexExpressions.length - 1}>>`; return `<<LATEX_${latexExpressions.length - 1}>>`;
} });
);
// Protect inline $...$ but NOT if it looks like money (e.g., $10, $3.99) // Protect inline $...$ but NOT if it looks like money (e.g., $10, $3.99)
content = maskInlineLaTeX(content, latexExpressions); content = maskInlineLaTeX(content, latexExpressions);
// Step 3: Escape standalone $ before digits (currency like $5 → \$5) // Step 3: Escape standalone $ before digits (currency like $5 → \$5)
// (Now that inline math is protected, this will only escape dollars not already protected) // (Now that inline math is protected, this will only escape dollars not already protected)
content = content.replace(/\$(?=\d)/g, '\\$'); content = content.replace(LATEX_CURRENCY_DOLLAR_REGEXP, LATEX_CURRENCY_ESCAPE);
// Step 4: Restore protected LaTeX expressions (they are valid) // Step 4: Restore protected LaTeX expressions (they are valid)
content = content.replace(/<<LATEX_(\d+)>>/g, (_, index) => { content = content.replace(LATEX_PLACEHOLDER_REGEXP, (_, index) => {
let expr = latexExpressions[parseInt(index)]; let expr = latexExpressions[parseInt(index)];
const match = expr.match(LATEX_LINEBREAK_REGEXP); const match = expr.match(LATEX_LINEBREAK_REGEXP);
if (match) { if (match) {
// Katex: The $$-delimiters should be in their own line // Katex: The $$-delimiters should be in their own line
// if there are \\-line-breaks. // if there are \\-line-breaks.
const formula = match[1]; const formula = match[1];
const prefix = formula.startsWith('\n') ? '' : '\n'; const prefix = formula.startsWith(NEWLINE) ? '' : NEWLINE;
const suffix = formula.endsWith('\n') ? '' : '\n'; const suffix = formula.endsWith(NEWLINE) ? '' : NEWLINE;
expr = '$$' + prefix + formula + suffix + '$$'; expr = LATEX_DISPLAY_DELIMITER + prefix + formula + suffix + LATEX_DISPLAY_DELIMITER;
} }
return expr; return expr;
}); });
@@ -247,7 +280,7 @@ export function preprocessLaTeX(content: string): string {
// This must happen BEFORE restoring code blocks to avoid affecting code content // This must happen BEFORE restoring code blocks to avoid affecting code content
content = escapeBrackets(content); content = escapeBrackets(content);
if (doEscapeMhchem && (content.includes('\\ce{') || content.includes('\\pu{'))) { if (doEscapeMhchem && (content.includes(LATEX_MHCHEM_CE) || content.includes(LATEX_MHCHEM_PU))) {
content = escapeMhchem(content); content = escapeMhchem(content);
} }
@@ -257,31 +290,38 @@ export function preprocessLaTeX(content: string): string {
// Using the lookbehind pattern `(?<!\\)` we skip matches // Using the lookbehind pattern `(?<!\\)` we skip matches
// that are preceded by a backslash, e.g. // that are preceded by a backslash, e.g.
// `Definitions\\(also called macros)` (title of chapter 20 in The TeXbook). // `Definitions\\(also called macros)` (title of chapter 20 in The TeXbook).
.replace(/(?<!\\)\\\((.+?)\\\)/g, '$$$1$') // inline .replace(LATEX_INLINE_CONVERT_REGEXP, (_, formula: string) => {
return `${LATEX_INLINE_DELIMITER}${formula}${LATEX_INLINE_DELIMITER}`;
}) // inline
.replace( .replace(
// Using the lookbehind pattern `(?<!\\)` we skip matches // Using the lookbehind pattern `(?<!\\)` we skip matches
// that are preceded by a backslash, e.g. `\\[4pt]`. // that are preceded by a backslash, e.g. `\\[4pt]`.
/(?<!\\)\\\[([\s\S]*?)\\\]/g, // display, see also PR #16599 LATEX_DISPLAY_CONVERT_REGEXP, // display, see also PR #16599
(_, content: string) => { (_, formula: string) => {
return `$$${content}$$`; return `${LATEX_DISPLAY_DELIMITER}${formula}${LATEX_DISPLAY_DELIMITER}`;
} }
); );
// Step 7: Restore code blocks // Step 7: Restore code blocks
// This happens AFTER all LaTeX conversions to preserve code content // This happens AFTER all LaTeX conversions to preserve code content
content = content.replace(/<<CODE_BLOCK_(\d+)>>/g, (_, index) => { content = content.replace(CODE_BLOCK_PLACEHOLDER_REGEXP, (_, index) => {
return codeBlocks[parseInt(index)]; return codeBlocks[parseInt(index)];
}); });
// Step 8: Restore blockquote markers // Step 8: Restore blockquote markers
if (blockquoteMarkers.size > 0) { if (blockquoteMarkers.size > 0) {
const finalLines = content.split('\n'); const finalLines = content.split(NEWLINE);
const restoredLines = finalLines.map((line, index) => { const restoredLines = finalLines.map((line, index) => {
const marker = blockquoteMarkers.get(index); const marker = blockquoteMarkers.get(index);
return marker ? marker + line : line; return marker ? marker + line : line;
}); });
content = restoredLines.join('\n'); content = restoredLines.join(NEWLINE);
} }
if (latexCache.size >= LATEX_CACHE_MAX_SIZE) {
latexCache.delete(latexCache.keys().next().value!);
}
latexCache.set(originalContent, content);
return content; return content;
} }
@@ -14,18 +14,44 @@ const JSON_ARRAY_CLOSE = ']';
// comma when the model cut off mid-key. // comma when the model cut off mid-key.
const TRAILING_JSON_PUNCTUATION_REGEX = /,?\s*$/; const TRAILING_JSON_PUNCTUATION_REGEX = /,?\s*$/;
/** Bounded cache for parsePartialJsonArgs results. */
const PARTIAL_JSON_CACHE_MAX_SIZE = 32;
const partialJsonCache = new Map<string, Record<string, unknown> | null>();
function cacheResult(input: string, result: Record<string, unknown> | null): void {
if (partialJsonCache.size >= PARTIAL_JSON_CACHE_MAX_SIZE) {
partialJsonCache.delete(partialJsonCache.keys().next().value!);
}
partialJsonCache.set(input, result);
}
// Parse partial tool-arg JSON streamed token-by-token. Closes any // Parse partial tool-arg JSON streamed token-by-token. Closes any
// unterminated string and dangling open containers (in reverse order), // unterminated string and dangling open containers (in reverse order),
// so parsers can still surface keys already received while the call // so parsers can still surface keys already received while the call
// is still in flight. // is still in flight. Memoized: the char-by-char scanner runs on every
// render during streaming even when toolArgs hasn't changed.
export function parsePartialJsonArgs(toolArgsString: string): Record<string, unknown> | null { export function parsePartialJsonArgs(toolArgsString: string): Record<string, unknown> | null {
const cached = partialJsonCache.get(toolArgsString);
if (cached !== undefined) return cached;
let result: Record<string, unknown> | null;
try { try {
const parsed: unknown = JSON.parse(toolArgsString); const parsed: unknown = JSON.parse(toolArgsString);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { result =
return parsed as Record<string, unknown>; parsed && typeof parsed === 'object' && !Array.isArray(parsed)
} ? (parsed as Record<string, unknown>)
return null; : null;
} catch { } catch {
result = scanPartialJson(toolArgsString);
}
cacheResult(toolArgsString, result);
return result;
}
/** Char-by-char scanner for unterminated partial JSON. */
function scanPartialJson(toolArgsString: string): Record<string, unknown> | null {
let inString = false; let inString = false;
let escape = false; let escape = false;
const stack: ('{' | '[')[] = []; const stack: ('{' | '[')[] = [];
@@ -72,12 +98,10 @@ export function parsePartialJsonArgs(toolArgsString: string): Record<string, unk
try { try {
const parsed: unknown = JSON.parse(completed); const parsed: unknown = JSON.parse(completed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
return parsed as Record<string, unknown>; ? (parsed as Record<string, unknown>)
} : null;
return null;
} catch { } catch {
return null; return null;
} }
}
} }
+35 -5
View File
@@ -155,41 +155,71 @@ function parseChunk(chunk: string): SearchResult | null {
return result; return result;
} }
/** Bounded cache for extractSearchResults results. */
const SEARCH_RESULTS_CACHE_MAX_SIZE = 32;
const searchResultsCache = new Map<string, SearchResult[]>();
/** /**
* Extract a SearchResult[] from a tool-result string. Returns `[]` when * Extract a SearchResult[] from a tool-result string. Returns `[]` when
* the input does not match the expected shape — useful for branching * the input does not match the expected shape — useful for branching
* between dedicated search-results rendering and the generic tool-call * between dedicated search-results rendering and the generic tool-call
* block. * block. Memoized: called per render during streaming on unchanged
* tool result strings.
*/ */
export function extractSearchResults(text: string | undefined | null): SearchResult[] { export function extractSearchResults(text: string | undefined | null): SearchResult[] {
if (!text) return []; if (!text) return [];
const cached = searchResultsCache.get(text);
if (cached) return cached;
const results: SearchResult[] = []; const results: SearchResult[] = [];
for (const chunk of splitChunks(text)) { for (const chunk of splitChunks(text)) {
const parsed = parseChunk(chunk); const parsed = parseChunk(chunk);
if (parsed) results.push(parsed); if (parsed) results.push(parsed);
} }
if (searchResultsCache.size >= SEARCH_RESULTS_CACHE_MAX_SIZE) {
searchResultsCache.delete(searchResultsCache.keys().next().value!);
}
searchResultsCache.set(text, results);
return results; return results;
} }
/** Bounded cache for extractSearchQuery results. */
const SEARCH_QUERY_CACHE_MAX_SIZE = 32;
const searchQueryCache = new Map<string, string>();
/** /**
* Best-effort extraction of the search query out of a tool call's JSON * Best-effort extraction of the search query out of a tool call's JSON
* argument blob. Currently looks for a `query` field (the convention * argument blob. Currently looks for a `query` field (the convention
* used by Exa and most web-search MCP servers); returns an empty string * used by Exa and most web-search MCP servers); returns an empty string
* if it cannot be located. * if it cannot be located. Memoized: called per render during streaming
* on unchanged tool args strings.
*/ */
export function extractSearchQuery(toolArgs: string | undefined | null): string { export function extractSearchQuery(toolArgs: string | undefined | null): string {
if (!toolArgs) return ''; if (!toolArgs) return '';
const cached = searchQueryCache.get(toolArgs);
if (cached !== undefined) return cached;
let result = '';
try { try {
const parsed: unknown = JSON.parse(toolArgs); const parsed: unknown = JSON.parse(toolArgs);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const candidate = (parsed as Record<string, unknown>)[SEARCH_TOOL_QUERY_FIELD]; const candidate = (parsed as Record<string, unknown>)[SEARCH_TOOL_QUERY_FIELD];
if (typeof candidate === 'string') return candidate.trim(); if (typeof candidate === 'string') result = candidate.trim();
} }
} catch { } catch {
return ''; result = '';
} }
return '';
if (searchQueryCache.size >= SEARCH_QUERY_CACHE_MAX_SIZE) {
searchQueryCache.delete(searchQueryCache.keys().next().value!);
}
searchQueryCache.set(toolArgs, result);
return result;
} }
/** /**
@@ -0,0 +1,146 @@
// Tests for the memoized parseToolCalls and O(1) tool message lookup in
// deriveAgenticSections. These were added to prevent regressions where
// streaming text tokens trigger redundant JSON.parse calls on unchanged
// tool call data.
import { describe, it, expect, vi } from 'vitest';
import { deriveAgenticSections } from '$lib/utils/agentic';
import type { ApiChatCompletionToolCall } from '$lib/types/api';
import type { DatabaseMessage } from '$lib/types/database';
import { MessageRole, AgenticSectionType } from '$lib/enums';
function makeMessage(overrides: Partial<DatabaseMessage>): DatabaseMessage {
return {
id: 'm1',
convId: 'c1',
type: 'text',
timestamp: 0,
role: MessageRole.ASSISTANT,
content: '',
parent: null,
children: [],
...overrides
} as DatabaseMessage;
}
describe('parseToolCalls memoization', () => {
it('returns the same array reference for the same JSON string', () => {
// parseToolCalls is not exported, but deriveAgenticSections uses it
// internally. We verify memoization through behavior: calling
// deriveAgenticSections twice with the same toolCalls should not
// re-parse (which we verify by checking the returned sections
// are equivalent).
const toolCallsJson = JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }
]);
const msg = makeMessage({ content: 'hello', toolCalls: toolCallsJson });
const sections1 = deriveAgenticSections(msg, [], [], false);
const sections2 = deriveAgenticSections(msg, [], [], false);
expect(sections1).toHaveLength(sections2.length);
expect(sections1[0].type).toBe(sections2[0].type);
});
it('does not re-parse JSON on cache hit', () => {
const toolCallsJson = JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }
]);
const msg = makeMessage({ content: 'hello', toolCalls: toolCallsJson });
const spy = vi.spyOn(JSON, 'parse');
deriveAgenticSections(msg, [], [], false);
const callsAfterFirst = spy.mock.calls.length;
deriveAgenticSections(msg, [], [], false);
expect(spy.mock.calls.length).toBe(callsAfterFirst);
spy.mockRestore();
});
it('handles empty/undefined toolCalls without error', () => {
const msg = makeMessage({ content: 'hello' });
const sections = deriveAgenticSections(msg, [], [], false);
expect(sections).toHaveLength(1);
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
});
it('handles invalid JSON gracefully', () => {
const msg = makeMessage({ content: 'hello', toolCalls: '{invalid' });
const sections = deriveAgenticSections(msg, [], [], false);
// Should return just the text section, no tool call sections
expect(sections).toHaveLength(1);
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
});
});
describe('deriveAgenticSections O(1) tool message lookup', () => {
it('matches tool messages to tool calls by toolCallId', () => {
const toolCallsJson = JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'test_1', arguments: '{}' } },
{ id: 'call_2', type: 'function', function: { name: 'test_2', arguments: '{}' } }
]);
const toolMessages = [
makeMessage({ role: MessageRole.TOOL, toolCallId: 'call_1', content: 'result_1' }),
makeMessage({ role: MessageRole.TOOL, toolCallId: 'call_2', content: 'result_2' })
];
const msg = makeMessage({ content: 'hello', toolCalls: toolCallsJson });
const sections = deriveAgenticSections(msg, toolMessages, [], false);
// Expect: TEXT + 2 TOOL_CALL sections
const toolCallSections = sections.filter((s) => s.type === AgenticSectionType.TOOL_CALL);
expect(toolCallSections).toHaveLength(2);
expect(toolCallSections[0].toolResult).toBe('result_1');
expect(toolCallSections[1].toolResult).toBe('result_2');
});
it('handles missing tool messages (pending calls during streaming)', () => {
const toolCallsJson = JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }
]);
const msg = makeMessage({ content: '', toolCalls: toolCallsJson });
const sections = deriveAgenticSections(msg, [], [], true);
const toolCallSection = sections.find((s) => s.type === AgenticSectionType.TOOL_CALL_PENDING);
expect(toolCallSection).toBeDefined();
expect(toolCallSection?.content).toBe('');
});
it('scales with many tool calls (no O(n^2) blowup)', () => {
const N = 100;
const toolCalls = Array.from(
{ length: N },
(_, i): ApiChatCompletionToolCall => ({
id: `call_${i}`,
type: 'function',
function: { name: `tool_${i}`, arguments: '{}' }
})
);
const toolCallsJson = JSON.stringify(toolCalls);
const toolMessages = Array.from({ length: N }, (_, i) =>
makeMessage({
role: MessageRole.TOOL,
toolCallId: `call_${i}`,
content: `result_${i}`
})
);
const msg = makeMessage({ content: 'hello', toolCalls: toolCallsJson });
// If the lookup were still O(n^2), this would be noticeably slow
const start = Date.now();
const sections = deriveAgenticSections(msg, toolMessages, [], false);
const elapsed = Date.now() - start;
const toolCallSections = sections.filter((s) => s.type === AgenticSectionType.TOOL_CALL);
expect(toolCallSections).toHaveLength(N);
expect(elapsed).toBeLessThan(100); // Should be fast with O(1) lookup
});
});