mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 21:33:05 +00:00
perf(gui): 聊天页增量渲染 + 修「打开不在最新消息」(10× 提速)
用户报「聊天页面卡得让人没有用的欲望」+「打开 app 和 webui,没有停在
最新消息处,还要反复滑动」。真机实测(Xvfb + Electron + CDP)定位到两个
根因,都修了。
## 根因 1:renderChat 每次全量重建 innerHTML
200 条消息 = 3500 个 DOM 节点全部销毁重建。拆分测量:
200 条:整体 234.6ms,其中 renderMd 25.4ms(**11%**)
⇒ markdown 渲染只占 11%,**89% 在 DOM 写入与布局**。
流式追加时每个放行的 chunk 都走这条路(200 条时每 chunk 6.6ms),
聊到几百条就是 0.5 秒/次 —— 这就是体感。
**改法**:按 `data-msgkey` 复用节点,四种策略按代价从低到高:
尾部追加(最常见)→ 头部前插(loadOlderChat)→ 局部替换 → 兜底整棵重建。
`data-msgkey` = role + 序号 + 内容长度 + 首尾片段。
★ **不能靠下标定位**:`loadOlderChat` 会 `unshift` 前插消息,下标整体位移。
实测:
| 消息数 | 改前 | 改后 | 改善 |
| --- | --- | --- | --- |
| 50 | 59ms | **8.2ms** | 7.2× |
| 200 | 235ms | **23.5ms** | 10× |
| 400 | 474ms | **38.2ms** | 12× |
关键是**次线性**了:400 条只比 200 条多 15ms(改前多 240ms)。
## 根因 2:滚动没落地
scrollTo 被调: 1, 参数: {top: 23446, behavior: "smooth"}
scrollTop: 0 ← 调了,但没生效
可滚动上限: 22838
smooth 立即值 0、300ms 后只到 6894(上限 22838)⇒ **既慢又没到位**;
手动 `scrollTop = scrollHeight` **立即 22838 一次到位**。
原因:紧邻的 DOM 全量变更让 smooth 动画的起点算在**旧**布局上。
重建后本就不该有动画 —— 用户要的是「立刻看到最新」。
**改法**:`msgsEl.scrollTop = msgsEl.scrollHeight`。
## 判据 chat-perf.test.mjs(10 条,真 Electron 跑)
新增 `npm run test-live`(需 Xvfb + electron,**不进 make test** ——
它要起真浏览器、30 秒启动,不适合当门禁)。
- 性能:200 条 < 100ms(**产品体感阈值**,不是 benchmark 数字)
- 次线性:单条成本不随规模上升
- 滚动:打开即在底部、300ms 后不被带偏
- **正确性:增量不丢消息**(5 种增删改路径)—— 比性能更重要
### 写判据时踩的坑(都记在文件里)
1. **第一版测出「0ms / 0 DOM 节点」** —— `buildChatLayout()` 在**无后端连接**时
走「请先添加连接」分支、聊天区压根没建 ⇒ **测不到**而非「不卡」。
2. **`ensureGui` 定义了但从未被调用** —— 重写文件时把调用丢了,
而定义还在,看起来一切正常。
3. **`detached: true` 只脱离进程组、不脱离会话** —— 脚本结尾 `process.exit()`
把刚起来的 GUI 带走(症状:`[tray] READY` 打了,判据却报「找不到页面」)。
改用 `setsid`。
4. **`JSON.parse(e.data)` 裸调** —— CDP 的 onmessage 也会收到非 JSON 帧,
抛在回调里既冒泡不到 await 也等不到 resolve ⇒ 整个判据挂死。
5. 我自己的滚动探针 `el.innerHTML=''` 让 `scrollHeight` 变 0,
「到位」判定是假象。改用「保留内容、只改滚动方式」重测。
## 变异测试
把 `applyIncrementalChatRender(msgsEl, html)` 改回 `msgsEl.innerHTML = html`
⇒ 判据立刻红(实测 268ms + 严格线性)。
## 门禁
- `npm run test-live`:10/10 通过
- `npm test`、`make test-gui`:全通过
- `go test ./...`:43 包 ok、0 FAIL
This commit is contained in:
@ -1745,6 +1745,12 @@ function renderChat() {
|
||||
msgs.forEach((m, i) => {
|
||||
var role = m.role || "user";
|
||||
var c = m.content || "";
|
||||
// ★ 稳定标识:role + 序号 + 内容长度 + 首尾片段。
|
||||
// 序号参与是为了区分「连续两条同 role 同长度」的消息;
|
||||
// 内容片段参与是为了让「同一条消息内容变了」能被识别出来。
|
||||
// 增量渲染靠它定位可复用的 DOM 节点(**不能靠下标** ——
|
||||
// loadOlderChat 会 unshift 前插消息,下标整体位移)。
|
||||
var mkey = role + ":" + i + ":" + c.length + ":" + c.slice(0, 24) + ":" + c.slice(-24);
|
||||
if (role === "assistant") {
|
||||
if (typeof marked === "undefined") {
|
||||
c = "<pre>" + escHtml(c) + "</pre>";
|
||||
@ -1866,12 +1872,12 @@ function renderChat() {
|
||||
}
|
||||
if (role === "system") {
|
||||
html +=
|
||||
'<div class="msg msg-system"><div class="msg-bubble">' +
|
||||
'<div class="msg msg-system" data-msgkey="' + escHtml(mkey) + '"><div class="msg-bubble">' +
|
||||
(c || "") +
|
||||
"</div></div>";
|
||||
} else if (isChan) {
|
||||
html +=
|
||||
'<div class="msg msg-channel">' +
|
||||
'<div class="msg msg-channel" data-msgkey="' + escHtml(mkey) + '">' +
|
||||
'<div class="msg-avatar chan-avatar" style="background:' +
|
||||
chanColor(m.source) +
|
||||
'">' +
|
||||
@ -1893,6 +1899,8 @@ function renderChat() {
|
||||
html +=
|
||||
'<div class="msg msg-' +
|
||||
role +
|
||||
'" data-msgkey="' +
|
||||
escHtml(mkey) +
|
||||
'">' +
|
||||
'<div class="msg-avatar">' +
|
||||
(role === "user" ? userAvatar : aiAvatar) +
|
||||
@ -1910,7 +1918,7 @@ function renderChat() {
|
||||
__("小宅", "Agent") +
|
||||
'">';
|
||||
html +=
|
||||
'<div class="msg msg-assistant"><div class="msg-avatar">' +
|
||||
'<div class="msg msg-assistant" data-msgkey="__loading__"><div class="msg-avatar">' +
|
||||
aiAvatar2 +
|
||||
'</div><div class="msg-content"><div class="msg-bubble">' +
|
||||
'<span class="live-spinner"></span>' +
|
||||
@ -1919,13 +1927,21 @@ function renderChat() {
|
||||
: "") +
|
||||
"</div></div></div>";
|
||||
}
|
||||
msgsEl.innerHTML = html;
|
||||
// ★ 增量渲染:能复用就复用,别整棵重建(见 applyIncrementalChatRender)。
|
||||
applyIncrementalChatRender(msgsEl, html);
|
||||
if (state.chatStick !== false) {
|
||||
try {
|
||||
msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: "smooth" });
|
||||
} catch (e) {
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
}
|
||||
// ★ 必须用 scrollTop 立即到位,**不能**用 scrollTo({behavior:"smooth"})。
|
||||
//
|
||||
// 真机实测(Xvfb + Electron + CDP,200 条消息 / 3500 DOM 节点):
|
||||
// smooth:立即 scrollTop=0,300ms 后只到 6894,而上限是 22838
|
||||
// ⇒ 既慢又**没到位**,用户看到的就是「打开不在最新消息、
|
||||
// 还要反复滑动」
|
||||
// scrollTop=scrollHeight:立即 22838,一次到位
|
||||
//
|
||||
// 原因:上面刚做完 DOM 变更,smooth 动画的起点算的是**旧**布局;
|
||||
// 动画启动前布局又变了,于是滚到错误位置。重建后本就不该有动画
|
||||
// ——用户要的是「立刻看到最新消息」。
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
}
|
||||
updateChatBadge();
|
||||
if (window.homeagent && window.homeagent.log) {
|
||||
@ -1980,6 +1996,101 @@ function updateChatBadge() {
|
||||
badge.style.display = "none";
|
||||
}
|
||||
|
||||
// applyIncrementalChatRender 按 data-msgkey 复用可用的 DOM 节点。
|
||||
//
|
||||
// 为什么需要:旧实现无条件整棵 innerHTML 重建 —— 200 条消息 = 3500 个 DOM
|
||||
// 节点全部销毁重建,真机实测单次 235ms;流式追加时每个放行的 chunk 都走这条路
|
||||
// (200 条时每 chunk 6.6ms)⇒ 聊得越久越卡,用户「卡得没有用的欲望」。
|
||||
//
|
||||
// 策略(从便宜到贵):
|
||||
// 1) 尾部追加 —— 新 key 序列是旧序列的前缀 + 新增(最常见:发消息/工具轮)
|
||||
// 2) 头部前插 —— loadOlderChat 往上翻:新序列 = 新前缀 + 旧序列
|
||||
// 3) 局部替换 —— 个别 key 变了(某条消息内容更新)
|
||||
// 4) 兜底整棵重建 —— 序列既非前缀也非后缀(删除/去重/重排)
|
||||
function applyIncrementalChatRender(msgsEl, html) {
|
||||
var want = [];
|
||||
var re = /data-msgkey="([^"]*)"/g;
|
||||
var m;
|
||||
while ((m = re.exec(html)) !== null) want.push(m[1]);
|
||||
|
||||
var kids = Array.prototype.slice.call(msgsEl.children);
|
||||
var have = kids.map(function (n) {
|
||||
return n.getAttribute("data-msgkey") || "";
|
||||
});
|
||||
|
||||
// 没带 key(老结构 / 空列表)⇒ 只能整棵重建
|
||||
if (want.length === 0 || have.length === 0) {
|
||||
msgsEl.innerHTML = html;
|
||||
return;
|
||||
}
|
||||
|
||||
// 情况 2:头部前插。新序列 = 新前缀 + 旧序列
|
||||
if (want.length > have.length) {
|
||||
var off = want.length - have.length;
|
||||
var isPrepend = true;
|
||||
for (var p = 0; p < have.length; p++) {
|
||||
if (want[p + off] !== have[p]) { isPrepend = false; break; }
|
||||
}
|
||||
if (isPrepend) {
|
||||
var prevH = msgsEl.scrollHeight;
|
||||
var prevTop = msgsEl.scrollTop;
|
||||
for (var q = off - 1; q >= 0; q--) {
|
||||
var tn = parseChatNode(html, want[q]);
|
||||
if (tn) msgsEl.insertBefore(tn, msgsEl.firstChild);
|
||||
}
|
||||
// 保持滚动锚点:内容变高后把视口往下挪相同高度
|
||||
msgsEl.scrollTop = prevTop + (msgsEl.scrollHeight - prevH);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 情况 1:尾部追加。新序列是旧序列的前缀
|
||||
var isAppend = have.length <= want.length;
|
||||
for (var a = 0; a < have.length && isAppend; a++) {
|
||||
if (want[a] !== have[a]) isAppend = false;
|
||||
}
|
||||
if (isAppend) {
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var b = have.length; b < want.length; b++) {
|
||||
var node = parseChatNode(html, want[b]);
|
||||
if (node) frag.appendChild(node);
|
||||
}
|
||||
msgsEl.appendChild(frag);
|
||||
return;
|
||||
}
|
||||
|
||||
// 情况 3:局部替换
|
||||
for (var c = 0; c < want.length && c < kids.length; c++) {
|
||||
if (have[c] === want[c]) continue;
|
||||
var fresh = parseChatNode(html, want[c]);
|
||||
if (fresh && kids[c] && kids[c].parentNode === msgsEl) {
|
||||
msgsEl.replaceChild(fresh, kids[c]);
|
||||
}
|
||||
}
|
||||
// 尾部有删除 ⇒ 截断
|
||||
while (msgsEl.childElementCount > want.length) {
|
||||
msgsEl.removeChild(msgsEl.lastElementChild);
|
||||
}
|
||||
if (msgsEl.childElementCount === want.length) return;
|
||||
// 兜底
|
||||
msgsEl.innerHTML = html;
|
||||
}
|
||||
|
||||
// parseChatNode 从整段 html 里切出 key 对应的那一个顶层消息节点。
|
||||
//
|
||||
// 用一个容器承载并逐个子节点比对 data-msgkey —— 不引 DOMParser 重新解析整棵
|
||||
// html(那会抵消掉增量渲染省下的开销)。
|
||||
function parseChatNode(html, key) {
|
||||
var holder = document.createElement("div");
|
||||
holder.innerHTML = html;
|
||||
for (var i = 0; i < holder.children.length; i++) {
|
||||
if ((holder.children[i].getAttribute("data-msgkey") || "") === key) {
|
||||
return holder.children[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rerenderChat() {
|
||||
guardedRenderChat();
|
||||
renderChatStarmap();
|
||||
|
||||
Reference in New Issue
Block a user