feat(webui): 聊天记录改为独立文件存储(位置可配)+ 存量自动迁移

起因:聊天记录原先作为插件配置项 plugin.webui.chathistory 存在 config.db 里,
带来三个后果(都在生产实例上实测过):
  1. 整段记录 5,176,016 字节会被 GET /api/v1/settings 当普通配置项整块返回;
  2. 每来一条消息就把整段记录重新 marshal 后写回 config 表,而那次写要拿
     config registry 的全局写锁 —— 消息频繁时所有配置读写都被拖着排队;
  3. 位置不可配(想放独立挂载盘只能改整个 data_dir)。

改动:
* 新增 internal/plugins/webui/history.go:
  - historyStore:默认 <data>/webui_chat_history.json,写盘用同目录 tmp+rename
    原子替换,崩溃不会留半截 JSON;读失败/JSON 损坏按空历史处理并告警
    (聊天记录不是关键数据,不该让它拖垮 WebUI)。
  - resolveHistoryFile:插件设置 history_file > 默认路径;相对路径按 data 目录
    解析(可指向独立挂载盘),data 目录未知时落到系统临时目录而不是进程 CWD。
  - LoadWithMigration:文件为准;文件为空而老配置项有内容时,把记录搬到文件、
    搬成功才删配置项(删不掉就保留并告警,不丢数据);文件已有数据时顺手清掉
    上次没删干净的遗留键。
* 新增插件设置项 history_file(设置页可见可改):留空 = 默认路径。
* handler.go:chatHistory 的读/写改走 historyStore,不再碰 settings;
  顺带把「写失败静默忽略」改成告警。

存量迁移实测(拿仍持有 5,271,690 字节老记录的实例跑新二进制):
  日志:聊天记录已迁移到独立文件 .../webui_chat_history.json(1300 条),并从插件配置表移除
  迁移后:config_webui 里 chathistory 行数 = 0;记录文件 5,279,491 字节
          GET /api/v1/settings = 33,921 字节(迁移前 8,244,108)
          GET /api/v1/chat/history 正常(从文件读回 1300 条里最新的 3 条)

新增测试:TestResolveHistoryFile、TestHistoryStoreMigratesFromConfig(含二次加载
不重复迁移 + 损坏文件不 panic)、TestHistoryStoreSaveIsAtomicAndRoundTrips。
This commit is contained in:
HomeAgent Agent
2026-09-14 07:00:07 +08:00
parent b23644ad74
commit 0db5a31de0
4 changed files with 280 additions and 16 deletions

View File

@ -141,6 +141,10 @@ type Handler struct {
chatHistory []ChatMsg
pendingIdx int // chatHistory 中正在进行的 assistant 消息索引,-1 表示无
// history 是聊天记录的独立存储(默认 <data>/webui_chat_history.json,
// 插件设置 history_file 可改)。
history *historyStore
chatMsgMu sync.Mutex
chatMsgCache map[string]*chatMsgEntry // client_msg_id -> 首次处理结果
chatMsgOrder []string // FIFO 淘汰序
@ -278,6 +282,9 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
pendingIdx: -1,
chatMsgCache: make(map[string]*chatMsgEntry),
sseEvents: newSSEEventRing(200),
// 聊天记录独立存储:默认 <data>/webui_chat_history.json,
// 插件设置 history_file 可改(相对路径按 data 目录解析)。
history: newHistoryStore(resolveHistoryFile(settingString(se, "history_file"), webDataDir)),
}
h.loadChatHistory()
if s != nil {
@ -325,19 +332,11 @@ func (h *Handler) subscribeTerminalStream() {
}
func (h *Handler) loadChatHistory() {
if h.settings == nil {
if h.history == nil {
return
}
v, err := h.settings.Get("chathistory")
if err != nil || v == nil {
return
}
s, ok := v.(string)
if !ok || s == "" {
return
}
var msgs []ChatMsg
if err := json.Unmarshal([]byte(s), &msgs); err != nil {
msgs := h.history.LoadWithMigration(h.settings)
if len(msgs) == 0 {
return
}
h.chatMu.Lock()
@ -538,11 +537,13 @@ func (h *Handler) pendingAssistantLocked() *ChatMsg {
}
func (h *Handler) persistChatLocked() {
if h.settings == nil {
if h.history == nil {
return
}
b, _ := json.Marshal(h.chatHistory)
_ = h.settings.Set("chathistory", string(b))
// 写独立文件(原子替换)。失败只告警:聊天记录不该影响对话主流程。
if err := h.history.Save(h.chatHistory); err != nil {
log.Printf("[webui] 写聊天记录 %s 失败: %v", h.history.Path(), err)
}
}
func (h *Handler) handleToolEvent(ev *sdk.Event) {