Files
DevFlow/docs/05-代码审查/自研块级memo流式渲染审查-2026-06-15.md
绝尘 04032a2a8d 重构: 文档汇总+进度看板+孤儿任务清理脚本+gitignore 噪音排除
- docs/02 架构设计: 新增 aichat审查/异步审批构想/流式渲染调研/generating状态机/密钥迁移健壮性/工作流脚本执行边界/条件表达式引擎/F-07 trait下沉/Agent架构说明/任务推进构想/功能创意池;更新功能决策记录+归档/对抗论证/文档记录规范/经验记录
- docs/03 模块文档: 新增 AI对话引擎/DAG引擎详解;更新 df-knowledge/df-nodes/df-storage/df-workflow/df-ai
- docs/05 代码审查: 新增 全栈审查/全局review/架构审查/近期改动审查/工作区多角度走查/自研memo流式渲染审查
- docs/09 问题排查: 新增 aichat-apikey-401
- docs/INDEX+README 索引同步;docs/todo 待办看板(2026-06-15 汇总)
- PROGRESS.md Sprint 22-25;URGENT.md 加急清单快照(5 项 P0 已全修)
- scripts/cleanup_orphan_tasks.{py,sh} 孤儿任务清理工具
- .gitignore 补 *.broken.bak + tmp/ 噪音排除
2026-06-15 05:14:21 +08:00

