import { Marked } from 'marked' import type { Tokens } from 'marked' import hljs from 'highlight.js' import DOMPurify from 'dompurify' // --------------------------------------------------------------------------- // Language metadata // --------------------------------------------------------------------------- const LANG_DISPLAY: Record = { js: 'JavaScript', javascript: 'JavaScript', ts: 'TypeScript', typescript: 'TypeScript', py: 'Python', python: 'Python', java: 'Java', kt: 'Kotlin', kotlin: 'Kotlin', go: 'Go', rust: 'Rust', rs: 'Rust', rb: 'Ruby', ruby: 'Ruby', cpp: 'C++', c: 'C', cs: 'C#', csharp: 'C#', swift: 'Swift', sh: 'Shell', bash: 'Bash', zsh: 'Zsh', shell: 'Shell', sql: 'SQL', html: 'HTML', css: 'CSS', scss: 'SCSS', less: 'LESS', json: 'JSON', xml: 'XML', yaml: 'YAML', yml: 'YAML', toml: 'TOML', md: 'Markdown', markdown: 'Markdown', dockerfile: 'Dockerfile', vue: 'Vue', jsx: 'JSX', tsx: 'TSX', php: 'PHP', lua: 'Lua', } const KNOWN_LANGS = [ 'typescript', 'javascript', 'python', 'kotlin', 'csharp', 'dockerfile', 'markdown', 'shell', 'swift', 'rust', 'ruby', 'bash', 'scss', 'less', 'yaml', 'toml', 'html', 'java', 'json', 'css', 'cpp', 'xml', 'vue', 'jsx', 'tsx', 'php', 'lua', 'sql', 'zsh', 'yml', 'go', 'kt', 'rs', 'rb', 'cs', 'ts', 'js', 'py', 'sh', 'md', 'c', ] function extractLang(raw: string): string { if (!raw) return '' const lower = raw.toLowerCase() if (hljs.getLanguage(lower)) return lower for (const lang of KNOWN_LANGS) { if (lower.startsWith(lang) && lower.length > lang.length) return lang } return lower } function escapeHtml(str: string): string { return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } // --------------------------------------------------------------------------- // Code block thresholds (must match `useMarkdownRenderer` doc comments) // --------------------------------------------------------------------------- /** Lines >= this trigger collapsible
wrap. */ const COLLAPSE_LINE_THRESHOLD = 20 /** JSON blob char count >= this triggers collapse even when line count is low. */ const COLLAPSE_JSON_CHAR_THRESHOLD = 800 // --------------------------------------------------------------------------- // Link safety // --------------------------------------------------------------------------- /** * Scheme whitelist. Only http(s), mailto, fragment, and same-origin paths * (absolute `/...`, relative `./...` / `../...`) are permitted. Everything * else (javascript:, data:, vbscript:, file:, …) is degraded to plain text. */ const SAFE_LINK_RE = /^(https?:|mailto:|#|\/|\.\/|\.\.\/)/i // --------------------------------------------------------------------------- // LaTeX pre-processor // --------------------------------------------------------------------------- // `$$ ... $$` (block) and `$ ... $` (inline) are extracted from raw markdown // and replaced with HTML placeholders that survive marked + DOMPurify. The // post-render KaTeX composable (useKatexRenderer) finds them by class + // data-tex attribute and mounts the typeset output. // // We deliberately walk the source character-by-character rather than running // a global regex, so that fenced/inline code blocks are skipped — otherwise // dollar signs inside Bash snippets or JSON blobs would be misinterpreted. function preprocessLatex(text: string): string { let out = '' let i = 0 let inFence = false let fenceMarker = '' while (i < text.length) { // Detect fence open/close at line start. if (i === 0 || text[i - 1] === '\n') { const fenceMatch = /^(```+|~~~+)([^\n]*)/.exec(text.slice(i)) if (fenceMatch) { const marker = fenceMatch[1] if (!inFence) { inFence = true fenceMarker = marker } else if (marker.length >= fenceMarker.length && marker[0] === fenceMarker[0]) { inFence = false fenceMarker = '' } out += fenceMatch[0] i += fenceMatch[0].length continue } } if (inFence) { out += text[i++] continue } // Inline code: copy verbatim until the matching backtick run. if (text[i] === '`') { let n = 0 while (text[i + n] === '`') n++ const tickRun = '`'.repeat(n) const close = text.indexOf(tickRun, i + n) if (close < 0) { // Unmatched — treat the rest as text but still advance past the ticks. out += text[i++] continue } out += text.slice(i, close + n) i = close + n continue } // LaTeX-style block math: \[...\] — must be checked BEFORE marked sees // the source, because CommonMark eats the backslash escape (`\[ → [`) // and the marker would be lost. LLMs (DeepSeek, Qwen, Claude) emit this // form heavily for display equations. if (text[i] === '\\' && text[i + 1] === '[') { const close = text.indexOf('\\]', i + 2) // Bound length so a stray `\[` doesn't swallow the rest of the doc. if (close > 0 && close - i < 800) { const tex = text.slice(i + 2, close) out += `\n\n
\n\n` i = close + 2 continue } } // LaTeX-style inline math: \(...\) if (text[i] === '\\' && text[i + 1] === '(') { const close = text.indexOf('\\)', i + 2) if (close > 0 && close - i < 400) { const tex = text.slice(i + 2, close) out += `` i = close + 2 continue } } // Block math: $$...$$ if (text[i] === '$' && text[i + 1] === '$') { const close = text.indexOf('$$', i + 2) if (close > 0) { const tex = text.slice(i + 2, close) // Wrap in newlines so marked treats the placeholder as its own block, // not glued onto a surrounding paragraph (which would make
a // direct child of

— invalid HTML the browser silently splits). out += `\n\n

