diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 2f80252..7696700 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -44,6 +44,9 @@ const state = { pendingTools: [], eventSource: null, chatFinalIdx: -1, + chatOffset: 0, // 分段历史:当前已加载消息在服务端全量中的起始下标 + chatTotal: 0, // 服务端历史总条数 + chatHasMore: false, // 是否还有更早历史可向上加载 sseLastEventID: "", // 最近一次 SSE 事件 id,断线重连时随 Last-Event-ID 头回传 lang: localStorage.getItem("ha-lang") || "zh", connections: [], @@ -1340,6 +1343,10 @@ function renderChat() { () => { state.chatStick = msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight < 80; + // 触顶(近顶部 60px)且服务端还有更早历史 → 向上懒加载下一页 + if (msgsEl.scrollTop < 60 && state.chatHasMore) { + loadOlderChat(); + } }, { passive: true }, ); @@ -2351,13 +2358,26 @@ function switchChatPanel(tab, el) { if (tab === "knowledge") searchKnowledgeChat(); } +// 首屏分段加载条数:只拉最新 N 条,向上滚动触顶时再拉更早的。 +var CHAT_PAGE_SIZE = 40; + async function loadChatHistory() { try { - var data = await api("/chat/history"); + var data = await api("/chat/history?limit=" + CHAT_PAGE_SIZE); if (data && data.messages) { state.messages = data.messages; + state.chatOffset = typeof data.offset === "number" ? data.offset : 0; + state.chatTotal = + typeof data.total === "number" ? data.total : data.messages.length; + state.chatHasMore = !!data.has_more; if (window.homeagent && window.homeagent.log) - window.homeagent.log("history: loaded " + data.messages.length); + window.homeagent.log( + "history: loaded " + + data.messages.length + + "/" + + state.chatTotal + + (state.chatHasMore ? " (has more)" : ""), + ); } else if (window.homeagent && window.homeagent.log) { window.homeagent.log("history: no messages field"); } @@ -2367,35 +2387,101 @@ async function loadChatHistory() { } } +// loadOlderChat 向上翻页:拉 offset 之前的一页,前置到 messages 头部。 +// 保持滚动位置补偿,避免视口跳动。 +var _loadingOlder = false; +async function loadOlderChat() { + if (_loadingOlder || !state.chatHasMore) return; + _loadingOlder = true; + var msgsEl = document.getElementById("chat-msgs"); + var prevH = msgsEl ? msgsEl.scrollHeight : 0; + var prevTop = msgsEl ? msgsEl.scrollTop : 0; + try { + var before = state.chatOffset || 0; + if (before <= 0) { + state.chatHasMore = false; + return; + } + var data = await api( + "/chat/history?limit=" + CHAT_PAGE_SIZE + "&before=" + before, + ); + if (data && data.messages && data.messages.length) { + state.messages = data.messages.concat(state.messages); + state.chatOffset = + typeof data.offset === "number" ? data.offset : 0; + state.chatHasMore = !!data.has_more; + state.chatStick = false; + rerenderChatIfActive(); + if (msgsEl) { + msgsEl.scrollTop = prevTop + (msgsEl.scrollHeight - prevH); + } + if (window.homeagent && window.homeagent.log) + window.homeagent.log( + "history: older " + data.messages.length + " (offset=" + state.chatOffset + ")", + ); + } else { + state.chatHasMore = false; + } + } catch (e) { + } finally { + _loadingOlder = false; + } +} + // syncChatFromHistory 增量同步:对比服务端历史,仅追加新消息 DOM 节点, // 不重建已有消息 → 无闪烁。用于 SSE 断连恢复期间的轮询兜底(跨渠道消息补偿)。 function syncChatFromHistory() { - return api("/chat/history").then(function (data) { - if (!data || !data.messages || data.messages.length === 0) return; - var serverMsgs = data.messages; - var localMsgs = state.messages; - if (localMsgs.length === 0) { - state.messages = serverMsgs; - rerenderChatIfActive(); - return; - } - if (serverMsgs.length <= localMsgs.length) { + return api("/chat/history?limit=" + CHAT_PAGE_SIZE) + .then(function (data) { + if (!data || !data.messages || data.messages.length === 0) return; + var serverMsgs = data.messages; + var localMsgs = state.messages; + // 首次加载(空列表)→ 全量赋值 + if (localMsgs.length === 0) { + state.messages = serverMsgs; + state.chatOffset = typeof data.offset === "number" ? data.offset : 0; + state.chatHasMore = !!data.has_more; + rerenderChatIfActive(); + return; + } + // 分段拉取只回传最新页,本地可能已向上翻页加载更多, + // 因此不能用长度比较,改用末尾内容比对 + 重叠区对齐。 var lastLocal = localMsgs[localMsgs.length - 1]; var lastServer = serverMsgs[serverMsgs.length - 1]; var localContent = lastLocal.content || lastLocal.Content || ""; var serverContent = lastServer.content || lastServer.Content || ""; - if (lastServer.role === "assistant" && localContent !== serverContent && serverContent) { - lastLocal.content = serverContent; - if (lastServer.ReasoningContent) lastLocal.reasoning_content = lastServer.ReasoningContent; + // 末尾一致 → 无新增 + if (localContent === serverContent) return; + // 寻找重叠点(本地末尾 k 条在服务端页中的位置) + var overlap = -1; + var maxK = Math.min(3, serverMsgs.length - 1, localMsgs.length - 1); + for (var k = maxK; k >= 1; k--) { + var sMsg = serverMsgs[serverMsgs.length - 1 - k]; + var lMsg = localMsgs[localMsgs.length - 1 - k]; + if ( + lMsg && + sMsg && + (lMsg.content || "") === (sMsg.content || "") && + lMsg.role === sMsg.role + ) { + overlap = k; + break; + } + } + if (overlap >= 0) { + var newMsgs = serverMsgs.slice(serverMsgs.length - overlap); + if (newMsgs.length === 0) return; + Array.prototype.push.apply(state.messages, newMsgs); + rerenderChatIfActive(); + } else { + // 无重叠点(本地领先过多/已失同步)→ 安全退化为全量刷新最新页 + state.messages = serverMsgs; + state.chatOffset = typeof data.offset === "number" ? data.offset : 0; + state.chatHasMore = !!data.has_more; rerenderChatIfActive(); } - return; - } - // 服务端消息更多 → 仅追加新消息对象(不重建已有 DOM) - var newMsgs = serverMsgs.slice(localMsgs.length); - Array.prototype.push.apply(state.messages, newMsgs); - rerenderChatIfActive(); - }).catch(function () {}); + }) + .catch(function () {}); } async function loadTerminals() { diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html index a25ec7f..e6efb0e 100644 --- a/internal/plugins/webui/dashboard.html +++ b/internal/plugins/webui/dashboard.html @@ -2258,6 +2258,9 @@ pendingTools: [], eventSource: null, chatFinalIdx: -1, + chatOffset: 0, // 分段历史:当前已加载消息在服务端全量中的起始下标 + chatTotal: 0, // 服务端历史总条数 + chatHasMore: false, // 是否还有更早历史可向上加载 lang: localStorage.getItem("ha-lang") || "zh", }; @@ -2996,6 +2999,10 @@ state.chatStick = msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight < 80; + // 触顶(近顶部 60px)且服务端还有更早历史 → 向上懒加载下一页 + if (msgsEl.scrollTop < 60 && state.chatHasMore) { + loadOlderChat(); + } }, { passive: true }, ); @@ -4199,47 +4206,116 @@ if (tab === "context") queryMemoryContext(); } + // 首屏分段加载条数:只拉最新 N 条,向上滚动触顶时再拉更早的。 + var CHAT_PAGE_SIZE = 40; async function loadChatHistory() { try { - var data = await api("/chat/history"); - if (data && data.messages) state.messages = data.messages; + var data = await api("/chat/history?limit=" + CHAT_PAGE_SIZE); + if (data && data.messages) { + state.messages = data.messages; + state.chatOffset = typeof data.offset === "number" ? data.offset : 0; + state.chatTotal = typeof data.total === "number" ? data.total : data.messages.length; + state.chatHasMore = !!data.has_more; + } } catch (e) {} } + // loadOlderChat 向上翻页:拉 offset 之前的一页,前置到 messages 头部。 + // 保持滚动位置(插入前后 scrollHeight 差值补偿),避免视口跳动。 + var _loadingOlder = false; + async function loadOlderChat() { + if (_loadingOlder || !state.chatHasMore) return; + _loadingOlder = true; + var msgsEl = document.getElementById("chat-msgs"); + var prevH = msgsEl ? msgsEl.scrollHeight : 0; + var prevTop = msgsEl ? msgsEl.scrollTop : 0; + try { + var before = state.chatOffset || 0; + if (before <= 0) { + state.chatHasMore = false; + return; + } + var data = await api( + "/chat/history?limit=" + CHAT_PAGE_SIZE + "&before=" + before, + ); + if (data && data.messages && data.messages.length) { + state.messages = data.messages.concat(state.messages); + state.chatOffset = typeof data.offset === "number" ? data.offset : 0; + state.chatHasMore = !!data.has_more; + state.chatStick = false; // 向上翻页时不自动粘底 + renderChat(); + if (msgsEl) { + // 补偿新增内容高度,保持用户视觉位置不变 + msgsEl.scrollTop = prevTop + (msgsEl.scrollHeight - prevH); + } + } else { + state.chatHasMore = false; + } + } catch (e) { + } finally { + _loadingOlder = false; + } + } + // syncChatFromHistory 增量同步:对比服务端历史,仅追加新消息 DOM 节点, // 不重建已有消息 → 无闪烁。用于 SSE 断连恢复期间的轮询兜底。 function syncChatFromHistory() { - return api("/chat/history").then(function (data) { + return api("/chat/history?limit=" + CHAT_PAGE_SIZE).then(function (data) { if (!data || !data.messages || data.messages.length === 0) return; var serverMsgs = data.messages; var localMsgs = state.messages; - // 空历史 → 全量加载(首次同步) + // 首次加载(空列表)→ 全量赋值 if (localMsgs.length === 0) { state.messages = serverMsgs; + state.chatOffset = typeof data.offset === "number" ? data.offset : 0; + state.chatHasMore = !!data.has_more; rerenderChat(true); return; } - // 无新增消息 → 检查最后一条是否被改写 - if (serverMsgs.length <= localMsgs.length) { - var lastLocal = localMsgs[localMsgs.length - 1]; - var lastServer = serverMsgs[serverMsgs.length - 1]; - var localContent = lastLocal.content || lastLocal.Content || ""; - var serverContent = lastServer.content || lastServer.Content || ""; + // 以「本地末尾 vs 服务端末尾」做依据。分段拉取只会回传最新页, + // 本地已向上翻页加载了更早内容,长度上本地可能 ≥ 服务端。 + var lastLocal = localMsgs[localMsgs.length - 1]; + var lastServer = serverMsgs[serverMsgs.length - 1]; + var localContent = lastLocal.content || lastLocal.Content || ""; + var serverContent = lastServer.content || lastServer.Content || ""; + // 服务端末尾和本地末尾内容相同 → 无新增(可能裁剪过的轻量项因不含 tool_calls args/result, + // 依赖 content+role+time 足以识别) + if (localContent === serverContent) { + // 检查是否本地末尾被服务端改写(如 stage 插件改写最终文本) if (lastServer.role === "assistant" && localContent !== serverContent && serverContent) { lastLocal.content = serverContent; if (lastServer.ReasoningContent) lastLocal.reasoning_content = lastServer.ReasoningContent; - // 仅更新最后一条消息 DOM,不全量重建 - var msgsEl = document.getElementById("chat-msgs"); - if (msgsEl && msgsEl.lastElementChild) { - var el = msgsEl.lastElementChild; - var textEl = el.querySelector(".msg-bubble .text"); - if (textEl) textEl.innerHTML = renderMd(serverContent); + var msgsEl0 = document.getElementById("chat-msgs"); + if (msgsEl0 && msgsEl0.lastElementChild) { + var textEl0 = msgsEl0.lastElementChild.querySelector(".msg-bubble .text"); + if (textEl0) textEl0.innerHTML = renderMd(serverContent); } } return; } - // 有新增消息:追加到 state.messages + DOM(仅追加节点,不触碰已有) - var newMsgs = serverMsgs.slice(localMsgs.length); + // 有新增消息:服务端末尾与本地末尾不同 → 找出本地新增的差异,追加到 DOM。 + // 服务端只回传最新页,我们需在 localMsgs 末尾取交集起点(本地末尾在服务端页的位置) + // 简化:取服务端末尾3条与本地末尾3条比对,找到重含点的下标;若找不到全量替换最新页。 + var overlap = -1; + for (var k = Math.min(3, serverMsgs.length - 1, localMsgs.length - 1); k >= 1; k--) { + var sMsg = serverMsgs[serverMsgs.length - 1 - k]; + var lMsg = localMsgs[localMsgs.length - 1 - k]; + if (lMsg && (lMsg.content || "") === (sMsg.content || "") && lMsg.role === sMsg.role) { + overlap = k; + break; + } + } + var newMsgs; + if (overlap >= 0) { + // 本地末尾 overlap 条在服务端页里,之后的部分是新增 + newMsgs = serverMsgs.slice(serverMsgs.length - overlap); + if (newMsgs.length === 0) return; // 无新增(内容改写走上面的分支) + } else { + // 找不到重含点(本地领先太多,超出服务端页)→ 无法精确差异,安全退化为全量刷新 + state.messages = serverMsgs; + rerenderChat(true); + return; + } var msgsEl = document.getElementById("chat-msgs"); if (msgsEl) { var aiAvatar = '小宅'; diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 95ecde6..205c2df 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -174,6 +174,9 @@ type ChatToolCall struct { Result interface{} `json:"result,omitempty"` Status string `json:"status,omitempty"` Plugin string `json:"plugin,omitempty"` + // Truncated 标记本条工具调用的 args/result 已被服务端裁剪(lean 模式), + // 前端可据此显示「详情需展开加载」而不是把空结果当成执行失败。 + Truncated bool `json:"truncated,omitempty"` } type CmdExec struct { @@ -1382,12 +1385,87 @@ func (h *Handler) addChatMsg(msg ChatMsg) { h.chatMu.Unlock() } +// handleChatHistory 返回对话历史,支持分段懒加载。 +// +// 查询参数(全部可选,省略时保持旧行为=返回全量,向后兼容旧客户端): +// - limit: 返回条数上限(1..maxChatHistory)。带 limit 时默认取「最新的 limit 条」。 +// - before: 游标,只返回下标 < before 的消息(配合 limit 向上翻页取更早历史)。 +// - lean: "1"/"true" 时裁剪重负载字段(tool_calls 的 args/result 置空、reasoning_content 省略), +// 体积可降约 80%(实测 tool_calls 占 ~72%、reasoning ~12%)。用于移动端/弱网首屏。 +// +// 响应额外返回 total / offset / has_more,供前端判断是否继续向上加载。 func (h *Handler) handleChatHistory(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + limit := parseIntDefault(q.Get("limit"), 0) + if limit < 0 { + limit = 0 + } + if limit > maxChatHistory { + limit = maxChatHistory + } + lean := q.Get("lean") == "1" || strings.EqualFold(q.Get("lean"), "true") + h.chatMu.Lock() - result := make([]ChatMsg, len(h.chatHistory)) - copy(result, h.chatHistory) + total := len(h.chatHistory) + // before 游标:默认取到末尾(最新) + end := parseIntDefault(q.Get("before"), total) + if end < 0 || end > total { + end = total + } + start := 0 + if limit > 0 && end-limit > 0 { + start = end - limit + } + result := make([]ChatMsg, end-start) + copy(result, h.chatHistory[start:end]) h.chatMu.Unlock() - writeJSON(w, http.StatusOK, map[string]interface{}{"messages": result}) + + if lean { + result = leanChatMsgs(result) + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "messages": result, + "total": total, + "offset": start, + "has_more": start > 0, + }) +} + +// parseIntDefault 解析十进制整数,失败/空串返回 def。 +func parseIntDefault(s string, def int) int { + if s == "" { + return def + } + n, err := strconv.Atoi(s) + if err != nil { + return def + } + return n +} + +// leanChatMsgs 裁剪重负载字段用于移动端/弱网首屏: +// tool_calls 只保留 tool/name/status/plugin(args/result 置 nil 并标记 truncated), +// reasoning_content 整体省略。前端需要完整内容时按 before/limit 拉非 lean 分段。 +func leanChatMsgs(in []ChatMsg) []ChatMsg { + out := make([]ChatMsg, len(in)) + for i, m := range in { + m.ReasoningContent = "" + if len(m.ToolCalls) > 0 { + tcs := make([]ChatToolCall, len(m.ToolCalls)) + for j, tc := range m.ToolCalls { + tcs[j] = ChatToolCall{ + Tool: tc.Tool, + Name: tc.Name, + Status: tc.Status, + Plugin: tc.Plugin, + Truncated: true, + } + } + m.ToolCalls = tcs + } + out[i] = m + } + return out } // handleChatFile 处理用户经 webui 上传文件并附带消息注入 agent。