mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 18:38:11 +00:00
起因:聊天记录原先作为插件配置项 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。
302 lines
11 KiB
Go
302 lines
11 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 {
|
||
if p.server != nil {
|
||
return p.server.Close()
|
||
}
|
||
return nil
|
||
}
|