//! Markdown 渲染 composable — 抽自 src/components/AiChat.vue //! //! 背景:TaskDetail 任务描述需 Markdown 渲染(## 标题 / - 列表),原渲染逻辑只在 //! AiChat.vue 内联,且与流式核心(splitBlocks/parseBlock/renderStreamingMd)共享 //! _marked/_purify/mdReady。抽出为 composable 复用,AiChat 流式核心保持原状不动。 //! //! 设计要点: //! - **模块级单例**:_marked/_purify/mdReady/_mdCache 放模块作用域,所有调用方共享 //! 同一份渲染器实例与缓存。AiChat 的 loadMarkdown() 与流式 parseBlock 共用此实例, //! 行为与抽前完全一致。 //! - **XSS 防护**:renderMd 输出经 DOMPurify.sanitize,可安全 v-html。 //! - **块级 memo**:历史/完成态整段缓存(_mdCache),文本不变命中跳过 marked 全量解析。 //! - **未就绪兜底**:marked 未加载完成时 escapeHtml +
降级(纯文本安全)。 //! //! 行为零变化说明:本文件函数体从 AiChat.vue 原样移出,逻辑未改动。 //! AiChat.vue 流式函数(splitBlocks/parseBlock/parseBlockNoCache/renderStreamingMd/ //! scheduleStreamParse)继续调用 useMarkdown 暴露的 mdReady/escapeFallback, //! 渲染结果不变。 import { computed, ref, type ComputedRef } from 'vue' // UX-2025-05: 代码块语法高亮 —— 用 highlight.js/lib/core + 按需 registerLanguage, // 只打常用 ~16 门语言进包(避免全量 import ~1MB);core+已注册语言 gzip 后远小于全量。 import hljs from 'highlight.js/lib/core' import javascript from 'highlight.js/lib/languages/javascript' import typescript from 'highlight.js/lib/languages/typescript' import rust from 'highlight.js/lib/languages/rust' import python from 'highlight.js/lib/languages/python' import go from 'highlight.js/lib/languages/go' import sql from 'highlight.js/lib/languages/sql' import bash from 'highlight.js/lib/languages/bash' import json from 'highlight.js/lib/languages/json' import yaml from 'highlight.js/lib/languages/yaml' import markdown from 'highlight.js/lib/languages/markdown' import xml from 'highlight.js/lib/languages/xml' import css from 'highlight.js/lib/languages/css' import shell from 'highlight.js/lib/languages/shell' import java from 'highlight.js/lib/languages/java' import csharp from 'highlight.js/lib/languages/csharp' import cpp from 'highlight.js/lib/languages/cpp' // B-260616-11(②子项): diff 语言注册,```diff 代码块 +/- 行获 hljs-addition/deletion 红绿(github-dark.css 自带) import diff from 'highlight.js/lib/languages/diff' // 主题:github-dark 与现有深色 UI(#0c0e1a)贴合;亮色主题下 code 块仍可读(深底浅字)。 // 注意:该 CSS 在 ai-md.css 之前/之后导入均可,hljs 用 .hljs/.hljs-* token class 着色, // 与 .ai-md pre code 的基础色互不冲突(token 是独立 )。 import 'highlight.js/styles/github-dark.css' // 按需注册常用语言(大小写不敏感的别名映射见 _LANG_ALIASES) hljs.registerLanguage('javascript', javascript) hljs.registerLanguage('typescript', typescript) hljs.registerLanguage('rust', rust) hljs.registerLanguage('python', python) hljs.registerLanguage('go', go) hljs.registerLanguage('sql', sql) hljs.registerLanguage('bash', bash) hljs.registerLanguage('json', json) hljs.registerLanguage('yaml', yaml) hljs.registerLanguage('markdown', markdown) hljs.registerLanguage('xml', xml) hljs.registerLanguage('css', css) hljs.registerLanguage('shell', shell) hljs.registerLanguage('java', java) hljs.registerLanguage('csharp', csharp) hljs.registerLanguage('cpp', cpp) hljs.registerLanguage('diff', diff) // 围栏 info 字符串常见别名 → 已注册语言(hljs.getLanguage 内置部分别名,这里补齐业务常用) const _LANG_ALIASES: Record = { js: 'javascript', ts: 'typescript', py: 'python', rb: 'ruby', sh: 'bash', shell: 'shell', yml: 'yaml', md: 'markdown', html: 'xml', vue: 'xml', svg: 'xml', cs: 'csharp', 'c++': 'cpp', 'c#': 'csharp', } // ═══ 模块级单例:AiChat 流式核心与历史 renderMd 共享同一份渲染器/缓存 ═══ let _marked: typeof import('marked')['marked'] | null = null let _purify: typeof import('dompurify')['default'] | null = null const mdReady = ref(false) // 历史消息整段缓存(文本不变,重渲染命中省去 marked+sanitize 全量解析) const _mdCache = new Map() /** * UX-2025-05: marked code renderer 注入 —— 优先按 infostring 指定语言高亮(需已注册), * 否则 highlightAuto 在已注册语言里猜测。输出
,
 * 保留 marked 原 code 块外层结构(pre/code),兼容现有 .ai-md pre code 样式与 DOMPurify。
 *
 * 返回 false 让 marked 用默认 code 渲染再包 pre 不可行(marked v8 code 返回值语义),
 * 故这里直接返回完整 
