- 看板: 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·简洁·扩展性·高内聚低耦合·单一真值来源)
18 KiB
消息渲染管线重构 — 设计
日期: 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 类,单一组件内并存):
- 列表编排/分段 ——
messageSegments(:837)、renderItems(:1067)、时间线needsTimeSep(:1049); - 轨迹卡/步骤 —— trace 分支(
:1510)/step 分支(:1587)+ 聚合助手:993–1246+ 轮次导航traceNav(:1386); - 子代理完成卡 + peek 浮层 —— agentFin 分支(
:1601)+openAgentTrace(:1280)+agentPeek*(:1297–1384); - 消息气泡 —— msg 分支(
:1644–1784),user 走子组件MessageItem.vue,assistant/system/json/长文折叠/错误操作条内联; - token 用量 —— 派生
:172–224+ 模板块:1792+; - 复制/选区 ——
copyMsgContent(:603)、copyMsgAsImage(:640)、saveSelection/restoreSelection(:501/:545)、offsetToDOMPosition(:481)、代码块复制(:728); - 滚动/折叠联动 ——
useMessageScroll(:21/:230–238)、collapseAllToolLists(:395)、anchorCollapseScroll(:354)、buildActiveToolIds(:380); - 展开状态集合 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。
二、目标与验收口径
功能目标
MessageList.vue拆分至 单文件 <400 行(script+template;style 随子组件 scoped 分流)。renderItems记忆化:按桶缓存,桶内容未变即复用同一 item 对象(稳定引用),不再 O(n) 重建。- 引入
v-memo:流式每帧仅重渲"真正变化"的 item,其余 item 跳过 vnode 新建/补丁。 - 指纹 watch 去
JSON.stringify,改 O(1) 版本计数。 - 流式帧渲染降载: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 流式帧渲染降载
- rAF 合并 —— 已有(
useStreamRenderer.scheduleStreamParse:225),每帧仅一次 parse;保持不动。 - v-memo(3.3)—— 每帧仅末条 item 重建 vnode,是最主要的降载(治"逐帧全列表 vnode 重建")。
- 离屏段落跳过 —— 对
.ai-msg-slot加 CSScontent-visibility: auto+contain-intrinsic-size(浏览器级跳过离屏布局/绘制,零 JS)。风险:与scrollToBottom(anchorCollapseScroll:354读getBoundingClientRect)可能因离屏高度估算产生滚动抖动 → 建议 P1 引入并实测,回退=去该 CSS 类。 - 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.vuescript 显著下降;所有交互回归;滚动锚点无抖动;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-memodep 遗漏(局部不刷新,可见回归)、桶 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-memodep 清单以本设计 3.3 表为准,实施时逐条 grep 模板引用核对,勿凭空增删。