mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
重构: 插件自注册 + .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:
16
DESIGN.md
16
DESIGN.md
@ -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)
|
||||
```
|
||||
|
||||
10
Makefile
10
Makefile
@ -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
358
PLAN.md
@ -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` | ✅ |
|
||||
| PluginAPI(RegisterTool/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).
|
||||
|
||||
94
README.md
94
README.md
@ -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 Pipeline(7 阶段,插件可拦截/改写)
|
||||
├── 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+、CGo(go-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)
|
||||
|
||||
@ -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 持久化) ===
|
||||
// 所有配置收敛到 SQLite,YAML 仅作首次 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 VM(LLM 协议适配)
|
||||
// ========================================================================
|
||||
|
||||
// === 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)===
|
||||
// ========================================================================
|
||||
// 内核 API:IOManager(IO 抽象层) + 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
180
cmd/waiter/main.go
Normal 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("> ")
|
||||
}
|
||||
}
|
||||
@ -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 — RelevanceContext:TF-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 — RelevanceContext:TF-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 ...
|
||||
}
|
||||
```
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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{}{
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 = ""
|
||||
}
|
||||
|
||||
298
internal/memory/social/social.go
Normal file
298
internal/memory/social/social.go
Normal 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
|
||||
}
|
||||
@ -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()
|
||||
|
||||
87
internal/plugin/dynamic.go
Normal file
87
internal/plugin/dynamic.go
Normal 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.so(Go 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.lua(Lua 插件)。
|
||||
// 返回 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)
|
||||
}
|
||||
11
internal/plugin/manifest.go
Normal file
11
internal/plugin/manifest.go
Normal 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
303
internal/plugin/registry.go
Normal 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) {
|
||||
// 优先尝试 .so(Go plugin),其次 .lua(Lua 脚本)
|
||||
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
8
internal/plugins/all.go
Normal 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"
|
||||
)
|
||||
128
internal/plugins/cli/plugin.go
Normal file
128
internal/plugins/cli/plugin.go
Normal 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
|
||||
}
|
||||
88
internal/plugins/openclaw/plugin.go
Normal file
88
internal/plugins/openclaw/plugin.go
Normal 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
|
||||
}
|
||||
98
internal/plugins/timer/plugin.go
Normal file
98
internal/plugins/timer/plugin.go
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
127
internal/plugins/webui/plugin.go
Normal file
127
internal/plugins/webui/plugin.go
Normal 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
38
internal/sdk/knowledge.go
Normal 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
42
internal/sdk/llm.go
Normal 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
154
internal/sdk/memory.go
Normal 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
230
internal/sdk/plugin.go
Normal 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
121
internal/sdk/settings.go
Normal 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
|
||||
}
|
||||
@ -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 群列表
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user