docs+test: update ARCHITECTURE.md + settings integration tests

- Add Lua adaptation layer section (Provider → LuaAdaptedProvider)
- Add ConfigRegistry + SettingsAPI section
- Add self-loop input channel section
- Update directory tree with config/, lua/adapters/
- Update v3→v4 comparison table
- Integration tests: settings GET/PUT/prefix/filter/HTML/edge cases
- Tests verify: key listing, prefix filtering, value update via PUT,
  HTML content checks, missing registry handling
This commit is contained in:
root
2026-07-03 08:43:16 +08:00
parent 672b098c4e
commit b1b90ae09f
2 changed files with 316 additions and 13 deletions

View File

@ -37,6 +37,9 @@
│ │ RegisterStage(stage, handler) ← 插件挂入消息处理阶段 │ │
│ │ Subscribe(eventType, handler) ← 插件订阅系统事件 │ │
│ │ Publish(event) → 插件发布事件 │ │
│ │ Settings().Get/Set/List ← 读写核心/插件配置 │ │
│ │ Memory().Recall/Commit ← 图记忆访问 │ │
│ │ Knowledge().Search/Create ← 知识库访问 │ │
│ └──────────────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────────┤
│ 插件域 (Plugin Domain) │
@ -371,7 +374,93 @@ spawn_child(task) → 新建轻量 Agent
---
## 十、SDK API 定义
## 九、LLM Provider 与 Lua 适配层
LLM 调用全部通过 `Provider` 接口,核心实现是 `LuaAdaptedProvider`
```
Agent
Provider 接口 (Name / Chat / ChatStream)
LuaAdaptedProvider
├── 1. 序列化 CompletionRequest → raw JSON
├── 2. adapter.transform_request(rawJSON) → 协议特定请求体
├── 3. 读取 adapter.endpoint + adapter.headers 发 HTTP
├── 4. adapter.transform_response(rawHTTPBody) → 统一响应格式
└── 5. 反序列化为 CompletionResponse
```
### Lua 适配器契约
每个适配器是一个返回 table 的 Lua 脚本,位于 `data/adapters/*.lua`
```lua
adapter.name = "deepseek"
adapter.version = "2.0.0"
adapter.endpoint = "/chat/completions"
adapter.headers = {} -- 静态头Go 自动加 Authorization
-- 请求变换raw JSON → 协议格式
function adapter.transform_request(raw_body) return transformed end
-- 响应变换HTTP body → 统一格式 {content, reasoning_content, finish_reason, token_usage, tool_calls}
function adapter.transform_response(raw_body) return unified end
-- 流变换可选SSE data line → {content, done}
function adapter.transform_stream_chunk(raw_line) return chunk end
```
### Lua VM 能力
- `json.encode(table)` → 使用 Go `json.Marshal` 的 JSON 序列化
- `json.decode(string)` → 使用 Go `json.Unmarshal` 的 JSON 反序列化
- 全局函数 `log(level, msg)` / `http_get(url)` / `http_post(url, body)`
- 适配器内置 3 个:`openai.lua``deepseek.lua``ollama.lua`
## 十、配置中心 (ConfigRegistry)
配置不再分散在各处——通过 `ConfigRegistry` 统一管理:
```
Plugin (通过 SDK)
├─ Settings().Get("core.llm.model") → 读核心配置
├─ Settings().Set("plugin.qq.token", x) → 写插件配置
├─ Settings().List("plugin.") → 列出所有插件键
ConfigRegistry (线程安全 KV 存储)
├── 持久化到 data/settings.json
├── 键命名空间: core.* / plugin.<name>.*
├── Register(key, default) ← 注册默认值(不标记 dirty
├── Get/Set/List/Delete ← 运行时读写
└── Flush() ← 写回磁盘
```
### WebUI 配置编辑
```
┌──────────────────────────────────┐
│ 侧边栏 编辑区 │
│ ┌──────┐ ┌──────────────────┐ │
│ │ core │ │ core.llm.model │ │
│ │plugin│ │ [input field] │ │
│ │.qq │ │ [保存] │ │
│ │plugin│ ├──────────────────┤ │
│ │.webui│ │ core.llm.base_url│ │
│ └──────┘ │ [input field] │ │
│ │ [保存] │ │
│ └──────────────────┘ │
└──────────────────────────────────┘
```
- `GET /api/v1/settings?prefix=core.` → 列出配置键值 + 插件列表
- `PUT /api/v1/settings``{key, value}` 写入配置
## 十一、SDK API 定义
### PluginAPI (`internal/plugin/sdk/api.go`)
@ -415,7 +504,7 @@ type StageContext struct {
---
## 十、事件系统
## 十、事件系统
### 事件类型
@ -438,7 +527,24 @@ func (b *Bus) Subscribe(eventType EventType, handler Handler) func()
---
## 十二、数据流全景
## 十三、自循环输入通道
核心维护一个独立的 `selfInputCh (chan string)`,用于内部任务(记忆消歧、系统维护),不经过 IO 层:
```
心跳检测 → entitySimilarity() → enqueueConsolidationTask()
injectSelf(msg)
selfInputCh ─→ eventLoop ─→ processTextInput
不经过 IOManager不经过任何插件
```
区别于 IOManager.InputChan外部输入selfInputCh 是核心自有的纯 Go channel
确保即使没有 IO 插件,记忆整理等维护任务也能正常执行。
## 十四、数据流全景
```
外部 (QQ/HTTP/硬件)
@ -474,7 +580,7 @@ Agent.eventLoop() → handleInput → processTextInput
---
## 十、代码结构
## 十、代码结构
```
cmd/homed/main.go — 入口:组装所有子系统
@ -489,9 +595,21 @@ internal/
│ ├── io/
│ │ └── channel.go — IOManager + Device 接口(过渡期保留)
│ └── personal.go — 人格加载
├── agent/
│ ├── core/
│ │ ├── agent.go — Agent 核心事件循环、工具循环、心跳、selfInputCh
│ │ ├── context.go — RelevanceContextTF-IDF 上下文管理
│ │ └── stages.go — StageHost阶段管道编排
│ ├── api/
│ │ └── provider.go — Provider 接口 + OpenAI/Ollama/LuaAdaptedProvider
│ ├── io/
│ │ └── channel.go — IOManager + Device 接口(过渡期保留)
│ └── personal.go — 人格加载
├── api/
│ ├── handler.go — HTTP API 端点
│ ├── handler.go — HTTP API 端点 + WebUI (内联 HTML/JS/CSS)
│ └── plugin.go — WebUI Device 包装
├── config/
│ └── registry.go — ConfigRegistry统一配置中心
├── events/
│ └── bus.go — 系统事件总线 (Publish/Subscribe)
├── memory/
@ -504,22 +622,27 @@ internal/
├── knowledge/
│ └── knowledge.go — 知识系统
├── plugin/
│ ├── plugin.go — 插件注册表 + 旧 Device 兼容层
│ ├── plugin.go — 插件注册表 + ConfigRegistry + SettingsAPI 注入
│ └── sdk/
│ ├── api.go — PluginAPI 定义
│ ├── api.go — PluginAPI (Tool/Stage/Event/Settings/Memory/Knowledge)
│ └── bus.go — 插件内部 EventBus 接口
├── onebot/ — OneBot V11 QQ 协议实现
├── tracker/ — 变更追踪 (overlayfs)
├── supervisor/ — 守护进程
├── skill/ — 技能管理器
├── lua/ — Lua 适配器
├── lua/
│ ├── vm.go — Lua VM (json.encode/decode, CallTransformRequest/Response)
│ └── adapters/
│ ├── openai.lua — OpenAI 协议适配
│ ├── deepseek.lua — DeepSeek 协议适配 (temperature=0, reasoning)
│ └── ollama.lua — Ollama 协议适配
├── network/ — 网络监控
├── container/ — 容器管理
├── snapshot/ — 快照
├── embed/ — 嵌入
└── tokenizer/ — 分词器
config/
├── config.go — 配置加载
config/ — 顶层配置加载
├── config.go — Config 结构 + 加载/保存
└── config.yaml
pkg/types/ — 类型定义
docs/
@ -528,13 +651,16 @@ docs/
---
## 十、与旧设计 (v3) 的关键区别
## 十、与旧设计 (v3) 的关键区别
| 维度 | v3 | v4 |
|------|-----|-----|
| 插件交互 | Device 接口 + IOManager 路由 | 三通道Tool/Stage/Event |
| 插件交互 | Device 接口 + IOManager 路由 | 三通道Tool/Stage/Event/Settings |
| 消息流编辑 | 无(纯事件推送) | 阶段管道 7 个 hook 点 |
| Event Bus | 无 | `internal/events/bus.go` |
| SDK | 无 | `internal/plugin/sdk/` |
| SDK | 无 | `internal/plugin/sdk/` (含 SettingsAPI) |
| LLM 适配 | 硬编码 Provider | Lua 脚本 raw JSON 变换 |
| 配置管理 | 分散在各处 | ConfigRegistry 统一 KV 存储 |
| 核心 IO | IOManager `EmitOutput` 直出 | 全部走 `output_send` 工具 |
| 插件工具路由 | IOManager `ExecuteTool` 链 | StageHost + Registry 双层路由 |
| 内部任务 | 无 | selfInputCh 自循环通道(不经过 IO |

View File

@ -2,13 +2,16 @@ package api
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
@ -382,3 +385,177 @@ func TestHandleAdaptersUnavailable(t *testing.T) {
t.Errorf("expected 503, got %d", w.Code)
}
}
// === 全流程集成测试ConfigRegistry → WebUI → HTTP API ===
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("plugin.qq.access_token", "secret123")
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
pluginReg := plugin.NewRegistry()
h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg)
t.Run("GET_settings_lists_keys_and_plugins", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
h.handleSettings(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
settings, ok := resp["settings"].(map[string]interface{})
if !ok {
t.Fatal("settings not a map")
}
if v, _ := settings["core.llm.model"].(string); v != "deepseek-v4-flash" {
t.Fatalf("expected deepseek-v4-flash, got %v", settings["core.llm.model"])
}
plugins, ok := resp["plugins"].([]interface{})
if !ok || len(plugins) == 0 {
t.Fatal("expected plugins list")
}
if plugins[0] != "core" {
t.Fatalf("expected first plugin 'core', got %v", plugins[0])
}
})
t.Run("GET_settings_with_prefix", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings?prefix=core.daemon", nil)
w := httptest.NewRecorder()
h.handleSettings(w, req)
var resp map[string]interface{}
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["core.llm.model"]; ok {
t.Fatal("core.llm.model should not be in core.daemon filtered results")
}
})
t.Run("PUT_settings_updates_value", func(t *testing.T) {
body := `{"key":"core.llm.model","value":"gpt-4"}`
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSettings(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
val, err := cfgReg.Get("core.llm.model")
if err != nil {
t.Fatalf("Get error: %v", err)
}
if v, _ := val.(string); v != "gpt-4" {
t.Fatalf("expected gpt-4, got %v", val)
}
})
t.Run("PUT_settings_invalid_body", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader("not json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSettings(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
})
t.Run("handleStatic_returns_webui_html", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
h.handleStatic(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body, _ := io.ReadAll(w.Body)
html := string(body)
if !strings.Contains(html, "settings-layout") {
t.Fatal("HTML should contain settings-layout class")
}
if !strings.Contains(html, "settings-sidebar") {
t.Fatal("HTML should contain settings-sidebar class")
}
if !strings.Contains(html, "saveSetting") {
t.Fatal("HTML should contain saveSetting JS function")
}
if !strings.Contains(html, "api('/settings'") {
t.Fatal("HTML should call api('/settings')")
}
})
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)
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
h2.handleSettings(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
})
}
func TestSettingsWithPluginRegistry(t *testing.T) {
cfgReg := internalConfig.NewConfigRegistry("")
cfgReg.Register("core.test.key", "value")
cfgReg.Register("plugin.testplug.apikey", "abc123")
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
pluginReg := plugin.NewRegistry()
h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg)
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
h.handleSettings(w, req)
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
plugins, _ := resp["plugins"].([]interface{})
foundCore := false
for _, p := range plugins {
if p == "core" {
foundCore = true
break
}
}
if !foundCore {
t.Fatal("expected 'core' in plugins list")
}
}