feat: complete HomeAgent architecture v2

- IO abstraction layer with OutputChannel routing and capability validation
- Three-layer memory (Context-Document-Graph) with TF-IDF relevance pruning
- OneBot V11 QQ protocol plugin with Reverse WebSocket client
- Plugin system with hot-reload (SKILL.md + native factories)
- Knowledge system with TF-IDF vector indexing
- Personality system (personal.md)
- Text memory (JSONL with rotation)
- Change tracker (overlayfs) with rollback
- Lua adapter VM
- Design document (DESIGN.md)

Module: gitcode.com/JianFeeeee/HomeAgent
This commit is contained in:
root
2026-07-02 12:04:36 +08:00
parent 1a7a846d58
commit bc26850b50
43 changed files with 10296 additions and 0 deletions

257
DESIGN.md Normal file
View File

@ -0,0 +1,257 @@
# HomeAgent 架构设计 v2
## 一、核心理念
24 小时陪伴用户、随时待命的智能管家。**单会话·单 Agent**(可分身/调用其他 Agent身份不漂移。
### 设计原则
- 所有输入走 IO 抽象层(中断模式),不直调 agent 方法
- DeepSeek v4 flash 为默认 LLMthinking 模式关闭
- 人格固定personal.md记忆分层管理防止性格突变
- 知识独立于记忆agent 主动学习
---
## 二、人格内核Personality Core
```
personal.md ──→ 固定注入 system prompt
```
- 纯文本 Markdown 文件,定义 agent 的人格、行为准则
- 加载一次永不改变(不随对话漂移)
- 放在 prompt 最前面,优先级最高
---
## 三、记忆体系Memory System
三层分级架构,自顶向下逐渐持久化、抽象化:
```
用户输入
┌─────────────────────┐
│ Layer 1: 上下 文 │ RelevanceContext
│ 基于相关性保留 │ 30 条活跃TF-IDF 排序
│ 最不相关的→文档记忆 │
└────────┬────────────┘
│ 相关性裁剪(每次响应后)
┌─────────────────────┐
│ Layer 2: 文档记忆 │ document.Store
│ 全文 + TF-IDF 向量 │ JSON 持久化
│ 冷文档→图数据库 │ 72h 未访问→Graph
└────────┬────────────┘
│ 心跳蒸馏30min
┌─────────────────────┐
│ Layer 3: 图数据库 │ memory.GraphDB (SQLite)
│ 实体-关系三元组 │ 向量索引(实体名)
│ 定期重整+同义合并 │ bigram Jaccard
└─────────────────────┘
```
### Layer 1: 上下文RelevanceContext
**不再使用固定条数裁剪。改为:**
1. 每次用户输入后,计算**模型输出**与每条上下文的 TF-IDF 余弦相似度
2. 按相关性从高到低排序,保留 topK默认 30
3. 最不相关的上下文 → 归档到文档记忆Layer 2保留全文+向量
4. 活跃上下文中只保留最近且相关性高的内容
**相关文件:** `internal/agent/core/context.go`
### Layer 2: 文档记忆Document Store
**作用:**
- 存储被上下文裁剪下来的事件摘要、agent 主动提交的文档、图记忆同步的索引
- 每个文档包含:摘要、全文、标签、实体、来源、访问计数、最后访问时间
- TF-IDF 向量索引(字符 bigram支持向量相似度搜索
**冷文档归化:**
- 心跳检测(每 30min找出 72h 未访问且访问次数 ≤ 2 的文档
- 转为图数据库三元组(文档→包含内容、提及实体、标签、来源)
- 清理已归化的冷文档
**相关文件:** `internal/memory/document/document.go`
### Layer 3: 图数据库Graph Memory
**存储:** SQLite 三元组(实体-关系-实体),每个关系带置信度、会话 ID、日期桶
**注入方式(区分于文档注入):**
- 用户消息到达时,先对实体名做**向量相似度搜索**TF-IDF
- 找到相关实体名 → 查询图数据库
- 只注入**节点索引**(实体名+类型+提及次数+关系类型)到 prompt不注入全文
- 需要更多细节时agent 调用 `memory_recall` 工具查询
**定期重整(心跳触发,每 30min**
1. 同步实体名到向量索引(`Indexer.Sync()`
2. 文档记忆向量索引重建
3. 冷文档→图归化
4. 实体同义合并bigram Jaccard > 0.5
**相关文件:** `internal/memory/graph.go`, `internal/memory/indexer.go`
### Agent 可用记忆工具
| 工具 | 作用 | 操作对象 |
|------|------|----------|
| memory_recall | 检索图记忆 | GraphDB |
| memory_commit | 写入三元组 | GraphDB |
| memory_introspect | 查看记忆统计 | GraphDB |
| doc_query | 向量查询文档记忆 | Document Store |
| doc_commit | 提交文档 | Document Store |
---
## 四、知识体系Knowledge System
独立于记忆系统,用于 agent 学习知识:
```
knowledge/
smart_home/
content.md ← 原始知识文件
cooking/
content.md
...
TF-IDF 向量索引 ← 知识目录扫描时自动构建
knowledge_search(query) → 返回相关内容
```
**知识来源:**
1. **agent 主动学习**:调用 `knowledge_create` 工具,生成知识→写入目录+向量化
2. **用户上传**HTTP 文件上传端点 → 写入目录+向量化
3. **预置知识**`knowledge/` 目录下的 content.md
**相关文件:** `internal/knowledge/knowledge.go`
### Agent 可用知识工具
| 工具 | 作用 |
|------|------|
| knowledge_search | 向量搜索知识库 |
| knowledge_list | 列出知识分类 |
| knowledge_create | agent 主动创建知识 |
---
## 五、数据流总览
```
用户消息
IO 输入中断 (channel.go)
eventLoop → processTextInput
├─ 1. 追加上下文 (RelevanceContext.Append)
│ └─ 计算向量,缓存
├─ 2. 构建 prompt
│ ├─ 人格设定 (personal.md)
│ ├─ 图记忆索引 (Indexer.BuildContext → 向量搜索实体名 → Recall → 摘要注入)
│ ├─ 文档记忆摘要 (DocStore.Query → 注入前3条摘要)
│ ├─ 工作记忆上下文 (RelevanceContext.Format)
│ ├─ 技能注入 (skills)
│ └─ 工具说明 (indexer + doc + knowledge tools)
├─ 3. 工具循环 (process)
│ ├─ 调用 LLM (DeepSeek v4 flash)
│ ├─ 解析 tool_calls
│ ├─ 执行工具 (memory_*/knowledge_*/doc_*/设备工具)
│ └─ 返回结果,循环直到无 tool_calls
├─ 4. 追加响应到上下文
├─ 5. 相关性裁剪 (RelevanceContext.Prune)
│ └─ 最不相关的 → 文档记忆归档
├─ 6. IO 输出响应
└─ 7. 记忆候选事件
└─ → TextMemory (JSONL 持久化)
└─ → Distiller → GraphMemory (三元组萃取)
心跳线程 (每 30min):
├─ distillContext: 安全裁剪兜底
├─ syncGraphToDocs: 图→文档索引同步
├─ reorgGraph:
│ ├─ Indexer.Sync(): 实体名→向量索引
│ ├─ DocStore.Reindex(): 文档向量重建
│ ├─ 冷文档→图归化
│ └─ 同义实体合并
└─ (后续) GraphDB 向量索引生成/重整
```
---
## 六、关键文件
```
cmd/homed/main.go — 入口:组装所有子系统
internal/agent/core/agent.go — Agent 核心:事件循环、工具循环、心跳
internal/agent/core/context.go — RelevanceContext基于 TF-IDF 的上下文管理
internal/agent/personal.go — Personalitypersonal.md 加载
internal/agent/api/provider.go — LLM ProviderDeepSeek API 封装
internal/agent/io/channel.go — IO 抽象层:中断输入、设备注册
internal/api/handler.go — HTTP APIREST + OpenAI 兼容端点
internal/knowledge/knowledge.go — 知识系统:目录扫描、向量索引、搜索
internal/memory/graph.go — 图数据库SQLite 三元组 CRUD
internal/memory/indexer.go — 图索引器:向量搜索实体名、摘要注入
internal/memory/vector/store.go — 向量存储TF-IDF + 倒排索引 + 余弦相似度
internal/memory/document/doc.go — 文档记忆:存储、查询、冷文档检测
internal/memory/text/text.go — 文本记忆JSONL 原始日志持久化
internal/memory/pipeline/ — 蒸馏器:原始日志→图记忆
internal/tracker/tracker.go — Change Trackeroverlayfs 文件变更追踪
internal/plugin/plugin.go — 插件平台SKILL.md 加载
internal/supervisor/daemon.go — 守护进程:健康检查、自动 rollback
internal/lua/vm.go — Lua 适配器 VM
config/config.go — 配置加载
pkg/types/ — 类型定义
```
---
## 七、配置示例
```yaml
daemon:
listen_addr: ":8080"
data_dir: "/var/lib/homeagent"
heartbeat_interval: 15s
check_interval: 30s
llm:
provider: "openai"
model: "deepseek-v4-flash"
base_url: "https://api.deepseek.com"
api_key: "${DEEPSEEK_API_KEY}"
temperature: 0.7
max_tokens: 4096
```
---
## 八、对比原设计
| 维度 | 旧设计 | 新设计 |
|------|--------|--------|
| 上下文裁剪 | 固定 20 条 FIFO | TF-IDF 相关性排序,保留最相关 |
| 上下文→文档 | 蒸馏器每周期 flush | 每次响应后按相关性裁剪归档 |
| 图记忆注入 | 关键词 LIKE 查询 | 向量搜索实体名,只注索引 |
| 文档→图 | 无 | 72h 冷文档自动归化 |
| 图重整 | 无 | 心跳:向量同步+同义合并 |
| 知识系统 | 无 | `knowledge/` 目录+向量索引+工具 |
| 人格 | 无 | `personal.md` 固定注入 |
| 向量引擎 | 无 | 自研 TF-IDF + 倒排索引 |

47
Makefile Normal file
View File

@ -0,0 +1,47 @@
.PHONY: all build clean install test run
BINARY=homed
GO=go
GOCACHE=/tmp/gocache
GOPATH=$(shell go env GOPATH)
BUILD_DIR=build
all: build
build:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=1 $(GO) build -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
@echo "Built: $(BUILD_DIR)/$(BINARY)"
build-static:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=1 $(GO) build -tags netgo -ldflags '-extldflags "-static"' -o $(BUILD_DIR)/$(BINARY)-static ./cmd/homed/
@echo "Built (static): $(BUILD_DIR)/$(BINARY)-static"
build-linux-arm64:
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=arm64 CGO_ENABLED=1 $(GO) build -o $(BUILD_DIR)/$(BINARY)-arm64 ./cmd/homed/
@echo "Built (arm64): $(BUILD_DIR)/$(BINARY)-arm64"
clean:
rm -rf $(BUILD_DIR) $(BINARY)
install: build
cp $(BUILD_DIR)/$(BINARY) /usr/local/bin/$(BINARY)
mkdir -p /etc/homeagent /var/lib/homeagent
cp config/config.yaml /etc/homeagent/
cp deploy/homeagent.service /etc/systemd/system/
systemctl daemon-reload
@echo "Installed. Run: systemctl enable --now homeagent"
test:
$(GO) test ./...
run: build
./$(BUILD_DIR)/$(BINARY) -data /tmp/homeagent
fmt:
$(GO) fmt ./...
lint:
$(GO) vet ./...

303
cmd/homed/main.go Normal file
View File

@ -0,0 +1,303 @@
package main
import (
"flag"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"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"
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
"gitcode.com/JianFeeeee/HomeAgent/internal/api"
"gitcode.com/JianFeeeee/HomeAgent/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/onebot"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
)
func main() {
configPath := flag.String("config", config.DefaultConfigPath, "path to config file")
dataDir := flag.String("data", "/var/lib/homeagent", "data directory")
flag.Parse()
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
log.Printf("[homed] starting HomeAgent v0.1.0")
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("load config: %v", err)
}
cfg.Daemon.DataDir = *dataDir
agentWorkDir := filepath.Join(cfg.Daemon.DataDir, "agentfs")
dirs := []string{
cfg.Daemon.DataDir,
filepath.Join(cfg.Daemon.DataDir, "snapshots"),
filepath.Join(cfg.Daemon.DataDir, "skills"),
filepath.Join(cfg.Daemon.DataDir, "plugins"),
filepath.Join(cfg.Daemon.DataDir, "changesets"),
filepath.Join(cfg.Daemon.DataDir, "memory"),
filepath.Join(cfg.Daemon.DataDir, "memory", "raw"),
filepath.Join(cfg.Daemon.DataDir, "adapters"),
agentWorkDir,
}
for _, d := range dirs {
if err := os.MkdirAll(d, 0755); err != nil {
log.Fatalf("create dir %s: %v", d, err)
}
}
// === Graph Memory ===
memDB, err := memory.NewGraphDB(filepath.Join(cfg.Daemon.DataDir, "memory", "graph.db"))
if err != nil {
log.Printf("[homed] warning: memory init failed: %v", err)
memDB = nil
} else {
log.Printf("[homed] graph memory initialized")
}
if memDB != nil {
defer memDB.Close()
}
memIdx := memory.NewIndexer(memDB)
// === Memory Pipeline ===
distiller := pipeline.NewDistiller(memDB, cfg.Daemon.DataDir, pipeline.DistillerConfig{
Interval: 10 * time.Minute,
RetentionDays: 7,
BatchSize: 50,
})
if memDB != nil {
distiller.Start()
defer distiller.Stop()
}
// === Skills ===
skMgr := skill.NewManager(filepath.Join(cfg.Daemon.DataDir, "skills"))
if err := skMgr.Init(); err != nil {
log.Printf("[homed] warning: skill init failed: %v", err)
}
// === Plugin Registry (OpenClaw SKILL.md compatible) ===
pluginReg := plugin.NewRegistry()
// 注册内置原生插件工厂
pluginReg.RegisterNative("qq", func(name string, config map[string]interface{}, iom *agentIO.IOManager) (agentIO.Device, error) {
wsURL, _ := config["entry"].(string)
if wsURL == "" {
wsURL = "ws://127.0.0.1:6700"
}
accessToken, _ := config["access_token"].(string)
return onebot.NewDevice(name, wsURL, accessToken, iom), nil
})
// === Supervisor ===
sup := supervisor.New(cfg)
if err := sup.Start(); err != nil {
log.Fatalf("start supervisor: %v", err)
}
// === Lua VM ===
luaVM := luapkg.NewVM(filepath.Join(cfg.Daemon.DataDir, "adapters"))
if err := luaVM.Start(); err != nil {
log.Printf("[homed] warning: lua vm init failed: %v", err)
} else {
defer luaVM.Stop()
}
// === IO Abstraction Layer (唯一输入路径) ===
iom := agentIO.NewIOManager()
iom.RegisterDevice(agentIO.NewMicrophone("mic", 16000, iom))
iom.RegisterDevice(agentIO.NewSpeaker("speaker", iom))
// mic 输入 → speaker 输出(语音 I/O 配对)
iom.RegisterOutputRoute("mic", "speaker")
iom.RegisterOutputRoute("voice", "speaker")
iom.RegisterDevice(agentIO.NewCamera("camera", iom))
iom.RegisterDevice(agentIO.NewRobotArm("arm", iom))
iom.RegisterDevice(agentIO.NewGPIODevice("gpio", []int{2, 3, 4, 17}, iom))
iom.StartAll()
defer iom.StopAll()
// 插件绑定 IO 管理器 → 插件自动注册为 IO 设备
pluginReg.SetIOManager(iom)
// 首次加载插件
if result, err := pluginReg.Reload(filepath.Join(cfg.Daemon.DataDir, "plugins")); err != nil {
log.Printf("[homed] warning: load plugins: %v", err)
} else {
log.Printf("[homed] %s", result)
}
// === Text Memory (三层记忆: Context → Text → Graph) ===
textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
if err := textMem.Start(); err != nil {
log.Printf("[homed] warning: text memory start: %v", err)
} else {
defer textMem.Stop()
log.Printf("[homed] text memory active at %s", filepath.Join(cfg.Daemon.DataDir, "memory", "text"))
}
// Wire IO output events → TextMemory + distiller → GraphMemory
if distiller != nil {
go func() {
for evt := range iom.OutputChan() {
if evt.Target == "memory" && evt.Type == "memory_candidate" {
source, _ := evt.Payload["source"].(string)
input, _ := evt.Payload["input"].(string)
response, _ := evt.Payload["response"].(string)
toolsUsed, _ := evt.Payload["tools_used"].([]string)
agentID, _ := evt.Payload["agent_id"].(string)
// 1. 写文本记忆(持久化原始日志)
if input != "" && textMem != nil {
te := text.Event{
Timestamp: time.Now().Unix(),
Source: source,
Input: input,
Response: response,
ToolsUsed: toolsUsed,
AgentID: agentID,
}
if err := textMem.Append(te); err != nil {
log.Printf("[homed] text memory append: %v", err)
}
}
// 2. 喂蒸馏器(生成三元组 → 图记忆)
if input != "" {
distiller.Append("agent", "user", input)
}
if response != "" {
distiller.Append("agent", "assistant", response)
}
}
}
}()
}
// === Change Tracker (overlayfs-based, 追踪所有修改) ===
trk := tracker.NewTracker(cfg.Daemon.DataDir, agentWorkDir)
if err := trk.Init(); err != nil {
log.Printf("[homed] warning: tracker init: %v", err)
} else {
if err := trk.Start(); err != nil {
log.Printf("[homed] warning: tracker mount overlay: %v (non-fatal: no overlayfs support?)", err)
} else {
log.Printf("[homed] change tracker active at %s", trk.MergeDir())
}
}
// === API Provider (唯一输出路径) ===
apiKey := cfg.LLM.APIKey
if apiKey == "" {
apiKey = os.Getenv("DEEPSEEK_API_KEY")
}
provider := agentAPI.NewOpenAIProvider(agentAPI.BaseConfig{
Model: cfg.LLM.Model,
BaseURL: cfg.LLM.BaseURL,
APIKey: apiKey,
Temperature: cfg.LLM.Temperature,
MaxTokens: cfg.LLM.MaxTokens,
})
// === Personality (固定人格内核) ===
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
personality, err := agentPkg.LoadPersonality(personalPath)
if err != nil {
log.Printf("[homed] warning: load personality: %v", err)
}
if personality != nil && personality.Content != "" {
log.Printf("[homed] personality loaded (%d bytes)", len(personality.Content))
}
// === Document Memory (第二层记忆:上下文→文档) ===
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"))
if err := docStore.Start(); err != nil {
log.Printf("[homed] warning: document store: %v", err)
}
// === 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()))
}
// === Single Agent Core ===
agent := agentCore.New(agentCore.AgentConfig{
ID: "main",
SystemPrompt: "你是一个智能家庭管家,持续运行。你有以下工具:\n1. memory_recall — 查询图记忆\n2. memory_commit — 写入图记忆\n3. memory_introspect — 查看记忆统计\n4. knowledge_search — 搜索知识库\n5. doc_query — 查询文档记忆\n6. doc_commit — 写入文档记忆\n\n当用户问及个人信息或历史时调用 memory_recall 工具来查询。当用户告诉了你新的个人信息时,调用 memory_commit 来记住。需要查询知识时使用 knowledge_search。",
Provider: provider,
IO: iom,
Memory: memDB,
Indexer: memIdx,
Skills: skMgr,
Tracker: trk,
MaxToolTurns: 10,
DocStore: docStore,
Knowledge: ks,
Personality: personality,
})
agent.Start()
defer agent.Stop()
// Wire supervisor with tracker + agent registration (after both exist)
sup.SetTracker(trk)
sup.RegisterAgent("main")
log.Printf("[homed] main agent started, model=%s base=%s", cfg.LLM.Model, cfg.LLM.BaseURL)
// === HTTP API ===
handler := api.NewHandler(sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
server := &http.Server{
Addr: cfg.Daemon.ListenAddr,
Handler: mux,
}
go func() {
log.Printf("[homed] HTTP API listening on %s", cfg.Daemon.ListenAddr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err)
}
}()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Printf("[homed] shutting down...")
if trk != nil {
trk.Stop()
}
sup.Shutdown()
server.Close()
log.Printf("[homed] stopped")
}
func countIOPlugins(r *plugin.Registry) int {
n := 0
for _, p := range r.List() {
if p.IOConfig() != nil {
n++
}
}
return n
}

91
config/config.go Normal file
View File

@ -0,0 +1,91 @@
package config
import (
"fmt"
"os"
"path/filepath"
"time"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
"gopkg.in/yaml.v3"
)
const DefaultConfigPath = "/etc/homeagent/config.yaml"
func DefaultConfig() types.Config {
return types.Config{
Daemon: types.DaemonConfig{
ListenAddr: ":8080",
DataDir: "/var/lib/homeagent",
HeartbeatInterval: 15 * time.Second,
CheckInterval: 30 * time.Second,
LogLevel: "info",
},
LLM: types.LLMConfig{
Provider: "openai",
Model: "deepseek-v4-flash",
BaseURL: "https://api.deepseek.com",
Temperature: 0.7,
MaxTokens: 4096,
},
Defaults: types.AgentConfig{
Image: "homeagent/agent-base:latest",
LLMEndpoints: []string{"https://api.openai.com/v1"},
SnapshotPolicy: types.SnapshotPolicy{
Interval: 10 * time.Minute,
MaxSnapshots: 20,
PreAction: true,
PostAction: false,
},
RollbackPolicy: types.RollbackPolicy{
MaxRetries: 3,
HealthThreshold: types.HealthDown,
CooldownPeriod: 30 * time.Second,
AutoRollback: true,
},
ResourceLimit: types.ResourceLimit{
CPU: "2",
Memory: "2g",
Disk: "10g",
Network: true,
},
OpenClawEnabled: true,
},
}
}
func Load(path string) (*types.Config, error) {
cfg := DefaultConfig()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &cfg, nil
}
return nil, fmt.Errorf("read config: %w", err)
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
return &cfg, nil
}
func Save(path string, cfg *types.Config) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("write config: %w", err)
}
return nil
}

34
config/config.yaml Normal file
View File

@ -0,0 +1,34 @@
daemon:
listen_addr: ":8080"
data_dir: "/var/lib/homeagent"
heartbeat_interval: 15s
check_interval: 30s
log_level: "info"
defaults:
image: "homeagent/agent-base:latest"
llm_endpoints:
- "https://api.openai.com/v1"
snapshot_policy:
interval: 10m
max_snapshots: 20
pre_action: true
post_action: false
rollback_policy:
max_retries: 3
health_threshold: 3
cooldown_period: 30s
auto_rollback: true
resource_limit:
cpu: "2"
memory: "2g"
disk: "10g"
network: true
openclaw_enabled: true
agents:
- id: "default"
name: "Default Agent"
image: "homeagent/agent-base:latest"
llm_endpoints:
- "https://api.openai.com/v1"

33
deploy/homeagent.service Normal file
View File

@ -0,0 +1,33 @@
[Unit]
Description=HomeAgent - Personal AI Home Assistant
Documentation=https://github.com/trueagent/HomeAgent
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/homed -config /etc/homeagent/config.yaml -data /var/lib/homeagent
Restart=always
RestartSec=10
StartLimitBurst=3
StartLimitInterval=60s
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=false
DevicePolicy=closed
# Audio devices
DeviceAllow=/dev/snd rw
DeviceAllow=/dev/dsp rw
# Resource limits
LimitNOFILE=65536
LimitNPROC=256
MemoryMax=2G
CPUQuota=100%
[Install]
WantedBy=multi-user.target

