Skip to content

feat(webagent): UI 对齐 DSH(TodoPanel / diff 展示 / 统计行 / 回放修复) - #85

Merged
lloydzhou merged 1 commit into
mainfrom
feat/webagent-ui-polish
Aug 21, 2026
Merged

feat(webagent): UI 对齐 DSH(TodoPanel / diff 展示 / 统计行 / 回放修复)#85
lloydzhou merged 1 commit into
mainfrom
feat/webagent-ui-polish

Conversation

@lloydzhou

Copy link
Copy Markdown
Owner

改动

1. TodoPanel(composer 上部计划条,对齐 DSH TodoPanel)

  • 最新一次 TodoWrite 快照驱动;默认收起,点击展开
  • 三态图标:completed ✓实线圈 / in_progress 旋转环 / pending 虚线圈
  • 进度摘要「N 完成 · N 进行 · N 待办」(零计数省略)
  • 发送消息 / WS 重连时清空(对齐 DSH "cleared on the next turn")

2. 工具输出结构化展示(对齐 CLI display)

  • Edit → unified diff 渲染:红删绿增、双行号列、@@ hunk 头、a/ b/ meta、diffhead 摘要(+N -M),ANSI 剥净
  • Write → 输入区绿色新增行号块(替代 JSON dump)
  • Read → 行号代码块(行号从 input.offset 起算)

3. 统计行(原 statusline 增强,移至输入框下方)

