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 0beb389223
commit c4998bb102
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) {

View File

@ -7,6 +7,8 @@ import (
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@ -1076,10 +1078,15 @@ func TestSettingsNoCrossPluginLeak(t *testing.T) {
if pw.Code != http.StatusBadRequest {
t.Fatalf("写内部键应被拒400实际 %d", pw.Code)
}
got, _ := webuiCfg.Get("chathistory")
if got != `[{"role":"assistant","content":"secret blob"}]` {
// 迁移已把这条记录搬去独立文件并从配置表移除;若仍在,则必须是原值(未被改写)
if got, _ := webuiCfg.Get("chathistory"); got != nil && got != `[{"role":"assistant","content":"secret blob"}]` {
t.Fatalf("内部数据被改写:%v", got)
}
// 记录本身必须没有丢:迁移后的文件里应能找到它
msgs := h.history.Load()
if len(msgs) != 1 || msgs[0].Content != "secret blob" {
t.Fatalf("迁移后记录不应丢失,实际 %+v文件 %s", msgs, h.history.Path())
}
}
// TestListenOverrideAndBindFailure 钉住两个曾经静默的缺陷:
@ -1153,3 +1160,89 @@ func TestResolveListenAddrPrecedence(t *testing.T) {
t.Fatalf("覆盖值应优先,得到 %q", got)
}
}
// TestResolveHistoryFile 钉住聊天记录路径的解析规则:
// 插件设置优先、相对路径按 data 目录解析、绝对路径原样、留空走默认。
func TestResolveHistoryFile(t *testing.T) {
cases := []struct{ setting, dataDir, want string }{
{"", "/data", "/data/webui_chat_history.json"},
{"chat.json", "/data", "/data/chat.json"},
{"sub/chat.json", "/data", "/data/sub/chat.json"},
{"/mnt/ssd/chat.json", "/data", "/mnt/ssd/chat.json"},
{" ", "/data", "/data/webui_chat_history.json"},
}
for _, c := range cases {
if got := resolveHistoryFile(c.setting, c.dataDir); got != c.want {
t.Errorf("resolveHistoryFile(%q, %q) = %q, want %q", c.setting, c.dataDir, got, c.want)
}
}
// data 目录未知时不得落到进程 CWD测试/嵌入场景会污染工作目录)
if got := resolveHistoryFile("", ""); filepath.Dir(got) != strings.TrimRight(os.TempDir(), "/") {
t.Errorf("data 目录未知时应落到临时目录,实际 %q", got)
}
}
// TestHistoryStoreMigratesFromConfig 钉住从「配置项存整段记录」到「独立文件」的迁移:
// 记录必须完好搬到文件、老配置项必须从配置表消失(它正是 config.db 膨胀与设置接口
// 大响应的来源),且第二次加载不再重复迁移。
func TestHistoryStoreMigratesFromConfig(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "chat.json")
cfgReg := internalConfig.NewConfigRegistry("")
webuiCfg := cfgReg.PluginConfig("webui")
webuiCfg.RegisterDef(internalConfig.ConfigDef{Key: "chathistory", Default: ""})
legacy := []ChatMsg{
{Role: "user", Content: "老记录 1", Time: "2026-01-01T00:00:00Z"},
{Role: "assistant", Content: "老记录 2", Time: "2026-01-01T00:00:01Z"},
}
b, _ := json.Marshal(legacy)
webuiCfg.Set("chathistory", string(b))
settings := sdk.NewSettings("webui", cfgReg)
hs := newHistoryStore(file)
got := hs.LoadWithMigration(settings)
if len(got) != 2 || got[0].Content != "老记录 1" || got[1].Content != "老记录 2" {
t.Fatalf("迁移后应拿到 2 条老记录,实际 %+v", got)
}
// 文件已落盘
onDisk := newHistoryStore(file).Load()
if len(onDisk) != 2 {
t.Fatalf("记录应写入 %s实际 %+v", file, onDisk)
}
// 老配置项必须消失(否则 config.db 里那 5MB 还在)
if v, _ := webuiCfg.Get("chathistory"); v != nil {
t.Fatalf("迁移后老配置项应被删除,实际仍为 %v", v)
}
// 二次加载:直接读文件,不重复迁移
hs2 := newHistoryStore(file)
if again := hs2.LoadWithMigration(settings); len(again) != 2 {
t.Fatalf("二次加载应仍为 2 条,实际 %+v", again)
}
// 文件损坏时按空历史处理,不得 panic
if err := os.WriteFile(file, []byte("{不是 JSON"), 0644); err != nil {
t.Fatal(err)
}
if broken := newHistoryStore(file).Load(); len(broken) != 0 {
t.Fatalf("损坏文件应按空历史处理,实际 %+v", broken)
}
}
// TestHistoryStoreSaveIsAtomicAndRoundTrips 钉住原子写:不留 .tmp、内容可回读。
func TestHistoryStoreSaveIsAtomicAndRoundTrips(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "sub", "chat.json") // 目录不存在Save 需自建
hs := newHistoryStore(file)
msgs := []ChatMsg{{Role: "user", Content: "你好", Time: "2026-01-01T00:00:00Z"}}
if err := hs.Save(msgs); err != nil {
t.Fatalf("Save: %v", err)
}
if _, err := os.Stat(file + ".tmp"); !os.IsNotExist(err) {
t.Fatalf("不应残留 %s.tmp", file)
}
got := hs.Load()
if len(got) != 1 || got[0].Content != "你好" {
t.Fatalf("回读不一致:%+v", got)
}
}

