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。

四个串行瓶颈

对着代码翻了几个小时:

  1. 展开的目录挨个请求 — 每个展开的目录一个 HTTP 请求,一个等一个。5 个目录 = 5 × RTT。
  2. 语法高亮堵住目录加载 — 前端先跑 Prism.js 高亮,跑完才发目录请求。两个不相关的任务硬串了。
  3. Git 信息三个子进程串行git statusgit rev-list --aheadgit rev-list --behindsubprocess.run 一个一个跑。三次 shell 子进程启动 = 三次开销。
  4. 每次拉全场消息 — 不管 100 条还是 500 条,后端全量序列化。最长 session 压缩后还 86KB,用户能看到的屏幕范围最多 30 条。

做法

消息分页:后端加 msg_limit/msg_before 参数,默认取最后 30 条(~16KB,降 70%),前端滚动到顶部懒加载老消息。

有个麻烦:重试、撤销、压缩这些操作需要全量消息。加了一个 _ensureAllMessagesLoaded() 先全拉再做操作。代价是前端要维护 _messagesTruncated 布尔值,在所有路径下正确重置。

并行化

  • 展开目录:for...of + awaitPromise.all()
  • 高亮和目录加载:互换顺序,先发目录请求再跑 Prism.js,两个并行
  • Git 信息:三个 subprocess.runThreadPoolExecutor(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 渲染。

PR: #1350 / #1355


#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修复符号链接无限递归fixv0.50.227
1158Session 切换性能优化 800ms→40msperfv0.50.229
1219加载老消息后滚动位置跳动修复fixv0.50.237
1213DeepSeek V4 + Z.AI/GLM Provider 支持featv0.50.237
1317Cron Job NameError(run_job 未 import)fixv0.50.245
1341Session 持久化 context_length 等字段fixv0.50.246
1349Context 指示器无需显式 context_lengthfixv0.50.248
1350SSE 实时推送 Approval 通知featv0.50.248
1355SSE 实时推送 Clarify 通知featv0.50.249
1780Kanban 文档字符串 + init_db 修复fixv0.51.17
1782自定义 CSS Tooltip 替换原生 titlefeatv0.51.17
1884点击 rail 按钮切换侧边栏折叠featv0.51.43
2185Session 切换压缩状态 404 修复fixv0.51.62
2186并发 send() 竞态修复fixv0.51.62
2187Steer 消息在聊天界面显示featv0.51.62
2236静默失败检测改为只扫新消息fixv0.51.65

几点感受

自用驱动。 每个 PR 都从自己的使用痛点出发——session 切换慢、通知延迟、UI 不顺手。这种贡献比”为了贡献而贡献”自然,也更容易写好 PR 描述,因为你就是用户。

Reviewer 的严格对双方都好。 不是”能跑就行”,而是要求覆盖边界路径、状态重置、并发安全。#1158 的 _messagesTruncated 被 hold 了两天直到补全 cancellation 路径。代码质量在这种反馈循环里实打实地涨。

Batch release 模式。 维护者不直接 merge,而是把多个 PR 打包成 batch release,统一跑 CI 后发布。PR 状态显示 “CLOSED” 而不是 “MERGED”,但代码确实进了主分支。