mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
736 lines
32 KiB
TypeScript
736 lines
32 KiB
TypeScript
import { Marked } from 'marked'
|
||
import type { Tokens } from 'marked'
|
||
import hljs from 'highlight.js'
|
||
import DOMPurify from 'dompurify'
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Language metadata
|
||
// ---------------------------------------------------------------------------
|
||
const LANG_DISPLAY: Record<string, string> = {
|
||
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, '>').replace(/"/g, '"')
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Code block thresholds (must match `useMarkdownRenderer` doc comments)
|
||
// ---------------------------------------------------------------------------
|
||
/** Lines >= this trigger collapsible <details> 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<div class="katex-block" data-tex="${encodeURIComponent(tex)}"></div>\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 += `<span class="katex-inline" data-tex="${encodeURIComponent(tex)}"></span>`
|
||
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 <div> a
|
||
// direct child of <p> — invalid HTML the browser silently splits).
|
||
out += `\n\n<div class="katex-block" data-tex="${encodeURIComponent(tex)}"></div>\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 += `<span class="katex-inline" data-tex="${encodeURIComponent(tex)}"></span>`
|
||
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 '<div class="product-cards product-cards--loading">'
|
||
+ '<span class="product-cards__dot"></span>'
|
||
+ '<span class="product-cards__dot"></span>'
|
||
+ '<span class="product-cards__dot"></span>'
|
||
+ '</div>'
|
||
}
|
||
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
|
||
? `<div class="product-card__media"><img src="${escapeHtml(img)}" alt="${name}"></div>`
|
||
: `<div class="product-card__media product-card__media--empty"></div>`
|
||
const meta = [platform, shop].filter(Boolean).join(' · ')
|
||
const priceLine = now
|
||
? `<div class="product-card__price"><span class="product-card__price-now">${now}</span>`
|
||
+ (was ? `<span class="product-card__price-was">${was}</span>` : '')
|
||
+ `</div>`
|
||
: ''
|
||
// The whole card is the anchor, but a visible CTA makes the "tap to buy"
|
||
// affordance explicit (an `<a>` can't legally wrap a `<button>`, so this is
|
||
// a styled span). Only shown when there's a real buy URL.
|
||
const platformWord = platform || '商家'
|
||
const buyCta = href
|
||
? `<span class="product-card__buy">去${platformWord}购买<span class="product-card__buy-arrow">→</span></span>`
|
||
: ''
|
||
const body = `<div class="product-card__body">`
|
||
+ `<div class="product-card__name">${name}</div>`
|
||
+ priceLine
|
||
+ (meta ? `<div class="product-card__meta">${meta}</div>` : '')
|
||
+ (low ? `<div class="product-card__low">历史最低 ${low}</div>` : '')
|
||
+ (advice ? `<div class="product-card__advice">${advice}</div>` : '')
|
||
+ buyCta
|
||
+ `</div>`
|
||
|
||
if (href) {
|
||
return `<a class="product-card" href="${escapeHtml(href)}">${media}${body}</a>`
|
||
}
|
||
return `<div class="product-card product-card--nolink">${media}${body}</div>`
|
||
}).join('')
|
||
|
||
return `<div class="product-cards">${cards}</div>`
|
||
}
|
||
|
||
/**
|
||
* Lightweight loading placeholder shown in place of an echarts/mermaid block
|
||
* during throttled mid-stream renders. These post-mount renderers parse the
|
||
* fenced source (echarts: JSON.parse, mermaid: diagram grammar), which fails
|
||
* loudly on the half-emitted source of a still-streaming block. Emitting a
|
||
* neutral placeholder (no `.echarts-block` / `.mermaid-block` class, so the
|
||
* observers ignore it) avoids the parse churn and the mount/dispose flicker;
|
||
* the final non-streaming render emits the real block and mounts once.
|
||
*/
|
||
function chartLoadingPlaceholder(): string {
|
||
return '<div class="chart-loading" aria-label="Loading chart">'
|
||
+ '<span class="chart-loading__dot"></span>'
|
||
+ '<span class="chart-loading__dot"></span>'
|
||
+ '<span class="chart-loading__dot"></span>'
|
||
+ '</div>'
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Custom renderer (marked v15 requires a plain object — class instances are
|
||
// NOT dispatched).
|
||
// ---------------------------------------------------------------------------
|
||
const customRenderer = {
|
||
code({ text, lang }: { type: string; raw: string; text: string; lang?: string }): string {
|
||
const rawCode = text || ''
|
||
const infoStr = (lang || '').split(/\s/)[0]
|
||
|
||
// Mermaid: ship raw source through a placeholder div for the
|
||
// useMermaidRenderer post-mount step. Skip syntax highlighting entirely.
|
||
// The header (lang label + Copy + Download SVG) is part of the placeholder
|
||
// so users can copy the diagram source even before render completes; the
|
||
// composable paints the SVG into `.mermaid-block__body`.
|
||
if (infoStr === 'mermaid') {
|
||
// Mid-stream: defer to a placeholder; mermaid can't parse a partial diagram.
|
||
if (streamingRenderMode) return chartLoadingPlaceholder()
|
||
const encoded = encodeURIComponent(rawCode)
|
||
const copySvg = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`
|
||
const downloadSvg = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`
|
||
return `<div class="mermaid-block" data-mermaid="${encoded}">`
|
||
+ `<div class="mermaid-block__header">`
|
||
+ `<span class="mermaid-block__lang">Mermaid</span>`
|
||
+ `<span class="mermaid-block__actions">`
|
||
+ `<button class="code-block__copy" type="button" data-code="${encoded}" aria-label="Copy diagram source">`
|
||
+ copySvg
|
||
+ `<span class="code-block__copy-text">Copy</span>`
|
||
+ `</button>`
|
||
+ `<button class="mermaid-block__download" type="button" data-mermaid-download="1" aria-label="Download SVG">`
|
||
+ downloadSvg
|
||
+ `<span class="mermaid-block__download-text">SVG</span>`
|
||
+ `</button>`
|
||
+ `</span>`
|
||
+ `</div>`
|
||
+ `<div class="mermaid-block__body"></div>`
|
||
+ `</div>`
|
||
}
|
||
|
||
// 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 `<div class="echarts-block" data-echarts-option="${encodeURIComponent(rawCode)}"></div>`
|
||
}
|
||
|
||
// 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 <li> 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 = `<ol class="hljs-lines">${rawLines.map(l => `<li>${l || ' '}</li>`).join('')}</ol>`
|
||
|
||
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 <div class="code-block__header"> (non-collapsible) or directly
|
||
// into <summary class="code-block__header"> (collapsible). Nesting a div
|
||
// inside <summary> caused weird browser-native height behavior and made
|
||
// the header visibly inflate; flattening fixes it.
|
||
const headerInner = `<span class="code-block__lang">${escapeHtml(langLabel)}</span>`
|
||
+ `<span class="code-block__lines">${lineCount} lines</span>`
|
||
+ `<button class="code-block__copy" type="button" data-code="${encodedCode}" aria-label="Copy code">`
|
||
+ `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`
|
||
+ `<span class="code-block__copy-text">Copy</span>`
|
||
+ `</button>`
|
||
|
||
const codeBody = `<pre><code class="hljs${langClass}">${linesHtml}</code></pre>`
|
||
|
||
if (shouldCollapse) {
|
||
const openAttr = openByDefault ? ' open' : ''
|
||
return `<details class="code-block code-block--collapsible"${openAttr}>`
|
||
+ `<summary class="code-block__header">${headerInner}</summary>`
|
||
+ codeBody
|
||
+ `</details>`
|
||
}
|
||
return `<div class="code-block">`
|
||
+ `<div class="code-block__header">${headerInner}</div>`
|
||
+ codeBody
|
||
+ `</div>`
|
||
},
|
||
|
||
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/<id> 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/<id>)`; 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 <img src> 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 `<img src="${escapeHtml(safeHref)}" alt="${alt}"`
|
||
+ ` class="markdown-generated-image" data-generated-image="1"${titleAttr} />`
|
||
}
|
||
|
||
return `<a href="${escapeHtml(safeHref)}"${titleAttr}${extra}>${innerHtml}</a>`
|
||
},
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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<string, string>()
|
||
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
|
||
* `<a class="wiki-link" data-wiki-title="...">`, 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 <a> 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<number, string>()
|
||
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 (
|
||
'<a class="wiki-citation" href="#" data-citation-index="' + idx +
|
||
'" data-citation-title="' + escapeHtml(title) +
|
||
'">' + match + '</a>'
|
||
)
|
||
},
|
||
)
|
||
|
||
// 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 (
|
||
'<a class="wiki-citation" href="#" data-citation-index="' + idx +
|
||
'" data-citation-title="' + escapeHtml(title) +
|
||
'">' + fullLine + '</a>'
|
||
)
|
||
},
|
||
)
|
||
|
||
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]] → <a class="wiki-link" …>.
|
||
// 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 (
|
||
'<a class="wiki-link" href="#" data-wiki-title="' + target +
|
||
'" onclick="window.dispatchEvent(new CustomEvent(\'wiki-link-click\',{detail:{title:\'' +
|
||
target.replace(/'/g, "\\'") +
|
||
'\'}}));return false">' + visible + '</a>'
|
||
)
|
||
},
|
||
)
|
||
// 2.5 Wiki citation preprocessing: [n] → <a class="wiki-citation" …>
|
||
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
|