495
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,495 @@
# HomeAgent 架构设计文档
> 基于 NextAgent 认知解耦架构,结合图记忆与工具调用系统的单二进制 AI 家庭助手。
> 参考设计NextAgent — 严格的边界划分 + 工具调用TrulyMEM — 自主图记忆系统
---
## 一、核心设计原则
### 1. 认知解耦架构(源自 NextAgent
系统分为三个严格边界层:
```
┌──────────────────────────────────────────────────────────────────┐
│ 外层IO 抽象层(唯一输入路径) │
│ IOManager ── Microphone / Camera / GPIO / HTTP / Sensors │
│ 所有外部输入 → InputEvent → inputCh │
└──────────────────────────┬───────────────────────────────────────┘
┌──────────────────────────▼───────────────────────────────────────┐
│ 中层Agent 操作层(核心编排器) │
│ Agent Core ── 事件循环 consume inputCh │
│ ├─ 构建上下文(记忆索引 + 技能注入 + ToolDef
│ ├─ Provider.Chat() → CompletionResponse │
│ └─ 通过 IO 层输出 text + memory_candidate 事件 │
└──────────────────────────┬───────────────────────────────────────┘
┌──────────────────────────▼───────────────────────────────────────┐
│ 内层API 抽象层(唯一输出路径) │
│ Provider ── OpenAI / Ollama / LuaAdaptedProvider │
│ 所有 LLM 调用通过 Provider.Chat() / ChatStream() │
└──────────────────────────────────────────────────────────────────┘
```
**三条核心规则:**
1. 所有外部输入 → 必须通过 `IOManager.InjectInput()` / `InjectText()` 注入
2. 所有 LLM 调用 → 必须通过 `Provider.Chat()` / `ChatStream()` 发出
3. Agent Core 不直接操作记忆系统,只发射 `memory_candidate` 事件,由 Memory Pipeline 异步消费
### 2. 自主记忆系统(源自 TrulyMEM
```
User Input ──→ Agent Core ──→ Provider ──→ LLM Response
IO 层发射 memory_candidate 事件
Memory Pipeline Distiller 消费
周期蒸馏 → Graph Memory (SQLite)
删除原始会话文件
```
**关键约束:**
- Agent 上下文中只注入**记忆索引 + 摘要****永远不**注入原始文本
- Agent 必须通过 `memory_recall` tool call **主动查询** 获取完整记忆细节
- 记忆蒸馏完全异步、自主运行,不阻塞主流程
---
## 二、系统层次详解
### 2.1 IO 抽象层(`internal/agent/io/channel.go`
**唯一输入路径。** 所有外部输入必须通过此层进入系统。
```
Device(Microphone) ─┐
Device(Camera) ─┤
Device(GPIO) ─┤──→ IOManager.InjectInput() → InputEvent → inputCh → Agent Core
Device(RobotArm) ─┤
HTTP API ─┘
```
**核心类型:**
| 类型 | 说明 |
|------|------|
| `InputEvent` | Source + Type + Payload — 所有外部输入的标准化格式 |
| `OutputEvent` | Target + Type + Payload — 所有输出的标准化格式 |
| `Device` | 接口Name() / Type() / Tools() / Execute() |
| `DeviceType` | Input / Output / IO |
| `ToolDef` | Name + Description + Parameters — 与 LLM Function Calling 同构 |
**内置设备(目前为桩实现,待真正硬件接入):**
| 设备 | 方向 | ToolDef 暴露 |
|------|------|-------------|
| Microphone | Input | `{name}_capture` — 录音 |
| Speaker | Output | `{name}_speak` — 语音播放 |
| Camera | Input | `{name}_capture` 拍照 + `{name}_stream` 视频流 |
| RobotArm | IO | `{name}_move` 移动 + `{name}_grip` 夹爪 |
| GPIO | IO | `{name}_gpio_write` + `{name}_gpio_read` |
**Device 与 Tool 同构原则:**
- 所有 Device 的 `Tools()` 返回 `[]ToolDef`,格式与 LLM Function Calling 完全一致
- Agent Core 自动收集所有 Device 的 ToolDef 合并到请求的 `tools` 字段
- Agent 通过 Function Calling 调用设备 capability
### 2.2 API 抽象层(`internal/agent/api/provider.go`
**唯一输出路径。** 所有 LLM 请求通过此层发出。
```
Agent Core ──→ ProviderManager ──→ Provider.Chat()
┌─────────┼─────────┐
▼ ▼ ▼
OpenAI Ollama LuaAdaptedProvider
┌───────┴───────┐
▼ ▼
Lua Adapter Base Provider
(transform) (OpenAI/Ollama)
```
**Provider 接口:**
```go
type Provider interface {
Name() string
Chat(ctx, req) (*CompletionResponse, error)
ChatStream(ctx, req) (<-chan StreamChunk, error)
}
```
| 实现 | 说明 |
|------|------|
| `OpenAIProvider` | 标准 OpenAI API 格式,支持 /chat/completions |
| `OllamaProvider` | Ollama /api/chat 格式,本地部署 |
| `LuaAdaptedProvider` | 通过 Lua 脚本转换请求/响应的适配 wrapper |
**Lua 适配器机制:**
- 适配器文件位于 `{dataDir}/adapters/*.lua`
- 每个适配器返回 Lua table 包含 `name` + `transform_request` + `transform_response`
- 首次运行时自动从 embed.FS 复制捆绑适配器openai / deepseek / ollama / custom
- 支持热重载(`POST /api/v1/adapters`
- 用于兼容不同 API 供应商的请求/响应格式差异
### 2.3 Agent 操作层(`internal/agent/core/agent.go`
**纯编排器,不涉及张量运算。**
```
eventLoop()
▼ select on inputCh
handleInput(evt)
process(input)
├─ indexer.BuildContext(input) → 仅摘要+索引
├─ buildSystemPrompt() → 拼接 system prompt
├─ buildToolDefs() → 收集 IO 工具 + 记忆工具
├─ provider.Chat(req) → LLM 调用
├─ 记忆候选事件 → IO 层 EmitOutput("memory", "memory_candidate", ...)
└─ 输出 → IO 层 EmitOutput(source, "text", ...)
```
**关键设计决策:**
- Agent 不直接持有 `memory.GraphDB` 引用 — 只通过 `Indexer` 构建上下文
- Agent 不直接调用 `memory.Commit()` — 只发射事件让 Pipeline 异步处理
- `Indexer.BuildContext()` 只返回内存索引摘要,不返回原始数据
- `buildSystemPrompt()` 中注入 `memory_recall` / `memory_commit` / `memory_introspect` 工具说明
- **所有输出走 IO 层** — 文本输出和记忆事件都通过 EmitOutput
### 2.4 图记忆系统(`internal/memory/graph.go`
SQLite 三元组存储,支持实体-关系-实体的图遍历。
**数据库 Schema**
```sql
entities(id PK, name UNIQUE, type, mention_count, created_at, updated_at)
relations(id PK, source_id FKentities, target_id FKentities,
relation_type, confidence, status, session_id, turn_id,
created_at, updated_at, date_bucket)
```
**核心操作:**
| 操作 | 说明 |
|------|------|
| `Commit(triples, sessionID, turnID)` | 写入三元组 → 自动 upsert 实体 + 插入关系 |
| `Recall(keywords, seedEntities, depth, session)` | 关键词搜索 → 图遍历 → 返回实体+关系 |
| `Purge(criteria, mode)` | 软/硬删除匹配的关系 |
| `Introspect()` | 统计信息:实体数、关系数、热点实体 |
| `Archive(days)` | 归档超过指定天数的关系 |
**Context Injection 机制(`internal/memory/indexer.go`**
- `BuildContext(userInput)` → 关键词提取 → `GraphDB.Recall()` → 构建摘要
- `FormatContext(context)` → 输出格式如:
`【记忆索引】关联 N 个记忆实体高频A、B、C 索引: A, B, C | 需更多细节请用 memory_recall 查询`
- `BuildToolPrompt()` → 生成 `memory_recall/commit/introspect/purge` 工具的 prompt 说明
- `GetToolDefinitions()` → 返回 LLM Function Calling 格式的工具定义
### 2.5 记忆管道(`internal/memory/pipeline/pipeline.go`
自主异步蒸馏管线。
```
Agent Core → EmitOutput("memory", "memory_candidate", {input, response})
onMemory(input, response) 回调
Append() → records[] 内存缓冲区
▼ 每 10 分钟触发
distillLoop()
extractKeyTriples() → GraphDB.Commit()
cleanupRawFiles() 删除超期原始文件
```
**配置:**
- `Interval: 10m` — 每 10 分钟蒸馏一次
- `RetentionDays: 7` — 原始记录保留 7 天
- `BatchSize: 50` — 每批处理 50 条
### 2.6 技能系统(`internal/skill/manager.go`
兼容 OpenClaw 格式的技能管理。
**技能格式:**
- `{skillsDir}/{name}/SKILL.md` — Markdown 描述文件
- `{skillsDir}/{name}/skill.json` — 可选的元数据文件
**注入机制:**
- `GetInjectedPrompt()` → 收集所有已启用的技能内容注入到 system prompt
- 支持安装/卸载/启用/禁用
### 2.7 Supervisor 守护进程(`internal/supervisor/daemon.go`
```go
type Daemon struct {
cfg *types.Config
cm *container.Manager // Docker 容器管理
nm *network.Monitor // 网络监控
sm *snapshot.Manager // 快照管理
agents map[AgentID]*agentInstance
}
```
**职责:**
- Agent 生命周期管理launch / restart / shutdown
- 健康检查循环(心跳间隔 15s
- 自动快照循环(快照间隔 10m
- 故障恢复:失败 MaxRetries(3) 次后自动回滚到最近快照
- 网络监控:检查 LLM API 可达性,影响 Agent 健康状态
### 2.8 快照与回滚(`internal/snapshot/manager.go`
基于 Docker commit/save/load 的版本管理。
**快照流程:**
`Docker Commit(container → image) → SaveImage(image → .tar) → 记录快照元数据`
**回滚流程:**
`Stop(container) → Remove(container) → LoadImage(.tar) → 创建新容器 → Start`
**策略:**
- 定时快照(每 10m
- 操作前快照PreAction — 可选)
- 限制最大保留(默认 20 个)
- 溢出时自动删除最旧的
### 2.9 网络监控(`internal/network/monitor.go`
异步定时检查 LLM API 端点可达性。
**监控方式:**
- HTTP HEAD 请求到配置的 endpoints
- 并发检查goroutine per endpoint
- DNS 解析检查fallback: google.com → baidu.com
- 结果聚合:`LLMAPIReachable` + `DNSResolving` + 平均延迟
**影响:**
- 网络不可达 → Agent 状态变为 `HealthDegraded`
- 持续不可达 → 触发回滚策略
### 2.10 Lua 虚拟机(`internal/lua/vm.go`
纯 Go 的 gopher-lua 5.1 VM用于 API 格式适配器。
**功能:**
- 加载 `{adapterDir}/*.lua` 适配器脚本
- `CallTransform(name, input)` — 调用 adapter 的 transform_request
- `CallResponseTransform(name, raw)` — 调用 adapter 的 transform_response
- `ReloadAll()` — 热重载所有适配器
- 内置 mock 函数:`log()`, `json_encode()`, `http_get()`, `http_post()`
### 2.11 Embedder`internal/embed/embedder.go`
向量嵌入接口,支持文本相似度计算。
| 实现 | 说明 |
|------|------|
| `OllamaEmbedder` | 通过 Ollama API 获取嵌入向量(默认: nomic-embed-text, 768d |
| `HashEmbedder` | 基于字符哈希的本地嵌入(无需外部依赖),用于备选方案 |
**可用性:** Embedder 已定义但尚未集成到记忆系统中。
### 2.12 Tokenizer`internal/tokenizer/jieba.go`
中文分词工具,基于 gojieba。全局单例线程安全。
**功能:**
- `ExtractKeywords(text, topK)` — 提取关键词TF-IDF 加权)
- `Cut(text)` — 分词
- `Tag(text)` — 词性标注
---
## 三、数据流全景
### 3.1 正常交互流程
```
外部输入HTTP POST / voice / GPIO 事件)
IOManager.InjectInput()
→ InputEvent{Source, Type, Payload}
→ 推入 inputCh
Agent Core eventLoop()
1. consume InputEvent
2. Indexer.BuildContext(input) → 记忆摘要(仅索引+摘要)
3. buildSystemPrompt() → 拼接 system + 记忆 + 技能 + 工具
4. buildToolDefs() → IO 工具 + 记忆工具
5. Provider.Chat(req) → LLM 响应
6. EmitOutput(target, "text", response)
7. EmitOutput("memory", "memory_candidate", {input, response})
┌─────┴─────┐
▼ ▼
IO 输出 Memory Pipeline异步
│ │
▼ ▼
HTTP Append() → 周期蒸馏
Response → GraphDB.Commit()
Speaker → 清理原始文件
```
### 3.2 记忆查询流程
```
Agent 推理中决定调用 memory_recall
LLM 返回 tool_call: {name: "memory_recall", args: {query_intent: "..."}}
Agent Core 解析 tool_call → 调用 GraphDB.Recall(keywords)
返回实体+关系数据 → 注入后续 LLM 请求上下文
```
---
## 四、配置系统
配置文件: `/etc/homeagent/config.yaml`YAML。若文件不存在则使用默认配置。
```yaml
daemon:
listen_addr: ":8080"
data_dir: "/var/lib/homeagent"
heartbeat_interval: 15s
check_interval: 30s
log_level: "info"
defaults:
image: "homeagent/agent-base:latest"
llm_endpoints:
- "https://api.openai.com/v1"
snapshot_policy:
interval: 10m
max_snapshots: 20
pre_action: true
post_action: false
rollback_policy:
max_retries: 3
health_threshold: 3
cooldown_period: 30s
auto_rollback: true
openclaw_enabled: true
agents: []
```
---
## 五、HTTP API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/status` | 系统状态 |
| GET | `/api/v1/agents` | Agent 列表 |
| POST | `/api/v1/agents` | 创建 Agent |
| GET | `/api/v1/agents/{id}` | Agent 详情 |
| POST | `/api/v1/agents/{id}/start/stop/restart` | 操作 |
| GET | `/api/v1/agents/{id}/snapshots` | 快照列表 |
| POST | `/api/v1/agents/{id}/rollback/{snap}` | 回滚 |
| GET | `/api/v1/memory?q=关键词` | 记忆检索 |
| POST | `/api/v1/memory` | 写入三元组 |
| DELETE | `/api/v1/memory` | 删除记忆 |
| GET | `/api/v1/memory/context?q=...` | 获取上下文注入 |
| GET | `/api/v1/memory/tools` | 记忆工具定义 |
| GET | `/api/v1/skills` | 技能列表 |
| POST | `/api/v1/skills` | 安装技能 |
| DELETE | `/api/v1/skills?name=...` | 卸载技能 |
| GET | `/api/v1/adapters` | 适配器列表 |
| POST | `/api/v1/adapters` | 安装适配器 |
| DELETE | `/api/v1/adapters/{name}` | 删除适配器 |
| GET | `/api/v1/network` | 网络状态 |
| GET | `/api/v1/config` | 配置查看 |
| PUT | `/api/v1/config` | 配置更新 |
| GET | `/` | WebUI 仪表盘 |
---
## 六、数据目录结构
```
{dataDir}/
├── config.yaml # 系统配置
├── memory/
│ ├── graph.db # SQLite 图记忆数据库
│ └── raw/ # 原始会话记录文件
│ └── raw_*.jsonl
├── skills/ # 安装的技能
│ └── {name}/
│ ├── SKILL.md
│ └── skill.json
├── adapters/ # Lua API 格式适配器
│ ├── openai.lua
│ ├── deepseek.lua
│ ├── ollama.lua
│ └── custom.lua
└── snapshots/ # Docker 快照
└── {agent_id}/
└── snap_*.tar
```
---
## 七、构建与部署
**构建:**
```bash
make build # 编译主二进制(~15MB
make install # 编译 + 安装到 /usr/local/bin
make test # 运行测试
make fmt # gofmt
make lint # golangci-lint
```
**部署:**
- 单二进制:`homed -config /etc/homeagent/config.yaml -data /var/lib/homeagent`
- systemd`deploy/homeagent.service`
- 依赖Go 1.19+CGo enabled用于 go-sqlite3Docker 可选(快照/回滚)
---
## 八、设计限制与后续计划
### 已知限制
1. Agent Core 目前是单轮 tool_call 处理,尚未实现完整的多轮 tool 执行循环
2. Embedder 已定义但未接入图记忆 — 缺失向量相似度排序和 ANN 索引
3. 记忆蒸馏器使用简单启发式三元组提取,生产环境应调用 LLM 进行结构化抽取
4. Linux namespace 隔离overlayfs尚未实现作为 Docker 替代方案
5. Speaker / Microphone / Camera 均为桩实现,无实际 ALSA/PulseAudio/Video4Linux 驱动
6. 无真正的唤醒词检测
7. LuaAdaptedProvider 失败时无重试/降级逻辑
8. 无持久化消息历史管理(目前仅内存中保留最近 50 条)
### 路线图
- **近期**:完成 tool_call 执行循环 → 设备真正驱动 → 向量记忆增强
- **中期**:单二进制 namespace 隔离 → 唤醒词检测 → CI/CD 流水线
- **远期**:多 Agent 协作 → 分布式部署 → 联邦记忆

14
go.mod Normal file
View File

@ -0,0 +1,14 @@
module gitcode.com/JianFeeeee/HomeAgent
go 1.19
require (
github.com/mattn/go-sqlite3 v1.14.22
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/gorilla/websocket v1.5.3 // indirect
github.com/yanyiwu/gojieba v1.4.7 // indirect
github.com/yuin/gopher-lua v1.1.2 // indirect
)

12
go.sum Normal file
View File

@ -0,0 +1,12 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/yanyiwu/gojieba v1.4.7 h1:2YkXELcYLTE0SJetq6xv4MjpEikWga6VpFn4jIFFQ/k=
github.com/yanyiwu/gojieba v1.4.7/go.mod h1:JUq4DddFVGdHXJHxxepxRmhrKlDpaBxR8O28v6fKYLY=
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

BIN
homed Executable file

Binary file not shown.

View File

@ -0,0 +1,509 @@
package api
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
)
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls []ToolCall `json:"-"`
}
func (m Message) MarshalJSON() ([]byte, error) {
raw := map[string]interface{}{
"role": m.Role,
"content": m.Content,
}
if m.ReasoningContent != "" {
raw["reasoning_content"] = m.ReasoningContent
}
if m.ToolCallID != "" {
raw["tool_call_id"] = m.ToolCallID
}
if len(m.ToolCalls) > 0 {
apiTCs := make([]apiToolCall, len(m.ToolCalls))
for i, tc := range m.ToolCalls {
argsBytes, _ := json.Marshal(tc.Arguments)
apiTCs[i] = apiToolCall{
ID: tc.ID,
Type: "function",
Function: apiFunction{
Name: tc.Name,
Arguments: string(argsBytes),
},
}
}
raw["tool_calls"] = apiTCs
}
return json.Marshal(raw)
}
type CompletionRequest struct {
Model string `json:"model,omitempty"`
Messages []Message `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []interface{} `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
ExtraBody map[string]interface{} `json:"-"`
}
func (r *CompletionRequest) MarshalJSON() ([]byte, error) {
type Alias CompletionRequest
data, err := json.Marshal((*Alias)(r))
if err != nil {
return nil, err
}
if len(r.ExtraBody) == 0 {
return data, nil
}
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
for k, v := range r.ExtraBody {
raw[k] = v
}
return json.Marshal(raw)
}
type CompletionResponse struct {
Content string `json:"content"`
FinishReason string `json:"finish_reason,omitempty"`
TokenUsage TokenUsage `json:"token_usage,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
type TokenUsage struct {
Prompt int `json:"prompt"`
Completion int `json:"completion"`
Total int `json:"total"`
}
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
type apiToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function apiFunction `json:"function"`
}
type apiFunction struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type StreamChunk struct {
Content string `json:"content"`
Done bool `json:"done"`
ToolCall *ToolCall `json:"tool_call,omitempty"`
}
type Provider interface {
Name() string
Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error)
ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error)
}
type BaseConfig struct {
Model string `json:"model"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
}
type OpenAIProvider struct {
cfg BaseConfig
client *http.Client
}
func NewOpenAIProvider(cfg BaseConfig) *OpenAIProvider {
if cfg.BaseURL == "" {
cfg.BaseURL = "https://api.openai.com/v1"
}
if cfg.Temperature == 0 {
cfg.Temperature = 0.7
}
if cfg.MaxTokens == 0 {
cfg.MaxTokens = 4096
}
return &OpenAIProvider{
cfg: cfg,
client: &http.Client{Timeout: 60 * time.Second},
}
}
func (p *OpenAIProvider) Name() string { return "openai" }
func (p *OpenAIProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
if req.Model == "" {
req.Model = p.cfg.Model
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequestWithContext(ctx, "POST", p.cfg.BaseURL+"/chat/completions", strings.NewReader(string(body)))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
resp, err := p.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("api call: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("api error %d: %s", resp.StatusCode, string(respBody))
}
var rawResult struct {
Choices []struct {
Message struct {
Content *string `json:"content"`
ReasoningContent *string `json:"reasoning_content"`
ToolCalls []rawToolCall `json:"tool_calls"`
Role string `json:"role"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := json.NewDecoder(resp.Body).Decode(&rawResult); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
if len(rawResult.Choices) == 0 {
return nil, fmt.Errorf("no choices returned")
}
ch := rawResult.Choices[0]
content := ""
if ch.Message.Content != nil {
content = *ch.Message.Content
}
var toolCalls []ToolCall
for _, tc := range ch.Message.ToolCalls {
tc := tc
args := make(map[string]interface{})
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
args["_raw"] = tc.Function.Arguments
}
toolCalls = append(toolCalls, ToolCall{
ID: tc.ID,
Type: tc.Type,
Name: tc.Function.Name,
Arguments: args,
})
}
return &CompletionResponse{
Content: content,
FinishReason: ch.FinishReason,
TokenUsage: TokenUsage{
Prompt: rawResult.Usage.PromptTokens,
Completion: rawResult.Usage.CompletionTokens,
Total: rawResult.Usage.TotalTokens,
},
ToolCalls: toolCalls,
}, nil
}
func (p *OpenAIProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
req.Stream = true
ch := make(chan StreamChunk, 64)
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequestWithContext(ctx, "POST", p.cfg.BaseURL+"/chat/completions", strings.NewReader(string(body)))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
resp, err := p.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream api: %w", err)
}
go func() {
defer resp.Body.Close()
defer close(ch)
decoder := json.NewDecoder(resp.Body)
for {
var line struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
}
if err := decoder.Decode(&line); err != nil {
break
}
if len(line.Choices) > 0 {
ch <- StreamChunk{
Content: line.Choices[0].Delta.Content,
Done: line.Choices[0].FinishReason != nil,
}
}
}
}()
return ch, nil
}
type OllamaProvider struct {
cfg BaseConfig
client *http.Client
}
func NewOllamaProvider(cfg BaseConfig) *OllamaProvider {
if cfg.BaseURL == "" {
cfg.BaseURL = "http://localhost:11434"
}
if cfg.Temperature == 0 {
cfg.Temperature = 0.7
}
if cfg.MaxTokens == 0 {
cfg.MaxTokens = 4096
}
return &OllamaProvider{
cfg: cfg,
client: &http.Client{Timeout: 120 * time.Second},
}
}
func (p *OllamaProvider) Name() string { return "ollama" }
func (p *OllamaProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
ollamaReq := map[string]interface{}{
"model": req.Model,
"messages": req.Messages,
"stream": false,
"options": map[string]interface{}{
"temperature": req.Temperature,
"num_predict": req.MaxTokens,
},
}
body, _ := json.Marshal(ollamaReq)
httpReq, _ := http.NewRequestWithContext(ctx, "POST", p.cfg.BaseURL+"/api/chat", strings.NewReader(string(body)))
httpReq.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("ollama chat: %w", err)
}
defer resp.Body.Close()
var result struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
DoneReason string `json:"done_reason"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return &CompletionResponse{
Content: result.Message.Content,
FinishReason: result.DoneReason,
}, nil
}
func (p *OllamaProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
ch := make(chan StreamChunk, 64)
go func() {
defer close(ch)
ch <- StreamChunk{Done: true}
}()
return ch, nil
}
type LuaAdaptedProvider struct {
name string
base Provider
vm *luaVM.VM
adapter string
}
func NewLuaAdaptedProvider(base Provider, vm *luaVM.VM, adapter string) *LuaAdaptedProvider {
return &LuaAdaptedProvider{
name: fmt.Sprintf("lua_%s", adapter),
base: base,
vm: vm,
adapter: adapter,
}
}
func (p *LuaAdaptedProvider) Name() string { return p.name }
func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
inputMap := map[string]interface{}{
"model": req.Model,
"messages": messagesToMap(req.Messages),
"temperature": req.Temperature,
"max_tokens": req.MaxTokens,
"stream": false,
}
transformed, err := p.vm.CallTransform(p.adapter, inputMap)
if err != nil {
return nil, fmt.Errorf("lua transform: %w", err)
}
transformedReq := &CompletionRequest{
Model: getString(transformed, "model"),
Temperature: getFloat(transformed, "temperature"),
MaxTokens: int(getFloat(transformed, "max_tokens")),
Stream: false,
}
if msgs, ok := transformed["messages"].([]interface{}); ok {
for _, m := range msgs {
if mm, ok := m.(map[string]interface{}); ok {
transformedReq.Messages = append(transformedReq.Messages, Message{
Role: getString(mm, "role"),
Content: getString(mm, "content"),
})
}
}
}
resp, err := p.base.Chat(ctx, transformedReq)
if err != nil {
return nil, err
}
return resp, nil
}
func (p *LuaAdaptedProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
return p.base.ChatStream(ctx, req)
}
type ProviderManager struct {
mu sync.RWMutex
providers map[string]Provider
default_ string
}
func NewProviderManager() *ProviderManager {
return &ProviderManager{
providers: make(map[string]Provider),
}
}
func (m *ProviderManager) Register(name string, p Provider) {
m.mu.Lock()
defer m.mu.Unlock()
m.providers[name] = p
if m.default_ == "" {
m.default_ = name
}
}
func (m *ProviderManager) SetDefault(name string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.providers[name]; !ok {
return fmt.Errorf("provider %s not found", name)
}
m.default_ = name
return nil
}
func (m *ProviderManager) Get(name string) Provider {
m.mu.RLock()
defer m.mu.RUnlock()
if name == "" {
name = m.default_
}
return m.providers[name]
}
func (m *ProviderManager) Default() Provider {
m.mu.RLock()
defer m.mu.RUnlock()
return m.providers[m.default_]
}
func (m *ProviderManager) List() []string {
m.mu.RLock()
defer m.mu.RUnlock()
var names []string
for n := range m.providers {
names = append(names, n)
}
return names
}
type rawToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
func messagesToMap(msgs []Message) []interface{} {
result := make([]interface{}, len(msgs))
for i, m := range msgs {
result[i] = map[string]interface{}{
"role": m.Role,
"content": m.Content,
}
}
return result
}
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func getFloat(m map[string]interface{}, key string) float64 {
if v, ok := m[key]; ok {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
}
}
return 0
}

1066
internal/agent/core/agent.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,172 @@
package core
import (
"fmt"
"sort"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
// ContextEvent — 单条上下文事件
type ContextEvent struct {
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"`
Input string `json:"input"`
Response string `json:"response,omitempty"`
ToolsUsed []string `json:"tools_used,omitempty"`
Vector vector.Vector `json:"-"` // 缓存向量,避免重复计算
}
// RelevanceContext — 基于相关性的上下文管理,非固定阈值
type RelevanceContext struct {
mu sync.Mutex
events []*ContextEvent
veczer *vector.TFIDFVectorizer
trained bool
}
func NewRelevanceContext() *RelevanceContext {
return &RelevanceContext{
veczer: vector.NewTFIDFVectorizer(2),
}
}
func (c *RelevanceContext) Append(evt ContextEvent) {
c.mu.Lock()
defer c.mu.Unlock()
evt.Vector = c.veczer.Vectorize(evt.Input + " " + evt.Response)
c.events = append(c.events, &evt)
// 增量训练向量化器
c.trained = false
}
// Prune — 基于当前输入计算每条上下文的相关性,归档最不相关的
// 返回被归档的事件(转为文档),保留 topK 个最相关的在活跃上下文中
func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.events) <= topK {
return 0
}
// 确保向量化器已训练
c.ensureTrained()
queryVec := c.veczer.Vectorize(currentInput)
// 计算每条上下文与当前输入的相关性
type scored struct {
event *ContextEvent
score float64
idx int
}
scoredEvents := make([]scored, len(c.events))
for i, evt := range c.events {
score := vector.CosineSimilarity(queryVec, evt.Vector)
scoredEvents[i] = scored{event: evt, score: score, idx: i}
}
// 按相关性从高到低排序
sort.Slice(scoredEvents, func(i, j int) bool {
return scoredEvents[i].score > scoredEvents[j].score
})
// 保留 topK 最相关的
keep := scoredEvents
if len(keep) > topK {
keep = keep[:topK]
}
archive := scoredEvents[topK:]
// 重建 events 为保留的
c.events = make([]*ContextEvent, len(keep))
for i, s := range keep {
c.events[i] = s.event
}
// 按时间重新排序
sort.Slice(c.events, func(i, j int) bool {
return c.events[i].Timestamp.Before(c.events[j].Timestamp)
})
// 归档到文档记忆
archived := 0
if docStore != nil && len(archive) > 0 {
entries := make([]document.ContextEntry, len(archive))
for i, s := range archive {
entries[i] = document.ContextEntry{
Timestamp: s.event.Timestamp,
Source: s.event.Source,
Content: s.event.Input,
Response: s.event.Response,
}
}
doc, err := docStore.ContextToDoc("context_archived", entries)
if err == nil && doc != nil {
archived = len(archive)
}
}
return archived
}
// Format — 输出活跃上下文的文本,用于注入 prompt
func (c *RelevanceContext) Format() string {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.events) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("【近期事件】\n")
for _, e := range c.events {
sb.WriteString(fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04:05"), e.Source, e.Input))
if e.Response != "" {
sb.WriteString(fmt.Sprintf(" → %s", truncateStr(e.Response, 80)))
}
sb.WriteString("\n")
}
return sb.String()
}
// Recent — 返回最近 n 条
func (c *RelevanceContext) Recent(n int) []ContextEvent {
c.mu.Lock()
defer c.mu.Unlock()
if n <= 0 || n > len(c.events) {
n = len(c.events)
}
result := make([]ContextEvent, n)
for i, evt := range c.events[len(c.events)-n:] {
result[i] = *evt
}
return result
}
// Len — 当前上下文事件数
func (c *RelevanceContext) Len() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.events)
}
func (c *RelevanceContext) ensureTrained() {
if !c.trained && len(c.events) > 0 {
texts := make([]string, len(c.events))
for i, evt := range c.events {
texts[i] = evt.Input + " " + evt.Response
}
c.veczer.Train(texts)
c.trained = true
}
}

View File

@ -0,0 +1,546 @@
package io
import (
"fmt"
"sync"
"time"
)
type DeviceType int
const (
DeviceInput DeviceType = 0
DeviceOutput DeviceType = 1
DeviceIO DeviceType = 2
)
// OutputCapability 定义通道支持的输出格式
type OutputCapability int
const (
CapText OutputCapability = 1 << iota // 文本
CapFile // 文件
CapImage // 图片
CapAudio // 音频
CapStructured // 结构化数据JSON/卡片)
)
func (c OutputCapability) Supports(cap OutputCapability) bool {
return c&cap != 0
}
func (c OutputCapability) String() string {
var flags []string
if c&CapText != 0 {
flags = append(flags, "text")
}
if c&CapFile != 0 {
flags = append(flags, "file")
}
if c&CapImage != 0 {
flags = append(flags, "image")
}
if c&CapAudio != 0 {
flags = append(flags, "audio")
}
if c&CapStructured != 0 {
flags = append(flags, "structured")
}
return fmt.Sprintf("%v", flags)
}
type Device interface {
Name() string
Type() DeviceType
Description() string
Tools() []ToolDef
Execute(tool string, args map[string]interface{}) (interface{}, error)
Start() error
Stop() error
OutputCapabilities() OutputCapability
}
type ToolHandler func(args map[string]interface{}) (interface{}, error)
type ToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
Handler ToolHandler `json:"-"` // 可选插件工具的直接处理器Device 通过 Execute() 分发
}
type InputEvent struct {
RequestID string `json:"request_id"`
Source string `json:"source"`
Type string `json:"type"`
Payload map[string]interface{} `json:"payload"`
ResponseCh chan<- *OutputEvent `json:"-"`
OutputChannel string `json:"output_channel"` // 默认输出通道(不传则等于 Source
}
type OutputEvent struct {
RequestID string `json:"request_id"`
Target string `json:"target"`
Type string `json:"type"`
Payload map[string]interface{} `json:"payload"`
Done bool `json:"done,omitempty"`
OutputChannel string `json:"output_channel"` // 路由到此通道
}
type IOManager struct {
mu sync.RWMutex
devices map[string]Device
inputCh chan *InputEvent
outputCh chan *OutputEvent
nextReqID int64
routes map[string]string // 输入源 → 默认输出通道 e.g. "mic" → "speaker"
}
func NewIOManager() *IOManager {
return &IOManager{
devices: make(map[string]Device),
inputCh: make(chan *InputEvent, 256),
outputCh: make(chan *OutputEvent, 256),
routes: make(map[string]string),
}
}
// RegisterOutputRoute 注册输入源 → 默认输出通道映射
// 例如mic → speakervoice_input → speaker
func (m *IOManager) RegisterOutputRoute(inputSource, outputChannel string) {
m.mu.Lock()
defer m.mu.Unlock()
m.routes[inputSource] = outputChannel
}
// DefaultOutput 返回输入源的默认输出通道
func (m *IOManager) DefaultOutput(source string) string {
m.mu.RLock()
defer m.mu.RUnlock()
if ch, ok := m.routes[source]; ok {
return ch
}
return source // 默认等于输入源
}
func (m *IOManager) nextRequestID() string {
m.mu.Lock()
defer m.mu.Unlock()
m.nextReqID++
return fmt.Sprintf("req_%d_%d", time.Now().UnixNano(), m.nextReqID)
}
func (m *IOManager) UnregisterDevice(name string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.devices, name)
for src, dst := range m.routes {
if src == name || dst == name {
delete(m.routes, src)
}
}
}
// AtomicSwapDevices 原子化替换全部 IO 设备与路由表
// 1. 新设备必须在调用前已完成 Start()
// 2. 调用后旧设备立即从路由表中摘除,新请求走向新设备
// 3. 返回旧设备列表,由调用方负责 Stop()
func (m *IOManager) AtomicSwapDevices(newDevices map[string]Device, newRoutes map[string]string) map[string]Device {
m.mu.Lock()
defer m.mu.Unlock()
oldDevices := m.devices
m.devices = newDevices
m.routes = newRoutes
return oldDevices
}
func (m *IOManager) RegisterDevice(dev Device) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.devices[dev.Name()]; ok {
return fmt.Errorf("device %s already registered", dev.Name())
}
m.devices[dev.Name()] = dev
return nil
}
func (m *IOManager) StartAll() error {
m.mu.RLock()
defer m.mu.RUnlock()
for name, dev := range m.devices {
if err := dev.Start(); err != nil {
return fmt.Errorf("start device %s: %w", name, err)
}
}
return nil
}
func (m *IOManager) StopAll() {
m.mu.RLock()
defer m.mu.RUnlock()
for _, dev := range m.devices {
dev.Stop()
}
}
func (m *IOManager) InjectInput(source string, eventType string, payload map[string]interface{}) {
m.inputCh <- &InputEvent{
RequestID: m.nextRequestID(),
Source: source,
Type: eventType,
Payload: payload,
OutputChannel: m.DefaultOutput(source),
}
}
func (m *IOManager) InjectInputSync(source string, eventType string, payload map[string]interface{}) *OutputEvent {
ch := make(chan *OutputEvent, 1)
m.inputCh <- &InputEvent{
RequestID: m.nextRequestID(),
Source: source,
Type: eventType,
Payload: payload,
ResponseCh: ch,
OutputChannel: m.DefaultOutput(source),
}
return <-ch
}
func (m *IOManager) InjectText(source string, text string) {
m.InjectInput(source, "text", map[string]interface{}{
"content": text,
})
}
func (m *IOManager) InjectTextSync(source string, text string) *OutputEvent {
return m.InjectInputSync(source, "text", map[string]interface{}{
"content": text,
})
}
func (m *IOManager) EmitOutput(target string, outputType string, payload map[string]interface{}) {
m.outputCh <- &OutputEvent{
RequestID: "",
Target: target,
Type: outputType,
Payload: payload,
Done: true,
}
}
// EmitOutputTo 通过指定输出通道发送
func (m *IOManager) EmitOutputTo(target, outputChannel, outputType string, payload map[string]interface{}) {
m.outputCh <- &OutputEvent{
RequestID: "",
Target: target,
Type: outputType,
Payload: payload,
Done: true,
OutputChannel: outputChannel,
}
}
func (m *IOManager) EmitText(target string, text string) {
m.EmitOutput(target, "text", map[string]interface{}{
"content": text,
})
}
// EmitTextTo 通过指定输出通道发送文本
func (m *IOManager) EmitTextTo(target, outputChannel, text string) {
m.EmitOutputTo(target, outputChannel, "text", map[string]interface{}{
"content": text,
})
}
func (m *IOManager) InputChan() <-chan *InputEvent { return m.inputCh }
func (m *IOManager) OutputChan() <-chan *OutputEvent { return m.outputCh }
func (m *IOManager) GetAllTools() []ToolDef {
m.mu.RLock()
defer m.mu.RUnlock()
var tools []ToolDef
for _, dev := range m.devices {
tools = append(tools, dev.Tools()...)
}
return tools
}
func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (interface{}, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, dev := range m.devices {
for _, t := range dev.Tools() {
if t.Name == name {
return dev.Execute(name, args)
}
}
}
return nil, fmt.Errorf("tool %s not found", name)
}
func (m *IOManager) ListDevices() []Device {
m.mu.RLock()
defer m.mu.RUnlock()
list := make([]Device, 0, len(m.devices))
for _, d := range m.devices {
list = append(list, d)
}
return list
}
// ChannelInfo 返回 IOManager 中已注册的所有通道信息
type ChannelInfo struct {
Name string `json:"name"`
Type DeviceType `json:"type"`
Description string `json:"description"`
Tools []ToolDef `json:"tools"`
OutputCaps OutputCapability `json:"output_capabilities"`
}
func (m *IOManager) ListChannels() []ChannelInfo {
m.mu.RLock()
defer m.mu.RUnlock()
var list []ChannelInfo
for _, dev := range m.devices {
list = append(list, ChannelInfo{
Name: dev.Name(),
Type: dev.Type(),
Description: dev.Description(),
Tools: dev.Tools(),
OutputCaps: dev.OutputCapabilities(),
})
}
return list
}
func (m *IOManager) GetChannelCapabilities(channel string) OutputCapability {
m.mu.RLock()
defer m.mu.RUnlock()
if dev, ok := m.devices[channel]; ok {
return dev.OutputCapabilities()
}
return 0
}
// Microphone
type Microphone struct {
name string
sampleRate int
io *IOManager
}
func NewMicrophone(name string, sampleRate int, io *IOManager) *Microphone {
return &Microphone{name: name, sampleRate: sampleRate, io: io}
}
func (d *Microphone) Name() string { return d.name }
func (d *Microphone) Type() DeviceType { return DeviceInput }
func (d *Microphone) OutputCapabilities() OutputCapability { return 0 } // 纯输入
func (d *Microphone) Description() string { return fmt.Sprintf("麦克风 (%s, %dHz)", d.name, d.sampleRate) }
func (d *Microphone) Start() error { return nil }
func (d *Microphone) Stop() error { return nil }
func (d *Microphone) Tools() []ToolDef {
return []ToolDef{{
Name: d.name + "_capture",
Description: fmt.Sprintf("从 %s 录制音频", d.name),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"duration": map[string]interface{}{"type": "number", "description": "录制时长(秒)", "default": 3},
},
},
}}
}
func (d *Microphone) Execute(tool string, args map[string]interface{}) (interface{}, error) {
return map[string]interface{}{"device": d.name, "status": "recorded", "format": "wav", "sample_rate": d.sampleRate}, nil
}
// Speaker
type Speaker struct {
name string
io *IOManager
}
func NewSpeaker(name string, io *IOManager) *Speaker {
return &Speaker{name: name, io: io}
}
func (d *Speaker) Name() string { return d.name }
func (d *Speaker) Type() DeviceType { return DeviceOutput }
func (d *Speaker) OutputCapabilities() OutputCapability { return CapText | CapAudio }
func (d *Speaker) Description() string { return fmt.Sprintf("扬声器 (%s)", d.name) }
func (d *Speaker) Start() error { return nil }
func (d *Speaker) Stop() error { return nil }
func (d *Speaker) Tools() []ToolDef {
return []ToolDef{{
Name: d.name + "_speak",
Description: fmt.Sprintf("通过 %s 播放语音", d.name),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"text": map[string]interface{}{"type": "string", "description": "播放文本"},
},
"required": []string{"text"},
},
}}
}
func (d *Speaker) Execute(tool string, args map[string]interface{}) (interface{}, error) {
text, _ := args["text"].(string)
return map[string]interface{}{"device": d.name, "status": "playing", "text": text}, nil
}
// Camera
type Camera struct {
name string
io *IOManager
}
func NewCamera(name string, io *IOManager) *Camera {
return &Camera{name: name, io: io}
}
func (d *Camera) Name() string { return d.name }
func (d *Camera) Type() DeviceType { return DeviceInput }
func (d *Camera) OutputCapabilities() OutputCapability { return CapImage } // 可返回图片
func (d *Camera) Description() string { return fmt.Sprintf("摄像头 (%s)", d.name) }
func (d *Camera) Start() error { return nil }
func (d *Camera) Stop() error { return nil }
func (d *Camera) Tools() []ToolDef {
return []ToolDef{
{
Name: d.name + "_capture",
Description: fmt.Sprintf("使用 %s 拍照", d.name),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"quality": map[string]interface{}{"type": "integer", "description": "质量1-100", "default": 90},
},
},
},
{
Name: d.name + "_stream",
Description: fmt.Sprintf("控制 %s 视频流", d.name),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "enum": []interface{}{"start", "stop"}},
},
"required": []string{"action"},
},
},
}
}
func (d *Camera) Execute(tool string, args map[string]interface{}) (interface{}, error) {
return map[string]interface{}{"device": d.name, "status": "captured"}, nil
}
// RobotArm
type RobotArm struct {
name string
io *IOManager
}
func NewRobotArm(name string, io *IOManager) *RobotArm {
return &RobotArm{name: name, io: io}
}
func (d *RobotArm) Name() string { return d.name }
func (d *RobotArm) Type() DeviceType { return DeviceIO }
func (d *RobotArm) OutputCapabilities() OutputCapability { return CapStructured }
func (d *RobotArm) Description() string { return fmt.Sprintf("机械臂 (%s)", d.name) }
func (d *RobotArm) Start() error { return nil }
func (d *RobotArm) Stop() error { return nil }
func (d *RobotArm) Tools() []ToolDef {
return []ToolDef{
{
Name: d.name + "_move",
Description: fmt.Sprintf("移动 %s 到坐标", d.name),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"x": map[string]interface{}{"type": "number", "description": "X 轴"},
"y": map[string]interface{}{"type": "number", "description": "Y 轴"},
"z": map[string]interface{}{"type": "number", "description": "Z 轴"},
},
"required": []string{"x", "y", "z"},
},
},
{
Name: d.name + "_grip",
Description: fmt.Sprintf("控制 %s 夹爪", d.name),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "enum": []interface{}{"open", "close"}},
},
"required": []string{"action"},
},
},
}
}
func (d *RobotArm) Execute(tool string, args map[string]interface{}) (interface{}, error) {
return map[string]interface{}{"device": d.name, "tool": tool, "status": "executed"}, nil
}
// GPIODevice
type GPIODevice struct {
name string
pins []int
io *IOManager
}
func NewGPIODevice(name string, pins []int, io *IOManager) *GPIODevice {
return &GPIODevice{name: name, pins: pins, io: io}
}
func (d *GPIODevice) Name() string { return d.name }
func (d *GPIODevice) Type() DeviceType { return DeviceIO }
func (d *GPIODevice) OutputCapabilities() OutputCapability { return CapStructured }
func (d *GPIODevice) Description() string { return "GPIO 通用引脚" }
func (d *GPIODevice) Start() error { return nil }
func (d *GPIODevice) Stop() error { return nil }
func (d *GPIODevice) Tools() []ToolDef {
return []ToolDef{
{
Name: d.name + "_gpio_write",
Description: "设置引脚电平",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"pin": map[string]interface{}{"type": "integer"},
"value": map[string]interface{}{"type": "integer", "enum": []interface{}{0, 1}},
},
"required": []string{"pin", "value"},
},
},
{
Name: d.name + "_gpio_read",
Description: "读取引脚电平",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"pin": map[string]interface{}{"type": "integer"},
},
"required": []string{"pin"},
},
},
}
}
func (d *GPIODevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
return map[string]interface{}{"device": d.name, "tool": tool, "status": "ok"}, nil
}