130 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 自研块级 memo 流式 Markdown 渲染 代码审查2026-06-15
> 范围:`src/components/AiChat.vue` ARC-260615-08 实施 diff —— 块级 memo 流式渲染splitBlocks/parseBlock/parseBlockNoCache/renderStreamingMd/scheduleStreamParse/renderContent + watch/onBeforeUnmount+ confirm 抽取CR-260615-02。约 ~90 行改动。
> 背景:决策转向(同日复盘)—— markstream-vue 试装弃用(样式 100% 还原成本高且脆),改自研块级 memo方案 B 增强版)。详见 [aichat流式Markdown渲染调研-2026-06-15.md](../02-架构设计/aichat流式Markdown渲染调研-2026-06-15.md) §5。
> 性质:**dry — 仅走查 + 文档,不改代码**(本会话职责:走查/整理/建待办,见 memory session-role-diagnose-only
---
## 🔴 必须修复0
无。
---
## 🟡 建议改进3
### ① [AiChat.vue splitBlocks] 手写正则切块与 marked 语法不一致 → 改用 marked.lexer()
**改什么**`fenceRe = /```[^\n]*\n[\s\S]*?(?:```|$)/g` 两个不一致点:
- 不要求 ``` 在行首 → 行中裸 ```(如文本解释 markdown 语法被误当围栏切入marked 要求行首≤3 空格缩进)。
- 固定匹配 3 backtick → LLM 展示嵌套代码用 4+ backtick 围栏(````````)时按 3 切错marked 按围栏 backtick 数判定。
前块缓存parseBlock会**固化错误 html**——文本稳定后 splitBlocks 切错的块被缓存marked 实际解析与之不符。决策备注「借鉴机制 2」但机制 2 原生做法是 `marked.lexer()` 切块,实现用了手写正则偏离。
**怎么改**:用 marked.lexer 切块,与 marked 解析天然一致零切错风险零额外成本marked 已加载):
```diff
- /// 切块:代码围栏(```...```)整体一块(跨双换行不切),非代码段按双换行切段。
- /// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
- function splitBlocks(text: string): string[] {
- const blocks: string[] = []
- const fenceRe = /```[^\n]*\n[\s\S]*?(?:```|$)/g
- let last = 0
- let m: RegExpExecArray | null
- while ((m = fenceRe.exec(text)) !== null) {
- if (m.index > last) {
- for (const b of text.slice(last, m.index).split(/\n{2,}/)) if (b.trim()) blocks.push(b)
- }
- blocks.push(m[0])
- last = m.index + m[0].length
- }
- if (last < text.length) {
- for (const b of text.slice(last).split(/\n{2,}/)) if (b.trim()) blocks.push(b)
- }
- return blocks.length ? blocks : [text]
- }
+ /// 切块:用 marked.lexer 切块(机制2原生做法),与 marked 解析一致,零切错风险。
+ /// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
+ function splitBlocks(text: string): string[] {
+ const tokens = _marked!.lexer(text)
+ const blocks = tokens.map(t => t.raw).filter(Boolean)
+ return blocks.length ? blocks : [text]
+ }
```
> Vercel AI SDK 官方 cookbook `parseMarkdownIntoBlocks` 正是 `marked.lexer(md).map(t => t.raw)`。
### ② [parseBlock/parseBlockNoCache] DRY — parse 核心逻辑重复
**改什么**`_purify!.sanitize(_marked!.parse(block) as string)` 在 parseBlock 和 parseBlockNoCache 各写一遍。
**怎么改**parseBlock 内部调 parseBlockNoCache
```diff
function parseBlock(block: string): string {
const cached = _blockCache.get(block)
if (cached !== undefined) return cached
- const html = _purify!.sanitize(_marked!.parse(block) as string)
+ const html = parseBlockNoCache(block)
if (_blockCache.size > BLOCK_CACHE_LIMIT) _blockCache.clear()
_blockCache.set(block, html)
return html
}
```
### ③ [loadMarkdown] marked 就绪后流式不主动重算 → 首屏 marked 慢时末段纯文本
**改什么**:流式中 marked 异步加载完成mdReady 翻转streamingHtml 不会自动重算 —— scheduleStreamParse 只在 `currentText` 变化时触发。若 marked 在流式结束前就绪且无新 delta 到达,最后一段停留在 escapeHtml 纯文本renderStreamingMd 的 `!mdReady` 兜底分支产物)。
**怎么改**loadMarkdown 成功后若正在流式,主动触发一次:
```diff
_purify = dp.default
_mdCache.clear()
mdReady.value = true
+ // marked 就绪后若正在流式,主动重算(防首屏 marked 慢致末段停留纯文本)
+ if (store.state.streaming && store.state.currentText) scheduleStreamParse(store.state.currentText)
```
---
## ⚪ 可选优化2
### ④ [parseBlock _blockCache] 超 limit 整体 clear 粗暴
`if (_blockCache.size > BLOCK_CACHE_LIMIT) _blockCache.clear()` —— 清后下一帧所有前块全重 parse可能瞬时卡顿。300 limit 对单条回复够(块数远不到),跨多条回复累积才触发,影响小。可改删最早一条做简易 LRU`_blockCache.delete(_blockCache.keys().next().value)`
### ⑤ [renderMd/renderStreamingMd 兜底] escapeHtml 重复
`escapeHtml(text).replace(/\n/g, '<br>')` 两处重复,可抽 `escapeFallback(text)`。一行重复,收益轻。
---
## ✅ 亮点
- **退役 AR-1 纯文本短路**:流式全程有格式(标题/列表/代码块D 级流式性能,零新依赖零样式对接(决策转向 markstream→自研的核心理由保留原 `.ai-md` 样式 > 虚拟窗口/shiki 高级能力)。
- **末块不缓存处理未闭合 token**`parseBlockNoCache` 每次重 parse 末块,正确处理流式未闭合围栏/半截段落;前块缓存命中 O(全文)→O(末块)。
- **rAF 节流 + 块级 memo 组合**业界主流机制Vercel AI SDK 做法60fps 封顶防主线程阻塞掉帧。
- **watch streaming 翻转清理时序正确**:结束 cancelAnimationFrame + 清 streamingHtml/lastStreamText回 renderMd 走 final marked + 整段缓存onBeforeUnmount 清 rAF 防 leak。
- **confirm 顺带抽取CR-260615-02**:第五份 confirm 样板迁 useConfirmDRY 收口(原 Projects/ProjectDetail/Ideas/Settings/AiChat 各一份)。
- **决策转向有完整留痕**:试装 markstream-vue 弃用 → 自研块级 memo调研文档 §5 复盘记录含反转理由(「不要高级能力、要原样式」前提下自研最优)。
---
## 📊 摘要
| # | 等级 | 文件:行 | 修改内容 |
|---|------|---------|----------|
| ① | 🟡 | AiChat.vue splitBlocks | 手写正则切块改 marked.lexer()(与 marked 一致,防切错固化错误 html |
| ② | 🟡 | AiChat.vue parseBlock | parseBlock 内部调 parseBlockNoCacheDRY |
| ③ | 🟡 | AiChat.vue loadMarkdown | marked 就绪后若流式中主动 scheduleStreamParse防末段纯文本 |
| ④ | ⚪ | AiChat.vue _blockCache | clear 改删最早一条(简易 LRU |
| ⑤ | ⚪ | AiChat.vue 兜底 | escapeFallback 抽公共DRY |
**总计**🔴0 🟡3 ⚪2
**总体评价**:块级 memo 实现方向正确(机制 2 + rAF + 末块重 parse + 退役 AR-1 纯文本短路),核心建议 ① 改 lexer 消除「手写切块与 marked 解析不一致」的固化风险——这是唯一偏离决策原意(机制 2 原生 = lexer之处。
**质量评级**:良(改 ①②③ 达优)