glm-4.7  上下文 32.2K/200K · 16%  输入 155K(缓存 97%)  输出 1K  对话 5 轮 · 5 请求  压缩 5K
  • 模型名 / max_context 通过 __WEBAGENT_CONFIG__ 占位符注入(serve 时替换;main.rs 解析 --model/--provider/--max-context-tokens
  • 上下文占用 = 最近一次 usage 的 in+cr+cc+out(compact 后自然回落);进度条 80%/90% 变色
  • 对话展示轮次 + 请求次数(user_input 计数 / usage 事件计数)

4. 布局与细节

  • DOM 顺序 todoPanel → composer → statsline(对齐 DSH:计划条在上、统计行贴输入框下方)
  • header 垂直居中(padding: 0 20px,修复原上 12 下 0 的偏移)

5. 回放修复(重要)

原实现按原始行数取 500 条:thinking/text 是流式 delta、一行一小片,长思考一轮几千片——500 行只覆盖尾部一小段。

  • user_input/user_message 为锚点取最近 5 轮整轮回放(REPLAY_TURNS
  • 轮内连续 thinking/text delta 合并为单条完整事件merge_replay),合并后大块自动命中前端「回放→折叠」启发式
  • 兜底 REPLAY_MAX_EVENTS=2000(逻辑事件,尾部截断)
  • 前端:实时 delta 追加到已有卡片时恢复 running 态(重连场景)

验证

  • cargo build --release
  • Playwright mock WS 23 项断言全绿(TodoPanel/diff/Read/Write/统计行/CFG/发送清空)
  • 真实 webagent + 合成 6110 行 events.jsonl(6 轮、两轮各 3000 分片思考)15 项断言全绿:第 1 轮正确裁掉、3000 分片首尾完整合并、TodoPanel/统计行回放重建
  • 布局实测:statsline 距 composer 8px、header 中点偏移 0px、滚动区与输入区 overlap=0

仅改 webagent crate,无四版本 prompt/tools.json 同步负担。

- TodoPanel:composer 上部计划条,默认收起可展开,三态图标(✓/旋转环/虚线圈)、
  进度摘要(零计数省略)、发送/重连清空
- 工具输出结构化:Edit→红删绿增双行号 diff 块(剥 ANSI)、Write→绿色新增行号块、
  Read→行号代码块
- 统计行:模型名 + 上下文占用(fmtK + 进度条 80%/90% 变色)+ 输入(缓存%)/输出 +
  对话 N 轮 · M 请求 + 压缩;CFG 占位符注入(--model/--provider/--max-context-tokens)
- 布局对齐 DSH:统计行移至输入框下方(todoPanel → composer → statsline),
  header 垂直居中
- 回放修复:以 user_input 为锚点取最近 5 轮整轮回放(REPLAY_TURNS=5),
  thinking/text delta 分片合并为单条完整事件(merge_replay),兜底 2000 逻辑事件
Comment thread webagent/src/server.rs
// 轮内的 thinking/text delta 分片合并为单条(一轮长思考可达数千分片,
// 按行数截断会把思考从中间截掉,实际展示内容量很小)。
// 兜底:合并后的逻辑事件再按 REPLAY_MAX_EVENTS 从尾部截断。
const REPLAY_TURNS: usize = 5;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

设计决策:按 turn 锚点回放,而非按行数。

原实现取最近 500 原始行——thinking/text 是流式 delta(SSE 一片一行),长思考一轮可达数千行,500 行只覆盖尾部一小段,用户实际看到的内容量很小。

改为以 user_input/user_message 为锚点取最近 5 轮整轮:整轮取全,思考再长也不会从中间截断。5 轮是会话回顾的常用窗口(与 CLI --continue 默认观感一致),一轮典型 20~60 逻辑事件,5 轮约 300 条,WS 回放流量可控。

注意:锚点匹配用 line.contains("\"type\":\"user_input\"") 字符串匹配——依赖 serde_json compact 序列化(冒号后无空格)。本仓库所有 runtime 均用 serde_json 写 events.jsonl,成立;若未来换序列化器需同步。

Comment thread webagent/src/server.rs
}
}
let merged = merge_replay(lines[anchor..].iter().copied(), usize::MAX);
for line in merged.iter().rev().take(REPLAY_MAX_EVENTS).rev() {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

兜底截断:rev().take(N).rev() 是「取最近 N 条」。

合并后逻辑事件理论上仍可能超限(如一轮几千个 tool_call)。merged.iter().rev().take(REPLAY_MAX_EVENTS).rev() = 从尾部取最近 2000 条再翻回正序发送。2000 是流量保护值:正常会话远达不到,只在异常场景(超大轮)兜底,宁可裁头也不让单连接回放几万条。

Comment thread webagent/src/server.rs

/// 回放合并器:把 events.jsonl 中连续的 thinking / text delta 分片合并为单条完整事件。
/// 输入按时间正序(文件顺序),输出同样按正序;非分片事件原样保留。
fn merge_replay<'a, I: Iterator<Item = &'a str>>(lines: I, max_lines: usize) -> Vec<String> {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

合并只针对 thinking/text 两类 delta 事件,不会跨轮粘连。

正确性论证:不同 turn 的 thinking(或 text)之间必然隔着 user_input/tool_call/stop 等事件(agent 循环里 assistant 块之间必有 tool_result 或新用户输入),所以「连续同类型」只发生在同一 assistant 流内部,合并是安全的。

合并的第二个收益:合并后单条 >400 字符,自动命中前端 appendThinking 的 replay 启发式(折叠 + done 态),无需额外信号区分回放/实时。

Comment thread webagent/src/index.html
H.cr += (evt.cache_read_input_tokens || 0);
H.cc += (evt.cache_creation_input_tokens || 0);
// 当前上下文实际占用:本次请求的全部输入(含缓存)+ 输出(compact 后会回落)
H.ctx = (evt.input_tokens || 0) + (evt.cache_read_input_tokens || 0)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

H.ctx 取最近一条 usage,而非累计——这是语义正确的选择。

上下文占用 = 本次请求的全部输入(input + cache_read + cache_creation)+ 输出,即请求真实前缀长度。compact 之后该值自然回落(被压缩成 summary),若用累计值则只增不减、永远显示偏高。

副作用:回放时中间轮的 usage 会被最后一轮覆盖,HUD 显示最后一轮的占用——恰好也是用户关心的"当前"状态,符合预期。

Comment thread webagent/src/index.html
autoScroll();
}

function addToolResult(name, content, isError) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

分流依赖 result 文本约定,格式变更需同步。

Edit → diff 的判定是 text.indexOf('@@') !== -1:依赖 rustagent unified_diff_color 输出 unified diff(含 @@ -a,b +c,d @@)。若日后改用 side-by-side 或 summary-only 输出,此分支自动退化为纯文本展示(fail-safe,不会崩)。

Read 行号从 input.offset(>1 时)起算;无 offset 默认 1,与 agent 侧 Read 语义一致。

Comment thread webagent/src/main.rs

/// 从透传给 agent 的参数中提取前端展示配置(--model / --provider / --max-context-tokens)。
/// 参数在 `--` 之后也可能出现,扫描全部 pos;值支持 `--k v` 与 `--k=v` 两种形式。
fn build_config_json(args: &[String]) -> String {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

参数扫描语义:全量扫描(含 -- 之后),支持两种形式。

--model xxx--model=xxx 都识别;扫描所有位置(包括 -- 分隔符之后的透传段),因为 model/provider 这类参数通常传给 agent 本体。未识别时 CFG 为空对象 {},前端各字段有默认(MAXCTX=200K、模型段不渲染),无兼容性问题。

Comment thread webagent/src/index.html
stream.buf += content;
stream.body.textContent = stream.buf;
// 重连/回放后继续收到实时分片:恢复运行态(合并大块落地时是 done)
stream.el.setAttribute('data-state', replay ? 'done' : 'running');

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

配套:恢复 running 态 + 上方 replay = content.length > 400 启发式的说明。

判定「大块=回放(折叠 done)/小块=实时流式(展开 running)」。实时流式前几个 delta(<400 字符)会短暂以 running 展开——有意为之:进行中的思考本就应展开,stop 后 finishThinking 再折叠。

重连场景:先收到 merge_replay 合并的大块(落地为 done 折叠),随后实时 delta 继续追加——此行把卡片恢复为 running,避免"已折叠但还在跑"的错觉。

回放路径已被 server 端 merge_replay 保证:合并后单条几乎必然 >400,稳定命中折叠分支。

@lloydzhou
lloydzhou merged commit a183dfd into main Aug 21, 2026
40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant