diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index d99bc84..fb87d6f 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -1209,9 +1209,14 @@ : "") + ""; } + // 重建前记住阅读位置:非粘底(用户正向上翻)时,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() { diff --git a/internal/plugins/webui/handler_chat.go b/internal/plugins/webui/handler_chat.go index 66b606d..01d9e65 100644 --- a/internal/plugins/webui/handler_chat.go +++ b/internal/plugins/webui/handler_chat.go @@ -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 diff --git a/internal/plugins/webui/handler_test.go b/internal/plugins/webui/handler_test.go index 1acd10f..3b75402 100644 --- a/internal/plugins/webui/handler_test.go +++ b/internal/plugins/webui/handler_test.go @@ -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) + } +}