Files
DevFlow/docs/02-架构设计/专项设计/消息渲染管线重构-设计-2026-10-10.md
T
lxy 8df4e33656 文档: 看板销账与登记 + 设计档六份 + 索引补录
- 看板: 907 补注/909~941 登记与销账(含 929 模型停用根因与 4 处次生缺口、931 队列丢失、933/935/937 新需求、939 上下文瓶颈清单、941 C-r0 行为变更待拍板)
- 归档: 全量完成态条目/段落 + 只读核验销账 4 段迁 todo归档/2026-10-10.md(机械搬运零措辞改动,未完成项零丢失)
- 设计档: 输入框列表预览与自动编号 / 定时消息与待发箱持久化(统一 ai_outbox 底座) / 对话内定时延时任务 / 消息渲染管线重构(P0-P2 分期) / 上下文分层组装器落地方案(记账基点单一真值,与并行写者职责划清) / FileTree 大目录方案(推荐后端分页,否决虚拟滚动) / u-relay 运维手册
- 索引与事实修正: docs/INDEX crate 数 8→13 + 四子树补录 25 条 + 专项设计补录 27 条;纠正「项目无独立 ARCHITECTURE.md」等过时声明;统一 bun 口径
- CLAUDE.md: 新增「命名与代码风格」节(变量/方法名精简+准确;DRY·简洁·扩展性·高内聚低耦合·单一真值来源)
2026-10-10 08:09:25 +08:00

18 KiB
Raw Blame History

消息渲染管线重构 — 设计

日期: 2026-10-10 | 状态: 设计稿(待拍板) | 勘察: 只读实测锚点(全仓零 v-memo,实测 grep -c v-memo = 0) 对象: src/components/ai/MessageList.vue(3041→实测 3054 行 God 组件) 目标(用户原话转译): 长会话流式掉帧根治 —— 拆分 God 组件、渲染记忆化、引入 v-memo、去 JSON.stringify 指纹、流式帧降载。 说明: 本专题为只读走查 + 设计,不含代码实施;所有行号均为实测锚点(文件以 E:/wk-lab/devflow 为根)。


一、现状勘察(锚点实测)

1.1 文件构成(3054 行 = script 1476 + template 460 + style 1117)

段 行区间 行数 备注
<script setup> 1–1476 1476 逻辑 + 组合式调用 + 大量内联渲染辅助
<template> 1477–1936 460 单一 v-for 扁平渲染 + 6 个 v-if/v-else-if 分支
<style scoped> 1938–3054 1117 全组件样式集中于此(拆分后随子组件 scoped 自然分流)

职责分类(>6 类,单一组件内并存):

  1. 列表编排/分段 —— messageSegments(:837)、renderItems(:1067)、时间线 needsTimeSep(:1049);
  2. 轨迹卡/步骤 —— trace 分支(:1510)/step 分支(:1587)+ 聚合助手 :993–1246 + 轮次导航 traceNav(:1386);
  3. 子代理完成卡 + peek 浮层 —— agentFin 分支(:1601)+ openAgentTrace(:1280)+ agentPeek*(:1297–1384);
  4. 消息气泡 —— msg 分支(:1644–1784),user 走子组件 MessageItem.vue,assistant/system/json/长文折叠/错误操作条内联;
  5. token 用量 —— 派生 :172–224 + 模板块 :1792+;
  6. 复制/选区 —— copyMsgContent(:603)、copyMsgAsImage(:640)、saveSelection/restoreSelection(:501/:545)、offsetToDOMPosition(:481)、代码块复制(:728);
  7. 滚动/折叠联动 —— useMessageScroll(:21/:230–238)、collapseAllToolLists(:395)、anchorCollapseScroll(:354)、buildActiveToolIds(:380);
  8. 展开状态集合 5 组 —— useToggleSet(:869–880)+ expanded*(:883/:891/:944/:949/:953)。

流式渲染(useStreamRenderer.ts)与 Markdown(useMarkdown.ts)此前已抽出;本专题针对剩余编排层。

1.2 热路径触发链(谁在每帧/每次事件重算)

