重构: 文档汇总+进度看板+孤儿任务清理脚本+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/ 噪音排除
This commit is contained in:
2026-06-15 05:14:21 +08:00
parent 4b5f096d1c
commit 04032a2a8d
43 changed files with 5372 additions and 163 deletions

View File

@@ -0,0 +1,129 @@
# 自研块级 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之处。
**质量评级**:良(改 ①②③ 达优)