ui: reduce per-token render cost when streaming (#26053)
* performance harness - the empirical root Assisted-by: Claude Opus 4.8 * 210.36ms -> 2.67ms per streamed token Assisted-by: Claude Opus 4.8 * 11.58ms -> 0.62ms per streamed token Assisted-by: Claude Opus 4.8 * 22.02ms -> 3.33ms per streamed token Assisted-by: Claude Opus 4.8 * 3.07ms -> 1.36ms per streamed token at 40 messages Assisted-by: Claude Opus 4.8 --------- Co-authored-by: Zach Winter <dmtommy@icloud.com>
This commit is contained in:
co-authored by
Zach Winter
parent
96013c5112
commit
555881ebc8
@@ -89,10 +89,15 @@
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="pl-1.5 grid min-w-0" style="min-height: var(--min-message-height);">
|
||||
<div class="min-w-0 border-l border-muted-foreground/20 pl-4 pb-2 my-2">
|
||||
{@render children()}
|
||||
<!-- Collapsible.Content renders its children unconditionally and only sets
|
||||
`hidden`, so a closed block would keep re-rendering its whole body on
|
||||
every streamed token. Gate on `open` so collapsed content costs nothing. -->
|
||||
{#if open}
|
||||
<div class="pl-1.5 grid min-w-0" style="min-height: var(--min-message-height);">
|
||||
<div class="min-w-0 border-l border-muted-foreground/20 pl-4 pb-2 my-2">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
|
||||
@@ -90,8 +90,12 @@
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="p-3 pt-1">
|
||||
{@render children()}
|
||||
</div>
|
||||
<!-- See CollapsibleContentBlock: bits-ui keeps closed content mounted, which
|
||||
makes a collapsed tool result re-render on every streamed token. -->
|
||||
{#if open}
|
||||
<div class="p-3 pt-1">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
|
||||
@@ -107,6 +107,15 @@
|
||||
return null;
|
||||
});
|
||||
const liveSvgHtml = $derived(streamingSvgCode !== null ? sanitizeSvg(streamingSvgCode) : '');
|
||||
|
||||
// Derived rather than called inline in the template so it only recomputes when
|
||||
// the block actually changes. Auto-detection is disabled while streaming: it
|
||||
// costs ~38ms a call and re-guesses the language on every chunk.
|
||||
const streamingCodeHtml = $derived(
|
||||
incompleteCodeBlock
|
||||
? highlightCode(incompleteCodeBlock.code, incompleteCodeBlock.language || 'text', false)
|
||||
: ''
|
||||
);
|
||||
let previewDialogOpen = $state(false);
|
||||
let previewCode = $state('');
|
||||
let previewLanguage = $state('text');
|
||||
@@ -903,10 +912,7 @@
|
||||
>
|
||||
<pre class="streaming-code-pre"><code
|
||||
class="hljs language-{incompleteCodeBlock.language || 'text'}"
|
||||
>{@html highlightCode(
|
||||
incompleteCodeBlock.code,
|
||||
incompleteCodeBlock.language || 'text'
|
||||
)}</code
|
||||
>{@html streamingCodeHtml}</code
|
||||
></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,14 @@ export const LATEX_MATH_AND_CODE_PATTERN =
|
||||
/** Regex to capture the content of a $$...\\\\...$$ block (display-formula with line-break) */
|
||||
export const LATEX_LINEBREAK_REGEXP = /\$\$([\s\S]*?\\\\[\s\S]*?)\$\$/;
|
||||
|
||||
/**
|
||||
* Cheap gate for `preprocessLaTeX`. Every transformation it performs is triggered
|
||||
* by a `$` (inline/display math, currency escaping) or a backslash escape
|
||||
* (`\(`, `\[`, `\ce{`, `\pu{`). Text containing neither is returned untouched, so
|
||||
* this lets the caller skip the whole protect/restore pipeline.
|
||||
*/
|
||||
export const LATEX_TRIGGER_REGEXP = /[$\\]/;
|
||||
|
||||
/** map from mchem-regexp to replacement */
|
||||
export const MHCHEM_PATTERN_MAP: readonly [RegExp, string][] = [
|
||||
[/(\s)\$\\ce{/g, '$1$\\\\ce{'],
|
||||
|
||||
@@ -169,8 +169,21 @@ class ConversationsStore {
|
||||
* Updates a message at a specific index in active messages
|
||||
*/
|
||||
updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
|
||||
if (index !== -1 && this.activeMessages[index]) {
|
||||
this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
|
||||
const message = index === -1 ? undefined : this.activeMessages[index];
|
||||
|
||||
if (!message) return;
|
||||
|
||||
// Assign field by field rather than replacing the object. Replacing it
|
||||
// changes the array slot, which invalidates every consumer that merely
|
||||
// walks the list - notably ChatMessages.displayMessages, which rebuilds
|
||||
// entries for every message in the conversation. Deep $state proxies make
|
||||
// per-field writes fine-grained, so only readers of the changed field wake.
|
||||
const target = message as unknown as Record<string, unknown>;
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (target[key] !== value) {
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,13 +30,21 @@ function trimCodePadding(code: string): string {
|
||||
return code.replace(TRIM_LEADING_PADDING_REGEX, '').replace(TRIM_TRAILING_PADDING_REGEX, '');
|
||||
}
|
||||
|
||||
function escapeCode(code: string): string {
|
||||
return code.replace(AMPERSAND_REGEX, '&').replace(LT_REGEX, '<').replace(GT_REGEX, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights code using highlight.js
|
||||
* @param code - The code to highlight
|
||||
* @param language - The programming language
|
||||
* @param autoDetect - Fall back to `highlightAuto` when `language` is unknown.
|
||||
* Callers rendering a still-streaming block should pass false: auto-detection
|
||||
* costs ~38ms per call and re-guesses on every chunk, so the language (and
|
||||
* therefore the whole highlight) flickers as the block grows.
|
||||
* @returns HTML string with syntax highlighting
|
||||
*/
|
||||
export function highlightCode(code: string, language: string): string {
|
||||
export function highlightCode(code: string, language: string, autoDetect = true): string {
|
||||
if (!code) return '';
|
||||
|
||||
const trimmed = trimCodePadding(code);
|
||||
@@ -47,15 +55,14 @@ export function highlightCode(code: string, language: string): string {
|
||||
|
||||
if (isSupported) {
|
||||
return hljs.highlight(trimmed, { language: lang }).value;
|
||||
} else {
|
||||
} else if (autoDetect) {
|
||||
return hljs.highlightAuto(trimmed).value;
|
||||
} else {
|
||||
return escapeCode(trimmed);
|
||||
}
|
||||
} catch {
|
||||
// Fallback to escaped plain text
|
||||
return trimmed
|
||||
.replace(AMPERSAND_REGEX, '&')
|
||||
.replace(LT_REGEX, '<')
|
||||
.replace(GT_REGEX, '>');
|
||||
return escapeCode(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
CODE_BLOCK_REGEXP,
|
||||
LATEX_MATH_AND_CODE_PATTERN,
|
||||
LATEX_LINEBREAK_REGEXP,
|
||||
LATEX_TRIGGER_REGEXP,
|
||||
MHCHEM_PATTERN_MAP
|
||||
} from '$lib/constants';
|
||||
|
||||
@@ -148,6 +149,15 @@ export function preprocessLaTeX(content: string): string {
|
||||
// See also:
|
||||
// https://github.com/danny-avila/LibreChat/blob/main/client/src/utils/latex.ts
|
||||
|
||||
// Every step below keys off a `$` or a backslash escape (\[ \] \( \) \ce{ \pu{).
|
||||
// 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
|
||||
// ~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.
|
||||
if (!LATEX_TRIGGER_REGEXP.test(content)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Step 0: Temporarily remove blockquote markers (>) to process LaTeX correctly
|
||||
// Store the structure so we can restore it later
|
||||
const blockquoteMarkers: Map<number, string> = new Map();
|
||||
@@ -175,24 +185,31 @@ export function preprocessLaTeX(content: string): string {
|
||||
const latexExpressions: string[] = [];
|
||||
|
||||
// Match \S...\[...\] and protect them and insert a line-break.
|
||||
content = content.replace(/([\S].*?)\\\[([\s\S]*?)\\\](.*)/g, (match, group1, group2, group3) => {
|
||||
// Check if there are characters following the formula (display-formula in a table-cell?)
|
||||
if (group1.endsWith('\\')) {
|
||||
return match; // Backslash before \[, do nothing.
|
||||
}
|
||||
const hasSuffix = /\S/.test(group3);
|
||||
let optBreak;
|
||||
// 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.
|
||||
if (content.includes('\\[')) {
|
||||
content = content.replace(
|
||||
/([\S].*?)\\\[([\s\S]*?)\\\](.*)/g,
|
||||
(match, group1, group2, group3) => {
|
||||
// Check if there are characters following the formula (display-formula in a table-cell?)
|
||||
if (group1.endsWith('\\')) {
|
||||
return match; // Backslash before \[, do nothing.
|
||||
}
|
||||
const hasSuffix = /\S/.test(group3);
|
||||
let optBreak;
|
||||
|
||||
if (hasSuffix) {
|
||||
latexExpressions.push(`\\(${group2.trim()}\\)`); // Convert into inline.
|
||||
optBreak = '';
|
||||
} else {
|
||||
latexExpressions.push(`\\[${group2}\\]`);
|
||||
optBreak = '\n';
|
||||
}
|
||||
if (hasSuffix) {
|
||||
latexExpressions.push(`\\(${group2.trim()}\\)`); // Convert into inline.
|
||||
optBreak = '';
|
||||
} else {
|
||||
latexExpressions.push(`\\[${group2}\\]`);
|
||||
optBreak = '\n';
|
||||
}
|
||||
|
||||
return `${group1}${optBreak}<<LATEX_${latexExpressions.length - 1}>>${optBreak}${group3}`;
|
||||
});
|
||||
return `${group1}${optBreak}<<LATEX_${latexExpressions.length - 1}>>${optBreak}${group3}`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Match \(...\), \[...\], $$...$$ and protect them
|
||||
content = content.replace(
|
||||
|
||||
Reference in New Issue
Block a user