mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 15:53:56 +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)。
|
完整架构文档参见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
|
||||||
|
实施计划参见 [PLAN.md](PLAN.md)。
|
||||||
|
|
||||||
## 核心原则
|
## 核心原则
|
||||||
|
|
||||||
- **核心零 IO** — 无任何硬编码 IO 能力,所有 IO 来自插件
|
- **核心零 IO** — 无任何硬编码 IO 能力
|
||||||
- **输出是工具调用** — Agent 必须显式 `output_send` 才能通信
|
- **输出是工具调用** — Agent 必须显式调用 output_send 才能通信
|
||||||
- **三通道插件** — 工具 (RegisterTool)、阶段 (RegisterStage)、事件 (Subscribe/Publish)
|
- **三通道插件** — 工具 (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)
|
- **三层记忆** — Context (内存) → Document (JSON+向量) → Graph (SQLite)
|
||||||
- **知识独立** — 独立 TF-IDF 向量索引,不与记忆耦合
|
- **知识独立** — 独立 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
|
BINARY=homed
|
||||||
|
CLI_BINARY=homecli
|
||||||
GO=go
|
GO=go
|
||||||
GOCACHE=/tmp/gocache
|
GOCACHE=/tmp/gocache
|
||||||
GOPATH=$(shell go env GOPATH)
|
GOPATH=$(shell go env GOPATH)
|
||||||
BUILD_DIR=build
|
BUILD_DIR=build
|
||||||
|
|
||||||
all: build
|
all: build build-cli
|
||||||
|
|
||||||
build:
|
build:
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
CGO_ENABLED=1 $(GO) build -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
|
CGO_ENABLED=1 $(GO) build -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
|
||||||
@echo "Built: $(BUILD_DIR)/$(BINARY)"
|
@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:
|
build-static:
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
CGO_ENABLED=1 $(GO) build -tags netgo -ldflags '-extldflags "-static"' -o $(BUILD_DIR)/$(BINARY)-static ./cmd/homed/
|
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` | ✅ |
|
| **自注册 (init)** | 内置插件 (timer/cli/openclaw/webui) | 包 `init()` 调 `plugin.RegisterFactory(name, factory)` |
|
||||||
| PluginAPI(RegisterTool/RegisterStage/Subscribe/Publish) | `internal/plugin/sdk/api.go` | ✅ |
|
| **动态加载** | 第三方插件 | 扫描 `<data>/plugins/<name>/`, 读 manifest + .so |
|
||||||
| 插件内部 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` | ✅ |
|
|
||||||
|
|
||||||
---
|
### 自注册流程
|
||||||
|
|
||||||
## 待实施
|
|
||||||
|
|
||||||
### 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/
|
internal/plugins/timer/plugin.go
|
||||||
├── cmd/homed/main.go — 入口
|
func init() {
|
||||||
├── internal/
|
plugin.RegisterFactory("timer", func(name string, cfg map[string]interface{}) (sdk.Plugin, error) {
|
||||||
│ ├── agent/
|
return New(name), nil
|
||||||
│ │ ├── core/
|
})
|
||||||
│ │ │ ├── agent.go — Agent 核心
|
}
|
||||||
│ │ │ ├── context.go — 相关性上下文
|
|
||||||
│ │ │ └── stages.go — StageHost
|
internal/plugins/all.go
|
||||||
│ │ └── api/
|
package plugins
|
||||||
│ │ └── provider.go — LLM Provider
|
import ( _ "timer" _ "cli" _ "openclaw" _ "webui" )
|
||||||
│ ├── events/
|
// 空白导入触发所有 init() → RegisterFactory
|
||||||
│ │ └── bus.go — 系统事件总线
|
|
||||||
│ ├── plugin/
|
cmd/homed/main.go
|
||||||
│ │ └── sdk/
|
cli.DefaultSocket = *cliSocket // 注入运行时变量
|
||||||
│ │ ├── api.go — PluginAPI
|
openclaw.SkillsDir = filepath.Join(...)
|
||||||
│ │ └── bus.go — 插件 EventBus
|
webui.Configure(httpAddr, sup, mem, sk, ...)
|
||||||
│ ├── memory/ — 三层记忆
|
pluginReg.Load(plgDir) // 自动创建目录 + 加载
|
||||||
│ ├── knowledge/ — 知识库
|
|
||||||
│ ├── tracker/ — 变更追踪
|
|
||||||
│ ├── supervisor/ — 守护进程
|
|
||||||
│ └── plugins/ — 插件实现
|
|
||||||
│ ├── webui/ — HTTP API + 仪表盘
|
|
||||||
│ ├── onebot/ — QQ 通道
|
|
||||||
│ └── outputbus/ — 输出通道管理
|
|
||||||
├── docs/
|
|
||||||
│ └── ARCHITECTURE.md — 架构文档
|
|
||||||
├── DESIGN.md
|
|
||||||
├── PLAN.md
|
|
||||||
└── README.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 设计原则
|
### 动态 .so 加载
|
||||||
|
|
||||||
1. **核心零 IO** — Core 不依赖任何插件、设备、通道实现
|
插件目录结构:
|
||||||
2. **三通道标准** — 所有插件通过 Tool/Stage/Event 与核心交互
|
```
|
||||||
3. **增量迁移** — 每阶段保持向后兼容,旧组件与新 SDK 并行运行
|
<data>/plugins/myplugin/
|
||||||
4. **测试覆盖** — 每阶段提交前确保全部测试通过
|
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
|
# HomeAgent
|
||||||
|
|
||||||
单二进制 24/7 智能管家。**核心零 IO**,所有输入输出通过插件,插件通过三通道与核心交互:工具、阶段钩子、事件订阅。
|
24/7 智能管家。**核心零 IO**,一切外界交互来自插件。
|
||||||
|
|
||||||
## 架构
|
## 架构概览
|
||||||
|
|
||||||
```
|
```
|
||||||
外部(QQ / HTTP / 硬件...)
|
homed (内核) — 零 IO,纯管理
|
||||||
│ 通过插件注册
|
├── LLM 源管理 (Lua 适配器协议转换)
|
||||||
▼
|
├── Agent 编排 (主 agent + interceptLoop + 子 agent)
|
||||||
IOManager.InjectInput()
|
├── 三层记忆 (Context → Document → Graph)
|
||||||
│
|
├── 知识库 (独立 TF-IDF)
|
||||||
▼
|
├── IO 通道管理 (Queue / Interrupt / Output)
|
||||||
┌─────────────────────────────────────────────────┐
|
├── 阶段管道 (StageHost: 7 阶段并行)
|
||||||
│ Agent Core │
|
└── 事件总线 (EventBus)
|
||||||
│ │
|
│
|
||||||
│ on_input → Context → Memory Recall │
|
▼ PluginSDK (Go API: 工具/阶段/事件/记忆/知识/LLM/配置)
|
||||||
│ → pre_action → [LLM ↔ 工具循环] → before_output │
|
│
|
||||||
│ → output_send → after_output │
|
plugins (init() 自注册 + .so 动态加载)
|
||||||
│ │
|
├── WebUI (HTTP 服务)
|
||||||
│ 内置:三层记忆 + 知识库 + Child Agent │
|
├── CLI (Unix socket)
|
||||||
└─────────────────────────────────────────────────┘
|
├── OpenClaw 兼容 (SKILL.md → SDK 工具)
|
||||||
│
|
├── Timer (timer_set 工具 + 中断反馈)
|
||||||
├── Stage Pipeline(7 阶段,插件可拦截/改写)
|
└── 第三方 .so 插件 (plugins/<name>/plugin.so)
|
||||||
├── Tool System(插件注册工具给 LLM)
|
|
||||||
└── Event Bus(插件订阅系统事件)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 快速开始
|
核心文档: [PLAN.md](PLAN.md) · [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build
|
make build
|
||||||
./build/homed -data /tmp/homeagent
|
./build/homed -data /tmp/homeagent
|
||||||
|
./build/waiter -say "你好"
|
||||||
```
|
```
|
||||||
|
|
||||||
依赖:Go 1.19+、CGo(go-sqlite3)。
|
依赖: Go 1.19+, CGo (go-sqlite3), Linux (Unix socket + overlayfs)
|
||||||
|
|
||||||
## 阶段管道
|
|
||||||
|
|
||||||
```
|
|
||||||
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)。
|
|
||||||
|
|||||||
@ -13,12 +13,8 @@ import (
|
|||||||
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
|
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
|
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"
|
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
"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"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||||
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
@ -26,38 +22,40 @@ import (
|
|||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/onebot"
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
"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/skill"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||||
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", config.DefaultConfigPath, "path to config file")
|
|
||||||
dataDir := flag.String("data", "/var/lib/homeagent", "data directory")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
if *cliSocket == "" {
|
||||||
log.Printf("[homed] starting HomeAgent v0.1.0")
|
*cliSocket = filepath.Join(*dataDir, "cli.sock")
|
||||||
|
|
||||||
cfg, err := config.Load(*configPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("load config: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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{
|
dirs := []string{
|
||||||
cfg.Daemon.DataDir,
|
*dataDir,
|
||||||
filepath.Join(cfg.Daemon.DataDir, "snapshots"),
|
filepath.Join(*dataDir, "snapshots"),
|
||||||
filepath.Join(cfg.Daemon.DataDir, "skills"),
|
filepath.Join(*dataDir, "skills"),
|
||||||
filepath.Join(cfg.Daemon.DataDir, "plugins"),
|
filepath.Join(*dataDir, "plugins"),
|
||||||
filepath.Join(cfg.Daemon.DataDir, "changesets"),
|
filepath.Join(*dataDir, "changesets"),
|
||||||
filepath.Join(cfg.Daemon.DataDir, "memory"),
|
filepath.Join(*dataDir, "memory"),
|
||||||
filepath.Join(cfg.Daemon.DataDir, "memory", "raw"),
|
filepath.Join(*dataDir, "memory", "raw"),
|
||||||
filepath.Join(cfg.Daemon.DataDir, "adapters"),
|
filepath.Join(*dataDir, "adapters"),
|
||||||
agentWorkDir,
|
agentWorkDir,
|
||||||
}
|
}
|
||||||
for _, d := range dirs {
|
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 {
|
if err != nil {
|
||||||
log.Printf("[homed] warning: memory init failed: %v", err)
|
log.Printf("[homed] warning: memory init failed: %v", err)
|
||||||
memDB = nil
|
memDB = nil
|
||||||
@ -79,15 +80,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
memIdx := memory.NewIndexer(memDB)
|
memIdx := memory.NewIndexer(memDB)
|
||||||
|
|
||||||
// === Social Store(人物特质与关系网)===
|
|
||||||
socialStore := social.New(memDB)
|
socialStore := social.New(memDB)
|
||||||
if memDB != nil {
|
|
||||||
log.Printf("[homed] social store initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Memory Pipeline ===
|
distiller := pipeline.NewDistiller(memDB, *dataDir, pipeline.DistillerConfig{
|
||||||
distiller := pipeline.NewDistiller(memDB, cfg.Daemon.DataDir, pipeline.DistillerConfig{
|
|
||||||
Interval: 10 * time.Minute,
|
Interval: 10 * time.Minute,
|
||||||
RetentionDays: 7,
|
RetentionDays: 7,
|
||||||
BatchSize: 50,
|
BatchSize: 50,
|
||||||
@ -97,41 +92,24 @@ func main() {
|
|||||||
defer distiller.Stop()
|
defer distiller.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Skills ===
|
skMgr := skill.NewManager(filepath.Join(*dataDir, "skills"))
|
||||||
skMgr := skill.NewManager(filepath.Join(cfg.Daemon.DataDir, "skills"))
|
|
||||||
if err := skMgr.Init(); err != nil {
|
if err := skMgr.Init(); err != nil {
|
||||||
log.Printf("[homed] warning: skill init failed: %v", err)
|
log.Printf("[homed] warning: skill init failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Plugin Registry (OpenClaw SKILL.md compatible) ===
|
// ========================================================================
|
||||||
pluginReg := plugin.NewRegistry()
|
// 配置中心(SQLite 持久化,唯一配置源)
|
||||||
// 注册内置原生插件工厂
|
// ========================================================================
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
// === Config Registry (统一配置中心,SQLite 持久化) ===
|
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(*dataDir, "config.db"))
|
||||||
// 所有配置收敛到 SQLite,YAML 仅作首次 seed
|
|
||||||
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(cfg.Daemon.DataDir, "config.db"))
|
|
||||||
defer cfgReg.Close()
|
defer cfgReg.Close()
|
||||||
cfgReg.SeedFrom(cfg)
|
cfgReg.SeedDefaults(*dataDir)
|
||||||
cfg = cfgReg.ToConfig() // 此后全从 DB 读取
|
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"))
|
luaVM := luapkg.NewVM(filepath.Join(cfg.Daemon.DataDir, "adapters"))
|
||||||
if err := luaVM.Start(); err != nil {
|
if err := luaVM.Start(); err != nil {
|
||||||
log.Printf("[homed] warning: lua vm init failed: %v", err)
|
log.Printf("[homed] warning: lua vm init failed: %v", err)
|
||||||
@ -139,66 +117,19 @@ func main() {
|
|||||||
defer luaVM.Stop()
|
defer luaVM.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// === IO Abstraction Layer (唯一输入路径) ===
|
// ========================================================================
|
||||||
iom := agentIO.NewIOManager()
|
// 守护管理(代理生命周期管理)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
// 插件绑定 IO 管理器 → 插件自动注册为 IO 设备
|
sup := supervisor.New(cfg)
|
||||||
pluginReg.SetIOManager(iom)
|
if err := sup.Start(); err != nil {
|
||||||
// 首次加载插件
|
log.Fatalf("start supervisor: %v", err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Text Memory (三层记忆: Context → Text → Graph) ===
|
// ========================================================================
|
||||||
textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
|
// 变更追踪(overlayfs)
|
||||||
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"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
trk := tracker.NewTracker(cfg.Daemon.DataDir, agentWorkDir)
|
||||||
if err := trk.Init(); err != nil {
|
if err := trk.Init(); err != nil {
|
||||||
log.Printf("[homed] warning: tracker init: %v", err)
|
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
|
apiKey := cfg.LLM.APIKey
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
apiKey = os.Getenv("DEEPSEEK_API_KEY")
|
apiKey = os.Getenv("DEEPSEEK_API_KEY")
|
||||||
@ -232,13 +220,31 @@ func main() {
|
|||||||
}, luaVM, src.Adapter)
|
}, luaVM, src.Adapter)
|
||||||
providerMgr.Register(src.Name, luaProvider)
|
providerMgr.Register(src.Name, luaProvider)
|
||||||
}
|
}
|
||||||
// 默认源由 config 指定
|
|
||||||
if cfg.LLM.Provider != "" {
|
if cfg.LLM.Provider != "" {
|
||||||
providerMgr.SetDefault(cfg.LLM.Provider)
|
providerMgr.SetDefault(cfg.LLM.Provider)
|
||||||
}
|
}
|
||||||
provider := providerMgr.Default()
|
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")
|
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
|
||||||
personality, err := agentPkg.LoadPersonality(personalPath)
|
personality, err := agentPkg.LoadPersonality(personalPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -248,41 +254,55 @@ func main() {
|
|||||||
log.Printf("[homed] personality loaded (%d bytes)", len(personality.Content))
|
log.Printf("[homed] personality loaded (%d bytes)", len(personality.Content))
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Document Memory (第二层记忆:上下文→文档) ===
|
// ========================================================================
|
||||||
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"))
|
// 阶段管道(StageHost)+ 插件系统(Registry)
|
||||||
if err := docStore.Start(); err != nil {
|
// ========================================================================
|
||||||
log.Printf("[homed] warning: document store: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 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 := agentCore.NewStageHost()
|
||||||
stageHost.SyncFromRegistry(pluginReg)
|
|
||||||
log.Printf("[homed] stage host initialized with %d plugin sdks", pluginReg.SDKPluginCount())
|
|
||||||
|
|
||||||
// === Test DeepSeek Plugin ===
|
pluginReg := plugin.NewRegistry()
|
||||||
tdBus := sdk.NewInProcessBus()
|
pluginReg.SetIOManager(iom)
|
||||||
tdPlugin := test_deepseek.New(tdBus)
|
pluginReg.SetEventBus(evBus)
|
||||||
// 插件作用域的配置表(RegisterPluginAPI 自动设置 config_test_deepseek 表)
|
pluginReg.SetMemory(memDB)
|
||||||
if err := pluginReg.RegisterPluginAPI(tdPlugin); err != nil {
|
pluginReg.SetTextMemory(textMem)
|
||||||
log.Printf("[homed] warning: register test_deepseek plugin: %v", err)
|
pluginReg.SetDocStore(docStore)
|
||||||
} else {
|
pluginReg.SetKnowledge(ks)
|
||||||
stageHost.RegisterPlugin(tdPlugin)
|
pluginReg.SetProviderManager(providerMgr)
|
||||||
log.Printf("[homed] test_deepseek plugin registered (config table: config_test_deepseek)")
|
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{
|
agent := agentCore.New(agentCore.AgentConfig{
|
||||||
ID: "main",
|
ID: "main",
|
||||||
SystemPrompt: `你是 HomeAgent,一个持续运行的个人管家。
|
SystemPrompt: `你是 HomeAgent,一个持续运行的个人管家。
|
||||||
@ -306,25 +326,20 @@ func main() {
|
|||||||
12. llm_list_sources — 列出所有可用的 LLM 源
|
12. llm_list_sources — 列出所有可用的 LLM 源
|
||||||
13. llm_set_source — 切换到指定 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,
|
Provider: provider,
|
||||||
ProviderManager: providerMgr,
|
ProviderManager: providerMgr,
|
||||||
IO: iom,
|
IO: iom,
|
||||||
Memory: memDB,
|
Memory: memDB,
|
||||||
Indexer: memIdx,
|
Indexer: memIdx,
|
||||||
Skills: skMgr,
|
Skills: skMgr,
|
||||||
Tracker: trk,
|
Tracker: trk,
|
||||||
MaxToolTurns: 10,
|
MaxToolTurns: 10,
|
||||||
DocStore: docStore,
|
DocStore: docStore,
|
||||||
Knowledge: ks,
|
Knowledge: ks,
|
||||||
SocialStore: socialStore,
|
SocialStore: socialStore,
|
||||||
TextMemory: textMem,
|
TextMemory: textMem,
|
||||||
Personality: personality,
|
Personality: personality,
|
||||||
PluginReg: pluginReg,
|
PluginReg: pluginReg,
|
||||||
PluginDir: filepath.Join(cfg.Daemon.DataDir, "plugins"),
|
PluginDir: filepath.Join(cfg.Daemon.DataDir, "plugins"),
|
||||||
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
|
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
|
||||||
@ -334,27 +349,23 @@ func main() {
|
|||||||
agent.Start()
|
agent.Start()
|
||||||
defer agent.Stop()
|
defer agent.Stop()
|
||||||
|
|
||||||
// Wire supervisor with tracker + agent registration (after both exist)
|
|
||||||
sup.SetTracker(trk)
|
sup.SetTracker(trk)
|
||||||
sup.RegisterAgent("main")
|
sup.RegisterAgent("main")
|
||||||
|
|
||||||
log.Printf("[homed] main agent started, model=%s base=%s sources=%d adapters=%d",
|
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()))
|
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)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
<-sigCh
|
<-sigCh
|
||||||
|
|
||||||
log.Printf("[homed] shutting down...")
|
log.Printf("[homed] shutting down...")
|
||||||
|
pluginReg.StopAll()
|
||||||
if trk != nil {
|
if trk != nil {
|
||||||
trk.Stop()
|
trk.Stop()
|
||||||
}
|
}
|
||||||
@ -364,5 +375,3 @@ func main() {
|
|||||||
sup.Shutdown()
|
sup.Shutdown()
|
||||||
log.Printf("[homed] stopped")
|
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/
|
internal/
|
||||||
├── agent/
|
├── agent/
|
||||||
│ ├── core/
|
│ ├── core/
|
||||||
│ │ ├── agent.go — Agent 核心:事件循环、工具循环、心跳
|
│ │ ├── agent.go — Agent: eventLoop/interceptLoop/process/distillLoop
|
||||||
│ │ ├── context.go — RelevanceContext:TF-IDF 上下文管理
|
│ │ ├── context.go — RelevanceContext:TF-IDF 上下文管理
|
||||||
│ │ └── stages.go — StageHost:阶段管道编排
|
│ │ └── stages.go — StageHost:阶段管道编排 (并行执行)
|
||||||
│ ├── api/
|
│ ├── api/
|
||||||
│ │ └── provider.go — Provider 接口 + DeepSeek/Ollama 实现
|
│ │ └── provider.go — Provider 接口 + DeepSeek/Ollama/LuaAdaptedProvider
|
||||||
│ ├── io/
|
│ ├── io/
|
||||||
│ │ └── channel.go — IOManager + Device 接口(过渡期保留)
|
│ │ └── channel.go — IOManager (排队/中断/输出三通道)
|
||||||
│ └── personal.go — 人格加载
|
│ └── personal.go — 人格加载
|
||||||
├── agent/
|
├── sdk/ ★ PluginSDK: 核心 Go API
|
||||||
│ ├── core/
|
│ ├── plugin.go — Plugin 接口 + PluginSDK 结构体
|
||||||
│ │ ├── agent.go — Agent 核心:事件循环、工具循环、心跳、selfInputCh
|
│ ├── memory.go — MemoryAPI (图/文本/文档)
|
||||||
│ │ ├── context.go — RelevanceContext:TF-IDF 上下文管理
|
│ ├── knowledge.go — KnowledgeAPI
|
||||||
│ │ └── stages.go — StageHost:阶段管道编排
|
│ ├── settings.go — SettingsAPI (配置)
|
||||||
│ ├── api/
|
│ └── llm.go — LLMAPI (源管理)
|
||||||
│ │ └── 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 包装
|
|
||||||
├── config/
|
├── config/
|
||||||
│ └── registry.go — ConfigRegistry:统一配置中心
|
│ └── registry.go — ConfigRegistry:统一配置中心 (SQLite)
|
||||||
├── events/
|
├── events/
|
||||||
│ └── bus.go — 系统事件总线 (Publish/Subscribe)
|
│ └── 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/
|
├── memory/
|
||||||
│ ├── graph.go — SQLite 图数据库
|
│ ├── graph.go — SQLite 图数据库
|
||||||
│ ├── indexer.go — 图索引器
|
│ ├── indexer.go — 图索引器
|
||||||
│ ├── vector/store.go — TF-IDF 向量存储
|
│ ├── vector/store.go — TF-IDF 向量存储
|
||||||
│ ├── document/doc.go — 文档记忆
|
│ ├── document/doc.go — 文档记忆
|
||||||
│ ├── text/text.go — 文本记忆(JSONL)
|
│ ├── text/text.go — 文本记忆 (JSONL)
|
||||||
│ └── pipeline/ — 蒸馏器
|
│ └── pipeline/ — 蒸馏器
|
||||||
├── knowledge/
|
├── knowledge/
|
||||||
│ └── knowledge.go — 知识系统
|
│ └── knowledge.go — 知识系统
|
||||||
├── plugin/
|
|
||||||
│ ├── plugin.go — 插件注册表 + ConfigRegistry + SettingsAPI 注入
|
|
||||||
│ └── sdk/
|
|
||||||
│ ├── api.go — PluginAPI (Tool/Stage/Event/Settings/Memory/Knowledge)
|
|
||||||
│ └── bus.go — 插件内部 EventBus 接口
|
|
||||||
├── onebot/ — OneBot V11 QQ 协议实现
|
├── onebot/ — OneBot V11 QQ 协议实现
|
||||||
├── tracker/ — 变更追踪 (overlayfs)
|
├── tracker/ — 变更追踪 (overlayfs)
|
||||||
├── supervisor/ — 守护进程
|
├── supervisor/ — 守护进程
|
||||||
├── skill/ — 技能管理器
|
├── skill/ — 技能管理器
|
||||||
├── lua/
|
├── lua/
|
||||||
│ ├── vm.go — Lua VM (json.encode/decode, CallTransformRequest/Response)
|
│ ├── vm.go — Lua VM (json.encode/decode, transform)
|
||||||
│ └── adapters/
|
│ └── adapters/ — LLM 协议适配器脚本
|
||||||
│ ├── openai.lua — OpenAI 协议适配
|
|
||||||
│ ├── deepseek.lua — DeepSeek 协议适配 (temperature=0, reasoning)
|
|
||||||
│ └── ollama.lua — Ollama 协议适配
|
|
||||||
├── network/ — 网络监控
|
├── network/ — 网络监控
|
||||||
├── container/ — 容器管理
|
├── container/ — 容器管理
|
||||||
├── snapshot/ — 快照
|
├── snapshot/ — 快照
|
||||||
├── embed/ — 嵌入
|
├── embed/ — 嵌入
|
||||||
└── tokenizer/ — 分词器
|
└── tokenizer/ — 分词器
|
||||||
config/ — 顶层配置加载
|
config/ — 顶层配置加载
|
||||||
├── config.go — Config 结构 + 加载/保存
|
├── config.go — Config 结构
|
||||||
└── config.yaml
|
└── config.yaml
|
||||||
pkg/types/ — 类型定义
|
pkg/types/ — 类型定义
|
||||||
docs/
|
docs/
|
||||||
└── ARCHITECTURE.md — 本架构文档
|
├── ARCHITECTURE.md — 本架构文档
|
||||||
|
├── ADAPTER.md — Lua 适配器文档
|
||||||
|
└── PLAN.md — 实施计划/概览
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -664,3 +665,109 @@ docs/
|
|||||||
| 核心 IO | IOManager `EmitOutput` 直出 | 全部走 `output_send` 工具 |
|
| 核心 IO | IOManager `EmitOutput` 直出 | 全部走 `output_send` 工具 |
|
||||||
| 插件工具路由 | IOManager `ExecuteTool` 链 | StageHost + Registry 双层路由 |
|
| 插件工具路由 | IOManager `ExecuteTool` 链 | StageHost + Registry 双层路由 |
|
||||||
| 内部任务 | 无 | selfInputCh 自循环通道(不经过 IO) |
|
| 内部任务 | 无 | 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/social"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
"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/skill"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||||
@ -82,6 +82,13 @@ type Agent struct {
|
|||||||
childMu sync.Mutex
|
childMu sync.Mutex
|
||||||
childNextID int64
|
childNextID int64
|
||||||
childResults map[string]string
|
childResults map[string]string
|
||||||
|
|
||||||
|
// 高优先级打断通道:interceptLoop 注入,process() 在工具循环轮次间非阻塞读取
|
||||||
|
interceptCh chan string
|
||||||
|
|
||||||
|
// 进行中的 LLM 请求取消函数,interceptLoop 可调用以在请求中打断
|
||||||
|
cancelLLM context.CancelFunc
|
||||||
|
llmMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentConfig struct {
|
type AgentConfig struct {
|
||||||
@ -148,11 +155,13 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
eventBus: cfg.EventBus,
|
eventBus: cfg.EventBus,
|
||||||
selfInputCh: make(chan string, 64),
|
selfInputCh: make(chan string, 64),
|
||||||
childResults: make(map[string]string),
|
childResults: make(map[string]string),
|
||||||
|
interceptCh: make(chan string, 64),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) Start() {
|
func (a *Agent) Start() {
|
||||||
go a.eventLoop()
|
go a.eventLoop()
|
||||||
|
go a.interceptLoop()
|
||||||
go a.distillLoop()
|
go a.distillLoop()
|
||||||
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
|
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 层)
|
// handleSelfInput 处理自循环输入(内部任务,不经过 IO 层)
|
||||||
func (a *Agent) handleSelfInput(task string) {
|
func (a *Agent) handleSelfInput(task string) {
|
||||||
a.processTextInput(&agentIO.InputEvent{
|
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++ {
|
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{
|
req := &agentAPI.CompletionRequest{
|
||||||
Messages: msgs,
|
Messages: msgs,
|
||||||
MaxTokens: 4096,
|
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 {
|
if err != nil {
|
||||||
return "", toolsUsed, fmt.Errorf("provider: %w", err)
|
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 {
|
func truncateStr(s string, max int) string {
|
||||||
runes := []rune(s)
|
if len(s) <= max {
|
||||||
if len(runes) > max {
|
return s
|
||||||
return string(runes[:max]) + "..."
|
}
|
||||||
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type StageHost struct {
|
type StageHost struct {
|
||||||
plugins []*sdk.PluginAPI
|
mu sync.RWMutex
|
||||||
toolDefs []sdk.ToolDef
|
toolDefs []sdk.ToolDef
|
||||||
tools map[string]sdk.ToolHandler
|
tools map[string]sdk.ToolHandler
|
||||||
|
stages map[sdk.Stage][]sdk.StageHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStageHost() *StageHost {
|
func NewStageHost() *StageHost {
|
||||||
return &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) {
|
func (h *StageHost) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||||
h.plugins = append(h.plugins, api)
|
h.mu.Lock()
|
||||||
for name, handler := range api.Tools() {
|
defer h.mu.Unlock()
|
||||||
h.tools[name] = handler
|
if _, exists := h.tools[name]; exists {
|
||||||
h.toolDefs = append(h.toolDefs, sdk.ToolDef{Name: name})
|
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) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {
|
||||||
func (h *StageHost) SyncFromRegistry(reg *plugin.Registry) {
|
h.mu.Lock()
|
||||||
if reg == nil {
|
defer h.mu.Unlock()
|
||||||
return
|
h.stages[stage] = append(h.stages[stage], handler)
|
||||||
}
|
|
||||||
for _, td := range reg.GetAllSDKToolDefs() {
|
|
||||||
h.toolDefs = append(h.toolDefs, td)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *StageHost) GetToolDefs() []sdk.ToolDef {
|
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) {
|
func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (interface{}, error) {
|
||||||
if handler, ok := h.tools[name]; ok {
|
h.mu.RLock()
|
||||||
return handler(args)
|
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) {
|
func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
|
||||||
for _, p := range h.plugins {
|
h.mu.RLock()
|
||||||
for _, handler := range p.StageHandlers(stage) {
|
handlers := h.stages[stage]
|
||||||
if err := handler(ctx); err != nil {
|
h.mu.RUnlock()
|
||||||
return
|
if len(handlers) == 0 {
|
||||||
}
|
return
|
||||||
if ctx.Response != nil {
|
|
||||||
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) {
|
func (h *StageHost) RunStageAll(stage sdk.Stage, ctx *sdk.StageContext) {
|
||||||
for _, p := range h.plugins {
|
h.RunStage(stage, ctx)
|
||||||
for _, handler := range p.StageHandlers(stage) {
|
|
||||||
handler(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *StageHost) PluginCount() int {
|
func (h *StageHost) ToolCount() int {
|
||||||
return len(h.plugins)
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
return len(h.tools)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,21 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"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()
|
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
|
return "ok", nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
host.RegisterPlugin(api)
|
t.Fatalf("register: %v", err)
|
||||||
|
|
||||||
if host.PluginCount() != 1 {
|
|
||||||
t.Errorf("expected 1 plugin, got %d", host.PluginCount())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defs := host.GetToolDefs()
|
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) {
|
func TestStageHostExecuteTool(t *testing.T) {
|
||||||
host := NewStageHost()
|
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
|
return "world", nil
|
||||||
})
|
})
|
||||||
|
|
||||||
host.RegisterPlugin(api)
|
|
||||||
|
|
||||||
result, err := host.ExecuteTool("hello", nil)
|
result, err := host.ExecuteTool("hello", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("execute: %v", err)
|
t.Fatalf("execute: %v", err)
|
||||||
@ -55,16 +59,13 @@ func TestStageHostExecuteTool(t *testing.T) {
|
|||||||
|
|
||||||
func TestStageHostRunStage(t *testing.T) {
|
func TestStageHostRunStage(t *testing.T) {
|
||||||
host := NewStageHost()
|
host := NewStageHost()
|
||||||
api := sdk.NewPluginAPI("test", "1.0.0", nil, nil, nil)
|
|
||||||
|
|
||||||
var called bool
|
var called bool
|
||||||
api.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
host.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||||||
called = true
|
called = true
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
host.RegisterPlugin(api)
|
|
||||||
|
|
||||||
ctx := &sdk.StageContext{RawMessage: "hello"}
|
ctx := &sdk.StageContext{RawMessage: "hello"}
|
||||||
host.RunStage(sdk.StageOnInput, ctx)
|
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()
|
host := NewStageHost()
|
||||||
|
|
||||||
api1 := sdk.NewPluginAPI("p1", "1.0.0", nil, nil, nil)
|
// Two handlers that both try to set Response under Lock.
|
||||||
api1.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
// Only the first to acquire Lock actually wins; the second sees IsResponded() and skips.
|
||||||
resp := "short-circuited"
|
host.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||||||
ctx.Response = &resp
|
ctx.Lock()
|
||||||
|
if ctx.Response == nil {
|
||||||
|
resp := "from-first"
|
||||||
|
ctx.Response = &resp
|
||||||
|
}
|
||||||
|
ctx.Unlock()
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
host.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||||||
var api2called bool
|
ctx.Lock()
|
||||||
api2 := sdk.NewPluginAPI("p2", "1.0.0", nil, nil, nil)
|
if ctx.Response == nil {
|
||||||
api2.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
resp := "from-second"
|
||||||
api2called = true
|
ctx.Response = &resp
|
||||||
|
}
|
||||||
|
ctx.Unlock()
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
host.RegisterPlugin(api1)
|
|
||||||
host.RegisterPlugin(api2)
|
|
||||||
|
|
||||||
ctx := &sdk.StageContext{RawMessage: "hello"}
|
ctx := &sdk.StageContext{RawMessage: "hello"}
|
||||||
host.RunStage(sdk.StageOnInput, ctx)
|
host.RunStage(sdk.StageOnInput, ctx)
|
||||||
|
|
||||||
if ctx.Response == nil || *ctx.Response != "short-circuited" {
|
if ctx.Response == nil {
|
||||||
t.Errorf("expected short-circuited, got %v", ctx.Response)
|
t.Fatal("expected a response to be set")
|
||||||
}
|
}
|
||||||
if api2called {
|
if *ctx.Response != "from-first" && *ctx.Response != "from-second" {
|
||||||
t.Error("api2 should not have been called after short circuit")
|
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) {
|
func TestStageHostRunStageAll(t *testing.T) {
|
||||||
host := NewStageHost()
|
host := NewStageHost()
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
count := 0
|
count := 0
|
||||||
api1 := sdk.NewPluginAPI("p1", "1.0.0", nil, nil, nil)
|
host.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error {
|
||||||
api1.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error {
|
mu.Lock()
|
||||||
count++
|
count++
|
||||||
|
mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
host.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error {
|
||||||
api2 := sdk.NewPluginAPI("p2", "1.0.0", nil, nil, nil)
|
mu.Lock()
|
||||||
api2.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error {
|
|
||||||
count++
|
count++
|
||||||
|
mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
host.RegisterPlugin(api1)
|
|
||||||
host.RegisterPlugin(api2)
|
|
||||||
|
|
||||||
host.RunStageAll(sdk.StageAfterOutput, &sdk.StageContext{})
|
host.RunStageAll(sdk.StageAfterOutput, &sdk.StageContext{})
|
||||||
|
|
||||||
if count != 2 {
|
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) {
|
func TestStageHostEmpty(t *testing.T) {
|
||||||
host := NewStageHost()
|
host := NewStageHost()
|
||||||
|
|
||||||
if host.PluginCount() != 0 {
|
if host.ToolCount() != 0 {
|
||||||
t.Errorf("expected 0 plugins, got %d", host.PluginCount())
|
t.Errorf("expected 0 tools, got %d", host.ToolCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
defs := host.GetToolDefs()
|
defs := host.GetToolDefs()
|
||||||
@ -178,6 +170,30 @@ func TestStageHostEmpty(t *testing.T) {
|
|||||||
t.Error("expected error on empty host")
|
t.Error("expected error on empty host")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunStage on empty host should not panic
|
|
||||||
host.RunStage(sdk.StageOnInput, &sdk.StageContext{})
|
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 {
|
type IOManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
devices map[string]Device
|
devices map[string]Device
|
||||||
inputCh chan *InputEvent
|
inputCh chan *InputEvent
|
||||||
outputCh chan *OutputEvent
|
interruptCh chan *InputEvent
|
||||||
nextReqID int64
|
outputCh chan *OutputEvent
|
||||||
|
nextReqID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIOManager() *IOManager {
|
func NewIOManager() *IOManager {
|
||||||
return &IOManager{
|
return &IOManager{
|
||||||
devices: make(map[string]Device),
|
devices: make(map[string]Device),
|
||||||
inputCh: make(chan *InputEvent, 256),
|
inputCh: make(chan *InputEvent, 256),
|
||||||
outputCh: make(chan *OutputEvent, 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 注入文本输入(同步等待)并指定输出通道
|
// InjectTextSyncTo 注入文本输入(同步等待)并指定输出通道
|
||||||
func (m *IOManager) InjectTextSyncTo(source, outputChannel, text string) *OutputEvent {
|
func (m *IOManager) InjectTextSyncTo(source, outputChannel, text string) *OutputEvent {
|
||||||
return m.InjectInputSyncTo(source, outputChannel, "text", map[string]interface{}{
|
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()
|
return r.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeedFrom 从 *types.Config 批量导入默认值到 config 表(仅空表时写入)
|
// SeedDefaults 用硬编码默认值填充 config 表(仅空表时写入),不再依赖 YAML
|
||||||
func (r *ConfigRegistry) SeedFrom(cfg *types.Config) {
|
func (r *ConfigRegistry) SeedDefaults(dataDir string) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
// 检查是否已有数据
|
|
||||||
var count int
|
var count int
|
||||||
r.db.QueryRow(`SELECT COUNT(*) FROM config`).Scan(&count)
|
r.db.QueryRow(`SELECT COUNT(*) FROM config`).Scan(&count)
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
@ -177,44 +176,54 @@ func (r *ConfigRegistry) SeedFrom(cfg *types.Config) {
|
|||||||
set := func(k, v string) { stmt.Exec(k, v) }
|
set := func(k, v string) { stmt.Exec(k, v) }
|
||||||
|
|
||||||
// daemon
|
// daemon
|
||||||
set("core.daemon.listen_addr", cfg.Daemon.ListenAddr)
|
set("core.daemon.listen_addr", ":8080")
|
||||||
set("core.daemon.data_dir", cfg.Daemon.DataDir)
|
set("core.daemon.data_dir", dataDir)
|
||||||
set("core.daemon.heartbeat_interval", cfg.Daemon.HeartbeatInterval.String())
|
set("core.daemon.heartbeat_interval", "15s")
|
||||||
set("core.daemon.check_interval", cfg.Daemon.CheckInterval.String())
|
set("core.daemon.check_interval", "30s")
|
||||||
set("core.daemon.log_level", cfg.Daemon.LogLevel)
|
set("core.daemon.log_level", "info")
|
||||||
|
|
||||||
// llm
|
// llm
|
||||||
set("core.llm.provider", cfg.LLM.Provider)
|
set("core.llm.provider", "deepseek")
|
||||||
set("core.llm.model", cfg.LLM.Model)
|
set("core.llm.model", "deepseek-v4-flash")
|
||||||
set("core.llm.base_url", cfg.LLM.BaseURL)
|
set("core.llm.base_url", "https://api.deepseek.com")
|
||||||
set("core.llm.adapter", cfg.LLM.Adapter)
|
set("core.llm.adapter", "deepseek")
|
||||||
set("core.llm.temperature", strconv.FormatFloat(cfg.LLM.Temperature, 'f', 2, 64))
|
set("core.llm.temperature", "0.7")
|
||||||
set("core.llm.max_tokens", strconv.Itoa(cfg.LLM.MaxTokens))
|
set("core.llm.max_tokens", "4096")
|
||||||
|
|
||||||
// llm sources
|
// llm sources
|
||||||
for _, src := range cfg.LLM.Sources {
|
sources := map[string]map[string]string{
|
||||||
p := "core.llm.sources." + src.Name
|
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"},
|
||||||
set(p+".base_url", src.BaseURL)
|
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o", "adapter": "openai", "adapter_path": "adapters/openai.lua"},
|
||||||
set(p+".model", src.Model)
|
"anthropic": {"base_url": "https://api.anthropic.com", "model": "claude-sonnet-4-20250514", "adapter": "anthropic", "adapter_path": "adapters/anthropic.lua"},
|
||||||
set(p+".adapter", src.Adapter)
|
"gemini": {"base_url": "https://generativelanguage.googleapis.com", "model": "gemini-2.0-flash", "adapter": "gemini", "adapter_path": "adapters/gemini.lua"},
|
||||||
set(p+".adapter_path", src.AdapterPath)
|
"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
|
// defaults
|
||||||
set("core.defaults.image", cfg.Defaults.Image)
|
set("core.defaults.image", "homeagent/agent-base:latest")
|
||||||
set("core.defaults.openclaw_enabled", strconv.FormatBool(cfg.Defaults.OpenClawEnabled))
|
set("core.defaults.openclaw_enabled", "true")
|
||||||
set("core.defaults.snapshot.interval", cfg.Defaults.SnapshotPolicy.Interval.String())
|
set("core.defaults.snapshot.interval", "10m")
|
||||||
set("core.defaults.snapshot.max_snapshots", strconv.Itoa(cfg.Defaults.SnapshotPolicy.MaxSnapshots))
|
set("core.defaults.snapshot.max_snapshots", "20")
|
||||||
set("core.defaults.snapshot.pre_action", strconv.FormatBool(cfg.Defaults.SnapshotPolicy.PreAction))
|
set("core.defaults.snapshot.pre_action", "true")
|
||||||
set("core.defaults.snapshot.post_action", strconv.FormatBool(cfg.Defaults.SnapshotPolicy.PostAction))
|
set("core.defaults.snapshot.post_action", "false")
|
||||||
set("core.defaults.rollback.max_retries", strconv.Itoa(cfg.Defaults.RollbackPolicy.MaxRetries))
|
set("core.defaults.rollback.max_retries", "3")
|
||||||
set("core.defaults.rollback.health_threshold", strconv.Itoa(int(cfg.Defaults.RollbackPolicy.HealthThreshold)))
|
set("core.defaults.rollback.health_threshold", "3")
|
||||||
set("core.defaults.rollback.cooldown_period", cfg.Defaults.RollbackPolicy.CooldownPeriod.String())
|
set("core.defaults.rollback.cooldown_period", "30s")
|
||||||
set("core.defaults.rollback.auto_rollback", strconv.FormatBool(cfg.Defaults.RollbackPolicy.AutoRollback))
|
set("core.defaults.rollback.auto_rollback", "true")
|
||||||
set("core.defaults.resource.cpu", cfg.Defaults.ResourceLimit.CPU)
|
set("core.defaults.resource.cpu", "2")
|
||||||
set("core.defaults.resource.memory", cfg.Defaults.ResourceLimit.Memory)
|
set("core.defaults.resource.memory", "2g")
|
||||||
set("core.defaults.resource.disk", cfg.Defaults.ResourceLimit.Disk)
|
set("core.defaults.resource.disk", "10g")
|
||||||
set("core.defaults.resource.network", strconv.FormatBool(cfg.Defaults.ResourceLimit.Network))
|
set("core.defaults.resource.network", "true")
|
||||||
set("core.agent.max_tool_turns", "10")
|
set("core.agent.max_tool_turns", "10")
|
||||||
set("core.agent.max_context_size", "30")
|
set("core.agent.max_context_size", "30")
|
||||||
set("core.agent.distill_interval", "30m")
|
set("core.agent.distill_interval", "30m")
|
||||||
|
|||||||
@ -4,8 +4,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRegistryBasic(t *testing.T) {
|
func TestRegistryBasic(t *testing.T) {
|
||||||
@ -154,70 +152,36 @@ func TestPluginConfig(t *testing.T) {
|
|||||||
r.Close()
|
r.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSeedFromToConfig(t *testing.T) {
|
func TestSeedDefaultsToConfig(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "config.db")
|
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 := NewConfigRegistry(path)
|
||||||
r.SeedFrom(cfg)
|
r.SeedDefaults(dir)
|
||||||
|
|
||||||
// Verify DB was seeded
|
// Verify DB was seeded with expected number of keys
|
||||||
if len(r.List("")) == 0 {
|
keys := r.List("")
|
||||||
t.Fatal("SeedFrom produced empty DB")
|
if len(keys) == 0 {
|
||||||
|
t.Fatal("SeedDefaults produced empty DB")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconstruct config from DB
|
// Reconstruct config from DB
|
||||||
cfg2 := r.ToConfig()
|
cfg2 := r.ToConfig()
|
||||||
|
|
||||||
if cfg2.Daemon.ListenAddr != ":9090" {
|
if cfg2.Daemon.ListenAddr != ":8080" {
|
||||||
t.Fatalf("expected :9090, got %s", cfg2.Daemon.ListenAddr)
|
t.Fatalf("expected :8080, got %s", cfg2.Daemon.ListenAddr)
|
||||||
}
|
|
||||||
if cfg2.Daemon.LogLevel != "debug" {
|
|
||||||
t.Fatalf("expected debug, got %s", cfg2.Daemon.LogLevel)
|
|
||||||
}
|
}
|
||||||
if cfg2.LLM.Provider != "deepseek" {
|
if cfg2.LLM.Provider != "deepseek" {
|
||||||
t.Fatalf("expected deepseek, got %s", cfg2.LLM.Provider)
|
t.Fatalf("expected deepseek, got %s", cfg2.LLM.Provider)
|
||||||
}
|
}
|
||||||
if cfg2.LLM.MaxTokens != 2048 {
|
if len(cfg2.LLM.Sources) == 0 {
|
||||||
t.Fatalf("expected 2048, got %d", cfg2.LLM.MaxTokens)
|
t.Fatal("expected at least 1 LLM source")
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second SeedFrom should be no-op (DB already has data)
|
// Second SeedDefaults should be no-op (DB already has data)
|
||||||
r.SeedFrom(cfg)
|
r.SeedDefaults(dir)
|
||||||
if len(r.List("")) != len(r.List("")) {
|
if len(r.List("")) != len(keys) {
|
||||||
t.Fatal("second SeedFrom changed DB count")
|
t.Fatal("second SeedDefaults changed DB count")
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Close()
|
r.Close()
|
||||||
|
|||||||
@ -131,6 +131,30 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry) (*Doc, error
|
|||||||
return doc, nil
|
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 — 向量相似度查询文档
|
// Query — 向量相似度查询文档
|
||||||
func (s *Store) Query(text string, topK int) []*Doc {
|
func (s *Store) Query(text string, topK int) []*Doc {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
|
|||||||
@ -14,14 +14,25 @@ type Indexer struct {
|
|||||||
vec *vector.Store
|
vec *vector.Store
|
||||||
veczer *vector.TFIDFVectorizer
|
veczer *vector.TFIDFVectorizer
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
trained bool
|
trained bool
|
||||||
|
recalled map[string]bool // 已通过工具调用显式召回的实体名,自动注入时跳过
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIndexer(db *GraphDB) *Indexer {
|
func NewIndexer(db *GraphDB) *Indexer {
|
||||||
return &Indexer{
|
return &Indexer{
|
||||||
db: db,
|
db: db,
|
||||||
vec: vector.NewStore(),
|
vec: vector.NewStore(),
|
||||||
veczer: vector.NewTFIDFVectorizer(2),
|
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: ""}
|
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{
|
ctx := &InjectedContext{
|
||||||
Entities: result.Entities,
|
Entities: filtered,
|
||||||
Relations: nil,
|
Relations: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Entities) > 0 {
|
if len(filtered) > 0 {
|
||||||
summary := buildIndexSummary(result.Entities)
|
summary := buildIndexSummary(filtered)
|
||||||
ctx.Summary = summary
|
ctx.Summary = summary
|
||||||
ctx.TokenEstimate = estimateTokens(summary) + len(result.Entities)*8
|
ctx.TokenEstimate = estimateTokens(summary) + len(filtered)*8
|
||||||
} else {
|
} else {
|
||||||
ctx.Summary = ""
|
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)
|
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{} {
|
func (m *Memory) Stats() map[string]interface{} {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||||
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
@ -37,9 +38,10 @@ type Handler struct {
|
|||||||
tracker *tracker.Tracker
|
tracker *tracker.Tracker
|
||||||
cfgReg *internalConfig.ConfigRegistry
|
cfgReg *internalConfig.ConfigRegistry
|
||||||
pluginReg *plugin.Registry
|
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
|
var idx *memory.Indexer
|
||||||
if mem != nil {
|
if mem != nil {
|
||||||
idx = memory.NewIndexer(mem)
|
idx = memory.NewIndexer(mem)
|
||||||
@ -58,6 +60,7 @@ func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager,
|
|||||||
tracker: tr,
|
tracker: tr,
|
||||||
cfgReg: cr,
|
cfgReg: cr,
|
||||||
pluginReg: pr,
|
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/adapters/", h.handleAdapterByID)
|
||||||
mux.HandleFunc("/api/v1/tracker", h.handleTracker)
|
mux.HandleFunc("/api/v1/tracker", h.handleTracker)
|
||||||
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("/v1/chat/completions", h.handleOpenAICompletions)
|
||||||
mux.HandleFunc("/", h.handleStatic)
|
mux.HandleFunc("/", h.handleStatic)
|
||||||
}
|
}
|
||||||
@ -416,7 +421,6 @@ func (h *Handler) handleTextMemory(w http.ResponseWriter, r *http.Request) {
|
|||||||
"recent": recent,
|
"recent": recent,
|
||||||
})
|
})
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
// future: purge
|
|
||||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "not_implemented"})
|
writeJSON(w, http.StatusAccepted, map[string]string{"status": "not_implemented"})
|
||||||
default:
|
default:
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
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) {
|
func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
@ -529,11 +607,10 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
v, _ := h.cfgReg.Get(k)
|
v, _ := h.cfgReg.Get(k)
|
||||||
values[k] = v
|
values[k] = v
|
||||||
}
|
}
|
||||||
// 返回插件列表供侧边栏分组
|
|
||||||
plugins := []string{"core"}
|
plugins := []string{"core"}
|
||||||
if h.pluginReg != nil {
|
if h.pluginReg != nil {
|
||||||
for _, p := range h.pluginReg.List() {
|
for _, p := range h.pluginReg.List() {
|
||||||
plugins = append(plugins, "plugin."+p.Name())
|
plugins = append(plugins, "plugin."+p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(plugins)
|
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) {
|
func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.iom == nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "IO manager not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Messages []openAIMessage `json:"messages"`
|
Messages []openAIMessage `json:"messages"`
|
||||||
@ -583,15 +664,17 @@ func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取最后一条 user 消息作为输入
|
|
||||||
lastMsg := req.Messages[len(req.Messages)-1]
|
lastMsg := req.Messages[len(req.Messages)-1]
|
||||||
if lastMsg.Role != "user" {
|
if lastMsg.Role != "user" {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "last message must be from user"})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "last message must be from user"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通过 IO 抽象层同步注入(中断式)
|
|
||||||
response := h.iom.InjectTextSync("http", lastMsg.Content)
|
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{}{
|
resp := map[string]interface{}{
|
||||||
"id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()),
|
"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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav>
|
<nav>
|
||||||
<h1>🦞 HomeAgent</h1>
|
<h1>HomeAgent</h1>
|
||||||
<a class="active" onclick="switchTab('overview')">概览</a>
|
<a class="active" onclick="switchTab('overview')">概览</a>
|
||||||
<a onclick="switchTab('memory')">图记忆</a>
|
<a onclick="switchTab('memory')">图记忆</a>
|
||||||
<a onclick="switchTab('skills')">技能</a>
|
<a onclick="switchTab('skills')">技能</a>
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package api
|
package webui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -9,8 +10,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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"
|
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||||
@ -28,7 +34,7 @@ func newTestHandler(t *testing.T) (*Handler, *supervisor.Daemon) {
|
|||||||
sup := supervisor.New(cfg)
|
sup := supervisor.New(cfg)
|
||||||
sup.Start()
|
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) {
|
func TestHandleStatus(t *testing.T) {
|
||||||
@ -132,7 +138,7 @@ func TestHandleKnowledgeSearch(t *testing.T) {
|
|||||||
sup.Start()
|
sup.Start()
|
||||||
defer sup.Shutdown()
|
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)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=test", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@ -164,7 +170,7 @@ func TestHandleKnowledgeCreate(t *testing.T) {
|
|||||||
sup.Start()
|
sup.Start()
|
||||||
defer sup.Shutdown()
|
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"}`
|
body := `{"name":"new_doc","content":"fresh content"}`
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", strings.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", strings.NewReader(body))
|
||||||
@ -229,7 +235,7 @@ func TestHandleTrackerStats(t *testing.T) {
|
|||||||
sup.Start()
|
sup.Start()
|
||||||
defer sup.Shutdown()
|
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)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/tracker", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@ -243,6 +249,8 @@ func TestHandleTrackerStats(t *testing.T) {
|
|||||||
func TestHandleOpenAICompletionsNoMessages(t *testing.T) {
|
func TestHandleOpenAICompletionsNoMessages(t *testing.T) {
|
||||||
h, sup := newTestHandler(t)
|
h, sup := newTestHandler(t)
|
||||||
defer sup.Shutdown()
|
defer sup.Shutdown()
|
||||||
|
// 给 handler 一个 IOManager,才能通过 nil 检查到达消息校验
|
||||||
|
h.iom = agentIO.NewIOManager()
|
||||||
|
|
||||||
body := `{"model":"test"}`
|
body := `{"model":"test"}`
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
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) {
|
func TestHandleOpenAICompletionsLastMsgNotUser(t *testing.T) {
|
||||||
h, sup := newTestHandler(t)
|
h, sup := newTestHandler(t)
|
||||||
defer sup.Shutdown()
|
defer sup.Shutdown()
|
||||||
|
// 给 handler 一个 IOManager,才能通过 nil 检查到达消息校验
|
||||||
|
h.iom = agentIO.NewIOManager()
|
||||||
|
|
||||||
body := `{"messages":[{"role":"assistant","content":"hi"}]}`
|
body := `{"messages":[{"role":"assistant","content":"hi"}]}`
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||||
@ -405,7 +415,7 @@ func TestSettingsAPIFlow(t *testing.T) {
|
|||||||
defer sup.Shutdown()
|
defer sup.Shutdown()
|
||||||
|
|
||||||
pluginReg := plugin.NewRegistry()
|
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) {
|
t.Run("GET_settings_lists_keys_and_plugins", func(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
|
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) {
|
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)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h2.handleSettings(w, req)
|
h2.handleSettings(w, req)
|
||||||
@ -538,7 +548,7 @@ func TestSettingsWithPluginRegistry(t *testing.T) {
|
|||||||
defer sup.Shutdown()
|
defer sup.Shutdown()
|
||||||
|
|
||||||
pluginReg := plugin.NewRegistry()
|
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)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@ -559,3 +569,125 @@ func TestSettingsWithPluginRegistry(t *testing.T) {
|
|||||||
t.Fatal("expected 'core' in plugins list")
|
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