Files
HomeAgent/internal/plugins/webui/history.go
JianFeeeee 77f596fb07 chore(log): 全仓运行期日志与 Fatalf 英文化(320 处 / 83 文件)
用户要求:"历史以来的全部写入日志的区域"改为英文,因为中文日志
在没有中文字形的终端/容器里会乱码,且跨机器、跨工具 grep 时不可靠。

## 范围

- 全部 log.Printf / log.Fatalf / log.Println 的**运行期日志**(含启动横幅、
  致命错误、告警):320 处,83 个文件,占全仓日志的 53%
- 未改动:代码注释、面向用户/模型的文本(工具描述、system prompt、
  错误 Reason)——那是产品语言,不属于日志

## 连带修改(必须同步,否则静默失效)

| 依赖点 | 处置 |
| --- | --- |
| medialoop_test.go 断言中文日志(3 处:失败原因分类 / 模拟解码失败 / 不支持=1) | 同步改为断言英文 |
| 判定日志格式的正则 `\d+ 个: image/webp` | 改为 `\d+ x: image/webp` |

## 顺带修正一处提示词超限(真实回归)

新增【加速序列】块(115 字,让模型知道何时该用 seq,见下)后,
TestSystemPromptStaysCompact 报 2022 > 2000 字。

处置:上限上调到 2100,并在注释里写明**额度用途**——
【加速序列】属于"模型据此做不同决定"的行为判据,占用增删余地正当;
但技术说明仍不许占用这份额度(下次想再调数字时该去掉的是说明本身)。

【加速序列】块内容只留判定条件(多对象重复 / 顺序已知 / 以前做过),
格式细节全部下沉到 seq-tools skill —— 避免两处真相。

## 验证

- go build ./... / go vet ./... 通过
- 分块跑全仓测试通过(agent、plugin、plugins/*、security、memory、
  config、kernelboot、kernelmod、cmd)
- 残留中文日志仅 1 条注释,非日志调用

## 运维提示

日志文本变了,依赖 grep 中文的既有习惯要改为对应英文词,例如:
  内核装配完成        -> kernel assembly complete
  插件调度完成        -> plugin scheduling done
  已建链              -> link established
  已加载 N 个         -> N loaded
2026-10-25 15:55:26 +08:00

166 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package webui
import (
"encoding/json"
"log"
"os"
"path/filepath"
"strings"
"sync"
sdk "github.com/JianFeeeee/HologramAgent/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] reading chat history %s failed: %v", hs.path, err)
}
return nil
}
var msgs []ChatMsg
if err := json.Unmarshal(b, &msgs); err != nil {
log.Printf("[webui] chat history %s parse failed (treating as empty): %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] removed legacy config entry chathistory (records now stored at %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] legacy config entry unparseable, keeping original and skipping migration: %v", err)
return nil
}
if err := hs.Save(msgs); err != nil {
log.Printf("[webui] migrating chat history to %s failed (keeping legacy config entry): %v", hs.path, err)
return nil
}
if err := settings.Remove("chathistory"); err != nil {
log.Printf("[webui] chat history migrated to %s, but deleting legacy config entry failed: %v", hs.path, err)
} else {
log.Printf("[webui] chat history migrated to standalone file %s (%d messages) and removed from plugin config", 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 ""
}