//! 流式 Markdown 块级 memo 渲染 — 抽自 MessageList.vue(零行为变更)。 //! //! 职责:流式 delta 的块级切块 + memo 缓存 + rAF 节流分块渲染。 //! - splitBlocks:marked.lexer() 围栏感知切块(代码块整段一块,非代码按双换行切段) //! - parseBlock/parseBlockNoCache:已完成块缓存命中 O(末块),末块每次重 parse //! - renderStreamingBlocks:切块 + memo,返回 { html, key } 数组供模板 v-for v-html //! - scheduleStreamParse:rAF 节流,帧内取最新文本 parse + 选区保持 + 跟随滚底 //! //! 与父组件(MessageList)的交互经回调注入: //! - saveSelection/restoreSelection:UX-2025-01 选文字保持(v-html 替换 innerHTML 销毁选区) //! - isFollowingBottom:跟随意图锁存(跟随中则 rAF 回调内滚到真实底部) //! - scrollToBottom:回底操作 //! //! 共享:useMarkdown 模块级单例(_marked/_purify/mdReady/escapeFallback/getMarked/getPurify), //! 与 TaskDetail/AiChat/MessageList 同实例,渲染结果零变化。 import { ref, computed, nextTick, type Ref, type ComputedRef } from 'vue' import { useMarkdown } from '../useMarkdown' import { useAppSettingsStore } from '../../stores/appSettings' /// 流式渲染返回的单块渲染结果(html + 模板 v-for key) export interface StreamBlock { html: string /** 已完成块=block文本hash(稳定), 末块='tail-'+递增序号(每帧内唯一) */ key: string } export interface UseStreamRendererOptions { /** 保存当前文字选区(v-html 替换前调) */ saveSelection: () => void /** 恢复之前保存的选区(DOM 重建后调) */ restoreSelection: () => void /** 跟随意图锁存(跟随中则 rAF 回调内滚到真实底部) */ isFollowingBottom: Ref | ComputedRef /** 回到底部操作 */ scrollToBottom: () => void } export function useStreamRenderer(opts: UseStreamRendererOptions) { const { mdReady, renderMd, escapeFallback, getMarked, getPurify, } = useMarkdown() // ═══ AR-1 流式 Markdown 渲染开关(保守方案 B 的可回退开关) ═══ // appSettings key `df-ai-streaming-md`,默认 true(开):流式走块级 memo + rAF 节流的 // Markdown 分块渲染(代码块/列表/标题流式过程中就有格式)。关(false):回退纯文本短路 // (escapeFallback,每 delta 全文转义,
换行)——即 AR-1 修复前的原行为,供性能敏感/ // 极端掉帧场景降级。读走 appSettings 缓存(响应式,Settings 改动即时生效)。 const appSettings = useAppSettingsStore() const streamingMdEnabled = computed(() => appSettings.get('df-ai-streaming-md', true)) // _marked/_purify 走 composable 单例(getMarked/getPurify 在 loadMarkdown 后才非 null); // 流式 parse 经 getter 取最新引用,行为零变化。 // 块级 memo:单块文本 → html(流式时已完成块命中跳过,O(全文)→O(末块),借鉴方案D/业界主流机制2) const _blockCache = new Map() const BLOCK_CACHE_LIMIT = 300 // 流式渲染状态:rAF 节流(多 delta 合并一帧,60fps 封顶防主线程阻塞掉帧) // 分块渲染:已完成块 DOM 稳定不重建 → 选文字保持 const streamingBlocks = ref([]) let rafId: number | null = null let lastStreamText = '' // ═══ AC-EFF-S1-1 增量渲染状态(2026-08-09) ═══ // 流式追加场景(text.startsWith(_prevStreamText))下,已完成块(除末块外)跨帧稳定: // splitBlocks 按 \n\n / 已闭合代码围栏切块,块边界随文本追加保持稳定(末块外不重切)。 // 原实现每帧对全文 marked.lexer O(n)(长回复/单块回复病态甚至每帧 O(n) lexer + O(n) parse)。 // 现缓存上一帧「已完成块 + 末块文本」,只对「旧末块 + 增长段」增量 lexer,已完成块直接复用 // (块内容寻址缓存 _blockCache 本就命中,此处省掉对全文的重复 lexer)。 // 非追加(首帧 / 重渲染 / 内容替换)回退全量 splitBlocks(与旧行为一致,渲染结果等价)。 let _prevStreamText = '' let _prevCompleted: string[] = [] let _prevTail = '' /// 单块回复病态阈值:整个回复无 \n\n/围栏边界(末块=全文)且超此长度时,末块降级纯文本 /// (escapeFallback,避免每帧 marked.lexer+parse O(n) 卡顿),AiCompleted 后整段 renderMd 完整渲染。 const SINGLE_BLOCK_TEXT_LIMIT = 12000 /// 切块:代码围栏(```...``` 或 3+ 反引号 + 行首可选空格 + info 字符串)整体一块 /// (跨双换行不切),非代码段按双换行切段。 /// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。 /// /// 旧实现用正则 /```[^\n]*\n[\s\S]*?(?:```|$)/ 切块,与 marked 围栏规则 /// 不一致——不要求行首、固定 3 反引号,导致行中裸三反引号 / 4+ 反引号嵌套围栏切错, /// 前块缓存固化错误 html。改用 marked.lexer()(经 getMarked() 取 marked 实例)做围栏 /// 感知切块:lexer 把代码围栏识别为单个 type==='code' token(.raw 含整段围栏), /// 段落/标题等其余 token 的 .raw 按 \n\n 切段。lexer 与 parse 用同一份 marked 实例 + /// 同一份围栏规则,切块边界与渲染边界一致。 function splitBlocks(text: string): string[] { const marked = getMarked() const blocks: string[] = [] // marked 未就绪时 splitBlocks 不应被调用(renderStreamingBlocks 已 mdReady 守卫), // 此处兜底返回原文,不引入正则。 if (!marked) return [text] const tokens = marked.lexer(text) for (const tok of tokens) { if (!tok || typeof (tok as { raw?: string }).raw !== 'string') continue const raw = (tok as { raw: string }).raw if ((tok as { type: string }).type === 'code') { // 代码围栏 token:整段一块(含行首可选空格 + 3+ 反引号 + info 字符串) if (raw.trim()) blocks.push(raw) continue } // 非代码 token(paragraph/space/heading/list/blockquote/...): // 按 \n\n 切段(段落内单换行不切,inline code 自然保留在段内) for (const b of raw.split(/\n{2,}/)) if (b.trim()) blocks.push(b) } return blocks.length ? blocks : [text] } /// 末块不缓存(可能不完整,下次 delta 变),每次重 parse 处理未闭合 token function parseBlockNoCache(block: string): string { // _marked/_purify 在 loadMarkdown 后才就绪;此处仅在 mdReady 后被流式分支调用。 // 防御性 null check —— renderStreamingBlocks 顶部已有 mdReady/getMarked/ // getPurify 守卫,正常路径 marked/purify 必非 null。但 marked.use(highlightCode) 注入的 // renderer 在动态 import 后仍可能抛异常(hljs 注册竞态/未知语言)→ 此前直接 marked!.parse() // 会让异常冒泡到 rAF 回调中断流式,表现为生成卡死。这里 null 兜底降级 escapeFallback, // parse 抛错时 try/catch 同样降级,保证流式不中断。 const marked = getMarked() const purify = getPurify() if (!marked || !purify) return escapeFallback(block) try { return purify.sanitize(marked.parse(block) as string) } catch { return escapeFallback(block) } } /// 块级 memo parse:已完成块缓存命中,O(末块) function parseBlock(block: string): string { const cached = _blockCache.get(block) if (cached !== undefined) return cached const html = parseBlockNoCache(block) if (_blockCache.size >= BLOCK_CACHE_LIMIT) _blockCache.delete(_blockCache.keys().next().value!) _blockCache.set(block, html) return html } /// 流式渲染:增量切块 + 块级 memo(前块缓存/末块重 parse) /// 返回 { html, key } 数组:已完成块 key 用 block 文本 hash(DOM 稳定), /// 末块 key 恒为 'tail-1'(内容更新靠 v-html 响应式重设,不依赖 key 变化), /// 模板 v-for + v-html 分块渲染,已完成块 DOM 不重建 → 选文字保持。 /// /// AC-EFF-S1-1 增量:追加场景复用上一帧已完成块,只对「旧末块+增长段」增量 lexer, /// 不再每帧全文 lexer;单块回复(整回复无块边界)超阈值降级纯文本防 O(n) parse 卡顿。 function renderStreamingBlocks(text: string): StreamBlock[] { if (!text) return [] // AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 +
,原行为) // mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。 if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) { _prevStreamText = '' // 纯文本降级路径不增量缓存(下帧回全量,防旧帧基线误命中) return [{ html: escapeFallback(text), key: 'fallback' }] } // 增量切块:追加场景复用上一帧已完成块,只对「旧末块 + 增长段」增量 lexer。 // 非追加(首帧/重渲染/内容替换)→ 全量 splitBlocks(与旧行为一致)。 const appendOnly = _prevStreamText.length > 0 && text.startsWith(_prevStreamText) let blocks: string[] if (appendOnly) { const growth = text.slice(_prevStreamText.length) const combined = _prevTail + growth const combinedBlocks = splitBlocks(combined) blocks = combinedBlocks.length <= 1 // 仍单块增长(旧末块未跨边界)→ 已完成块不变,新末块 = 旧末块+增长 ? _prevCompleted.concat(combinedBlocks) // 旧末块跨边界拆出若干已完成块(围栏闭合/新 \n\n)+ 新末块 : _prevCompleted.concat(combinedBlocks.slice(0, -1), combinedBlocks[combinedBlocks.length - 1]) } else { blocks = splitBlocks(text) } // 记录下一帧增量基线:已完成块 = 除末块外全部;末块 = 最后一块(可能不完整)。 if (blocks.length > 1) { _prevCompleted = blocks.slice(0, -1) _prevTail = blocks[blocks.length - 1] } else { _prevCompleted = [] _prevTail = blocks.length === 1 ? blocks[0] : '' } _prevStreamText = text const n = blocks.length let tailSeq = 0 // 末块 key 序号:每帧函数内重置且仅一个末块,key 恒为 'tail-1' return blocks.map((b, i) => { const isTail = i === n - 1 // AR-1 代码块降级:末块若是未闭合的代码围栏(```/~~~ 数为奇数,说明代码块还没结束), // 不走 marked.parse(未闭合围栏 → marked 推断为代码块 + hljs 对不完整代码高亮, // 流式过程中每 delta 重 parse 会闪烁/错乱)。降级为纯文本(转义围栏原文),AiCompleted // 后整段 renderMd 会做完整代码块渲染(此时围栏已闭合)。已完成块不受影响(围栏已闭合)。 const degrade = isTail && isUnclosedCodeFence(b) // 单块回复病态(整回复无 \n\n/围栏边界,末块=全文)超阈值 → 降级纯文本, // 防每帧 marked.lexer+parse O(n) 卡顿(流式期间临时纯文本,AiCompleted 后完整 markdown)。 const longSingleBlock = isTail && n === 1 && b.length > SINGLE_BLOCK_TEXT_LIMIT const html = (degrade || longSingleBlock) ? escapeFallback(b) : isTail ? parseBlockNoCache(b) : parseBlock(b) // 已完成块用文本做 key(DOM 稳定不重建);末块 key 恒为 'tail-1'(v-html 每帧重设内容) const key = isTail ? `tail-${++tailSeq}` : `b-${simpleHash(b)}` return { html, key } }) } /// 末块未闭合代码围栏检测:统计行首(可选 ≤3 空格)的 ``` / ~~~ 围栏开/闭数量, /// 奇数 = 有未闭合的代码块(流式还在写入该代码块)。仅末块调用,前块必已闭合(splitBlocks /// 已把完整 code token 切成独立块)。对齐 marked 围栏规则(行首 ≤3 空格 + 3+ 反引号/波浪)。 function isUnclosedCodeFence(block: string): boolean { let fenceCount = 0 const lines = block.split('\n') for (const line of lines) { // 行首 ≤3 空格 + 3+ 反引号或波浪(marked 围栏开关判定) const m = /^[ ]{0,3}(`{3,}|~{3,})/.exec(line) if (m) fenceCount++ } return fenceCount % 2 === 1 } /// 轻量字符串哈希:用于 block key 稳定性(非加密,仅避免长文本做 key) function simpleHash(s: string): number { let h = 5381 for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0 return h >>> 0 } /// rAF 节流:同帧多 delta 仅排队一次,帧内取最新 lastStreamText parse function scheduleStreamParse(text: string) { lastStreamText = text if (rafId !== null) return rafId = requestAnimationFrame(() => { rafId = null // 末块 innerHTML 每帧替换,保存选区 opts.saveSelection() streamingBlocks.value = renderStreamingBlocks(lastStreamText) nextTick(() => { opts.restoreSelection() // rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。 // 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF, // 滚到渲染前高度)致"差一截"。 if (opts.isFollowingBottom.value) opts.scrollToBottom() }) }) } /// 清理流式渲染状态(streaming 翻 false / 会话切换 / 卸载前调) /// 取消 pending rAF + 清 streamingBlocks + 重置 lastStreamText + 清增量基线。 function clearStreamingState(): void { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null } streamingBlocks.value = [] lastStreamText = '' // 清增量基线(防新轮/新会话误命中旧帧已完成块)。 _prevStreamText = '' _prevCompleted = [] _prevTail = '' } /// 仅取消 pending rAF(不清 streamingBlocks/lastStreamText)。 /// 用于 streaming 翻 true 时只取消上一帧 pending,保留 streamingBlocks(原行为)。 function cancelPendingRaf(): void { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null } } /// 清空实例级块缓存(内容寻址 Map + LRU)。会话切换/开关变化时调用, /// 防旧会话已缓存块被新会话命中(跨会话内容错乱)。仅清缓存,不动 streamingBlocks。 /// 同步清增量基线(已完成块引用旧会话内容,防跨会话误复用)。 function resetStreamCache(): void { _blockCache.clear() _prevStreamText = '' _prevCompleted = [] _prevTail = '' } return { mdReady, renderMd, escapeFallback, streamingBlocks, scheduleStreamParse, renderStreamingBlocks, clearStreamingState, cancelPendingRaf, resetStreamCache, } }