View File

@ -0,0 +1,41 @@
package agent
import (
"fmt"
"os"
"path/filepath"
)
type Personality struct {
Content string
Path string
}
func LoadPersonality(path string) (*Personality, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Personality{}, nil
}
return nil, fmt.Errorf("read personal.md: %w", err)
}
return &Personality{
Content: string(data),
Path: path,
}, nil
}
func SavePersonality(path, content string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create personality dir: %w", err)
}
return os.WriteFile(path, []byte(content), 0644)
}
func (p *Personality) InjectPrompt() string {
if p.Content == "" {
return ""
}
return fmt.Sprintf("【人格设定】\n%s\n", p.Content)
}

653
internal/api/handler.go Normal file
View File

@ -0,0 +1,653 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"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/skill"
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
type Handler struct {
supervisor *supervisor.Daemon
memory *memory.GraphDB
indexer *memory.Indexer
skills *skill.Manager
lua *luaVM.VM
config *types.Config
startTime time.Time
iom *agentIO.IOManager
textMem *text.Memory
knowledge *knowledge.Store
}
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) *Handler {
var idx *memory.Indexer
if mem != nil {
idx = memory.NewIndexer(mem)
}
return &Handler{
supervisor: sup,
memory: mem,
indexer: idx,
skills: sk,
lua: lua,
config: cfg,
startTime: time.Now(),
iom: iom,
textMem: tm,
knowledge: ks,
}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/status", h.handleStatus)
mux.HandleFunc("/api/v1/agents", h.handleAgents)
mux.HandleFunc("/api/v1/agents/", h.handleAgentByID)
mux.HandleFunc("/api/v1/skills", h.handleSkills)
mux.HandleFunc("/api/v1/memory", h.handleMemory)
mux.HandleFunc("/api/v1/memory/", h.handleMemory)
mux.HandleFunc("/api/v1/memory/context", h.handleMemoryContext)
mux.HandleFunc("/api/v1/memory/tools", h.handleMemoryTools)
mux.HandleFunc("/api/v1/memory/text", h.handleTextMemory)
mux.HandleFunc("/api/v1/network", h.handleNetwork)
mux.HandleFunc("/api/v1/config", h.handleConfig)
mux.HandleFunc("/api/v1/knowledge", h.handleKnowledge)
mux.HandleFunc("/api/v1/knowledge/", h.handleKnowledge)
mux.HandleFunc("/api/v1/adapters", h.handleAdapters)
mux.HandleFunc("/api/v1/adapters/", h.handleAdapterByID)
mux.HandleFunc("/v1/chat/completions", h.handleOpenAICompletions)
mux.HandleFunc("/", h.handleStatic)
}
func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
agents := h.supervisor.ListAgents()
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "running",
"uptime": time.Since(h.startTime).String(),
"agents": len(agents),
"version": "0.1.0",
"startedAt": h.startTime,
})
}
func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
agents := h.supervisor.ListAgents()
writeJSON(w, http.StatusOK, map[string]interface{}{"agents": agents})
case http.MethodPost:
var cfg types.AgentConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if cfg.ID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent id is required"})
return
}
h.config.Agents = append(h.config.Agents, cfg)
writeJSON(w, http.StatusCreated, map[string]string{"id": string(cfg.ID)})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleAgentByID(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/")
parts := strings.Split(path, "/")
agentID := types.AgentID(parts[0])
if len(parts) == 1 {
switch r.Method {
case http.MethodGet:
status, err := h.supervisor.GetAgentStatus(agentID)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, status)
case http.MethodDelete:
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": string(agentID)})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
return
}
action := parts[1]
switch action {
case "snapshots":
h.handleSnapshots(w, r, agentID, parts)
case "rollback":
h.handleRollback(w, r, agentID, parts)
case "start", "stop", "restart":
h.handleAgentAction(w, r, agentID, action)
default:
http.Error(w, "not found", http.StatusNotFound)
}
}
func (h *Handler) handleSnapshots(w http.ResponseWriter, r *http.Request, agentID types.AgentID, parts []string) {
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{"agent_id": agentID, "snapshots": []map[string]interface{}{}})
case http.MethodPost:
snap, err := h.supervisor.PreActionSnapshot(agentID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, snap)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleRollback(w http.ResponseWriter, r *http.Request, agentID types.AgentID, parts []string) {
if r.Method != http.MethodPost || len(parts) < 3 {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
snapID := types.SnapshotID(parts[2])
if err := h.supervisor.RollbackAgent(agentID, snapID); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "rollback_initiated", "agent": string(agentID), "snap": string(snapID)})
}
func (h *Handler) handleAgentAction(w http.ResponseWriter, r *http.Request, agentID types.AgentID, action string) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": fmt.Sprintf("%s_requested", action), "agent": string(agentID)})
}
func (h *Handler) handleSkills(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{"skills": h.skills.List()})
case http.MethodPost:
var req struct {
Name string `json:"name"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
if err := h.skills.Install(req.Name, req.Content); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "installed", "name": req.Name})
case http.MethodDelete:
name := r.URL.Query().Get("name")
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name query param required"})
return
}
if err := h.skills.Uninstall(name); err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "uninstalled", "name": name})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleMemory(w http.ResponseWriter, r *http.Request) {
if h.memory == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "memory system not available"})
return
}
switch r.Method {
case http.MethodGet:
userInput := r.URL.Query().Get("q")
keywords := strings.Split(userInput, ",")
depth, _ := strconv.Atoi(r.URL.Query().Get("depth"))
if depth <= 0 {
depth = 2
}
result, err := h.memory.Recall(keywords, nil, depth, "")
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, result)
case http.MethodPost:
var req struct {
Triples []memory.Triple `json:"triples"`
SessionID string `json:"session_id"`
TurnID int `json:"turn_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
ec, rc, err := h.memory.Commit(req.Triples, req.SessionID, req.TurnID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, map[string]int{"entities_created": ec, "relations_created": rc})
case http.MethodDelete:
var req struct {
Criteria map[string]string `json:"criteria"`
Mode string `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
deleted, err := h.memory.Purge(req.Criteria, req.Mode)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]int{"deleted": deleted})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleMemoryContext(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.indexer == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "indexer not available"})
return
}
userInput := r.URL.Query().Get("q")
injected := h.indexer.BuildContext(userInput)
writeJSON(w, http.StatusOK, map[string]interface{}{
"context": h.indexer.FormatContext(injected),
"summary": injected.Summary,
"entities": injected.Entities,
"token_estimate": injected.TokenEstimate,
"tool_prompt": h.indexer.BuildToolPrompt(),
})
}
func (h *Handler) handleMemoryTools(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.indexer == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "indexer not available"})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"tools": h.indexer.GetToolDefinitions(),
"tool_prompt": h.indexer.BuildToolPrompt(),
})
}
func (h *Handler) handleKnowledge(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if h.knowledge == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "knowledge not available"})
return
}
query := r.URL.Query().Get("q")
if query != "" {
results := h.knowledge.Search(query, 10)
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"categories": h.knowledge.List(),
"stats": h.knowledge.Stats(),
})
case http.MethodPost:
if h.knowledge == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "knowledge not available"})
return
}
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(10 << 20); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
name := r.FormValue("name")
file, _, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file required"})
return
}
defer file.Close()
buf := make([]byte, 10<<20)
n, _ := file.Read(buf)
content := string(buf[:n])
if err := h.knowledge.Add(name, content); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "created", "name": name})
return
}
var req struct {
Name string `json:"name"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
if req.Name == "" || req.Content == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name and content required"})
return
}
if err := h.knowledge.Add(req.Name, req.Content); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "created", "name": req.Name})
case http.MethodDelete:
name := r.URL.Query().Get("name")
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name query param required"})
return
}
if err := h.knowledge.Remove(name); err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleTextMemory(w http.ResponseWriter, r *http.Request) {
if h.textMem == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "text memory not available"})
return
}
switch r.Method {
case http.MethodGet:
recent, _ := h.textMem.RecentEvents(50)
stats := h.textMem.Stats()
writeJSON(w, http.StatusOK, map[string]interface{}{
"stats": stats,
"recent": recent,
})
case http.MethodDelete:
// future: purge
writeJSON(w, http.StatusAccepted, map[string]string{"status": "not_implemented"})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleAdapters(w http.ResponseWriter, r *http.Request) {
if h.lua == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "lua vm not available"})
return
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{"adapters": h.lua.ListAdapters()})
case http.MethodPost:
var req struct {
Name string `json:"name"`
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
path := fmt.Sprintf("%s/%s.lua", h.lua.AdapterDir(), req.Name)
if err := os.WriteFile(path, []byte(req.Code), 0644); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if err := h.lua.ReloadAll(); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "loaded", "name": req.Name})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleAdapterByID(w http.ResponseWriter, r *http.Request) {
if h.lua == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "lua vm not available"})
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/v1/adapters/")
if name == "" {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
for _, a := range h.lua.ListAdapters() {
if a.Name == name {
writeJSON(w, http.StatusOK, a)
return
}
}
http.NotFound(w, r)
case http.MethodDelete:
path := fmt.Sprintf("%s/%s.lua", h.lua.AdapterDir(), name)
if err := os.Remove(path); err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "adapter not found"})
return
}
h.lua.ReloadAll()
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handler) handleNetwork(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"network_status": "monitoring",
"endpoints": h.config.Defaults.LLMEndpoints,
})
}
func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, h.config)
case http.MethodPut:
var cfg types.Config
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid config"})
return
}
h.config = &cfg
writeJSON(w, http.StatusOK, map[string]string{"status": "config_updated"})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// OpenAI 兼容 API — 所有输入走 IO 抽象层(中断)
func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Model string `json:"model"`
Messages []openAIMessage `json:"messages"`
Stream bool `json:"stream"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request: " + err.Error()})
return
}
if len(req.Messages) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "messages is required"})
return
}
// 取最后一条 user 消息作为输入
lastMsg := req.Messages[len(req.Messages)-1]
if lastMsg.Role != "user" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "last message must be from user"})
return
}
// 通过 IO 抽象层同步注入(中断式)
response := h.iom.InjectTextSync("http", lastMsg.Content)
resp := map[string]interface{}{
"id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()),
"object": "chat.completion",
"created": time.Now().Unix(),
"model": req.Model,
"choices": []map[string]interface{}{
{
"index": 0,
"message": map[string]interface{}{
"role": "assistant",
"content": response.Payload["content"],
},
"finish_reason": "stop",
},
},
"usage": map[string]interface{}{
"prompt_tokens": len(lastMsg.Content) / 2,
"completion_tokens": len(response.Payload["content"].(string)) / 2,
"total_tokens": (len(lastMsg.Content) + len(response.Payload["content"].(string))) / 2,
},
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(resp)
}
func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(webuiHTML)
return
}
http.NotFound(w, r)
}
type openAIMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
var webuiHTML = []byte(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HomeAgent Dashboard</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
body{background:#0f172a;color:#e2e8f0;min-height:100vh}
nav{background:#1e293b;padding:12px 24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #334155}
nav h1{font-size:18px;font-weight:600;color:#38bdf8}
nav a{color:#94a3b8;text-decoration:none;font-size:14px;cursor:pointer}
nav a:hover{color:#38bdf8;text-decoration:none}
nav a.active{color:#38bdf8;border-bottom:2px solid #38bdf8}
.container{padding:24px;max-width:1400px;margin:0 auto}
.card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:20px;margin-bottom:16px}
.card h2{font-size:16px;font-weight:600;margin-bottom:12px;color:#f1f5f9}
.status-dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px}
.dot-green{background:#22c55e}
.dot-yellow{background:#eab308}
.dot-red{background:#ef4444}
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
.stat-value{font-size:28px;font-weight:700;color:#38bdf8}
.stat-label{font-size:12px;color:#64748b;margin-top:4px}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;padding:8px 12px;color:#64748b;font-weight:500;border-bottom:1px solid #334155;font-size:12px;text-transform:uppercase}
td{padding:8px 12px;border-bottom:1px solid #1e293b}
.status-badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:500}
.badge-running{background:#166534;color:#86efac}
.badge-stopped{background:#7f1d1d;color:#fca5a5}
.btn{padding:6px 14px;border-radius:6px;border:none;font-size:12px;cursor:pointer;font-weight:500}
.btn-primary{background:#2563eb;color:#fff}
.btn-primary:hover{background:#1d4ed8}
.btn-danger{background:#dc2626;color:#fff}
.btn-sm{padding:4px 10px;font-size:11px}
.tab-content{display:none}
.tab-content.active{display:block}
input,textarea,select{background:#0f172a;border:1px solid #334155;border-radius:6px;padding:8px 12px;color:#e2e8f0;font-size:13px;width:100%;margin-bottom:12px}
label{display:block;font-size:12px;color:#94a3b8;margin-bottom:4px}
h3{font-size:14px;font-weight:600;color:#f1f5f9;margin-bottom:8px}
pre{background:#0f172a;border-radius:6px;padding:12px;font-size:12px;overflow-x:auto;color:#a5b4fc}
</style>
</head>
<body>
<nav>
<h1>🦞 HomeAgent</h1>
<a class="active" onclick="switchTab('overview')">概览</a>
<a onclick="switchTab('memory')">图记忆</a>
<a onclick="switchTab('skills')">技能</a>
<a onclick="switchTab('network')">网络</a>
<a onclick="switchTab('config')">配置</a>
</nav>
<div class="container" id="app">
<div id="tab-overview" class="tab-content active"></div>
<div id="tab-memory" class="tab-content"></div>
<div id="tab-skills" class="tab-content"></div>
<div id="tab-network" class="tab-content"></div>
<div id="tab-config" class="tab-content"></div>
</div>
<script>
let state={status:null};
async function api(p,o={}){const r=await fetch('/api/v1'+p,{headers:{'Content-Type':'application/json',...o.headers},...o});return r.json()}
function switchTab(n){document.querySelectorAll('.tab-content').forEach(e=>e.classList.remove('active'));document.getElementById('tab-'+n).classList.add('active');document.querySelectorAll('nav a').forEach(e=>e.classList.remove('active'));document.querySelector('nav a[onclick*="'+n+'"]')?.classList.add('active');renderAll()}
async function renderAll(){try{state.status=await api('/status')}catch(e){}renderOverview();renderMemory();renderSkills();renderNetwork();renderConfig()}
function renderOverview(){const s=state.status||{};document.getElementById('tab-overview').innerHTML='<div class="grid-3">'+statCard('运行状态',s.status||'unknown')+statCard('运行时间',s.uptime||'-')+statCard('版本',s.version||'-')+'</div>'}
function statCard(l,v){return '<div class="card"><div class="stat-value">'+v+'</div><div class="stat-label">'+l+'</div></div>'}
function renderMemory(){document.getElementById('tab-memory').innerHTML='<div class="card"><h2>图记忆</h2><p style="color:#94a3b8">agent 通过 memory_recall / memory_commit 自动管理</p></div>'}
function renderSkills(){document.getElementById('tab-skills').innerHTML='<div class="card"><h2>技能</h2><p style="color:#94a3b8">SKILL.md 插件通过 IO 层注入</p></div>'}
function renderNetwork(){document.getElementById('tab-network').innerHTML='<div class="card"><h2>网络</h2><p style="color:#94a3b8">LLM API 连通性监控</p></div>'}
function renderConfig(){document.getElementById('tab-config').innerHTML='<div class="card"><h2>配置</h2><pre>'+JSON.stringify(state.status,null,2)+'</pre></div>'}
renderAll();setInterval(renderAll,30000);
</script>
</body>
</html>`)

