mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(wiki): smaller batch-create + resume button for partial generation
This commit is contained in:
parent
b7c911f01d
commit
e2df16893e
@ -92,10 +92,11 @@ public class WikiProperties {
|
||||
* Pages planned by route are chunked into sub-batches of this size;
|
||||
* a local liveIndex is updated between sub-batches so later pages can
|
||||
* link to earlier ones created in the same chunk.
|
||||
* Default 5: keeps output tokens per call predictable while covering
|
||||
* the typical 3–5 creates per chunk in a single call.
|
||||
* Default 2: keeps total output tokens well under typical provider caps
|
||||
* (~2k–3k completion tokens) so the FILE-block JSON doesn't get truncated
|
||||
* mid-object. Raising this risks unparseable JSON skips on long content.
|
||||
*/
|
||||
private int batchCreatePageSize = 3;
|
||||
private int batchCreatePageSize = 2;
|
||||
|
||||
/**
|
||||
* RFC-047: Minimum chunk length (chars) for the chunk-fallback mechanism.
|
||||
|
||||
@ -18,8 +18,10 @@
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.9.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.45",
|
||||
"marked": "^15.0.6",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"mermaid": "^11.14.0",
|
||||
"pinia": "^3.0.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "9.14.4",
|
||||
@ -27,6 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
|
||||
948
mateclaw-ui/pnpm-lock.yaml
generated
948
mateclaw-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -117,6 +117,8 @@
|
||||
--mc-attachment-color: #7B3F1E;
|
||||
--mc-thinking-bg: #F5F0EB;
|
||||
--mc-thinking-text: #5A4030;
|
||||
--mc-mermaid-bg: #FAFAF8;
|
||||
--mc-mermaid-border: #DDD5CC;
|
||||
--mc-thinking-hover: rgba(217, 119, 87, 0.1);
|
||||
--mc-thinking-icon-bg: rgba(217, 119, 87, 0.12);
|
||||
--mc-thinking-border: rgba(217, 119, 87, 0.2);
|
||||
@ -232,6 +234,8 @@ html.dark {
|
||||
--mc-attachment-color: #F0C4A0;
|
||||
--mc-thinking-bg: #231C17;
|
||||
--mc-thinking-text: #C4A898;
|
||||
--mc-mermaid-bg: #1A1410;
|
||||
--mc-mermaid-border: #3D3028;
|
||||
--mc-thinking-hover: rgba(224, 136, 96, 0.1);
|
||||
--mc-thinking-icon-bg: rgba(224, 136, 96, 0.12);
|
||||
--mc-thinking-border: rgba(224, 136, 96, 0.2);
|
||||
|
||||
@ -1597,6 +1597,103 @@ watch(isGenerating, (generating) => {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ===== Code block: line numbers via CSS counter (keeps DOM minimal) ===== */
|
||||
.markdown-body :deep(.code-block ol.hljs-lines) {
|
||||
counter-reset: ln;
|
||||
padding: 12px 16px;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
.markdown-body :deep(.code-block ol.hljs-lines > li) {
|
||||
counter-increment: ln;
|
||||
padding-left: 3.5em;
|
||||
position: relative;
|
||||
white-space: pre;
|
||||
min-height: 1.3em;
|
||||
}
|
||||
.markdown-body :deep(.code-block ol.hljs-lines > li::before) {
|
||||
content: counter(ln);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 2.8em;
|
||||
text-align: right;
|
||||
color: #94a3b8;
|
||||
user-select: none;
|
||||
opacity: 0.55;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
/* Highlighted line spans inherit highlight.js colours; keep them on the line itself. */
|
||||
|
||||
/* ===== Code block: collapsible (long blocks / large JSON) ===== */
|
||||
.markdown-body :deep(details.code-block--collapsible) {
|
||||
/* Reuse the .code-block visuals — already applied via the shared class. */
|
||||
}
|
||||
.markdown-body :deep(details.code-block--collapsible > summary) {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.markdown-body :deep(details.code-block--collapsible > summary::-webkit-details-marker) {
|
||||
display: none;
|
||||
}
|
||||
.markdown-body :deep(.code-block__lines) {
|
||||
display: none;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin: 0 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
user-select: none;
|
||||
}
|
||||
/* Show line-count badge only when the block is collapsible AND collapsed. */
|
||||
.markdown-body :deep(details.code-block--collapsible:not([open]) .code-block__lines) {
|
||||
display: inline-block;
|
||||
}
|
||||
.markdown-body :deep(details.code-block--collapsible:not([open]) > pre) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ===== Mermaid block ===== */
|
||||
.markdown-body :deep(.mermaid-block) {
|
||||
margin: 14px 0;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--mc-mermaid-bg, #f8fafc);
|
||||
border: 1px solid var(--mc-mermaid-border, #e2e8f0);
|
||||
text-align: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.markdown-body :deep(.mermaid-block svg) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.markdown-body :deep(.mermaid-block.mermaid-error) {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #b91c1c;
|
||||
text-align: left;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ===== KaTeX inline / block ===== */
|
||||
.markdown-body :deep(.katex-inline) {
|
||||
font-size: 1em;
|
||||
}
|
||||
.markdown-body :deep(.katex-block) {
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
text-align: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.markdown-body :deep(.katex-error) {
|
||||
color: #b91c1c;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.markdown-body :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
|
||||
106
mateclaw-ui/src/composables/useKatexRenderer.ts
Normal file
106
mateclaw-ui/src/composables/useKatexRenderer.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { type Ref, watch, nextTick } from 'vue'
|
||||
|
||||
// Lazy-load KaTeX to keep initial bundle small (~280 KB saved).
|
||||
type KatexLib = typeof import('katex').default
|
||||
let katexModule: KatexLib | null = null
|
||||
async function getKatex(): Promise<KatexLib> {
|
||||
if (!katexModule) {
|
||||
katexModule = (await import('katex')).default
|
||||
// Side-effect import for the stylesheet — Vite tree-shakes this when
|
||||
// unused at build time, so users without LaTeX in any visible message
|
||||
// never download katex.min.css.
|
||||
await import('katex/dist/katex.min.css')
|
||||
}
|
||||
return katexModule
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that observes a container for `.katex-inline` / `.katex-block`
|
||||
* placeholder elements (emitted by `useMarkdownRenderer.preprocessLatex`)
|
||||
* and replaces them with KaTeX-typeset HTML.
|
||||
*
|
||||
* Mirrors the structure of `useEChartsRenderer` so behaviour is predictable:
|
||||
* MutationObserver picks up new placeholders as messages stream in, and
|
||||
* mounted elements are tracked via WeakMap to avoid double-renders.
|
||||
*/
|
||||
export function useKatexRenderer(containerRef: Ref<HTMLElement | null>) {
|
||||
// Track elements we've already rendered so re-scans are cheap.
|
||||
const rendered = new WeakSet<HTMLElement>()
|
||||
const mounting = new Set<HTMLElement>()
|
||||
let observer: MutationObserver | null = null
|
||||
|
||||
async function mountElement(el: HTMLElement) {
|
||||
if (rendered.has(el) || mounting.has(el)) return
|
||||
mounting.add(el)
|
||||
const tex = decodeURIComponent(el.getAttribute('data-tex') || '')
|
||||
if (!tex) {
|
||||
mounting.delete(el)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const katex = await getKatex()
|
||||
const isBlock = el.classList.contains('katex-block')
|
||||
katex.render(tex, el, {
|
||||
throwOnError: false, // failed input renders as red TeX, never throws
|
||||
displayMode: isBlock,
|
||||
output: 'html',
|
||||
trust: false, // don't trust input (it's user/LLM content)
|
||||
strict: 'ignore', // tolerate non-standard commands
|
||||
})
|
||||
rendered.add(el)
|
||||
} catch (e) {
|
||||
console.error('[KatexRenderer] render error:', e)
|
||||
// Fall back to the raw TeX so the user can still read it.
|
||||
el.textContent = tex
|
||||
el.classList.add('katex-error')
|
||||
} finally {
|
||||
mounting.delete(el)
|
||||
}
|
||||
}
|
||||
|
||||
function scanAndMount() {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
const blocks = container.querySelectorAll<HTMLElement>(
|
||||
'.katex-inline[data-tex]:not(.katex-error), .katex-block[data-tex]:not(.katex-error)',
|
||||
)
|
||||
blocks.forEach((el) => {
|
||||
if (!rendered.has(el) && !mounting.has(el) && !el.querySelector('.katex')) {
|
||||
mountElement(el)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function attachObserver(container: HTMLElement) {
|
||||
observer?.disconnect()
|
||||
observer = new MutationObserver(() => {
|
||||
// Defer to nextTick so all of Vue's batched DOM updates settle first.
|
||||
nextTick(() => scanAndMount())
|
||||
})
|
||||
observer.observe(container, { childList: true, subtree: true })
|
||||
scanAndMount()
|
||||
}
|
||||
|
||||
function startObserving() {
|
||||
const container = containerRef.value
|
||||
if (container) attachObserver(container)
|
||||
}
|
||||
|
||||
// If the ref is null at composable-call time (e.g. v-if container), wait
|
||||
// for it to materialise.
|
||||
const stopContainerWatch = watch(
|
||||
() => containerRef.value,
|
||||
(newContainer) => {
|
||||
if (newContainer && !observer) attachObserver(newContainer)
|
||||
},
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
function dispose() {
|
||||
stopContainerWatch()
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
}
|
||||
|
||||
return { startObserving, dispose, scanAndMount }
|
||||
}
|
||||
@ -1,8 +1,11 @@
|
||||
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',
|
||||
@ -37,28 +40,162 @@ function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// marked v15 requires a plain object renderer — class instances extending Renderer are NOT dispatched
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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]
|
||||
|
||||
// ECharts chart block: render as a placeholder div
|
||||
// Mermaid: ship raw source through a placeholder div for the
|
||||
// useMermaidRenderer post-mount step. Skip syntax highlighting entirely.
|
||||
if (infoStr === 'mermaid') {
|
||||
return `<div class="mermaid-block" data-mermaid="${encodeURIComponent(rawCode)}"></div>`
|
||||
}
|
||||
|
||||
// ECharts: same pattern, mounted by useEChartsRenderer.
|
||||
if (infoStr === 'echarts') {
|
||||
const encodedOption = encodeURIComponent(rawCode)
|
||||
return `<div class="echarts-block" data-echarts-option="${encodedOption}"></div>`
|
||||
return `<div class="echarts-block" data-echarts-option="${encodeURIComponent(rawCode)}"></div>`
|
||||
}
|
||||
|
||||
const detectedLang = extractLang(infoStr)
|
||||
const hasLanguage = detectedLang && hljs.getLanguage(detectedLang)
|
||||
const hasLanguage = !!detectedLang && !!hljs.getLanguage(detectedLang)
|
||||
|
||||
let highlighted: string
|
||||
try {
|
||||
if (hasLanguage) {
|
||||
highlighted = hljs.highlight(rawCode, { language: detectedLang }).value
|
||||
} else {
|
||||
highlighted = hljs.highlightAuto(rawCode).value
|
||||
}
|
||||
highlighted = hasLanguage
|
||||
? hljs.highlight(rawCode, { language: detectedLang }).value
|
||||
: hljs.highlightAuto(rawCode).value
|
||||
} catch {
|
||||
highlighted = escapeHtml(rawCode)
|
||||
}
|
||||
@ -67,41 +204,149 @@ const customRenderer = {
|
||||
const encodedCode = encodeURIComponent(rawCode)
|
||||
const langClass = hasLanguage ? ` language-${detectedLang}` : ''
|
||||
|
||||
return `<div class="code-block">`
|
||||
+ `<div class="code-block__header">`
|
||||
// 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
|
||||
|
||||
const headerHtml = `<div class="code-block__header">`
|
||||
+ `<span class="code-block__lang">${escapeHtml(langLabel)}</span>`
|
||||
+ `<button class="code-block__copy" type="button" data-code="${encodedCode}">`
|
||||
+ `<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></div>`
|
||||
+ `<pre><code class="hljs${langClass}">${highlighted}</code></pre>`
|
||||
+ `</div>`
|
||||
|
||||
const codeBody = `<pre><code class="hljs${langClass}">${linesHtml}</code></pre>`
|
||||
|
||||
if (shouldCollapse) {
|
||||
// <details>/<summary> gives a native, no-JS collapse affordance. The
|
||||
// summary holds the header (lang badge + line count + copy button); the
|
||||
// <pre> sits in the <details> body and is hidden by CSS until expanded.
|
||||
const openAttr = openByDefault ? ' open' : ''
|
||||
return `<details class="code-block code-block--collapsible"${openAttr}>`
|
||||
+ `<summary>${headerHtml}</summary>`
|
||||
+ codeBody
|
||||
+ `</details>`
|
||||
}
|
||||
return `<div class="code-block">${headerHtml}${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
|
||||
}
|
||||
let extra = ''
|
||||
try {
|
||||
const url = new URL(href, 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)}"` : ''
|
||||
return `<a href="${escapeHtml(href)}"${titleAttr}${extra}>${innerHtml}</a>`
|
||||
},
|
||||
}
|
||||
|
||||
// 创建 marked 实例
|
||||
// ---------------------------------------------------------------------------
|
||||
// marked instance
|
||||
// ---------------------------------------------------------------------------
|
||||
const markedInstance = new Marked({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
renderer: customRenderer,
|
||||
})
|
||||
|
||||
// 配置 DOMPurify — 允许 Markdown + 代码块复制按钮的标签和属性
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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', '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'],
|
||||
ADD_ATTR: [
|
||||
'target', 'rel', 'class',
|
||||
'data-code', 'data-echarts-option', 'data-wiki-title', 'data-slug',
|
||||
'data-tex', 'data-mermaid',
|
||||
'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,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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): 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).
|
||||
return `${text.length}:${text.slice(0, 40)}:${text.slice(-40)}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
export function useMarkdownRenderer() {
|
||||
function renderMarkdown(content: string): string {
|
||||
if (!content) return ''
|
||||
// 将 [[Wiki Link]] 转换为可点击的 Wiki 引用链接
|
||||
const withWikiLinks = content.replace(
|
||||
const k = cacheKey(content)
|
||||
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" …>.
|
||||
const withWikiLinks = withLatex.replace(
|
||||
/\[\[([^\]]+)\]\]/g,
|
||||
'<a class="wiki-link" href="#" data-wiki-title="$1" onclick="window.dispatchEvent(new CustomEvent(\'wiki-link-click\',{detail:{title:\'$1\'}}));return false">$1</a>'
|
||||
)
|
||||
// 3. Marked → 4. DOMPurify.
|
||||
const rawHtml = markedInstance.parse(withWikiLinks) as string
|
||||
return DOMPurify.sanitize(rawHtml, purifyConfig)
|
||||
const result = DOMPurify.sanitize(rawHtml, purifyConfig)
|
||||
|
||||
// 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 {
|
||||
@ -115,6 +360,6 @@ export function useMarkdownRenderer() {
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例供直接使用
|
||||
// Direct singletons for tests / advanced callers.
|
||||
export { markedInstance, purifyConfig }
|
||||
export default markedInstance
|
||||
|
||||
151
mateclaw-ui/src/composables/useMermaidRenderer.ts
Normal file
151
mateclaw-ui/src/composables/useMermaidRenderer.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { type Ref, watch, nextTick } from 'vue'
|
||||
import { useThemeStore } from '@/stores/useThemeStore'
|
||||
|
||||
// Lazy-load Mermaid (~600 KB minified) — only fetched when a chat message
|
||||
// actually contains a ```mermaid block.
|
||||
type MermaidLib = typeof import('mermaid').default
|
||||
let mermaidModule: MermaidLib | null = null
|
||||
let initializedTheme: 'dark' | 'default' | null = null
|
||||
|
||||
async function getMermaid(theme: 'dark' | 'default'): Promise<MermaidLib> {
|
||||
if (!mermaidModule) {
|
||||
mermaidModule = (await import('mermaid')).default
|
||||
}
|
||||
if (initializedTheme !== theme) {
|
||||
// securityLevel:'strict' disables click handlers and inline JS — important
|
||||
// because Mermaid sources come from arbitrary LLM output. Coupled with
|
||||
// our existing DOMPurify pass it provides defence in depth.
|
||||
mermaidModule.initialize({
|
||||
startOnLoad: false,
|
||||
theme,
|
||||
securityLevel: 'strict',
|
||||
flowchart: { useMaxWidth: true, htmlLabels: true },
|
||||
themeVariables: theme === 'dark'
|
||||
? { darkMode: true, background: '#1e293b' }
|
||||
: {},
|
||||
})
|
||||
initializedTheme = theme
|
||||
}
|
||||
return mermaidModule
|
||||
}
|
||||
|
||||
let renderCounter = 0
|
||||
|
||||
/**
|
||||
* Composable that observes a container for `.mermaid-block[data-mermaid]`
|
||||
* placeholders (emitted by `useMarkdownRenderer.code()` for ```mermaid fenced
|
||||
* blocks) and replaces them with rendered SVG diagrams.
|
||||
*
|
||||
* Mirrors `useEChartsRenderer` / `useKatexRenderer` so the three post-render
|
||||
* augmentations behave identically: lazy-loaded module, MutationObserver for
|
||||
* streaming inserts, theme reactivity via re-render on dark-mode toggle.
|
||||
*/
|
||||
export function useMermaidRenderer(containerRef: Ref<HTMLElement | null>) {
|
||||
const themeStore = useThemeStore()
|
||||
const rendered = new WeakSet<HTMLElement>()
|
||||
const mounting = new Set<HTMLElement>()
|
||||
const tracked = new Set<HTMLElement>()
|
||||
let observer: MutationObserver | null = null
|
||||
|
||||
async function mountBlock(el: HTMLElement) {
|
||||
if (rendered.has(el) || mounting.has(el)) return
|
||||
mounting.add(el)
|
||||
const src = decodeURIComponent(el.getAttribute('data-mermaid') || '')
|
||||
if (!src.trim()) {
|
||||
mounting.delete(el)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const theme = themeStore.isDark ? 'dark' : 'default'
|
||||
const mermaid = await getMermaid(theme)
|
||||
|
||||
// Pre-parse so we can fall back gracefully on bad input. Without this,
|
||||
// mermaid 11.x's render() emits its own bomb-icon "Syntax error" SVG
|
||||
// INSTEAD of throwing — which (1) looks ugly and (2) wouldn't trigger
|
||||
// our catch block, so the same broken source would re-render on every
|
||||
// streaming token mutation, flooding the console.
|
||||
const parsed = await mermaid.parse(src, { suppressErrors: true })
|
||||
if (!parsed) {
|
||||
throw new Error('mermaid parse failed')
|
||||
}
|
||||
const id = `mc-mermaid-${++renderCounter}`
|
||||
const { svg } = await mermaid.render(id, src)
|
||||
el.innerHTML = svg
|
||||
rendered.add(el)
|
||||
tracked.add(el)
|
||||
} catch (e) {
|
||||
// Streaming-aware: if the LLM is still emitting tokens the source may
|
||||
// legitimately be incomplete on each pass. Mark this element done so
|
||||
// we don't retry on every mutation; when streaming finishes Vue's v-html
|
||||
// produces a fresh element (different identity) and we'll try again.
|
||||
console.warn('[MermaidRenderer] failed to render — showing source:', e)
|
||||
el.classList.add('mermaid-error')
|
||||
el.textContent = src
|
||||
rendered.add(el)
|
||||
} finally {
|
||||
mounting.delete(el)
|
||||
}
|
||||
}
|
||||
|
||||
function scanAndMount() {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
const blocks = container.querySelectorAll<HTMLElement>(
|
||||
'.mermaid-block[data-mermaid]:not(.mermaid-error)',
|
||||
)
|
||||
blocks.forEach((el) => {
|
||||
if (!rendered.has(el) && !mounting.has(el) && !el.querySelector('svg')) {
|
||||
mountBlock(el)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function rebuildAll() {
|
||||
// Theme switch: clear rendered SVGs and re-mount with the new theme.
|
||||
tracked.forEach((el) => {
|
||||
el.innerHTML = ''
|
||||
rendered.delete(el)
|
||||
})
|
||||
tracked.clear()
|
||||
initializedTheme = null // force re-init with the new theme
|
||||
scanAndMount()
|
||||
}
|
||||
|
||||
function attachObserver(container: HTMLElement) {
|
||||
observer?.disconnect()
|
||||
observer = new MutationObserver(() => {
|
||||
nextTick(() => scanAndMount())
|
||||
})
|
||||
observer.observe(container, { childList: true, subtree: true })
|
||||
scanAndMount()
|
||||
}
|
||||
|
||||
function startObserving() {
|
||||
const container = containerRef.value
|
||||
if (container) attachObserver(container)
|
||||
}
|
||||
|
||||
const stopContainerWatch = watch(
|
||||
() => containerRef.value,
|
||||
(newContainer) => {
|
||||
if (newContainer && !observer) attachObserver(newContainer)
|
||||
},
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
// Re-render on theme change so diagrams pick up the new colour palette.
|
||||
const stopThemeWatch = watch(
|
||||
() => themeStore.isDark,
|
||||
() => rebuildAll(),
|
||||
)
|
||||
|
||||
function dispose() {
|
||||
stopContainerWatch()
|
||||
stopThemeWatch()
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
tracked.clear()
|
||||
}
|
||||
|
||||
return { startObserving, dispose, scanAndMount }
|
||||
}
|
||||
@ -1238,6 +1238,7 @@ export default {
|
||||
addText: 'Add Text',
|
||||
noRawMaterials: 'No raw materials yet',
|
||||
reprocess: 'Reprocess',
|
||||
resume: 'Resume',
|
||||
processAll: 'Process All Pending',
|
||||
materialTitle: 'Title',
|
||||
materialContent: 'Content',
|
||||
|
||||
@ -1248,6 +1248,7 @@ export default {
|
||||
addText: '添加文本',
|
||||
noRawMaterials: '暂无原始材料',
|
||||
reprocess: '重新处理',
|
||||
resume: '继续生成',
|
||||
processAll: '处理所有待处理材料',
|
||||
materialTitle: '标题',
|
||||
materialContent: '内容',
|
||||
|
||||
@ -320,6 +320,11 @@ async function fetchPromptFiles() {
|
||||
function handlePreviewClick(e: MouseEvent) {
|
||||
const btn = (e.target as HTMLElement).closest('.code-block__copy') as HTMLElement | null
|
||||
if (!btn) return
|
||||
// Same as ChatConsole: the copy button now lives inside <details><summary>
|
||||
// for collapsible blocks. Without preventDefault the click toggles the
|
||||
// details open state in addition to copying.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const encoded = btn.getAttribute('data-code')
|
||||
if (!encoded) return
|
||||
const code = decodeURIComponent(encoded)
|
||||
|
||||
@ -291,6 +291,8 @@ import StreamLoadingBar from '@/components/chat/StreamLoadingBar.vue'
|
||||
import TalkMode from '@/components/chat/TalkMode.vue'
|
||||
import ModelSelector from '@/components/chat/ModelSelector.vue'
|
||||
import { useEChartsRenderer } from '@/composables/useEChartsRenderer'
|
||||
import { useKatexRenderer } from '@/composables/useKatexRenderer'
|
||||
import { useMermaidRenderer } from '@/composables/useMermaidRenderer'
|
||||
|
||||
// ============ Talk Mode ============
|
||||
const showTalkMode = ref(false)
|
||||
@ -555,9 +557,13 @@ async function collectFilesFromEntries(dirEntries: FileSystemDirectoryEntry[]):
|
||||
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
|
||||
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null)
|
||||
|
||||
// ECharts: extract DOM element from MessageList component ref
|
||||
// Post-render augmentations (ECharts, KaTeX, Mermaid) all watch the same
|
||||
// MessageList container — placeholders emitted by useMarkdownRenderer get
|
||||
// upgraded in place after Vue paints the rendered Markdown HTML.
|
||||
const echartsContainerRef = computed(() => messageListRef.value?.$el as HTMLElement | null)
|
||||
const { startObserving: startECharts, dispose: disposeECharts } = useEChartsRenderer(echartsContainerRef)
|
||||
const { startObserving: startKatex, dispose: disposeKatex } = useKatexRenderer(echartsContainerRef)
|
||||
const { startObserving: startMermaid, dispose: disposeMermaid } = useMermaidRenderer(echartsContainerRef)
|
||||
|
||||
// 使用 useChat composable
|
||||
const {
|
||||
@ -767,6 +773,8 @@ onMounted(async () => {
|
||||
document.addEventListener('keydown', handleKeyboardShortcuts)
|
||||
document.addEventListener('click', handleCodeCopy)
|
||||
startECharts()
|
||||
startKatex()
|
||||
startMermaid()
|
||||
mobileQuery = window.matchMedia('(max-width: 768px)')
|
||||
handleMobileChange(mobileQuery)
|
||||
mobileQuery.addEventListener('change', handleMobileChange)
|
||||
@ -782,6 +790,8 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleKeyboardShortcuts)
|
||||
document.removeEventListener('click', handleCodeCopy)
|
||||
disposeECharts()
|
||||
disposeKatex()
|
||||
disposeMermaid()
|
||||
mobileQuery?.removeEventListener('change', handleMobileChange)
|
||||
mediumQuery?.removeEventListener('change', handleConvMediumChange)
|
||||
if (activityPollTimer !== null) {
|
||||
@ -1442,6 +1452,12 @@ function formatConversationTime(time?: string) {
|
||||
function handleCodeCopy(e: MouseEvent) {
|
||||
const btn = (e.target as HTMLElement).closest('.code-block__copy') as HTMLElement | null
|
||||
if (!btn) return
|
||||
// The copy button now sits inside <details><summary> for collapsible code
|
||||
// blocks. Without preventDefault the click would also toggle the details
|
||||
// open state — a regression introduced when we wrapped long blocks in
|
||||
// <details>. stopPropagation guards against any future ancestor handlers.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const encoded = btn.getAttribute('data-code')
|
||||
if (!encoded) return
|
||||
const code = decodeURIComponent(encoded)
|
||||
|
||||
@ -154,7 +154,16 @@
|
||||
</div>
|
||||
<div class="raw-item-actions">
|
||||
<button
|
||||
v-if="raw.processingStatus === 'failed' || raw.processingStatus === 'completed' || raw.processingStatus === 'partial'"
|
||||
v-if="raw.processingStatus === 'partial'"
|
||||
class="btn-icon btn-icon-resume" :title="t('wiki.resume')"
|
||||
@click="reprocess(raw.id)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
|
||||
<polygon points="6 4 20 12 6 20 6 4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="raw.processingStatus === 'failed' || raw.processingStatus === 'completed'"
|
||||
class="btn-icon" :title="t('wiki.reprocess')"
|
||||
@click="reprocess(raw.id)"
|
||||
>
|
||||
@ -686,6 +695,8 @@ async function handleScanDir() {
|
||||
.btn-icon { width: 30px; height: 30px; border: 1px solid var(--mc-border-light); background: var(--mc-bg-elevated); cursor: pointer; border-radius: 8px; color: var(--mc-text-secondary); transition: all 0.15s; display: flex; align-items: center; justify-content: center; }
|
||||
.btn-icon:hover { background: var(--mc-bg-sunken); color: var(--mc-primary); border-color: var(--mc-border); }
|
||||
.btn-icon-danger:hover { background: var(--mc-danger-bg); color: var(--mc-danger); border-color: var(--mc-danger); }
|
||||
.btn-icon-resume { color: var(--mc-primary); border-color: var(--mc-primary); background: var(--mc-primary-bg); }
|
||||
.btn-icon-resume:hover { background: var(--mc-primary); color: #fff; border-color: var(--mc-primary); }
|
||||
|
||||
/* Status badges */
|
||||
.status-badge { font-size: 10px; padding: 2px 8px; border-radius: 9999px; text-transform: uppercase; font-weight: 500; letter-spacing: 0.02em; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user