mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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;
|
* Pages planned by route are chunked into sub-batches of this size;
|
||||||
* a local liveIndex is updated between sub-batches so later pages can
|
* a local liveIndex is updated between sub-batches so later pages can
|
||||||
* link to earlier ones created in the same chunk.
|
* link to earlier ones created in the same chunk.
|
||||||
* Default 5: keeps output tokens per call predictable while covering
|
* Default 2: keeps total output tokens well under typical provider caps
|
||||||
* the typical 3–5 creates per chunk in a single call.
|
* (~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.
|
* RFC-047: Minimum chunk length (chars) for the chunk-fallback mechanism.
|
||||||
|
|||||||
@ -18,8 +18,10 @@
|
|||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"element-plus": "^2.9.1",
|
"element-plus": "^2.9.1",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"katex": "^0.16.45",
|
||||||
"marked": "^15.0.6",
|
"marked": "^15.0.6",
|
||||||
"marked-highlight": "^2.2.3",
|
"marked-highlight": "^2.2.3",
|
||||||
|
"mermaid": "^11.14.0",
|
||||||
"pinia": "^3.0.1",
|
"pinia": "^3.0.1",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "9.14.4",
|
"vue-i18n": "9.14.4",
|
||||||
@ -27,6 +29,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@types/katex": "^0.16.8",
|
||||||
"@vitejs/plugin-vue": "^6.0.5",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
"@vue/tsconfig": "^0.7.0",
|
"@vue/tsconfig": "^0.7.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"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-attachment-color: #7B3F1E;
|
||||||
--mc-thinking-bg: #F5F0EB;
|
--mc-thinking-bg: #F5F0EB;
|
||||||
--mc-thinking-text: #5A4030;
|
--mc-thinking-text: #5A4030;
|
||||||
|
--mc-mermaid-bg: #FAFAF8;
|
||||||
|
--mc-mermaid-border: #DDD5CC;
|
||||||
--mc-thinking-hover: rgba(217, 119, 87, 0.1);
|
--mc-thinking-hover: rgba(217, 119, 87, 0.1);
|
||||||
--mc-thinking-icon-bg: rgba(217, 119, 87, 0.12);
|
--mc-thinking-icon-bg: rgba(217, 119, 87, 0.12);
|
||||||
--mc-thinking-border: rgba(217, 119, 87, 0.2);
|
--mc-thinking-border: rgba(217, 119, 87, 0.2);
|
||||||
@ -232,6 +234,8 @@ html.dark {
|
|||||||
--mc-attachment-color: #F0C4A0;
|
--mc-attachment-color: #F0C4A0;
|
||||||
--mc-thinking-bg: #231C17;
|
--mc-thinking-bg: #231C17;
|
||||||
--mc-thinking-text: #C4A898;
|
--mc-thinking-text: #C4A898;
|
||||||
|
--mc-mermaid-bg: #1A1410;
|
||||||
|
--mc-mermaid-border: #3D3028;
|
||||||
--mc-thinking-hover: rgba(224, 136, 96, 0.1);
|
--mc-thinking-hover: rgba(224, 136, 96, 0.1);
|
||||||
--mc-thinking-icon-bg: rgba(224, 136, 96, 0.12);
|
--mc-thinking-icon-bg: rgba(224, 136, 96, 0.12);
|
||||||
--mc-thinking-border: rgba(224, 136, 96, 0.2);
|
--mc-thinking-border: rgba(224, 136, 96, 0.2);
|
||||||
|
|||||||
@ -1597,6 +1597,103 @@ watch(isGenerating, (generating) => {
|
|||||||
border-radius: 0;
|
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) {
|
.markdown-body :deep(img) {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
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 { Marked } from 'marked'
|
||||||
|
import type { Tokens } from 'marked'
|
||||||
import hljs from 'highlight.js'
|
import hljs from 'highlight.js'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
// 语言映射
|
// ---------------------------------------------------------------------------
|
||||||
|
// Language metadata
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
const LANG_DISPLAY: Record<string, string> = {
|
const LANG_DISPLAY: Record<string, string> = {
|
||||||
js: 'JavaScript', javascript: 'JavaScript', ts: 'TypeScript', typescript: 'TypeScript',
|
js: 'JavaScript', javascript: 'JavaScript', ts: 'TypeScript', typescript: 'TypeScript',
|
||||||
py: 'Python', python: 'Python', java: 'Java', kt: 'Kotlin', kotlin: 'Kotlin',
|
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, '"')
|
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 = {
|
const customRenderer = {
|
||||||
code({ text, lang }: { type: string; raw: string; text: string; lang?: string }): string {
|
code({ text, lang }: { type: string; raw: string; text: string; lang?: string }): string {
|
||||||
const rawCode = text || ''
|
const rawCode = text || ''
|
||||||
const infoStr = (lang || '').split(/\s/)[0]
|
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') {
|
if (infoStr === 'echarts') {
|
||||||
const encodedOption = encodeURIComponent(rawCode)
|
return `<div class="echarts-block" data-echarts-option="${encodeURIComponent(rawCode)}"></div>`
|
||||||
return `<div class="echarts-block" data-echarts-option="${encodedOption}"></div>`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const detectedLang = extractLang(infoStr)
|
const detectedLang = extractLang(infoStr)
|
||||||
const hasLanguage = detectedLang && hljs.getLanguage(detectedLang)
|
const hasLanguage = !!detectedLang && !!hljs.getLanguage(detectedLang)
|
||||||
|
|
||||||
let highlighted: string
|
let highlighted: string
|
||||||
try {
|
try {
|
||||||
if (hasLanguage) {
|
highlighted = hasLanguage
|
||||||
highlighted = hljs.highlight(rawCode, { language: detectedLang }).value
|
? hljs.highlight(rawCode, { language: detectedLang }).value
|
||||||
} else {
|
: hljs.highlightAuto(rawCode).value
|
||||||
highlighted = hljs.highlightAuto(rawCode).value
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
highlighted = escapeHtml(rawCode)
|
highlighted = escapeHtml(rawCode)
|
||||||
}
|
}
|
||||||
@ -67,41 +204,149 @@ const customRenderer = {
|
|||||||
const encodedCode = encodeURIComponent(rawCode)
|
const encodedCode = encodeURIComponent(rawCode)
|
||||||
const langClass = hasLanguage ? ` language-${detectedLang}` : ''
|
const langClass = hasLanguage ? ` language-${detectedLang}` : ''
|
||||||
|
|
||||||
return `<div class="code-block">`
|
// Split into one <li> per source line so CSS counter renders the gutter.
|
||||||
+ `<div class="code-block__header">`
|
// 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>`
|
+ `<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>`
|
+ `<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>`
|
+ `<span class="code-block__copy-text">Copy</span>`
|
||||||
+ `</button></div>`
|
+ `</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({
|
const markedInstance = new Marked({
|
||||||
gfm: true,
|
gfm: true,
|
||||||
breaks: true,
|
breaks: true,
|
||||||
renderer: customRenderer,
|
renderer: customRenderer,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 配置 DOMPurify — 允许 Markdown + 代码块复制按钮的标签和属性
|
// ---------------------------------------------------------------------------
|
||||||
|
// DOMPurify config — allow Markdown + custom blocks (code-block, KaTeX/Mermaid
|
||||||
|
// placeholders) and the inline copy SVG button.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
const purifyConfig = {
|
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_ATTR: [
|
||||||
ADD_TAGS: ['input', 'button', 'svg', 'path', 'rect', 'polyline', 'circle', 'line', 'span'],
|
'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() {
|
export function useMarkdownRenderer() {
|
||||||
function renderMarkdown(content: string): string {
|
function renderMarkdown(content: string): string {
|
||||||
if (!content) return ''
|
if (!content) return ''
|
||||||
// 将 [[Wiki Link]] 转换为可点击的 Wiki 引用链接
|
const k = cacheKey(content)
|
||||||
const withWikiLinks = content.replace(
|
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,
|
/\[\[([^\]]+)\]\]/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>'
|
'<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
|
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 {
|
function escapeText(text: string): string {
|
||||||
@ -115,6 +360,6 @@ export function useMarkdownRenderer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出单例供直接使用
|
// Direct singletons for tests / advanced callers.
|
||||||
export { markedInstance, purifyConfig }
|
export { markedInstance, purifyConfig }
|
||||||
export default markedInstance
|
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',
|
addText: 'Add Text',
|
||||||
noRawMaterials: 'No raw materials yet',
|
noRawMaterials: 'No raw materials yet',
|
||||||
reprocess: 'Reprocess',
|
reprocess: 'Reprocess',
|
||||||
|
resume: 'Resume',
|
||||||
processAll: 'Process All Pending',
|
processAll: 'Process All Pending',
|
||||||
materialTitle: 'Title',
|
materialTitle: 'Title',
|
||||||
materialContent: 'Content',
|
materialContent: 'Content',
|
||||||
|
|||||||
@ -1248,6 +1248,7 @@ export default {
|
|||||||
addText: '添加文本',
|
addText: '添加文本',
|
||||||
noRawMaterials: '暂无原始材料',
|
noRawMaterials: '暂无原始材料',
|
||||||
reprocess: '重新处理',
|
reprocess: '重新处理',
|
||||||
|
resume: '继续生成',
|
||||||
processAll: '处理所有待处理材料',
|
processAll: '处理所有待处理材料',
|
||||||
materialTitle: '标题',
|
materialTitle: '标题',
|
||||||
materialContent: '内容',
|
materialContent: '内容',
|
||||||
|
|||||||
@ -320,6 +320,11 @@ async function fetchPromptFiles() {
|
|||||||
function handlePreviewClick(e: MouseEvent) {
|
function handlePreviewClick(e: MouseEvent) {
|
||||||
const btn = (e.target as HTMLElement).closest('.code-block__copy') as HTMLElement | null
|
const btn = (e.target as HTMLElement).closest('.code-block__copy') as HTMLElement | null
|
||||||
if (!btn) return
|
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')
|
const encoded = btn.getAttribute('data-code')
|
||||||
if (!encoded) return
|
if (!encoded) return
|
||||||
const code = decodeURIComponent(encoded)
|
const code = decodeURIComponent(encoded)
|
||||||
|
|||||||
@ -291,6 +291,8 @@ import StreamLoadingBar from '@/components/chat/StreamLoadingBar.vue'
|
|||||||
import TalkMode from '@/components/chat/TalkMode.vue'
|
import TalkMode from '@/components/chat/TalkMode.vue'
|
||||||
import ModelSelector from '@/components/chat/ModelSelector.vue'
|
import ModelSelector from '@/components/chat/ModelSelector.vue'
|
||||||
import { useEChartsRenderer } from '@/composables/useEChartsRenderer'
|
import { useEChartsRenderer } from '@/composables/useEChartsRenderer'
|
||||||
|
import { useKatexRenderer } from '@/composables/useKatexRenderer'
|
||||||
|
import { useMermaidRenderer } from '@/composables/useMermaidRenderer'
|
||||||
|
|
||||||
// ============ Talk Mode ============
|
// ============ Talk Mode ============
|
||||||
const showTalkMode = ref(false)
|
const showTalkMode = ref(false)
|
||||||
@ -555,9 +557,13 @@ async function collectFilesFromEntries(dirEntries: FileSystemDirectoryEntry[]):
|
|||||||
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
|
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
|
||||||
const chatInputRef = ref<InstanceType<typeof ChatInput> | 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 echartsContainerRef = computed(() => messageListRef.value?.$el as HTMLElement | null)
|
||||||
const { startObserving: startECharts, dispose: disposeECharts } = useEChartsRenderer(echartsContainerRef)
|
const { startObserving: startECharts, dispose: disposeECharts } = useEChartsRenderer(echartsContainerRef)
|
||||||
|
const { startObserving: startKatex, dispose: disposeKatex } = useKatexRenderer(echartsContainerRef)
|
||||||
|
const { startObserving: startMermaid, dispose: disposeMermaid } = useMermaidRenderer(echartsContainerRef)
|
||||||
|
|
||||||
// 使用 useChat composable
|
// 使用 useChat composable
|
||||||
const {
|
const {
|
||||||
@ -767,6 +773,8 @@ onMounted(async () => {
|
|||||||
document.addEventListener('keydown', handleKeyboardShortcuts)
|
document.addEventListener('keydown', handleKeyboardShortcuts)
|
||||||
document.addEventListener('click', handleCodeCopy)
|
document.addEventListener('click', handleCodeCopy)
|
||||||
startECharts()
|
startECharts()
|
||||||
|
startKatex()
|
||||||
|
startMermaid()
|
||||||
mobileQuery = window.matchMedia('(max-width: 768px)')
|
mobileQuery = window.matchMedia('(max-width: 768px)')
|
||||||
handleMobileChange(mobileQuery)
|
handleMobileChange(mobileQuery)
|
||||||
mobileQuery.addEventListener('change', handleMobileChange)
|
mobileQuery.addEventListener('change', handleMobileChange)
|
||||||
@ -782,6 +790,8 @@ onBeforeUnmount(() => {
|
|||||||
document.removeEventListener('keydown', handleKeyboardShortcuts)
|
document.removeEventListener('keydown', handleKeyboardShortcuts)
|
||||||
document.removeEventListener('click', handleCodeCopy)
|
document.removeEventListener('click', handleCodeCopy)
|
||||||
disposeECharts()
|
disposeECharts()
|
||||||
|
disposeKatex()
|
||||||
|
disposeMermaid()
|
||||||
mobileQuery?.removeEventListener('change', handleMobileChange)
|
mobileQuery?.removeEventListener('change', handleMobileChange)
|
||||||
mediumQuery?.removeEventListener('change', handleConvMediumChange)
|
mediumQuery?.removeEventListener('change', handleConvMediumChange)
|
||||||
if (activityPollTimer !== null) {
|
if (activityPollTimer !== null) {
|
||||||
@ -1442,6 +1452,12 @@ function formatConversationTime(time?: string) {
|
|||||||
function handleCodeCopy(e: MouseEvent) {
|
function handleCodeCopy(e: MouseEvent) {
|
||||||
const btn = (e.target as HTMLElement).closest('.code-block__copy') as HTMLElement | null
|
const btn = (e.target as HTMLElement).closest('.code-block__copy') as HTMLElement | null
|
||||||
if (!btn) return
|
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')
|
const encoded = btn.getAttribute('data-code')
|
||||||
if (!encoded) return
|
if (!encoded) return
|
||||||
const code = decodeURIComponent(encoded)
|
const code = decodeURIComponent(encoded)
|
||||||
|
|||||||
@ -154,7 +154,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="raw-item-actions">
|
<div class="raw-item-actions">
|
||||||
<button
|
<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')"
|
class="btn-icon" :title="t('wiki.reprocess')"
|
||||||
@click="reprocess(raw.id)"
|
@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 { 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: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-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 badges */
|
||||||
.status-badge { font-size: 10px; padding: 2px 8px; border-radius: 9999px; text-transform: uppercase; font-weight: 500; letter-spacing: 0.02em; }
|
.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