触发源 频率 影响 锚点
streamingBlocks 每帧更新(rAF) 每帧 组件 render 函数整体重跑 → v-for 对全部 item 逐条新建 vnode(全仓零 v-memo) useStreamRenderer.ts:232(rAF 内赋值)、模板 :1727–1728
工具事件(status 翻转) 每次工具事件 renderItems 全量 O(n) 重算(读 m.toolCalls/tc.status/tc.completedAt) renderItems :1067;status 就地改 useAiToolEvents.ts:51/101/119、useAiApproval.ts:87/109、useAiConversations.ts:501、useAiStreamingEvents.ts:123
指纹 watch 每次工具事件 / 消息数变 map(m=>toolCalls→{id,s}) + JSON.stringify 全量 O(n) :429–442(source :430–433)
展开/折叠 toggle 用户点击 renderItems 重算(trace/seg 集合在 dep 内)+ 全组件 render :1116(expandedTraceIds)、:1152(expandedSegmentIds)

关键链路细节:

  • renderItems(:1067–1189)读 store.state.messages(数组)、shouldRenderMsg(:159–167,读 msg.content?.trim())与 isViewingGenerating(:126)。流式 delta 期间末条 AI 的 msg.content 不被改写(正文回填在收尾单点 aiShared.ts:544),故 renderItems 本身不逐帧重算;逐帧的开销来自 render 函数里 v-for 对全部 item 重建 vnode(无 v-memo 跳过)。工具事件则确会触发 renderItems 全量重算。
  • flushBucket(:1075)对每个桶 collectTurnStats(useTurnTrace.ts:90,遍历全部 toolCalls)+ trace 桶 sumTurnTokens(useTurnTrace.ts:134,遍历全部消息 token 字段)+ 模板逐条 traceTaskRefs(item.traceMsgs)(:88,遍历 toolCalls 提取任务引用)。

1.3 已有的内容级缓存(部分缓解,非本次病灶)

设施 锚点 粒度
renderMd 整段缓存 LRU useMarkdown.ts:79(_MD_CACHE_LIMIT=200)、:195–207 文本→HTML
代码块装饰缓存 LRU MessageList.vue:681–711(DECO_CACHE_LIMIT=800) 文本→装饰 HTML
流式块级 memo + 增量切块 useStreamRenderer.ts:59(BLOCK_CACHE_LIMIT=300)、:67–79 单块→HTML
MessageItem 派生 computed MessageItem.vue:99–101 单条 user 派生值

缺口(本次目标):内容级缓存已就绪,但列表结构级(renderItems)与 vnode 级(v-memo)缺优化 —— 前者在工具事件时全量重算,后者在每帧重建全列表 vnode。


二、目标与验收口径

功能目标

  1. MessageList.vue 拆分至 单文件 <400 行(script+template;style 随子组件 scoped 分流)。
  2. renderItems 记忆化:按桶缓存,桶内容未变即复用同一 item 对象(稳定引用),不再 O(n) 重建。
  3. 引入 v-memo:流式每帧仅重渲"真正变化"的 item,其余 item 跳过 vnode 新建/补丁。
  4. 指纹 watch 去 JSON.stringify,改 O(1) 版本计数。
  5. 流式帧渲染降载:rAF 合并(已有)+ 离屏段落跳过。

验收口径

  • 长会话(≥200 条)+ 长回复流式:每帧 patch 的 item 数从"全列表"降到"1(末条)"——Vue DevTools render 计数或 dev 打点可观测;人眼无掉帧、无卡顿。
  • 工具事件(审批/完成/拒绝)后:轨迹卡统计(collectTurnStats)与聚合行正确刷新;renderItems 重算次数 ≤ 变更桶数。
  • 展开/折叠(轨迹卡/思考 pill/JSON/长文/折叠段)即时生效,无"点了没反应"。
  • 复制/选文字保持/错误重试/子代理 peek/轮次导航跳转等既有交互逐条回归无弱化。
  • 不倒退:普通单轮问答、历史会话滚动、切换会话行为与改造前逐字节一致。
  • vue-tsc --noEmit 与 pnpm test:e2e(Playwright,tests/e2e/smoke.spec.ts)全绿。

三、设计方案

3.1 核心判断:三件事正交,可分笔落地

病灶分处三层,互不耦合,可分笔提交、独立回退:

  • vnode 层(每帧) —— v-memo 缺 → 补 v-memo(不改结构)。
  • 列表结构层(每工具事件) —— renderItems 全量重算 → 桶 memo 缓存(不改结构)。
  • 代码组织层(可维护性) —— God 组件 → 拆 composable / 子组件(行为零变)。

设计原则:v-memo 与 memo 缓存不改 renderItems 输出契约(仍返回 RenderItem[],:1018–1028 类型不变),模板 6 分支结构不变 → 回归面收敛在"渲染策略"而非"渲染语义"。

