- 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/ 噪音排除
130 lines
7.1 KiB
Markdown
130 lines
7.1 KiB
Markdown
# 自研块级 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 样板迁 useConfirm,DRY 收口(原 Projects/ProjectDetail/Ideas/Settings/AiChat 各一份)。
|
||
- **决策转向有完整留痕**:试装 markstream-vue 弃用 → 自研块级 memo,调研文档 §5 复盘记录含反转理由(「不要高级能力、要原样式」前提下自研最优)。
|
||
|
||
---
|
||
|
||
## 📊 摘要
|
||
|
||
| # | 等级 | 文件:行 | 修改内容 |
|
||
|---|------|---------|----------|
|
||
| ① | 🟡 | AiChat.vue splitBlocks | 手写正则切块改 marked.lexer()(与 marked 一致,防切错固化错误 html) |
|
||
| ② | 🟡 | AiChat.vue parseBlock | parseBlock 内部调 parseBlockNoCache(DRY) |
|
||
| ③ | 🟡 | 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)之处。
|
||
|
||
**质量评级**:良(改 ①②③ 达优)
|