给 hermes-webui 提了 18 个 PR,全部被上游合并,跨越 v0.50.226 到 v0.51.65。
挑三个有难度的展开写,其余列在末尾表里。
#1158 — Session 切换从 800ms 到 40ms
起因
WebUI 跑在远程服务器上,我在国内,切一个长对话的 session 要等 1-2 秒,loading 闪烁看着烦。
打开 session 本来就想看最后几句话,但后端把 400 条完整历史全序列化打包传过来,前端再全部渲染——然后你才看到那几句。
这不是 bug,是设计假设和实际使用场景的错位。WebUI 最早是本地开发的工具,localhost 下传 86KB 一眨眼的事。放到远程部署,一次 HTTP 往返 200ms,86KB payload 再乘以 2 是 400ms,前端渲染几百条 DOM 节点又是几百 ms。1+1=2。
四个串行瓶颈
对着代码翻了几个小时:
- 展开的目录挨个请求 — 每个展开的目录一个 HTTP 请求,一个等一个。5 个目录 = 5 × RTT。
- 语法高亮堵住目录加载 — 前端先跑 Prism.js 高亮,跑完才发目录请求。两个不相关的任务硬串了。
- Git 信息三个子进程串行 —
git status、git rev-list --ahead、git rev-list --behind用subprocess.run一个一个跑。三次 shell 子进程启动 = 三次开销。 - 每次拉全场消息 — 不管 100 条还是 500 条,后端全量序列化。最长 session 压缩后还 86KB,用户能看到的屏幕范围最多 30 条。
做法
消息分页:后端加 msg_limit/msg_before 参数,默认取最后 30 条(~16KB,降 70%),前端滚动到顶部懒加载老消息。
有个麻烦:重试、撤销、压缩这些操作需要全量消息。加了一个 _ensureAllMessagesLoaded() 先全拉再做操作。代价是前端要维护 _messagesTruncated 布尔值,在所有路径下正确重置。
并行化:
- 展开目录:
for...of + await→Promise.all() - 高亮和目录加载:互换顺序,先发目录请求再跑 Prism.js,两个并行
- Git 信息:三个
subprocess.run→ThreadPoolExecutor(max_workers=3)
代码改动不大。难的是判断串行之间有没有隐含依赖——比如高亮和目录加载的顺序有没有人依赖了某种副作用。这种优化的通用矛盾:不是并行化难,而是得搞清楚为什么之前是串行的。
效果
| 瓶颈 | 优化前 | 优化后 |
|---|---|---|
| 展开目录预取 | N × 网络往返 | 1 × 网络往返 |
| 目录 + 高亮 | 串行阻塞 | 并行 |
| Git 信息 | 3 × 子进程开销 | 1 × 子进程开销 |
| 消息传输 | 全量 ~86KB | 后 30 条 ~16KB,降 70% |
~800ms → ~40ms,loading 闪烁消失。
PR 过程
5 个 commit 提上去。reviewer 对分页部分提了 hold——担心 _messagesTruncated 在边界路径(重试、撤销、错误恢复)没重置,界面会显示错误的加载提示。补了 cancellation safety 修复后 merge。
这个项目的测试覆盖率让我意外:test/ 下 150+ 文件、4 万行,覆盖渲染器到 CSRF 到 i18n 几乎每个角落。给这种项目做贡献最大的安全感不是代码写得多漂亮,而是改完东西知道不会被静默打破什么。
PR: #1158
#1350 / #1355 — SSE 实时推送 Approval 和 Clarify 通知
起因
Agent 发起 approval(执行终端命令前等确认)或 clarify(向用户提问)时,前端靠轮询拿状态,延迟 5-10 秒。用户体验上就是点了个按钮,等半天才有反应。
架构选择
轮询 → SSE(Server-Sent Events)。没有选 WebSocket,因为这里只需要服务端推、客户端收,不需要双向通信。SSE 的好处是原生浏览器支持、自动重连、HTTP 协议兼容、不需要额外的连接管理。
设计
后端维护一个 per-session 的事件队列。客户端通过 /api/sse/subscribe 建立长连接,事件发生时 put 到队列,SSE 流推给前端。
几个要处理的点:
跨 session 隔离 — 每个 session 有自己的订阅,不能把 A session 的 approval 推给 B。用 session_id 做 key 管理订阅字典。
队列溢出 — 如果客户端断连但服务端还在推,队列会无限增长。加了 maxlen 限制,满了就丢弃最老的事件(反正客户端重连后会重新拉全量状态)。
并发订阅 — 同一个 session 理论上可能在多个浏览器标签页打开。允许多个订阅同时存在,事件广播到所有订阅者。
生命周期管理 — 客户端断连时自动 unsubscribe(SSE 的 EventSource 关闭触发后端清理)。
测试
写了 subscribe/unsubscribe 生命周期、跨 session 隔离、队列溢出、并发订阅几组测试。reviewer 专门提了测试质量。
#1350(Approval)先做,#1355(Clarify)是把同样的模式移植到 clarify 流程——同样的事件队列机制,不同的事件类型和前端 UI 渲染。
#1884 — 侧边栏折叠:点击当前 rail 按钮切换
起因
WebUI 侧边栏可以折叠成一条窄 rail(只显示图标),但折叠后想展开只能点汉堡菜单。移动端或窄屏上汉堡菜单不一定可见,rail 上又没有明确的展开入口。
做法
点击 rail 上已经激活(高亮)的按钮,触发侧边栏展开/折叠。逻辑很简单——判断点击的按钮是否是当前激活项,是就 toggle。
有趣的部分:合并 PR
另一个贡献者 @spektro33 同时提了 #1924 做同样的事,方案几乎一样。维护者把我们两个的 PR 合成一个 #2054,给了双作者署名(Co-authored-by)。
第一次遇到开源项目里这种合并方式。维护者的原话:“Fusing this into a stealth-mode PR — they were both proposing the same UX from different angles.”
PR: #1884
#2186 — 并发 send() 竞态:消息丢失和流吞没
起因
WebUI 用 S.busy 布尔值防止用户在 agent 回复过程中重复发送消息。但 setBusy(true) 在 send() 内部第一个 await 之后才执行——也就是说,两次快速 send() 调用之间有个时间窗口,第二次能通过 busy 检查,两条流并发跑。
结果:第二条消息的流覆盖第一条的输出,或者前端渲染混乱。
做法
把 setBusy(true) 移到 send() 函数的最顶部,在任何 await 之前。改动只有几行,但需要确认所有错误路径(catch / finally)都正确重置了 busy 状态。
PR: #2186
#2236 — 静默失败检测扫了全量消息
起因
当 provider 返回错误(401、429、rate-limit),agent 没有生成新回复,WebUI 需要检测这个”静默失败”并给用户显示错误。
问题在于检测逻辑在 _assistant_added 里扫的是 session 的 全部历史消息,而不是刚跑完的这轮新增消息。长 session 下这不是性能问题——是因为全量扫描碰到了旧消息里的错误标记,误判为新失败,弹出不该出现的错误提示。
做法
改成只扫描本轮新增的消息。需要在调用点传入消息范围(result["messages"] 里新消息的起始 index),检测函数只看这个窗口。
代码 217 行 additions,主要因为补了对应的测试覆盖。
PR: #2236
#2187 — Steer 消息在聊天界面显示
起因
WebUI 有个 busy_input_mode=steer 功能——agent 忙的时候,用户输入的消息不会排队等 agent 处理,而是直接注入 agent 的 context(“steer”它的方向)。
问题是注入后消息就消失了,用户看到输入框闪了一下,不知道消息去哪了。
做法
Steer 注入的消息现在在聊天界面显示——半透明的斜体气泡,带一个 Steer 标记。用户能看到消息被投递了,也知道它走的是 steer 通道。
PR: #2187
#2185 — Session 切换触发压缩状态 404
起因
切 session 时前端调用 GET /api/session/compress/status 检查有没有正在跑的压缩任务。没任务时后端返回 None(从 j() 函数),Flask 把 None 当成 404 处理。控制台一堆红字,虽然不影响功能但碍眼。
做法
空状态返回 {"active": false} 而不是 None。3 个文件改了 144 行(含测试),1 行删除。
PR: #2185
全部 PR 一览
| # | 标题 | 类型 | 版本 |
|---|---|---|---|
| 1149 | 修复符号链接无限递归 | fix | v0.50.227 |
| 1158 | Session 切换性能优化 800ms→40ms | perf | v0.50.229 |
| 1219 | 加载老消息后滚动位置跳动修复 | fix | v0.50.237 |
| 1213 | DeepSeek V4 + Z.AI/GLM Provider 支持 | feat | v0.50.237 |
| 1317 | Cron Job NameError(run_job 未 import) | fix | v0.50.245 |
| 1341 | Session 持久化 context_length 等字段 | fix | v0.50.246 |
| 1349 | Context 指示器无需显式 context_length | fix | v0.50.248 |
| 1350 | SSE 实时推送 Approval 通知 | feat | v0.50.248 |
| 1355 | SSE 实时推送 Clarify 通知 | feat | v0.50.249 |
| 1780 | Kanban 文档字符串 + init_db 修复 | fix | v0.51.17 |
| 1782 | 自定义 CSS Tooltip 替换原生 title | feat | v0.51.17 |
| 1884 | 点击 rail 按钮切换侧边栏折叠 | feat | v0.51.43 |
| 2185 | Session 切换压缩状态 404 修复 | fix | v0.51.62 |
| 2186 | 并发 send() 竞态修复 | fix | v0.51.62 |
| 2187 | Steer 消息在聊天界面显示 | feat | v0.51.62 |
| 2236 | 静默失败检测改为只扫新消息 | fix | v0.51.65 |
几点感受
自用驱动。 每个 PR 都从自己的使用痛点出发——session 切换慢、通知延迟、UI 不顺手。这种贡献比”为了贡献而贡献”自然,也更容易写好 PR 描述,因为你就是用户。
Reviewer 的严格对双方都好。 不是”能跑就行”,而是要求覆盖边界路径、状态重置、并发安全。#1158 的 _messagesTruncated 被 hold 了两天直到补全 cancellation 路径。代码质量在这种反馈循环里实打实地涨。
Batch release 模式。 维护者不直接 merge,而是把多个 PR 打包成 batch release,统一跑 CI 后发布。PR 状态显示 “CLOSED” 而不是 “MERGED”,但代码确实进了主分支。