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

@ -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")