diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index b5b48f5..8a951f6 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -141,6 +141,10 @@ type Handler struct { chatHistory []ChatMsg pendingIdx int // chatHistory 中正在进行的 assistant 消息索引,-1 表示无 + // history 是聊天记录的独立存储(默认 /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), + // 聊天记录独立存储:默认 /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) { diff --git a/internal/plugins/webui/handler_test.go b/internal/plugins/webui/handler_test.go index 3aa2fc4..4dd1483 100644 --- a/internal/plugins/webui/handler_test.go +++ b/internal/plugins/webui/handler_test.go @@ -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) + } +} diff --git a/internal/plugins/webui/history.go b/internal/plugins/webui/history.go new file mode 100644 index 0000000..a92f59b --- /dev/null +++ b/internal/plugins/webui/history.go @@ -0,0 +1,165 @@ +package webui + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "strings" + "sync" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// defaultHistoryFile 是聊天记录的默认文件名(落在 下)。 +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)。 +// +// 现在改为独立文件:默认 /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(非空)> 默认 /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 "" +} diff --git a/internal/plugins/webui/plugin.go b/internal/plugins/webui/plugin.go index 9e11712..9cd87f2 100644 --- a/internal/plugins/webui/plugin.go +++ b/internal/plugins/webui/plugin.go @@ -49,6 +49,9 @@ func randomSecret(n int) string { // 由插件 Start 时从 daemon.data_dir 推导注入。 var webFilesDir string +// webDataDir 是 根目录(同上推导),供聊天记录等路径解析使用。 +var webDataDir string + // uploadsDir 是用户经 webui 上传文件的存储目录(/uploads)。 // handleChatFile 落盘、handleUploads 下载共用;参考 qq 插件 files_dir 收文件设计。 var uploadsDir string @@ -165,6 +168,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { // 中转目录:/webui_files,agent 发送 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: "聊天记录存放路径。留空 = /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"})