perf(webui): /chat/history 分段懒加载 + lean 瘦身模式

问题:/api/v1/chat/history 无分页返回全量 1.26MB(200条),移动端/弱网首屏很慢。
实测体积构成:tool_calls args/result 占 71.8%,reasoning_content 11.6%,content 仅 3.4%。

后端 handler.go:
- 新增查询参数 limit(1..maxChatHistory)/ before(游标)/ lean(瘦身)
- 全部可选,省略时返回全量 → 向后兼容旧客户端
- 响应加 total/offset/has_more,供前端判断能否继续向上加载
- lean=1 裁剪 tool_calls 的 args/result 并置 truncated 标记、省略 reasoning_content
- ChatToolCall 新增 Truncated 字段(前端可显示'详情需展开加载'而非误判执行失败)

前端 WebUI + GUI(两端同步):
- 首屏只拉最新 40 条(CHAT_PAGE_SIZE),1.26MB → 48.5KB(lean 26KB)
- 新增 loadOlderChat():滚动触顶(<60px)自动拉上一页,插入后按 scrollHeight 差值补偿
  滚动位置避免视口跳动
- state 新增 chatOffset/chatTotal/chatHasMore
- syncChatFromHistory 改用重叠区对齐(分段后不能再用长度比较判断新增),
  找不到重叠点安全退化为全量刷新最新页

实测:无参数 1287.9KB / limit=40 48.5KB / limit=40&lean=1 26.0KB;
before 游标翻页、limit 越界/非法值、before=0 等边界均正确
This commit is contained in:
JianFeeeee
2026-08-28 23:21:13 +08:00
parent 5fccc31afc
commit a0f7c9b5eb
3 changed files with 283 additions and 43 deletions

View File

@ -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/pluginargs/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。