View File

@ -0,0 +1,209 @@
package container
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
type Manager struct {
dataDir string
}
func NewManager(dataDir string) *Manager {
return &Manager{dataDir: dataDir}
}
type ContainerInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Image string `json:"image"`
}
func (m *Manager) Create(ctx context.Context, cfg *types.AgentConfig) (*ContainerInfo, error) {
args := []string{
"create",
"--name", "ha-" + string(cfg.ID),
"--hostname", string(cfg.ID),
"--restart", "no",
"--stop-timeout", "10",
"--memory", cfg.ResourceLimit.Memory,
"--cpus", cfg.ResourceLimit.CPU,
"--label", "homeagent.managed=true",
"--label", "homeagent.agent-id=" + string(cfg.ID),
}
if !cfg.ResourceLimit.Network {
args = append(args, "--network", "none")
}
args = append(args, cfg.Image)
cmd := exec.CommandContext(ctx, "docker", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("docker create: %s: %w", strings.TrimSpace(stderr.String()), err)
}
id := strings.TrimSpace(string(out))
return &ContainerInfo{ID: id, Name: "ha-" + string(cfg.ID), Status: "created", Image: cfg.Image}, nil
}
func (m *Manager) Start(ctx context.Context, containerID string) error {
cmd := exec.CommandContext(ctx, "docker", "start", containerID)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("docker start: %s: %w", strings.TrimSpace(stderr.String()), err)
}
return nil
}
func (m *Manager) Stop(ctx context.Context, containerID string) error {
cmd := exec.CommandContext(ctx, "docker", "stop", "--time", "5", containerID)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("docker stop: %s: %w", strings.TrimSpace(stderr.String()), err)
}
return nil
}
func (m *Manager) Remove(ctx context.Context, containerID string) error {
cmd := exec.CommandContext(ctx, "docker", "rm", "-f", containerID)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("docker rm: %s: %w", strings.TrimSpace(stderr.String()), err)
}
return nil
}
func (m *Manager) Inspect(ctx context.Context, containerID string) (*ContainerInfo, error) {
cmd := exec.CommandContext(ctx, "docker", "inspect", containerID)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("docker inspect: %w", err)
}
var containers []struct {
ID string `json:"Id"`
Name string `json:"Name"`
State struct {
Status string `json:"Status"`
} `json:"State"`
Config struct {
Image string `json:"Image"`
} `json:"Config"`
}
if err := json.Unmarshal(out, &containers); err != nil {
return nil, fmt.Errorf("parse inspect: %w", err)
}
if len(containers) == 0 {
return nil, fmt.Errorf("container %s not found", containerID)
}
c := containers[0]
return &ContainerInfo{
ID: c.ID,
Name: strings.TrimPrefix(c.Name, "/"),
Status: c.State.Status,
Image: c.Config.Image,
}, nil
}
func (m *Manager) WaitHealthy(ctx context.Context, containerID string) error {
args := []string{
"exec", containerID,
"agentd", "--probe",
}
cmd := exec.CommandContext(ctx, "docker", args...)
return cmd.Run()
}
func (m *Manager) Exec(ctx context.Context, containerID string, cmdArgs []string) ([]byte, error) {
args := append([]string{"exec", containerID}, cmdArgs...)
cmd := exec.CommandContext(ctx, "docker", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("docker exec: %s: %w", strings.TrimSpace(stderr.String()), err)
}
return out, nil
}
func (m *Manager) Commit(ctx context.Context, containerID string, imageTag string) error {
cmd := exec.CommandContext(ctx, "docker", "commit", containerID, imageTag)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("docker commit: %s: %w", strings.TrimSpace(stderr.String()), err)
}
return nil
}
func (m *Manager) SaveImage(ctx context.Context, imageTag string, outputPath string) error {
cmd := exec.CommandContext(ctx, "docker", "save", "-o", outputPath, imageTag)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("docker save: %s: %w", strings.TrimSpace(stderr.String()), err)
}
return nil
}
func (m *Manager) LoadImage(ctx context.Context, inputPath string) error {
cmd := exec.CommandContext(ctx, "docker", "load", "-i", inputPath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("docker load: %s: %w", strings.TrimSpace(stderr.String()), err)
}
return nil
}
func (m *Manager) ListManaged(ctx context.Context) ([]ContainerInfo, error) {
cmd := exec.CommandContext(ctx, "docker", "ps", "-a",
"--filter", "label=homeagent.managed=true",
"--format", "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}",
)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("docker ps: %w", err)
}
var containers []ContainerInfo
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 4)
if len(parts) < 4 {
continue
}
containers = append(containers, ContainerInfo{
ID: parts[0], Name: parts[1], Status: parts[2], Image: parts[3],
})
}
return containers, nil
}

162
internal/embed/embedder.go Normal file
View File

@ -0,0 +1,162 @@
package embed
import (
"bytes"
"encoding/json"
"fmt"
"math"
"net/http"
"sync"
"time"
)
type Embedder interface {
Embed(text string) ([]float64, error)
Similarity(a, b []float64) float64
Dimension() int
}
type OllamaEmbedder struct {
client *http.Client
baseURL string
model string
dimension int
mu sync.RWMutex
}
func NewOllamaEmbedder(baseURL, model string, dimension int) *OllamaEmbedder {
if baseURL == "" {
baseURL = "http://localhost:11434"
}
if model == "" {
model = "nomic-embed-text"
}
if dimension <= 0 {
dimension = 768
}
return &OllamaEmbedder{
client: &http.Client{
Timeout: 30 * time.Second,
},
baseURL: baseURL,
model: model,
dimension: dimension,
}
}
func (e *OllamaEmbedder) Embed(text string) ([]float64, error) {
if text == "" {
return make([]float64, e.dimension), nil
}
reqBody := map[string]interface{}{
"model": e.model,
"prompt": text,
}
data, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
resp, err := e.client.Post(e.baseURL+"/api/embeddings", "application/json", bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("ollama api: %w", err)
}
defer resp.Body.Close()
var result struct {
Embedding []float64 `json:"embedding"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return result.Embedding, nil
}
func (e *OllamaEmbedder) Similarity(a, b []float64) float64 {
return cosineSimilarity(a, b)
}
func (e *OllamaEmbedder) Dimension() int {
return e.dimension
}
type HashEmbedder struct {
dimension int
}
func NewHashEmbedder(dimension int) *HashEmbedder {
if dimension <= 0 {
dimension = 64
}
return &HashEmbedder{dimension: dimension}
}
func (e *HashEmbedder) Embed(text string) ([]float64, error) {
vec := make([]float64, e.dimension)
runes := []rune(text)
if len(runes) == 0 {
return vec, nil
}
// Character-level hash embedding
for i, r := range runes {
h := hashRune(r)
idx := i % e.dimension
vec[idx] += float64(h) / 65536.0
}
// Normalize
mag := 0.0
for _, v := range vec {
mag += v * v
}
if mag > 0 {
mag = math.Sqrt(mag)
for i := range vec {
vec[i] /= mag
}
}
return vec, nil
}
func (e *HashEmbedder) Similarity(a, b []float64) float64 {
return cosineSimilarity(a, b)
}
func (e *HashEmbedder) Dimension() int {
return e.dimension
}
func hashRune(r rune) uint64 {
h := uint64(r)
h ^= h >> 33
h *= 0xff51afd7ed558ccd
h ^= h >> 33
h *= 0xc4ceb9fe1a85ec53
h ^= h >> 33
return h
}
func cosineSimilarity(a, b []float64) float64 {
if len(a) != len(b) || len(a) == 0 {
return 0
}
var dot, na, nb float64
for i := range a {
dot += a[i] * b[i]
na += a[i] * a[i]
nb += b[i] * b[i]
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}

View File

@ -0,0 +1,274 @@
package knowledge
import (
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
// Knowledge — 单条知识
type Knowledge struct {
Name string `json:"name"`
Content string `json:"content"`
Path string `json:"path"`
Tags []string `json:"tags"`
UpdatedAt time.Time `json:"updated_at"`
Meta map[string]string `json:"meta,omitempty"`
}
// Store — 知识库,文件系统 + 向量索引
type Store struct {
root string
vec *vector.Store
veczer *vector.TFIDFVectorizer
mu sync.RWMutex
items map[string]*Knowledge
summaries []string
}
func NewStore(root string) *Store {
return &Store{
root: root,
vec: vector.NewStore(),
veczer: vector.NewTFIDFVectorizer(3),
items: make(map[string]*Knowledge),
}
}
func (s *Store) Start() error {
if err := os.MkdirAll(s.root, 0755); err != nil {
return fmt.Errorf("knowledge root: %w", err)
}
if err := s.scanAll(); err != nil {
log.Printf("[knowledge] scan error: %v", err)
}
log.Printf("[knowledge] started with %d items, %d vectors", len(s.items), s.vec.Size())
return nil
}
func (s *Store) Stop() {}
// Search — 向量查询知识
func (s *Store) Search(query string, topK int) []*Knowledge {
s.mu.RLock()
defer s.mu.RUnlock()
if topK <= 0 {
topK = 5
}
vec := s.veczer.Vectorize(query)
results := s.vec.Search(vec, topK)
var out []*Knowledge
for _, r := range results {
if k, ok := s.items[r.ID]; ok {
out = append(out, k)
}
}
return out
}
// Add — 添加或更新知识
func (s *Store) Add(name, content string) error {
s.mu.Lock()
defer s.mu.Unlock()
// 创建知识目录
dir := filepath.Join(s.root, sanitize(name))
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create knowledge dir: %w", err)
}
// 写入知识文件
path := filepath.Join(dir, "content.md")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return fmt.Errorf("write knowledge: %w", err)
}
now := time.Now()
k := &Knowledge{
Name: name,
Content: content,
Path: path,
Tags: extractKeywords(name + " " + content),
UpdatedAt: now,
}
// 生成 ID = 目录名
id := sanitize(name)
s.items[id] = k
vec := s.veczer.Vectorize(name + " " + content)
s.vec.Insert(id, name+": "+content, vec, map[string]string{
"name": name, "path": path,
})
s.summaries = append(s.summaries, name+" "+content)
log.Printf("[knowledge] added: %s (%d bytes)", name, len(content))
return nil
}
// SearchCategories — 返回所有知识类别
func (s *Store) SearchCategories(query string, topK int) []string {
s.mu.RLock()
defer s.mu.RUnlock()
if query == "" {
var names []string
for _, k := range s.items {
names = append(names, k.Name)
}
sort.Strings(names)
if len(names) > topK {
names = names[:topK]
}
return names
}
vec := s.veczer.Vectorize(query)
results := s.vec.Search(vec, topK)
var names []string
for _, r := range results {
if k, ok := s.items[r.ID]; ok {
names = append(names, k.Name)
}
}
return names
}
func (s *Store) Remove(name string) error {
s.mu.Lock()
defer s.mu.Unlock()
id := sanitize(name)
dir := filepath.Join(s.root, id)
if err := os.RemoveAll(dir); err != nil {
return err
}
delete(s.items, id)
s.vec.Remove(id)
return nil
}
func (s *Store) Stats() map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
return map[string]interface{}{
"knowledge_count": len(s.items),
"vector_count": s.vec.Size(),
"root": s.root,
}
}
func (s *Store) List() []string {
s.mu.RLock()
defer s.mu.RUnlock()
var names []string
for _, k := range s.items {
names = append(names, k.Name)
}
sort.Strings(names)
return names
}
// ——— internal ———
func (s *Store) scanAll() error {
entries, err := os.ReadDir(s.root)
if err != nil {
return err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
dir := filepath.Join(s.root, entry.Name())
contentPath := filepath.Join(dir, "content.md")
data, err := os.ReadFile(contentPath)
if err != nil {
continue
}
name := entry.Name()
content := string(data)
now := time.Now()
k := &Knowledge{
Name: name,
Content: content,
Path: contentPath,
Tags: extractKeywords(name + " " + content),
UpdatedAt: now,
}
s.items[name] = k
s.summaries = append(s.summaries, name+" "+content)
}
// 训练向量化器
if len(s.summaries) > 0 {
s.veczer.Train(s.summaries)
}
// 构建向量索引
for _, k := range s.items {
vec := s.veczer.Vectorize(k.Name + " " + k.Content)
s.vec.Insert(k.Name, k.Name+": "+k.Content, vec, map[string]string{
"name": k.Name, "path": k.Path,
})
}
return nil
}
func sanitize(name string) string {
name = strings.ToLower(name)
name = strings.TrimSpace(name)
name = strings.ReplaceAll(name, " ", "_")
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
return name
}
func extractKeywords(text string) []string {
stopWords := map[string]bool{
"的": true, "了": true, "是": true, "在": true, "有": true,
"和": true, "就": true, "不": true, "都": true,
"一": true, "一个": true, "也": true, "很": true,
"到": true, "说": true, "要": true, "去": true,
"会": true, "着": true, "没有": true, "看": true, "好": true,
"自己": true, "这": true, "他": true, "她": true, "它": true,
"什么": true, "怎么": true, "为什么": true, "如何": true,
"我们": true, "你们": true, "他们": true, "这个": true,
"那个": true, "可以": true, "吗": true, "吧": true, "啊": true,
}
var keywords []string
runes := []rune(text)
seen := make(map[string]bool)
// bi-gram
for i := 0; i < len(runes)-1; i++ {
word := string(runes[i : i+2])
if !stopWords[word] && len(strings.TrimSpace(word)) == len(word) && !seen[word] {
seen[word] = true
keywords = append(keywords, word)
}
}
if len(keywords) > 10 {
keywords = keywords[:10]
}
return keywords
}

View File

@ -0,0 +1,22 @@
local adapter = {}
adapter.name = "deepseek"
adapter.version = "1.0.0"
function adapter.transform_request(input)
local messages = input.messages or {}
local result = {
model = input.model or "deepseek-chat",
messages = messages,
temperature = input.temperature or 0.0,
max_tokens = input.max_tokens or 4096,
stream = input.stream or false
}
return result
end
function adapter.transform_response(raw)
return raw
end
return adapter

View File

@ -0,0 +1,24 @@
local adapter = {}
adapter.name = "ollama"
adapter.version = "1.0.0"
function adapter.transform_request(input)
local messages = input.messages or {}
local result = {
model = input.model or "llama3",
messages = messages,
stream = input.stream or false,
options = {
temperature = input.temperature or 0.7,
num_predict = input.max_tokens or 2048
}
}
return result
end
function adapter.transform_response(raw)
return raw
end
return adapter

View File

@ -0,0 +1,22 @@
local adapter = {}
adapter.name = "openai"
adapter.version = "1.0.0"
function adapter.transform_request(input)
local messages = input.messages or {}
local result = {
model = input.model or "gpt-4",
messages = messages,
temperature = input.temperature or 0.7,
max_tokens = input.max_tokens or 2048,
stream = input.stream or false
}
return result
end
function adapter.transform_response(raw)
return raw
end
return adapter

327
internal/lua/vm.go Normal file
View File

@ -0,0 +1,327 @@
package lua
import (
"embed"
"fmt"
"os"
"path/filepath"
"sync"
lua "github.com/yuin/gopher-lua"
)
//go:embed adapters/*.lua
var bundledAdapters embed.FS
type VM struct {
mu sync.Mutex
state *lua.LState
adapterDir string
loaded map[string]*lua.LTable
}
type APIAdapter struct {
Name string
Version string
Script string
}
func (v *VM) AdapterDir() string {
return v.adapterDir
}
func NewVM(adapterDir string) *VM {
return &VM{
adapterDir: adapterDir,
loaded: make(map[string]*lua.LTable),
}
}
func (v *VM) Start() error {
os.MkdirAll(v.adapterDir, 0755)
if err := v.writeBundledAdapters(); err != nil {
return fmt.Errorf("write bundled adapters: %w", err)
}
v.state = lua.NewState()
v.state.SetGlobal("log", v.state.NewFunction(func(L *lua.LState) int {
level := L.ToString(1)
msg := L.ToString(2)
fmt.Printf("[lua/%s] %s\n", level, msg)
return 0
}))
v.state.SetGlobal("json_encode", v.state.NewFunction(func(L *lua.LState) int {
val := L.CheckAny(1)
L.Push(lua.LString(fmt.Sprintf("%v", val)))
return 1
}))
v.state.SetGlobal("http_get", v.state.NewFunction(func(L *lua.LState) int {
url := L.ToString(1)
L.Push(lua.LString(fmt.Sprintf(`{"url":%q,"status":200,"body":"mock"}`, url)))
return 1
}))
v.state.SetGlobal("http_post", v.state.NewFunction(func(L *lua.LState) int {
url := L.ToString(1)
body := L.ToString(2)
L.Push(lua.LString(fmt.Sprintf(`{"url":%q,"body":%q,"status":200}`, url, body)))
return 1
}))
if err := v.loadAdapters(); err != nil {
return fmt.Errorf("load adapters: %w", err)
}
return nil
}
func (v *VM) Stop() {
if v.state != nil {
v.state.Close()
}
}
func (v *VM) loadAdapters() error {
entries, err := os.ReadDir(v.adapterDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
if filepath.Ext(entry.Name()) != ".lua" {
continue
}
path := filepath.Join(v.adapterDir, entry.Name())
if err := v.LoadAdapter(path); err != nil {
fmt.Printf("[lua] load %s: %v\n", entry.Name(), err)
}
}
return nil
}
func (v *VM) LoadAdapter(path string) error {
v.mu.Lock()
defer v.mu.Unlock()
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read adapter: %w", err)
}
script := string(data)
if err := v.state.DoString(script); err != nil {
return fmt.Errorf("execute adapter script: %w", err)
}
adapterTable := v.state.Get(-1)
v.state.Pop(1)
tbl, ok := adapterTable.(*lua.LTable)
if !ok {
return fmt.Errorf("adapter script must return a table")
}
name := ""
if nameVal := tbl.RawGetString("name"); nameVal != nil {
name = nameVal.String()
}
if name == "" {
name = filepath.Base(path)
}
v.loaded[name] = tbl
fmt.Printf("[lua] loaded adapter: %s\n", name)
return nil
}
func (v *VM) CallTransform(name string, input map[string]interface{}) (map[string]interface{}, error) {
v.mu.Lock()
adapter, ok := v.loaded[name]
v.mu.Unlock()
if !ok {
return nil, fmt.Errorf("adapter %s not loaded", name)
}
v.mu.Lock()
defer v.mu.Unlock()
fn := adapter.RawGetString("transform_request")
if fn == nil {
return nil, fmt.Errorf("adapter %s missing transform_request", name)
}
inputTable := mapToTable(v.state, input)
v.state.Push(fn)
v.state.Push(inputTable)
if err := v.state.PCall(1, 1, nil); err != nil {
return nil, fmt.Errorf("transform_request: %w", err)
}
result := v.state.Get(-1)
v.state.Pop(1)
resultTable, ok := result.(*lua.LTable)
if !ok {
return nil, fmt.Errorf("transform_request must return a table")
}
return tableToMap(resultTable), nil
}
func (v *VM) CallResponseTransform(name string, raw []byte) ([]byte, error) {
v.mu.Lock()
adapter, ok := v.loaded[name]
v.mu.Unlock()
if !ok {
return raw, nil
}
v.mu.Lock()
defer v.mu.Unlock()
fn := adapter.RawGetString("transform_response")
if fn == nil {
return raw, nil
}
v.state.Push(fn)
v.state.Push(lua.LString(string(raw)))
if err := v.state.PCall(1, 1, nil); err != nil {
return nil, fmt.Errorf("transform_response: %w", err)
}
result := v.state.Get(-1)
v.state.Pop(1)
return []byte(result.String()), nil
}
func (v *VM) ListAdapters() []APIAdapter {
v.mu.Lock()
defer v.mu.Unlock()
adapters := make([]APIAdapter, 0)
for name, tbl := range v.loaded {
adapter := APIAdapter{Name: name}
if v := tbl.RawGetString("version"); v != nil {
adapter.Version = v.String()
}
adapters = append(adapters, adapter)
}
return adapters
}
func (v *VM) ReloadAll() error {
v.mu.Lock()
v.loaded = make(map[string]*lua.LTable)
v.mu.Unlock()
if v.state != nil {
v.state.Close()
}
v.state = lua.NewState()
return v.Start()
}
func mapToTable(L *lua.LState, m map[string]interface{}) *lua.LTable {
tbl := L.NewTable()
for k, v := range m {
switch val := v.(type) {
case string:
tbl.RawSetString(k, lua.LString(val))
case float64:
tbl.RawSetString(k, lua.LNumber(val))
case int:
tbl.RawSetString(k, lua.LNumber(val))
case bool:
tbl.RawSetString(k, lua.LBool(val))
case map[string]interface{}:
tbl.RawSetString(k, mapToTable(L, val))
case []interface{}:
arr := L.NewTable()
for i, item := range val {
if m, ok := item.(map[string]interface{}); ok {
arr.RawSetInt(i+1, mapToTable(L, m))
} else {
arr.RawSetInt(i+1, lua.LString(fmt.Sprintf("%v", item)))
}
}
tbl.RawSetString(k, arr)
default:
tbl.RawSetString(k, lua.LString(fmt.Sprintf("%v", v)))
}
}
return tbl
}
func (v *VM) writeBundledAdapters() error {
entries, err := bundledAdapters.ReadDir("adapters")
if err != nil {
return nil
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
dstPath := filepath.Join(v.adapterDir, entry.Name())
if _, err := os.Stat(dstPath); err == nil {
continue
}
data, err := bundledAdapters.ReadFile(filepath.Join("adapters", entry.Name()))
if err != nil {
continue
}
if err := os.WriteFile(dstPath, data, 0644); err != nil {
return fmt.Errorf("write %s: %w", entry.Name(), err)
}
fmt.Printf("[lua] installed bundled adapter: %s\n", entry.Name())
}
return nil
}
func tableToMap(tbl *lua.LTable) map[string]interface{} {
result := make(map[string]interface{})
tbl.ForEach(func(key lua.LValue, val lua.LValue) {
k := key.String()
switch v := val.(type) {
case lua.LString:
result[k] = string(v)
case lua.LNumber:
result[k] = float64(v)
case lua.LBool:
result[k] = bool(v)
case *lua.LTable:
if v.MaxN() == 0 {
result[k] = tableToMap(v)
} else {
var arr []interface{}
v.ForEach(func(_, item lua.LValue) {
if tbl, ok := item.(*lua.LTable); ok {
arr = append(arr, tableToMap(tbl))
} else {
arr = append(arr, item.String())
}
})
result[k] = arr
}
}
})
return result
}

View File

@ -0,0 +1,388 @@
package document
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
// Doc — 记忆文档:由上下文提炼而来
type Doc struct {
ID string `json:"id"`
Summary string `json:"summary"`
Content string `json:"content"`
Tags []string `json:"tags"`
Entities []string `json:"entities"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Source string `json:"source"` // context / graph / manual
Meta map[string]string `json:"meta,omitempty"`
AccessCount int `json:"access_count"` // 访问次数
LastAccess time.Time `json:"last_access"` // 最后访问时间
}
// Store — 文档记忆存储,包含向量索引
type Store struct {
dir string
vec *vector.Store
veczer *vector.TFIDFVectorizer
mu sync.RWMutex
docs map[string]*Doc
summaries []string // 用于训练向量化器
dirty bool
}
func NewStore(dir string) *Store {
return &Store{
dir: dir,
vec: vector.NewStore(),
veczer: vector.NewTFIDFVectorizer(2),
docs: make(map[string]*Doc),
}
}
func (s *Store) Start() error {
if err := os.MkdirAll(s.dir, 0755); err != nil {
return fmt.Errorf("document store dir: %w", err)
}
if err := s.loadAll(); err != nil {
log.Printf("[document memory] load error: %v", err)
}
log.Printf("[document memory] started with %d docs, %d vectors", len(s.docs), s.vec.Size())
return nil
}
func (s *Store) Stop() {
s.flush()
}
// Insert 创建/更新文档
func (s *Store) Insert(doc *Doc) error {
s.mu.Lock()
defer s.mu.Unlock()
if doc.ID == "" {
doc.ID = fmt.Sprintf("doc_%d", time.Now().UnixNano())
doc.CreatedAt = time.Now()
}
doc.UpdatedAt = time.Now()
doc.LastAccess = time.Now()
if doc.AccessCount == 0 {
doc.AccessCount = 1
}
s.docs[doc.ID] = doc
vec := s.veczer.Vectorize(doc.Summary + " " + doc.Content)
s.vec.Insert(doc.ID, doc.Summary, vec, doc.Meta)
// 更新训练集
s.summaries = append(s.summaries, doc.Summary)
s.dirty = true
return nil
}
// ContextToDoc — 将一段上下文对话历史提炼为文档
func (s *Store) ContextToDoc(source string, entries []ContextEntry) (*Doc, error) {
if len(entries) == 0 {
return nil, nil
}
var parts []string
for _, e := range entries {
line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content)
if e.Response != "" {
line += fmt.Sprintf(" → %s", truncate(e.Response, 100))
}
parts = append(parts, line)
}
content := strings.Join(parts, "\n")
summary := summarizeEntries(entries)
tags := extractTags(entries)
entities := extractEntities(entries)
doc := &Doc{
ID: fmt.Sprintf("doc_%d", time.Now().UnixNano()),
Summary: summary,
Content: content,
Tags: tags,
Entities: entities,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Source: source,
}
if err := s.Insert(doc); err != nil {
return nil, err
}
return doc, nil
}
// Query — 向量相似度查询文档
func (s *Store) Query(text string, topK int) []*Doc {
s.mu.RLock()
defer s.mu.RUnlock()
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 {
d.AccessCount++
d.LastAccess = time.Now()
docs = append(docs, d)
}
}
return docs
}
// Reindex — 重新训练并重建向量索引
func (s *Store) Reindex() {
s.mu.Lock()
defer s.mu.Unlock()
log.Printf("[document memory] reindexing %d docs", len(s.docs))
s.veczer.Train(s.summaries)
s.vec = vector.NewStore()
for _, doc := range s.docs {
vec := s.veczer.Vectorize(doc.Summary + " " + doc.Content)
s.vec.Insert(doc.ID, doc.Summary, vec, doc.Meta)
}
log.Printf("[document memory] reindex complete (%d vectors)", s.vec.Size())
}
func (s *Store) Stats() map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
return map[string]interface{}{
"doc_count": len(s.docs),
"vector_count": s.vec.Size(),
"summary_count": len(s.summaries),
"dir": s.dir,
}
}
// FindColdDocs — 查找冷文档:超过 maxAge 未访问且访问次数 <= minAccess
func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc {
s.mu.RLock()
defer s.mu.RUnlock()
cutoff := time.Now().Add(-maxAge)
var cold []*Doc
for _, d := range s.docs {
if d.AccessCount <= minAccess && d.LastAccess.Before(cutoff) {
cold = append(cold, d)
}
}
return cold
}
func (s *Store) RecentDocs(n int) []*Doc {
s.mu.RLock()
defer s.mu.RUnlock()
var list []*Doc
for _, d := range s.docs {
list = append(list, d)
}
sort.Slice(list, func(i, j int) bool {
return list[i].CreatedAt.After(list[j].CreatedAt)
})
if len(list) > n {
list = list[:n]
}
return list
}
// ——— internal ———
func (s *Store) loadAll() error {
entries, err := os.ReadDir(s.dir)
if err != nil {
return err
}
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".json") || !strings.HasPrefix(e.Name(), "doc_") {
continue
}
path := filepath.Join(s.dir, e.Name())
data, err := os.ReadFile(path)
if err != nil {
continue
}
var doc Doc
if err := json.Unmarshal(data, &doc); err != nil {
continue
}
s.docs[doc.ID] = &doc
s.summaries = append(s.summaries, doc.Summary)
}
// 训练向量化器
if len(s.summaries) > 0 {
s.veczer.Train(s.summaries)
}
// 重建向量索引
for _, doc := range s.docs {
vec := s.veczer.Vectorize(doc.Summary + " " + doc.Content)
s.vec.Insert(doc.ID, doc.Summary, vec, nil)
}
return nil
}
func (s *Store) flush() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.dirty {
return
}
for _, doc := range s.docs {
path := filepath.Join(s.dir, doc.ID+".json")
data, err := json.MarshalIndent(doc, "", " ")
if err != nil {
continue
}
os.WriteFile(path, data, 0644)
}
s.dirty = false
}
type ContextEntry struct {
Timestamp time.Time
Source string
Content string
Response string
}
func summarizeEntries(entries []ContextEntry) string {
if len(entries) == 0 {
return ""
}
sources := make(map[string]int)
var topics []string
for _, e := range entries {
sources[e.Source]++
words := extractKeywords(e.Content)
topics = append(topics, words...)
}
summary := fmt.Sprintf("来自 %d 个来源的 %d 条对话", len(sources), len(entries))
var srcList []string
for s := range sources {
srcList = append(srcList, s)
}
summary += " (" + strings.Join(srcList, ", ") + ")"
if len(topics) > 0 {
seen := make(map[string]bool)
var uniq []string
for _, t := range topics {
if !seen[t] {
seen[t] = true
uniq = append(uniq, t)
}
}
if len(uniq) > 5 {
uniq = uniq[:5]
}
summary += " 涉及: " + strings.Join(uniq, ", ")
}
return summary
}
func extractTags(entries []ContextEntry) []string {
tagSet := make(map[string]bool)
for _, e := range entries {
for _, kw := range extractKeywords(e.Content) {
tagSet[kw] = true
}
}
var tags []string
for t := range tagSet {
if len(tags) >= 10 {
break
}
tags = append(tags, t)
}
return tags
}
func extractEntities(entries []ContextEntry) []string {
// 简易实体提取:提取引号内的内容、粗体/标记词
var entities []string
seen := make(map[string]bool)
for _, e := range entries {
for _, kw := range extractKeywords(e.Content) {
if len(kw) >= 2 && !seen[kw] {
seen[kw] = true
entities = append(entities, kw)
}
}
}
if len(entities) > 20 {
entities = entities[:20]
}
return entities
}
func extractKeywords(text string) []string {
stopWords := map[string]bool{
"的": true, "了": true, "是": true, "在": true, "有": true,
"和": true, "就": true, "不": true, "人": true, "都": true,
"一": true, "一个": true, "上": true, "也": true, "很": true,
"到": true, "说": true, "要": true, "去": true, "你": true,
"会": true, "着": true, "没有": true, "看": true, "好": true,
"自己": true, "这": true, "他": true, "她": true, "它": true,
"什么": true, "怎么": true, "为什么": true, "如何": true,
"我": true, "我们": true, "你们": true, "他们": true, "这个": true,
"那个": true, "可以": true, "吗": true, "吧": true, "啊": true,
}
var keywords []string
runes := []rune(text)
// bi-gram
for i := 0; i < len(runes)-1; i++ {
word := string(runes[i : i+2])
if !stopWords[word] && len(strings.TrimSpace(word)) == len(word) {
keywords = append(keywords, word)
}
}
return keywords
}
func truncate(s string, max int) string {
runes := []rune(s)
if len(runes) > max {
return string(runes[:max]) + "..."
}
return s
}

