fix(webui): 聊天记录不再"每次都发完整记录",并修掉视口跳顶

两个都是你指出的症状,都定位到了具体代码路径。

① 「每次都发完整聊天记录」
   a) API 缺省值错了:/api/v1/chat/history 的 limit 缺省是 0 = **不限制**,
      于是任何不带 limit 的调用每次都拿到整段记录。实测(126 条):
      不带 limit 635,297 字节;现在默认只回一页 180,668 字节。
      显式 limit=0 仍可整取(逃生口)。WebUI/GUI 本来都带 limit,不受影响。
   b) 前端 30s 轮询(以及每次 SSE 报错)都直接拉一页 40 条:
      浏览器实测单次 180,813 字节。现在先做"尾巴探测"(limit=1,362 字节),
      尾巴一致就直接跳过;不一致才拉整页。

② 「聊天记录会跳到顶部」——两条会导致视口丢失的路径都堵上
   a) syncChatFromHistory 在"找不到重合点"时直接 `state.messages = serverMsgs`:
      服务端只回一页,而本地可能已经向上翻了好几页;一覆盖,容器立刻变矮,
      视口被夹回顶部,用户翻过的旧消息也凭空消失。现在只在服务端页**不短于**本地时
      才整体替换。
   b) renderChat 全量重建 innerHTML 后,仅在粘底时滚到底;非粘底(用户正在向上读)
      时位置没人管。改为重建前记住 scrollTop、非粘底时原样还回去。

浏览器实测(CDP 驱动真实页面,126 条历史):
  * 15s/30s 定时器跑满 40s:聊天区滚动位置 **0 px 变化**,未跳顶;
  * 期间 chat/history 请求:limit=1 × 2(各 362 字节)+ 首屏 limit=40 一次;
  * 控制台无报错;新增消息后轮询仍能正确并进来(尾巴探测→拉整页→合并)。

测试:新增 TestChatHistoryDefaultIsPaged(默认一页 / has_more / limit=0 整取 / 显式分页)。
顺带修测试串味:迁移用例往 os.TempDir() 写共享历史文件,会让其它用例的
NewHandler 加载到脏历史(表现为条数多 1);现在各用例用自己的临时文件。
This commit is contained in:
HomeAgent Agent
2026-09-14 08:06:16 +08:00
parent 764939ed90
commit c0e9dc1818
3 changed files with 116 additions and 9 deletions

View File

