mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-19 01:04:55 +02:00
ui : share markdown block infrastructure
Every markdown block duplicated shared work: a full copy of the hljs theme CSS per instance, and the remark/rehype plugin chain rebuilt on every processMarkdown call ( once per block at mount, again per coalesced chunk while streaming ). Use the single theme style element already maintained by SyntaxHighlightedCode, and build pipelines once - shared process-wide for attachment-less blocks, cached by attachments identity otherwise. Assisted-by: pi:zai-org/GLM-5.3
This commit is contained in:
Vendored
-1
@@ -137,7 +137,6 @@ declare global {
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
idxThemeStyle?: number;
|
||||
idxCodeBlock?: number;
|
||||
|
||||
// File System Access API - not in the DOM lib and unavailable in some browsers
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
<script lang="ts">
|
||||
import '$lib/styles/katex-custom.scss';
|
||||
import { getMarkdownProcessor, type MarkdownProcessor } from './markdown-processor';
|
||||
import {
|
||||
getCodeInfoFromTarget,
|
||||
getHastNodeId,
|
||||
getMdastNodeHash,
|
||||
isAppendMode
|
||||
} from './markdown-utils';
|
||||
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
|
||||
import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
|
||||
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
|
||||
import { rehypeEnhanceSvgBlocks } from './plugins/rehype/enhance-svg-blocks';
|
||||
import { rehypeFileBadge } from './plugins/rehype/file-badge';
|
||||
import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
|
||||
import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support';
|
||||
import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images';
|
||||
import { rehypeSvgPre } from './plugins/rehype/svg-pre';
|
||||
import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
|
||||
import { remarkLiteralHtml } from './plugins/remark/literal-html';
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
ActionIconCopyToClipboard,
|
||||
@@ -38,10 +28,10 @@
|
||||
MERMAID_WRAPPER_CLASS,
|
||||
SETTINGS_KEYS,
|
||||
SVG,
|
||||
TOGGLE_SOURCE_BTN_CLASS
|
||||
TOGGLE_SOURCE_BTN_CLASS,
|
||||
UI_DATA_ATTRS
|
||||
} from '$lib/constants';
|
||||
import { BooleanString, ColorMode, UrlProtocol } from '$lib/enums';
|
||||
import { FileTypeText } from '$lib/enums/files.enums';
|
||||
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
||||
import { settingsStore } from '$lib/stores';
|
||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||
@@ -58,17 +48,8 @@
|
||||
import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
|
||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||
import { all as lowlightAll } from 'lowlight';
|
||||
import type { Root as MdastRoot } from 'mdast';
|
||||
import { mode } from 'mode-watcher';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeStringify from 'rehype-stringify';
|
||||
import { remark } from 'remark';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
@@ -144,44 +125,6 @@
|
||||
const transformCache = new SvelteMap<string, string>();
|
||||
let previousContent = '';
|
||||
|
||||
const themeStyleId = `highlight-theme-${(window.idxThemeStyle = (window.idxThemeStyle ?? 0) + 1)}`;
|
||||
|
||||
let processor = $derived(() => {
|
||||
void attachments;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let proc: any = remark().use(remarkGfm); // GitHub Flavored Markdown
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
|
||||
}
|
||||
|
||||
proc = proc
|
||||
.use(remarkBreaks) // Convert line breaks to <br>
|
||||
.use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation
|
||||
.use(remarkRehype); // Convert Markdown AST to rehype
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(rehypeKatex); // Render math using KaTeX
|
||||
}
|
||||
|
||||
return proc
|
||||
.use(rehypeHighlight, {
|
||||
aliases: { [FileTypeText.XML]: [FileTypeText.SVELTE, FileTypeText.VUE] },
|
||||
languages: lowlightAll
|
||||
}) // Add syntax highlighting
|
||||
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
|
||||
.use(rehypeEnhanceLinks) // Add target="_blank" to links
|
||||
.use(rehypeFileBadge) // Render file:// anchors as inline badge chips
|
||||
.use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
|
||||
.use(rehypeSvgPre) // Convert svg blocks to <pre class="svg-block">
|
||||
.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
|
||||
.use(rehypeEnhanceMermaidBlocks) // Wrap mermaid blocks with header and actions
|
||||
.use(rehypeEnhanceSvgBlocks) // Wrap svg blocks with header and actions
|
||||
.use(rehypeResolveAttachmentImages, { attachments })
|
||||
.use(rehypeRtlSupport) // Add bidirectional text support
|
||||
.use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
|
||||
});
|
||||
|
||||
/**
|
||||
* Removes click event listeners from copy and preview buttons.
|
||||
* Called on component destroy.
|
||||
@@ -201,33 +144,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes this component's highlight.js theme style from the document head.
|
||||
* Called on component destroy to clean up injected styles.
|
||||
*/
|
||||
function cleanupHighlightTheme() {
|
||||
if (!browser) return;
|
||||
|
||||
const existingTheme = document.getElementById(themeStyleId);
|
||||
|
||||
existingTheme?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the appropriate highlight.js theme based on dark/light mode.
|
||||
* Injects a scoped style element into the document head.
|
||||
* One shared style element for every markdown block, mirroring
|
||||
* SyntaxHighlightedCode.svelte. The old per-instance copies duplicated the
|
||||
* full theme CSS once per rendered message, which grows without bound in
|
||||
* long conversations.
|
||||
* @param isDark - Whether to load the dark theme (true) or light theme (false)
|
||||
*/
|
||||
function loadHighlightTheme(isDark: boolean) {
|
||||
if (!browser) return;
|
||||
|
||||
const existingTheme = document.getElementById(themeStyleId);
|
||||
|
||||
existingTheme?.remove();
|
||||
document
|
||||
.querySelectorAll(`style[${UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW}]`)
|
||||
.forEach((style) => style.remove());
|
||||
|
||||
const style = document.createElement('style');
|
||||
|
||||
style.id = themeStyleId;
|
||||
style.setAttribute(UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW, BooleanString.TRUE);
|
||||
style.textContent = isDark ? githubDarkCss : githubLightCss;
|
||||
|
||||
document.head.appendChild(style);
|
||||
@@ -247,7 +181,7 @@
|
||||
* @returns Object containing the HTML string and cache hash
|
||||
*/
|
||||
async function transformMdastNode(
|
||||
processorInstance: ReturnType<typeof processor>,
|
||||
processorInstance: MarkdownProcessor,
|
||||
node: unknown,
|
||||
index: number
|
||||
): Promise<{ html: string; hash: string }> {
|
||||
@@ -369,7 +303,7 @@
|
||||
|
||||
if (prefixMarkdown.trim()) {
|
||||
const normalizedPrefix = preprocessLaTeX(prefixMarkdown);
|
||||
const processorInstance = processor();
|
||||
const processorInstance = getMarkdownProcessor({ attachments, disableMath });
|
||||
const ast = processorInstance.parse(normalizedPrefix) as MdastRoot;
|
||||
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
|
||||
const nextBlocks: MarkdownBlock[] = [];
|
||||
@@ -419,7 +353,7 @@
|
||||
incompleteCodeBlock = null;
|
||||
|
||||
const normalized = preprocessLaTeX(markdown);
|
||||
const processorInstance = processor();
|
||||
const processorInstance = getMarkdownProcessor({ attachments, disableMath });
|
||||
const ast = processorInstance.parse(normalized) as MdastRoot;
|
||||
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
|
||||
const stableCount = Math.max(mdastChildren.length - 1, 0);
|
||||
@@ -858,7 +792,6 @@
|
||||
|
||||
onDestroy(() => {
|
||||
cleanupEventListeners();
|
||||
cleanupHighlightTheme();
|
||||
streamingAutoScroll.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// Shared remark/rehype pipeline factory for MarkdownContent.
|
||||
//
|
||||
// The frozen plugin chain is expensive to build ( ~15 plugin instances ),
|
||||
// and MarkdownContent used to rebuild it on every processMarkdown call:
|
||||
// once per block at mount, and again on every coalesced chunk while
|
||||
// streaming. Pipelines without attachments are shared process-wide per
|
||||
// math flag; attachment-bearing pipelines are cached by the attachments
|
||||
// array identity, which changes whenever extras are updated.
|
||||
|
||||
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
|
||||
import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
|
||||
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
|
||||
import { rehypeEnhanceSvgBlocks } from './plugins/rehype/enhance-svg-blocks';
|
||||
import { rehypeFileBadge } from './plugins/rehype/file-badge';
|
||||
import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
|
||||
import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support';
|
||||
import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images';
|
||||
import { rehypeSvgPre } from './plugins/rehype/svg-pre';
|
||||
import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
|
||||
import { remarkLiteralHtml } from './plugins/remark/literal-html';
|
||||
import { FileTypeText } from '$lib/enums/files.enums';
|
||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||
import type { Root as HastRoot } from 'hast';
|
||||
import { all as lowlightAll } from 'lowlight';
|
||||
import type { Root as MdastRoot } from 'mdast';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeStringify from 'rehype-stringify';
|
||||
import { remark } from 'remark';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
|
||||
export interface MarkdownProcessor {
|
||||
parse(markdown: string): MdastRoot;
|
||||
run(tree: MdastRoot): Promise<HastRoot>;
|
||||
stringify(tree: HastRoot): string;
|
||||
}
|
||||
|
||||
export interface MarkdownProcessorOptions {
|
||||
attachments?: DatabaseMessageExtra[];
|
||||
disableMath?: boolean;
|
||||
}
|
||||
|
||||
const sharedPipelines = new Map<string, MarkdownProcessor>();
|
||||
const attachmentPipelines = new WeakMap<object, MarkdownProcessor>();
|
||||
|
||||
function buildPipeline({
|
||||
attachments,
|
||||
disableMath = false
|
||||
}: MarkdownProcessorOptions): MarkdownProcessor {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let proc: any = remark().use(remarkGfm); // GitHub Flavored Markdown
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
|
||||
}
|
||||
|
||||
proc = proc
|
||||
.use(remarkBreaks) // Convert line breaks to <br>
|
||||
// Treat raw HTML as literal text with preserved indentation
|
||||
.use(remarkLiteralHtml)
|
||||
.use(remarkRehype); // Convert Markdown AST to rehype
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(rehypeKatex); // Render math using KaTeX
|
||||
}
|
||||
|
||||
const pipeline = proc
|
||||
.use(rehypeHighlight, {
|
||||
aliases: { [FileTypeText.XML]: [FileTypeText.SVELTE, FileTypeText.VUE] },
|
||||
languages: lowlightAll
|
||||
}) // Add syntax highlighting
|
||||
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g. <br>, <ul>) inside Markdown tables
|
||||
.use(rehypeEnhanceLinks) // Add target="_blank" to links
|
||||
.use(rehypeFileBadge) // Render file:// anchors as inline badge chips
|
||||
.use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
|
||||
.use(rehypeSvgPre) // Convert svg blocks to <pre class="svg-block">
|
||||
.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
|
||||
.use(rehypeEnhanceMermaidBlocks) // Wrap mermaid blocks with header and actions
|
||||
.use(rehypeEnhanceSvgBlocks) // Wrap svg blocks with header and actions
|
||||
.use(rehypeResolveAttachmentImages, { attachments })
|
||||
.use(rehypeRtlSupport) // Add bidirectional text support
|
||||
.use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
|
||||
|
||||
return pipeline as MarkdownProcessor;
|
||||
}
|
||||
|
||||
export function getMarkdownProcessor(options: MarkdownProcessorOptions): MarkdownProcessor {
|
||||
if (options.attachments && options.attachments.length > 0) {
|
||||
let cached = attachmentPipelines.get(options.attachments);
|
||||
|
||||
if (!cached) {
|
||||
cached = buildPipeline(options);
|
||||
attachmentPipelines.set(options.attachments, cached);
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
const key = String(Boolean(options.disableMath));
|
||||
|
||||
let cached = sharedPipelines.get(key);
|
||||
|
||||
if (!cached) {
|
||||
cached = buildPipeline(options);
|
||||
sharedPipelines.set(key, cached);
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
Reference in New Issue
Block a user