3.2 renderItems 记忆化(稳定 item 对象)

输入指纹(桶签名 sig):仅取 renderItems 真正消费的字段(与 :429 指纹同源思路,但按桶):

sig(bucket) =
  bucket.map(m => m.id + ':' + m.status + ':' + (m.toolCalls ?? [])
      .map(t => t.id + ':' + t.status + ':' + (t.completedAt ?? 0) + ':' + (t.argsHash ?? '')).join(','))
   .join('|')
  + '#' + finalIndex            // 完成态桶的 finalMsg 下标
  + '#' + (expandedTraceIds.has(traceKey) ? 1 : 0)

缓存:组件级 Map<string, RenderItem[]>(LRU 有界,如 128,沿用 _decoCache:681 同款范式)。命中即回同一 item 对象数组(引用稳定);未命中才 collectTurnStats/sumTurnTokens/traceTaskRefs 并写缓存。顶层 items 数组仍每次重组(桶数 ≪ 消息数,成本可忽略)。

收益:①工具事件时 O(n)→O(变更桶);②item 引用稳定 → v-memo 的 dep 可用 [item] 表达(见 3.3)。

风险:签名漏字段 → 命中陈旧 item → 界面不刷新。故签名须覆盖所有影响输出的字段;此风险由 3.3 的保守 dep 兜底(primitive dep 即使签名漏,值变仍会重渲)。

备选(更保守):不引入桶 memo,P0 仅 v-memo。但工具事件时的 O(n) 重算仍在 —— 建议两步都做、分两笔提交。

3.3 v-memo 依赖数组口径

核心原理:renderItems 是 computed,跨帧被缓存(流式帧不重算)→ item 对象跨帧引用稳定;任其已读取的响应式源变化 → 重算 → 新 item 对象 → [item] dep 变化 → 该 item 重渲。因此 dep 只需补"renderItems 未读、但模板渲染该 item 时读了"的兄弟响应式源。

已读(自动失效,无需列入):store.state.messages、m.status/m.role/m.content(经 shouldRenderMsg)、expandedTraceIds(:1116)、expandedSegmentIds(:1152)、isViewingGenerating(读 streaming)。

未读(必须补入 dep,否则 v-memo 跳过致不刷新):

源 影响 锚点
expandedThinkIds 轨迹卡内思考 pill 展开 :953/:1559
expandedJsonIds JSON 工具结果展开 :944/:1711
expandedLongIds 长文折叠/展开 :891/:898/:1714
tokenPopoverMsgId token 详情面板显隐 :180
editingMsgId user 编辑按钮显隐 :1691
agentPeekLoadingId 子代理 peek 转圈 :1625
_mdRenderKey marked 就绪后首屏重渲 :457–458/:718
任务标题源 trace 任务 chip 文案 taskTitleOf :83

推荐写法(DRY 单出口,补 dep 一处维护):

// template: <template v-for="item in renderItems" :key="item.key" v-memo="itemDeps(item)">
function itemDeps(it: RenderItem): unknown[] {
  return [it, expandedThinkIds.value, expandedJsonIds.value, expandedLongIds.value,
          tokenPopoverMsgId.value, editingMsgId.value, agentPeekLoadingId.value,
          mdRenderVersion.value, taskTitleVersion.value]
}
  • [it,...] 依赖"renderItems 重算即新对象"这一既定事实(无需 primitive 枚举,简洁);兄弟集合用整体引用(useToggleSet 每次 toggle 替换 Set → 引用变 → 全部 item 重渲)。toggle 为用户点击(低频),全量重渲可接受,换取 dep 永不过期。
  • taskTitleVersion/mdRenderVersion 用 O(1) 版本号(见 3.4)表达异步就绪源,避免把整个 skills/tasks 数组塞进 dep。

风险与兜底:若 Vite/Vue 编译器不支持在 <template v-for> 上挂 v-memo(与内部 v-if 链组合),则回退方案=为每个 item 包一层 keyed 子组件(即 P2 的 <AiMessageBubble :item> 提前),v-memo 落在子组件根 —— 属结构性,故列为风险项而非默认路径。

3.4 指纹 watch 改 O(1) 版本计数

现状(:429–442):source(:430–433)每次工具状态变动作 messages.map(...) + JSON.stringify,O(n) 分配 + 序列化。