\n\n` i = close + 2 continue } } // Inline math: $...$ — require non-whitespace adjacent to the dollars // so that "$5.99" or "saved $10" are NOT treated as math. if (text[i] === '$') { const m = /^\$([^$\n]+?)\$(?!\d)/.exec(text.slice(i)) if (m && !/^\s/.test(m[1]) && !/\s$/.test(m[1])) { const tex = m[1] out += `` i += m[0].length continue } } out += text[i++] } return out } // --------------------------------------------------------------------------- // Product cards // --------------------------------------------------------------------------- /** Shape the model is asked to emit inside a ```product-cards fence. */ interface ProductCard { name?: string url?: string imageUrl?: string price?: number | string originalPrice?: number | string lowestPrice?: number | string platformLabel?: string shopName?: string purchaseAdvice?: string } /** Format a numeric/string amount as `¥1,234` (drops a trailing `.0`). */ function formatPrice(v: number | string | undefined): string { if (v === undefined || v === null || v === '') return '' const n = typeof v === 'number' ? v : Number(String(v).replace(/[^\d.]/g, '')) if (!Number.isFinite(n)) return '' const s = Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.0+$/, '') return '¥' + s.replace(/\B(?=(\d{3})+(?!\d))/g, ',') } /** * Render a ```product-cards fenced JSON block into a clickable card grid. * * Accepts a bare array or an object wrapping the array under * `recommendations` / `products` / `items`. While streaming, the JSON is * frequently incomplete — we swallow the parse error and show a lightweight * loading placeholder rather than dumping half a JSON blob into the bubble. */ function renderProductCards(rawCode: string): string { let items: ProductCard[] = [] try { const parsed = JSON.parse(rawCode) if (Array.isArray(parsed)) items = parsed else if (parsed && typeof parsed === 'object') { items = parsed.recommendations || parsed.products || parsed.items || [] } } catch { return '
' + '' + '' + '' + '
' } if (!Array.isArray(items) || items.length === 0) return '' const cards = items.map((it) => { const href = typeof it.url === 'string' && SAFE_LINK_RE.test(it.url) ? it.url : '' const name = escapeHtml(String(it.name ?? '').trim()) || '商品' const img = typeof it.imageUrl === 'string' && /^https?:/i.test(it.imageUrl) ? it.imageUrl : '' const now = formatPrice(it.price) const wasNum = typeof it.originalPrice === 'number' ? it.originalPrice : Number(it.originalPrice) const nowNum = typeof it.price === 'number' ? it.price : Number(it.price) const showWas = Number.isFinite(wasNum) && Number.isFinite(nowNum) && wasNum > nowNum const was = showWas ? formatPrice(it.originalPrice) : '' const low = formatPrice(it.lowestPrice) const platform = escapeHtml(String(it.platformLabel ?? '').trim()) const shop = escapeHtml(String(it.shopName ?? '').trim()) const advice = escapeHtml(String(it.purchaseAdvice ?? '').trim()) // target/rel (anchor) and referrerpolicy/loading (img) are re-applied by the // afterSanitizeAttributes hook — DOMPurify strips them here regardless. const media = img ? `
${name}
` : `
` const meta = [platform, shop].filter(Boolean).join(' · ') const priceLine = now ? `
${now}` + (was ? `${was}` : '') + `
` : '' // The whole card is the anchor, but a visible CTA makes the "tap to buy" // affordance explicit (an `` can't legally wrap a `` + `` + `` + `
` + `
` + `` } // ECharts: same pattern, mounted by useEChartsRenderer. if (infoStr === 'echarts') { // Mid-stream: defer to a placeholder; the option JSON is still truncated. if (streamingRenderMode) return chartLoadingPlaceholder() return `
` } // Product cards: a ```product-cards fenced block carries a JSON array (or an // object wrapping `recommendations` / `products` / `items`) of shopping // recommendations. We render it inline as a clickable card grid — image, // name, price, platform — so price-comparison results show up as real cards // in the chat instead of a markdown list. Pure HTML, no post-mount step. if (infoStr === 'product-cards') { return renderProductCards(rawCode) } const detectedLang = extractLang(infoStr) const hasLanguage = !!detectedLang && !!hljs.getLanguage(detectedLang) let highlighted: string try { if (hasLanguage) { highlighted = hljs.highlight(rawCode, { language: detectedLang }).value } else if (streamingRenderMode) { // Mid-stream throttled render: skip language auto-detection. hljs // probes every registered grammar, which is the single most expensive // step in the pipeline and would re-run on each throttled pass over a // still-growing block. Show escaped plain text now; the final // (non-streaming) render does the real auto-highlight once. highlighted = escapeHtml(rawCode) } else { highlighted = hljs.highlightAuto(rawCode).value } } catch { highlighted = escapeHtml(rawCode) } const langLabel = LANG_DISPLAY[detectedLang] || detectedLang || 'Code' const encodedCode = encodeURIComponent(rawCode) const langClass = hasLanguage ? ` language-${detectedLang}` : '' // Split into one
  • per source line so CSS counter renders the gutter. // We trim a trailing empty line if highlight.js produced one (common when // the user's fenced block ends with a newline), to avoid a blank tail row. const rawLines = highlighted.split('\n') if (rawLines.length && rawLines[rawLines.length - 1] === '') rawLines.pop() const lineCount = rawLines.length || 1 const linesHtml = `
      ${rawLines.map(l => `
    1. ${l || ' '}
    2. `).join('')}
    ` const isJson = detectedLang === 'json' const isLongJson = isJson && rawCode.length >= COLLAPSE_JSON_CHAR_THRESHOLD const shouldCollapse = lineCount >= COLLAPSE_LINE_THRESHOLD || isLongJson // Default-open for normal long code (the user wants to see it; the // collapsible header is just an opt-in fold). Default-closed only for // giant JSON blobs, which are typically noisy tool-call output. const openByDefault = !isLongJson // Header content: lang badge (left) — line-count badge (only shown when // collapsed) — copy button (right). We render the SAME inner content into // either a
    (non-collapsible) or directly // into (collapsible). Nesting a div // inside caused weird browser-native height behavior and made // the header visibly inflate; flattening fixes it. const headerInner = `${escapeHtml(langLabel)}` + `${lineCount} lines` + `` const codeBody = `
    ${linesHtml}
    ` if (shouldCollapse) { const openAttr = openByDefault ? ' open' : '' return `
    ` + `${headerInner}` + codeBody + `
    ` } return `
    ` + `
    ${headerInner}
    ` + codeBody + `
    ` }, link({ href, title, tokens }: Tokens.Link): string { // marked v15 passes already-parsed inline tokens; render them ourselves so // that the inner content keeps any bold/italic formatting from `[**x**](u)`. const innerHtml = (this as unknown as { parser: { parseInline: (t: unknown[]) => string } }) .parser.parseInline(tokens) if (!href || !SAFE_LINK_RE.test(href)) { // Dangerous scheme — render the inner content as plain content (no anchor). return innerHtml } // Defense against LLMs hallucinating a host on tool-returned download URLs. // /api/v1/files/generated/ is always same-origin; multiple models have // been observed prepending bogus schemes/hosts (https://localhost:8080, // https://ai-tools-system.com, …) when echoing the URL back, breaking the // download. Strip any prepended scheme://host so the link works regardless // of what the model wrote. const hostStripped = /^https?:\/\/[^/]+(\/api\/v1\/files\/generated\/.+)$/i.exec(href) const safeHref = hostStripped ? hostStripped[1] : href let extra = '' try { const url = new URL(safeHref, typeof window !== 'undefined' ? window.location.href : 'http://localhost/') if (typeof window !== 'undefined' && url.origin !== window.location.origin) { extra = ' target="_blank" rel="noopener noreferrer"' } } catch { // Malformed URL — treat as same-origin (relative link path). } const titleAttr = title ? ` title="${escapeHtml(title)}"` : '' // Inline-preview tool-generated image files instead of showing a // download-only link. render_html_image / image generation return // `[cover.png](/api/v1/files/generated/)`; without this the chat only // offers a download and the user can never *see* the picture. The // generated-file endpoint is permitAll, so a same-origin loads // without an auth header. Detection is by the link label's extension // (the URL itself carries only a UUID). Clicking the image opens it // full-size in a new tab (see useGlobalFileDownloadClick). const labelText = innerHtml.replace(/<[^>]*>/g, '').trim() const isFileApi = /^\/api\/v1\/(files|chat\/files)\//.test(safeHref) if (isFileApi && /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(labelText)) { const alt = escapeHtml(labelText) return `${alt}` } return `${innerHtml}` }, } // --------------------------------------------------------------------------- // marked instance // --------------------------------------------------------------------------- const markedInstance = new Marked({ gfm: true, breaks: true, renderer: customRenderer, }) // --------------------------------------------------------------------------- // DOMPurify config — allow Markdown + custom blocks (code-block, KaTeX/Mermaid // placeholders) and the inline copy SVG button. // --------------------------------------------------------------------------- const purifyConfig = { ADD_ATTR: [ 'target', 'rel', 'class', 'data-code', 'data-echarts-option', 'data-wiki-title', 'data-slug', 'data-citation-index', 'data-citation-title', 'data-tex', 'data-mermaid', 'data-mermaid-download', 'x1', 'y1', 'x2', 'y2', 'aria-label', 'open', 'type', 'viewBox', 'fill', 'stroke', 'stroke-width', 'd', 'x', 'y', 'width', 'height', 'rx', 'ry', 'points', ], ADD_TAGS: [ 'input', 'button', 'svg', 'path', 'rect', 'polyline', 'circle', 'line', 'span', 'details', 'summary', ], // Defence in depth: even if a malicious href slips past our link() // override, DOMPurify drops anything outside this whitelist. ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|#|\/|\.\/|\.\.\/)/i, } // The custom ALLOWED_URI_REGEXP above also vets non-URI attribute *values*, so // DOMPurify strips `target="_blank"`, `rel="noopener"`, `referrerpolicy="..."` // etc. (their values don't match the URL whitelist). For product cards we need // those back: the buy link must open in a new tab instead of navigating away // from the chat, and marketplace CDN thumbnails (e.g. 360buyimg) are hotlink- // protected and only load with `referrer-policy: no-referrer`. An // afterSanitizeAttributes hook re-applies them with fixed, safe values — // attributes set inside this hook are NOT re-validated, so this is the // canonical DOMPurify pattern. Scoped strictly to product-card nodes so no // other rendered markdown changes behaviour. let productCardHookRegistered = false function ensureProductCardHook(): void { if (productCardHookRegistered) return productCardHookRegistered = true DOMPurify.addHook('afterSanitizeAttributes', (node: Element) => { if (!node || typeof node.tagName !== 'string') return const tag = node.tagName.toLowerCase() if (tag === 'a' && node.classList?.contains('product-card')) { node.setAttribute('target', '_blank') node.setAttribute('rel', 'noopener noreferrer') } else if (tag === 'img' && typeof node.closest === 'function' && node.closest('.product-cards')) { node.setAttribute('referrerpolicy', 'no-referrer') node.setAttribute('loading', 'lazy') node.setAttribute('decoding', 'async') } }) } ensureProductCardHook() // --------------------------------------------------------------------------- // LRU render cache // --------------------------------------------------------------------------- // Streaming token-by-token defeats this (each delta produces a new key) but // scrolling history and re-renders of completed messages are common, and the // cost of marked + highlight.js + DOMPurify is non-trivial for long messages. const RENDER_CACHE = new Map() const RENDER_CACHE_CAP = 200 function cacheKey(text: string, wikilink: WikilinkMode): string { // Compact key — collisions on the order of 10^-6 in single-conversation // scope, and a false hit only causes a "stale" render of unchanged content // (no security implication since cached values are sanitized HTML). // The wikilink mode is part of the key so a 'none' caller cannot read back // a 'legacy'-substituted cached entry of the same source. return `${wikilink}:${text.length}:${text.slice(0, 40)}:${text.slice(-40)}` } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Wikilink handling mode for {@link useMarkdownRenderer}. * * - `'legacy'` (default): pre-markdown string substitution of `[[Title]]` into * ``, dispatching the global * `wiki-link-click` event when clicked. Kept for chat / other views that * already rely on this behaviour. * - `'none'`: skip wikilink substitution entirely. Use this when the caller * wants to walk the rendered DOM itself and resolve `[[...]]` against an * authoritative `{slug, title}` index — the dedicated path used by the Wiki * page viewer, where the legacy "guess slug from title" approach is unsafe. */ export type WikilinkMode = 'legacy' | 'none' export interface RenderMarkdownOptions { /** How to handle `[[...]]` syntax. Defaults to `'legacy'`. */ wikilink?: WikilinkMode /** * Streaming-friendly render. When `true`, the renderer skips code-block * language auto-detection (the most expensive step) and bypasses the LRU * cache. Use it for the throttled mid-stream renders driven by * {@link useStreamingMarkdown}; the final render must run with this off so * the completed message gets full-fidelity highlighting. */ streaming?: boolean } // Module-level flag read by the custom code renderer. Safe because // `markedInstance.parse()` runs fully synchronously (JS single-threaded) — the // flag is set immediately before the parse and cleared in a `finally`, so it // can never leak across renders. let streamingRenderMode = false // --------------------------------------------------------------------------- // Wiki citation pre-processor // --------------------------------------------------------------------------- // Parses the canonical "来源:" source table appended by the backend's // SourceEvidenceLedger.appendWikiSourceTable() to build a citation index → // title map, then replaces every [n] marker in the answer body with a // clickable and wraps entire source-table rows so the full line is // clickable. function preprocessWikiCitations(text: string): string { let sourceIdx = -1 const dblIdx = text.indexOf('\n\n来源:') if (dblIdx >= 0) { sourceIdx = dblIdx + 2 } else { const sngIdx = text.indexOf('\n来源:') if (sngIdx >= 0) { sourceIdx = sngIdx + 1 } else if (text.startsWith('来源:')) { sourceIdx = 0 } } if (sourceIdx < 0) return text const body = text.slice(0, sourceIdx) const sourceSection = text.slice(sourceIdx) const map = new Map() const sourceLineRe = /^\[(\d+)\]\s+(.+)$/gm let slMatch: RegExpExecArray | null while ((slMatch = sourceLineRe.exec(sourceSection)) !== null) { const index = parseInt(slMatch[1], 10) if (map.has(index)) continue const fullContent = slMatch[2].trim() const title = fullContent.split(' - ')[0].trim() if (title) map.set(index, title) } if (map.size === 0) return text // Body: replace only the [n] marker. const bodyWithCitations = body.replace( /\[(\d+)\]/g, (match, indexStr: string) => { const idx = parseInt(indexStr, 10) const title = map.get(idx) if (!title) return match return ( '' + match + '' ) }, ) // Source table: wrap the entire row. const sourceWithCitations = sourceSection.replace( /^(\s*\[(\d+)\]\s+.+)$/gm, (fullLine, _content, indexStr: string) => { const idx = parseInt(indexStr, 10) const title = map.get(idx) if (!title) return fullLine return ( '' + fullLine + '' ) }, ) return bodyWithCitations + sourceWithCitations } export function useMarkdownRenderer() { function renderMarkdown(content: string, opts?: RenderMarkdownOptions): string { if (!content) return '' const wikilink: WikilinkMode = opts?.wikilink ?? 'legacy' const streaming = opts?.streaming ?? false const k = cacheKey(content, wikilink) // Streaming renders bypass the cache entirely: their length-based keys // collide with the final full-fidelity render of the same text, and a // streaming entry (no auto-highlight) must never be served as the final // result. if (!streaming) { const cached = RENDER_CACHE.get(k) if (cached !== undefined) { // Refresh LRU position — re-insert at the tail. RENDER_CACHE.delete(k) RENDER_CACHE.set(k, cached) return cached } } // 1. LaTeX placeholders (skips fenced/inline code). const withLatex = preprocessLatex(content) // 2. Wiki link substitution: [[Title]] → . // Skipped in 'none' mode so the caller can do its own DOM postprocess. const withWikiLinks = wikilink === 'none' ? withLatex : // Split `[[slug|display]]` into slug + display halves so the // `data-wiki-title` attribute carries the slug ALONE (the cross-KB // lookup keys off that) and the visible label is the display text // (the alias an author chose). The earlier single-capture regex // copied the whole bracket interior — including the literal `|` — // into both, producing `data-wiki-title="slug|display"` lookups // that the backend would never resolve. withLatex.replace( /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, slug: string, alias?: string) => { const target = slug.trim().replace(/"/g, '"') const visible = (alias?.trim() || slug.trim()).replace(/"/g, '"') return ( '' + visible + '' ) }, ) // 2.5 Wiki citation preprocessing: [n] → const withCitations = preprocessWikiCitations(withWikiLinks) // 3. Marked → 4. DOMPurify. let rawHtml: string streamingRenderMode = streaming try { rawHtml = markedInstance.parse(withCitations) as string } finally { streamingRenderMode = false } const result = DOMPurify.sanitize(rawHtml, purifyConfig) if (streaming) { // Throwaway render — don't pollute the LRU with low-fidelity entries. return result } // Evict oldest entry when at capacity (Map preserves insertion order). if (RENDER_CACHE.size >= RENDER_CACHE_CAP) { const oldestKey = RENDER_CACHE.keys().next().value if (oldestKey !== undefined) RENDER_CACHE.delete(oldestKey) } RENDER_CACHE.set(k, result) return result } function escapeText(text: string): string { return escapeHtml(text) } return { renderMarkdown, escapeText, markedInstance, } } // Direct singletons for tests / advanced callers. export { markedInstance, purifyConfig } export default markedInstance