View File

@ -0,0 +1,165 @@
package webui
import (
"encoding/json"
"log"
"os"
"path/filepath"
"strings"
"sync"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// defaultHistoryFile 是聊天记录的默认文件名(落在 <data> 下)。
const defaultHistoryFile = "webui_chat_history.json"
// historyStore 把聊天记录存在**独立文件**里。
//
// 背景:原先聊天记录是插件配置项 plugin.webui.chathistory存在 config.db 的
// config_webui 表里,带来三个后果:
//
// 1. 整段记录(生产实例实测 5,176,016 字节)会被 GET /api/v1/settings
// 当普通配置项整块返回(那次响应实测 8,244,108 字节);
// 2. 每来一条消息就把整段记录重新 marshal 后 UPDATE 回 config 表,而那次写要拿
// config registry 的**全局写锁** —— 消息频繁时所有配置读写都被拖着排队;
// 3. 存放位置不可配(想放挂载盘只能改整个 data_dir
//
// 现在改为独立文件:默认 <data>/webui_chat_history.json插件设置 history_file
// 可指定(相对路径按 data 目录解析)。写盘用 tmp+rename 原子替换,
// 避免进程被杀时留下半截 JSON。
type historyStore struct {
mu sync.Mutex
path string
}
func newHistoryStore(path string) *historyStore { return &historyStore{path: path} }
// Path 返回记录文件路径(用于日志与状态展示)。
func (hs *historyStore) Path() string { return hs.path }
// Load 读回全部记录。文件不存在返回 nil内容损坏时按空历史处理并告警 ——
// 聊天记录不是关键数据,不该因为它让 WebUI 起不来。
func (hs *historyStore) Load() []ChatMsg {
hs.mu.Lock()
defer hs.mu.Unlock()
b, err := os.ReadFile(hs.path)
if err != nil {
if !os.IsNotExist(err) {
log.Printf("[webui] 读取聊天记录 %s 失败: %v", hs.path, err)
}
return nil
}
var msgs []ChatMsg
if err := json.Unmarshal(b, &msgs); err != nil {
log.Printf("[webui] 聊天记录 %s 解析失败(按空历史处理): %v", hs.path, err)
return nil
}
return msgs
}
// Save 原子写回全部记录(同目录 tmp + rename
func (hs *historyStore) Save(msgs []ChatMsg) error {
b, err := json.Marshal(msgs)
if err != nil {
return err
}
hs.mu.Lock()
defer hs.mu.Unlock()
if dir := filepath.Dir(hs.path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
}
tmp := hs.path + ".tmp"
if err := os.WriteFile(tmp, b, 0644); err != nil {
return err
}
return os.Rename(tmp, hs.path)
}
// LoadWithMigration 返回聊天记录:以文件为准,并顺手收拾老版本的配置项。
//
// - 文件里已有记录:老配置项若还在(上次迁移没删掉),直接清掉;
// - 文件为空/不存在但老配置项有内容:把记录搬到文件,搬成功后删除配置项
// —— 这条 5MB 的记录正是 config.db 膨胀与设置接口大响应的来源。
func (hs *historyStore) LoadWithMigration(settings sdk.SettingsAPI) []ChatMsg {
if msgs := hs.Load(); len(msgs) > 0 {
if settings != nil {
if v, err := settings.Get("chathistory"); err == nil && v != nil {
if err := settings.Remove("chathistory"); err == nil {
log.Printf("[webui] 已清理历史遗留的配置项 chathistory记录现存放于 %s", hs.path)
}
}
}
return msgs
}
return migrateLegacyHistory(settings, hs)
}
// migrateLegacyHistory 把老配置项里的整段聊天记录搬到文件,成功后删掉那条配置。
func migrateLegacyHistory(settings sdk.SettingsAPI, hs *historyStore) []ChatMsg {
if settings == nil {
return nil
}
v, err := settings.Get("chathistory")
if err != nil || v == nil {
return nil
}
raw, ok := v.(string)
if !ok || strings.TrimSpace(raw) == "" {
// 空值也算遗留(占着设置页一行),直接删掉。
_ = settings.Remove("chathistory")
return nil
}
var msgs []ChatMsg
if err := json.Unmarshal([]byte(raw), &msgs); err != nil {
log.Printf("[webui] 历史配置项无法解析,保留原值不迁移: %v", err)
return nil
}
if err := hs.Save(msgs); err != nil {
log.Printf("[webui] 聊天记录迁移到 %s 失败(保留原配置项): %v", hs.path, err)
return nil
}
if err := settings.Remove("chathistory"); err != nil {
log.Printf("[webui] 聊天记录已迁移到 %s但旧配置项删除失败: %v", hs.path, err)
} else {
log.Printf("[webui] 聊天记录已迁移到独立文件 %s%d 条),并从插件配置表移除", hs.path, len(msgs))
}
return msgs
}
// resolveHistoryFile 解析聊天记录文件路径:
// 插件设置 history_file非空> 默认 <data>/webui_chat_history.json。
// 相对路径按 data 目录解析,便于把记录放到独立挂载盘。
func resolveHistoryFile(setting, dataDir string) string {
if s := strings.TrimSpace(setting); s != "" {
if filepath.IsAbs(s) {
return s
}
if dataDir == "" {
dataDir = os.TempDir()
}
return filepath.Join(dataDir, s)
}
if dataDir == "" {
// data 目录未知(嵌入/测试场景):落到系统临时目录,别污染进程当前目录。
dataDir = os.TempDir()
}
return filepath.Join(dataDir, defaultHistoryFile)
}
// settingString 读一个字符串型插件设置(读不到/类型不符都返回空串)。
func settingString(s sdk.SettingsAPI, key string) string {
if s == nil {
return ""
}
v, err := s.Get(key)
if err != nil || v == nil {
return ""
}
if str, ok := v.(string); ok {
return str
}
return ""
}

View File

@ -49,6 +49,9 @@ func randomSecret(n int) string {
// 由插件 Start 时从 daemon.data_dir 推导注入。
var webFilesDir string
// webDataDir 是 <data> 根目录(同上推导),供聊天记录等路径解析使用。
var webDataDir string
// uploadsDir 是用户经 webui 上传文件的存储目录(<data>/uploads
// handleChatFile 落盘、handleUploads 下载共用;参考 qq 插件 files_dir 收文件设计。
var uploadsDir string
@ -165,6 +168,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
// 中转目录:<data>/webui_filesagent 发送 image/file 时拷贝至此
if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil {
if s2, ok := dd.(string); ok && s2 != "" {
webDataDir = s2
webFilesDir = filepath.Join(s2, "webui_files")
uploadsDir = filepath.Join(s2, "uploads")
}
@ -222,6 +226,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "addr", Default: ":8080", Type: "string", DisplayName: "监听地址", Description: "Web 控制台监听地址", Category: "webui"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "history_file", Default: "", Type: "string", DisplayName: "聊天记录文件", Description: "聊天记录存放路径。留空 = <data>/webui_chat_history.json相对路径按 data 目录解析(可指向独立挂载盘)", Category: "webui"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "api_key", Default: "", Type: "password", DisplayName: "API 密钥", Description: "访问 API 时需要的密钥", Category: "webui"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "username", Default: "admin", Type: "string", DisplayName: "登录用户名", Description: "Web 控制台登录用户名", Category: "webui"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "password", Default: "", Type: "password", DisplayName: "Web 控制台登录密码", Description: "Web 控制台登录密码", Category: "webui"})