@ -1209,9 +1209,14 @@
: "") +
"</div></div></div>";
}
// 重建前记住阅读位置非粘底用户正向上翻innerHTML 重建后必须把位置还回去,
// 否则视口会被重置——这就是"聊天记录跳到顶部"的直接来源。
var prevTop = msgsEl.scrollTop;
msgsEl.innerHTML = html;
if (state.chatStick !== false) {
msgsEl.scrollTop = msgsEl.scrollHeight;
} else {
msgsEl.scrollTop = prevTop;
}
updateChatBadge();
}
@ -2161,10 +2166,44 @@
}
}
// syncChatFromHistory 增量同步:对比服务端历史,仅追加新消息 DOM 节点,
// 不重建已有消息 → 无闪烁。用于 SSE 断连恢复期间的轮询兜底。
// syncChatFromHistory 增量同步:先做一次极轻的"尾巴探测",只有尾巴变了才拉整页。
//
// 原先每 30s以及每次 SSE 报错)都直接拉一页 40 条:本地实测 180KB、
// 生产消息更大时可达 ~1MB —— 这是"每次都在发完整聊天记录"的观感来源。
// 探测只需 1 条(约几 KB尾巴一致就直接跳过。
var _syncingChat = false;
function syncChatFromHistory() {
return api("/chat/history?limit=" + CHAT_PAGE_SIZE).then(function (data) {
if (_syncingChat) return Promise.resolve();
_syncingChat = true;
return api("/chat/history?limit=1")
.then(function (tail) {
var t = tail && tail.messages && tail.messages[0];
var local = state.messages.length
? state.messages[state.messages.length - 1]
: null;
var same =
t &&
local &&
t.role === (local.role || local.Role) &&
(t.content || "") === (local.content || local.Content || "");
if (same) return null; // 尾巴一致:无需拉整页
return api("/chat/history?limit=" + CHAT_PAGE_SIZE);
})
.then(function (data) {
if (!data) return;
return mergeChatFromHistory(data);
})
.catch(function () {})
.then(function () {
_syncingChat = false;
});
}
// mergeChatFromHistory 把服务端的一页历史并进本地:只追加新消息,不重建已有节点。
// 关键约束:**绝不**用更短的服务端页替换更长的本地列表(那会让用户翻上来的旧页
// 凭空消失、视口跳回顶部)。
function mergeChatFromHistory(data) {
{
if (!data || !data.messages || data.messages.length === 0) return;
var serverMsgs = data.messages;
var localMsgs = state.messages;
@ -2215,9 +2254,14 @@
newMsgs = serverMsgs.slice(serverMsgs.length - overlap);
if (newMsgs.length === 0) return; // 无新增(内容改写走上面的分支)
} else {
// 找不到重含点(本地领先太多,超出服务端页)→ 无法精确差异,安全退化为全量刷新
state.messages = serverMsgs;
rerenderChat(true);
// 找不到重合点:**不能**直接拿服务端页覆盖本地。
// 服务端只回一页,本地翻上来的旧页更长;覆盖会同时造成两个后果:
// 用户翻过的旧消息凭空消失、容器变矮后视口被夹回顶部。
// 只有在服务端页不短于本地时才整体替换(那种情况下不丢内容)。
if (serverMsgs.length >= localMsgs.length) {
state.messages = serverMsgs;
rerenderChat(true);
}
return;
}
var msgsEl = document.getElementById("chat-msgs");
@ -2246,7 +2290,7 @@
Array.prototype.push.apply(state.messages, newMsgs);
// 同步聊天占位符(如果有新消息但最后一条非 assistant → 显示流式占位)
syncStreamingPlaceholder();
}).catch(function () {});
}
}
// syncStreamingPlaceholder同步聊天占位符的可见性
function syncStreamingPlaceholder() {

View File

@ -46,6 +46,9 @@ type ChatToolCall struct {
Plugin string `json:"plugin,omitempty"`
}
// defaultChatHistoryLimit 是 /chat/history 不带 limit 时默认返回的页大小。
const defaultChatHistoryLimit = 40
const maxChatHistory = 200
// ===== client_msg_id 去重(防 GUI 断线重连/超时重试导致的消息重放)=====
@ -481,9 +484,14 @@ func (h *Handler) addChatMsg(msg ChatMsg) {
// 上下文还原的关键信息,必须完整下发;瘦身只通过分页控制条数。
func (h *Handler) handleChatHistory(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit := parseIntDefault(q.Get("limit"), 0)
// 默认只给**一页**,不是整段历史。
//
// 原先缺省值 0 = 不限制,于是任何不带 limit 的客户端每次都会拿到完整聊天记录
// (生产实例上 ~5MB本地 126 条实测 635KB。WebUI/GUI 都显式带 limit
// 所以把默认收到一页不会影响它们;想整取的调用方显式传 limit=0。
limit := parseIntDefault(q.Get("limit"), defaultChatHistoryLimit)
if limit < 0 {
limit = 0
limit = defaultChatHistoryLimit
}
if limit > maxChatHistory {
limit = maxChatHistory

View File

@ -1002,6 +1002,12 @@ func TestSettingsNoCrossPluginLeak(t *testing.T) {
webuiCfg := cfgReg.PluginConfig("webui")
webuiCfg.RegisterDef(internalConfig.ConfigDef{Key: "addr", Default: ":8080"})
// 隔离聊天记录文件:不设的话会落到 os.TempDir()/webui_chat_history.json
// 与其它用例串味(迁移用例会把自己的数据写进去)。
webuiCfg.RegisterDef(internalConfig.ConfigDef{Key: "history_file", Default: ""})
if err := webuiCfg.Set("history_file", filepath.Join(t.TempDir(), "chat.json")); err != nil {
t.Fatalf("set history_file: %v", err)
}
webuiCfg.Set("addr", ":8080")
webuiCfg.Set("chathistory", `[{"role":"assistant","content":"secret blob"}]`)
@ -1332,3 +1338,52 @@ func TestDashboardAssetsSplit(t *testing.T) {
t.Fatal("组装后的页面缺脚本")
}
}
// TestChatHistoryDefaultIsPaged 钉住 /chat/history 的默认页大小。
//
// 原先缺省 limit=0 表示"不限制",于是任何不带 limit 的调用每次都拿到完整聊天记录
// (生产实测 ~5MB本地 126 条 635KB——这正是"每次都在发完整聊天记录"的来源。
// 现在默认只回一页;想整取必须显式 limit=0。
func TestChatHistoryDefaultIsPaged(t *testing.T) {
h, _ := newTestHandler(t)
// 清空可能从临时目录共享文件加载进来的历史,让用例只依赖自己播的数据
h.chatMu.Lock()
h.chatHistory = nil
for i := 0; i < maxChatHistory; i++ {
h.chatHistory = append(h.chatHistory, ChatMsg{Role: "user", Content: "消息", Time: "t"})
}
h.chatMu.Unlock()
get := func(q string) map[string]interface{} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/chat/history"+q, nil)
w := httptest.NewRecorder()
h.handleChatHistory(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET %q: %d", q, w.Code)
}
var out map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return out
}
// 不带 limit → 只回一页,且明确告知还有更早的
def := get("")
if n := len(def["messages"].([]interface{})); n != defaultChatHistoryLimit {
t.Fatalf("默认应回 %d 条,实际 %d", defaultChatHistoryLimit, n)
}
if def["has_more"] != true {
t.Fatal("还有更早内容时 has_more 应为 true")
}
// 显式 limit=0 → 整取(逃生口):返回条数应等于 total
all := get("?limit=0")
total := int(all["total"].(float64))
if n := len(all["messages"].([]interface{})); n != total {
t.Fatalf("limit=0 应整取 total=%d 条,实际 %d", total, n)
}
// 显式分页仍然照旧
page := get("?limit=5")
if n := len(page["messages"].([]interface{})); n != 5 {
t.Fatalf("limit=5 应回 5 条,实际 %d", n)
}
}