重构: 插件自注册 + .so 动态加载 + 中断打断机制

- 所有内置插件 init() 自注册 (plugin.RegisterFactory), 移除 main.go 硬编码
- 新增 .so 动态加载器 (internal/plugin/dynamic.go), 插件可编译为 plugin.so
- 新增 plugin.json 元数据 (internal/plugin/manifest.go)
- 新增 interceptLoop 独立 goroutine:
  (a) cancelLLM() 取消进行中的 HTTP 请求
  (b) interceptCh → drainInterrupt() 注入 [打断消息] 到 LLM 上下文
  (c) InjectInput 空闲时触发新处理循环
- 新增 internal/plugins/all.go 空白导入触发所有内置插件 init()
- internal/sdk/ 作为 PluginSDK 正式 Go API
- internal/api/ → internal/plugins/webui/ 迁移
- 删除旧 cmd/cli/, 使用 cmd/waiter/ 替代
- 更新 PLAN.md / ARCHITECTURE.md / README.md 文档
This commit is contained in:
root
2026-07-03 16:53:34 +08:00
parent 4442c9cea4
commit 2d314b3e9c
37 changed files with 3376 additions and 1576 deletions

View File

@ -1,19 +1,13 @@
# HomeAgent 架构设计 v4
# HomeAgent 架构设计
完整架构文档参见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
实施计划参见 [PLAN.md](PLAN.md)。
## 核心原则
- **核心零 IO** — 无任何硬编码 IO 能力,所有 IO 来自插件
- **输出是工具调用** — Agent 必须显式 `output_send` 才能通信
- **核心零 IO** — 无任何硬编码 IO 能力
- **输出是工具调用** — Agent 必须显式调用 output_send 才能通信
- **三通道插件** — 工具 (RegisterTool)、阶段 (RegisterStage)、事件 (Subscribe/Publish)
- **阶段管道** — 7 个 hook 点让插件干预消息处理流`on_input``pre_action``post_action``before_toolcall`/`after_toolcall``before_output``after_output`
- **阶段管道** — 7 个 hook 点让插件干预消息处理流
- **三层记忆** — Context (内存) → Document (JSON+向量) → Graph (SQLite)
- **知识独立** — 独立 TF-IDF 向量索引,不与记忆耦合
## 快速启动
```bash
make build # 编译
make run # 编译并启动(数据 /tmp/homeagent
```

View File

@ -1,18 +1,24 @@
.PHONY: all build clean install test run
.PHONY: all build clean install test run build-cli
BINARY=homed
CLI_BINARY=homecli
GO=go
GOCACHE=/tmp/gocache
GOPATH=$(shell go env GOPATH)
BUILD_DIR=build
all: build
all: build build-cli
build:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=1 $(GO) build -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
@echo "Built: $(BUILD_DIR)/$(BINARY)"
build-cli:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 $(GO) build -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/cli/
@echo "Built: $(BUILD_DIR)/$(CLI_BINARY)"
build-static:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=1 $(GO) build -tags netgo -ldflags '-extldflags "-static"' -o $(BUILD_DIR)/$(BINARY)-static ./cmd/homed/

358
PLAN.md
View File

