fix(wiki): smaller batch-create + resume button for partial generation

This commit is contained in:
matevip 2026-04-26 18:42:27 +08:00
parent b7c911f01d
commit e2df16893e
13 changed files with 1597 additions and 50 deletions

View File

@ -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 35 creates per chunk in a single call.
* Default 2: keeps total output tokens well under typical provider caps
* (~2k3k 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.

View File

@ -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",

File diff suppressed because it is too large Load Diff

View File

@ -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);

View File

@ -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;

View 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 }
}

View File

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
// 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

View 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 }
}

View File

@ -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',

View File

@ -1248,6 +1248,7 @@ export default {
addText: '添加文本',
noRawMaterials: '暂无原始材料',
reprocess: '重新处理',
resume: '继续生成',
processAll: '处理所有待处理材料',
materialTitle: '标题',
materialContent: '内容',

View File

@ -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)

View File

@ -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)

View File

@ -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; }