改法:引入 toolEpoch(数字 ref,建议落 aiShared.ts 与 convStreamStates 同层),在唯一入口 bumpToolEpoch() 自增;所有工具状态变更点调用:

  • useAiToolEvents.ts:39(push 新 toolCall)、:51(completed)、:101(pending_approval)、:119(rejected);
  • useAiApproval.ts:87(running/rejected)、:109(completed);
  • useAiConversations.ts:501(待审批回填)、useAiStreamingEvents.ts:123(待审批)。

watch 改:

watch([() => store.state.messages.length, () => toolEpoch.value], () => {
  if (isFollowingBottom.value) collapseAllToolLists(buildActiveToolIds(store.state.messages))
})

收益:每次工具事件从 O(n) map+stringify 降为 O(1) 计数;lastMsgSnapshot 去重串可删(watch 已按值变化触发)。代价:需在 ~7 处 mutation 点补 bumpToolEpoch() —— 建议收敛到一个 markToolChanged() 助手,避免散点漏调(漏调 = 折叠联动不触发,非渲染错误,风险低)。

3.5 流式帧渲染降载

  1. rAF 合并 —— 已有(useStreamRenderer.scheduleStreamParse:225),每帧仅一次 parse;保持不动。
  2. v-memo(3.3)—— 每帧仅末条 item 重建 vnode,是最主要的降载(治"逐帧全列表 vnode 重建")。
  3. 离屏段落跳过 —— 对 .ai-msg-slot 加 CSS content-visibility: auto + contain-intrinsic-size(浏览器级跳过离屏布局/绘制,零 JS)。风险:与 scrollToBottom(anchorCollapseScroll:354 读 getBoundingClientRect)可能因离屏高度估算产生滚动抖动 → 建议 P1 引入并实测,回退=去该 CSS 类。
  4. renderItems 不读流式正文 —— 现状已满足(正文回填在 aiShared.ts:544 收尾单点,delta 期间不改 msg.content);设计上明确此约束不得破坏(勿在 renderItems 内引入 store.state.currentText 读取),否则 computed 每 delta 重算,前功尽弃。

3.6 拆分方案(职责切分 + 数据流)

数据流不变:store.state.messages(真相)→ messageSegments → renderItems(RenderItem[])→ 模板分支渲染。拆分仅重排"谁来持有逻辑"。

P1 — 抽出 composable(模板不动,纯脚本迁移 + 依赖注入):参照已抽的 useStreamRenderer(回调注入)范式:

新 composable 迁出内容 入参(注入) 出参
useRenderItems messageSegments/renderItems/时间线/traceNav + 桶 memo messages、isViewingGenerating、各 expanded 集合 renderItems、traceNav、jumpToTrace
useTracePanel trace 展开/task refs/轮次导航 + agent peek 浮层 store、expandedTraceIds toggleTrace、traceTaskRefs、openAgentTrace/closeAgentTrace、agentPeek*
useMessageActions 复制(文本/图片)/重试/装 obscura/去设置 store、emit、scrollToBottom copyMsgContent、handleRetry、…
useTextSelection 保存/恢复选区 + 偏移定位 + 代码块复制委托 messagesContainer saveSelection/restoreSelection、onMdCodeCopyClick
useTokenUsage token 派生 + popover 状态 — token*Of、tokenPopoverMsgId、toggleTokenPopover

P2 — 抽子组件(模板分流 + style scoped 随迁):

子组件 迁出模板 目标
TraceCard.vue trace(:1510–1583)+ step(:1587–1596)同构两态 + rail 面板 含内部 v-memo/computed,思考 pill 展开自治
AgentFinishedCard.vue agentFin(:1601–1627) peek 点击 emit 上抛
AiMessageBubble.vue msg 的 assistant/system/json/长文折叠/错误操作(:1697–1784)+ 流式块(:1727–1730) 单条气泡自治,item 作 prop
TimeSeparator.vue / SegmentSeparator.vue timesep(:1629)/sep(:1650–1671) 小件,可留 MessageList

拆完后 MessageList.vue 余:容器 + 滚动 + 编排 v-for + 事件委托(目标 <400 行)。

高内聚低耦合要点:子组件只收 item 数据 + 经 emit 上抛动作(不直接持 store 写);ToolCardList/MessageItem 已是此模式,新组件对齐。


四、分期(每期可独立上线、可回退)