@ -1,132 +1,246 @@
# HomeAgent 实施计划
# HomeAgent 架构与实施
## 已完成
## 架构概览
### Phase 0 — 核心基础设施 ✅
```
┌─────────────────────────────────────────────────────────────────────┐
│ homed (内核) │
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ ┌───────────────────┐ │
│ │LLM源管理 │ │Agent编排 │ │记忆管理 │ │知识管理 (TF-IDF) │ │
│ │Lua适配器 │ │主agent │ │图/文本/ │ │ │ │
│ │(协议转换) │ │子agent │ │文档三层 │ │ │ │
│ └──────────┘ └──────────┘ └────────────┘ └───────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ IO通道管理器 (IOManager) │ │
│ │ 排队通道 (Queue) + 中断通道 (Interrupt) │ │
│ │ 核心回环 _consolidation_ (记忆消歧/系统维护) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 阶段管道 (StageHost 7 阶段, 并行执行) + 事件总线 (EventBus) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ PluginSDK — 内核对插件的完整 Go API │ │
│ │ IO/工具/阶段/事件/记忆/知识/LLM/配置 │ │
│ └──────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ 内置插件 (编译入内核, init() 自注册) │
│ internal/plugins/all.go (空白导入触发 init) │
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ WebUI │ │ CLI │ │ OpenClaw │ │ Timer │ │
│ │ HTTP 服务│ │ Unix socket│ │SKILL.md→SDK│ │ timer_set 工具│ │
│ └──────────┘ └──────────┘ └────────────┘ └──────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ 动态插件 (<data>/plugins/<name>/ 按需加载) │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ plugin.json 元数据 + plugin.so (Go -buildmode=plugin) │ │
│ │ 或 main.lua (Lua 脚本, 预留) │ │
│ │ Registry.Load() 自动扫描, 无 factory → tryLoadSO → tryLoadLua │ │
│ └──────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ waiter (通用客户端) │
│ waiter -say "你好" → Unix socket → CLI 插件 │
│ waiter (交互模式) → 同上 │
└─────────────────────────────────────────────────────────────────────┘
```
| 任务 | 文件 | 状态 |
## 核心原则
1. **核心零 IO** — homed 不监听端口, 不启动 HTTP 服务, 不读 stdin
2. **一切外界交互都是插件** — 通过 PluginSDK 与核心交互
3. **插件自注册** — 每个插件的 `init()` 调用 `plugin.RegisterFactory()`, 内核无需硬编码
4. **动态 .so 加载** — 第三方插件编译为 `.so`, 放入 `plugins/<name>/plugin.so`
5. **PluginSDK 是内核"系统调用"** — 插件只能通过 SDK 访问核心能力
6. **Stage 并行执行** — 同阶段所有 handler goroutine 并发, StageContext 内嵌 RWMutex
7. **中断最高优先级** — 独立 `interceptLoop` 可随时打断进行中的 LLM 请求
8. **子 agent 是主 agent 的工具**`spawn_child`/`child_result`, 不是 SDK 部分
## 插件注册体系
### 两种注册路径
| 路径 | 场景 | 实现 |
|------|------|------|
| SDK 接口定义 | `internal/plugin/sdk/api.go` | ✅ |
| PluginAPIRegisterTool/RegisterStage/Subscribe/Publish | `internal/plugin/sdk/api.go` | ✅ |
| 插件内部 EventBus | `internal/plugin/sdk/bus.go` | ✅ |
| 系统 EventBus | `internal/events/bus.go` | ✅ |
| StageHost 编排器 | `internal/agent/core/stages.go` | ✅ |
| Agent 阶段注入7 个 hook 点) | `internal/agent/core/agent.go` | ✅ |
| 插件注册表 SDK 支持 | `internal/plugin/plugin.go` | ✅ |
| main.go 接入 EventBus + StageHost | `cmd/homed/main.go` | ✅ |
| 架构文档 v4 | `docs/ARCHITECTURE.md` | ✅ |
| **自注册 (init)** | 内置插件 (timer/cli/openclaw/webui) | 包 `init()``plugin.RegisterFactory(name, factory)` |
| **动态加载** | 第三方插件 | 扫描 `<data>/plugins/<name>/`, 读 manifest + .so |
---
## 待实施
### Phase 1 — 插件 SDK 迁移(当前)
| # | 任务 | 说明 | 优先级 |
|---|------|------|--------|
| 1.1 | SDK 添加 `ToolDef` 参数描述支持 | `RegisterTool` 接受 `ToolDef` 结构体(含 parameters而非纯 handler | high |
| 1.2 | StageHost 收集完整 ToolDef | 目前只传 name需传完整 description + parameters 给 LLM | high |
| 1.3 | Registry.AddPluginAPI 自动构建 StageHost | 替代手动 `syncFromRegistry` | high |
| 1.4 | 添加 `before_toolcall` deny 机制的测试 | 确保 `StageContext.Response` 在工具级别生效 | medium |
| 1.5 | 添加 `on_input` 改写消息的测试 | `stageCtx.RawMessage` 在阶段后被正确使用 | medium |
### Phase 2 — 迁移 WebUI 到 SDK 模式
| # | 任务 | 说明 | 优先级 |
|---|------|------|--------|
| 2.1 | WebUI 改为通过 `PluginAPI` 注册 | 不再依赖 `Device` 接口 | high |
| 2.2 | WebUI 通过 `Subscribe(EventAll)` 获取所有事件 | 取代 OutputChan 监听 | high |
| 2.3 | WebUI 注册 `output_send` 工具 | 通过 `RegisterTool` 暴露给 LLM | high |
| 2.4 | 删除 `internal/api/plugin.go` 的 Device 包装 | 不再需要 `Device` 适配器 | medium |
| 2.5 | Handler 改为通过 EventBus 获取 IOManager 引用 | 减少直接依赖 | low |
### Phase 3 — 迁移 QQ/OneBot 到 SDK 模式
| # | 任务 | 说明 | 优先级 |
|---|------|------|--------|
| 3.1 | OneBot 插件改为 `PluginAPI.RegisterTool` | 注册 `qq_send_private_msg` 等工具 | high |
| 3.2 | OneBot 接管后通过 `Publish(raw_input)` 发布事件 | 取代 IOManager.InjectInput | high |
| 3.3 | OneBot 注册阶段钩子 | 可接入群聊特定的 `pre_action` 逻辑 | medium |
| 3.4 | 删除 `internal/onebot/device.go` 的 Device 包装 | SDK 模式原生支持 | medium |
### Phase 4 — 迁移 OutputBus 到 SDK
| # | 任务 | 说明 | 优先级 |
|---|------|------|--------|
| 4.1 | 创建 `internal/outputbus/` 插件 | 管理 `output_send`/`output_list_channels` | high |
| 4.2 | 通过 `RegisterTool` 注册输出工具 | LLM 可直接调用 | high |
| 4.3 | 通过 `RegisterStage(before_output)` 拦截最终文本 | 渠道适配 | medium |
| 4.4 | Agent 内置的 output_* 工具改为委托给 outputbus | 解耦核心 | medium |
### Phase 5 — 清理旧组件
| # | 任务 | 说明 | 优先级 |
|---|------|------|--------|
| 5.1 | 删除 `Device` 接口定义 | 全部迁移后移除 | high |
| 5.2 | 删除 `IOManager.ExecuteTool` | 工具路由走 StageHost | high |
| 5.3 | 删除 `IOManager.EmitOutput`/`EmitOutputTo` | 走 EventBus | medium |
| 5.4 | 删除 `IOManager.AtomicSwapDevices` | 不再需要设备热替换 | medium |
| 5.5 | 删除 `PluginDevice` 包装器 | SDK 模式替代 | medium |
| 5.6 | 删除 `internal/onebot/device.go` | 已迁移到 SDK | high |
| 5.7 | 删除 `internal/api/plugin.go` | 已迁移到 SDK | medium |
| 5.8 | 精简 `cmd/homed/main.go` | 移除设备相关初始化 | medium |
### Phase 6 — 进程隔离
| # | 任务 | 说明 | 优先级 |
|---|------|------|--------|
| 6.1 | 实现 Unix Socket JSON-RPC 传输层 | 进程隔离模式 | low |
| 6.2 | `sdk.Run()` 自动检测 in-process/external | 开发 vs 生产 | low |
| 6.3 | 插件进程管理(启动/停止/健康检查) | Supervisor 扩展 | low |
### Phase 7 — 增强功能
| # | 任务 | 说明 | 优先级 |
|---|------|------|--------|
| 7.1 | WebUI D3.js 力导向图记忆星图 | 已有 API `GET /api/v1/memory/star` | low |
| 7.2 | Model Context Protocol (MCP) 支持 | 标准工具协议 | low |
| 7.3 | 多 Agent 支持 | 每个 Agent 独立上下文 | low |
| 7.4 | Python 插件 SDK | 扩展生态 | low |
---
## 文件最终结构Phase 5 完成后)
### 自注册流程
```
HomeAgent/
├── cmd/homed/main.go — 入口
├── internal/
├── agent/
├── core/
│ │ ├── agent.go — Agent 核心
│ │ │ ├── context.go — 相关性上下文
│ │ │ └── stages.go — StageHost
│ └── api/
│ └── provider.go — LLM Provider
├── events/
│ │ └── bus.go — 系统事件总线
│ ├── plugin/
└── sdk/
│ ├── api.go — PluginAPI
│ │ └── bus.go — 插件 EventBus
├── memory/ — 三层记忆
│ ├── knowledge/ — 知识库
│ ├── tracker/ — 变更追踪
│ ├── supervisor/ — 守护进程
│ └── plugins/ — 插件实现
│ ├── webui/ — HTTP API + 仪表盘
│ ├── onebot/ — QQ 通道
│ └── outputbus/ — 输出通道管理
├── docs/
│ └── ARCHITECTURE.md — 架构文档
├── DESIGN.md
├── PLAN.md
└── README.md
internal/plugins/timer/plugin.go
func init() {
plugin.RegisterFactory("timer", func(name string, cfg map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
}
internal/plugins/all.go
package plugins
import ( _ "timer" _ "cli" _ "openclaw" _ "webui" )
// 空白导入触发所有 init() → RegisterFactory
cmd/homed/main.go
cli.DefaultSocket = *cliSocket // 注入运行时变量
openclaw.SkillsDir = filepath.Join(...)
webui.Configure(httpAddr, sup, mem, sk, ...)
pluginReg.Load(plgDir) // 自动创建目录 + 加载
```
## 设计原则
### 动态 .so 加载
1. **核心零 IO** — Core 不依赖任何插件、设备、通道实现
2. **三通道标准** — 所有插件通过 Tool/Stage/Event 与核心交互
3. **增量迁移** — 每阶段保持向后兼容,旧组件与新 SDK 并行运行
4. **测试覆盖** — 每阶段提交前确保全部测试通过
插件目录结构:
```
<data>/plugins/myplugin/
plugin.json — 元数据 {name, version, description, author, entry}
plugin.so — Go -buildmode=plugin 编译, 导出 NewPlugin(name, config)
```
动态加载器 `internal/plugin/dynamic.go` 扫描 `.so`:
```go
func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
p, _ := plugin.Open(filepath.Join(dir, "plugin.so"))
sym, _ := p.Lookup("NewPlugin")
fn := sym.(func(string, map[string]interface{}) (sdk.Plugin, error))
return fn(name, config), nil
}
```
内置插件也可剥离为 .so, 当前保持 init 自注册。
## 输入双通道 + 中断打断
### 通道结构
```
IOManager
├── inputCh (chan *InputEvent, 256) — 排队通道, 按序处理
├── interruptCh (chan *InputEvent, 64) — 中断通道, 可打断 LLM
└── outputCh (chan *OutputEvent, 256) — 输出通道
```
### 中断打断机制
```
timer 插件 interceptLoop (独立 goroutine)
│ │
├─ s.InjectInterruptText(...) ─────────┤
│ │
│ ┌──────────┴──────────┐
│ │ (a) cancelLLM() │ → 取消进行中的 HTTP 请求
│ │ (b) interceptCh <- │ → process() 非阻塞读取
│ │ (c) InjectInput(...) │ → 空闲时 eventLoop 消费
│ └─────────────────────┘
│ │
▼ ▼
interruptCh process() 工具循环
每个 turn 开始前:
drainInterrupt() → 注入 [打断消息] system msg
```
**三种投递路径 (interceptLoop)**:
- **(a)** `cancelLLM()` — 直接取消当前 Provider HTTP 请求, 捕获 `context.Canceled`
- **(b)** `interceptCh <- text``process()` 每轮 LLM 调用前 `drainInterrupt()`, 注入 `[打断消息]` 到上下文
- **(c)** `InjectInput("interrupt", "text", ...)``eventLoop` 在空闲时收到新输入, 启动新处理循环
## 阶段管道 (Stage Pipeline)
7 个阶段, **并行执行**:
```
on_input → pre_action → post_action ↔ before_toolcall/after_toolcall → before_output → after_output
```
| 阶段 | 时机 | 插件能力 |
|------|------|---------|
| `on_input` | 消息到 Agent | 可短路回复 |
| `pre_action` | LLM 调用前 | 注入 system 消息 |
| `post_action` | LLM 返回后 | 审查/修改文本和工具调用 |
| `before_toolcall` | 工具执行前 | 拒绝/改参 |
| `after_toolcall` | 工具执行后 | 修改结果 |
| `before_output` | 输出前 | 改写最终文本 |
| `after_output` | 输出后 | 只读统计 |
并行规则: 所有 handler 用 goroutine 并发, StageContext 内嵌 `sync.RWMutex`, handler 通过 `Lock()/RLock()/IsResponded()` 协防。
## 配置体系 (ConfigRegistry)
全部配置持久化在 SQLite:
| 表 | 用途 | 访问 |
|----|------|------|
| `config` | 核心配置 (LLM/daemon/agent) | SettingsAPI.GetCore/SetCore |
| `config_<plugin>` | 插件独立配置 | SettingsAPI.Get/Set/List |
| | 跨插件读写 | GetPlugin/SetPlugin/ListPlugin/Dump |
## 内核入口 (cmd/homed/main.go)
初始化顺序:
```
1. 基础设施 → 记忆/技能/Lua/监督/追踪/IO/事件
2. 配置中心 (SQLite) + LLM Provider
3. 阶段管道 StageHost + 插件注册表 Registry
4. 注入内置插件依赖 (cli.DefaultSocket / openclaw.SkillsDir / webui.Configure)
5. Registry.Load(plgDir) → 自注册 + 动态加载
6. Agent 启动 (eventLoop + interceptLoop + distillLoop)
7. 等待信号 → 关机
```
## 目录结构
```
cmd/
homed/main.go — 内核入口 (零 IO)
waiter/main.go — CLI 客户端 (Unix socket)
internal/
sdk/ ★ PluginSDK (Go API)
plugin.go — Plugin 接口 + PluginSDK 结构体
memory.go / knowledge.go — 记忆/知识包装
settings.go / llm.go — 配置/LLM 源
agent/
core/
agent.go — Agent: eventLoop/interceptLoop/process
context.go — RelevanceContext (TF-IDF)
stages.go — StageHost (并行阶段管道)
api/provider.go — Provider 接口 + LuaAdaptedProvider
io/channel.go — IOManager (Queue/Interrupt/Output)
plugin/
registry.go — 注册表: 生命周期, Load, RegisterFactory
manifest.go — PluginManifest (plugin.json)
dynamic.go — .so 动态加载器
plugin.go — SKILL 插件解析
plugins/
all.go — 空白导入触发所有内置插件 init()
timer/ cli/ openclaw/ webui/ — 内置插件
events/bus.go — 系统事件总线
memory/ — 三层记忆 (Context→Document→Graph)
knowledge/ — 知识库
config/registry.go — 配置中心
tracker/ — overlayfs 变更追踪
supervisor/ — 守护管理
skill/ — 技能管理
lua/vm.go — Lua VM (LLM 协议适配)
pkg/types/ — 类型定义
docs/ARCHITECTURE.md — 完整架构文档
```
## 与旧架构关键区别
| 维度 | 之前 | 现在 |
|------|------|------|
| 插件注册 | main.go 硬编码 RegisterNative | init() 自注册 + .so 动态加载 |
| 内核入口 | 逐个 import 插件包 | 仅 import all.go (空白导入) |
| 中断处理 | 无消费者, 消息丢失 | interceptLoop + cancelLLM + drainInterrupt |
| 插件目录 | 手动硬编码创建 | Load() 自动为每个注册工厂创建 |
| 依赖注入 | 闭包绑定在 RegisterNative | 包级变量 (cli.DefaultSocket 等) |
| 阶段执行 | 顺序 | 并行 (goroutine + WaitGroup) |
## 构建与验证
```bash
make build # 编译 homed + waiter
./build/homed -data /tmp/ha # 启动
./build/waiter -say "你好" # 发送消息
```
要求: Go 1.19+, CGo (go-sqlite3), Linux (Unix socket + overlayfs).

View File

@ -1,83 +1,37 @@
# HomeAgent
单二进制 24/7 智能管家。**核心零 IO**所有输入输出通过插件,插件通过三通道与核心交互:工具、阶段钩子、事件订阅
24/7 智能管家。**核心零 IO**一切外界交互来自插件
## 架构
## 架构概览
```
外部QQ / HTTP / 硬件...
│ 通过插件注册
IOManager.InjectInput()
─────────────────────────────────────────────────┐
│ Agent Core │
on_input → Context → Memory Recall │
→ pre_action → [LLM ↔ 工具循环] → before_output
│ → output_send → after_output │
│ │
│ 内置:三层记忆 + 知识库 + Child Agent │
└─────────────────────────────────────────────────┘
├── Stage Pipeline7 阶段,插件可拦截/改写)
├── Tool System插件注册工具给 LLM
└── Event Bus插件订阅系统事件
homed (内核) — 零 IO纯管理
├── LLM 源管理 (Lua 适配器协议转换)
├── Agent 编排 (主 agent + interceptLoop + 子 agent)
├── 三层记忆 (Context → Document → Graph)
├── 知识库 (独立 TF-IDF)
├── IO 通道管理 (Queue / Interrupt / Output)
── 阶段管道 (StageHost: 7 阶段并行)
└── 事件总线 (EventBus)
▼ PluginSDK (Go API: 工具/阶段/事件/记忆/知识/LLM/配置)
plugins (init() 自注册 + .so 动态加载)
├── WebUI (HTTP 服务)
├── CLI (Unix socket)
├── OpenClaw 兼容 (SKILL.md → SDK 工具)
├── Timer (timer_set 工具 + 中断反馈)
└── 第三方 .so 插件 (plugins/<name>/plugin.so)
```
## 快速开始
核心文档: [PLAN.md](PLAN.md) · [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
## 构建
```bash
make build
./build/homed -data /tmp/homeagent
./build/waiter -say "你好"
```
依赖Go 1.19+CGogo-sqlite3)。
## 阶段管道
```
on_input → pre_action → post_action ↔ before_toolcall/after_toolcall → before_output → after_output
↑_______________|
循环
```
| 阶段 | 插件可做 |
|---|---|
| `on_input` | 鉴权、拉黑、改写、短路 |
| `pre_action` | 注入 context 消息 |
| `post_action` | 审查/改写 LLM 输出、增删工具 |
| `before_toolcall` | 拒绝、改参、审计 |
| `after_toolcall` | 脱敏、改写结果 |
| `before_output` | 改写最终文本、加格式 |
| `after_output` | 记录/统计 |
## 三层记忆
| 层 | 存储 | 容量 | 裁剪 |
|---|---|---|---|
| Context | 内存 TF-IDF | 30 条 | 余弦相似度排序→文档 |
| Document | JSON + 向量索引 | ∞ | 72h 冷→图 |
| Graph | SQLite 三元组 | ∞ | 定期重整+同义合并 |
## 插件三通道
| 通道 | 方向 | 用途 |
|---|---|---|
| `RegisterTool` | 插件→LLM | Agent 调用插件功能 |
| `RegisterStage` | 核心→插件 | 干预消息处理流 |
| `Subscribe/Publish` | 双向 | 审计/日志/通知 |
## 核心命令
```bash
make build # 编译
make run # 编译+启动
make test # 测试
make install # 系统安装
```
## 完整文档
详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
依赖: Go 1.19+, CGo (go-sqlite3), Linux (Unix socket + overlayfs)

View File

@ -13,12 +13,8 @@ import (
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
"gitcode.com/JianFeeeee/HomeAgent/internal/api"
"gitcode.com/JianFeeeee/HomeAgent/config"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugins/test_deepseek"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
@ -26,38 +22,40 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/onebot"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
openclaw "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw"
webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins"
)
func main() {
configPath := flag.String("config", config.DefaultConfigPath, "path to config file")
dataDir := flag.String("data", "/var/lib/homeagent", "data directory")
httpAddr := flag.String("webui", ":8080", "webui listen address")
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
flag.Parse()
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
log.Printf("[homed] starting HomeAgent v0.1.0")
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("load config: %v", err)
if *cliSocket == "" {
*cliSocket = filepath.Join(*dataDir, "cli.sock")
}
cfg.Daemon.DataDir = *dataDir
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
log.Printf("[homed] starting HomeAgent v0.1.0 (pure kernel)")
agentWorkDir := filepath.Join(cfg.Daemon.DataDir, "agentfs")
agentWorkDir := filepath.Join(*dataDir, "agentfs")
dirs := []string{
cfg.Daemon.DataDir,
filepath.Join(cfg.Daemon.DataDir, "snapshots"),
filepath.Join(cfg.Daemon.DataDir, "skills"),
filepath.Join(cfg.Daemon.DataDir, "plugins"),
filepath.Join(cfg.Daemon.DataDir, "changesets"),
filepath.Join(cfg.Daemon.DataDir, "memory"),
filepath.Join(cfg.Daemon.DataDir, "memory", "raw"),
filepath.Join(cfg.Daemon.DataDir, "adapters"),
*dataDir,
filepath.Join(*dataDir, "snapshots"),
filepath.Join(*dataDir, "skills"),
filepath.Join(*dataDir, "plugins"),
filepath.Join(*dataDir, "changesets"),
filepath.Join(*dataDir, "memory"),
filepath.Join(*dataDir, "memory", "raw"),
filepath.Join(*dataDir, "adapters"),
agentWorkDir,
}
for _, d := range dirs {
@ -66,8 +64,11 @@ func main() {
}
}
// === Graph Memory ===
memDB, err := memory.NewGraphDB(filepath.Join(cfg.Daemon.DataDir, "memory", "graph.db"))
// ========================================================================
// 基础设施层:记忆、技能
// ========================================================================
memDB, err := memory.NewGraphDB(filepath.Join(*dataDir, "memory", "graph.db"))
if err != nil {
log.Printf("[homed] warning: memory init failed: %v", err)
memDB = nil
@ -79,15 +80,9 @@ func main() {
}
memIdx := memory.NewIndexer(memDB)
// === Social Store人物特质与关系网===
socialStore := social.New(memDB)
if memDB != nil {
log.Printf("[homed] social store initialized")
}
// === Memory Pipeline ===
distiller := pipeline.NewDistiller(memDB, cfg.Daemon.DataDir, pipeline.DistillerConfig{
distiller := pipeline.NewDistiller(memDB, *dataDir, pipeline.DistillerConfig{
Interval: 10 * time.Minute,
RetentionDays: 7,
BatchSize: 50,
@ -97,41 +92,24 @@ func main() {
defer distiller.Stop()
}
// === Skills ===
skMgr := skill.NewManager(filepath.Join(cfg.Daemon.DataDir, "skills"))
skMgr := skill.NewManager(filepath.Join(*dataDir, "skills"))
if err := skMgr.Init(); err != nil {
log.Printf("[homed] warning: skill init failed: %v", err)
}
// === Plugin Registry (OpenClaw SKILL.md compatible) ===
pluginReg := plugin.NewRegistry()
// 注册内置原生插件工厂
pluginReg.RegisterNative("qq", func(name string, config map[string]interface{}, iom *agentIO.IOManager) (agentIO.Device, error) {
wsURL, _ := config["entry"].(string)
if wsURL == "" {
wsURL = "ws://127.0.0.1:6700"
}
accessToken, _ := config["access_token"].(string)
return onebot.NewDevice(name, wsURL, accessToken, iom), nil
})
// ========================================================================
// 配置中心SQLite 持久化,唯一配置源)
// ========================================================================
// === Config Registry (统一配置中心SQLite 持久化) ===
// 所有配置收敛到 SQLiteYAML 仅作首次 seed
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(cfg.Daemon.DataDir, "config.db"))
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(*dataDir, "config.db"))
defer cfgReg.Close()
cfgReg.SeedFrom(cfg)
cfg = cfgReg.ToConfig() // 此后全从 DB 读取
cfgReg.SeedDefaults(*dataDir)
cfg := cfgReg.ToConfig()
pluginReg.SetConfigRegistry(cfgReg)
log.Printf("[homed] config registry seeded from YAML, %d keys in SQLite", len(cfgReg.List("")))
// ========================================================================
// Lua VMLLM 协议适配)
// ========================================================================
// === Supervisor ===
sup := supervisor.New(cfg)
if err := sup.Start(); err != nil {
log.Fatalf("start supervisor: %v", err)
}
// === Lua VM ===
luaVM := luapkg.NewVM(filepath.Join(cfg.Daemon.DataDir, "adapters"))
if err := luaVM.Start(); err != nil {
log.Printf("[homed] warning: lua vm init failed: %v", err)
@ -139,66 +117,19 @@ func main() {
defer luaVM.Stop()
}
// === IO Abstraction Layer (唯一输入路径) ===
iom := agentIO.NewIOManager()
// ========================================================================
// 守护管理(代理生命周期管理)
// ========================================================================
// 插件绑定 IO 管理器 → 插件自动注册为 IO 设备
pluginReg.SetIOManager(iom)
// 首次加载插件
if result, err := pluginReg.Reload(filepath.Join(cfg.Daemon.DataDir, "plugins")); err != nil {
log.Printf("[homed] warning: load plugins: %v", err)
} else {
log.Printf("[homed] %s", result)
sup := supervisor.New(cfg)
if err := sup.Start(); err != nil {
log.Fatalf("start supervisor: %v", err)
}
// === Text Memory (三层记忆: Context → Text → Graph) ===
textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
if err := textMem.Start(); err != nil {
log.Printf("[homed] warning: text memory start: %v", err)
} else {
defer textMem.Stop()
log.Printf("[homed] text memory active at %s", filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
}
// ========================================================================
// 变更追踪overlayfs
// ========================================================================
// Wire IO output events → TextMemory + distiller → GraphMemory
if distiller != nil {
go func() {
for evt := range iom.OutputChan() {
if evt.Target == "memory" && evt.Type == "memory_candidate" {
source, _ := evt.Payload["source"].(string)
input, _ := evt.Payload["input"].(string)
response, _ := evt.Payload["response"].(string)
toolsUsed, _ := evt.Payload["tools_used"].([]string)
agentID, _ := evt.Payload["agent_id"].(string)
// 1. 写文本记忆(持久化原始日志)
if input != "" && textMem != nil {
te := text.Event{
Timestamp: time.Now().Unix(),
Source: source,
Input: input,
Response: response,
ToolsUsed: toolsUsed,
AgentID: agentID,
}
if err := textMem.Append(te); err != nil {
log.Printf("[homed] text memory append: %v", err)
}
}
// 2. 喂蒸馏器(生成三元组 → 图记忆)
if input != "" {
distiller.Append("agent", "user", input)
}
if response != "" {
distiller.Append("agent", "assistant", response)
}
}
}
}()
}
// === Change Tracker (overlayfs-based, 追踪所有修改) ===
trk := tracker.NewTracker(cfg.Daemon.DataDir, agentWorkDir)
if err := trk.Init(); err != nil {
log.Printf("[homed] warning: tracker init: %v", err)
@ -210,7 +141,64 @@ func main() {
}
}
// === API Provider Manager多 LLM 源,通过 Lua 适配器兼容不同 API===
// ========================================================================
// 内核 APIIOManagerIO 抽象层) + EventBus事件总线
// 所有插件通过这两个通道与核心交互
// ========================================================================
iom := agentIO.NewIOManager()
evBus := events.NewBus()
log.Printf("[homed] kernel API ready: IOManager + EventBus")
// ========================================================================
// 文本记忆 + 记忆蒸馏管线
// ========================================================================
textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
if err := textMem.Start(); err != nil {
log.Printf("[homed] warning: text memory start: %v", err)
} else {
defer textMem.Stop()
log.Printf("[homed] text memory active at %s", filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
}
go func() {
for evt := range iom.OutputChan() {
if evt.Target == "memory" && evt.Type == "memory_candidate" {
source, _ := evt.Payload["source"].(string)
input, _ := evt.Payload["input"].(string)
response, _ := evt.Payload["response"].(string)
toolsUsed, _ := evt.Payload["tools_used"].([]string)
agentID, _ := evt.Payload["agent_id"].(string)
if input != "" && textMem != nil {
te := text.Event{
Timestamp: time.Now().Unix(),
Source: source,
Input: input,
Response: response,
ToolsUsed: toolsUsed,
AgentID: agentID,
}
if err := textMem.Append(te); err != nil {
log.Printf("[homed] text memory append: %v", err)
}
}
if input != "" {
distiller.Append("agent", "user", input)
}
if response != "" {
distiller.Append("agent", "assistant", response)
}
}
}
}()
// ========================================================================
// LLM Provider 管理(多源,通过 Lua 适配器协议转换)
// ========================================================================
apiKey := cfg.LLM.APIKey
if apiKey == "" {
apiKey = os.Getenv("DEEPSEEK_API_KEY")
@ -232,13 +220,31 @@ func main() {
}, luaVM, src.Adapter)
providerMgr.Register(src.Name, luaProvider)
}
// 默认源由 config 指定
if cfg.LLM.Provider != "" {
providerMgr.SetDefault(cfg.LLM.Provider)
}
provider := providerMgr.Default()
// === Personality (固定人格内核) ===
// ========================================================================
// 文档记忆 + 知识库
// ========================================================================
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"))
if err := docStore.Start(); err != nil {
log.Printf("[homed] warning: document store: %v", err)
}
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
if err := ks.Start(); err != nil {
log.Printf("[homed] warning: knowledge store: %v", err)
} else {
log.Printf("[homed] knowledge store active with %d items", len(ks.List()))
}
// ========================================================================
// 人格设定
// ========================================================================
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
personality, err := agentPkg.LoadPersonality(personalPath)
if err != nil {
@ -248,41 +254,55 @@ func main() {
log.Printf("[homed] personality loaded (%d bytes)", len(personality.Content))
}
// === Document Memory (第二层记忆:上下文→文档) ===
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"))
if err := docStore.Start(); err != nil {
log.Printf("[homed] warning: document store: %v", err)
}
// ========================================================================
// 阶段管道StageHost+ 插件系统Registry
// ========================================================================
// === Knowledge Store (知识库) ===
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
if err := ks.Start(); err != nil {
log.Printf("[homed] warning: knowledge store: %v", err)
} else {
log.Printf("[homed] knowledge store active with %d items", len(ks.List()))
}
// === Event Bus (系统事件总线) ===
evBus := events.NewBus()
log.Printf("[homed] event bus initialized")
// === Stage Host (阶段管道编排) ===
stageHost := agentCore.NewStageHost()
stageHost.SyncFromRegistry(pluginReg)
log.Printf("[homed] stage host initialized with %d plugin sdks", pluginReg.SDKPluginCount())
// === Test DeepSeek Plugin ===
tdBus := sdk.NewInProcessBus()
tdPlugin := test_deepseek.New(tdBus)
// 插件作用域的配置表RegisterPluginAPI 自动设置 config_test_deepseek 表)
if err := pluginReg.RegisterPluginAPI(tdPlugin); err != nil {
log.Printf("[homed] warning: register test_deepseek plugin: %v", err)
} else {
stageHost.RegisterPlugin(tdPlugin)
log.Printf("[homed] test_deepseek plugin registered (config table: config_test_deepseek)")
pluginReg := plugin.NewRegistry()
pluginReg.SetIOManager(iom)
pluginReg.SetEventBus(evBus)
pluginReg.SetMemory(memDB)
pluginReg.SetTextMemory(textMem)
pluginReg.SetDocStore(docStore)
pluginReg.SetKnowledge(ks)
pluginReg.SetProviderManager(providerMgr)
pluginReg.SetConfigRegistry(cfgReg)
pluginReg.SetPluginDir(filepath.Join(cfg.Daemon.DataDir, "plugins"))
// Wire registration callbacks: plugins' RegisterTool/RegisterStage → StageHost
pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
return stageHost.RegisterTool(name, def, handler)
})
pluginReg.SetStageRegistrar(func(stage sdk.Stage, handler sdk.StageHandler) {
stageHost.RegisterStage(stage, handler)
})
pluginReg.SetAPIRegistrar(func(name string) error {
return nil
})
// 为内置插件注入内核依赖(各插件通过 init() 自注册工厂)
cli.DefaultSocket = *cliSocket
openclaw.SkillsDir = filepath.Join(cfg.Daemon.DataDir, "skills")
webui.Configure(*httpAddr,
sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks, trk, cfgReg, pluginReg, evBus,
)
// Auto-create plugins directory (without hardcoding plugin names)
plgDir := filepath.Join(cfg.Daemon.DataDir, "plugins")
os.MkdirAll(plgDir, 0755)
// Load all plugins — each scans its own dir and is loaded via factory or .so
if err := pluginReg.Load(plgDir); err != nil {
log.Printf("[homed] warning: load plugins: %v", err)
}
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
// ========================================================================
// Agent Core
// ========================================================================
// === Single Agent Core ===
agent := agentCore.New(agentCore.AgentConfig{
ID: "main",
SystemPrompt: `你是 HomeAgent一个持续运行的个人管家。
@ -306,25 +326,20 @@ func main() {
12. llm_list_sources — 列出所有可用的 LLM 源
13. llm_set_source — 切换到指定 LLM 源
当用户问及个人信息或历史时,调用 memory_recall。
当用户告诉了你新的个人信息时,调用 memory_commit。
当用户提到某个人的性格、喜好或人际关系时,使用 person_set_trait 和 person_relate 记录。
需要查询知识时使用 knowledge_search。
如需切换 LLM 供应商(如从 DeepSeek 切到 OpenAI使用 llm_list_sources 查看可用源,再用 llm_set_source 切换。
回复你的真实想法,用自然语言与用户交流。`,
Provider: provider,
ProviderManager: providerMgr,
IO: iom,
Memory: memDB,
Indexer: memIdx,
Skills: skMgr,
Tracker: trk,
MaxToolTurns: 10,
DocStore: docStore,
Knowledge: ks,
SocialStore: socialStore,
TextMemory: textMem,
Personality: personality,
IO: iom,
Memory: memDB,
Indexer: memIdx,
Skills: skMgr,
Tracker: trk,
MaxToolTurns: 10,
DocStore: docStore,
Knowledge: ks,
SocialStore: socialStore,
TextMemory: textMem,
Personality: personality,
PluginReg: pluginReg,
PluginDir: filepath.Join(cfg.Daemon.DataDir, "plugins"),
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
@ -334,27 +349,23 @@ func main() {
agent.Start()
defer agent.Stop()
// Wire supervisor with tracker + agent registration (after both exist)
sup.SetTracker(trk)
sup.RegisterAgent("main")
log.Printf("[homed] main agent started, model=%s base=%s sources=%d adapters=%d",
cfg.LLM.Model, cfg.LLM.BaseURL, len(cfg.LLM.Sources), len(luaVM.ListAdapters()))
log.Printf("[homed] kernel ready, waiting for plugin IO...")
// === Built-in HTTP API & WebUI Plugin ===
webui := api.NewWebUIPlugin(
"webui", cfg.Daemon.ListenAddr,
sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks, trk, cfgReg, pluginReg,
)
iom.RegisterDevice(webui)
webui.Start()
defer webui.Stop()
// ========================================================================
// 等待退出信号
// ========================================================================
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Printf("[homed] shutting down...")
pluginReg.StopAll()
if trk != nil {
trk.Stop()
}
@ -364,5 +375,3 @@ func main() {
sup.Shutdown()
log.Printf("[homed] stopped")
}

180
cmd/waiter/main.go Normal file
View File

@ -0,0 +1,180 @@
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
)
func main() {
socket := flag.String("socket", "/var/lib/homeagent/cli.sock", "unix socket path")
remote := flag.String("remote", "", "remote webui URL (e.g. http://127.0.0.1:8080)")
say := flag.String("say", "", "send a message and print response (one-shot, no TUI)")
flag.Parse()
if *say != "" {
if *remote != "" {
sayRemote(*remote, *say)
} else {
sayLocal(*socket, *say)
}
return
}
if *remote != "" {
runRemote(*remote)
} else {
runLocal(*socket)
}
}
// sayLocal sends one message via Unix socket and prints the response
func sayLocal(socketPath, message string) {
conn, err := net.Dial("unix", socketPath)
if err != nil {
log.Fatalf("connect to %s: %v", socketPath, err)
}
defer conn.Close()
fmt.Fprintf(conn, "%s\n", message)
scanner := bufio.NewScanner(conn)
scanner.Scan()
if err := scanner.Err(); err != nil {
log.Fatalf("read: %v", err)
}
var resp struct {
Type string `json:"type"`
Content string `json:"content"`
Error string `json:"error"`
}
if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil {
fmt.Println(scanner.Text())
return
}
switch resp.Type {
case "response":
fmt.Println(resp.Content)
case "error":
log.Fatalf("error: %s", resp.Error)
default:
fmt.Println(scanner.Text())
}
}
// sayRemote sends one message via HTTP and prints the response
func sayRemote(baseURL, message string) {
baseURL = strings.TrimRight(baseURL, "/")
body := fmt.Sprintf(`{"message":%q}`, message)
resp, err := http.Post(baseURL+"/api/v1/chat", "application/json", strings.NewReader(body))
if err != nil {
log.Fatalf("http post: %v", err)
}
defer resp.Body.Close()
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
if content, ok := result["response"].(string); ok {
fmt.Println(content)
}
}
// runLocal starts an interactive TUI via Unix socket
func runLocal(socketPath string) {
conn, err := net.Dial("unix", socketPath)
if err != nil {
log.Fatalf("connect to %s: %v", socketPath, err)
}
defer conn.Close()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
var resp struct {
Type string `json:"type"`
Content string `json:"content"`
Error string `json:"error"`
}
if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil {
fmt.Println(scanner.Text())
continue
}
switch resp.Type {
case "response":
fmt.Println(resp.Content)
case "error":
fmt.Fprintf(os.Stderr, "error: %s\n", resp.Error)
default:
fmt.Println(scanner.Text())
}
}
}()
scanner := bufio.NewScanner(os.Stdin)
fmt.Print("> ")
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
fmt.Print("> ")
continue
}
if line == "/exit" || line == "/quit" {
break
}
fmt.Fprintf(conn, "%s\n", line)
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(60 * time.Second):
}
}
// runRemote starts an interactive TUI via HTTP
func runRemote(baseURL string) {
baseURL = strings.TrimRight(baseURL, "/")
scanner := bufio.NewScanner(os.Stdin)
fmt.Print("> ")
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
fmt.Print("> ")
continue
}
if line == "/exit" || line == "/quit" {
break
}
body := fmt.Sprintf(`{"message":%q}`, line)
resp, err := http.Post(baseURL+"/api/v1/chat", "application/json", strings.NewReader(body))
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
fmt.Print("> ")
continue
}
var result map[string]interface{}
if resp.StatusCode == http.StatusOK {
json.NewDecoder(resp.Body).Decode(&result)
}
resp.Body.Close()
if content, ok := result["response"].(string); ok {
fmt.Println(content)
}
fmt.Print("> ")
}
}

View File

@ -583,70 +583,71 @@ Agent.eventLoop() → handleInput → processTextInput
## 十五、代码结构
```
cmd/homed/main.go — 入口:组装所有子系统
cmd/homed/main.go — 入口:组装所有子系统, 零 IO
cmd/waiter/main.go — CLI 客户端 (Unix socket)
internal/
├── agent/
│ ├── core/
│ │ ├── agent.go — Agent 核心:事件循环、工具循环、心跳
│ │ ├── agent.go — Agent: eventLoop/interceptLoop/process/distillLoop
│ │ ├── context.go — RelevanceContextTF-IDF 上下文管理
│ │ └── stages.go — StageHost阶段管道编排
│ │ └── stages.go — StageHost阶段管道编排 (并行执行)
│ ├── api/
│ │ └── provider.go — Provider 接口 + DeepSeek/Ollama 实现
│ │ └── provider.go — Provider 接口 + DeepSeek/Ollama/LuaAdaptedProvider
│ ├── io/
│ │ └── channel.go — IOManager + Device 接口(过渡期保留)
│ │ └── channel.go — IOManager (排队/中断/输出三通道)
│ └── 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 端点 + WebUI (内联 HTML/JS/CSS)
│ └── plugin.go — WebUI Device 包装
├── sdk/ ★ PluginSDK: 核心 Go API
│ ├── plugin.go — Plugin 接口 + PluginSDK 结构体
│ ├── memory.go — MemoryAPI (图/文本/文档)
│ ├── knowledge.go KnowledgeAPI
├── settings.go — SettingsAPI (配置)
── llm.go — LLMAPI (源管理)
├── config/
│ └── registry.go — ConfigRegistry统一配置中心
│ └── registry.go — ConfigRegistry统一配置中心 (SQLite)
├── events/
│ └── bus.go — 系统事件总线 (Publish/Subscribe)
├── plugin/
│ ├── registry.go — 注册表:生命周期 Load/StopAll/Reload, RegisterFactory
│ ├── manifest.go — PluginManifest (plugin.json 元数据)
│ ├── dynamic.go — .so 动态加载器 (Go plugin.Open)
│ └── plugin.go — SKILL 插件解析 (OpenClaw 兼容)
├── plugins/
│ ├── all.go — 空白导入触发所有内置插件 init()
│ ├── timer/plugin.go — 定时器 (timer_set 工具 + 中断反馈)
│ ├── cli/plugin.go — CLI 插件 (Unix socket, InjectTextSync)
│ ├── openclaw/plugin.go — OpenClaw 兼容 (SKILL.md → SDK 工具注册)
│ └── webui/ — WebUI 插件 (HTTP 服务器 + 仪表盘)
│ ├── plugin.go
│ └── handler.go
├── memory/
│ ├── graph.go — SQLite 图数据库
│ ├── indexer.go — 图索引器
│ ├── vector/store.go — TF-IDF 向量存储
│ ├── document/doc.go — 文档记忆
│ ├── text/text.go — 文本记忆JSONL
│ ├── text/text.go — 文本记忆 (JSONL)
│ └── pipeline/ — 蒸馏器
├── knowledge/
│ └── knowledge.go — 知识系统
├── plugin/
│ ├── plugin.go — 插件注册表 + ConfigRegistry + SettingsAPI 注入
│ └── sdk/
│ ├── api.go — PluginAPI (Tool/Stage/Event/Settings/Memory/Knowledge)
│ └── bus.go — 插件内部 EventBus 接口
├── onebot/ — OneBot V11 QQ 协议实现
├── tracker/ — 变更追踪 (overlayfs)
├── supervisor/ — 守护进程
├── skill/ — 技能管理器
├── lua/
│ ├── vm.go — Lua VM (json.encode/decode, CallTransformRequest/Response)
│ └── adapters/
│ ├── openai.lua — OpenAI 协议适配
│ ├── deepseek.lua — DeepSeek 协议适配 (temperature=0, reasoning)
│ └── ollama.lua — Ollama 协议适配
│ ├── vm.go — Lua VM (json.encode/decode, transform)
│ └── adapters/ — LLM 协议适配器脚本
├── network/ — 网络监控
├── container/ — 容器管理
├── snapshot/ — 快照
├── embed/ — 嵌入
└── tokenizer/ — 分词器
config/ — 顶层配置加载
├── config.go — Config 结构 + 加载/保存
├── config.go — Config 结构
└── config.yaml
pkg/types/ — 类型定义
docs/
── ARCHITECTURE.md — 本架构文档
── ARCHITECTURE.md — 本架构文档
├── ADAPTER.md — Lua 适配器文档
└── PLAN.md — 实施计划/概览
```
---
@ -664,3 +665,109 @@ docs/
| 核心 IO | IOManager `EmitOutput` 直出 | 全部走 `output_send` 工具 |
| 插件工具路由 | IOManager `ExecuteTool` 链 | StageHost + Registry 双层路由 |
| 内部任务 | 无 | selfInputCh 自循环通道(不经过 IO |
---
## 十七、插件自注册与动态加载
### 自注册机制
内置插件通过 `init()` 自注册,无需 `main.go` 硬编码:
```go
// internal/plugins/timer/plugin.go
func init() {
plugin.RegisterFactory("timer", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
}
```
空白导入文件 `internal/plugins/all.go` 触发所有内置插件的 `init()`
```go
package plugins
import (
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/timer"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
)
```
`main.go` 不再需要 `RegisterNative` 调用,只需设置包级变量注入运行时依赖:
```go
cli.DefaultSocket = *cliSocket
openclaw.SkillsDir = filepath.Join(*dataDir, "skills")
webui.Configure(httpAddr, sup, memDB, ...)
pluginReg.Load(plgDir) // 自动扫描目录 + 使用已注册的工厂
```
`Load()` 分两步执行:
1. 扫描 `plugins/` 下已有子目录,匹配已注册工厂加载
2. 对已注册工厂但尚无目录的,自动创建目录并加载
### 动态 .so 加载
第三方插件编译为 `.so` 文件,放入 `<data>/plugins/<name>/`
```
<data>/plugins/myplugin/
plugin.json { "name": "myplugin", "version": "1.0", "entry": "plugin.so" }
plugin.so (Go -buildmode=plugin, 导出 NewPlugin 函数)
```
加载器 (`internal/plugin/dynamic.go`) 流程:
```go
tryLoadSO(dir, name, config):
1. plugin.Open("plugin.so")
2. Lookup("NewPlugin") 签名 func(name string, config map[string]interface{}) (sdk.Plugin, error)
3. 调用 factory, 包装为 dynamicPlugin
```
内置插件保持 init() 自注册编译进内核,第三方插件以 .so 形式热加载。
---
## 十八、中断打断机制
### 架构
```
interceptLoop (独立 goroutine)
├── InputInterruptChan() ← 定时器/消息通知等
├── (a) cancelLLM() → Provider HTTP 请求取消
├── (b) interceptCh < text → process() turn 前 drainInterrupt()
└── (c) InjectInput("interrupt", "text", ...) → 空闲时触发新处理
```
### 三种投递路径
| 路径 | 目标 | 触发时机 |
|------|------|---------|
| **(a) cancelLLM** | 取消进行中的 Provider HTTP 请求 | 拦截到 `context.Canceled` |
| **(b) interceptCh** | process() 工具循环中注入 `[打断消息]` | 每个 LLM call 前 `drainInterrupt()` |
| **(c) InjectInput** | eventLoop 空闲时启动新处理循环 | 无进行中请求时 |
### process() 内中断注入
```go
for turn := 0; turn < maxTurns; turn++ {
if text := a.drainInterrupt(); text != "" {
msgs = append(msgs, agentAPI.Message{
Role: "system",
Content: fmt.Sprintf("[打断消息] %s", text),
})
}
// LLM call with cancellable context
reqCtx, cancel := context.WithCancel(a.ctx)
a.cancelLLM = cancel // interceptLoop 可调用
resp, err := provider.Chat(reqCtx, req)
a.cancelLLM = nil
cancel()
// ... tool call loop ...
}
```

BIN
homed

Binary file not shown.

View File

@ -18,7 +18,7 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
@ -82,6 +82,13 @@ type Agent struct {
childMu sync.Mutex
childNextID int64
childResults map[string]string
// 高优先级打断通道interceptLoop 注入process() 在工具循环轮次间非阻塞读取
interceptCh chan string
// 进行中的 LLM 请求取消函数interceptLoop 可调用以在请求中打断
cancelLLM context.CancelFunc
llmMu sync.Mutex
}
type AgentConfig struct {
@ -148,11 +155,13 @@ func New(cfg AgentConfig) *Agent {
eventBus: cfg.EventBus,
selfInputCh: make(chan string, 64),
childResults: make(map[string]string),
interceptCh: make(chan string, 64),
}
}
func (a *Agent) Start() {
go a.eventLoop()
go a.interceptLoop()
go a.distillLoop()
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
}
@ -191,6 +200,45 @@ func (a *Agent) eventLoop() {
}
}
// interceptLoop 独立 goroutine 监控中断通道。
// 两种路径投递:
// a) 通过 cancelLLM + interceptCh 直接打断进行中的 LLM 请求
// b) 通过 a.io.InjectInput() → InputChan → eventLoop代理空闲时触发新处理循环
func (a *Agent) interceptLoop() {
for {
select {
case evt := <-a.io.InputInterruptChan():
text, _ := evt.Payload["content"].(string)
if text == "" {
continue
}
log.Printf("[agent] interrupt from %s: %s", evt.Source, truncateStr(text, 80))
// (a) 直接取消进行中的 LLM 请求
a.llmMu.Lock()
if a.cancelLLM != nil {
a.cancelLLM()
log.Printf("[agent] LLM request cancelled by interrupt")
}
a.llmMu.Unlock()
// 注入拦截通道 — process() 在工具循环中非阻塞读取
select {
case a.interceptCh <- text:
default:
}
// (b) 投递为新输入 — 代理空闲时 eventLoop 会消费
a.io.InjectInput("interrupt", "text", map[string]interface{}{
"content": fmt.Sprintf("[interrupt] %s: %s", evt.Source, text),
})
case <-a.ctx.Done():
return
}
}
}
// handleSelfInput 处理自循环输入(内部任务,不经过 IO 层)
func (a *Agent) handleSelfInput(task string) {
a.processTextInput(&agentIO.InputEvent{
@ -361,6 +409,15 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
}
for turn := 0; turn < a.maxTurns; turn++ {
// === 高优先级打断:每次 LLM 调用前检查拦截通道 ===
if text := a.drainInterrupt(); text != "" {
msgs = append(msgs, agentAPI.Message{
Role: "system",
Content: fmt.Sprintf("[打断消息] 用户发来一条紧急消息,请优先处理:\n%s", text),
})
log.Printf("[agent] interrupt injected before LLM call (turn %d)", turn)
}
req := &agentAPI.CompletionRequest{
Messages: msgs,
MaxTokens: 4096,
@ -371,7 +428,19 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
},
}
resp, err := a.provider.Chat(a.ctx, req)
// 可取消的 LLM 调用interceptLoop 通过 cancelLLM 打断进行中的请求
reqCtx, reqCancel := context.WithCancel(a.ctx)
a.llmMu.Lock()
a.cancelLLM = reqCancel
a.llmMu.Unlock()
resp, err := a.provider.Chat(reqCtx, req)
a.llmMu.Lock()
a.cancelLLM = nil
a.llmMu.Unlock()
reqCancel()
if err != nil {
return "", toolsUsed, fmt.Errorf("provider: %w", err)
}
@ -1969,9 +2038,19 @@ func getFloat(m map[string]interface{}, key string) float64 {
}
func truncateStr(s string, max int) string {
runes := []rune(s)
if len(runes) > max {
return string(runes[:max]) + "..."
if len(s) <= max {
return s
}
return s[:max] + "..."
}
// drainInterrupt 非阻塞读取 interceptCh 中的一条打断消息。
// 若有多条,只取最先到达的一条(丢弃后续)。
func (a *Agent) drainInterrupt() string {
select {
case text := <-a.interceptCh:
return text
default:
return ""
}
return s
}

View File

@ -2,73 +2,89 @@ package core
import (
"fmt"
"sync"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
type StageHost struct {
plugins []*sdk.PluginAPI
mu sync.RWMutex
toolDefs []sdk.ToolDef
tools map[string]sdk.ToolHandler
stages map[sdk.Stage][]sdk.StageHandler
}
func NewStageHost() *StageHost {
return &StageHost{
tools: make(map[string]sdk.ToolHandler),
tools: make(map[string]sdk.ToolHandler),
stages: make(map[sdk.Stage][]sdk.StageHandler),
}
}
func (h *StageHost) RegisterPlugin(api *sdk.PluginAPI) {
h.plugins = append(h.plugins, api)
for name, handler := range api.Tools() {
h.tools[name] = handler
h.toolDefs = append(h.toolDefs, sdk.ToolDef{Name: name})
func (h *StageHost) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
h.mu.Lock()
defer h.mu.Unlock()
if _, exists := h.tools[name]; exists {
return fmt.Errorf("tool %s already registered", name)
}
h.tools[name] = handler
h.toolDefs = append(h.toolDefs, def)
return nil
}
// SyncFromRegistry 从插件注册表同步 SDK 插件
func (h *StageHost) SyncFromRegistry(reg *plugin.Registry) {
if reg == nil {
return
}
for _, td := range reg.GetAllSDKToolDefs() {
h.toolDefs = append(h.toolDefs, td)
}
func (h *StageHost) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {
h.mu.Lock()
defer h.mu.Unlock()
h.stages[stage] = append(h.stages[stage], handler)
}
func (h *StageHost) GetToolDefs() []sdk.ToolDef {
return h.toolDefs
h.mu.RLock()
defer h.mu.RUnlock()
defs := make([]sdk.ToolDef, len(h.toolDefs))
copy(defs, h.toolDefs)
return defs
}
func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (interface{}, error) {
if handler, ok := h.tools[name]; ok {
return handler(args)
h.mu.RLock()
handler, ok := h.tools[name]
h.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("tool %s not found in any plugin", name)
}
return nil, fmt.Errorf("tool %s not found in any plugin", name)
return handler(args)
}
// RunStage 并行调用同阶段所有注册的处理函数。
// 各 handler 共享 *StageContext通过其内置 RWMutex 安全读写:
// - 只读操作先调用 ctx.RLock() / defer ctx.RUnlock()
// - 写操作(如设置 ctx.Response先调用 ctx.Lock() / defer ctx.Unlock()
// 如果任意 handler 设置了 Response后续 handler 可通过 ctx.IsResponded() 判断后提前返回。
func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
for _, p := range h.plugins {
for _, handler := range p.StageHandlers(stage) {
if err := handler(ctx); err != nil {
return
}
if ctx.Response != nil {
return
}
}
h.mu.RLock()
handlers := h.stages[stage]
h.mu.RUnlock()
if len(handlers) == 0 {
return
}
var wg sync.WaitGroup
for _, handler := range handlers {
wg.Add(1)
go func(fn sdk.StageHandler) {
defer wg.Done()
fn(ctx)
}(handler)
}
wg.Wait()
}
func (h *StageHost) RunStageAll(stage sdk.Stage, ctx *sdk.StageContext) {
for _, p := range h.plugins {
for _, handler := range p.StageHandlers(stage) {
handler(ctx)
}
}
h.RunStage(stage, ctx)
}
func (h *StageHost) PluginCount() int {
return len(h.plugins)
func (h *StageHost) ToolCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.tools)
}

View File

@ -1,23 +1,21 @@
package core
import (
"sync"
"sync/atomic"
"testing"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func TestStageHostRegisterPlugin(t *testing.T) {
func TestStageHostRegisterTool(t *testing.T) {
host := NewStageHost()
api := sdk.NewPluginAPI("test", "1.0.0", nil, nil, nil)
api.RegisterTool("test_tool", func(args map[string]interface{}) (interface{}, error) {
err := host.RegisterTool("test_tool", sdk.ToolDef{Name: "test_tool"}, func(args map[string]interface{}) (interface{}, error) {
return "ok", nil
})
host.RegisterPlugin(api)
if host.PluginCount() != 1 {
t.Errorf("expected 1 plugin, got %d", host.PluginCount())
if err != nil {
t.Fatalf("register: %v", err)
}
defs := host.GetToolDefs()
@ -29,16 +27,22 @@ func TestStageHostRegisterPlugin(t *testing.T) {
}
}
func TestStageHostRegisterToolDuplicate(t *testing.T) {
host := NewStageHost()
host.RegisterTool("dup", sdk.ToolDef{Name: "dup"}, nil)
err := host.RegisterTool("dup", sdk.ToolDef{Name: "dup"}, nil)
if err == nil {
t.Error("expected error on duplicate tool")
}
}
func TestStageHostExecuteTool(t *testing.T) {
host := NewStageHost()
api := sdk.NewPluginAPI("test", "1.0.0", nil, nil, nil)
api.RegisterTool("hello", func(args map[string]interface{}) (interface{}, error) {
host.RegisterTool("hello", sdk.ToolDef{Name: "hello"}, func(args map[string]interface{}) (interface{}, error) {
return "world", nil
})
host.RegisterPlugin(api)
result, err := host.ExecuteTool("hello", nil)
if err != nil {
t.Fatalf("execute: %v", err)
@ -55,16 +59,13 @@ func TestStageHostExecuteTool(t *testing.T) {
func TestStageHostRunStage(t *testing.T) {
host := NewStageHost()
api := sdk.NewPluginAPI("test", "1.0.0", nil, nil, nil)
var called bool
api.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
host.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
called = true
return nil
})
host.RegisterPlugin(api)
ctx := &sdk.StageContext{RawMessage: "hello"}
host.RunStage(sdk.StageOnInput, ctx)
@ -73,56 +74,78 @@ func TestStageHostRunStage(t *testing.T) {
}
}
func TestStageHostRunStageShortCircuit(t *testing.T) {
func TestStageHostRunStageParallel(t *testing.T) {
host := NewStageHost()
api1 := sdk.NewPluginAPI("p1", "1.0.0", nil, nil, nil)
api1.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
resp := "short-circuited"
ctx.Response = &resp
// Two handlers that both try to set Response under Lock.
// Only the first to acquire Lock actually wins; the second sees IsResponded() and skips.
host.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
ctx.Lock()
if ctx.Response == nil {
resp := "from-first"
ctx.Response = &resp
}
ctx.Unlock()
return nil
})
var api2called bool
api2 := sdk.NewPluginAPI("p2", "1.0.0", nil, nil, nil)
api2.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
api2called = true
host.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
ctx.Lock()
if ctx.Response == nil {
resp := "from-second"
ctx.Response = &resp
}
ctx.Unlock()
return nil
})
host.RegisterPlugin(api1)
host.RegisterPlugin(api2)
ctx := &sdk.StageContext{RawMessage: "hello"}
host.RunStage(sdk.StageOnInput, ctx)
if ctx.Response == nil || *ctx.Response != "short-circuited" {
t.Errorf("expected short-circuited, got %v", ctx.Response)
if ctx.Response == nil {
t.Fatal("expected a response to be set")
}
if api2called {
t.Error("api2 should not have been called after short circuit")
if *ctx.Response != "from-first" && *ctx.Response != "from-second" {
t.Errorf("expected either from-first or from-second, got %s", *ctx.Response)
}
}
func TestStageHostRunStageConcurrency(t *testing.T) {
host := NewStageHost()
var counter int32
n := 10
for i := 0; i < n; i++ {
host.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error {
atomic.AddInt32(&counter, 1)
return nil
})
}
host.RunStage(sdk.StageAfterOutput, &sdk.StageContext{})
if int(counter) != n {
t.Errorf("expected %d handlers called, got %d", n, counter)
}
}
func TestStageHostRunStageAll(t *testing.T) {
host := NewStageHost()
var mu sync.Mutex
count := 0
api1 := sdk.NewPluginAPI("p1", "1.0.0", nil, nil, nil)
api1.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error {
host.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error {
mu.Lock()
count++
mu.Unlock()
return nil
})
api2 := sdk.NewPluginAPI("p2", "1.0.0", nil, nil, nil)
api2.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error {
host.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error {
mu.Lock()
count++
mu.Unlock()
return nil
})
host.RegisterPlugin(api1)
host.RegisterPlugin(api2)
host.RunStageAll(sdk.StageAfterOutput, &sdk.StageContext{})
if count != 2 {
@ -130,42 +153,11 @@ func TestStageHostRunStageAll(t *testing.T) {
}
}
func TestStageHostMultiplePlugins(t *testing.T) {
host := NewStageHost()
p1 := sdk.NewPluginAPI("p1", "1.0.0", nil, nil, nil)
p1.RegisterTool("tool1", func(args map[string]interface{}) (interface{}, error) {
return "from_p1", nil
})
p2 := sdk.NewPluginAPI("p2", "1.0.0", nil, nil, nil)
p2.RegisterTool("tool2", func(args map[string]interface{}) (interface{}, error) {
return "from_p2", nil
})
host.RegisterPlugin(p1)
host.RegisterPlugin(p2)
if host.PluginCount() != 2 {
t.Errorf("expected 2 plugins, got %d", host.PluginCount())
}
r1, _ := host.ExecuteTool("tool1", nil)
if r1.(string) != "from_p1" {
t.Errorf("expected from_p1, got %v", r1)
}
r2, _ := host.ExecuteTool("tool2", nil)
if r2.(string) != "from_p2" {
t.Errorf("expected from_p2, got %v", r2)
}
}
func TestStageHostEmpty(t *testing.T) {
host := NewStageHost()
if host.PluginCount() != 0 {
t.Errorf("expected 0 plugins, got %d", host.PluginCount())
if host.ToolCount() != 0 {
t.Errorf("expected 0 tools, got %d", host.ToolCount())
}
defs := host.GetToolDefs()
@ -178,6 +170,30 @@ func TestStageHostEmpty(t *testing.T) {
t.Error("expected error on empty host")
}
// RunStage on empty host should not panic
host.RunStage(sdk.StageOnInput, &sdk.StageContext{})
}
func TestStageHostMultipleTools(t *testing.T) {
host := NewStageHost()
host.RegisterTool("tool1", sdk.ToolDef{Name: "tool1"}, func(args map[string]interface{}) (interface{}, error) {
return "from_p1", nil
})
host.RegisterTool("tool2", sdk.ToolDef{Name: "tool2"}, func(args map[string]interface{}) (interface{}, error) {
return "from_p2", nil
})
if host.ToolCount() != 2 {
t.Errorf("expected 2 tools, got %d", host.ToolCount())
}
r1, _ := host.ExecuteTool("tool1", nil)
if r1.(string) != "from_p1" {
t.Errorf("expected from_p1, got %v", r1)
}
r2, _ := host.ExecuteTool("tool2", nil)
if r2.(string) != "from_p2" {
t.Errorf("expected from_p2, got %v", r2)
}
}

View File

@ -89,18 +89,20 @@ type OutputEvent struct {
}
type IOManager struct {
mu sync.RWMutex
devices map[string]Device
inputCh chan *InputEvent
outputCh chan *OutputEvent
nextReqID int64
mu sync.RWMutex
devices map[string]Device
inputCh chan *InputEvent
interruptCh chan *InputEvent
outputCh chan *OutputEvent
nextReqID int64
}
func NewIOManager() *IOManager {
return &IOManager{
devices: make(map[string]Device),
inputCh: make(chan *InputEvent, 256),
outputCh: make(chan *OutputEvent, 256),
devices: make(map[string]Device),
inputCh: make(chan *InputEvent, 256),
interruptCh: make(chan *InputEvent, 64),
outputCh: make(chan *OutputEvent, 256),
}
}
@ -239,6 +241,29 @@ func (m *IOManager) InjectTextTo(source, outputChannel, text string) {
})
}
// InjectInterrupt 向中断通道发送输入
func (m *IOManager) InjectInterrupt(source, channel string, payload map[string]interface{}) {
if payload == nil {
payload = map[string]interface{}{}
}
m.interruptCh <- &InputEvent{
RequestID: m.nextRequestID(),
Source: source,
Type: payload["type"].(string),
Payload: payload,
OutputChannel: channel,
}
}
func (m *IOManager) InjectInterruptText(source, channel, text string) {
m.InjectInterrupt(source, channel, map[string]interface{}{
"type": "text",
"content": text,
})
}
func (m *IOManager) InputInterruptChan() <-chan *InputEvent { return m.interruptCh }
// InjectTextSyncTo 注入文本输入(同步等待)并指定输出通道
func (m *IOManager) InjectTextSyncTo(source, outputChannel, text string) *OutputEvent {
return m.InjectInputSyncTo(source, outputChannel, "text", map[string]interface{}{

View File

@ -1,71 +0,0 @@
package api
import (
"log"
"net/http"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
// Plugin 将 HTTP API + WebUI 包装为 IO Device
// 作为 HomeAgent 自带的默认 IO 通道插件
type Plugin struct {
name string
handler *Handler
server *http.Server
addr string
mux *http.ServeMux
}
func NewWebUIPlugin(name, addr string, sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager,
lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker,
cr *internalConfig.ConfigRegistry, pr *plugin.Registry) *Plugin {
h := NewHandler(sup, mem, sk, lua, cfg, iom, tm, ks, tr, cr, pr)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return &Plugin{
name: name,
handler: h,
addr: addr,
mux: mux,
}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Type() agentIO.DeviceType { return agentIO.DeviceIO }
func (p *Plugin) Description() string { return "HTTP API & Web Dashboard" }
func (p *Plugin) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText | agentIO.CapStructured }
func (p *Plugin) Tools() []agentIO.ToolDef { return nil }
func (p *Plugin) Execute(tool string, args map[string]interface{}) (interface{}, error) {
return nil, nil
}
func (p *Plugin) Start() error {
p.server = &http.Server{Addr: p.addr, Handler: p.mux}
go func() {
log.Printf("[webui] HTTP server listening on %s", p.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
}

View File

@ -150,12 +150,11 @@ func (r *ConfigRegistry) Close() error {
return r.db.Close()
}
// SeedFrom 从 *types.Config 批量导入默认值 config 表(仅空表时写入)
func (r *ConfigRegistry) SeedFrom(cfg *types.Config) {
// SeedDefaults 用硬编码默认值填充 config 表(仅空表时写入),不再依赖 YAML
func (r *ConfigRegistry) SeedDefaults(dataDir string) {
r.mu.Lock()
defer r.mu.Unlock()
// 检查是否已有数据
var count int
r.db.QueryRow(`SELECT COUNT(*) FROM config`).Scan(&count)
if count > 0 {
@ -177,44 +176,54 @@ func (r *ConfigRegistry) SeedFrom(cfg *types.Config) {
set := func(k, v string) { stmt.Exec(k, v) }
// daemon
set("core.daemon.listen_addr", cfg.Daemon.ListenAddr)
set("core.daemon.data_dir", cfg.Daemon.DataDir)
set("core.daemon.heartbeat_interval", cfg.Daemon.HeartbeatInterval.String())
set("core.daemon.check_interval", cfg.Daemon.CheckInterval.String())
set("core.daemon.log_level", cfg.Daemon.LogLevel)
set("core.daemon.listen_addr", ":8080")
set("core.daemon.data_dir", dataDir)
set("core.daemon.heartbeat_interval", "15s")
set("core.daemon.check_interval", "30s")
set("core.daemon.log_level", "info")
// llm
set("core.llm.provider", cfg.LLM.Provider)
set("core.llm.model", cfg.LLM.Model)
set("core.llm.base_url", cfg.LLM.BaseURL)
set("core.llm.adapter", cfg.LLM.Adapter)
set("core.llm.temperature", strconv.FormatFloat(cfg.LLM.Temperature, 'f', 2, 64))
set("core.llm.max_tokens", strconv.Itoa(cfg.LLM.MaxTokens))
set("core.llm.provider", "deepseek")
set("core.llm.model", "deepseek-v4-flash")
set("core.llm.base_url", "https://api.deepseek.com")
set("core.llm.adapter", "deepseek")
set("core.llm.temperature", "0.7")
set("core.llm.max_tokens", "4096")
// llm sources
for _, src := range cfg.LLM.Sources {
p := "core.llm.sources." + src.Name
set(p+".base_url", src.BaseURL)
set(p+".model", src.Model)
set(p+".adapter", src.Adapter)
set(p+".adapter_path", src.AdapterPath)
sources := map[string]map[string]string{
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"},
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o", "adapter": "openai", "adapter_path": "adapters/openai.lua"},
"anthropic": {"base_url": "https://api.anthropic.com", "model": "claude-sonnet-4-20250514", "adapter": "anthropic", "adapter_path": "adapters/anthropic.lua"},
"gemini": {"base_url": "https://generativelanguage.googleapis.com", "model": "gemini-2.0-flash", "adapter": "gemini", "adapter_path": "adapters/gemini.lua"},
"mistral": {"base_url": "https://api.mistral.ai", "model": "mistral-large-latest", "adapter": "mistral", "adapter_path": "adapters/mistral.lua"},
"groq": {"base_url": "https://api.groq.com", "model": "llama3-70b-8192", "adapter": "groq", "adapter_path": "adapters/groq.lua"},
"github": {"base_url": "https://models.inference.ai.azure.com", "model": "gpt-4o", "adapter": "github", "adapter_path": "adapters/github.lua"},
"ollama": {"base_url": "http://localhost:11434", "model": "llama3", "adapter": "ollama", "adapter_path": "adapters/ollama.lua"},
}
for name, props := range sources {
p := "core.llm.sources." + name
set(p+".base_url", props["base_url"])
set(p+".model", props["model"])
set(p+".adapter", props["adapter"])
set(p+".adapter_path", props["adapter_path"])
}
// defaults
set("core.defaults.image", cfg.Defaults.Image)
set("core.defaults.openclaw_enabled", strconv.FormatBool(cfg.Defaults.OpenClawEnabled))
set("core.defaults.snapshot.interval", cfg.Defaults.SnapshotPolicy.Interval.String())
set("core.defaults.snapshot.max_snapshots", strconv.Itoa(cfg.Defaults.SnapshotPolicy.MaxSnapshots))
set("core.defaults.snapshot.pre_action", strconv.FormatBool(cfg.Defaults.SnapshotPolicy.PreAction))
set("core.defaults.snapshot.post_action", strconv.FormatBool(cfg.Defaults.SnapshotPolicy.PostAction))
set("core.defaults.rollback.max_retries", strconv.Itoa(cfg.Defaults.RollbackPolicy.MaxRetries))
set("core.defaults.rollback.health_threshold", strconv.Itoa(int(cfg.Defaults.RollbackPolicy.HealthThreshold)))
set("core.defaults.rollback.cooldown_period", cfg.Defaults.RollbackPolicy.CooldownPeriod.String())
set("core.defaults.rollback.auto_rollback", strconv.FormatBool(cfg.Defaults.RollbackPolicy.AutoRollback))
set("core.defaults.resource.cpu", cfg.Defaults.ResourceLimit.CPU)
set("core.defaults.resource.memory", cfg.Defaults.ResourceLimit.Memory)
set("core.defaults.resource.disk", cfg.Defaults.ResourceLimit.Disk)
set("core.defaults.resource.network", strconv.FormatBool(cfg.Defaults.ResourceLimit.Network))
set("core.defaults.image", "homeagent/agent-base:latest")
set("core.defaults.openclaw_enabled", "true")
set("core.defaults.snapshot.interval", "10m")
set("core.defaults.snapshot.max_snapshots", "20")
set("core.defaults.snapshot.pre_action", "true")
set("core.defaults.snapshot.post_action", "false")
set("core.defaults.rollback.max_retries", "3")
set("core.defaults.rollback.health_threshold", "3")
set("core.defaults.rollback.cooldown_period", "30s")
set("core.defaults.rollback.auto_rollback", "true")
set("core.defaults.resource.cpu", "2")
set("core.defaults.resource.memory", "2g")
set("core.defaults.resource.disk", "10g")
set("core.defaults.resource.network", "true")
set("core.agent.max_tool_turns", "10")
set("core.agent.max_context_size", "30")
set("core.agent.distill_interval", "30m")

View File

@ -4,8 +4,6 @@ import (
"path/filepath"
"testing"
"time"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
func TestRegistryBasic(t *testing.T) {
@ -154,70 +152,36 @@ func TestPluginConfig(t *testing.T) {
r.Close()
}
func TestSeedFromToConfig(t *testing.T) {
func TestSeedDefaultsToConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.db")
cfg := &types.Config{
Daemon: types.DaemonConfig{
ListenAddr: ":9090",
DataDir: "/tmp/test",
HeartbeatInterval: 10 * time.Second,
CheckInterval: 20 * time.Second,
LogLevel: "debug",
},
LLM: types.LLMConfig{
Provider: "deepseek",
Model: "deepseek-v4-flash",
BaseURL: "https://api.deepseek.com",
Adapter: "deepseek",
Temperature: 0.5,
MaxTokens: 2048,
Sources: []types.LLMSource{
{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Adapter: "deepseek", AdapterPath: "adapters/deepseek.lua"},
{Name: "openai", BaseURL: "https://api.openai.com/v1", Model: "gpt-4o", Adapter: "openai", AdapterPath: "adapters/openai.lua"},
},
},
Defaults: types.AgentConfig{
Image: "test-image",
OpenClawEnabled: true,
},
}
r := NewConfigRegistry(path)
r.SeedFrom(cfg)
r.SeedDefaults(dir)
// Verify DB was seeded
if len(r.List("")) == 0 {
t.Fatal("SeedFrom produced empty DB")
// Verify DB was seeded with expected number of keys
keys := r.List("")
if len(keys) == 0 {
t.Fatal("SeedDefaults produced empty DB")
}
// Reconstruct config from DB
cfg2 := r.ToConfig()
if cfg2.Daemon.ListenAddr != ":9090" {
t.Fatalf("expected :9090, got %s", cfg2.Daemon.ListenAddr)
}
if cfg2.Daemon.LogLevel != "debug" {
t.Fatalf("expected debug, got %s", cfg2.Daemon.LogLevel)
if cfg2.Daemon.ListenAddr != ":8080" {
t.Fatalf("expected :8080, got %s", cfg2.Daemon.ListenAddr)
}
if cfg2.LLM.Provider != "deepseek" {
t.Fatalf("expected deepseek, got %s", cfg2.LLM.Provider)
}
if cfg2.LLM.MaxTokens != 2048 {
t.Fatalf("expected 2048, got %d", cfg2.LLM.MaxTokens)
}
if len(cfg2.LLM.Sources) != 2 {
t.Fatalf("expected 2 sources, got %d", len(cfg2.LLM.Sources))
}
if cfg2.LLM.Sources[0].AdapterPath != "adapters/deepseek.lua" {
t.Fatalf("expected adapters/deepseek.lua, got %s", cfg2.LLM.Sources[0].AdapterPath)
if len(cfg2.LLM.Sources) == 0 {
t.Fatal("expected at least 1 LLM source")
}
// Second SeedFrom should be no-op (DB already has data)
r.SeedFrom(cfg)
if len(r.List("")) != len(r.List("")) {
t.Fatal("second SeedFrom changed DB count")
// Second SeedDefaults should be no-op (DB already has data)
r.SeedDefaults(dir)
if len(r.List("")) != len(keys) {
t.Fatal("second SeedDefaults changed DB count")
}
r.Close()

View File

@ -131,6 +131,30 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry) (*Doc, error
return doc, nil
}
// Consume — 向量相似度查询并移除文档(召回后即从冷存储删除,避免重复记忆)
func (s *Store) Consume(text string, topK int) []*Doc {
s.mu.Lock()
defer s.mu.Unlock()
if topK <= 0 {
topK = 5
}
vec := s.veczer.Vectorize(text)
results := s.vec.Search(vec, topK)
var docs []*Doc
for _, r := range results {
if d, ok := s.docs[r.ID]; ok {
delete(s.docs, r.ID)
s.vec.Remove(r.ID)
s.dirty = true
docs = append(docs, d)
}
}
return docs
}
// Query — 向量相似度查询文档
func (s *Store) Query(text string, topK int) []*Doc {
s.mu.RLock()

View File

@ -14,14 +14,25 @@ type Indexer struct {
vec *vector.Store
veczer *vector.TFIDFVectorizer
mu sync.RWMutex
trained bool
trained bool
recalled map[string]bool // 已通过工具调用显式召回的实体名,自动注入时跳过
}
func NewIndexer(db *GraphDB) *Indexer {
return &Indexer{
db: db,
vec: vector.NewStore(),
veczer: vector.NewTFIDFVectorizer(2),
db: db,
vec: vector.NewStore(),
veczer: vector.NewTFIDFVectorizer(2),
recalled: make(map[string]bool),
}
}
// MarkRecalled 标记实体名已被工具调用显式召回,后续自动注入时跳过
func (idx *Indexer) MarkRecalled(names ...string) {
idx.mu.Lock()
defer idx.mu.Unlock()
for _, name := range names {
idx.recalled[name] = true
}
}
@ -100,15 +111,25 @@ func (idx *Indexer) BuildContext(userInput string) *InjectedContext {
return &InjectedContext{Summary: ""}
}
// 过滤已被工具调用显式召回的实体,避免重复注入
idx.mu.RLock()
filtered := result.Entities[:0]
for _, e := range result.Entities {
if !idx.recalled[e.Name] {
filtered = append(filtered, e)
}
}
idx.mu.RUnlock()
ctx := &InjectedContext{
Entities: result.Entities,
Entities: filtered,
Relations: nil,
}
if len(result.Entities) > 0 {
summary := buildIndexSummary(result.Entities)
if len(filtered) > 0 {
summary := buildIndexSummary(filtered)
ctx.Summary = summary
ctx.TokenEstimate = estimateTokens(summary) + len(result.Entities)*8
ctx.TokenEstimate = estimateTokens(summary) + len(filtered)*8
} else {
ctx.Summary = ""
}

View File

@ -0,0 +1,298 @@
package social
import (
"fmt"
"strings"
"sync"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
const (
entityTypePerson = "person"
entityTypeTrait = "trait_value"
traitPrefix = "trait:"
)
type PersonProfile struct {
Name string `json:"name"`
Traits map[string]string `json:"traits,omitempty"`
Relations []SocialRelation `json:"relations,omitempty"`
}
type SocialRelation struct {
Person string `json:"person"`
Relation string `json:"relation"` // 关系类型:朋友/家人/同事/邻居/...
}
type SocialStore struct {
db *memory.GraphDB
mu sync.RWMutex
}
func New(db *memory.GraphDB) *SocialStore {
return &SocialStore{db: db}
}
// GetPerson 获取人物完整档案(特质 + 社交关系)
func (s *SocialStore) GetPerson(name string) (*PersonProfile, error) {
if s.db == nil {
return nil, fmt.Errorf("social store not available")
}
result, err := s.db.Recall([]string{name}, nil, 2, "")
if err != nil {
return nil, err
}
profile := &PersonProfile{
Name: name,
Traits: make(map[string]string),
}
// 查找指定 person 的 ID
var personID int64
for _, e := range result.Entities {
if e.Name == name {
personID = e.ID
break
}
}
if personID == 0 {
return nil, fmt.Errorf("person '%s' not found", name)
}
// 区分 trait 关系和社交关系
for _, r := range result.Relations {
if strings.HasPrefix(r.RelationType, traitPrefix) {
// 特质trait:<特质名>
traitName := strings.TrimPrefix(r.RelationType, traitPrefix)
if r.SourceID == personID {
profile.Traits[traitName] = r.TargetName
} else {
profile.Traits[traitName] = r.SourceName
}
} else if r.SourceID == personID {
profile.Relations = append(profile.Relations, SocialRelation{
Person: r.TargetName,
Relation: r.RelationType,
})
} else if r.TargetID == personID {
profile.Relations = append(profile.Relations, SocialRelation{
Person: r.SourceName,
Relation: r.RelationType,
})
}
}
return profile, nil
}
// SetTrait 设置/更新人物特质。如果同名特质已存在则覆盖
func (s *SocialStore) SetTrait(name, trait, value string) error {
if s.db == nil {
return fmt.Errorf("social store not available")
}
// 先清除旧特质值
oldVal, found := s.GetTrait(name, trait)
if found && oldVal != "" {
s.db.Purge(map[string]string{
"subject_contains": name,
"relation_type": traitPrefix + trait,
}, "soft")
}
triples := []memory.Triple{
{
Subject: name,
SubjectType: entityTypePerson,
Relation: traitPrefix + trait,
Object: value,
ObjectType: entityTypeTrait,
Confidence: 1.0,
},
}
_, _, err := s.db.Commit(triples, "social_trait", 0)
return err
}
// GetTrait 获取指定人物的指定特质值
func (s *SocialStore) GetTrait(name, trait string) (string, bool) {
if s.db == nil {
return "", false
}
result, err := s.db.Recall([]string{name}, nil, 1, "")
if err != nil || result == nil {
return "", false
}
var personID int64
for _, e := range result.Entities {
if e.Name == name {
personID = e.ID
break
}
}
if personID == 0 {
return "", false
}
for _, r := range result.Relations {
if r.RelationType == traitPrefix+trait {
if r.SourceID == personID {
return r.TargetName, true
}
return r.SourceName, true
}
}
return "", false
}
// AddRelation 建立两人之间的社交关系
func (s *SocialStore) AddRelation(personA, relation, personB string) error {
if s.db == nil {
return fmt.Errorf("social store not available")
}
triples := []memory.Triple{
{
Subject: personA,
SubjectType: entityTypePerson,
Relation: relation,
Object: personB,
ObjectType: entityTypePerson,
Confidence: 1.0,
},
}
_, _, err := s.db.Commit(triples, "social_relation", 0)
return err
}
// RemoveRelation 删除两人之间的社交关系
func (s *SocialStore) RemoveRelation(personA, relation, personB string) error {
if s.db == nil {
return fmt.Errorf("social store not available")
}
_, err := s.db.Purge(map[string]string{
"subject_contains": personA,
"target_contains": personB,
"relation_type": relation,
}, "soft")
return err
}
// GetRelations 获取指定人物的所有社交关系
func (s *SocialStore) GetRelations(name string) ([]SocialRelation, error) {
if s.db == nil {
return nil, fmt.Errorf("social store not available")
}
result, err := s.db.Recall([]string{name}, nil, 1, "")
if err != nil {
return nil, err
}
var personID int64
for _, e := range result.Entities {
if e.Name == name {
personID = e.ID
break
}
}
if personID == 0 {
return nil, nil
}
var relations []SocialRelation
for _, r := range result.Relations {
if strings.HasPrefix(r.RelationType, traitPrefix) {
continue
}
if r.SourceID == personID {
relations = append(relations, SocialRelation{Person: r.TargetName, Relation: r.RelationType})
} else if r.TargetID == personID {
relations = append(relations, SocialRelation{Person: r.SourceName, Relation: r.RelationType})
}
}
return relations, nil
}
// GetNetwork 获取指定人物周围 depth 度的社交网络
func (s *SocialStore) GetNetwork(name string, depth int) ([]*PersonProfile, error) {
if s.db == nil {
return nil, fmt.Errorf("social store not available")
}
// 用 Recall 的 BFS 遍历获取多度关联
result, err := s.db.Recall([]string{name}, nil, depth, "")
if err != nil {
return nil, err
}
personMap := make(map[int64]*PersonProfile)
for _, e := range result.Entities {
p := &PersonProfile{
Name: e.Name,
Traits: make(map[string]string),
}
personMap[e.ID] = p
}
for _, r := range result.Relations {
if strings.HasPrefix(r.RelationType, traitPrefix) {
traitName := strings.TrimPrefix(r.RelationType, traitPrefix)
if p, ok := personMap[r.SourceID]; ok {
p.Traits[traitName] = r.TargetName
}
if p, ok := personMap[r.TargetID]; ok {
p.Traits[traitName] = r.SourceName
}
}
}
// 收集关系
for _, r := range result.Relations {
if strings.HasPrefix(r.RelationType, traitPrefix) {
continue
}
sr := SocialRelation{Relation: r.RelationType}
if p, ok := personMap[r.SourceID]; ok {
sr.Person = r.TargetName
p.Relations = append(p.Relations, sr)
}
sr = SocialRelation{Relation: r.RelationType}
if p, ok := personMap[r.TargetID]; ok {
sr.Person = r.SourceName
p.Relations = append(p.Relations, sr)
}
}
var profiles []*PersonProfile
for _, p := range personMap {
profiles = append(profiles, p)
}
return profiles, nil
}
// ListPersons 列出所有已知人物entity.type = person
func (s *SocialStore) ListPersons() ([]string, error) {
if s.db == nil {
return nil, fmt.Errorf("social store not available")
}
result, err := s.db.Recall(nil, nil, 1, "")
if err != nil {
return nil, err
}
var names []string
for _, e := range result.Entities {
if e.Type == entityTypePerson || e.Type == "Person" {
names = append(names, e.Name)
}
}
return names, nil
}

View File

@ -227,6 +227,148 @@ func (m *Memory) FileCount() int {
return len(files)
}
// PurgeByFilter 删除所有满足 filter 函数的事件(重写所有 JSONL 文件)
func (m *Memory) PurgeByFilter(filter func(Event) bool) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
// 关闭当前文件,准备重建
if m.current != nil {
m.current.Close()
m.current = nil
}
files, err := m.listFiles()
if err != nil {
return 0, err
}
totalRemoved := 0
for _, fpath := range files {
kept, removed, err := m.purgeFile(fpath, filter)
if err != nil {
log.Printf("[text memory] purge file %s: %v", fpath, err)
continue
}
totalRemoved += removed
if len(kept) == 0 {
os.Remove(fpath)
} else if removed > 0 {
m.rewriteFile(fpath, kept)
}
}
// 重新打开当前文件
m.openCurrent()
return totalRemoved, nil
}
// ReplaceByFilter 替换所有满足 filter 的事件(通过 replace 函数修改),重写文件
func (m *Memory) ReplaceByFilter(filter func(Event) bool, replace func(Event) Event) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.current != nil {
m.current.Close()
m.current = nil
}
files, err := m.listFiles()
if err != nil {
return 0, err
}
totalReplaced := 0
for _, fpath := range files {
events, replaced, err := m.replaceFile(fpath, filter, replace)
if err != nil {
log.Printf("[text memory] replace file %s: %v", fpath, err)
continue
}
totalReplaced += replaced
if len(events) == 0 {
os.Remove(fpath)
} else if replaced > 0 {
m.rewriteFile(fpath, events)
}
}
m.openCurrent()
return totalReplaced, nil
}
// ——— internal helpers ———
func (m *Memory) purgeFile(path string, filter func(Event) bool) (kept []Event, removed int, err error) {
f, err := os.Open(path)
if err != nil {
return nil, 0, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var evt Event
if err := json.Unmarshal([]byte(line), &evt); err != nil {
continue
}
if filter(evt) {
removed++
} else {
kept = append(kept, evt)
}
}
return kept, removed, scanner.Err()
}
func (m *Memory) replaceFile(path string, filter func(Event) bool, replace func(Event) Event) (events []Event, replaced int, err error) {
f, err := os.Open(path)
if err != nil {
return nil, 0, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var evt Event
if err := json.Unmarshal([]byte(line), &evt); err != nil {
continue
}
if filter(evt) {
evt = replace(evt)
replaced++
}
events = append(events, evt)
}
return events, replaced, scanner.Err()
}
func (m *Memory) rewriteFile(path string, events []Event) {
f, err := os.Create(path)
if err != nil {
log.Printf("[text memory] rewrite %s: %v", path, err)
return
}
defer f.Close()
enc := json.NewEncoder(f)
for _, evt := range events {
enc.Encode(evt)
}
}
func (m *Memory) Stats() map[string]interface{} {
m.mu.Lock()
defer m.mu.Unlock()

View File

@ -0,0 +1,87 @@
package plugin
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// .so 插件必须导出函数 NewPlugin签名与 NativeFactory 一致:
//
// func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
// return &myPlugin{name: name}, nil
// }
const (
soEntry = "plugin.so"
luaEntry = "main.lua"
metaEntry = "plugin.json"
)
type dynamicPlugin struct {
name string
impl sdk.Plugin
}
func (p *dynamicPlugin) Name() string { return p.name }
func (p *dynamicPlugin) Start(s *sdk.PluginSDK) error { return p.impl.Start(s) }
func (p *dynamicPlugin) Stop() error { return p.impl.Stop() }
// readManifest 读取插件目录下的 plugin.json。文件不存在时不报错。
func readManifest(dir string) *PluginManifest {
data, err := os.ReadFile(filepath.Join(dir, metaEntry))
if err != nil {
return nil
}
var m PluginManifest
if err := json.Unmarshal(data, &m); err != nil {
return nil
}
return &m
}
// tryLoadSO 尝试从插件目录加载 plugin.soGo plugin -buildmode=plugin
// 返回 nil,nil 表示目录中没有 plugin.so。
func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
soPath := filepath.Join(dir, soEntry)
if _, err := os.Stat(soPath); os.IsNotExist(err) {
return nil, nil
}
p, err := plugin.Open(soPath)
if err != nil {
return nil, fmt.Errorf("plugin.Open %s: %w", soPath, err)
}
sym, err := p.Lookup("NewPlugin")
if err != nil {
return nil, fmt.Errorf(".so %s must export NewPlugin: %w", soPath, err)
}
fn, ok := sym.(func(string, map[string]interface{}) (sdk.Plugin, error))
if !ok {
return nil, fmt.Errorf("NewPlugin in %s has wrong signature", soPath)
}
plg, err := fn(name, config)
if err != nil {
return nil, fmt.Errorf("NewPlugin %s: %w", name, err)
}
return &dynamicPlugin{name: name, impl: plg}, nil
}
// tryLoadLua 尝试从插件目录加载 main.luaLua 插件)。
// 返回 nil,nil 表示目录中没有 main.lua。
func tryLoadLua(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
luaPath := filepath.Join(dir, luaEntry)
if _, err := os.Stat(luaPath); os.IsNotExist(err) {
return nil, nil
}
// 预留Lua 插件需在 LuaVM 中注册一个 LuaPlugin 包装器
return nil, fmt.Errorf("lua plugin loading not yet implemented: %s", name)
}

View File

@ -0,0 +1,11 @@
package plugin
// PluginManifest 每个插件目录中的 plugin.json 元数据。
type PluginManifest struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Description string `json:"description,omitempty"`
Author string `json:"author,omitempty"`
Entry string `json:"entry,omitempty"` // "plugin.so" | "main.lua" | ""
Deprecated bool `json:"deprecated,omitempty"`
}

File diff suppressed because it is too large Load Diff

303
internal/plugin/registry.go Normal file
View File

@ -0,0 +1,303 @@
package plugin
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"sync"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
)
type NativeFactory func(name string, config map[string]interface{}) (sdk.Plugin, error)
// globalFactories 是插件通过 init() 自注册的全局工厂表。
// Registry.RegisterNative() 写入此表Registry.Load() 从中查找。
var globalFactories sync.Map
// RegisterFactory 供插件包在 init() 中调用,实现自注册。
// plugin.RegisterFactory("timer", func(name string, cfg map[string]interface{}) (sdk.Plugin, error) { ... })
func RegisterFactory(name string, factory NativeFactory) {
globalFactories.Store(name, factory)
}
type Registry struct {
mu sync.RWMutex
plugins map[string]sdk.Plugin
instances []sdk.Plugin
factories map[string]NativeFactory
iom *agentIO.IOManager
evBus *events.Bus
memDB *memory.GraphDB
textMem *text.Memory
docStore *doc.Store
ks *knowledge.Store
mgr *agentAPI.ProviderManager
cfgReg *internalConfig.ConfigRegistry
plgDir string
regTool sdk.ToolRegistrar
regStage sdk.StageRegistrar
regAPI sdk.APIRegistrar
}
func NewRegistry() *Registry {
return &Registry{
plugins: make(map[string]sdk.Plugin),
factories: make(map[string]NativeFactory),
}
}
func (r *Registry) SetIOManager(iom *agentIO.IOManager) { r.iom = iom }
func (r *Registry) SetEventBus(evBus *events.Bus) { r.evBus = evBus }
func (r *Registry) SetMemory(memDB *memory.GraphDB) { r.memDB = memDB }
func (r *Registry) SetTextMemory(tm *text.Memory) { r.textMem = tm }
func (r *Registry) SetDocStore(ds *doc.Store) { r.docStore = ds }
func (r *Registry) SetKnowledge(ks *knowledge.Store) { r.ks = ks }
func (r *Registry) SetProviderManager(mgr *agentAPI.ProviderManager) { r.mgr = mgr }
func (r *Registry) SetConfigRegistry(cfgReg *internalConfig.ConfigRegistry) { r.cfgReg = cfgReg }
func (r *Registry) SetPluginDir(dir string) { r.plgDir = dir }
func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) { r.regTool = fn }
func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) { r.regStage = fn }
func (r *Registry) SetAPIRegistrar(fn sdk.APIRegistrar) { r.regAPI = fn }
func (r *Registry) RegisterNative(name string, factory NativeFactory) {
r.mu.Lock()
defer r.mu.Unlock()
r.factories[name] = factory
globalFactories.Store(name, factory)
}
func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
sett := sdk.NewSettings(name, r.cfgReg)
regTool := r.regTool
if regTool == nil {
regTool = func(toolName string, def sdk.ToolDef, handler sdk.ToolHandler) error {
return nil
}
}
regStage := r.regStage
if regStage == nil {
regStage = func(stage sdk.Stage, handler sdk.StageHandler) {}
}
regAPI := r.regAPI
if regAPI == nil {
regAPI = func(name string) error { return nil }
}
return sdk.New(name,
r.iom, r.evBus,
sdk.NewGraphMemory(r.memDB),
sdk.NewTextMemory(r.textMem),
sdk.NewDocMemory(r.docStore),
sdk.NewKnowledge(r.ks),
sdk.NewLLM(r.mgr),
sett,
regTool, regStage, regAPI,
)
}
func (r *Registry) Load(dir string) error {
if dir == "" {
dir = r.plgDir
}
os.MkdirAll(dir, 0755)
// 1) 扫描已有目录
loaded := map[string]bool{}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
os.MkdirAll(dir, 0755)
return nil
}
return err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
plgDir := filepath.Join(dir, name)
if r.loadOne(plgDir, name) {
loaded[name] = true
}
}
// 2) 对已注册但尚无目录的工厂,创建目录并加载
r.mu.RLock()
allFactories := make(map[string]NativeFactory)
for name, f := range r.factories {
allFactories[name] = f
}
r.mu.RUnlock()
globalFactories.Range(func(key, val interface{}) bool {
name := key.(string)
if _, ok := allFactories[name]; !ok {
allFactories[name] = val.(NativeFactory)
}
return true
})
for name, factory := range allFactories {
if loaded[name] {
continue
}
plgDir := filepath.Join(dir, name)
os.MkdirAll(plgDir, 0755)
cfg := r.readConfig(plgDir)
p, err := factory(name, cfg)
if err != nil {
log.Printf("[plugin] factory %s: %v", name, err)
continue
}
if p == nil {
continue
}
plgSDK := r.buildSDK(name)
if err := p.Start(plgSDK); err != nil {
log.Printf("[plugin] start %s: %v", name, err)
continue
}
r.mu.Lock()
r.plugins[name] = p
r.instances = append(r.instances, p)
r.mu.Unlock()
log.Printf("[plugin] loaded: %s", name)
}
return nil
}
func (r *Registry) loadOne(plgDir, name string) bool {
// 1) 查找工厂init 自注册或 RegisterNative
r.mu.RLock()
factory, hasFactory := r.factories[name]
r.mu.RUnlock()
if !hasFactory {
v, ok := globalFactories.Load(name)
if ok {
factory = v.(NativeFactory)
hasFactory = true
}
}
var plg sdk.Plugin
if hasFactory {
cfg := r.readConfig(plgDir)
p, err := factory(name, cfg)
if err != nil {
log.Printf("[plugin] factory %s: %v", name, err)
return false
}
if p == nil {
return false
}
plg = p
} else {
// 2) 无工厂,尝试动态加载 .so / .lua
dynCfg := r.readConfig(plgDir)
p, err := r.tryDynamic(plgDir, name, dynCfg)
if err != nil {
log.Printf("[plugin] dynamic %s: %v", name, err)
}
if p == nil {
return false
}
plg = p
}
plgSDK := r.buildSDK(name)
if err := plg.Start(plgSDK); err != nil {
log.Printf("[plugin] start %s: %v", name, err)
return false
}
r.mu.Lock()
r.plugins[name] = plg
r.instances = append(r.instances, plg)
r.mu.Unlock()
log.Printf("[plugin] loaded: %s", name)
return true
}
func (r *Registry) StopAll() {
r.mu.Lock()
defer r.mu.Unlock()
for _, p := range r.instances {
if err := p.Stop(); err != nil {
log.Printf("[plugin] stop %s: %v", p.Name(), err)
}
}
r.plugins = make(map[string]sdk.Plugin)
r.instances = nil
}
func (r *Registry) Reload(dir string) (string, error) {
r.StopAll()
if err := r.Load(dir); err != nil {
return "", err
}
return fmt.Sprintf("loaded %d plugins", len(r.instances)), nil
}
func (r *Registry) List() []string {
r.mu.RLock()
defer r.mu.RUnlock()
list := make([]string, 0, len(r.plugins))
for name := range r.plugins {
list = append(list, name)
}
sort.Strings(list)
return list
}
func (r *Registry) Get(name string) sdk.Plugin {
r.mu.RLock()
defer r.mu.RUnlock()
return r.plugins[name]
}
func (r *Registry) tryDynamic(plgDir, name string, config map[string]interface{}) (sdk.Plugin, error) {
// 优先尝试 .soGo plugin其次 .luaLua 脚本)
plg, err := tryLoadSO(plgDir, name, config)
if err != nil {
return nil, err
}
if plg != nil {
return plg, nil
}
return tryLoadLua(plgDir, name, config)
}
func (r *Registry) readConfig(plgDir string) map[string]interface{} {
cfg := map[string]interface{}{}
data, err := os.ReadFile(filepath.Join(plgDir, "skill.json"))
if err != nil {
return cfg
}
var meta map[string]interface{}
if err := json.Unmarshal(data, &meta); err == nil {
return meta
}
return cfg
}

8
internal/plugins/all.go Normal file
View File

@ -0,0 +1,8 @@
package plugins
import (
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/timer"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
)

View File

@ -0,0 +1,128 @@
package cli
import (
"bufio"
"encoding/json"
"fmt"
"log"
"net"
"os"
"path/filepath"
"sync"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// DefaultSocket 由 main.go 在 Load() 前设置,覆盖默认 socket 路径。
// 若为空factory 使用 "<dataDir>/cli.sock"。
var DefaultSocket string
func init() {
plugin.RegisterFactory("cli", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
sock := DefaultSocket
if sock == "" {
sock = filepath.Join(config["data_dir"].(string), "cli.sock")
}
return New(name, sock), nil
})
}
type Plugin struct {
name string
socket string
ln net.Listener
mu sync.Mutex
wg sync.WaitGroup
}
func New(name, socketPath string) *Plugin {
return &Plugin{
name: name,
socket: socketPath,
}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
dir := filepath.Dir(p.socket)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create socket dir: %w", err)
}
os.Remove(p.socket)
ln, err := net.Listen("unix", p.socket)
if err != nil {
return fmt.Errorf("listen unix socket %s: %w", p.socket, err)
}
p.ln = ln
os.Chmod(p.socket, 0666)
p.wg.Add(1)
go p.acceptLoop(s)
log.Printf("[cli] unix socket listening on %s", p.socket)
return nil
}
func (p *Plugin) acceptLoop(s *sdk.PluginSDK) {
defer p.wg.Done()
for {
conn, err := p.ln.Accept()
if err != nil {
break
}
p.wg.Add(1)
go p.handleConn(conn, s)
}
}
func (p *Plugin) handleConn(conn net.Conn, s *sdk.PluginSDK) {
defer conn.Close()
defer p.wg.Done()
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
resp := s.InjectTextSync("cli", "cli", line)
if resp != nil {
content, _ := resp.Payload["content"].(string)
writeLine(conn, map[string]interface{}{
"type": "response",
"content": content,
})
} else {
writeLine(conn, map[string]interface{}{
"type": "error",
"error": "agent is not available",
})
}
}
}
func writeLine(conn net.Conn, v interface{}) {
data, err := json.Marshal(v)
if err != nil {
return
}
data = append(data, '\n')
conn.Write(data)
}
func (p *Plugin) Stop() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.ln != nil {
p.ln.Close()
}
p.wg.Wait()
os.Remove(p.socket)
return nil
}

View File

@ -0,0 +1,88 @@
package openclaw
import (
"fmt"
"log"
"os"
"path/filepath"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// SkillsDir 由 main.go 在 Load() 前设置,指向 SKILL.md 存放目录。
var SkillsDir string
func init() {
plugin.RegisterFactory("openclaw", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
dir := SkillsDir
if dir == "" {
dir = filepath.Join(config["data_dir"].(string), "skills")
}
return New(name, dir), nil
})
}
type Plugin struct {
name string
skillsDir string
skills []*plugin.SKILLPlugin
}
func New(name, skillsDir string) *Plugin {
return &Plugin{
name: name,
skillsDir: skillsDir,
}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
entries, err := os.ReadDir(p.skillsDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("read skills dir %s: %w", p.skillsDir, err)
}
for _, entry := range entries {
skillPath := filepath.Join(p.skillsDir, entry.Name())
sk, err := plugin.LoadSKILL(skillPath)
if err != nil {
log.Printf("[openclaw] load skill %s: %v", entry.Name(), err)
continue
}
p.skills = append(p.skills, sk)
// Register each tool defined in the SKILL
for _, td := range sk.Tools() {
name := td.Name
def := sdk.ToolDef{
Name: name,
Description: td.Description,
Parameters: td.Parameters,
}
// SKILL tools are informational (advisory) — no handler
if err := s.RegisterTool(name, def, nil); err != nil {
log.Printf("[openclaw] register tool %s: %v", name, err)
}
}
// Register IO config as a channel if defined
if iocfg := sk.IOConfig(); iocfg != nil {
log.Printf("[openclaw] skill %s io: type=%s in=%s out=%s caps=%v",
sk.Name(), iocfg.Type, iocfg.InputRoute, iocfg.OutputRoute, iocfg.OutputCaps)
}
log.Printf("[openclaw] loaded skill: %s v%s", sk.Name(), sk.Version())
}
return nil
}
func (p *Plugin) Stop() error {
p.skills = nil
return nil
}

View File

@ -0,0 +1,98 @@
package timer
import (
"fmt"
"log"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func init() {
plugin.RegisterFactory("timer", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
}
type Plugin struct {
name string
mu sync.Mutex
wg sync.WaitGroup
}
type timerTask struct {
id int
dur time.Duration
message string
doneAt time.Time
s *sdk.PluginSDK
}
func New(name string) *Plugin {
return &Plugin{name: name}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool("timer_set", sdk.ToolDef{
Name: "timer_set",
Description: "设置一个定时提醒。倒计时结束后通过中断通道通知 agent。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"duration": map[string]interface{}{
"type": "string",
"description": "持续时间,例如 5s, 2m, 1h",
},
"message": map[string]interface{}{
"type": "string",
"description": "提醒内容",
},
},
"required": []string{"duration", "message"},
},
}, func(args map[string]interface{}) (interface{}, error) {
durStr, _ := args["duration"].(string)
message, _ := args["message"].(string)
if durStr == "" {
return map[string]interface{}{"error": "duration is required"}, nil
}
if message == "" {
return map[string]interface{}{"error": "message is required"}, nil
}
dur, err := time.ParseDuration(durStr)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("invalid duration %q: %v", durStr, err)}, nil
}
p.mu.Lock()
p.wg.Add(1)
p.mu.Unlock()
go func() {
defer p.wg.Done()
time.Sleep(dur)
log.Printf("[timer] firing: %s (%s later)", message, dur)
s.InjectInterruptText("timer", "timer", fmt.Sprintf("timer: %s", message))
}()
doneAt := time.Now().Add(dur)
return map[string]interface{}{
"status": "timer_set",
"duration": durStr,
"message": message,
"done_at": doneAt.Format(time.RFC3339),
}, nil
})
return nil
}
func (p *Plugin) Stop() error {
p.wg.Wait()
return nil
}

View File

@ -1,4 +1,4 @@
package api
package webui
import (
"encoding/json"
@ -12,6 +12,7 @@ import (
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
@ -37,9 +38,10 @@ type Handler struct {
tracker *tracker.Tracker
cfgReg *internalConfig.ConfigRegistry
pluginReg *plugin.Registry
eventBus *events.Bus
}
func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry) *Handler {
func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus) *Handler {
var idx *memory.Indexer
if mem != nil {
idx = memory.NewIndexer(mem)
@ -58,6 +60,7 @@ func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager,
tracker: tr,
cfgReg: cr,
pluginReg: pr,
eventBus: evBus,
}
}
@ -81,6 +84,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/adapters/", h.handleAdapterByID)
mux.HandleFunc("/api/v1/tracker", h.handleTracker)
mux.HandleFunc("/api/v1/tracker/", h.handleTracker)
mux.HandleFunc("/api/v1/chat", h.handleChat)
mux.HandleFunc("/api/v1/chat/events", h.handleChatEvents)
mux.HandleFunc("/v1/chat/completions", h.handleOpenAICompletions)
mux.HandleFunc("/", h.handleStatic)
}
@ -416,7 +421,6 @@ func (h *Handler) handleTextMemory(w http.ResponseWriter, r *http.Request) {
"recent": recent,
})
case http.MethodDelete:
// future: purge
writeJSON(w, http.StatusAccepted, map[string]string{"status": "not_implemented"})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@ -498,6 +502,80 @@ func (h *Handler) handleNetwork(w http.ResponseWriter, r *http.Request) {
})
}
func (h *Handler) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Message string `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if body.Message == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message is required"})
return
}
resp := h.iom.InjectTextSync("cli", body.Message)
if resp == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"})
return
}
content, _ := resp.Payload["content"].(string)
writeJSON(w, http.StatusOK, map[string]interface{}{
"response": content,
})
}
func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
flusher.Flush()
done := r.Context().Done()
if h.eventBus == nil {
fmt.Fprintf(w, "event: error\ndata: {\"msg\":\"event bus unavailable\"}\n\n")
flusher.Flush()
return
}
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
unsub := h.eventBus.Subscribe(events.EventAll, func(evt *events.Event) {
data, _ := json.Marshal(evt)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evt.Type, data)
flusher.Flush()
})
defer unsub()
for {
select {
case <-done:
return
case <-ticker.C:
fmt.Fprintf(w, ": heartbeat\n\n")
flusher.Flush()
}
}
}
func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@ -529,11 +607,10 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
v, _ := h.cfgReg.Get(k)
values[k] = v
}
// 返回插件列表供侧边栏分组
plugins := []string{"core"}
if h.pluginReg != nil {
for _, p := range h.pluginReg.List() {
plugins = append(plugins, "plugin."+p.Name())
plugins = append(plugins, "plugin."+p)
}
}
sort.Strings(plugins)
@ -560,13 +637,17 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
}
}
// OpenAI 兼容 API — 所有输入走 IO 抽象层(中断)
func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.iom == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "IO manager not available"})
return
}
var req struct {
Model string `json:"model"`
Messages []openAIMessage `json:"messages"`
@ -583,15 +664,17 @@ func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request
return
}
// 取最后一条 user 消息作为输入
lastMsg := req.Messages[len(req.Messages)-1]
if lastMsg.Role != "user" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "last message must be from user"})
return
}
// 通过 IO 抽象层同步注入(中断式)
response := h.iom.InjectTextSync("http", lastMsg.Content)
if response == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no response from agent"})
return
}
resp := map[string]interface{}{
"id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()),
@ -731,7 +814,7 @@ pre{background:#0f172a;border-radius:6px;padding:12px;font-size:12px;overflow-x:
</head>
<body>
<nav>
<h1>🦞 HomeAgent</h1>
<h1>HomeAgent</h1>
<a class="active" onclick="switchTab('overview')">概览</a>
<a onclick="switchTab('memory')">图记忆</a>
<a onclick="switchTab('skills')">技能</a>

View File

@ -1,6 +1,7 @@
package api
package webui
import (
"context"
"encoding/json"
"io"
"net/http"
@ -9,8 +10,13 @@ import (
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
@ -28,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), sup
return NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, events.NewBus()), sup
}
func TestHandleStatus(t *testing.T) {
@ -132,7 +138,7 @@ func TestHandleKnowledgeSearch(t *testing.T) {
sup.Start()
defer sup.Shutdown()
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil)
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus())
req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=test", nil)
w := httptest.NewRecorder()
@ -164,7 +170,7 @@ func TestHandleKnowledgeCreate(t *testing.T) {
sup.Start()
defer sup.Shutdown()
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil)
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus())
body := `{"name":"new_doc","content":"fresh content"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", strings.NewReader(body))
@ -229,7 +235,7 @@ func TestHandleTrackerStats(t *testing.T) {
sup.Start()
defer sup.Shutdown()
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, tr, nil, nil)
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, tr, nil, nil, events.NewBus())
req := httptest.NewRequest(http.MethodGet, "/api/v1/tracker", nil)
w := httptest.NewRecorder()
@ -243,6 +249,8 @@ func TestHandleTrackerStats(t *testing.T) {
func TestHandleOpenAICompletionsNoMessages(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
// 给 handler 一个 IOManager才能通过 nil 检查到达消息校验
h.iom = agentIO.NewIOManager()
body := `{"model":"test"}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
@ -258,6 +266,8 @@ func TestHandleOpenAICompletionsNoMessages(t *testing.T) {
func TestHandleOpenAICompletionsLastMsgNotUser(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
// 给 handler 一个 IOManager才能通过 nil 检查到达消息校验
h.iom = agentIO.NewIOManager()
body := `{"messages":[{"role":"assistant","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
@ -405,7 +415,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)
h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus())
t.Run("GET_settings_lists_keys_and_plugins", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
@ -512,7 +522,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)
h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus())
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
h2.handleSettings(w, req)
@ -538,7 +548,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)
h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus())
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
@ -559,3 +569,125 @@ func TestSettingsWithPluginRegistry(t *testing.T) {
t.Fatal("expected 'core' in plugins list")
}
}
// === 端到端测试Handler + IOManager + Agent + HTTP ===
type echoProvider struct{ name string }
func (p *echoProvider) Name() string { return p.name }
func (p *echoProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
content := "echo: " + req.Messages[len(req.Messages)-1].Content
return &agentAPI.CompletionResponse{Content: content, FinishReason: "stop"}, nil
}
func (p *echoProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
ch := make(chan agentAPI.StreamChunk, 1)
ch <- agentAPI.StreamChunk{Content: "mock", Done: true}
return ch, nil
}
func init() {
// 避免测试时自动输出
}
func TestHandleCompletionsEndToEnd(t *testing.T) {
iom := agentIO.NewIOManager()
// 启动一个最小 Agent使用 echoProvider不调真实 LLM
memDB, err := memory.NewGraphDB(t.TempDir() + "/graph.db")
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
defer memDB.Close()
agent := agentCore.New(agentCore.AgentConfig{
ID: "test",
SystemPrompt: "你是测试助手",
Provider: &echoProvider{name: "echo"},
IO: iom,
Memory: memDB,
Indexer: nil,
MaxToolTurns: 0,
ContextSavePath: "",
})
agent.Start()
defer agent.Stop()
// Handler 需要 iom
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
h := NewHandler(sup, nil, nil, nil, &types.Config{}, iom, nil, nil, nil, nil, nil, events.NewBus())
t.Run("POST_chat_completions_returns_echo", func(t *testing.T) {
body := `{"model":"test","messages":[{"role":"user","content":"你好"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleOpenAICompletions(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)
}
choices, ok := resp["choices"].([]interface{})
if !ok || len(choices) == 0 {
t.Fatal("expected choices")
}
msg, ok := choices[0].(map[string]interface{})["message"].(map[string]interface{})
if !ok {
t.Fatal("expected message")
}
if msg["content"] != "echo: 你好" {
t.Fatalf("expected 'echo: 你好', got '%v'", msg["content"])
}
})
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())
body := `{"messages":[{"role":"user","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h2.handleOpenAICompletions(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
})
t.Run("POST_chat_completions_400_on_no_messages", func(t *testing.T) {
body := `{"model":"test"}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleOpenAICompletions(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
})
t.Run("POST_chat_completions_400_on_non_user_last_msg", func(t *testing.T) {
body := `{"messages":[{"role":"assistant","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleOpenAICompletions(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
})
}

View File

@ -0,0 +1,127 @@
package webui
import (
"log"
"net/http"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
// 包级依赖注入 — 由 main.go 在 Load() 前调用 Configure() 设置。
var (
webuiAddr string
webuiSup *supervisor.Daemon
webuiMem *memory.GraphDB
webuiSK *skill.Manager
webuiLua *luaVM.VM
webuiCfg *types.Config
webuiIOM *agentIO.IOManager
webuiTM *text.Memory
webuiKS *knowledge.Store
webuiTR *tracker.Tracker
webuiCR *internalConfig.ConfigRegistry
webuiPR *plugin.Registry
webuiEvBus *events.Bus
)
// Configure 注入 WebUI 插件需要的内核依赖。必须在 Load() 之前调用。
func Configure(addr string,
sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager,
lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager,
tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker,
cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus,
) {
webuiAddr = addr
webuiSup, webuiMem, webuiSK, webuiLua = sup, mem, sk, lua
webuiCfg, webuiIOM, webuiTM, webuiKS = cfg, iom, tm, ks
webuiTR, webuiCR, webuiPR, webuiEvBus = tr, cr, pr, evBus
}
func init() {
plugin.RegisterFactory("webui", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
if webuiSup == nil {
return nil, nil // 未 Configure 则跳过(不给日志警告)
}
addr := webuiAddr
if a, ok := config["addr"].(string); ok {
addr = a
}
return New(name, addr,
webuiSup, webuiMem, webuiSK, webuiLua,
webuiCfg, webuiIOM, webuiTM, webuiKS,
webuiTR, webuiCR, webuiPR, webuiEvBus,
), nil
})
}
type Plugin struct {
name string
addr string
handler *Handler
server *http.Server
mux *http.ServeMux
sup *supervisor.Daemon
mem *memory.GraphDB
sk *skill.Manager
lua *luaVM.VM
cfg *types.Config
iom *agentIO.IOManager
tm *text.Memory
ks *knowledge.Store
tr *tracker.Tracker
cr *internalConfig.ConfigRegistry
pr *plugin.Registry
evBus *events.Bus
}
func New(name, addr string,
sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager,
lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager,
tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker,
cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus,
) *Plugin {
return &Plugin{
name: name,
addr: addr,
mux: http.NewServeMux(),
sup: sup, mem: mem, sk: sk, lua: lua, cfg: cfg,
iom: iom, tm: tm, ks: ks, tr: tr, cr: cr, pr: pr, evBus: evBus,
}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
h := NewHandler(p.sup, p.mem, p.sk, p.lua, p.cfg, p.iom, p.tm, p.ks, p.tr, p.cr, p.pr, p.evBus)
p.handler = h
h.RegisterRoutes(p.mux)
p.server = &http.Server{Addr: p.addr, Handler: p.mux}
go func() {
log.Printf("[webui] HTTP server listening on %s", p.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
}

38
internal/sdk/knowledge.go Normal file
View File

@ -0,0 +1,38 @@
package sdk
import "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
type KnowledgeAPI interface {
Search(query string, topK int) ([]*knowledge.Knowledge, error)
Add(name, content string) error
List() ([]string, error)
}
type knowledgeImpl struct {
ks *knowledge.Store
}
func NewKnowledge(ks *knowledge.Store) KnowledgeAPI {
return &knowledgeImpl{ks: ks}
}
func (k *knowledgeImpl) Search(query string, topK int) ([]*knowledge.Knowledge, error) {
if k.ks == nil {
return nil, nil
}
return k.ks.Search(query, topK), nil
}
func (k *knowledgeImpl) Add(name, content string) error {
if k.ks == nil {
return nil
}
return k.ks.Add(name, content)
}
func (k *knowledgeImpl) List() ([]string, error) {
if k.ks == nil {
return nil, nil
}
return k.ks.List(), nil
}

42
internal/sdk/llm.go Normal file
View File

@ -0,0 +1,42 @@
package sdk
import agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
type LLMAPI interface {
ListSources() []string
SetSource(name string) error
CurrentSource() string
}
type llmImpl struct {
mgr *agentAPI.ProviderManager
}
func NewLLM(mgr *agentAPI.ProviderManager) LLMAPI {
return &llmImpl{mgr: mgr}
}
func (l *llmImpl) ListSources() []string {
if l.mgr == nil {
return nil
}
return l.mgr.List()
}
func (l *llmImpl) SetSource(name string) error {
if l.mgr == nil {
return nil
}
return l.mgr.SetDefault(name)
}
func (l *llmImpl) CurrentSource() string {
if l.mgr == nil {
return ""
}
p := l.mgr.Default()
if p == nil {
return ""
}
return p.Name()
}

154
internal/sdk/memory.go Normal file
View File

@ -0,0 +1,154 @@
package sdk
import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
)
type MemoryAPI interface {
Recall(query []string, depth int) ([]Entity, []Relation, error)
Commit(triples []Triple) error
Introspect() (map[string]interface{}, error)
MergeEntities(source, target string) (int, error)
Purge(criteria map[string]string, mode string) (int, error)
}
type Entity struct {
Name string `json:"name"`
Type string `json:"type"`
MentionCount int `json:"mention_count"`
}
type Relation struct {
SourceName string `json:"source_name"`
TargetName string `json:"target_name"`
RelationType string `json:"relation_type"`
}
type Triple struct {
Subject string `json:"subject"`
Relation string `json:"relation"`
Object string `json:"object"`
}
type TextMemoryAPI interface {
Append(evt text.Event) error
}
type DocMemoryAPI interface {
Query(text string, topK int) []*doc.Doc
Insert(doc *doc.Doc) error
Remove(id string)
Stats() map[string]interface{}
}
type graphMemory struct {
db *memory.GraphDB
}
func NewGraphMemory(db *memory.GraphDB) MemoryAPI {
return &graphMemory{db: db}
}
func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, error) {
if m.db == nil {
return nil, nil, nil
}
result, err := m.db.Recall(query, nil, depth, "")
if err != nil {
return nil, nil, err
}
entities := make([]Entity, len(result.Entities))
for i, e := range result.Entities {
entities[i] = Entity{Name: e.Name, Type: e.Type, MentionCount: e.MentionCount}
}
relations := make([]Relation, len(result.Relations))
for i, r := range result.Relations {
relations[i] = Relation{SourceName: r.SourceName, TargetName: r.TargetName, RelationType: r.RelationType}
}
return entities, relations, nil
}
func (m *graphMemory) Commit(triples []Triple) error {
if m.db == nil {
return nil
}
ts := make([]memory.Triple, len(triples))
for i, t := range triples {
ts[i] = memory.Triple{Subject: t.Subject, Relation: t.Relation, Object: t.Object}
}
_, _, err := m.db.Commit(ts, "plugin", 0)
return err
}
func (m *graphMemory) Introspect() (map[string]interface{}, error) {
if m.db == nil {
return map[string]interface{}{}, nil
}
return m.db.Introspect()
}
func (m *graphMemory) MergeEntities(source, target string) (int, error) {
if m.db == nil {
return 0, nil
}
return m.db.MergeEntities(source, target)
}
func (m *graphMemory) Purge(criteria map[string]string, mode string) (int, error) {
if m.db == nil {
return 0, nil
}
return m.db.Purge(criteria, mode)
}
type textMemoryImpl struct {
tm *text.Memory
}
func NewTextMemory(tm *text.Memory) TextMemoryAPI {
return &textMemoryImpl{tm: tm}
}
func (m *textMemoryImpl) Append(evt text.Event) error {
if m.tm == nil {
return nil
}
return m.tm.Append(evt)
}
type docMemoryImpl struct {
ds *doc.Store
}
func NewDocMemory(ds *doc.Store) DocMemoryAPI {
return &docMemoryImpl{ds: ds}
}
func (m *docMemoryImpl) Query(text string, topK int) []*doc.Doc {
if m.ds == nil {
return nil
}
return m.ds.Query(text, topK)
}
func (m *docMemoryImpl) Insert(d *doc.Doc) error {
if m.ds == nil {
return nil
}
return m.ds.Insert(d)
}
func (m *docMemoryImpl) Remove(id string) {
if m.ds != nil {
m.ds.Remove(id)
}
}
func (m *docMemoryImpl) Stats() map[string]interface{} {
if m.ds == nil {
return map[string]interface{}{}
}
return m.ds.Stats()
}

230
internal/sdk/plugin.go Normal file
View File

@ -0,0 +1,230 @@
package sdk
import (
"log"
"sync"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
)
type Plugin interface {
Name() string
Start(sdk *PluginSDK) error
Stop() error
}
type ToolHandler func(args map[string]interface{}) (interface{}, error)
type StageHandler func(ctx *StageContext) error
type Stage string
const (
StageOnInput Stage = "on_input"
StagePreAction Stage = "pre_action"
StagePostAction Stage = "post_action"
StageBeforeToolcall Stage = "before_toolcall"
StageAfterToolcall Stage = "after_toolcall"
StageBeforeOutput Stage = "before_output"
StageAfterOutput Stage = "after_output"
)
type StageContext struct {
mu sync.RWMutex
RawMessage string
UserID string
GroupID string
ContextMsgs []map[string]interface{}
LLMText string
ToolCalls []ToolCall
ToolResults []ToolResult
FinalText string
Response *string
Phase Stage
Memory []MemItem
Extra map[string]interface{}
}
func (c *StageContext) RLock() { c.mu.RLock() }
func (c *StageContext) RUnlock() { c.mu.RUnlock() }
func (c *StageContext) Lock() { c.mu.Lock() }
func (c *StageContext) Unlock() { c.mu.Unlock() }
func (c *StageContext) IsResponded() bool { c.mu.RLock(); defer c.mu.RUnlock(); return c.Response != nil }
type MemItem struct {
Role string `json:"role"`
Content string `json:"content"`
Score float64 `json:"score"`
}
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
type ToolResult struct {
CallID string `json:"call_id"`
Name string `json:"name"`
Success bool `json:"success"`
Result interface{} `json:"result"`
}
type ToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
}
type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error
type StageRegistrar func(stage Stage, handler StageHandler)
type APIRegistrar func(name string) error
type PluginSDK struct {
name string
iom *agentIO.IOManager
eventBus *events.Bus
mem MemoryAPI
textMem TextMemoryAPI
docMem DocMemoryAPI
know KnowledgeAPI
llm LLMAPI
sett SettingsAPI
regTool ToolRegistrar
regStage StageRegistrar
regAPI APIRegistrar
logger *log.Logger
}
func New(name string, iom *agentIO.IOManager, eventBus *events.Bus, mem MemoryAPI, textMem TextMemoryAPI, docMem DocMemoryAPI, know KnowledgeAPI, llm LLMAPI, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar) *PluginSDK {
return &PluginSDK{
name: name,
iom: iom,
eventBus: eventBus,
mem: mem,
textMem: textMem,
docMem: docMem,
know: know,
llm: llm,
sett: sett,
regTool: regTool,
regStage: regStage,
regAPI: regAPI,
logger: log.Default(),
}
}
// === IO 双通道 ===
func (s *PluginSDK) InjectInput(source, channel string, payload map[string]interface{}) {
if s.iom != nil {
s.iom.InjectInputTo(source, channel, "text", payload)
}
}
func (s *PluginSDK) InjectInterrupt(source, channel string, payload map[string]interface{}) {
if s.iom != nil {
p := payload
if p == nil {
p = map[string]interface{}{}
}
if _, ok := p["type"]; !ok {
p["type"] = "text"
}
s.iom.InjectInterrupt(source, channel, p)
}
}
func (s *PluginSDK) InjectText(source, channel, text string) {
if s.iom != nil {
s.iom.InjectTextTo(source, channel, text)
}
}
func (s *PluginSDK) InjectTextSync(source, channel, text string) *agentIO.OutputEvent {
if s.iom != nil {
return s.iom.InjectTextSyncTo(source, channel, text)
}
return nil
}
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
if s.iom != nil {
s.iom.InjectInterruptText(source, channel, text)
}
}
func (s *PluginSDK) OutputChan() <-chan *agentIO.OutputEvent {
if s.iom != nil {
return s.iom.OutputChan()
}
return nil
}
func (s *PluginSDK) RegisterChannel(name string, dev agentIO.Device) error {
if s.iom != nil {
return s.iom.RegisterDevice(dev)
}
return nil
}
func (s *PluginSDK) UnregisterChannel(name string) {
if s.iom != nil {
s.iom.UnregisterDevice(name)
}
}
func (s *PluginSDK) ListChannels() []agentIO.ChannelInfo {
if s.iom != nil {
return s.iom.ListChannels()
}
return nil
}
// === 三通道 ===
func (s *PluginSDK) Publish(evt *events.Event) {
if s.eventBus != nil {
s.eventBus.Publish(evt)
}
}
func (s *PluginSDK) Subscribe(eventType events.EventType, handler events.Handler) func() {
if s.eventBus != nil {
return s.eventBus.Subscribe(eventType, handler)
}
return func() {}
}
// === 能力 ===
func (s *PluginSDK) Memory() MemoryAPI { return s.mem }
func (s *PluginSDK) TextMemory() TextMemoryAPI { return s.textMem }
func (s *PluginSDK) DocMemory() DocMemoryAPI { return s.docMem }
func (s *PluginSDK) Knowledge() KnowledgeAPI { return s.know }
func (s *PluginSDK) LLM() LLMAPI { return s.llm }
func (s *PluginSDK) Settings() SettingsAPI { return s.sett }
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error {
if s.regTool != nil {
return s.regTool(name, def, handler)
}
return nil
}
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler) {
if s.regStage != nil {
s.regStage(stage, handler)
}
}
func (s *PluginSDK) RegisterPluginAPI(name string) error {
if s.regAPI != nil {
return s.regAPI(name)
}
return nil
}
func (s *PluginSDK) PluginName() string { return s.name }
func (s *PluginSDK) Logger() *log.Logger { return s.logger }

121
internal/sdk/settings.go Normal file
View File

@ -0,0 +1,121 @@
package sdk
import (
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
)
type SettingsAPI interface {
// 插件自身配置表 config_<name>
Get(key string) (interface{}, error)
Set(key string, value interface{}) error
List(prefix string) ([]string, error)
// 核心配置表 config
GetCore(key string) (interface{}, error)
SetCore(key string, value interface{}) error
ListCore(prefix string) ([]string, error)
// 任意插件配置表 config_<plugin>
GetPlugin(plugin, key string) (interface{}, error)
SetPlugin(plugin, key string, value interface{}) error
ListPlugin(plugin, prefix string) ([]string, error)
// 全局
Dump() map[string]interface{}
Plugins() []string
}
type settingsImpl struct {
pluginName string
reg *internalConfig.ConfigRegistry
}
func NewSettings(name string, reg *internalConfig.ConfigRegistry) SettingsAPI {
return &settingsImpl{pluginName: name, reg: reg}
}
func (s *settingsImpl) Get(key string) (interface{}, error) {
if s.reg == nil {
return nil, nil
}
return s.reg.PluginConfig(s.pluginName).Get(key)
}
func (s *settingsImpl) Set(key string, value interface{}) error {
if s.reg == nil {
return nil
}
return s.reg.PluginConfig(s.pluginName).Set(key, value)
}
func (s *settingsImpl) List(prefix string) ([]string, error) {
if s.reg == nil {
return nil, nil
}
return s.reg.PluginConfig(s.pluginName).List(prefix)
}
func (s *settingsImpl) GetCore(key string) (interface{}, error) {
if s.reg == nil {
return nil, nil
}
return s.reg.Get(key)
}
func (s *settingsImpl) SetCore(key string, value interface{}) error {
if s.reg == nil {
return nil
}
return s.reg.Set(key, value)
}
func (s *settingsImpl) ListCore(prefix string) ([]string, error) {
if s.reg == nil {
return nil, nil
}
return s.reg.List(prefix), nil
}
func (s *settingsImpl) Dump() map[string]interface{} {
if s.reg == nil {
return nil
}
return s.reg.Dump()
}
func (s *settingsImpl) GetPlugin(plugin, key string) (interface{}, error) {
if s.reg == nil {
return nil, nil
}
return s.reg.PluginConfig(plugin).Get(key)
}
func (s *settingsImpl) SetPlugin(plugin, key string, value interface{}) error {
if s.reg == nil {
return nil
}
return s.reg.PluginConfig(plugin).Set(key, value)
}
func (s *settingsImpl) ListPlugin(plugin, prefix string) ([]string, error) {
if s.reg == nil {
return nil, nil
}
return s.reg.PluginConfig(plugin).List(prefix)
}
func (s *settingsImpl) Plugins() []string {
if s.reg == nil {
return nil
}
keys := s.reg.List("config_")
names := make([]string, 0, len(keys)+1)
names = append(names, "core")
for _, k := range keys {
// config_xxx → xxx
if len(k) > 7 {
names = append(names, k[7:])
}
}
return names
}

View File

@ -1,31 +0,0 @@
# QQ 通知插件
version: 1.0.0
author: HomeAgent
io_type: io
io_input_route: qq
io_output_route: qq
io_output_caps: text,file,image
QQ 通道插件,通过 OneBot 标准协议与 QQ 前端go-cqhttp / Lagrange协同工作。
OneBot 前端处理 QQ 协议登录与消息收发,本插件将 OneBot 事件转换为 IO 输入,
将 AI 输出转换为 OneBot 动作调用。
## qq_send_private_msg
发送 QQ 私聊消息
- user_id: 目标 QQ 号
- message: 消息内容(支持 CQ 码,如 [CQ:image,file=xxx.jpg]
- auto_escape: 是否作为纯文本发送
## qq_send_group_msg
发送 QQ 群消息
- group_id: 目标群号
- message: 消息内容(支持 CQ 码)
- auto_escape: 是否作为纯文本发送
## qq_get_group_member_info
获取 QQ 群成员信息
- group_id: 群号
- user_id: QQ 号
## qq_get_group_list
获取 QQ 群列表

View File

@ -1,13 +0,0 @@
{
"name": "qq",
"description": "QQ 通道插件,通过 OneBot 标准协议与 QQ 前端协同工作",
"version": "1.0.0",
"author": "HomeAgent",
"entry": "ws://127.0.0.1:6700",
"io": {
"type": "io",
"input_route": "qq",
"output_route": "qq",
"output_caps": ["text", "file", "image"]
}
}