...
HTML(marked renderer.code 的契约)。 */ function highlightCode(code: string, infostring: string): string { // infostring 形如 "ts" / "javascript" / "rust,norun" —— 取首个 token 去空白小写 const langRaw = (infostring || '').split(/[\s,]/)[0]?.trim().toLowerCase() ?? '' const lang = _LANG_ALIASES[langRaw] ?? langRaw let highlighted: string try { if (lang && hljs.getLanguage(lang)) { highlighted = hljs.highlight(code, { language: lang, ignoreIllegals: true }).value } else { // 无 infostring 或未注册语言:在已注册语言里自动猜测(仍有限,但覆盖常用) highlighted = hljs.highlightAuto(code).value } } catch { // 高亮异常时降级为纯 HTML 转义(marked 原行为),不破坏渲染 return code.replace(/&/g, '&').replace(//g, '>') } const cls = lang ? `hljs language-${lang}` : 'hljs' return `
${highlighted}
` } /** 异步加载 marked + DOMPurify;加载完成后 mdReady 翻转触发重渲染(模块级,所有调用方共享) */ async function loadMarkdown() { const [{ marked }, dp] = await Promise.all([import('marked'), import('dompurify')]) marked.setOptions({ breaks: true, gfm: true }) // UX-2025-05: 注入 code renderer 做语法高亮(marked.use renderer 合并,仅覆盖 code, // 其余 renderer 沿用 marked 默认)。 marked.use({ renderer: { code({ text, lang }: { text: string; lang?: string }) { return highlightCode(text, lang ?? '') }, }, }) _marked = marked _purify = dp.default _mdCache.clear() // 渲染器就绪,清兜底缓存触发完整重渲染 mdReady.value = true } export function useMarkdown() { return { mdReady, loadMarkdown, renderMd, // 暴露给 AiChat 流式核心(parseBlock/renderStreamingMd)复用,保持单例共享 escapeFallback, getMarked: () => _marked, getPurify: () => _purify, } } /** * 详情页 Markdown 渲染辅助:封装 computed(读 mdReady 触发响应式 + renderMd(getText)) * + ensureLoaded(幂等预热 loadMarkdown)。消除 TaskDetail/ProjectDetail/Ideas/Knowledge * 四视图手写 computed + onMounted(loadMarkdown) 重复。 * * - getText: 返回待渲染文本(通常读响应式 source.value?.description ?? ''), * 文本变化时 computed 重算。 * - rendered: computed,mdReady 翻转/文本变化触发重渲染,行为与抽前一致。 * - ensureLoaded():幂等触发后台预热,首次调用执行 loadMarkdown,后续 no-op。 * * 行为零变化:渲染/预热逻辑未改,仅 DRY 抽取。 */ export function useRendered(getText: () => string): { rendered: ComputedRef ensureLoaded: () => void } { const rendered = computed(() => { void mdReady.value // 触发响应式依赖:mdReady 翻转时 computed 重算 return renderMd(getText()) }) let loaded = false function ensureLoaded() { if (loaded) return loaded = true loadMarkdown() } return { rendered, ensureLoaded } } function escapeHtml(text: string): string { return text.replace(/&/g, '&').replace(//g, '>') } /// marked 未就绪时的兜底渲染:HTML 转义 + 换行转
(纯文本安全降级) function escapeFallback(text: string): string { return escapeHtml(text).replace(/\n/g, '
') } /// 历史/完成态:整段 marked + sanitize + 缓存(函数体原样移自 AiChat.vue) function renderMd(text: string): string { if (!text) return '' if (!mdReady.value || !_marked || !_purify) { return escapeFallback(text) } // B-260616-11(①子项): 裸 diff 文本预处理。渲染前将无围栏的连续 +/- 行包成 ```diff // 围栏代码块,复用②子项已注册的 diff 语言 hljs 红绿样式,零额外 CSS。 // 防御性:即使现网 AI 多带围栏,裸行也正确处理(围栏内整段不动)。 const wrapped = wrapNakedDiff(text) const cached = _mdCache.get(wrapped) if (cached !== undefined) return cached const html = _purify.sanitize(_marked.parse(wrapped) as string) if (_mdCache.size > 200) _mdCache.clear() _mdCache.set(wrapped, html) return html } /** * B-260616-11(①子项,方案A wrapNakedDiff): 裸 +/- diff 文本预处理。 * * 现网 AI(尤其 deepseek/glm/qwen 等)在描述「改动/对比」时常直接吐裸 -/+ 行而忘记 * 围栏(或仅用普通文本),导致 marked 当成普通段落/list 渲染,丢失 diff 视觉语义。 * 本函数在 marked.parse 前扫描文本,把「连续 ≥2 行以 + 或 - 开头、且至少含 1 行 +」的 * 段落整段包成 ```diff 围栏代码块,复用②子项已就绪的 hljs diff 红绿样式,零额外 CSS。 * * 守卫: * 1. **纯 - 列表不触发**: 普通 markdown ul 每行 `- ` 是常见语法,纯 - 会误伤成代码块。 * 故要求段内至少含 1 行 `+ `(+ 必须含),这是裸 diff 区别于普通列表的强特征。 * 2. **连续 ≥2 行 ±开头**: 单行 +/- 不构成 diff 块(可能只是 emoji/符号起首),不处理。 * 3. **已在围栏内不动**: 段落在 ```...```/~~~...~~~ 内(行首可选 ≤3 空格 + 围栏)整段跳过, * 避免双重包裹破坏已围栏的 diff 代码块。 * 4. **段落级处理**: 按 blank line 切段(\n\n),非 diff 段落(标题/普通段落/list)原样保留, * 避免 +- 混排在正文里被整段吞掉。 * * 注意:仅 marked.parse 前文本预处理,不碰 marked/hljs/DOMPurify 流程,行为可逆(去掉包裹即原文)。 * 缓存 key 用 wrapped(预处理后),同一原文命中同一缓存项。 */ const _DIFF_LINE_RE = /^[ \t]*[+-][^\n]*$/ // 行首可选空格/tab + +/- + 任意内容(+ 不限空格) const _PLUS_LINE_RE = /^[ \t]*\+[^\n]*$/ // 含 + 的行(+ 必须含的判定) const _FENCE_OPEN_RE = /^[ \t]{0,3}(```|~~~)/ // marked 围栏开头(行首 ≤3 空格 + 3+ 反引号/波浪) export function wrapNakedDiff(text: string): string { if (!text) return text // 快速短路:全文无 +/- 行首特征直接返回原文(避免大文本白跑切段) if (!/[+-]/.test(text)) return text // 按 blank line 切段(保留分隔符便于重组)。\n\n 为段落边界(marked 与 gfm 段落规则一致)。 // 用 lookahead split 保留分隔符,避免段落内容错位。 const paragraphs = text.split(/(\n{2,})/) let touched = false for (let i = 0; i < paragraphs.length; i++) { const seg = paragraphs[i] if (!seg || /^\n{2,}$/.test(seg)) continue // 分隔符或空串跳过 // 守卫3: 整段在围栏内不动。检测段内是否含围栏开头行,含则跳过该段。 if (_FENCE_OPEN_RE.test(seg)) continue const lines = seg.split('\n') // 守卫2 + 特征判定:连续 ≥2 行以 +/- 开头,且(守卫1)段内至少含 1 行 + // 用最大连续 run 判定:找到连续 ≥2 行 ±开头的 run,且该 run 含 + let runStart = -1 let runLen = 0 let hasPlus = false let bestStart = -1 let bestLen = 0 for (let j = 0; j < lines.length; j++) { if (_DIFF_LINE_RE.test(lines[j])) { if (runStart < 0) { runStart = j runLen = 0 hasPlus = false } runLen++ if (_PLUS_LINE_RE.test(lines[j])) hasPlus = true } else { // 收尾当前 run:连续 ≥2 且含 + 才记为候选 diff 段 if (runStart >= 0 && runLen >= 2 && hasPlus) { if (runLen > bestLen) { bestLen = runLen bestStart = runStart } } runStart = -1 runLen = 0 hasPlus = false } } // 段尾收尾 if (runStart >= 0 && runLen >= 2 && hasPlus) { if (runLen > bestLen) { bestLen = runLen bestStart = runStart } } // 找到满足条件的 diff 段(整段都是连续 ± run,即段首即 diff):整段包成围栏 // 注意:仅当 diff run 覆盖整段时才包(混合段不包,避免吞掉正常文字)。 // bestStart===0 且 bestLen===lines.length → 整段都是 ± 行 if (bestStart === 0 && bestLen === lines.length && bestLen >= 2) { paragraphs[i] = '```diff\n' + seg + '\n```' touched = true } } return touched ? paragraphs.join('') : text }