外观
AI 消息渲染方案 v2 设计(ReAct 与 Plan-and-Act 统一)
description: 重构回复组渲染模型——执行过程区与最终结果区两个互斥区域,结果确认前一切消息只渲染在过程区内,消除串流/错位/区外悬挂,并支持每轮思考过程独立折叠
created: 2026-10-13 19:12:11
updated: 2026-10-13 19:36:13
author:
1. 设计目标与硬约束
范围:
frontend/src/components/AiChatPanel.vue回复组(group)渲染层,即折叠区(执行过程区)、最终结果区、任务耗时行、每轮思考过程折叠的展示逻辑与状态机。 边界:不改动消息持久化结构与 SSE 流协议;不改动 ReAct 循环与 Plan-and-Act 执行编排(ai-react-loop-design.md、ai-plan-and-act-design.md);不改动授权弹窗、计划面板、变更文件面板的交互。 关联代码:frontend/src/components/AiChatPanel.vue、frontend/src/lib/ai.ts、frontend/src/stores/session.ts
| 目标 | 量化口径 |
|---|---|
| 不串流 | 任意时刻同一段文本最多渲染在一个区域;不存在同一正文在折叠区内外同显的帧 |
| 不错位 | 折叠区内条目按消息顺序唯一确定槽位,无"末条即结果"的动态归属规则 |
| 顺序不乱 | 轮次严格按消息序渲染;结果区永远位于折叠区之后 |
| 禁止区外悬挂 | 结果确认前,最终结果区恒为空,组内没有任何消息渲染在折叠区外;"区外有待定结果 + 区内持续追加"状态不可能出现 |
| 过程长度可控 | 每轮思考过程独立折叠,自动态下过程区可视高度只保留正文与工具卡片 |
| 兼容旧数据 | 旧会话消息(无 stepIndex 标记)重载后渲染完整,仅缺步骤分隔条 |
2. 必须遵守的既有项目约束
- AI 消息不使用气泡样式,固定从上到下:项目 Logo + Agent 标识 → 可折叠思考过程 → 最终回答内容(既有用户决策);
- 推理可视化状态操作统一使用 Map 直接 mutate 更新,手动展开/收起优先于自动逻辑(
isTraceOpen的 manual 优先原则沿用); - 工具调用提案卡片与后续自动续跑回复合并为单条消息视觉,不重复显示 Logo;
- 任务运行期间输入区右侧显示实时耗时计时器(hh:mm:ss),任务耗时行同时承担过程区折叠开关职责;
- 切换 scope 或清空对话时统一清理手动状态记录;
- 消息随会话持久化,渲染层必须可由消息列表纯推导,重载后结果一致。
3. 现状盘点与差距
| 位置 | 现状 | 差距 |
|---|---|---|
折叠区收集(groupTrace) | 扁平条目流:全部 reasoning + 工具提案 + 非末条 content;组末条 content 留在区外作"待定结果" | 流式期间区外常驻待定正文,若该轮产生工具调用,正文需随 groupEnd 后移"落入"区内,存在动态归属导致的错位风险 |
自动展开/收起(isTraceOpen) | 任务活跃(groupActive)期间全程展开,落定时收起 | 最终结果流出时过程区仍展开,收起跳动发生在用户开始读结果时;单轮"content 到达即收起"与多步"全部结束才收起"语义不一致 |
| 思考过程折叠 | 仅整体折叠区一层 + 末条 reasoning 单独折叠 | 中间轮 reasoning 全部平铺在过程区内,长任务过程区高度失控 |
| Plan 步骤标识 | 步骤边界仅靠计划面板状态展示,过程区内无步骤分隔 | 用户无法在过程区内定位"这条思考属于哪个步骤" |
结论:核心差距是"最终结果"的归属规则——现状为动态归属(当前末条正文即结果),v2 改为确认式归属(确认是最后一轮才晋升到结果区),从结构上消除区外悬挂与串流窗口。
4. 目标与非目标
目标
- 回复组只渲染两个固定区域:执行过程区(折叠区)与最终结果区,职责互斥;
- 结果确认前结果区恒空,全部组消息(含流式中的当前轮)渲染在折叠区内;
- 最终结果晋升规则统一:ReAct/单轮在流结束且无工具调用时原子晋升;Plan 总结轮首轮 token 即确认,直接流入结果区;
- 折叠区按轮次分组渲染,每轮思考过程独立折叠(两级状态机);
- Plan 模式在过程区内显示步骤分隔条(
步骤 i/n · title)。
非目标
- 不实现虚拟滚动/长列表性能优化(条目数量级为单任务十轮以内);
- 不改动后端
ai-chat-delta等 SSE 事件协议; - 不实现折叠状态的持久化(手动状态为会话内存态);
- 不处理用户消息的渲染变化(用户消息不进组,不受影响)。
5. 总体架构
text
回复组(一条 user 消息触发的全部 assistant 消息)
│
├── Logo + Agent 标识 + 任务耗时行(折叠开关)
│
├── 执行过程区(折叠区) ← 任务期间内容
│ ├── 轮次 1
│ │ ├── 思考过程 ▸/▾(独立折叠)
│ │ ├── 正文(Markdown)
│ │ └── 工具卡片(提案 + 状态徽标)
│ ├── [Plan] 步骤分隔条:步骤 i/n · title
│ ├── 轮次 2 …
│ └── 当前轮(流式中):思考中… 脉冲 / 正文实时流出
│
└── 最终结果区 ← 确认后内容(任务期间恒空)数据流不变:SSE 逐条追加 AiChatMessage,渲染层每次由消息列表实时推导分组与槽位,折叠区不持有独立存储。
6. 详细设计
6.1 轮次与槽位推导
- 轮次 = 一条 assistant 消息(ReAct 轮 / 计划步骤轮 / 续跑轮 / 总结轮),严格按消息顺序;
- 每轮槽位固定三段:
reasoning→ 思考条目,toolProposals→ 工具卡片,content→ 正文; - 槽位由消息在
[groupStart, groupEnd]区间的下标唯一确定,与是否流式无关。
6.2 最终结果确认与晋升规则(统一一条)
某轮正文成为最终结果,当且仅当它被确认是最后一轮;确认前它只渲染在折叠区本轮槽位内。
| 场景 | 确认时机 | 晋升方式 |
|---|---|---|
| ReAct / 单轮 | ai-done 且本轮无工具调用提案 | 正文原子晋升至结果区(文本已完整,无流式中途搬迁),折叠区同时收起 |
| Plan 总结轮 | 由 planSummaryRef 标记、无工具调用,首轮 token 即确认 | 直接流入结果区,不搬迁;折叠区在总结 content 开始时收起 |
| Plan 各步骤末轮 | 永不确认(非组末轮) | 留在折叠区作为该步正文 |
| 中止 / 步骤失败 | 落定时末轮已有正文 | 部分正文晋升;无正文则结果区为空(计划各步状态与结果只在规划面板呈现,不在消息回复区拼装步骤列表) |
| 计划 cancelled | 落定 | 续跑轮正文晋升;无正文则结果区为空(同上) |
6.3 两级折叠状态机
整体折叠区(沿用 traceManual,manual 优先):
| 状态 | 自动态 |
|---|---|
| 任务活跃 && 结果区尚未出现内容 | 展开 |
| 结果区一出现内容 | 收起(仅一次) |
| 中止 / 失败 / cancelled | 落定时收起 |
每轮思考过程(新增 reasoningManual: Map<msgId, boolean>,manual 优先,scope 切换/清空对话时清理):
| 状态 | 自动态 |
|---|---|
| reasoning 流式中且该轮正文未到达 | 展开 + 「思考中…」脉冲 |
| 该轮正文开始到达 | 自动收起(正文优先) |
| 轮次完成 | 保持收起 |
即单轮"思考展开、说话收起"语义推广到每一轮;用户手动切换后进入手动模式,不受自动逻辑覆盖。
6.4 Plan 步骤分隔条
- 给计划流推送的消息新增可选字段
stepIndex(随消息持久化,旧数据无此字段); - 渲染时相邻消息
stepIndex发生变化则在轮次前插入分隔条:步骤 i/n · title; - 旧数据无标记 → 不插入分隔条,退化为一层轮次列表,其余行为一致。
6.5 已知取舍与晋升滚动兜底
ReAct/单轮的最终回答在流式期间显示在折叠区内、流结束后晋升——晋升瞬间折叠区收起、结果区出现,文本位置上移一次(折叠区高度)。这是"确认前不外显"约束的直接代价,用晋升后的视口补偿缓解,且必须复用现有「用户上翻则暂停自动滚动」守卫,不劫持用户视口:
| 视口状态 | 晋升后行为 |
|---|---|
| 用户未手动上翻(视口贴近最新消息) | 平滑滚动至结果区顶部,补偿上移 |
| 用户已向上翻看历史过程 | 禁止自动滚动,原地渲染,保持用户当前阅读位置 |
守卫判定沿用现有流式滚动的同一口径(用户滚动位置距底部超过阈值即视为上翻中),晋升滚动与流式滚动共享该守卫状态,不引入第二套判定。阈值取 80px——覆盖 2~3 行正文增量,滚动条微抖不误判,上翻半屏必命中;守卫仅约束任务驱动滚动(流式 token 跟随、晋升补偿),用户主动动作(发送、切会话、确认计划)仍无条件回底。
7. 安全与降级
- 纯前端渲染层改动,无新增网络、文件、命令执行路径,无安全面变化;
- 降级路径:
stepIndex缺失 → 无步骤分隔条;reasoningManualMap 异常缺失 → 退化为始终展开;planSummaryRef未命中(异常中断)→ 退化为落定时晋升,行为与 v1 一致; - 晋升操作只移动渲染归属,不修改消息数据,重载后按同一规则重新推导,保证持久化一致。
8. 测试与验证
| 项 | 验证方式 |
|---|---|
| 不串流/不错位 | 多轮 ReAct 对话(含工具调用 ≥ 2 轮)全程观察:任一正文不出现在两处;轮次顺序与消息顺序一致 |
| 结果区时序 | 单轮对话流式期间正文在折叠区内流出,ai-done 后一次性晋升,过程区收起 |
| Plan 全流程 | 多步骤计划执行:步骤分隔条正确;总结轮 content 到达时折叠区收起、结果直接流入结果区 |
| 各状态收敛 | 步骤执行中中止 / 步骤失败终止 / cancelled / 总结轮中中止——四类场景结果区均被填充且折叠区收起 |
| 手动优先 | 任务期间手动折叠过程区或某轮思考后,自动逻辑不再覆盖 |
| 晋升滚动守卫 | 未上翻时晋升后平滑滚动至结果区顶部;先上翻查看历史再等晋升完成,视口保持原位不跳动 |
| 旧数据兼容 | 打开 v1 时期的历史会话,渲染完整无报错,仅无步骤分隔条 |
| 回归 | npm run build(frontend/)通过;删除消息、重新生成、编辑重发、多模态附件、授权弹窗交互不回归 |
9. 未决问题
- 每轮思考条目是否需要像整体折叠区一样在条目头部展示耗时标签(现有末条 reasoning 已有),待实现时统一。