mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 21:33:05 +00:00
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:
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user