555
internal/memory/graph.go Normal file
View File

@ -0,0 +1,555 @@
package memory
import (
"database/sql"
"fmt"
"sync"
"time"
_ "github.com/mattn/go-sqlite3"
)
type Entity struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
MentionCount int `json:"mention_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Relation struct {
ID int64 `json:"id"`
SourceID int64 `json:"source_id"`
TargetID int64 `json:"target_id"`
SourceName string `json:"source_name"`
TargetName string `json:"target_name"`
RelationType string `json:"relation_type"`
Confidence float64 `json:"confidence"`
Status string `json:"status"`
SessionID string `json:"session_id"`
TurnID int `json:"turn_id"`
CreatedAt time.Time `json:"created_at"`
DateBucket string `json:"date_bucket"`
}
type Triple struct {
Subject string `json:"subject"`
Relation string `json:"relation"`
Object string `json:"object"`
Confidence float64 `json:"confidence,omitempty"`
SubjectType string `json:"subject_type,omitempty"`
ObjectType string `json:"object_type,omitempty"`
}
type GraphDB struct {
db *sql.DB
mu sync.RWMutex
dbPath string
}
func NewGraphDB(dbPath string) (*GraphDB, error) {
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on")
if err != nil {
return nil, fmt.Errorf("open graph db: %w", err)
}
g := &GraphDB{db: db, dbPath: dbPath}
if err := g.initSchema(); err != nil {
return nil, fmt.Errorf("init schema: %w", err)
}
return g, nil
}
func (g *GraphDB) initSchema() error {
g.mu.Lock()
defer g.mu.Unlock()
tx, err := g.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
schemas := []string{
`CREATE TABLE IF NOT EXISTS entities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
type TEXT DEFAULT 'Concept',
mention_count INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
relation_type TEXT NOT NULL,
confidence REAL DEFAULT 1.0,
status TEXT DEFAULT 'active',
session_id TEXT,
turn_id INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
date_bucket TEXT,
FOREIGN KEY (source_id) REFERENCES entities(id),
FOREIGN KEY (target_id) REFERENCES entities(id)
)`,
`CREATE INDEX IF NOT EXISTS idx_entity_name ON entities(name)`,
`CREATE INDEX IF NOT EXISTS idx_entity_type ON entities(type)`,
`CREATE INDEX IF NOT EXISTS idx_relation_source ON relations(source_id)`,
`CREATE INDEX IF NOT EXISTS idx_relation_target ON relations(target_id)`,
`CREATE INDEX IF NOT EXISTS idx_relation_type ON relations(relation_type)`,
`CREATE INDEX IF NOT EXISTS idx_relation_status ON relations(status)`,
`CREATE INDEX IF NOT EXISTS idx_relation_session ON relations(session_id)`,
}
for _, s := range schemas {
if _, err := tx.Exec(s); err != nil {
return fmt.Errorf("schema exec: %w", err)
}
}
return tx.Commit()
}
func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
g.mu.Lock()
defer g.mu.Unlock()
tx, err := g.db.Begin()
if err != nil {
return 0, 0, err
}
defer tx.Rollback()
entitiesCreated := 0
relationsCreated := 0
dateBucket := time.Now().Format("2006-01-02")
for _, t := range triples {
if t.Subject == "" || t.Relation == "" || t.Object == "" {
continue
}
subjType := t.SubjectType
if subjType == "" {
subjType = "Concept"
}
objType := t.ObjectType
if objType == "" {
objType = "Concept"
}
confidence := t.Confidence
if confidence == 0 {
confidence = 1.0
}
ec, err := g.upsertEntity(tx, t.Subject, subjType)
if err != nil {
return 0, 0, err
}
entitiesCreated += ec
ec, err = g.upsertEntity(tx, t.Object, objType)
if err != nil {
return 0, 0, err
}
entitiesCreated += ec
var sourceID, targetID int64
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Subject).Scan(&sourceID)
if err != nil {
return 0, 0, err
}
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Object).Scan(&targetID)
if err != nil {
return 0, 0, err
}
_, err = tx.Exec(
`INSERT INTO relations (source_id, target_id, relation_type, confidence, session_id, turn_id, date_bucket)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
sourceID, targetID, t.Relation, confidence, sessionID, turnID, dateBucket,
)
if err != nil {
return 0, 0, err
}
relationsCreated++
}
if err := tx.Commit(); err != nil {
return 0, 0, err
}
return entitiesCreated, relationsCreated, nil
}
func (g *GraphDB) upsertEntity(tx *sql.Tx, name string, entityType string) (int, error) {
result, err := tx.Exec(
`INSERT INTO entities (name, type) VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET
mention_count = mention_count + 1,
updated_at = CURRENT_TIMESTAMP`,
name, entityType,
)
if err != nil {
return 0, err
}
rows, _ := result.RowsAffected()
if rows > 0 {
return 1, nil
}
return 0, nil
}
type RecallResult struct {
Entities []Entity `json:"entities"`
Relations []Relation `json:"relations"`
}
func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, sessionFilter string) (*RecallResult, error) {
g.mu.RLock()
defer g.mu.RUnlock()
result := &RecallResult{}
if len(keywords) == 0 && len(seedEntities) == 0 {
rows, err := g.db.Query(
`SELECT id, name, type, mention_count, created_at, updated_at
FROM entities ORDER BY mention_count DESC LIMIT 50`,
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var e Entity
if err := rows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil {
return nil, err
}
result.Entities = append(result.Entities, e)
}
relRows, err := g.db.Query(
`SELECT r.id, r.source_id, r.target_id, e1.name, e2.name,
r.relation_type, r.confidence, r.status, r.session_id,
r.turn_id, r.created_at, COALESCE(r.date_bucket, '')
FROM relations r
JOIN entities e1 ON r.source_id = e1.id
JOIN entities e2 ON r.target_id = e2.id
WHERE r.status = 'active'
ORDER BY r.created_at DESC LIMIT 30`,
)
if err != nil {
return nil, err
}
defer relRows.Close()
for relRows.Next() {
var rel Relation
if err := relRows.Scan(&rel.ID, &rel.SourceID, &rel.TargetID,
&rel.SourceName, &rel.TargetName, &rel.RelationType,
&rel.Confidence, &rel.Status, &rel.SessionID,
&rel.TurnID, &rel.CreatedAt, &rel.DateBucket); err != nil {
return nil, err
}
result.Relations = append(result.Relations, rel)
}
return result, nil
}
entityIDs := make(map[int64]bool)
for _, kw := range keywords {
rows, err := g.db.Query(
`SELECT id, name, type, mention_count, created_at, updated_at
FROM entities WHERE LOWER(name) LIKE ?`,
"%"+kw+"%",
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var e Entity
if err := rows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil {
return nil, err
}
if !entityIDs[e.ID] {
entityIDs[e.ID] = true
result.Entities = append(result.Entities, e)
}
}
}
for _, se := range seedEntities {
row := g.db.QueryRow(
`SELECT id, name, type, mention_count, created_at, updated_at
FROM entities WHERE name = ?`, se)
var e Entity
if err := row.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err == nil {
if !entityIDs[e.ID] {
entityIDs[e.ID] = true
result.Entities = append(result.Entities, e)
}
}
}
if len(entityIDs) == 0 {
return result, nil
}
for depthLevel := 0; depthLevel < depth; depthLevel++ {
ids := make([]interface{}, 0, len(entityIDs))
for id := range entityIDs {
ids = append(ids, id)
}
if len(ids) == 0 {
break
}
query := fmt.Sprintf(
`SELECT r.id, r.source_id, r.target_id, e1.name, e2.name,
r.relation_type, r.confidence, r.status, r.session_id,
r.turn_id, r.created_at, COALESCE(r.date_bucket, '')
FROM relations r
JOIN entities e1 ON r.source_id = e1.id
JOIN entities e2 ON r.target_id = e2.id
WHERE (r.source_id IN (%s) OR r.target_id IN (%s))
AND r.status = 'active'`,
placeholders(len(ids)),
placeholders(len(ids)),
)
allIDs := append(ids, ids...)
if sessionFilter != "" {
query += " AND r.session_id = ?"
allIDs = append(allIDs, sessionFilter)
}
relRows, err := g.db.Query(query, allIDs...)
if err != nil {
return nil, err
}
defer relRows.Close()
newIDs := make(map[int64]bool)
for relRows.Next() {
var rel Relation
if err := relRows.Scan(&rel.ID, &rel.SourceID, &rel.TargetID,
&rel.SourceName, &rel.TargetName, &rel.RelationType,
&rel.Confidence, &rel.Status, &rel.SessionID,
&rel.TurnID, &rel.CreatedAt, &rel.DateBucket); err != nil {
return nil, err
}
result.Relations = append(result.Relations, rel)
if !entityIDs[rel.SourceID] {
newIDs[rel.SourceID] = true
}
if !entityIDs[rel.TargetID] {
newIDs[rel.TargetID] = true
}
}
if len(newIDs) == 0 {
break
}
ids2 := make([]interface{}, 0, len(newIDs))
for id := range newIDs {
ids2 = append(ids2, id)
}
eRows, err := g.db.Query(
fmt.Sprintf(
`SELECT id, name, type, mention_count, created_at, updated_at
FROM entities WHERE id IN (%s)`, placeholders(len(ids2))),
ids2...,
)
if err != nil {
return nil, err
}
defer eRows.Close()
for eRows.Next() {
var e Entity
if err := eRows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil {
return nil, err
}
if !entityIDs[e.ID] {
entityIDs[e.ID] = true
result.Entities = append(result.Entities, e)
}
}
for id := range newIDs {
entityIDs[id] = true
}
}
return result, nil
}
func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) {
g.mu.Lock()
defer g.mu.Unlock()
conds := []string{"r.status = 'active'"}
args := []interface{}{}
if v, ok := criteria["subject_contains"]; ok {
rows, err := g.db.Query("SELECT id FROM entities WHERE name LIKE ?", "%"+v+"%")
if err != nil {
return 0, err
}
defer rows.Close()
var ids []interface{}
for rows.Next() {
var id int64
rows.Scan(&id)
ids = append(ids, id)
}
if len(ids) > 0 {
conds = append(conds, fmt.Sprintf("r.source_id IN (%s)", placeholders(len(ids))))
args = append(args, ids...)
}
}
if v, ok := criteria["target_contains"]; ok {
rows, err := g.db.Query("SELECT id FROM entities WHERE name LIKE ?", "%"+v+"%")
if err != nil {
return 0, err
}
defer rows.Close()
var ids []interface{}
for rows.Next() {
var id int64
rows.Scan(&id)
ids = append(ids, id)
}
if len(ids) > 0 {
conds = append(conds, fmt.Sprintf("r.target_id IN (%s)", placeholders(len(ids))))
args = append(args, ids...)
}
}
if v, ok := criteria["relation_type"]; ok {
conds = append(conds, "r.relation_type = ?")
args = append(args, v)
}
if v, ok := criteria["session_id"]; ok {
conds = append(conds, "r.session_id = ?")
args = append(args, v)
}
if len(conds) == 1 {
return 0, fmt.Errorf("no criteria provided")
}
where := ""
for i, c := range conds {
if i == 0 {
where = c
} else {
where += " AND " + c
}
}
if mode == "hard" {
result, err := g.db.Exec(
fmt.Sprintf(`DELETE FROM relations WHERE %s`, where), args...)
if err != nil {
return 0, err
}
n, _ := result.RowsAffected()
g.db.Exec(`DELETE FROM entities WHERE id NOT IN (
SELECT DISTINCT source_id FROM relations
UNION SELECT DISTINCT target_id FROM relations)`)
return int(n), nil
}
result, err := g.db.Exec(
fmt.Sprintf(`UPDATE relations SET status = 'deleted', updated_at = CURRENT_TIMESTAMP WHERE %s`, where),
args...,
)
if err != nil {
return 0, err
}
n, _ := result.RowsAffected()
return int(n), nil
}
func (g *GraphDB) Introspect() (map[string]interface{}, error) {
g.mu.RLock()
defer g.mu.RUnlock()
var entityCount, relationCount int
g.db.QueryRow("SELECT COUNT(*) FROM entities").Scan(&entityCount)
g.db.QueryRow("SELECT COUNT(*) FROM relations WHERE status = 'active'").Scan(&relationCount)
hotspots := []map[string]interface{}{}
rows, err := g.db.Query(
`SELECT name, mention_count, type FROM entities ORDER BY mention_count DESC LIMIT 10`,
)
if err == nil {
defer rows.Close()
for rows.Next() {
var name, etype string
var count int
if err := rows.Scan(&name, &count, &etype); err == nil {
hotspots = append(hotspots, map[string]interface{}{
"name": name, "count": count, "type": etype,
})
}
}
}
return map[string]interface{}{
"entity_count": entityCount,
"relation_count": relationCount,
"memory_hotspots": hotspots,
}, nil
}
func (g *GraphDB) Archive(days int) (int, error) {
g.mu.Lock()
defer g.mu.Unlock()
result, err := g.db.Exec(
`UPDATE relations SET status = 'archived', updated_at = CURRENT_TIMESTAMP
WHERE status = 'active' AND created_at < datetime('now', ?)`,
fmt.Sprintf("-%d days", days),
)
if err != nil {
return 0, err
}
n, _ := result.RowsAffected()
return int(n), nil
}
func (g *GraphDB) Close() error {
return g.db.Close()
}
func placeholders(n int) string {
if n <= 0 {
return "NULL"
}
b := make([]byte, 0, n*2-1)
for i := 0; i < n; i++ {
if i > 0 {
b = append(b, ',')
}
b = append(b, '?')
}
return string(b)
}

329
internal/memory/indexer.go Normal file
View File

@ -0,0 +1,329 @@
package memory
import (
"fmt"
"log"
"strings"
"sync"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
type Indexer struct {
db *GraphDB
vec *vector.Store
veczer *vector.TFIDFVectorizer
mu sync.RWMutex
trained bool
}
func NewIndexer(db *GraphDB) *Indexer {
return &Indexer{
db: db,
vec: vector.NewStore(),
veczer: vector.NewTFIDFVectorizer(2),
}
}
// Sync 从图数据库中同步实体名到向量索引
func (idx *Indexer) Sync() error {
idx.mu.Lock()
defer idx.mu.Unlock()
if idx.db == nil {
return nil
}
result, err := idx.db.Recall(nil, nil, 1, "")
if err != nil || result == nil {
return err
}
// 收集实体名
var names []string
for _, e := range result.Entities {
names = append(names, e.Name)
}
if len(names) == 0 {
return nil
}
// 训练向量化器
idx.veczer.Train(names)
// 重建向量索引
idx.vec = vector.NewStore()
for _, e := range result.Entities {
vec := idx.veczer.Vectorize(e.Name)
idx.vec.Insert(fmt.Sprintf("entity_%d", e.ID), e.Name, vec, map[string]string{
"type": "entity",
"name": e.Name,
})
}
idx.trained = true
log.Printf("[indexer] synced %d entities to vector index", len(names))
return nil
}
type InjectedContext struct {
Entities []Entity `json:"entities"`
Relations []Relation `json:"relations"`
Summary string `json:"summary"`
TokenEstimate int `json:"token_estimate"`
}
func (idx *Indexer) BuildContext(userInput string) *InjectedContext {
if idx.db == nil {
return &InjectedContext{Summary: ""}
}
// 1. 向量搜索:从实体名向量索引中找到相关实体
vectorEntities := idx.vectorSearchEntities(userInput)
// 2. 关键词搜索:已有逻辑
keywords := extractKeywords(userInput)
if len(keywords) == 0 && len(vectorEntities) == 0 {
keywords = []string{userInput}
}
// 合并关键词和向量找到的实体名
seedNames := make([]string, 0, len(vectorEntities))
for _, e := range vectorEntities {
seedNames = append(seedNames, e.Name)
}
allKeywords := append(keywords, seedNames...)
result, err := idx.db.Recall(allKeywords, nil, 2, "")
if err != nil || result == nil {
return &InjectedContext{Summary: ""}
}
ctx := &InjectedContext{
Entities: result.Entities,
Relations: nil,
}
if len(result.Entities) > 0 {
summary := buildIndexSummary(result.Entities)
ctx.Summary = summary
ctx.TokenEstimate = estimateTokens(summary) + len(result.Entities)*8
} else {
ctx.Summary = ""
}
return ctx
}
// vectorSearchEntities 在实体名向量索引中搜索
func (idx *Indexer) vectorSearchEntities(query string) []Entity {
idx.mu.RLock()
defer idx.mu.RUnlock()
if !idx.trained || idx.vec.Size() == 0 {
return nil
}
queryVec := idx.veczer.Vectorize(query)
results := idx.vec.Search(queryVec, 5)
var entities []Entity
for _, r := range results {
if r.Meta != nil && r.Meta["type"] == "entity" {
entities = append(entities, Entity{Name: r.Meta["name"]})
}
}
return entities
}
func (idx *Indexer) BuildToolPrompt() string {
return `## 图记忆工具
你有以下工具可以操作长期图记忆系统:
### memory_recall
检索与关键词相关的实体和关系。
参数:
- query_intent: 查询关键词,逗号分隔
- depth: 遍历深度默认2
### memory_commit
将三元组写入图记忆。
参数:
- triples: [{"subject": "实体名", "relation": "关系类型", "object": "目标实体"}]
### memory_introspect
查看记忆统计信息。
### memory_purge
删除或修正记忆。
参数:
- criteria: {"subject_contains": "...", "relation_type": "..."}
- mode: "soft" | "supersede"
使用方法:在推理过程中调用对应的 tool系统会自动执行并返回结果。`
}
func (idx *Indexer) FormatContext(ctx *InjectedContext) string {
if ctx == nil || len(ctx.Entities) == 0 {
return ""
}
var b strings.Builder
b.WriteString("【记忆索引】")
if ctx.Summary != "" {
b.WriteString(" ")
b.WriteString(ctx.Summary)
}
b.WriteString(fmt.Sprintf(" 索引: "))
for i, e := range ctx.Entities {
if i >= 5 {
b.WriteString("…")
break
}
if i > 0 {
b.WriteString(", ")
}
b.WriteString(e.Name)
if e.Type != "Concept" {
b.WriteString("(" + e.Type + ")")
}
}
b.WriteString(" | 需更多细节请用 memory_recall 查询")
return b.String()
}
func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
return []map[string]interface{}{
{
"type": "function",
"function": map[string]interface{}{
"name": "memory_recall",
"description": "检索图记忆。输入查询意图关键词,返回相关实体和关系。",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query_intent": map[string]interface{}{
"type": "string",
"description": "查询意图,支持逗号分隔多个关键词",
},
"depth": map[string]interface{}{
"type": "integer",
"description": "遍历深度默认2",
"default": 2,
},
},
"required": []string{"query_intent"},
},
},
},
{
"type": "function",
"function": map[string]interface{}{
"name": "memory_commit",
"description": "写入图记忆。将三元组列表写入长期记忆。",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"triples": map[string]interface{}{
"type": "array",
"description": "三元组列表",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"subject": map[string]interface{}{"type": "string"},
"relation": map[string]interface{}{"type": "string"},
"object": map[string]interface{}{"type": "string"},
},
"required": []string{"subject", "relation", "object"},
},
},
},
"required": []string{"triples"},
},
},
},
{
"type": "function",
"function": map[string]interface{}{
"name": "memory_introspect",
"description": "查看图记忆统计信息:实体数量、关系数量、热点实体。",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
},
}
}
func extractKeywords(input string) []string {
stopWords := map[string]bool{
"的": true, "了": true, "是": true, "在": true, "有": true,
"和": true, "就": true, "不": true, "人": true, "都": true,
"一": true, "一个": true, "上": true, "也": true, "很": true,
"到": true, "说": true, "要": true, "去": true, "你": true,
"会": true, "着": true, "没有": true, "看": true, "好": true,
"自己": true, "这": true, "他": true, "她": true, "它": true,
"什么": true, "怎么": true, "为什么": true, "如何": true,
}
var keywords []string
seen := make(map[string]bool)
runes := []rune(input)
bigram := []rune{}
for _, r := range runes {
bigram = append(bigram, r)
if len(bigram) >= 2 {
word := string(bigram)
if !stopWords[word] && !seen[word] {
seen[word] = true
keywords = append(keywords, word)
}
bigram = bigram[1:]
}
}
if len(keywords) == 0 && len(runes) > 0 {
keywords = []string{string(runes)}
}
if len(keywords) > 5 {
keywords = keywords[:5]
}
return keywords
}
func buildIndexSummary(entities []Entity) string {
if len(entities) == 0 {
return ""
}
var b strings.Builder
b.WriteString(fmt.Sprintf("关联 %d 个记忆实体", len(entities)))
topN := 3
if len(entities) < topN {
topN = len(entities)
}
b.WriteString(",高频:")
for i := 0; i < topN; i++ {
if i > 0 {
b.WriteString("、")
}
b.WriteString(entities[i].Name)
}
return b.String()
}
func estimateTokens(s string) int {
return len(s) / 2
}

View File

