Files
HomeAgent/internal/plugins/webui/plugin.go
JianFeeeee 6e6035141a fix(resident/plugin): 二进制级压测暴露的三个真问题(DataDir 漏接线 / inputch 未登记 / create 不开工)+ 通道双向登记贯穿全部内建插件
在真实内核二进制(私有 netns + mock LLM + CLI unix socket)上做压力测试时,
下面三个问题**只有跑真二进制才暴露** —— 单元测试里都显式传了参数、没走插件加载,
所以全绿也照样漏。

## ① 根 agent 的 DataDir 没接线 ⇒ 驻留子永远建不出来

现象:模型调用 `resident_agents` 成功,但结果是
`创建驻留子需要 data_dir 或显式 temp_path`。
根因:`cmd/homed/main.go` 构造 AgentConfig 时没有 `DataDir`,
而驻留子的 temp 图库需要 `<data>/residents/<id>/graph.db` 这个锚点。
(单测里 `AgentConfig{DataDir: dir}` 显式给了,所以测不出来。)

修:main.go 接线 `DataDir: cfg.Daemon.DataDir`;并在工具层加**兜底 + 告警** ——
data_dir 为空时从主图库路径反推(`<data>/memory/graph.db` ⇒ `<data>`),
失败才报错。静默失败会让线上表现成"工具能调但永远建不出来"。

## ② 插件通道没登记为 inputch ⇒ "划入 inputch"必然失败

现象:`划入 inputch cli: inputch 未注册`。
根因:`cli` 插件只调 `RegisterOutputChannel("cli", ...)`,却用同一个名字
`InjectTextSync("cli", ...)` 注入输入 —— 内核 inputch 登记表里根本没有它。
(实测审计:内建 6 个插件里只有 0 个登记过入站通道;SDK 示例里只有 qq/weather 是对的。)

修两处:
- **全部内建插件显式登记入站通道**:`cli`/`agentcli`/`timer`/`webui`(+`http`, NoMemory)/
  `clawhubadapter`(每个 OC 通道声明处)/`remotedevice`(`device/<id>` 懒登记,幂等)。
- `registry.go` 把隐式兜底改成**留痕的兼容网**:只有当该名字还没登记为 inputch 时
  才兜底登记,并打日志说明"建议显式 RegisterInputChannel"。
  实测:改完内建插件后,启动日志里兜底告警 **0 次**。

## ③ create 之后子不开工 ⇒ rounds 恒为 0

现象:`[agent] r1 started, waiting for IO interrupts` 之后什么都没有,登记表里 rounds=0。
根因:`TaskPrompt` 只进了子的**系统提示词**,从没作为输入投给子。
修:create 即开工 —— 把任务提示词作为**第一条排队输入**投给子(排队而非中断:
创建是"安排工作",不是"打断它正在做的事")。

## 测试

- `TestResident_InputchTableAutoAndProactive` / `TestLightKernel_TraditionalContextNoTrimming`
  随行为更新:create 会多跑一轮(任务提示词那轮也会写处理表),
  断言改为"以创建时的表长为基线 + 等待新的一轮"。
- 全量 `go test ./...` = 37 包 ok / 0 FAIL;`-race`(agent/plugin/plugins)干净。

## 真实二进制压力测试结果(修复后)

私有 netns 里跑 mock LLM + 内核,用 CLI socket 驱动多并发连接:
- 密集:16 连接×12 输入 + 4 线程×20 次 L4 中断 → **274 任务 executed=274 / rejected=0 / errors=0**,
  峰值排队 15、峰值待处理中断 76;
- 稀疏(中断每 3s 一次,压在排队任务的流式段上)→ **suspended=27 / resumed=27 / preempted=27**;
- 驻留子全链路:父建子(inputchs=["cli"])→ 子开工 → 子 `notify_parent` → 父侧收到
  `interrupt from r1/child/r1`(L3,且父被抢占 suspended/resumed=1);
- 优雅退出:SIGTERM 后驻留子 temp 目录被清除、无残留进程。
2026-09-13 11:38:03 +08:00

257 lines
9.4 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 (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log"
"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
// uploadsDir 是用户经 webui 上传文件的存储目录(<data>/uploads
// handleChatFile 落盘、handleUploads 下载共用;参考 qq 插件 files_dir 收文件设计。
var uploadsDir string
// 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_filesagent 发送 image/file 时拷贝至此
if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil {
if s2, ok := dd.(string); ok && 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
}
}
// 能力位 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: "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 / 方法 / 路径 / 认证方式 / 状态码。
// 用于排查“谁调用了什么接口”(如插件禁用等变更操作)。
p.server = &http.Server{Addr: addr, Handler: p.handler.logged(p.mux)}
go func() {
log.Printf("[webui] HTTP server listening on %s", addr)
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[webui] server error: %v", err)
}
}()
return nil
}
func (p *Plugin) Stop() error {
if p.server != nil {
return p.server.Close()
}
return nil
}