P0 — 零风险渲染层(v-memo + 桶 memo + 版本计数)不改结构

  • 补 v-memo(itemDeps,3.3);renderItems 桶 memo(3.2);指纹 watch 改 toolEpoch(3.4)。
  • 不改模板分支、DOM 契约(data-msg-id/role class)、RenderItem 类型。
  • 建议拆 两笔提交:v-memo 一笔、桶 memo+版本计数一笔 → 可分笔 revert。
  • 验收:§二"每帧仅末条 patch"人眼/打点;工具事件统计刷新正确;toggle 即时生效;vue-tsc + e2e 全绿。

P1 — 拆 composable + 离屏降载(模板不动)

  • 抽 useRenderItems/useTracePanel/useMessageActions/useTextSelection/useTokenUsage(3.6);.ai-msg-slot 加 content-visibility(3.5-3)。
  • 验收:MessageList.vue script 显著下降;所有交互回归;滚动锚点无抖动;e2e 全绿。

P2 — 拆子组件(结构 + style 分流)

  • 抽 TraceCard/AgentFinishedCard/AiMessageBubble(3.6);style scoped 随迁。
  • 验收:单文件 <400 行;全交互 + e2e 全绿;v-memo 下沉各子组件(可改用 [item] 简化)。

五、影响面(五维)

  • 兼容性:纯前端渲染层重构,不改 IPC / 事件 / DB / 后端协议;RenderItem[] 输出契约与模板分支不变;DOM 语义属性保留 → 既有 e2e 选择器/getBoundingClientRect 锚点不破坏;MessageItem/ToolCardList 对外 props/emit 不变。
  • 破坏性:无不可逆操作(纯代码,git 可回退)。行为风险=v-memo dep 遗漏(局部不刷新,可见回归)、桶 memo 签名冲突(串内容)。缓解:dep 保守(3.3)+ 签名含 id 唯一性 + LRU 有界。
  • 安全面:无新端点/命令执行面;渲染 HTML 仍走既有 DOMPurify(useMarkdown.renderMd/parseBlockNoCache)链,v-memo 只跳过补丁、不改变 v-html 内容来源,无新 XSS 面。
  • 资源面:内存 —— 新增桶 memo Map + dep 数组,有界 LRU(128);CPU —— 每帧 vnode 从 O(全列表) 降 O(1~少数);工具事件 O(n) 重算 → O(变更桶);content-visibility 降离屏布局/绘制。工具事件 O(1) 版本计数替代 O(n) stringify。
  • 回退开关:v-memo/结构拆分属编译期,无运行时开关 → 回退粒度=逐笔 git revert(故 P0 拆两笔、P1/P2 各自独立)。toolEpoch 为本地计数,回退即恢复旧 watch。content-visibility 可用 CSS 类开关运行时回退。

六、拍板点清单

# 决策 选项 建议
1 v-memo 依赖口径 A 逐 kind primitive 枚举;B [item, ...兄弟引用](依赖 renderItems 缓存保证 item 稳定);C 仅对 msg 分支上 v-memo B。简洁且覆盖充分;前提=3.2 桶 memo 与 3.3 的 sibling 清单正确;A 作理论最保守备选(枚举成本高、易漏)
2 P0 是否同时做桶 memo A 仅 v-memo(结构零改,工具事件仍 O(n));B v-memo + 桶 memo(拆两笔) B。两者正交、各自可测;桶 memo 治"工具事件级"重算,仅 v-memo 治不了
3 离屏 content-visibility 时机 A P0 一起;B P1 引入;C 不做 B。收益大但需实测滚动锚点抖动,单列为 P1 可回退项,风险隔离
4 P2 子组件边界 A 抽 TraceCard/AgentFinishedCard/AiMessageBubble;小件(时间线/折叠条)留父;B 仅抽最大件 AiMessageBubble A。目标是 <400 行,需 style scoped 随迁才达标;B 达标不足

附:实施纪律

  • renderItems 是全仓最热路径,回归面大 → 遵循"优先级 Bug→重构→新功能、单次 1-2 文件、原子操作"策略(memory large-refactor-strategy);每期独立提交 + 独立 e2e。
  • 改代码一律用 Edit 工具(禁 sed/perl);验证阶段收官统一跑 vue-tsc + e2e,不逐文件全量 build(CLAUDE.md 编排纪律)。
  • 存疑处源码形态优先于文档描述;v-memo dep 清单以本设计 3.3 表为准,实施时逐条 grep 模板引用核对,勿凭空增删。