refactor: move webui.listen_addr from core.daemon to webui category

This commit is contained in:
JianFeeeee
2026-07-14 11:22:46 +08:00
parent 28dc095dc5
commit c7e22fbe30
15 changed files with 1813 additions and 379 deletions

View File

@ -42,7 +42,7 @@ import (
func main() {
dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)")
httpAddr := flag.String("webui", ":8080", "webui listen address")
httpAddr := flag.String("webui", "", "webui listen address (default: webui.listen_addr from config)")
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
flag.Parse()
@ -395,7 +395,11 @@ func main() {
// 为内置插件注入内核依赖(各插件通过 init() 自注册工厂)
cli.DefaultSocket = *cliSocket
openclaw.SkillsDir = filepath.Join(cfg.Daemon.DataDir, "skills")
webui.Configure(*httpAddr,
webuiListenAddr := *httpAddr
if webuiListenAddr == "" {
webuiListenAddr = cfgReg.GetString("webui.listen_addr", ":8080")
}
webui.Configure(webuiListenAddr,
sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks, trk, cfgReg, pluginReg, evBus, agent,
providerMgr, baseAPIKey,
)

View File

@ -299,6 +299,37 @@ s.LLM().ListSources() []SourceInfo
s.LLM().SetSource(name string) error
```
#### Event Subscription (built-in plugins)
```go
// Subscribe to system events, returns unsubscribe function
unsub := s.Subscribe("tool_call", func(evt *events.Event) {
log.Printf("Tool was called: %v", evt.Payload)
})
defer unsub()
// Publish event
s.Publish(&events.Event{
Type: "custom_event",
Payload: map[string]interface{}{"key": "value"},
})
```
#### IO Channel Management (built-in plugins)
```go
// Register a channel (bind device driver)
s.RegisterChannel("mydevice", deviceImpl)
// Unregister a channel
s.UnregisterChannel("mydevice")
// List all channels
channels := s.ListChannels()
```
> **Note**: `Subscribe`, `Publish`, `RegisterChannel`, `UnregisterChannel`, `ListChannels`, `InjectInput`, `InjectInputSync`, `InjectInterrupt`, `InjectTextSync`, `InjectTextSyncNoMemory`, `OutputChan` are only available in built-in plugins (`internal/sdk` package). External dynamic plugins should use the public APIs: `InjectText`, `InjectInterruptText`, `InjectTextNoMemory`.
---
## 3. Lua Plugin Development in Detail
@ -367,6 +398,8 @@ When running inside the kernel, `sdk.*` global variables are injected by the Go
| `sdk.http.get(url)` | HTTP GET request (`-- !impl`) |
| `sdk.http.post(url, body, content_type)` | HTTP POST request (`-- !impl`) |
> **Note**: Lua plugin's `sdk.register_stage` callback currently only receives `raw_message`, `user_id`, `phase` fields. The functionality is limited. For complex stage handling logic, use Go plugins.
---
## 4. Built-in Plugins

View File

@ -300,6 +300,37 @@ s.LLM().ListSources() []SourceInfo
s.LLM().SetSource(name string) error
```
#### 事件订阅(内置插件)
```go
// 订阅系统事件,返回取消订阅函数
unsub := s.Subscribe("tool_call", func(evt *events.Event) {
log.Printf("工具被调用: %v", evt.Payload)
})
defer unsub()
// 发布事件
s.Publish(&events.Event{
Type: "custom_event",
Payload: map[string]interface{}{"key": "value"},
})
```
#### IO 通道管理(内置插件)
```go
// 注册通道(绑定设备驱动)
s.RegisterChannel("mydevice", deviceImpl)
// 注销通道
s.UnregisterChannel("mydevice")
// 列出所有通道
channels := s.ListChannels()
```
> **注意**`Subscribe`、`Publish`、`RegisterChannel`、`UnregisterChannel`、`ListChannels`、`InjectInput`、`InjectInputSync`、`InjectInterrupt`、`InjectTextSync`、`InjectTextSyncNoMemory`、`OutputChan` 这些方法仅在内置插件中可用(`internal/sdk` 包),外部动态插件无法访问。外部插件请使用 `InjectText`、`InjectInterruptText`、`InjectTextNoMemory` 等公共 API。
---
## 三、Lua 插件开发详解
@ -368,6 +399,8 @@ lua main.lua
| `sdk.http.get(url)` | HTTP GET 请求(`-- !impl` |
| `sdk.http.post(url, body, content_type)` | HTTP POST 请求(`-- !impl` |
> **注意**Lua 插件的 `sdk.register_stage` 阶段回调目前仅传递 `raw_message`、`user_id`、`phase` 三个字段,功能受限。复杂的阶段处理逻辑建议使用 Go 插件。
---
## 四、内置插件

2
go.mod
View File

@ -11,6 +11,8 @@ require (
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0 // direct
replace gitcode.com/JianFeeeee/homeagent-sdk => ../homeagentsdk
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect

View File

@ -1522,6 +1522,9 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
prompt += "\n\n" + a.indexer.BuildToolPrompt()
}
// 动态工具目录
prompt += a.buildToolCatalog()
return prompt
}
@ -1549,6 +1552,41 @@ func cleanParams(params map[string]interface{}) map[string]interface{} {
return cleaned
}
func (a *Agent) buildToolCatalog() string {
defs := a.buildToolDefs()
if len(defs) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("\n\n【可用工具列表】")
seen := make(map[string]bool)
for _, d := range defs {
t, ok := d.(map[string]interface{})
if !ok {
continue
}
fn, ok := t["function"].(map[string]interface{})
if !ok {
continue
}
name, _ := fn["name"].(string)
if name == "" || seen[name] {
continue
}
seen[name] = true
desc, _ := fn["description"].(string)
sb.WriteString(fmt.Sprintf("\n- %s", name))
if desc != "" {
// only first 80 chars of description
if len(desc) > 80 {
desc = desc[:80] + "..."
}
sb.WriteString(": " + desc)
}
}
return sb.String()
}
func (a *Agent) buildToolDefs() []interface{} {
var tools []interface{}

View File

@ -2,6 +2,7 @@ package core
import (
"fmt"
"log"
"sync"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
@ -85,6 +86,7 @@ func inferToolPlugin(name string) string {
// - 只读操作先调用 ctx.RLock() / defer ctx.RUnlock()
// - 写操作(如设置 ctx.Response先调用 ctx.Lock() / defer ctx.Unlock()
// 如果任意 handler 设置了 Response后续 handler 可通过 ctx.IsResponded() 判断后提前返回。
// handler 返回的 error 会被收集到 ctx.Errors 中并记录日志,不会中断其他 handler 的执行。
func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
h.mu.RLock()
handlers := h.stages[stage]
@ -93,14 +95,29 @@ func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
return
}
var wg sync.WaitGroup
errCh := make(chan error, len(handlers))
for _, handler := range handlers {
wg.Add(1)
go func(fn sdk.StageHandler) {
defer wg.Done()
fn(ctx)
if err := fn(ctx); err != nil {
errCh <- err
}
}(handler)
}
wg.Wait()
close(errCh)
var errs []string
for err := range errCh {
errs = append(errs, err.Error())
log.Printf("[stage] %s handler error: %v", stage, err)
}
if len(errs) > 0 {
ctx.Lock()
ctx.Errors = append(ctx.Errors, errs...)
ctx.Unlock()
}
}
func (h *StageHost) ToolCount() int {

View File

@ -68,32 +68,32 @@ func channelInfoFromIO(ch agentIO.ChannelInfo) ChannelInfo {
}
type MemoryStatus struct {
Available bool `json:"available"`
EntityCount int `json:"entity_count,omitempty"`
RelationCount int `json:"relation_count,omitempty"`
EntityTypes int `json:"entity_types,omitempty"`
Available bool `json:"available"`
EntityCount int `json:"entity_count"`
RelationCount int `json:"relation_count"`
EntityTypes int `json:"entity_types"`
}
type KnowledgeStatus struct {
Available bool `json:"available"`
ItemCount int `json:"item_count,omitempty"`
ItemCount int `json:"item_count"`
Items []string `json:"items,omitempty"`
}
type DocumentStatus struct {
Available bool `json:"available"`
DocCount int `json:"doc_count,omitempty"`
VectorCount int `json:"vector_count,omitempty"`
DocCount int `json:"doc_count"`
VectorCount int `json:"vector_count"`
}
type TextMemoryStatus struct {
Available bool `json:"available"`
FileCount int `json:"file_count,omitempty"`
FileCount int `json:"file_count"`
}
type SocialStatus struct {
Available bool `json:"available"`
PersonCount int `json:"person_count,omitempty"`
PersonCount int `json:"person_count"`
}
type SkillsStatus struct {

View File

@ -298,7 +298,7 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
set := func(k, v string) { stmt.Exec(k, v) }
set("core.daemon.listen_addr", ":8080")
set("webui.listen_addr", ":8080")
set("core.daemon.data_dir", dataDir)
set("core.daemon.heartbeat_interval", "15s")
set("core.daemon.check_interval", "30s")
@ -349,26 +349,11 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
set("core.agent.distill_interval", "30m")
set("core.agent.workdir", "")
set("core.agent.system_prompt", `你是 HomeAgent一个持续运行的个人管家。
你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。
如需切换回复通道,使用 output_set_channel
如需异步发送消息或通知,使用 output_send 指定通道和内容
使用 output_list_channels 查看可用通道及其能力。
你的回复默认发送到用户的输入来源,无需额外工具。
如需异步发送消息到其他通道,使用 output_send
使用 output_list_channels 查看可用通道
可用工具列表会由系统自动传入,按需使用即可。以下是你尤其需要关注的几类工具
- memory_* — 图记忆(长期记忆,记录和查询个人信息/事实)
- knowledge_* — 知识库(查阅预设知识文档)
- doc_* — 文档记忆(近期对话的存档,查询后自动清除)
- person_* — 人物特质与社交关系网
- llm_* — LLM 源管理(列出/切换模型提供商)
- output_* — 输出通道管理(切换/发送消息)
- timer_set — 设置定时提醒
- plgreload — 热重载插件
- spawn_child — 生成子 Agent 执行独立任务
- describe_image — 描述用户上传的图片
- transcribe_audio — 转写用户上传的音频
- ocr_image — 识别图片中的文字
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请使用上述工具。
当用户上传图片或音频时,系统自动附着媒体内容。如果模型不支持直接处理多媒体,请调用对应的媒体处理工具
回复你的真实想法,用自然语言与用户交流。`)
@ -387,7 +372,7 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
reg := func(d ConfigDef) { r.defs[d.Key] = &d }
reg(ConfigDef{Key: "core.daemon.listen_addr", Default: ":8080", Type: "string", DisplayName: "监听地址", Description: "WebUI HTTP 监听地址", Category: "daemon"})
reg(ConfigDef{Key: "webui.listen_addr", Default: ":8080", Type: "string", DisplayName: "监听地址", Description: "WebUI HTTP 监听地址", Category: "webui"})
reg(ConfigDef{Key: "core.daemon.data_dir", Default: dataDir, Type: "string", DisplayName: "数据目录", Description: "数据存储根目录", Category: "daemon"})
reg(ConfigDef{Key: "core.daemon.heartbeat_interval", Default: "15s", Type: "duration", DisplayName: "心跳间隔", Description: "Agent 心跳检查间隔", Category: "daemon"})
reg(ConfigDef{Key: "core.daemon.check_interval", Default: "30s", Type: "duration", DisplayName: "检查间隔", Description: "网络状态检查间隔", Category: "daemon"})
@ -568,7 +553,6 @@ func (r *ConfigRegistry) ToConfig() *types.Config {
return b
}
cfg.Daemon.ListenAddr = read("core.daemon.listen_addr", cfg.Daemon.ListenAddr)
cfg.Daemon.DataDir = read("core.daemon.data_dir", cfg.Daemon.DataDir)
cfg.Daemon.HeartbeatInterval = readDur("core.daemon.heartbeat_interval", cfg.Daemon.HeartbeatInterval)
cfg.Daemon.CheckInterval = readDur("core.daemon.check_interval", cfg.Daemon.CheckInterval)

View File

@ -168,8 +168,8 @@ func TestSeedDefaultsToConfig(t *testing.T) {
// Reconstruct config from DB
cfg2 := r.ToConfig()
if cfg2.Daemon.ListenAddr != ":8080" {
t.Fatalf("expected :8080, got %s", cfg2.Daemon.ListenAddr)
if v := r.GetString("webui.listen_addr", ""); v != ":8080" {
t.Fatalf("expected :8080, got %s", v)
}
if cfg2.LLM.Provider != "deepseek" {
t.Fatalf("expected deepseek, got %s", cfg2.LLM.Provider)

View File

@ -4,7 +4,6 @@ import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"sync"
@ -324,42 +323,24 @@ func (v *VM) GetAdapterHeaders(name string) map[string]string {
}
func (v *VM) writeBundledAdapters() error {
// Try multiple paths for compatibility
tryPaths := []string{"adapters", ".", "lua/adapters"}
var entries []fs.DirEntry
var err error
for _, p := range tryPaths {
entries, err = bundledAdapters.ReadDir(p)
if err == nil && len(entries) > 0 {
break
}
known := []string{
"openai", "anthropic", "deepseek", "gemini",
"github", "groq", "mistral", "ollama",
}
if err != nil || len(entries) == 0 {
return nil
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
if filepath.Ext(entry.Name()) != ".lua" {
continue
}
dstPath := filepath.Join(v.adapterDir, entry.Name())
for _, name := range known {
srcPath := "adapters/" + name + ".lua"
dstPath := filepath.Join(v.adapterDir, name+".lua")
if _, err := os.Stat(dstPath); err == nil {
continue
}
data, err := bundledAdapters.ReadFile(filepath.Join("adapters", entry.Name()))
data, err := bundledAdapters.ReadFile(srcPath)
if err != nil {
// try alternative paths
data, err = bundledAdapters.ReadFile(entry.Name())
if err != nil {
continue
}
continue
}
if err := os.WriteFile(dstPath, data, 0644); err != nil {
return fmt.Errorf("write %s: %w", entry.Name(), err)
return fmt.Errorf("write %s: %w", name+".lua", err)
}
fmt.Printf("[lua] installed bundled adapter: %s\n", entry.Name())
fmt.Printf("[lua] installed bundled adapter: %s\n", name+".lua")
}
return nil
}

View File

@ -5,6 +5,7 @@ import (
"context"
"fmt"
"os/exec"
"runtime"
"strings"
"sync"
"time"
@ -13,6 +14,9 @@ import (
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// isWindows 缓存运行时检测结果
var isWindows = runtime.GOOS == "windows"
// shellUnquote 拆解命令字符串,处理单引号/双引号包裹的参数
func shellUnquote(s string) []string {
var args []string
@ -152,7 +156,12 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
parts := shellUnquote(command)
// Windows 上预置 chcp 65001 确保控制台输出为 UTF-8避免 GBK 乱码
execCmd := command
if isWindows {
execCmd = "chcp 65001>nul & " + command
}
parts := shellUnquote(execCmd)
if len(parts) == 0 {
return map[string]interface{}{"error": "command is required"}, nil
}

File diff suppressed because one or more lines are too long

View File

@ -950,7 +950,7 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
}
}()
subTypes := []string{"agent_output", "reasoning", "agent_error"}
subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call"}
for _, t := range subTypes {
t2 := t
_ = h.eventBus.Subscribe(events.EventType(t2), func(evt *events.Event) {

View File

@ -34,7 +34,7 @@ func newTestHandler(t *testing.T) (*Handler, *supervisor.Daemon) {
sup := supervisor.New(cfg)
sup.Start()
return NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, events.NewBus(), nil), sup
return NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, events.NewBus(), nil, nil, ""), sup
}
func TestAuthMiddleware(t *testing.T) {
@ -194,7 +194,7 @@ func TestHandleKnowledgeSearch(t *testing.T) {
sup.Start()
defer sup.Shutdown()
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus(), nil)
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus(), nil, nil, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=test", nil)
w := httptest.NewRecorder()
@ -226,7 +226,7 @@ func TestHandleKnowledgeCreate(t *testing.T) {
sup.Start()
defer sup.Shutdown()
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus(), nil)
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus(), nil, nil, "")
body := `{"name":"new_doc","content":"fresh content"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", strings.NewReader(body))
@ -291,7 +291,7 @@ func TestHandleTrackerStats(t *testing.T) {
sup.Start()
defer sup.Shutdown()
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, tr, nil, nil, events.NewBus(), nil)
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, tr, nil, nil, events.NewBus(), nil, nil, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/tracker", nil)
w := httptest.NewRecorder()
@ -458,7 +458,7 @@ func TestSettingsAPIFlow(t *testing.T) {
cfgReg := internalConfig.NewConfigRegistry("")
cfgReg.Register("core.llm.model", "deepseek-v4-flash")
cfgReg.Register("core.llm.base_url", "https://api.deepseek.com")
cfgReg.Register("core.daemon.listen_addr", ":8080")
cfgReg.Register("webui.listen_addr", ":8080")
cfgReg.Register("plugin.qq.access_token", "secret123")
sup := supervisor.New(&types.Config{
@ -471,7 +471,7 @@ func TestSettingsAPIFlow(t *testing.T) {
defer sup.Shutdown()
pluginReg := plugin.NewRegistry()
h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus(), nil)
h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus(), nil, nil, "")
t.Run("GET_settings_lists_keys_and_plugins", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
@ -505,7 +505,7 @@ func TestSettingsAPIFlow(t *testing.T) {
})
t.Run("GET_settings_with_prefix", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings?prefix=core.daemon", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings?prefix=webui", nil)
w := httptest.NewRecorder()
h.handleSettings(w, req)
@ -513,11 +513,11 @@ func TestSettingsAPIFlow(t *testing.T) {
json.NewDecoder(w.Body).Decode(&resp)
settings := resp["settings"].(map[string]interface{})
if _, ok := settings["core.daemon.listen_addr"]; !ok {
t.Fatal("expected core.daemon.listen_addr in filtered results")
if _, ok := settings["webui.listen_addr"]; !ok {
t.Fatal("expected webui.listen_addr in filtered results")
}
if _, ok := settings["core.llm.model"]; ok {
t.Fatal("core.llm.model should not be in core.daemon filtered results")
t.Fatal("core.llm.model should not be in webui filtered results")
}
})
@ -578,7 +578,7 @@ func TestSettingsAPIFlow(t *testing.T) {
})
t.Run("settings_not_available_without_registry", func(t *testing.T) {
h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus(), nil)
h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus(), nil, nil, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
h2.handleSettings(w, req)
@ -604,7 +604,7 @@ func TestSettingsWithPluginRegistry(t *testing.T) {
defer sup.Shutdown()
pluginReg := plugin.NewRegistry()
h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus(), nil)
h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus(), nil, nil, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
@ -677,7 +677,7 @@ func TestHandleCompletionsEndToEnd(t *testing.T) {
sup.Start()
defer sup.Shutdown()
h := NewHandler(sup, nil, nil, nil, &types.Config{}, iom, nil, nil, nil, nil, nil, events.NewBus(), nil)
h := NewHandler(sup, nil, nil, nil, &types.Config{}, iom, nil, nil, nil, nil, nil, events.NewBus(), nil, nil, "")
t.Run("POST_chat_completions_returns_echo", func(t *testing.T) {
body := `{"model":"test","messages":[{"role":"user","content":"你好"}]}`
@ -710,7 +710,7 @@ func TestHandleCompletionsEndToEnd(t *testing.T) {
})
t.Run("POST_chat_completions_no_iom_returns_503", func(t *testing.T) {
h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus(), nil)
h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus(), nil, nil, "")
body := `{"messages":[{"role":"user","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")

View File

@ -150,7 +150,6 @@ type Config struct {
}
type DaemonConfig struct {
ListenAddr string `json:"listen_addr"`
DataDir string `json:"data_dir"`
HeartbeatInterval time.Duration `json:"heartbeat_interval"`
CheckInterval time.Duration `json:"check_interval"`