@ -0,0 +1,291 @@
package pipeline
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
type RawRecord struct {
ID int64 `json:"id"`
SessionID string `json:"session_id"`
Role string `json:"role"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
Distilled bool `json:"distilled"`
}
type DistillerConfig struct {
Interval time.Duration `json:"interval"`
RetentionDays int `json:"retention_days"`
BatchSize int `json:"batch_size"`
}
type Distiller struct {
mu sync.Mutex
db *memory.GraphDB
rawPath string
records []RawRecord
nextID int64
cfg DistillerConfig
ctx context.Context
cancel context.CancelFunc
onMemory func(input, response string)
}
func NewDistiller(db *memory.GraphDB, dataDir string, cfg DistillerConfig) *Distiller {
ctx, cancel := context.WithCancel(context.Background())
return &Distiller{
db: db,
rawPath: filepath.Join(dataDir, "memory", "raw"),
cfg: cfg,
ctx: ctx,
cancel: cancel,
}
}
func (d *Distiller) OnMemoryCandidate(fn func(input, response string)) {
d.onMemory = fn
}
func (d *Distiller) Start() {
if err := os.MkdirAll(d.rawPath, 0755); err != nil {
log.Printf("[memory] create raw path: %v", err)
}
d.loadExisting()
log.Printf("[memory] distiller started (interval: %v, retention: %d days)", d.cfg.Interval, d.cfg.RetentionDays)
go d.distillLoop()
}
func (d *Distiller) Stop() {
d.cancel()
d.flush()
}
func (d *Distiller) Append(sessionID string, role string, content string) {
d.mu.Lock()
defer d.mu.Unlock()
d.nextID++
d.records = append(d.records, RawRecord{
ID: d.nextID, SessionID: sessionID, Role: role,
Content: content, CreatedAt: time.Now(),
})
}
func (d *Distiller) flush() {
d.mu.Lock()
defer d.mu.Unlock()
if len(d.records) == 0 {
return
}
path := filepath.Join(d.rawPath, fmt.Sprintf("raw_%d.jsonl", time.Now().UnixNano()))
f, err := os.Create(path)
if err != nil {
log.Printf("[memory] flush error: %v", err)
return
}
defer f.Close()
for _, r := range d.records {
line := fmt.Sprintf("%d\t%s\t%s\t%s\t%d\n", r.ID, r.SessionID, r.Role, r.Content, r.CreatedAt.Unix())
f.WriteString(line)
}
}
func (d *Distiller) loadExisting() {
entries, err := os.ReadDir(d.rawPath)
if err != nil {
return
}
for _, entry := range entries {
if filepath.Ext(entry.Name()) != ".jsonl" {
continue
}
path := filepath.Join(d.rawPath, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
continue
}
for _, line := range parseLines(string(data)) {
parts := splitLine(line)
if len(parts) >= 4 {
d.records = append(d.records, RawRecord{
ID: d.nextID, SessionID: parts[1], Role: parts[2], Content: parts[3],
})
d.nextID++
}
}
}
}
func (d *Distiller) distillLoop() {
ticker := time.NewTicker(d.cfg.Interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
d.distillOnce()
case <-d.ctx.Done():
return
}
}
}
func (d *Distiller) distillOnce() {
d.mu.Lock()
cutoff := time.Now().AddDate(0, 0, -d.cfg.RetentionDays)
var toDistill []RawRecord
var remaining []RawRecord
for _, r := range d.records {
if r.CreatedAt.Before(cutoff) && !r.Distilled {
toDistill = append(toDistill, r)
} else {
remaining = append(remaining, r)
}
}
d.records = remaining
d.mu.Unlock()
if len(toDistill) == 0 {
return
}
batchSize := d.cfg.BatchSize
if batchSize <= 0 {
batchSize = 50
}
for i := 0; i < len(toDistill); i += batchSize {
end := i + batchSize
if end > len(toDistill) {
end = len(toDistill)
}
d.distillBatch(toDistill[i:end])
}
d.cleanupRawFiles()
log.Printf("[memory] distilled %d records", len(toDistill))
}
func (d *Distiller) distillBatch(batch []RawRecord) {
var userContent, assistantContent string
sessionIDs := make(map[string]bool)
for _, r := range batch {
sessionIDs[r.SessionID] = true
if r.Role == "user" {
userContent += r.Content + " "
} else {
assistantContent += r.Content + " "
}
}
triples := extractKeyTriples(userContent, assistantContent)
if len(triples) > 0 {
sessionID := ""
for sid := range sessionIDs {
sessionID = sid
break
}
if _, _, err := d.db.Commit(triples, sessionID, 0); err != nil {
log.Printf("[memory] distill commit: %v", err)
}
}
}
func (d *Distiller) cleanupRawFiles() {
entries, err := os.ReadDir(d.rawPath)
if err != nil {
return
}
cutoff := time.Now().AddDate(0, 0, -(d.cfg.RetentionDays + 1))
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
os.Remove(filepath.Join(d.rawPath, entry.Name()))
}
}
}
func extractKeyTriples(userContent, assistantContent string) []memory.Triple {
var triples []memory.Triple
if len(userContent) > 0 && len(userContent) < 500 {
triples = append(triples, memory.Triple{Subject: "用户", Relation: "提及", Object: truncate(userContent, 200)})
}
if len(assistantContent) > 0 && len(assistantContent) < 500 {
triples = append(triples, memory.Triple{Subject: "AI", Relation: "回应", Object: truncate(assistantContent, 200)})
}
return triples
}
func truncate(s string, max int) string {
if len(s) > max {
return s[:max] + "..."
}
return s
}
func parseLines(data string) []string {
var lines []string
current := ""
for _, ch := range data {
if ch == '\n' {
if current != "" {
lines = append(lines, current)
}
current = ""
} else {
current += string(ch)
}
}
if current != "" {
lines = append(lines, current)
}
return lines
}
func splitLine(line string) []string {
var parts []string
current := ""
for _, ch := range line {
if ch == '\t' {
parts = append(parts, current)
current = ""
} else {
current += string(ch)
}
}
if current != "" {
parts = append(parts, current)
}
return parts
}
func (d *Distiller) GetRecentRecords(limit int) []RawRecord {
d.mu.Lock()
defer d.mu.Unlock()
n := len(d.records)
if n == 0 {
return nil
}
if limit > 0 && limit < n {
n = limit
}
result := make([]RawRecord, n)
copy(result, d.records[len(d.records)-n:])
return result
}
func (d *Distiller) Stats() map[string]interface{} {
d.mu.Lock()
defer d.mu.Unlock()
return map[string]interface{}{
"raw_records": len(d.records),
"interval": d.cfg.Interval.String(),
"retention_days": d.cfg.RetentionDays,
}
}

View File

@ -0,0 +1,241 @@
package text
import (
"bufio"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// Event — 原始 I/O 事件记录,写入 JSONL
type Event struct {
Timestamp int64 `json:"ts"`
Source string `json:"source"`
Input string `json:"input"`
Response string `json:"response,omitempty"`
ToolsUsed []string `json:"tools_used,omitempty"`
AgentID string `json:"agent_id,omitempty"`
}
// Memory — 文本记忆:追加写 JSONL按时间/大小旋转
type Memory struct {
dir string
interval time.Duration
maxSize int64
mu sync.Mutex
current *os.File
encoder *json.Encoder
created time.Time
size int64
stopCh chan struct{}
}
type Option func(*Memory)
func WithRotationInterval(d time.Duration) Option {
return func(m *Memory) { m.interval = d }
}
func WithMaxSizeBytes(n int64) Option {
return func(m *Memory) { m.maxSize = n }
}
func New(dir string, opts ...Option) *Memory {
m := &Memory{
dir: dir,
interval: 24 * time.Hour,
maxSize: 10 * 1024 * 1024,
stopCh: make(chan struct{}),
}
for _, opt := range opts {
opt(m)
}
return m
}
func (m *Memory) Start() error {
if err := os.MkdirAll(m.dir, 0755); err != nil {
return fmt.Errorf("text memory dir: %w", err)
}
if err := m.openCurrent(); err != nil {
return err
}
go m.rotationLoop()
return nil
}
func (m *Memory) Stop() {
close(m.stopCh)
m.mu.Lock()
if m.current != nil {
m.current.Close()
}
m.mu.Unlock()
}
func (m *Memory) Append(evt Event) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.needRotate() {
m.rotateLocked()
}
if err := m.encoder.Encode(evt); err != nil {
return fmt.Errorf("encode event: %w", err)
}
m.current.Sync()
return nil
}
func (m *Memory) needRotate() bool {
return time.Since(m.created) > m.interval || m.size > m.maxSize
}
func (m *Memory) rotateLocked() {
if m.current != nil {
m.current.Close()
}
m.openCurrent()
}
func (m *Memory) openCurrent() error {
now := time.Now()
name := fmt.Sprintf("text_%s.jsonl", now.Format("2006-01-02_15-04-05"))
path := filepath.Join(m.dir, name)
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("open text log %s: %w", path, err)
}
stat, _ := f.Stat()
m.current = f
m.encoder = json.NewEncoder(f)
m.created = now
m.size = stat.Size()
return nil
}
func (m *Memory) rotationLoop() {
ticker := time.NewTicker(m.interval / 2)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.mu.Lock()
if m.needRotate() {
m.rotateLocked()
log.Printf("[text memory] rotated log file")
}
m.mu.Unlock()
case <-m.stopCh:
return
}
}
}
// Replay — 从 JSONL 文件流式回放事件
func (m *Memory) Replay(fn func(Event) error) error {
m.mu.Lock()
files, err := m.listFiles()
m.mu.Unlock()
if err != nil {
return err
}
for _, fpath := range files {
if err := m.replayFile(fpath, fn); err != nil {
return err
}
}
return nil
}
func (m *Memory) replayFile(path string, fn func(Event) error) error {
f, err := os.Open(path)
if err != nil {
return 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 err := fn(evt); err != nil {
return err
}
}
return scanner.Err()
}
func (m *Memory) listFiles() ([]string, error) {
entries, err := os.ReadDir(m.dir)
if err != nil {
return nil, err
}
var files []string
for _, e := range entries {
if strings.HasPrefix(e.Name(), "text_") && strings.HasSuffix(e.Name(), ".jsonl") {
files = append(files, filepath.Join(m.dir, e.Name()))
}
}
sort.Strings(files)
return files, nil
}
// RecentEvents — 返回最近 n 条事件(跨所有文件的最新事件)
func (m *Memory) RecentEvents(n int) ([]Event, error) {
var all []Event
err := m.Replay(func(evt Event) error {
all = append(all, evt)
return nil
})
if err != nil {
return nil, err
}
if len(all) > n {
all = all[len(all)-n:]
}
return all, nil
}
func (m *Memory) FileCount() int {
m.mu.Lock()
defer m.mu.Unlock()
files, err := m.listFiles()
if err != nil {
return 0
}
return len(files)
}
func (m *Memory) Stats() map[string]interface{} {
m.mu.Lock()
defer m.mu.Unlock()
files, _ := m.listFiles()
return map[string]interface{}{
"file_count": len(files),
"current_size": m.size,
"rotation_bytes": m.maxSize,
"rotation_interval": m.interval.String(),
"dir": m.dir,
}
}

View File

@ -0,0 +1,302 @@
package vector
import (
"math"
"sort"
"strings"
"sync"
)
// Vectorizer 接口:将文本转为向量
type Vectorizer interface {
Vectorize(text string) Vector
}
// Vector 是带权特征映射feature → weight
type Vector map[string]float64
// Store 向量存储,支持近似查询
type Store struct {
mu sync.RWMutex
docs []DocVector
dim int
index *InvertedIndex
}
type DocVector struct {
ID string
Vector Vector
Text string
Meta map[string]string
}
func NewStore() *Store {
return &Store{
index: NewInvertedIndex(),
}
}
func (s *Store) Insert(id, text string, vec Vector, meta map[string]string) {
s.mu.Lock()
defer s.mu.Unlock()
s.docs = append(s.docs, DocVector{
ID: id, Vector: vec, Text: text, Meta: meta,
})
s.index.Add(id, vec)
}
func (s *Store) Remove(id string) {
s.mu.Lock()
defer s.mu.Unlock()
filtered := make([]DocVector, 0, len(s.docs))
for _, d := range s.docs {
if d.ID != id {
filtered = append(filtered, d)
}
}
s.docs = filtered
s.index.Remove(id)
}
func (s *Store) Search(query Vector, topK int) []DocVector {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.docs) == 0 || len(query) == 0 {
return nil
}
candidates := s.index.Search(query, len(s.docs))
type scored struct {
doc DocVector
score float64
}
var results []scored
seen := make(map[string]bool)
for _, id := range candidates {
if seen[id] {
continue
}
seen[id] = true
for _, d := range s.docs {
if d.ID == id {
score := CosineSimilarity(query, d.Vector)
if score > 0 {
results = append(results, scored{d, score})
}
break
}
}
}
sort.Slice(results, func(i, j int) bool {
return results[i].score > results[j].score
})
if len(results) > topK {
results = results[:topK]
}
out := make([]DocVector, len(results))
for i, r := range results {
out[i] = r.doc
}
return out
}
func (s *Store) Size() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.docs)
}
func (s *Store) All() []DocVector {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]DocVector, len(s.docs))
copy(out, s.docs)
return out
}
// TFIDFVectorizer 使用字符 bigram + TF-IDF
type TFIDFVectorizer struct {
mu sync.RWMutex
docFreq map[string]float64 // feature → 文档频率
totalDocs int
maxNGram int
}
func NewTFIDFVectorizer(maxNGram int) *TFIDFVectorizer {
if maxNGram <= 0 {
maxNGram = 2
}
return &TFIDFVectorizer{
docFreq: make(map[string]float64),
maxNGram: maxNGram,
}
}
func (v *TFIDFVectorizer) Train(docs []string) {
v.mu.Lock()
defer v.mu.Unlock()
v.docFreq = make(map[string]float64)
v.totalDocs = len(docs)
seen := make(map[string]map[string]bool)
for _, doc := range docs {
features := extractNGrams(doc, v.maxNGram)
key := doc
if seen[key] == nil {
seen[key] = make(map[string]bool)
}
for _, f := range features {
if !seen[key][f] {
seen[key][f] = true
v.docFreq[f]++
}
}
}
}
func (v *TFIDFVectorizer) Vectorize(text string) Vector {
v.mu.RLock()
defer v.mu.RUnlock()
features := extractNGrams(text, v.maxNGram)
tf := make(map[string]float64)
for _, f := range features {
tf[f]++
}
maxTF := 0.0
for _, c := range tf {
if c > maxTF {
maxTF = c
}
}
vec := make(Vector)
for f, count := range tf {
tfNorm := count / maxTF
idf := 1.0
if v.totalDocs > 0 {
df := v.docFreq[f]
if df > 0 {
idf = math.Log(float64(v.totalDocs+1)/df+1) + 1
}
}
vec[f] = tfNorm * idf
}
return vec
}
// extractNGrams 提取 n-gram 特征(主要用于中文)
func extractNGrams(text string, maxN int) []string {
runes := []rune(strings.ToLower(text))
var features []string
seen := make(map[string]bool)
for n := 1; n <= maxN; n++ {
for i := 0; i <= len(runes)-n; i++ {
gram := string(runes[i : i+n])
gram = strings.TrimSpace(gram)
if gram == "" {
continue
}
if !seen[gram] {
seen[gram] = true
features = append(features, gram)
}
}
}
return features
}
func CosineSimilarity(a, b Vector) float64 {
var dot, normA, normB float64
for f, va := range a {
dot += va * b[f]
normA += va * va
}
for _, vb := range b {
normB += vb * vb
}
if normA == 0 || normB == 0 {
return 0
}
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
}
// InvertedIndex 倒排索引,加速向量搜索
type InvertedIndex struct {
mu sync.RWMutex
postings map[string]map[string]float64 // feature → {docID: weight}
}
func NewInvertedIndex() *InvertedIndex {
return &InvertedIndex{
postings: make(map[string]map[string]float64),
}
}
func (idx *InvertedIndex) Add(docID string, vec Vector) {
idx.mu.Lock()
defer idx.mu.Unlock()
for feature, weight := range vec {
if idx.postings[feature] == nil {
idx.postings[feature] = make(map[string]float64)
}
idx.postings[feature][docID] = weight
}
}
func (idx *InvertedIndex) Remove(docID string) {
idx.mu.Lock()
defer idx.mu.Unlock()
for feature, postings := range idx.postings {
delete(postings, docID)
if len(postings) == 0 {
delete(idx.postings, feature)
}
}
}
func (idx *InvertedIndex) Search(query Vector, maxResults int) []string {
idx.mu.RLock()
defer idx.mu.RUnlock()
scores := make(map[string]float64)
for feature, qw := range query {
if postings, ok := idx.postings[feature]; ok {
for docID, dw := range postings {
scores[docID] += qw * dw
}
}
}
type pair struct {
id string
score float64
}
var sorted []pair
for id, score := range scores {
sorted = append(sorted, pair{id, score})
}
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].score > sorted[j].score
})
if len(sorted) > maxResults {
sorted = sorted[:maxResults]
}
out := make([]string, len(sorted))
for i, p := range sorted {
out[i] = p.id
}
return out
}

174
internal/network/monitor.go Normal file
View File

@ -0,0 +1,174 @@
package network
import (
"context"
"fmt"
"net"
"net/http"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
type Monitor struct {
mu sync.RWMutex
client *http.Client
interval time.Duration
endpoints []string
status []EndpointStatus
}
type EndpointStatus struct {
URL string
Reachable bool
Latency time.Duration
LastCheck time.Time
Error string
}
func NewMonitor(interval time.Duration) *Monitor {
return &Monitor{
client: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
DisableKeepAlives: false,
MaxIdleConns: 2,
IdleConnTimeout: 30 * time.Second,
},
},
interval: interval,
}
}
func (m *Monitor) Start(ctx context.Context, endpoints []string) {
m.mu.Lock()
m.endpoints = endpoints
m.status = make([]EndpointStatus, len(endpoints))
for i, ep := range endpoints {
m.status[i] = EndpointStatus{URL: ep, Reachable: false}
}
m.mu.Unlock()
ticker := time.NewTicker(m.interval)
defer ticker.Stop()
m.checkAll(ctx)
for {
select {
case <-ticker.C:
m.checkAll(ctx)
case <-ctx.Done():
return
}
}
}
func (m *Monitor) CheckOnce(ctx context.Context, endpoint string) EndpointStatus {
start := time.Now()
req, err := http.NewRequestWithContext(ctx, "HEAD", endpoint, nil)
if err != nil {
return EndpointStatus{URL: endpoint, Reachable: false, Error: err.Error(), LastCheck: time.Now()}
}
resp, err := m.client.Do(req)
latency := time.Since(start)
if err != nil {
return EndpointStatus{URL: endpoint, Reachable: false, Latency: latency, Error: err.Error(), LastCheck: time.Now()}
}
resp.Body.Close()
return EndpointStatus{
URL: endpoint,
Reachable: resp.StatusCode < 500,
Latency: latency,
LastCheck: time.Now(),
}
}
func (m *Monitor) checkAll(ctx context.Context) {
m.mu.RLock()
endpoints := m.endpoints
m.mu.RUnlock()
var wg sync.WaitGroup
results := make([]EndpointStatus, len(endpoints))
for i, ep := range endpoints {
wg.Add(1)
go func(idx int, url string) {
defer wg.Done()
results[idx] = m.CheckOnce(ctx, url)
}(i, ep)
}
wg.Wait()
m.mu.Lock()
m.status = results
m.mu.Unlock()
}
func (m *Monitor) Status() []EndpointStatus {
m.mu.RLock()
defer m.mu.RUnlock()
status := make([]EndpointStatus, len(m.status))
copy(status, m.status)
return status
}
func (m *Monitor) AllReachable() bool {
m.mu.RLock()
defer m.mu.RUnlock()
for _, s := range m.status {
if !s.Reachable {
return false
}
}
return len(m.status) > 0
}
func (m *Monitor) AggregateResult() types.NetworkCheckResult {
m.mu.RLock()
defer m.mu.RUnlock()
result := types.NetworkCheckResult{LLMAPIReachable: true, DNSResolving: true}
var totalLatency time.Duration
checked := 0
for _, s := range m.status {
if !s.Reachable {
result.LLMAPIReachable = false
result.Error = fmt.Sprintf("endpoint %s unreachable: %s", s.URL, s.Error)
}
if s.Latency > 0 {
totalLatency += s.Latency
checked++
}
}
if checked > 0 {
result.Latency = totalLatency / time.Duration(checked)
}
result.DNSResolving = m.checkDNS()
return result
}
func (m *Monitor) checkDNS() bool {
_, err := net.LookupHost("google.com")
if err != nil {
_, err = net.LookupHost("baidu.com")
}
return err == nil
}

244
internal/onebot/client.go Normal file
View File

@ -0,0 +1,244 @@
package onebot
import (
"encoding/json"
"fmt"
"log"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
// EventHandler 处理 OneBot 推送的事件
type EventHandler func(event *Event)
// Client 是 OneBot 反向 WebSocket 客户端
// 连接到 OneBot 兼容前端(如 go-cqhttp、Lagrange的 WS 地址
type Client struct {
url string
accessToken string
conn *websocket.Conn
mu sync.Mutex
done chan struct{}
eventHandler EventHandler
// 等待响应的 Action 调用
pending sync.Map
echoCount int64
connected atomic.Bool
}
// NewClient 创建 OneBot 反向 WS 客户端
// wsURL: ws://host:port/onebot/v11/ws
func NewClient(wsURL, accessToken string) *Client {
return &Client{
url: wsURL,
accessToken: accessToken,
done: make(chan struct{}),
}
}
// SetEventHandler 注册事件处理函数
func (c *Client) SetEventHandler(h EventHandler) {
c.eventHandler = h
}
// Connected 返回是否已连接
func (c *Client) Connected() bool {
return c.connected.Load()
}
// Connect 连接到 OneBot 前端(阻塞直到连接建立或失败)
func (c *Client) Connect() error {
header := make(map[string][]string)
if c.accessToken != "" {
header["Authorization"] = []string{"Bearer " + c.accessToken}
}
conn, _, err := websocket.DefaultDialer.Dial(c.url, header)
if err != nil {
return err
}
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
}
c.conn = conn
c.mu.Unlock()
c.connected.Store(true)
log.Printf("[onebot] connected to %s", c.url)
go c.readLoop()
return nil
}
// reconnect 自动重连
func (c *Client) reconnect() {
c.connected.Store(false)
backoff := time.Second
for {
select {
case <-c.done:
return
case <-time.After(backoff):
log.Printf("[onebot] reconnecting in %v...", backoff)
if err := c.Connect(); err != nil {
log.Printf("[onebot] reconnect failed: %v, retry", err)
backoff *= 2
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
continue
}
log.Printf("[onebot] reconnected")
return
}
}
}
func (c *Client) readLoop() {
defer c.connected.Store(false)
defer c.mu.Lock()
defer c.mu.Unlock()
defer func() {
if c.conn != nil {
c.conn.Close()
}
}()
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
log.Printf("[onebot] read error: %v", err)
go c.reconnect()
return
}
// 尝试解析为 ActionResponse有 echo 字段)
var resp ActionResponse
if err := json.Unmarshal(message, &resp); err == nil && resp.Echo != "" {
if ch, ok := c.pending.Load(resp.Echo); ok {
ch.(chan *ActionResponse) <- &resp
}
continue
}
// 解析为 Event
var evt Event
if err := json.Unmarshal(message, &evt); err != nil {
log.Printf("[onebot] parse error: %v", err)
continue
}
if c.eventHandler != nil {
c.eventHandler(&evt)
}
}
}
// SendAction 发送一个 OneBot API 请求并等待响应
func (c *Client) SendAction(action string, params map[string]interface{}, timeout time.Duration) (*ActionResponse, error) {
echo := atomic.AddInt64(&c.echoCount, 1)
echoStr := formatInt64(echo)
msg := Action{
Action: action,
Params: params,
Echo: echoStr,
}
data, err := json.Marshal(msg)
if err != nil {
return nil, err
}
ch := make(chan *ActionResponse, 1)
c.pending.Store(echoStr, ch)
defer c.pending.Delete(echoStr)
c.mu.Lock()
if c.conn == nil {
c.mu.Unlock()
return nil, fmt.Errorf("not connected")
}
err = c.conn.WriteMessage(websocket.TextMessage, data)
c.mu.Unlock()
if err != nil {
return nil, err
}
if timeout <= 0 {
timeout = 10 * time.Second
}
select {
case resp := <-ch:
if resp.Status == "failed" {
return resp, fmt.Errorf("onebot action %s failed: retcode=%d", action, resp.RetCode)
}
return resp, nil
case <-time.After(timeout):
return nil, fmt.Errorf("onebot action %s timeout", action)
}
}
// SendPrivateMessage 发送私聊消息(便捷方法)
func (c *Client) SendPrivateMessage(userID int64, message interface{}, autoEscape bool) (*ActionResponse, error) {
params := map[string]interface{}{
"user_id": userID,
"message": message,
"auto_escape": autoEscape,
}
return c.SendAction("send_private_msg", params, 0)
}
// SendGroupMessage 发送群消息(便捷方法)
func (c *Client) SendGroupMessage(groupID int64, message interface{}, autoEscape bool) (*ActionResponse, error) {
params := map[string]interface{}{
"group_id": groupID,
"message": message,
"auto_escape": autoEscape,
}
return c.SendAction("send_group_msg", params, 0)
}
// GetLoginInfo 获取登录号信息
func (c *Client) GetLoginInfo() (*ActionResponse, error) {
return c.SendAction("get_login_info", nil, 0)
}
// GetGroupMemberInfo 获取群成员信息
func (c *Client) GetGroupMemberInfo(groupID, userID int64) (*ActionResponse, error) {
return c.SendAction("get_group_member_info", map[string]interface{}{
"group_id": groupID,
"user_id": userID,
}, 0)
}
// GetGroupList 获取群列表
func (c *Client) GetGroupList() (*ActionResponse, error) {
return c.SendAction("get_group_list", nil, 0)
}
// Close 关闭连接
func (c *Client) Close() error {
close(c.done)
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
return c.conn.Close()
}
return nil
}
func formatInt64(n int64) string {
return fmt.Sprintf("%d", n)
}

222
internal/onebot/device.go Normal file
View File

@ -0,0 +1,222 @@
package onebot
import (
"encoding/json"
"fmt"
"log"
"strings"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// Device 将 OneBot 客户端包装为 IO 抽象层的 Device
// 作为 QQ 通道与 HomeAgent 之间的桥梁
type Device struct {
name string
desc string
client *Client
iom *agentIO.IOManager
}
// NewDevice 创建 OneBot IO 设备
// name: 设备名称(如 "qq"
// wsURL: OneBot 前端 WebSocket 地址(如 "ws://127.0.0.1:6700"
// accessToken: OneBot 鉴权令牌(可选)
func NewDevice(name, wsURL, accessToken string, iom *agentIO.IOManager) *Device {
return &Device{
name: name,
desc: fmt.Sprintf("OneBot 标准 QQ 通道 (%s)", wsURL),
client: NewClient(wsURL, accessToken),
iom: iom,
}
}
// Name 返回设备名称
func (d *Device) Name() string { return d.name }
// Type 返回设备类型(双向 IO
func (d *Device) Type() agentIO.DeviceType { return agentIO.DeviceIO }
// Description 返回设备描述
func (d *Device) Description() string { return d.desc }
// OutputCapabilities 返回输出能力(文本+文件+图片)
func (d *Device) OutputCapabilities() agentIO.OutputCapability {
return agentIO.CapText | agentIO.CapFile | agentIO.CapImage
}
// Tools 返回 OneBot 标准 API 的工具定义
// AI 可以通过这些工具调用 OneBot 功能
func (d *Device) Tools() []agentIO.ToolDef {
return []agentIO.ToolDef{
{
Name: d.name + "_send_private_msg",
Description: "发送 QQ 私聊消息",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"user_id": map[string]interface{}{"type": "integer", "description": "目标 QQ 号"},
"message": map[string]interface{}{"type": "string", "description": "消息内容(支持 CQ 码,如 [CQ:image,file=xxx.jpg]"},
"auto_escape": map[string]interface{}{"type": "boolean", "description": "是否作为纯文本发送(不解析 CQ 码)"},
},
"required": []string{"user_id", "message"},
},
},
{
Name: d.name + "_send_group_msg",
Description: "发送 QQ 群消息",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "目标群号"},
"message": map[string]interface{}{"type": "string", "description": "消息内容(支持 CQ 码)"},
"auto_escape": map[string]interface{}{"type": "boolean", "description": "是否作为纯文本发送"},
},
"required": []string{"group_id", "message"},
},
},
{
Name: d.name + "_get_group_member_info",
Description: "获取 QQ 群成员信息",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "群号"},
"user_id": map[string]interface{}{"type": "integer", "description": "QQ 号"},
},
"required": []string{"group_id", "user_id"},
},
},
{
Name: d.name + "_get_group_list",
Description: "获取 QQ 群列表",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
}
}
// Execute 执行 OneBot 工具调用
func (d *Device) Execute(tool string, args map[string]interface{}) (interface{}, error) {
if !d.client.Connected() {
return nil, fmt.Errorf("onebot 未连接到前端")
}
// 去掉名称前缀以匹配方法名
method := strings.TrimPrefix(tool, d.name+"_")
switch method {
case "send_private_msg":
userID, _ := toInt64(args["user_id"])
message, _ := args["message"].(string)
autoEscape, _ := args["auto_escape"].(bool)
return d.client.SendPrivateMessage(userID, message, autoEscape)
case "send_group_msg":
groupID, _ := toInt64(args["group_id"])
message, _ := args["message"].(string)
autoEscape, _ := args["auto_escape"].(bool)
return d.client.SendGroupMessage(groupID, message, autoEscape)
case "get_group_member_info":
groupID, _ := toInt64(args["group_id"])
userID, _ := toInt64(args["user_id"])
return d.client.GetGroupMemberInfo(groupID, userID)
case "get_group_list":
return d.client.GetGroupList()
default:
return nil, fmt.Errorf("unknown onebot tool: %s", tool)
}
}
// Start 连接到 OneBot 前端
func (d *Device) Start() error {
// 非阻塞连接
go func() {
if err := d.client.Connect(); err != nil {
log.Printf("[onebot] %s initial connect failed, will retry: %v", d.name, err)
}
}()
// 注册事件处理OneBot 事件 → IO InputEvent
d.client.SetEventHandler(func(evt *Event) {
d.handleEvent(evt)
})
return nil
}
// Stop 断开连接
func (d *Device) Stop() error {
return d.client.Close()
}
// handleEvent 将 OneBot 事件转换为 IO InputEvent
func (d *Device) handleEvent(evt *Event) {
if d.iom == nil {
return
}
switch evt.PostType {
case "message":
var text string
if evt.RawMessage != "" {
text = evt.RawMessage
} else if s, ok := evt.Message.(string); ok {
text = s
}
if text == "" {
return
}
// 构造输入源标识
source := d.name
payload := map[string]interface{}{
"content": text,
"source": source,
"user_id": evt.UserID,
"sender": evt.Sender,
}
if evt.MessageType == "group" {
payload["group_id"] = evt.GroupID
payload["label"] = fmt.Sprintf("group:%d:%d", evt.GroupID, evt.UserID)
} else {
payload["label"] = fmt.Sprintf("private:%d", evt.UserID)
}
d.iom.InjectText(source, text)
// 同时注册输出路由QQ 消息默认回复到 QQ 通道
d.iom.RegisterOutputRoute(source, d.name)
case "notice":
log.Printf("[onebot] notice from %s: type=%s", d.name, evt.NoticeType)
case "request":
log.Printf("[onebot] request from %s: type=%s flag=%s", d.name, evt.RequestType, evt.Flag)
case "meta_event":
if evt.MetaEventType == "heartbeat" {
log.Printf("[onebot] %s heartbeat: online=%v", d.name, evt.Status != nil && evt.Status.Online)
}
}
}
func toInt64(v interface{}) (int64, bool) {
switch n := v.(type) {
case int64:
return n, true
case float64:
return int64(n), true
case int:
return int64(n), true
case json.Number:
i, err := n.Int64()
return i, err == nil
}
return 0, false
}

94
internal/onebot/types.go Normal file
View File

@ -0,0 +1,94 @@
// Package onebot 实现 OneBot V11 标准协议Reverse WebSocket 通信)
// 参考: https://github.com/botuniverse/onebot-11
package onebot
import "fmt"
// Action 是 OneBot 标准 API 请求
type Action struct {
Action string `json:"action"`
Params map[string]interface{} `json:"params,omitempty"`
Echo string `json:"echo,omitempty"`
}
// ActionResponse 是 OneBot 标准 API 响应
type ActionResponse struct {
Status string `json:"status"`
RetCode int `json:"retcode"`
Data interface{} `json:"data"`
Echo string `json:"echo,omitempty"`
}
// Event 是 OneBot 推送的事件
type Event struct {
Time int64 `json:"time"`
SelfID int64 `json:"self_id"`
PostType string `json:"post_type"` // message, notice, request, meta_event
DetailType string `json:"-"`
// 消息事件字段
MessageType string `json:"message_type,omitempty"` // private, group
SubType string `json:"sub_type,omitempty"`
MessageID int64 `json:"message_id,omitempty"`
UserID int64 `json:"user_id,omitempty"`
GroupID int64 `json:"group_id,omitempty"`
Message interface{} `json:"message,omitempty"` // string 或 []MessageSegment
RawMessage string `json:"raw_message,omitempty"`
Font int `json:"font,omitempty"`
Sender *Sender `json:"sender,omitempty"`
// 通知事件字段
NoticeType string `json:"notice_type,omitempty"`
// 请求事件字段
RequestType string `json:"request_type,omitempty"`
Flag string `json:"flag,omitempty"`
Comment string `json:"comment,omitempty"`
// 元事件字段
MetaEventType string `json:"meta_event_type,omitempty"`
Interval int64 `json:"interval,omitempty"`
Status *Status `json:"status,omitempty"`
}
type Sender struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Sex string `json:"sex,omitempty"`
Age int `json:"age,omitempty"`
Card string `json:"card,omitempty"` // 群名片
Area string `json:"area,omitempty"`
Level string `json:"level,omitempty"`
Role string `json:"role,omitempty"` // owner, admin, member
Title string `json:"title,omitempty"`
}
type Status struct {
AppInitialized bool `json:"app_initialized"`
AppEnabled bool `json:"app_enabled"`
PluginsGood bool `json:"plugins_good"`
AppGood bool `json:"app_good"`
Online bool `json:"online"`
Good bool `json:"good"`
}
// MessageSegment 表示 OneBot 消息段(数组格式)
type MessageSegment struct {
Type string `json:"type"`
Data map[string]string `json:"data"`
}
// MessageText 快速构造纯文本消息段
func MessageText(text string) MessageSegment {
return MessageSegment{Type: "text", Data: map[string]string{"text": text}}
}
// MessageImage 构造图片消息段
func MessageImage(file string) MessageSegment {
return MessageSegment{Type: "image", Data: map[string]string{"file": file}}
}
// MessageAt 构造 @ 消息段
func MessageAt(userID int64) MessageSegment {
return MessageSegment{Type: "at", Data: map[string]string{"qq": fmt.Sprintf("%d", userID)}}
}

899
internal/plugin/plugin.go Normal file
View File

@ -0,0 +1,899 @@
package plugin
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
type PluginType string
const (
PluginTypeSKILL PluginType = "skill"
PluginTypeNative PluginType = "native"
)
// IOConfig 定义插件作为 IO 通道时的配置
// 每个插件通过此配置声明自己的 I/O 端口
type IOConfig struct {
Type string `json:"type"` // "input" / "output" / "io"
InputRoute string `json:"input_route"` // 输入源标识,如 "qq", "email"
OutputRoute string `json:"output_route"` // 输出通道标识,默认等于 InputRoute
OutputCaps []string `json:"output_caps"` // 支持的输出能力: "text","file","image","audio","structured"
}
type Plugin interface {
Name() string
PluginType() PluginType
Description() string
Version() string
Tools() []ToolDef
Enabled() bool
SetEnabled(bool)
IOConfig() *IOConfig
Device() agentIO.Device // 内嵌的 IO 设备nil 表示纯技能插件
}
// ToolDef 复用 IO 抽象层的定义,确保 Plugin 和 Device 使用同一类型
type ToolDef = agentIO.ToolDef
type SKILLPlugin struct {
mu sync.RWMutex
name string
description string
version string
author string
enabled bool
rawContent string
sourceDir string
toolDefs []ToolDef
ioConfig *IOConfig
}
func LoadSKILL(path string) (*SKILLPlugin, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("stat %s: %w", path, err)
}
name := filepath.Base(path)
p := &SKILLPlugin{
name: name,
sourceDir: path,
enabled: true,
}
if info.IsDir() {
skillFile := filepath.Join(path, "SKILL.md")
if data, err := os.ReadFile(skillFile); err == nil {
p.rawContent = string(data)
p.description = extractDescription(p.rawContent)
p.version = extractField(p.rawContent, "version")
p.author = extractField(p.rawContent, "author")
p.ioConfig = extractIOConfig(p.rawContent)
}
metaFile := filepath.Join(path, "skill.json")
if data, err := os.ReadFile(metaFile); err == nil {
var meta struct {
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Author string `json:"author"`
IO *IOConfig `json:"io,omitempty"`
}
if err := json.Unmarshal(data, &meta); err == nil {
if meta.Name != "" {
p.name = meta.Name
}
if meta.Description != "" {
p.description = meta.Description
}
if meta.Version != "" {
p.version = meta.Version
}
if meta.Author != "" {
p.author = meta.Author
}
if meta.IO != nil {
p.ioConfig = meta.IO
}
}
}
} else if filepath.Ext(path) == ".md" {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
p.rawContent = string(data)
p.name = name[:len(name)-3]
p.description = extractDescription(p.rawContent)
p.ioConfig = extractIOConfig(p.rawContent)
}
if p.rawContent != "" {
p.toolDefs = extractToolDefs(p.rawContent)
}
log.Printf("[plugin] loaded SKILL: %s v%s", p.name, p.version)
return p, nil
}
func (p *SKILLPlugin) Name() string { return p.name }
func (p *SKILLPlugin) PluginType() PluginType { return PluginTypeSKILL }
func (p *SKILLPlugin) Description() string { return p.description }
func (p *SKILLPlugin) Version() string { return p.version }
func (p *SKILLPlugin) Enabled() bool { p.mu.RLock(); defer p.mu.RUnlock(); return p.enabled }
func (p *SKILLPlugin) SetEnabled(v bool) { p.mu.Lock(); defer p.mu.Unlock(); p.enabled = v }
func (p *SKILLPlugin) Tools() []ToolDef { return p.toolDefs }
func (p *SKILLPlugin) IOConfig() *IOConfig { return p.ioConfig }
func (p *SKILLPlugin) Device() agentIO.Device { return nil } // SKILL 插件无原生 IO 设备
func (p *SKILLPlugin) RawContent() string { return p.rawContent }
// NativeFactory 是内置插件构造器,用于需要原生 Go 实现的插件(如 OneBot QQ
// 返回 agentIO.Device 以直接注册到 IO 管理层
type NativeFactory func(name string, config map[string]interface{}, iom *agentIO.IOManager) (agentIO.Device, error)
type Registry struct {
mu sync.RWMutex
plugins map[string]Plugin
ioMgr *agentIO.IOManager
factories map[string]NativeFactory // 名称匹配的插件使用原生实现
}
func NewRegistry() *Registry {
return &Registry{
plugins: make(map[string]Plugin),
factories: make(map[string]NativeFactory),
}
}
// RegisterNative 注册内置原生插件工厂。当从 plugins/ 加载插件时,
// 如果插件名称匹配已注册的工厂,优先使用原生设备注册。
// 例如: r.RegisterNative("qq", onebot.NewDeviceFactory)
func (r *Registry) RegisterNative(name string, factory NativeFactory) {
r.mu.Lock()
defer r.mu.Unlock()
r.factories[name] = factory
}
// SetIOManager 绑定 IO 管理器,启用 IO 设备自动注册
func (r *Registry) SetIOManager(mgr *agentIO.IOManager) {
r.mu.Lock()
defer r.mu.Unlock()
r.ioMgr = mgr
}
func (r *Registry) Register(p Plugin) {
r.mu.Lock()
defer r.mu.Unlock()
r.plugins[p.Name()] = p
log.Printf("[plugin] registered: %s (%s)", p.Name(), p.PluginType())
// 注册内嵌 IO 设备到 IOManager
if r.ioMgr != nil {
dev := p.Device()
if dev != nil {
// 原生设备直接注册
if err := r.ioMgr.RegisterDevice(dev); err != nil {
log.Printf("[plugin] register native device %s: %v", p.Name(), err)
return
}
} else if p.IOConfig() != nil {
// 有 IO 配置但无原生设备 → 用 PluginDevice 包装
dev = NewPluginDevice(p)
if err := r.ioMgr.RegisterDevice(dev); err != nil {
log.Printf("[plugin] register plugin device %s: %v", p.Name(), err)
return
}
} else {
return // 纯技能插件,无 IO 通道
}
// 自动注册输出路由
if cfg := p.IOConfig(); cfg != nil {
log.Printf("[plugin] io device %s active (type=%s, caps=%v)",
p.Name(), cfg.Type, cfg.OutputCaps)
if cfg.InputRoute != "" {
outputRoute := cfg.OutputRoute
if outputRoute == "" {
outputRoute = cfg.InputRoute
}
r.ioMgr.RegisterOutputRoute(cfg.InputRoute, outputRoute)
log.Printf("[plugin] route: %s → %s", cfg.InputRoute, outputRoute)
}
}
}
}
// nativePlugin 包装原生 IO 设备为 Plugin 接口
// Plugin 是容器Device 是组件
type nativePlugin struct {
name string
dev agentIO.Device
cfg *IOConfig
}
func (p *nativePlugin) Name() string { return p.name }
func (p *nativePlugin) PluginType() PluginType { return PluginTypeNative }
func (p *nativePlugin) Description() string { return p.dev.Description() }
func (p *nativePlugin) Version() string { return "1.0.0" }
func (p *nativePlugin) Tools() []ToolDef { return p.dev.Tools() }
func (p *nativePlugin) Enabled() bool { return true }
func (p *nativePlugin) SetEnabled(v bool) {}
func (p *nativePlugin) IOConfig() *IOConfig { return p.cfg }
func (p *nativePlugin) Device() agentIO.Device { return p.dev }
// Reload 原子化重载 plugins/ 目录:
// 1. 扫描磁盘加载新插件,构造并启动新 IO 设备
// 2. 原子替换 IOManager 的设备表与路由表
// 3. 停止并清理旧设备
// 全程无通信中断:旧设备持续服务直到路由切换完成
func (r *Registry) Reload(dir string) (string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return "插件目录不存在", nil
}
return "", fmt.Errorf("扫描插件目录: %w", err)
}
// 1. 扫描磁盘,并行加载新插件
type loadedPlugin struct {
name string
p Plugin
err error
}
var loaded []loadedPlugin
for _, entry := range entries {
name := entry.Name()
if !entry.IsDir() && !strings.HasSuffix(name, ".md") {
continue
}
if entry.IsDir() {
// 略过非插件目录(不含 SKILL.md
if _, err := os.Stat(filepath.Join(dir, name, "SKILL.md")); os.IsNotExist(err) {
continue
}
} else {
name = name[:len(name)-3]
}
r.mu.RLock()
_, exists := r.plugins[name]
r.mu.RUnlock()
if exists {
continue // 已存在,跳过(后续可用 diff 检测变更)
}
r.mu.RLock()
factory, hasFactory := r.factories[name]
r.mu.RUnlock()
if hasFactory {
dev, err := factory(name, r.configFor(name, dir), r.ioMgr)
if err != nil {
loaded = append(loaded, loadedPlugin{name: name, err: err})
continue
}
np := &nativePlugin{name: name, dev: dev}
np.cfg = extractIOConfig(r.readFile(filepath.Join(dir, name)))
loaded = append(loaded, loadedPlugin{name: name, p: np})
} else {
p, err := LoadSKILL(filepath.Join(dir, entry.Name()))
if err != nil {
loaded = append(loaded, loadedPlugin{name: name, err: err})
continue
}
loaded = append(loaded, loadedPlugin{name: name, p: p})
}
}
// 2. 启动新设备的 IO 通道
newDevices := make(map[string]agentIO.Device)
newRoutes := make(map[string]string)
for _, lp := range loaded {
if lp.err != nil {
log.Printf("[plugin] skip %s: %v", lp.name, lp.err)
continue
}
dev := lp.p.Device()
if dev == nil && lp.p.IOConfig() != nil {
dev = NewPluginDevice(lp.p)
}
if dev != nil {
dev.Start() // 新设备预先启动
newDevices[lp.name] = dev
if cfg := lp.p.IOConfig(); cfg != nil {
if cfg.InputRoute != "" {
out := cfg.OutputRoute
if out == "" {
out = cfg.InputRoute
}
newRoutes[cfg.InputRoute] = out
}
}
}
}
// 3. 原子切换
r.mu.Lock()
var oldPlugins map[string]Plugin
if r.ioMgr != nil {
// 获取旧设备并原子替换
oldDevices := r.ioMgr.AtomicSwapDevices(newDevices, newRoutes)
// 停止旧设备
for _, dev := range oldDevices {
go dev.Stop()
}
} else {
for _, dev := range newDevices {
dev.Stop()
}
}
// 替换插件表
oldPlugins = r.plugins
r.plugins = make(map[string]Plugin)
for _, lp := range loaded {
if lp.p != nil {
r.plugins[lp.name] = lp.p
}
}
pluginCount := len(r.plugins)
ioCount := len(newDevices)
r.mu.Unlock()
// 4. 清理旧插件资源
for _, p := range oldPlugins {
_ = p
}
log.Printf("[plugin] atomic reload: %d plugins, %d io channels", pluginCount, ioCount)
return fmt.Sprintf("插件重载完成: %d 个插件, %d 个 IO 通道", pluginCount, ioCount), nil
}
func (r *Registry) Unregister(name string) {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.plugins[name]; ok && r.ioMgr != nil {
r.ioMgr.UnregisterDevice(name)
log.Printf("[plugin] unregistered io device: %s", name)
}
delete(r.plugins, name)
}
func (r *Registry) Get(name string) Plugin {
r.mu.RLock()
defer r.mu.RUnlock()
return r.plugins[name]
}
func (r *Registry) List() []Plugin {
r.mu.RLock()
defer r.mu.RUnlock()
list := make([]Plugin, 0, len(r.plugins))
for _, p := range r.plugins {
list = append(list, p)
}
sort.Slice(list, func(i, j int) bool {
return list[i].Name() < list[j].Name()
})
return list
}
func (r *Registry) ListEnabled() []Plugin {
r.mu.RLock()
defer r.mu.RUnlock()
var list []Plugin
for _, p := range r.plugins {
if p.Enabled() {
list = append(list, p)
}
}
return list
}
func (r *Registry) GetAllToolDefs() []ToolDef {
r.mu.RLock()
defer r.mu.RUnlock()
var defs []ToolDef
for _, p := range r.plugins {
if !p.Enabled() {
continue
}
defs = append(defs, p.Tools()...)
}
return defs
}
// HotReload 定期扫描插件目录,检测新增/变更/删除的插件并动态注册/注销
// interval=0 表示只执行一次扫描
func (r *Registry) HotReload(dir string, interval time.Duration, done <-chan struct{}) {
if interval <= 0 {
r.scanAndSync(dir)
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
r.scanAndSync(dir)
case <-done:
return
}
}
}
// scanAndSync 扫描插件目录并与当前注册表同步
func (r *Registry) scanAndSync(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
if !os.IsNotExist(err) {
log.Printf("[plugin] scan error: %v", err)
}
return
}
// 收集当前磁盘上的插件名
diskSet := make(map[string]bool)
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
diskSet[name] = true
} else if strings.HasSuffix(name, ".md") {
diskSet[name[:len(name)-3]] = true
}
}
r.mu.Lock()
// 移除已不存在的插件
for name := range r.plugins {
if !diskSet[name] {
if r.ioMgr != nil {
r.ioMgr.UnregisterDevice(name)
}
delete(r.plugins, name)
log.Printf("[plugin] hot-unload: %s", name)
}
}
r.mu.Unlock()
// 加载新增的插件
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
name := entry.Name()
if entry.IsDir() {
name = entry.Name()
} else if strings.HasSuffix(name, ".md") {
name = name[:len(name)-3]
} else {
continue
}
r.mu.RLock()
exists := r.plugins[name]
r.mu.RUnlock()
if exists != nil {
continue
}
// 检查是否有原生工厂
r.mu.RLock()
factory, hasFactory := r.factories[name]
r.mu.RUnlock()
if hasFactory {
// 使用原生设备,构造 Plugin 容器
dev, err := factory(name, r.configFor(name, dir), r.ioMgr)
if err != nil {
log.Printf("[plugin] native factory %s: %v", name, err)
continue
}
np := &nativePlugin{name: name, dev: dev}
np.cfg = extractIOConfig(r.readFile(path))
r.Register(np)
log.Printf("[plugin] hot-load native: %s", name)
} else {
var p Plugin
p, err = LoadSKILL(path)
if err != nil {
log.Printf("[plugin] hot-load skip %s: %v", entry.Name(), err)
continue
}
r.Register(p)
log.Printf("[plugin] hot-load: %s", p.Name())
}
}
}
// configFor 读取插件目录的 skill.json 作为原生工厂的配置
func (r *Registry) configFor(name, dir string) map[string]interface{} {
cfg := map[string]interface{}{}
path := filepath.Join(dir, name, "skill.json")
data, err := os.ReadFile(path)
if err != nil {
return cfg
}
var meta map[string]interface{}
if json.Unmarshal(data, &meta) == nil {
for k, v := range meta {
cfg[k] = v
}
}
return cfg
}
// readFile 读取插件目录的 SKILL.md
func (r *Registry) readFile(path string) string {
if info, err := os.Stat(path); err == nil && info.IsDir() {
data, _ := os.ReadFile(filepath.Join(path, "SKILL.md"))
return string(data)
}
data, _ := os.ReadFile(path)
return string(data)
}
func (r *Registry) LoadDir(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
name := entry.Name()
if entry.IsDir() {
// 原生插件优先
r.mu.RLock()
factory, hasFactory := r.factories[name]
r.mu.RUnlock()
if hasFactory {
dev, err := factory(name, r.configFor(name, dir), r.ioMgr)
if err != nil {
log.Printf("[plugin] native factory %s: %v", name, err)
continue
}
r.mu.Lock()
r.plugins[name] = &nativePlugin{name: name, dev: dev}
r.mu.Unlock()
if r.ioMgr != nil {
r.ioMgr.RegisterDevice(dev)
log.Printf("[plugin] native: %s", name)
}
continue
}
p, err := LoadSKILL(path)
if err != nil {
log.Printf("[plugin] skip dir %s: %v", entry.Name(), err)
continue
}
r.Register(p)
} else if filepath.Ext(entry.Name()) == ".md" {
p, err := LoadSKILL(path)
if err != nil {
log.Printf("[plugin] skip file %s: %v", entry.Name(), err)
continue
}
r.Register(p)
}
}
return nil
}
// extractIOConfig parses IO port metadata from SKILL.md content
// Supported fields:
// io_type: input|output|io
// io_input_route: qq
// io_output_route: qq (optional, defaults to input_route)
// io_output_caps: text,file,image
func extractIOConfig(content string) *IOConfig {
ioType := extractField(content, "io_type")
if ioType == "" {
return nil
}
cfg := &IOConfig{
Type: ioType,
InputRoute: extractField(content, "io_input_route"),
}
if cfg.InputRoute == "" {
cfg.InputRoute = extractField(content, "io_route")
}
cfg.OutputRoute = extractField(content, "io_output_route")
if cfg.OutputRoute == "" {
cfg.OutputRoute = cfg.InputRoute
}
capsStr := extractField(content, "io_output_caps")
if capsStr != "" {
for _, c := range strings.Split(capsStr, ",") {
cfg.OutputCaps = append(cfg.OutputCaps, strings.TrimSpace(c))
}
}
return cfg
}
// PluginDevice 将 Plugin 包装为 IO Device实现热插拔
type PluginDevice struct {
plugin Plugin
caps agentIO.OutputCapability
}
func NewPluginDevice(p Plugin) *PluginDevice {
var caps agentIO.OutputCapability
if cfg := p.IOConfig(); cfg != nil {
// 有 IO 配置时使用声明的能力
for _, c := range cfg.OutputCaps {
switch strings.ToLower(c) {
case "text":
caps |= agentIO.CapText
case "file":
caps |= agentIO.CapFile
case "image":
caps |= agentIO.CapImage
case "audio":
caps |= agentIO.CapAudio
case "structured":
caps |= agentIO.CapStructured
}
}
}
// 无 IO 配置时 caps == 0 → 纯工具插件,不暴露为输出通道
return &PluginDevice{plugin: p, caps: caps}
}
func (d *PluginDevice) Name() string { return d.plugin.Name() }
func (d *PluginDevice) Type() agentIO.DeviceType {
if cfg := d.plugin.IOConfig(); cfg != nil {
switch cfg.Type {
case "input":
return agentIO.DeviceInput
case "output":
return agentIO.DeviceOutput
case "io":
return agentIO.DeviceIO
}
}
// 无 IO 配置 → 纯工具插件,归为 DeviceInput无输出能力
return agentIO.DeviceInput
}
func (d *PluginDevice) Description() string { return d.plugin.Description() }
func (d *PluginDevice) Tools() []agentIO.ToolDef {
pts := d.plugin.Tools()
defs := make([]agentIO.ToolDef, 0, len(pts))
for _, t := range pts {
defs = append(defs, agentIO.ToolDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
})
}
return defs
}
func (d *PluginDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
for _, t := range d.plugin.Tools() {
if t.Name == tool {
if t.Handler != nil {
return t.Handler(args)
}
return nil, fmt.Errorf("plugin %s: tool %s has no handler", d.plugin.Name(), tool)
}
}
return nil, fmt.Errorf("plugin %s: unknown tool %s", d.plugin.Name(), tool)
}
func (d *PluginDevice) Start() error { return nil }
func (d *PluginDevice) Stop() error { return nil }
func (d *PluginDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
// extractDescription returns the first non-empty, non-header line
func extractDescription(content string) string {
for _, line := range splitLines(content) {
line = trimSpace(line)
if line != "" && !hasPrefix(line, "#") {
return line
}
}
return ""
}
// extractField finds `field: value` pattern in content
func extractField(content string, field string) string {
prefix := field + ":"
for _, line := range splitLines(content) {
trimmed := trimSpace(line)
if hasPrefix(toLower(trimmed), prefix) {
return trimSpace(trimPrefix(trimmed, prefix))
}
}
return ""
}
// extractToolDefs 从 SKILL.md 中解析工具定义OpenClaw 格式)
//
// 格式:
// ## tool_name
// 工具描述
// - param1: 参数描述
// - param2: 参数描述
//
// 也支持:
// ### Tool: tool_name
// 格式(三级标题)
func extractToolDefs(content string) []ToolDef {
lines := splitLines(content)
var defs []ToolDef
var currentTool *ToolDef
inCodeBlock := false
for i := 0; i < len(lines); i++ {
line := lines[i]
trimmed := trimSpace(line)
// 跳过代码块
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
continue
}
if inCodeBlock {
continue
}
// 检测工具定义开始: ## tool_name 或 ### Tool: tool_name
if strings.HasPrefix(trimmed, "## ") && !strings.HasPrefix(trimmed, "### ") {
// 结束上一个工具
if currentTool != nil && currentTool.Name != "" {
defs = append(defs, *currentTool)
}
currentTool = &ToolDef{}
namePart := strings.TrimPrefix(trimmed, "## ")
// 跳过已知的非工具章节
if isNonToolSection(namePart) {
currentTool = nil
continue
}
currentTool.Name = namePart
currentTool.Parameters = map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
}
continue
}
// 也支持 ### Tool: name 格式
if strings.HasPrefix(trimmed, "### Tool: ") {
if currentTool != nil && currentTool.Name != "" {
defs = append(defs, *currentTool)
}
currentTool = &ToolDef{}
namePart := strings.TrimPrefix(trimmed, "### Tool: ")
currentTool.Name = namePart
currentTool.Parameters = map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
}
continue
}
if currentTool == nil {
continue
}
// 如果 name 为空且已进入工具段,跳过
if currentTool.Name == "" {
continue
}
// 描述行:第一个非空、非标题、非列表行
if currentTool.Description == "" && trimmed != "" &&
!strings.HasPrefix(trimmed, "- ") &&
!strings.HasPrefix(trimmed, "#") {
currentTool.Description = trimmed
continue
}
// 参数行:- param: description
if strings.HasPrefix(trimmed, "- ") {
paramLine := strings.TrimPrefix(trimmed, "- ")
colonIdx := strings.Index(paramLine, ":")
if colonIdx > 0 {
paramName := strings.TrimSpace(paramLine[:colonIdx])
paramDesc := strings.TrimSpace(paramLine[colonIdx+1:])
if paramName != "" {
props := currentTool.Parameters["properties"].(map[string]interface{})
props[paramName] = map[string]interface{}{
"type": "string",
"description": paramDesc,
}
}
}
}
}
// 收尾最后一个工具
if currentTool != nil && currentTool.Name != "" {
defs = append(defs, *currentTool)
}
return defs
}
// isNonToolSection 判断是否为非工具章节(如 Usage、Examples、Installation 等)
func isNonToolSection(name string) bool {
lower := strings.ToLower(name)
skip := []string{
"tools", "usage", "examples", "installation", "setup",
"configuration", "overview", "description", "notes",
"parameters", "return", "returns", "options", "syntax",
}
for _, s := range skip {
if lower == s || strings.HasPrefix(lower, s+" ") || strings.HasPrefix(lower, s+":") {
return true
}
}
return false
}
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i <= len(s); i++ {
if i == len(s) || s[i] == '\n' {
if i > start {
lines = append(lines, s[start:i])
}
start = i + 1
}
}
return lines
}
func trimSpace(s string) string {
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\r') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\r') {
end--
}
return s[start:end]
}
func hasPrefix(s, prefix string) bool {
if len(s) < len(prefix) {
return false
}
return s[:len(prefix)] == prefix
}
func toLower(s string) string {
b := make([]byte, len(s))
for i := 0; i < len(s); i++ {
if s[i] >= 'A' && s[i] <= 'Z' {
b[i] = s[i] + 32
} else {
b[i] = s[i]
}
}
return string(b)
}
func trimPrefix(s, prefix string) string {
if hasPrefix(s, prefix) {
return s[len(prefix):]
}
return s
}

218
internal/skill/manager.go Normal file
View File

@ -0,0 +1,218 @@
package skill
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
type Skill struct {
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Author string `json:"author,omitempty"`
Entry string `json:"entry,omitempty"`
Source string `json:"source,omitempty"`
Enabled bool `json:"enabled"`
RawContent string `json:"-"`
}
type Manager struct {
mu sync.RWMutex
skillsDir string
skills map[string]*Skill
}
func NewManager(skillsDir string) *Manager {
return &Manager{
skillsDir: skillsDir,
skills: make(map[string]*Skill),
}
}
func (m *Manager) Init() error {
if err := os.MkdirAll(m.skillsDir, 0755); err != nil {
return fmt.Errorf("create skills dir: %w", err)
}
return m.loadAll()
}
func (m *Manager) loadAll() error {
entries, err := os.ReadDir(m.skillsDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
skillDir := filepath.Join(m.skillsDir, entry.Name())
skill, err := m.loadSkill(skillDir)
if err != nil {
continue
}
m.skills[skill.Name] = skill
}
return nil
}
func (m *Manager) loadSkill(dir string) (*Skill, error) {
skill := &Skill{
Name: filepath.Base(dir),
Enabled: true,
}
skillFilePath := filepath.Join(dir, "SKILL.md")
if data, err := os.ReadFile(skillFilePath); err == nil {
skill.RawContent = string(data)
skill.Description = extractDescription(skill.RawContent)
skill.Version = extractField(skill.RawContent, "version")
skill.Author = extractField(skill.RawContent, "author")
}
metaPath := filepath.Join(dir, "skill.json")
if data, err := os.ReadFile(metaPath); err == nil {
var meta struct {
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Author string `json:"author"`
Entry string `json:"entry"`
}
if err := json.Unmarshal(data, &meta); err == nil {
if meta.Name != "" {
skill.Name = meta.Name
}
if meta.Description != "" {
skill.Description = meta.Description
}
if meta.Version != "" {
skill.Version = meta.Version
}
if meta.Author != "" {
skill.Author = meta.Author
}
if meta.Entry != "" {
skill.Entry = meta.Entry
}
}
}
return skill, nil
}
func (m *Manager) Install(name string, content string) error {
m.mu.Lock()
defer m.mu.Unlock()
skillDir := filepath.Join(m.skillsDir, name)
if err := os.MkdirAll(skillDir, 0755); err != nil {
return fmt.Errorf("create skill dir: %w", err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0644); err != nil {
return fmt.Errorf("write SKILL.md: %w", err)
}
skill, err := m.loadSkill(skillDir)
if err != nil {
return fmt.Errorf("load installed skill: %w", err)
}
m.skills[name] = skill
return nil
}
func (m *Manager) Uninstall(name string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.skills[name]; !ok {
return fmt.Errorf("skill %s not found", name)
}
skillDir := filepath.Join(m.skillsDir, name)
if err := os.RemoveAll(skillDir); err != nil {
return fmt.Errorf("remove skill dir: %w", err)
}
delete(m.skills, name)
return nil
}
func (m *Manager) List() []*Skill {
m.mu.RLock()
defer m.mu.RUnlock()
skills := make([]*Skill, 0, len(m.skills))
for _, s := range m.skills {
skills = append(skills, s)
}
sort.Slice(skills, func(i, j int) bool {
return skills[i].Name < skills[j].Name
})
return skills
}
func (m *Manager) Get(name string) *Skill {
m.mu.RLock()
defer m.mu.RUnlock()
return m.skills[name]
}
func (m *Manager) Toggle(name string, enabled bool) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.skills[name]; !ok {
return fmt.Errorf("skill %s not found", name)
}
m.skills[name].Enabled = enabled
return nil
}
func (m *Manager) GetInjectedPrompt() string {
m.mu.RLock()
defer m.mu.RUnlock()
var parts []string
for _, s := range m.skills {
if s.Enabled && s.RawContent != "" {
parts = append(parts, fmt.Sprintf("=== Skill: %s ===\n%s", s.Name, s.RawContent))
}
}
return strings.Join(parts, "\n\n")
}
func extractDescription(content string) string {
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
return line
}
}
return ""
}
func extractField(content string, field string) string {
prefix := fmt.Sprintf("%s:", field)
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(strings.ToLower(trimmed), prefix) {
return strings.TrimSpace(strings.TrimPrefix(trimmed, prefix))
}
}
return ""
}

View File

@ -0,0 +1,196 @@
package snapshot
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/container"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
type Manager struct {
mu sync.RWMutex
dataDir string
container *container.Manager
snapshots map[types.AgentID][]types.Snapshot
}
func NewManager(dataDir string, cm *container.Manager) *Manager {
return &Manager{
dataDir: filepath.Join(dataDir, "snapshots"),
container: cm,
snapshots: make(map[types.AgentID][]types.Snapshot),
}
}
func (m *Manager) Create(ctx context.Context, agentID types.AgentID, containerID string, reason string) (*types.Snapshot, error) {
snapDir := filepath.Join(m.dataDir, string(agentID))
if err := os.MkdirAll(snapDir, 0755); err != nil {
return nil, fmt.Errorf("create snapshot dir: %w", err)
}
snapID := types.SnapshotID(fmt.Sprintf("snap_%s_%d", agentID, time.Now().UnixNano()))
imageTag := fmt.Sprintf("homeagent/snap-%s:%s", agentID, snapID)
imagePath := filepath.Join(snapDir, string(snapID)+".tar")
if err := m.container.Commit(ctx, containerID, imageTag); err != nil {
return nil, fmt.Errorf("commit container: %w", err)
}
if err := m.container.SaveImage(ctx, imageTag, imagePath); err != nil {
return nil, fmt.Errorf("save image: %w", err)
}
info, err := os.Stat(imagePath)
var size int64
if err == nil {
size = info.Size()
}
snap := types.Snapshot{
ID: snapID,
AgentID: agentID,
CreatedAt: time.Now(),
Reason: reason,
Size: size,
DockerImage: imageTag,
Valid: true,
}
m.mu.Lock()
m.snapshots[agentID] = append(m.snapshots[agentID], snap)
m.mu.Unlock()
log.Printf("[snapshot] created %s for agent %s (reason: %s, size: %d bytes)", snapID, agentID, reason, size)
m.enforceLimit(agentID)
return &snap, nil
}
func (m *Manager) Restore(ctx context.Context, agentID types.AgentID, containerID string, snapID types.SnapshotID) error {
m.mu.RLock()
snapshots := m.snapshots[agentID]
var target *types.Snapshot
for _, s := range snapshots {
if s.ID == snapID && s.Valid {
target = &s
break
}
}
m.mu.RUnlock()
if target == nil {
return fmt.Errorf("snapshot %s not found or invalid", snapID)
}
snapDir := filepath.Join(m.dataDir, string(agentID))
imagePath := filepath.Join(snapDir, string(snapID)+".tar")
if _, err := os.Stat(imagePath); os.IsNotExist(err) {
return fmt.Errorf("snapshot file %s not found", imagePath)
}
if err := m.container.Stop(ctx, containerID); err != nil {
log.Printf("[snapshot] warning: stop container during restore: %v", err)
}
if err := m.container.Remove(ctx, containerID); err != nil {
return fmt.Errorf("remove container for restore: %w", err)
}
if err := m.container.LoadImage(ctx, imagePath); err != nil {
return fmt.Errorf("load snapshot image: %w", err)
}
log.Printf("[snapshot] restored agent %s to snapshot %s", agentID, snapID)
return nil
}
func (m *Manager) List(agentID types.AgentID) []types.Snapshot {
m.mu.RLock()
defer m.mu.RUnlock()
snapshots := m.snapshots[agentID]
result := make([]types.Snapshot, len(snapshots))
copy(result, snapshots)
sort.Slice(result, func(i, j int) bool {
return result[i].CreatedAt.After(result[j].CreatedAt)
})
return result
}
func (m *Manager) Latest(agentID types.AgentID) *types.Snapshot {
snapshots := m.List(agentID)
if len(snapshots) == 0 {
return nil
}
return &snapshots[0]
}
func (m *Manager) MarkInvalid(agentID types.AgentID, snapID types.SnapshotID) {
m.mu.Lock()
defer m.mu.Unlock()
for i, s := range m.snapshots[agentID] {
if s.ID == snapID {
m.snapshots[agentID][i].Valid = false
return
}
}
}
func (m *Manager) enforceLimit(agentID types.AgentID) {
m.mu.Lock()
defer m.mu.Unlock()
snapshots := m.snapshots[agentID]
if len(snapshots) <= 20 {
return
}
sort.Slice(snapshots, func(i, j int) bool {
return snapshots[i].CreatedAt.Before(snapshots[j].CreatedAt)
})
toRemove := len(snapshots) - 20
for i := 0; i < toRemove; i++ {
s := snapshots[i]
snapDir := filepath.Join(m.dataDir, string(agentID))
imagePath := filepath.Join(snapDir, string(s.ID)+".tar")
os.Remove(imagePath)
}
m.snapshots[agentID] = snapshots[toRemove:]
}
func (m *Manager) Cleanup(agentID types.AgentID, keep int) {
m.mu.Lock()
defer m.mu.Unlock()
snapshots := m.snapshots[agentID]
if len(snapshots) <= keep {
return
}
sort.Slice(snapshots, func(i, j int) bool {
return snapshots[i].CreatedAt.Before(snapshots[j].CreatedAt)
})
toRemove := len(snapshots) - keep
for i := 0; i < toRemove; i++ {
s := snapshots[i]
snapDir := filepath.Join(m.dataDir, string(agentID))
imagePath := filepath.Join(snapDir, string(s.ID)+".tar")
os.Remove(imagePath)
}
m.snapshots[agentID] = snapshots[toRemove:]
}

View File

@ -0,0 +1,234 @@
package supervisor
import (
"context"
"fmt"
"log"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/network"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
const directAgentID types.AgentID = "main"
type Daemon struct {
cfg *types.Config
nm *network.Monitor
trk *tracker.Tracker
agents map[types.AgentID]*agentInstance
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
}
type agentInstance struct {
cfg *types.AgentConfig
state types.AgentState
health types.HealthStatus
lastHB time.Time
failCount int
useTracker bool
}
func New(cfg *types.Config) *Daemon {
ctx, cancel := context.WithCancel(context.Background())
nm := network.NewMonitor(cfg.Daemon.CheckInterval)
return &Daemon{
cfg: cfg,
nm: nm,
agents: make(map[types.AgentID]*agentInstance),
ctx: ctx,
cancel: cancel,
}
}
func (d *Daemon) SetTracker(trk *tracker.Tracker) {
d.trk = trk
}
func (d *Daemon) Start() error {
log.Println("[homed] starting HomeAgent daemon")
go d.nm.Start(d.ctx, d.cfg.Defaults.LLMEndpoints)
go d.healthLoop()
log.Println("[homed] daemon started successfully")
return nil
}
func (d *Daemon) Shutdown() {
log.Println("[homed] shutting down...")
d.cancel()
d.mu.RLock()
defer d.mu.RUnlock()
for id, agent := range d.agents {
if agent.state == types.AgentStateRunning {
log.Printf("[homed] stopping agent %s", id)
agent.state = types.AgentStateStopped
}
}
}
func (d *Daemon) RegisterAgent(id types.AgentID) {
d.mu.Lock()
defer d.mu.Unlock()
useTrk := d.trk != nil
maxRetries := d.cfg.Defaults.RollbackPolicy.MaxRetries
if maxRetries <= 0 {
maxRetries = 3
}
cfg := &types.AgentConfig{ID: id}
cfg.RollbackPolicy.MaxRetries = maxRetries
d.agents[id] = &agentInstance{
cfg: cfg,
state: types.AgentStateRunning,
health: types.HealthHealthy,
lastHB: time.Now(),
useTracker: useTrk,
}
log.Printf("[homed] agent %s registered (tracker=%v, maxRetries=%d)", id, useTrk, maxRetries)
}
func (d *Daemon) healthLoop() {
ticker := time.NewTicker(d.cfg.Daemon.HeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
d.checkAllAgents()
case <-d.ctx.Done():
return
}
}
}
func (d *Daemon) checkAllAgents() {
d.mu.RLock()
agents := make(map[types.AgentID]*agentInstance)
for id, a := range d.agents {
agents[id] = a
}
d.mu.RUnlock()
for id, agent := range agents {
d.checkAgent(id, agent)
}
}
func (d *Daemon) checkAgent(id types.AgentID, agent *agentInstance) {
netStatus := d.nm.AggregateResult()
if !netStatus.LLMAPIReachable {
agent.health = types.HealthDegraded
agent.failCount++
log.Printf("[homed] agent %s: LLM API unreachable (fail %d)", id, agent.failCount)
} else {
agent.health = types.HealthHealthy
agent.failCount = 0
}
agent.lastHB = time.Now()
if agent.failCount >= agent.cfg.RollbackPolicy.MaxRetries {
d.handleFailure(id, agent)
}
}
func (d *Daemon) handleFailure(id types.AgentID, agent *agentInstance) {
log.Printf("[homed] agent %s failed %d times, initiating recovery", id, agent.failCount)
if agent.useTracker && d.trk != nil {
log.Printf("[homed] rolling back agent %s via change tracker", id)
if err := d.trk.Rollback(); err != nil {
log.Printf("[homed] tracker rollback failed: %v — restarting agent", err)
d.restartAgent(id, agent)
return
}
log.Printf("[homed] agent %s tracker rollback complete", id)
agent.failCount = 0
return
}
d.restartAgent(id, agent)
}
func (d *Daemon) restartAgent(id types.AgentID, agent *agentInstance) {
log.Printf("[homed] resetting agent %s", id)
agent.state = types.AgentStateStopped
d.RegisterAgent(id)
agent.failCount = 0
log.Printf("[homed] agent %s reset", id)
}
func (d *Daemon) GetAgentStatus(id types.AgentID) (*AgentStatus, error) {
d.mu.RLock()
agent, ok := d.agents[id]
d.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("agent %s not found", id)
}
netStatus := d.nm.AggregateResult()
trackerStats := map[string]interface{}{"active": false}
if d.trk != nil {
trackerStats = d.trk.Stats()
}
return &AgentStatus{
ID: id,
State: agent.state,
Health: agent.health,
Uptime: time.Since(agent.lastHB),
Network: netStatus,
TrackerStats: trackerStats,
}, nil
}
func (d *Daemon) ListAgents() []AgentStatus {
d.mu.RLock()
defer d.mu.RUnlock()
var statuses []AgentStatus
for id, agent := range d.agents {
statuses = append(statuses, AgentStatus{
ID: id,
State: agent.state,
Health: agent.health,
})
}
return statuses
}
func (d *Daemon) PreActionSnapshot(id types.AgentID) (*types.Snapshot, error) {
return nil, fmt.Errorf("snapshot not supported in direct mode — use tracker instead")
}
func (d *Daemon) RollbackAgent(id types.AgentID, snapID types.SnapshotID) error {
if d.trk != nil {
return d.trk.Rollback()
}
return fmt.Errorf("no tracker available for rollback")
}
type AgentStatus struct {
ID types.AgentID `json:"id"`
State types.AgentState `json:"state"`
Health types.HealthStatus `json:"health"`
Uptime time.Duration `json:"uptime,omitempty"`
Network types.NetworkCheckResult `json:"network,omitempty"`
TrackerStats map[string]interface{} `json:"tracker_stats,omitempty"`
}

View File

@ -0,0 +1,81 @@
package tokenizer
import (
"strings"
"sync"
jieba "github.com/yanyiwu/gojieba"
)
type Jieba struct {
mu sync.Mutex
handle *jieba.Jieba
}
var (
global *Jieba
once sync.Once
)
func Global() *Jieba {
once.Do(func() {
global = &Jieba{
handle: jieba.NewJieba(),
}
})
return global
}
func (j *Jieba) Close() {
j.mu.Lock()
defer j.mu.Unlock()
if j.handle != nil {
j.handle.Free()
j.handle = nil
}
}
func (j *Jieba) ExtractKeywords(text string, topK int) []string {
j.mu.Lock()
defer j.mu.Unlock()
words := j.handle.ExtractWithWeight(text, topK)
result := make([]string, 0, len(words))
seen := make(map[string]bool)
for _, w := range words {
if seen[w.Word] {
continue
}
if len([]rune(w.Word)) < 2 {
continue
}
seen[w.Word] = true
result = append(result, w.Word)
}
return result
}
func (j *Jieba) Cut(text string) []string {
j.mu.Lock()
defer j.mu.Unlock()
return j.handle.Cut(text, true)
}
func (j *Jieba) Tag(text string) map[string]string {
j.mu.Lock()
defer j.mu.Unlock()
words := j.handle.Tag(text)
result := make(map[string]string, len(words))
for _, pair := range words {
if idx := strings.Index(pair, "/"); idx > 0 {
result[pair[:idx]] = pair[idx+1:]
} else {
result[pair] = ""
}
}
return result
}

View File

@ -0,0 +1,130 @@
package tracker
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"time"
)
type ChangeType string
const (
ChangeFileCreated ChangeType = "created"
ChangeFileModified ChangeType = "modified"
ChangeFileDeleted ChangeType = "deleted"
)
type FileChange struct {
Path string `json:"path"`
Type ChangeType `json:"type"`
SizeBefore int64 `json:"size_before,omitempty"`
SizeAfter int64 `json:"size_after,omitempty"`
HashBefore string `json:"hash_before,omitempty"`
HashAfter string `json:"hash_after,omitempty"`
Content []byte `json:"-"` // stored separately, not in JSON
}
type ChangeSet struct {
ID string `json:"id"`
Action string `json:"action"`
Timestamp time.Time `json:"timestamp"`
Files []FileChange `json:"files"`
}
func NewChangeSet(action string) *ChangeSet {
return &ChangeSet{
ID: fmt.Sprintf("cs_%d", time.Now().UnixNano()),
Action: action,
Timestamp: time.Now(),
}
}
func fileHash(path string) (string, int64, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", 0, err
}
h := sha256.Sum256(data)
return hex.EncodeToString(h[:]), int64(len(data)), nil
}
func fileInfo(path string) (size int64, modTime time.Time, err error) {
info, err := os.Stat(path)
if err != nil {
return 0, time.Time{}, err
}
return info.Size(), info.ModTime(), nil
}
type FSState struct {
Files map[string]FileChange `json:"files"`
Root string `json:"root"`
}
func captureFSState(root string) (*FSState, error) {
state := &FSState{
Files: make(map[string]FileChange),
Root: root,
}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
rel, _ := filepath.Rel(root, path)
hash, size, err := fileHash(path)
if err != nil {
return nil
}
state.Files[rel] = FileChange{
Path: rel,
HashAfter: hash,
SizeAfter: size,
}
return nil
})
return state, err
}
func diffStates(before, after *FSState) []FileChange {
var changes []FileChange
seen := make(map[string]bool)
for path, afterFile := range after.Files {
seen[path] = true
if beforeFile, ok := before.Files[path]; ok {
if beforeFile.HashAfter != afterFile.HashAfter {
changes = append(changes, FileChange{
Path: path,
Type: ChangeFileModified,
HashBefore: beforeFile.HashAfter,
HashAfter: afterFile.HashAfter,
SizeBefore: beforeFile.SizeAfter,
SizeAfter: afterFile.SizeAfter,
})
}
} else {
changes = append(changes, FileChange{
Path: path,
Type: ChangeFileCreated,
HashAfter: afterFile.HashAfter,
SizeAfter: afterFile.SizeAfter,
})
}
}
for path := range before.Files {
if !seen[path] {
changes = append(changes, FileChange{
Path: path,
Type: ChangeFileDeleted,
HashBefore: before.Files[path].HashAfter,
SizeBefore: before.Files[path].SizeAfter,
})
}
}
return changes
}

223
internal/tracker/tracker.go Normal file
View File

@ -0,0 +1,223 @@
package tracker
import (
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"sync"
)
type Tracker struct {
mu sync.Mutex
dataDir string
workDir string
lowerDir string
upperDir string
mergeDir string
mounted bool
active bool
before *FSState
changeSets []*ChangeSet
}
func NewTracker(dataDir, workDir string) *Tracker {
return &Tracker{
dataDir: dataDir,
workDir: workDir,
lowerDir: filepath.Join(workDir, "lower"),
upperDir: filepath.Join(workDir, "upper"),
mergeDir: filepath.Join(workDir, "merged"),
changeSets: make([]*ChangeSet, 0),
}
}
func (t *Tracker) Init() error {
for _, d := range []string{t.lowerDir, t.upperDir, t.mergeDir} {
if err := os.MkdirAll(d, 0755); err != nil {
return fmt.Errorf("create overlay dir %s: %w", d, err)
}
}
log.Printf("[tracker] initialized (work=%s)", t.workDir)
return nil
}
func (t *Tracker) Start() error {
t.mu.Lock()
defer t.mu.Unlock()
if t.mounted {
return nil
}
if err := t.mountOverlay(); err != nil {
return fmt.Errorf("mount overlay: %w", err)
}
t.mounted = true
t.active = true
t.before = t.capture()
log.Printf("[tracker] overlay mounted at %s", t.mergeDir)
return nil
}
func (t *Tracker) Stop() error {
t.mu.Lock()
defer t.mu.Unlock()
if !t.mounted {
return nil
}
if err := t.umountOverlay(); err != nil {
return fmt.Errorf("umount overlay: %w", err)
}
t.mounted = false
t.active = false
return nil
}
func (t *Tracker) PreAction(action string) *ChangeSet {
t.mu.Lock()
defer t.mu.Unlock()
cs := NewChangeSet(action)
t.before = t.capture()
return cs
}
func (t *Tracker) PostAction(action string) *ChangeSet {
t.mu.Lock()
defer t.mu.Unlock()
after := t.capture()
changes := diffStates(t.before, after)
cs := NewChangeSet(action)
cs.Files = changes
if len(changes) > 0 {
t.changeSets = append(t.changeSets, cs)
t.saveChangeSet(cs)
log.Printf("[tracker] action=%s changed=%d files", action, len(changes))
for _, f := range changes {
log.Printf(" %s: %s", f.Type, f.Path)
}
}
t.before = t.capture()
return cs
}
func (t *Tracker) HasChanges() bool {
return len(t.changeSets) > 0
}
func (t *Tracker) ChangeSets() []*ChangeSet {
t.mu.Lock()
defer t.mu.Unlock()
result := make([]*ChangeSet, len(t.changeSets))
copy(result, t.changeSets)
return result
}
func (t *Tracker) capture() *FSState {
state, err := captureFSState(t.upperDir)
if err != nil {
return &FSState{Files: make(map[string]FileChange), Root: t.upperDir}
}
return state
}
func (t *Tracker) mountOverlay() error {
workDir := filepath.Join(t.workDir, "work")
os.MkdirAll(workDir, 0755)
args := []string{
"-t", "overlay",
"overlay",
"-o", fmt.Sprintf("lowerdir=%s,upperdir=%s,workdir=%s", t.lowerDir, t.upperDir, workDir),
t.mergeDir,
}
cmd := exec.Command("mount", args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("mount overlayfs failed: %s: %w", string(output), err)
}
return nil
}
func (t *Tracker) umountOverlay() error {
cmd := exec.Command("umount", t.mergeDir)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("umount overlayfs failed: %s: %w", string(output), err)
}
return nil
}
func (t *Tracker) saveChangeSet(cs *ChangeSet) {
dir := filepath.Join(t.dataDir, "changesets")
os.MkdirAll(dir, 0755)
path := filepath.Join(dir, cs.ID+".json")
data, err := json.MarshalIndent(cs, "", " ")
if err != nil {
log.Printf("[tracker] save changeset %s: %v", cs.ID, err)
return
}
if err := os.WriteFile(path, data, 0644); err != nil {
log.Printf("[tracker] write changeset %s: %v", cs.ID, err)
}
}
func (t *Tracker) Rollback() error {
t.mu.Lock()
defer t.mu.Unlock()
if t.mounted {
if err := t.umountOverlay(); err != nil {
return fmt.Errorf("umount for rollback: %w", err)
}
}
if err := os.RemoveAll(t.upperDir); err != nil {
return fmt.Errorf("remove upper: %w", err)
}
if err := os.RemoveAll(filepath.Join(t.workDir, "work")); err != nil {
return fmt.Errorf("remove work: %w", err)
}
if err := os.MkdirAll(t.upperDir, 0755); err != nil {
return fmt.Errorf("recreate upper: %w", err)
}
t.changeSets = nil
t.before = nil
t.mounted = false
log.Printf("[tracker] rollback complete")
return nil
}
func (t *Tracker) MergeDir() string {
return t.mergeDir
}
func (t *Tracker) Stats() map[string]interface{} {
t.mu.Lock()
defer t.mu.Unlock()
totalChanges := 0
for _, cs := range t.changeSets {
totalChanges += len(cs.Files)
}
return map[string]interface{}{
"mounted": t.mounted,
"active": t.active,
"change_sets": len(t.changeSets),
"total_changes": totalChanges,
"merge_dir": t.mergeDir,
"upper_dir": t.upperDir,
}
}

118
pkg/types/types.go Normal file
View File

@ -0,0 +1,118 @@
package types
import "time"
type AgentState int
const (
AgentStateStopped AgentState = 0
AgentStateRunning AgentState = 1
AgentStateDegraded AgentState = 2
AgentStatePanic AgentState = 3
)
type HealthStatus int
const (
HealthUnknown HealthStatus = 0
HealthHealthy HealthStatus = 1
HealthUnstable HealthStatus = 2
HealthDown HealthStatus = 3
HealthDegraded HealthStatus = 4
)
type AgentID string
type SnapshotID string
type Snapshot struct {
ID SnapshotID `json:"id"`
AgentID AgentID `json:"agent_id"`
CreatedAt time.Time `json:"created_at"`
Reason string `json:"reason"`
Size int64 `json:"size_bytes"`
DockerImage string `json:"docker_image,omitempty"`
Valid bool `json:"valid"`
}
type Heartbeat struct {
AgentID AgentID `json:"agent_id"`
Timestamp time.Time `json:"timestamp"`
State AgentState `json:"state"`
Health HealthStatus `json:"health"`
Uptime time.Duration `json:"uptime"`
LLMConnected bool `json:"llm_connected"`
Error string `json:"error,omitempty"`
}
type NetworkCheckResult struct {
LLMAPIReachable bool `json:"llm_api_reachable"`
DNSResolving bool `json:"dns_resolving"`
Latency time.Duration `json:"latency_ms"`
Error string `json:"error,omitempty"`
}
type SnapshotPolicy struct {
Interval time.Duration `json:"interval"`
MaxSnapshots int `json:"max_snapshots"`
PreAction bool `json:"pre_action"`
PostAction bool `json:"post_action"`
}
type RollbackPolicy struct {
MaxRetries int `json:"max_retries"`
HealthThreshold HealthStatus `json:"health_threshold"`
CooldownPeriod time.Duration `json:"cooldown_period"`
AutoRollback bool `json:"auto_rollback"`
}
type AgentConfig struct {
ID AgentID `json:"id"`
Image string `json:"image"`
Name string `json:"name"`
LLMEndpoints []string `json:"llm_endpoints"`
SnapshotPolicy SnapshotPolicy `json:"snapshot_policy"`
RollbackPolicy RollbackPolicy `json:"rollback_policy"`
ResourceLimit ResourceLimit `json:"resource_limit"`
OpenClawEnabled bool `json:"openclaw_enabled"`
}
type ResourceLimit struct {
CPU string `json:"cpu"`
Memory string `json:"memory"`
Disk string `json:"disk"`
Network bool `json:"network"`
}
type OperationLog struct {
ID string `json:"id"`
AgentID AgentID `json:"agent_id"`
Timestamp time.Time `json:"timestamp"`
Action string `json:"action"`
SnapshotID SnapshotID `json:"snapshot_id,omitempty"`
Success bool `json:"success"`
}
type LLMConfig struct {
Provider string `json:"provider"`
Model string `json:"model"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
}
type Config struct {
Daemon DaemonConfig `json:"daemon"`
LLM LLMConfig `json:"llm"`
Defaults AgentConfig `json:"defaults"`
Agents []AgentConfig `json:"agents"`
}
type DaemonConfig struct {
ListenAddr string `json:"listen_addr"`
DataDir string `json:"data_dir"`
HeartbeatInterval time.Duration `json:"heartbeat_interval"`
CheckInterval time.Duration `json:"check_interval"`
LogLevel string `json:"log_level"`
}

31
plugins/qq/SKILL.md Normal file
View File

@ -0,0 +1,31 @@
# 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 群列表

13
plugins/qq/skill.json Normal file
View File

@ -0,0 +1,13 @@
{
"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"]
}
}