mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
两条都是我上一封里点出、你说继续的问题。
① 聊天记录:每条消息都整段重写 → 节流合并写
原来 persistChatLocked 每次变更就整段重写记录文件,而一轮对话会触发多次
(用户消息、每个工具事件、收尾消息)。200 条上限下文件可达数 MB,单轮就能
放大出几十 MB 写。文件里还留着一个 chatSaveThrottle=3s 常量——声明了但从未
被使用(疑似上次 revert 的遗留),等于节流从来没生效。
现在:persistChatLocked 只置脏 + 唤醒写盘协程;chatPersistLoop 去抖
chatSaveThrottle(3s)、并以 chatSaveMaxDelay(10s) 兜底(持续输出也不会无限拖延);
写盘前把快照拷出来,**不持 chatMu 做文件 IO**;写失败重新标脏下轮重试。
插件 Stop 里调 Handler.Close():停协程 + 强制落最后一次(幂等),否则丢最后一轮。
实测(临时实例,连发 3 条消息):3s 窗口内记录文件**尚未创建**(节流生效);
SIGTERM 后文件出现且 6 条(3 用户 + 3 助手,无 LLM key 故为错误回复)全在
——关停落盘没丢。
② config.db:SQLite 的 DELETE 不缩文件 → 空闲页够多时 VACUUM
新增 ConfigRegistry.MaybeCompact(minFreeBytes, minRatio):空闲页 >= 1MB 且
占页数 >= 25% 才做一次 VACUUM,避免每次启动都重写整库。库里是 WAL 模式,
VACUUM 之后必须再 wal_checkpoint(TRUNCATE),否则主库文件看着没变小。
调用点放在插件加载**之后**(大值的搬走/删除发生在插件 Start 里,之前调没意义)。
实测(一个刚被搬走 5MB 聊天记录的实例):
freelist 1288 页 × 4096B;启动日志「配置库已压缩: 5394432 -> 118784 字节」
config.db 5,394,432 → 118,784 字节;记录文件 5,279,491 字节完好未动。
测试:TestChatPersistenceIsThrottled(节流窗口内不写盘 + Close 必落盘 + Close 幂等)、
TestMaybeCompactReclaimsFreePages(删大值后文件确实变小 + 数据完好 + 阈值不达标时不白做功)。
307 lines
12 KiB
Go
307 lines
12 KiB
Go
package webui
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
)
|
||
|
||
func init() {
|
||
plugin.RegisterPluginMeta("webui", "Web 控制台", "WebUI")
|
||
plugin.RegisterFactory("webui", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||
return New(name), nil
|
||
})
|
||
}
|
||
|
||
type Plugin struct {
|
||
name string
|
||
handler *Handler
|
||
server *http.Server
|
||
mux *http.ServeMux
|
||
}
|
||
|
||
func New(name string) *Plugin {
|
||
return &Plugin{
|
||
name: name,
|
||
mux: http.NewServeMux(),
|
||
}
|
||
}
|
||
|
||
func randomSecret(n int) string {
|
||
buf := make([]byte, n)
|
||
if _, err := rand.Read(buf); err != nil {
|
||
return ""
|
||
}
|
||
return hex.EncodeToString(buf)
|
||
}
|
||
|
||
// webFilesDir 是 agent 向 webui 发送文件时的中转目录(<data>/webui_files)。
|
||
// 由插件 Start 时从 daemon.data_dir 推导注入。
|
||
var webFilesDir string
|
||
|
||
// webDataDir 是 <data> 根目录(同上推导),供聊天记录等路径解析使用。
|
||
var webDataDir string
|
||
|
||
// uploadsDir 是用户经 webui 上传文件的存储目录(<data>/uploads)。
|
||
// handleChatFile 落盘、handleUploads 下载共用;参考 qq 插件 files_dir 收文件设计。
|
||
var uploadsDir string
|
||
|
||
// listenOverride 是内核在插件加载前给出的监听地址覆盖(CLI --webui,
|
||
// 或核心配置 webui.listen_addr 被显式改成非默认值)。
|
||
//
|
||
// 为什么需要这个旁路:内核曾在插件加载前写 settings["addr"],但那时
|
||
// config_<name> 表还没建,PluginSettings.Set 的 INSERT 会失败且错误被忽略;
|
||
// 随后 plugin Start 里 RegisterDef 才建表并写入默认值 :8080。结果是
|
||
// CLI --webui 与 webui.listen_addr **一直是死配置**。这里改为插件自己
|
||
// 接受一个显式覆盖值,优先级高于 settings["addr"](后者是 Web 设置页的持久值)。
|
||
var listenOverride string
|
||
|
||
// SetListenOverride 设置监听地址覆盖(空值表示不覆盖)。
|
||
// 由 cmd/homed 在插件加载前调用,见 resolveWebUIOverride。
|
||
func SetListenOverride(addr string) {
|
||
listenOverride = strings.TrimSpace(addr)
|
||
}
|
||
|
||
// resolveListenAddr 决定最终监听地址:覆盖值 > 插件设置 > 内置默认。
|
||
// 抽成纯函数是为了能被单测直接钉住优先级。
|
||
func resolveListenAddr(setting string) string {
|
||
addr := ":8080"
|
||
if setting != "" {
|
||
addr = setting
|
||
}
|
||
if listenOverride != "" {
|
||
addr = listenOverride
|
||
}
|
||
return addr
|
||
}
|
||
|
||
// stageWebFile 把 agent 要发送的本地文件拷贝到 webui_files 中转目录,
|
||
// 返回可下载 URL 路径与字节数。image/file 的 payload 支持本地路径或 http(s) URL
|
||
// (URL 直接透传给前端,不落盘)。文件名用随机 UUID 防路径猜测,扩展名保留自源文件。
|
||
func stageWebFile(payload string, isImage bool) (url string, size int64, err error) {
|
||
if strings.HasPrefix(payload, "http://") || strings.HasPrefix(payload, "https://") {
|
||
return payload, 0, nil // 远程 URL 直接透传
|
||
}
|
||
if webFilesDir == "" {
|
||
return "", 0, fmt.Errorf("webui files dir not initialized")
|
||
}
|
||
src := payload
|
||
if _, err := os.Stat(src); err != nil {
|
||
return "", 0, fmt.Errorf("文件不存在: %s", src)
|
||
}
|
||
if err := os.MkdirAll(webFilesDir, 0755); err != nil {
|
||
return "", 0, fmt.Errorf("create webui_files: %w", err)
|
||
}
|
||
buf := make([]byte, 8)
|
||
rand.Read(buf)
|
||
ext := strings.ToLower(filepath.Ext(src))
|
||
if extBad(ext) {
|
||
ext = ".bin"
|
||
}
|
||
name := hex.EncodeToString(buf) + ext
|
||
dst := filepath.Join(webFilesDir, name)
|
||
in, err := os.Open(src)
|
||
if err != nil {
|
||
return "", 0, fmt.Errorf("open source: %w", err)
|
||
}
|
||
defer in.Close()
|
||
out, err := os.Create(dst)
|
||
if err != nil {
|
||
return "", 0, fmt.Errorf("create dest: %w", err)
|
||
}
|
||
defer out.Close()
|
||
n, err := io.Copy(out, in)
|
||
if err != nil {
|
||
os.Remove(dst)
|
||
return "", 0, fmt.Errorf("copy: %w", err)
|
||
}
|
||
return "/files/" + name, n, nil
|
||
}
|
||
|
||
// extBad 过滤危险/无意义扩展名(防双扩展名绕过 Content-Type)。
|
||
func extBad(ext string) bool {
|
||
switch ext {
|
||
case "", ".html", ".htm", ".svg", ".js", ".exe", ".sh", ".bat", ".cmd", ".ps1":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (p *Plugin) ensureAuthBootstrap(s *sdk.PluginSDK) {
|
||
sett := s.Settings()
|
||
if sett == nil {
|
||
return
|
||
}
|
||
if v, _ := sett.Get("username"); v == nil || fmt.Sprint(v) == "" {
|
||
_ = sett.Set("username", "admin")
|
||
}
|
||
if v, _ := sett.Get("password"); v == nil || fmt.Sprint(v) == "" {
|
||
pw := randomSecret(12)
|
||
_ = sett.Set("password", pw)
|
||
log.Printf("[webui] bootstrap password generated for user admin: %s", pw)
|
||
}
|
||
if v, _ := sett.Get("api_key"); v == nil || fmt.Sprint(v) == "" {
|
||
key := randomSecret(16)
|
||
_ = sett.Set("api_key", key)
|
||
log.Printf("[webui] bootstrap api_key generated: %s", key)
|
||
}
|
||
if v, _ := sett.Get("session_ttl_hours"); v == nil || fmt.Sprint(v) == "" {
|
||
_ = sett.Set("session_ttl_hours", "24")
|
||
}
|
||
}
|
||
|
||
func (p *Plugin) Name() string { return p.name }
|
||
|
||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||
s.SetAutoRestart(true)
|
||
|
||
// 中转目录:<data>/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")
|
||
}
|
||
}
|
||
|
||
addr := ":8080"
|
||
if v, _ := s.Settings().Get("addr"); v != nil {
|
||
if s2, ok := v.(string); ok && s2 != "" {
|
||
addr = s2
|
||
}
|
||
}
|
||
addr = resolveListenAddr(addr)
|
||
|
||
// 能力位 7 = CapText|CapFile|CapImage;旧值 1 仅文本,agent 无法向 webui 发文件/图片
|
||
// 入站通道:webui(控制台对话)与 http(外部 HTTP 注入),都由本插件注入输入。
|
||
// http 通道还声明 NoMemory:外部抓来的内容不进记忆计算(见 handler 里的 NoMemory 注入)。
|
||
_ = s.RegisterInputChannel("webui", sdk.ChannelDef{})
|
||
_ = s.RegisterInputChannel("http", sdk.ChannelDef{NoMemory: true})
|
||
s.RegisterOutputChannel("webui", 7, "Web 控制台(支持文字/图片/文件,图片内联展示、文件可下载)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
||
payload, _ := args["payload"].(string)
|
||
rawType, _ := args["type"].(string)
|
||
if payload == "" {
|
||
return nil, fmt.Errorf("payload 不能为空")
|
||
}
|
||
// 能力位:CapText|CapFile|CapImage = 1|2|4 = 7(旧值 1 仅文本)。
|
||
// image/file 时 payload 为本地路径(或 http URL),拷贝到 webui_files
|
||
// 并经 /files/ 带鉴权下发;前端按 kind 渲染图片预览/文件下载卡片。
|
||
if rawType == "image" || rawType == "file" {
|
||
url, size, err := stageWebFile(payload, rawType == "image")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.Publish(&sdk.Event{
|
||
Type: sdk.EventAgentOutput,
|
||
Payload: map[string]interface{}{
|
||
"content": payload,
|
||
"channel": "webui",
|
||
"kind": "channel_output",
|
||
"output_type": rawType,
|
||
"url": url,
|
||
"size": size,
|
||
},
|
||
})
|
||
return map[string]interface{}{"status": "ok", "url": url, "size": size}, nil
|
||
}
|
||
s.Publish(&sdk.Event{
|
||
Type: sdk.EventAgentOutput,
|
||
Payload: map[string]interface{}{
|
||
"content": payload,
|
||
"channel": "webui",
|
||
"kind": "channel_output",
|
||
},
|
||
})
|
||
return map[string]interface{}{"status": "ok"}, nil
|
||
})
|
||
|
||
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"})
|
||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "session_ttl_hours", Default: "24", Type: "int", DisplayName: "会话时长(小时)", Description: "登录 cookie 有效时长", Category: "webui"})
|
||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "device_gateway_enabled", Default: "false", Type: "bool", DisplayName: "设备网关反代", Description: "启用后 /api/v1/device/* 反代到 remotedevice 插件(默认关闭,避免硬耦合)", Category: "webui"})
|
||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "device_gateway_addr", Default: "127.0.0.1:9890", Type: "string", DisplayName: "设备网关地址", Description: "remotedevice 插件的内部监听地址", Category: "webui"})
|
||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "device_gateway_token", Default: "", Type: "password", DisplayName: "设备网关令牌", Description: "访问 remotedevice 的 token(与 remotedevice 的 ws_token 一致)", Category: "webui"})
|
||
p.ensureAuthBootstrap(s)
|
||
// 注入设备网关反代配置(默认禁用;仅当用户开启时才挂载路由)
|
||
if v, _ := s.Settings().Get("device_gateway_enabled"); v != nil {
|
||
if s2, ok := v.(string); ok && s2 == "true" {
|
||
deviceGatewayEnabled = true
|
||
}
|
||
}
|
||
if v, _ := s.Settings().Get("device_gateway_addr"); v != nil {
|
||
if s2, ok := v.(string); ok && s2 != "" {
|
||
deviceGatewayAddr = s2
|
||
}
|
||
}
|
||
if v, _ := s.Settings().Get("device_gateway_token"); v != nil {
|
||
if s2, ok := v.(string); ok && s2 != "" {
|
||
deviceGatewayToken = s2
|
||
}
|
||
}
|
||
|
||
s.RegisterStage(sdk.StagePreAction, func(ctx *sdk.StageContext) error {
|
||
s.Publish(&sdk.Event{Type: sdk.EventStage, Payload: map[string]interface{}{"phase": "pre_action", "message": "thinking"}})
|
||
return nil
|
||
})
|
||
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
|
||
tool := ""
|
||
if len(ctx.ToolCalls) > 0 {
|
||
tool = ctx.ToolCalls[0].Name
|
||
}
|
||
s.Publish(&sdk.Event{Type: sdk.EventStage, Payload: map[string]interface{}{"phase": "before_toolcall", "tool": tool, "message": "tool:" + tool}})
|
||
return nil
|
||
})
|
||
s.RegisterStage(sdk.StageBeforeOutput, func(ctx *sdk.StageContext) error {
|
||
s.Publish(&sdk.Event{Type: sdk.EventStage, Payload: map[string]interface{}{"phase": "before_output", "message": "output"}})
|
||
return nil
|
||
})
|
||
|
||
p.handler = NewHandler(s)
|
||
p.handler.RegisterRoutes(p.mux)
|
||
|
||
// 最外层套 logged 中间件:记录每个请求的来源 IP / 方法 / 路径 / 认证方式 / 状态码。
|
||
// 用于排查“谁调用了什么接口”(如插件禁用等变更操作)。
|
||
//
|
||
// 同步 Listen:端口被占时必须**在这里**失败并把错误交回加载器,
|
||
// 而不是“后台 goroutine 里报一行日志、插件仍被当成加载成功”。
|
||
// 修复前 Start 总是返回 nil,于是 :8080 被占时 WebUI 静默死亡,
|
||
// 调用方看不到任何失败信号。
|
||
ln, err := net.Listen("tcp", addr)
|
||
if err != nil {
|
||
return fmt.Errorf("webui: 监听 %s 失败: %w", addr, err)
|
||
}
|
||
p.server = &http.Server{Handler: p.handler.logged(p.mux)}
|
||
go func() {
|
||
if err := p.server.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||
log.Printf("[webui] server error: %v", err)
|
||
}
|
||
}()
|
||
log.Printf("[webui] HTTP server listening on %s", ln.Addr())
|
||
return nil
|
||
}
|
||
|
||
func (p *Plugin) Stop() error {
|
||
// 先停聊天记录写盘协程并落最后一次,再关服务器:
|
||
// 写盘是节流的(chatSaveThrottle),不显式关会丢掉最后一轮对话。
|
||
if p.handler != nil {
|
||
p.handler.Close()
|
||
}
|
||
if p.server != nil {
|
||
return p.server.Close()
|
||
}
|
||
return nil
|
||
}
|