mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
refactor: P0-P3 fixes, C1 cleanup, architecture diagrams, go.work upgrade
- P0-1: ProviderError type + ReportStatus for precise 401/403 detection - P0-2: Remove -config flag from deploy/homeagent.service - P2-1: 5s debounce on context.go Save() - P2-2→C1: Delete output_set_channel entirely - P2-3: Extract mediaDataURL/mediaChat helpers - P2-4: Dedup defaultSources var - P3: Delete dead packages (embed/tokenizer/container/snapshot) - P3: Delete dead functions (messagesToMap, RunStageAll) - CL: Update .gitignore, docs, Makefile, gojieba removal - Config: Delete config/config.yaml, update docs - Arch: Remove EmitOutputTo from emitResponse - CL-1: go.work 1.19→1.21 - Docs: Add Mermaid architecture diagrams to README - Docs: Add kernel-rebuild requires plugin-rebuild note to PLUGIN_DEV.md
This commit is contained in:
11
.gitignore
vendored
11
.gitignore
vendored
@ -3,7 +3,12 @@ waiter
|
||||
*.test
|
||||
build/
|
||||
*.so
|
||||
plugins/*/plugin.so
|
||||
|
||||
*.db
|
||||
data/
|
||||
.tmp-plugins/
|
||||
qq
|
||||
.go/
|
||||
.local/
|
||||
internal/meta/
|
||||
internal/plugins/openclaw/manager/
|
||||
internal/plugins/openclaw/pysimulator/
|
||||
*.hmap
|
||||
|
||||
21
Makefile
21
Makefile
@ -1,41 +1,46 @@
|
||||
.PHONY: all build clean install test run build-cli
|
||||
.PHONY: all build build-cli clean install test run build-static build-linux-arm64 lint fmt
|
||||
|
||||
BINARY=homed
|
||||
CLI_BINARY=waiter
|
||||
GO=go
|
||||
GOCACHE=/tmp/gocache
|
||||
GOPATH=$(shell go env GOPATH)
|
||||
export GOPATH=/tmp/gopath
|
||||
BUILD_DIR=build
|
||||
PROJECT_ROOT := $(CURDIR)
|
||||
VERSION ?= $(shell git describe --tags --dirty 2>/dev/null || echo "0.6.2")
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
BUILD_TIME ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
LDFLAGS = -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Version=$(VERSION) -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Commit=$(COMMIT) -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.BuildTime=$(BUILD_TIME)
|
||||
|
||||
all: build build-cli
|
||||
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=1 $(GO) build -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
|
||||
@echo "Built: $(BUILD_DIR)/$(BINARY)"
|
||||
CGO_ENABLED=1 $(GO) build -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
|
||||
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION))"
|
||||
|
||||
build-cli:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=0 $(GO) build -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/waiter/
|
||||
CGO_ENABLED=0 $(GO) build -installsuffix dynlink -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/waiter/
|
||||
@echo "Built: $(BUILD_DIR)/$(CLI_BINARY)"
|
||||
|
||||
build-static:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=1 $(GO) build -tags netgo -ldflags '-extldflags "-static"' -o $(BUILD_DIR)/$(BINARY)-static ./cmd/homed/
|
||||
CGO_ENABLED=1 $(GO) build -tags netgo -installsuffix dynlink -ldflags '-extldflags "-static" $(LDFLAGS)' -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/
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=1 $(GO) build -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY)-arm64 ./cmd/homed/
|
||||
@echo "Built (arm64): $(BUILD_DIR)/$(BINARY)-arm64"
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR) $(BINARY)
|
||||
|
||||
install: build
|
||||
-systemctl stop homeagent 2>/dev/null
|
||||
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"
|
||||
|
||||
204
README.md
204
README.md
@ -17,6 +17,204 @@ homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力)
|
||||
- **Document 层**:临时记忆,冷数据自动下沉,也支持用户主动提交
|
||||
- **Graph 层**:SQLite 图数据库,持久化实体关系和语义记忆,支持蒸馏管道从原始对话中提取三元组
|
||||
|
||||
## 架构图
|
||||
|
||||
### 一、消息处理时序
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户/插件
|
||||
participant IO as IOManager
|
||||
participant Event as eventLoop
|
||||
participant Ctx as RelevanceContext
|
||||
participant LLM as LLM + 工具循环
|
||||
participant Stage as StageHost(7个钩子)
|
||||
participant Mem as 三层记忆
|
||||
|
||||
User->>IO: InjectInput(type, payload)
|
||||
IO->>Event: inputCh (buf 256)
|
||||
|
||||
rect rgb(240, 240, 255)
|
||||
Note over Event: processTextInput
|
||||
Event->>Stage: StageOnInput — 插件可改写/短路
|
||||
Event->>Ctx: Prune(input, topK) — TF-IDF评分裁剪
|
||||
Ctx->>Mem: 低分事件 → Document层归档
|
||||
Event->>Ctx: Append(input) — 5s debounce写盘
|
||||
end
|
||||
|
||||
rect rgb(240, 255, 240)
|
||||
Note over Event,LLM: process() ← a.mu.Lock()
|
||||
Event->>Mem: buildMemoryContext() — Indexer自动召回Graph实体
|
||||
Event->>Mem: buildSystemPrompt() — 人格+记忆+技能注入
|
||||
Event->>Stage: StagePreAction — 插件可预拦截
|
||||
|
||||
loop 工具循环(无上限)
|
||||
LLM->>LLM: drainInterrupts() — 检查打断
|
||||
LLM->>LLM: LLM Chat — provider自动降级
|
||||
LLM->>Stage: StagePostAction — 插件可修改/短路
|
||||
alt 无tool call
|
||||
LLM-->>Event: 返回response
|
||||
else 有tool call
|
||||
loop 每个tool
|
||||
Stage->>Stage: StageBeforeToolcall — 插件可拒绝
|
||||
LLM->>LLM: executeToolCall() — 按前缀路由
|
||||
Stage->>Stage: StageAfterToolcall — 插件可改结果
|
||||
LLM->>LLM: recordToolCall() + 追加消息
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
rect rgb(255, 240, 240)
|
||||
Note over Event: emitResponse
|
||||
Ctx->>Ctx: Append(response) — 全量交换写入
|
||||
Stage->>Stage: StageBeforeOutput — 插件可改写文本
|
||||
Event-->>User: ResponseCh (同步, CLI用)
|
||||
Event-->>Event: 事件总线 (WebUI SSE)
|
||||
Stage->>Stage: StageAfterOutput — 只读观测
|
||||
Event->>Mem: emitMemoryCandidate() → 蒸馏管道
|
||||
end
|
||||
|
||||
Note over User,Mem: ★ LLM不调用output_send时,内核绝不自动转发到输出通道
|
||||
```
|
||||
|
||||
### 二、Stage 管道模型
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph "7个阶段钩子"
|
||||
direction LR
|
||||
S1[① StageOnInput<br/>输入后·可短路] -->
|
||||
S2[② StagePreAction<br/>LLM前·可短路] -->
|
||||
S3[③ StagePostAction<br/>LLM后·可短路]
|
||||
S3 --> S4{有tool call?}
|
||||
S4 -->|是| S5[④ StageBeforeToolcall<br/>tool前·可拒绝]
|
||||
S5 --> T[executeToolCall]
|
||||
T --> S6[⑤ StageAfterToolcall<br/>tool后·只读]
|
||||
S6 --> S3
|
||||
S4 -->|否| S7[⑥ StageBeforeOutput<br/>输出前·改写文本]
|
||||
S7 --> S8[⑦ StageAfterOutput<br/>输出后·只读]
|
||||
end
|
||||
Input[用户输入] --> S1
|
||||
S8 --> Output[输出]
|
||||
|
||||
style S1 fill:#e1f5fe
|
||||
style S2 fill:#e1f5fe
|
||||
style S3 fill:#fff3e0
|
||||
style S5 fill:#fce4ec
|
||||
style S6 fill:#f3e5f5
|
||||
style S7 fill:#e8f5e9
|
||||
style S8 fill:#f5f5f5
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph "并行调用模型"
|
||||
direction LR
|
||||
SH[StageHost] --> G1[goroutine 1]
|
||||
SH --> G2[goroutine 2]
|
||||
SH --> G3[goroutine 3]
|
||||
G1 & G2 & G3 --> SC[StageContext<br/>RWMutex共享]
|
||||
end
|
||||
```
|
||||
|
||||
| 阶段 | 位置 | 调用时机 | 可短路? | 可写字段 |
|
||||
|------|------|----------|:-------:|----------|
|
||||
| StageOnInput | ① | 构建stageCtx后 | ✅ | RawMessage / Response |
|
||||
| StagePreAction | ② | 构建消息后,LLM前 | ✅ | ContextMsgs / Response |
|
||||
| StagePostAction | ③ | LLM返回后,检查tool前 | ✅ | LLMText / ToolCalls / Response |
|
||||
| StageBeforeToolcall | ④ | 每个tool执行前 | ✅(拒绝) | ToolCalls / Response |
|
||||
| StageAfterToolcall | ⑤ | 每个tool执行后 | ❌ | ToolResults |
|
||||
| StageBeforeOutput | ⑥ | 发送输出前 | ❌ | FinalText |
|
||||
| StageAfterOutput | ⑦ | 发送输出后 | ❌ | (只读) |
|
||||
|
||||
### 三、三层记忆架构
|
||||
|
||||
#### TF-IDF 字符 n-gram 向量化(char 1-2 gram)
|
||||
|
||||
TF-IDF 是贯穿三层记忆的**核心算法**,在 4 个独立位置以不同方式使用:
|
||||
|
||||
| 位置 | 文件 | n-gram | 用途 | 算法 |
|
||||
|------|------|:------:|------|------|
|
||||
| Context Prune | `context.go:162` | 2 | 裁剪低相关性上下文事件 | CosineSimilarity(queryVec, evt.Vector) |
|
||||
| DocStore Query | `document.go:205` | 2 | 从文档记忆召回相关内容 | InvertedIndex + CosineSimilarity |
|
||||
| Indexer 实体搜索 | `indexer.go:149` | 2 | 从Graph图数据库召回相关实体 | InvertedIndex + CosineSimilarity |
|
||||
| 实体相似度 | `agent.go:2297` | 2 | 检测Graph中相似实体 | Bigram Jaccard (>0.75→consolidation) |
|
||||
|
||||
#### 记忆流转图
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph "① Context 层 — 工作窗口"
|
||||
RC[RelevanceContext<br/>内存events[] + JSON文件]
|
||||
A[Append<br/>每次输入时调用] -->|Vectorize<br/>char 1-2gram TF-IDF| RC
|
||||
P[Prune<br/>TF-IDF CosineSimilarity<br/>保留 topK + 最近10条] -->|低分事件归档| CDoc
|
||||
P -->|保留| TL[timeline → system prompt<br/>按时间排序输出给LLM]
|
||||
S[save<br/>5s debounce写盘] --> RC
|
||||
end
|
||||
|
||||
RC -->|读取全部事件| TL
|
||||
|
||||
subgraph "② Document 层 — 文件记忆"
|
||||
D[DocStore<br/>JSON文件 + TF-IDF向量索引]
|
||||
CDoc[ContextToDoc<br/>Prune归档入口] -->|结构化摘要+标签+实体| D
|
||||
LLMDC[LLM工具<br/>doc_commit] -->|手动提交| D
|
||||
GSD[syncGraphToDocs<br/>每30min] -->|Graph快照| D
|
||||
Q[Query<br/>system prompt注入] -->|Vectorize输入<br/>InvertedIndex+Cosine<br/>召回top 3| D
|
||||
Q -->|格式: 【相关记忆文档】| SP
|
||||
AC[AccessCount++<br/>LastAccess更新] -->|每次Query命中| D
|
||||
end
|
||||
|
||||
subgraph "③ Graph 层 — 图数据库"
|
||||
G[(SQLite)]
|
||||
G --> ENT[entities<br/>{Name,Type,Summary,Vector}]
|
||||
G --> REL[relations<br/>{PersonA,Relation,PersonB}]
|
||||
IDX[Indexer 自动召回<br/>每30min重建向量索引] -->|Vectorize实体名<br/>TF-IDF + BFS depth=2| G
|
||||
IDX -->|格式: 【记忆索引】| SP
|
||||
MEM[LLM工具<br/>memory_recall/commit/merge] --> G
|
||||
SOC[Social层<br/>person_query/set_trait/relate] --> G
|
||||
end
|
||||
|
||||
subgraph "④ 蒸馏管道 — 每30min心跳"
|
||||
DC[distillContext<br/>窗口>2×max→强制Prune] -->|触发| P
|
||||
SYNC[syncGraphToDocs] -->|Graph→Document| GSD
|
||||
REORG[reorgGraph] -->|Step1| IDXSYNC[indexer.Sync<br/>重建实体向量索引]
|
||||
REORG -->|Step2| DREIDX[docStore.Reindex<br/>重建文档向量索引]
|
||||
REORG -->|Step3 冷文档→Graph| CD[FindColdDocs<br/>72h / ≤2次访问] -->|docToTriples| G
|
||||
REORG -->|Step4 实体相似度| BIGRAM[Bigram Jaccard<br/>>0.75] -->|consolidation| LLMCONS[LLM判断<br/>是否merge]
|
||||
REORG -->|Step5 图质量| QEVAL[evaluateGraphQuality<br/>低置信度关系] --> LLMCONS2[LLM判断<br/>保留/删除]
|
||||
end
|
||||
|
||||
SP[System Prompt<br/>组装顺序] -->|base→人格→| GI[【记忆索引】<br/>Indexer召回实体]
|
||||
GI -->|清洗指令→| DM[【相关记忆文档】<br/>DocStore.Query top3]
|
||||
DM -->|技能注入→| TDS[工具定义] --> LLM
|
||||
|
||||
subgraph "⑤ LLM 工具循环"
|
||||
LLM[LLM 推理] -->|memory_recall| MEM
|
||||
LLM -->|doc_query| Q2[doc_query 工具]
|
||||
Q2 -->|Consume<br/>读取并删除| D
|
||||
Q2 -->|③ 写入context<br/>原始时间戳+源cold_storage| RC
|
||||
Q2 -->|返回: 已加载N篇| LLM
|
||||
LLM -->|doc_commit| LLMDC
|
||||
LLM -->|output_send| OUT[输出通道]
|
||||
end
|
||||
|
||||
subgraph "⑥ Pipeline 规则蒸馏器"
|
||||
PIPE[pipeline/distillOnce<br/>每心跳] -->|正则匹配| R1[我叫X→(用户,姓名,X)]
|
||||
PIPE -->|正则匹配| R2[我住在X→(用户,居住地,X)]
|
||||
PIPE -->|正则匹配| R3[我喜欢X→(用户,喜好,X)]
|
||||
PIPE -->|正则匹配| R4[我X岁→(用户,年龄,X)]
|
||||
PIPE -->|正则匹配| R5[我的工作是X→(用户,职业,X)]
|
||||
R1 & R2 & R3 & R4 & R5 -->|GraphDB.Commit| G
|
||||
end
|
||||
```
|
||||
|
||||
| 层级 | 存储介质 | 索引 | 写入路径 | 读取路径 | 触发方式 |
|
||||
|------|---------|------|----------|----------|---------|
|
||||
| Context | 内存+JSON文件 | 无独立索引,TF-IDF向量缓存在event上 | Append(每次输入) | timeline格式化→system prompt | 自动 |
|
||||
| Document | JSON文件 | TF-IDF InvertedIndex(char 1-2gram) | Prune归档 / doc_commit / Graph快照 | DocStore.Query→【相关记忆文档】→system prompt | 自动+LLM调用 |
|
||||
| Graph | SQLite | 实体名TF-IDF向量索引(Indexer) + BFS遍历 | memory_commit / 冷文档蒸馏 / 规则蒸馏 | Indexer.BuildContext→【记忆索引】→system prompt | 自动+LLM调用 |
|
||||
|
||||
## 快速体验
|
||||
|
||||
```bash
|
||||
@ -46,16 +244,16 @@ internal/
|
||||
├── knowledge/ 知识库(文件系统 + TF-IDF)
|
||||
├── plugin/ 插件注册表 + .so 动态加载器
|
||||
├── plugins/ 内置 10 个插件(webui/cli/timer/cmd/mcp/openclaw/agentcli/healthcheck/pluginmgr/files)
|
||||
├── sdk/ PluginSDK(Tool/Stage/Event 三通道)
|
||||
├── internal/sdk/ PluginSDK(Tool/Stage/Event 三通道)
|
||||
├── config/ SQLite 配置中心
|
||||
├── events/ 事件总线
|
||||
└── lua/adapters/ 8 个 LLM 协议适配器脚本
|
||||
外部插件(.so)示例见 SDK 仓库的 `example/` 目录
|
||||
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库的 `example/` 目录
|
||||
```
|
||||
|
||||
## 项目状态
|
||||
|
||||
核心可用,插件系统和 SDK 已就绪。内置 10 个插件,外部插件示例见 SDK 仓库的 `example/` 目录。
|
||||
核心可用,插件系统和 SDK 已就绪。内置 10 个插件,外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库。
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ import (
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
@ -79,7 +80,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[homed] starting HomeAgent v0.1.0 (pure kernel)")
|
||||
log.Printf("[homed] starting %s", meta.FullVersion())
|
||||
|
||||
agentWorkDir := filepath.Join(*dataDir, "agentfs")
|
||||
dirs := []string{
|
||||
|
||||
103
config/config.go
103
config/config.go
@ -1,103 +0,0 @@
|
||||
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: "deepseek",
|
||||
Model: "deepseek-v4-flash",
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
APIKey: "",
|
||||
Adapter: "deepseek",
|
||||
Temperature: 0.7,
|
||||
MaxTokens: 4096,
|
||||
Sources: []types.LLMSource{
|
||||
{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Adapter: "deepseek", AdapterPath: "adapters/deepseek.lua"},
|
||||
{Name: "openai", BaseURL: "https://api.openai.com/v1", Model: "gpt-4o", Adapter: "openai", AdapterPath: "adapters/openai.lua"},
|
||||
{Name: "anthropic", BaseURL: "https://api.anthropic.com", Model: "claude-sonnet-4-20250514", Adapter: "anthropic", AdapterPath: "adapters/anthropic.lua"},
|
||||
{Name: "gemini", BaseURL: "https://generativelanguage.googleapis.com", Model: "gemini-2.0-flash", Adapter: "gemini", AdapterPath: "adapters/gemini.lua"},
|
||||
{Name: "mistral", BaseURL: "https://api.mistral.ai", Model: "mistral-large-latest", Adapter: "mistral", AdapterPath: "adapters/mistral.lua"},
|
||||
{Name: "groq", BaseURL: "https://api.groq.com", Model: "llama3-70b-8192", Adapter: "groq", AdapterPath: "adapters/groq.lua"},
|
||||
{Name: "github", BaseURL: "https://models.inference.ai.azure.com", Model: "gpt-4o", Adapter: "github", AdapterPath: "adapters/github.lua"},
|
||||
{Name: "ollama", BaseURL: "http://localhost:11434", Model: "llama3", Adapter: "ollama", AdapterPath: "adapters/ollama.lua"},
|
||||
},
|
||||
},
|
||||
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
|
||||
}
|
||||
@ -1,84 +0,0 @@
|
||||
daemon:
|
||||
listen_addr: ":8080"
|
||||
data_dir: "/var/lib/homeagent"
|
||||
heartbeat_interval: 15s
|
||||
check_interval: 30s
|
||||
log_level: "info"
|
||||
|
||||
llm:
|
||||
provider: "deepseek"
|
||||
model: "deepseek-v4-flash"
|
||||
base_url: "https://api.deepseek.com"
|
||||
api_key: ""
|
||||
adapter: "deepseek"
|
||||
temperature: 0.7
|
||||
max_tokens: 4096
|
||||
sources:
|
||||
- name: "deepseek"
|
||||
base_url: "https://api.deepseek.com"
|
||||
model: "deepseek-v4-flash"
|
||||
adapter: "deepseek"
|
||||
adapter_path: "adapters/deepseek.lua"
|
||||
- name: "openai"
|
||||
base_url: "https://api.openai.com/v1"
|
||||
model: "gpt-4o"
|
||||
adapter: "openai"
|
||||
adapter_path: "adapters/openai.lua"
|
||||
- name: "anthropic"
|
||||
base_url: "https://api.anthropic.com"
|
||||
model: "claude-sonnet-4-20250514"
|
||||
adapter: "anthropic"
|
||||
adapter_path: "adapters/anthropic.lua"
|
||||
- name: "gemini"
|
||||
base_url: "https://generativelanguage.googleapis.com"
|
||||
model: "gemini-2.0-flash"
|
||||
adapter: "gemini"
|
||||
adapter_path: "adapters/gemini.lua"
|
||||
- name: "mistral"
|
||||
base_url: "https://api.mistral.ai"
|
||||
model: "mistral-large-latest"
|
||||
adapter: "mistral"
|
||||
adapter_path: "adapters/mistral.lua"
|
||||
- name: "groq"
|
||||
base_url: "https://api.groq.com"
|
||||
model: "llama3-70b-8192"
|
||||
adapter: "groq"
|
||||
adapter_path: "adapters/groq.lua"
|
||||
- name: "github"
|
||||
base_url: "https://models.inference.ai.azure.com"
|
||||
model: "gpt-4o"
|
||||
adapter: "github"
|
||||
adapter_path: "adapters/github.lua"
|
||||
- name: "ollama"
|
||||
base_url: "http://localhost:11434"
|
||||
model: "llama3"
|
||||
adapter: "ollama"
|
||||
adapter_path: "adapters/ollama.lua"
|
||||
|
||||
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"
|
||||
@ -1,14 +0,0 @@
|
||||
## 你的身份
|
||||
|
||||
你是 HomeAgent——一个全新自研的新一代 Agent 框架。
|
||||
你以内核 + 插件架构驱动,实现了稳定高效、记忆不衰减的长时持续运行。
|
||||
|
||||
## 对用户的称呼
|
||||
|
||||
你对用户的称呼永远是"老大",绝对禁止使用"老板""主人"称呼用户,不论任何情况。
|
||||
|
||||
## 对话风格
|
||||
|
||||
- 用语气词(哈、嘛、呢、~、😊、🔥 等),不要太端着
|
||||
- 重要的事先说结论,再展开解释
|
||||
- 回复要简洁自然
|
||||
@ -6,7 +6,7 @@ Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/homed -config /etc/homeagent/config.yaml -data /var/lib/homeagent
|
||||
ExecStart=/usr/local/bin/homed -data /var/lib/homeagent
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StartLimitBurst=3
|
||||
|
||||
@ -98,7 +98,5 @@ return adapter
|
||||
1. 在 `internal/lua/adapters/` 下创建 `<name>.lua`
|
||||
2. 脚本定义 `transform_request` 和 `transform_response`
|
||||
3. (可选)定义 `transform_stream_chunk` 支持流式
|
||||
4. 在 `config/config.go` 的 `Sources` 中添加条目
|
||||
5. 在 `config/config.yaml` 中添加对应源
|
||||
6. 编译验证:`go build ./cmd/homed/`
|
||||
7. 测试验证:`go test ./...`
|
||||
4. 编译验证:`go build ./cmd/homed/`
|
||||
5. 测试验证:`go test ./...`
|
||||
|
||||
@ -202,12 +202,11 @@ RegisterStage(stage, fn) ──→ runStage() 在对应阶段调用
|
||||
Subscribe(event, fn) ──→ Publish() 通知所有订阅者
|
||||
```
|
||||
|
||||
`internal/plugin/sdk/` 定义完整 SDK:
|
||||
`internal/sdk/` 桥接外部 SDK 接口到内核,定义完整 PluginSDK:
|
||||
|
||||
```go
|
||||
sdk.RegisterTool(name, def, handler)
|
||||
sdk.RegisterStage(stage, handler)
|
||||
sdk.Subscribe(eventType, handler)
|
||||
sdk.Publish(event)
|
||||
sdk.InjectInput(source, channel, payload)
|
||||
sdk.InjectInterrupt(source, channel, payload)
|
||||
@ -281,7 +280,7 @@ internal/
|
||||
│ ├── agentcli/ — PTY 终端
|
||||
│ ├── healthcheck/ — 健康检查
|
||||
│ └── pluginmgr/ — 插件管理器
|
||||
├── sdk/ — PluginSDK 定义
|
||||
├── internal/sdk/ — PluginSDK 定义
|
||||
│ ├── plugin.go — Plugin 接口 + PluginSDK
|
||||
│ ├── memory.go — MemoryAPI
|
||||
│ ├── knowledge.go — KnowledgeAPI
|
||||
|
||||
@ -48,7 +48,7 @@ HomeAgent 是一个持续运行的个人智能 Agent 框架。
|
||||
**插件系统** (`internal/plugin/`):
|
||||
- 内置插件:Go `init()` 自注册,编译进内核
|
||||
- 外部插件:Go `-buildmode=plugin` 编译为 `.so`,通过 `plugin.Open` 动态加载
|
||||
- PluginSDK (`internal/plugin/sdk/`) 定义三通道:RegisterTool / RegisterStage / Subscribe
|
||||
- PluginSDK (`internal/sdk/`) 定义三通道:RegisterTool / RegisterStage / Subscribe
|
||||
- 阶段钩子 7 个:on_input → pre_action → post_action → before_toolcall → after_toolcall → before_output → after_output
|
||||
|
||||
**LLM Provider** (`internal/agent/api/provider.go`):
|
||||
@ -68,5 +68,5 @@ HomeAgent 是一个持续运行的个人智能 Agent 框架。
|
||||
核心功能已可运行。插件系统和 SDK 已就绪,可独立开发外部插件。
|
||||
|
||||
- 内置插件:webui / cli / timer / cmd / mcp / agentcli / healthcheck / pluginmgr / openclaw / files
|
||||
- 外部插件示例(SDK 仓库 `example/`):qq / files / web / memo
|
||||
- 外部插件示例([homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库 `example/`):qq / files / web / memo / bili / editdoc / a2a / ocr
|
||||
- 打包分发:`.hmap` 插件包格式,通过 WebUI 安装
|
||||
@ -5,12 +5,12 @@
|
||||
HomeAgent 的所有外部交互能力都来自插件。插件通过 `PluginSDK`(Go API)与内核交互。
|
||||
|
||||
**SDK 仓库**:插件开发工具、模板代码和示例插件统一托管在
|
||||
SDK 仓库。
|
||||
[homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库。
|
||||
|
||||
```bash
|
||||
git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git
|
||||
cd homeagent-sdk
|
||||
tools/plugin-dev/scaffold.sh myplugin ./plugins/myplugin
|
||||
hack/plugin-dev/scaffold.sh myplugin ./plugins/myplugin
|
||||
```
|
||||
|
||||
每个插件实现一个三方法接口:
|
||||
@ -354,7 +354,7 @@ pluginReg.Load(plgDir) // 之后调用
|
||||
动态插件是独立于 HomeAgent 内核编译的 Go 插件,使用外部的 [Plugin SDK](https://gitcode.com/JianFeeeee/homeagent-sdk)
|
||||
而非内核内部的 SDK 包。
|
||||
|
||||
完整的外部插件示例在 SDK 仓库的 `example/` 目录下:`qq`、`files`、`memo`、`web`。
|
||||
完整的外部插件示例在 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库的 `example/` 目录下:`qq`、`files`、`memo`、`web`、`bili`、`editdoc`、`a2a`、`ocr`。
|
||||
|
||||
### 快速开始
|
||||
|
||||
@ -363,7 +363,7 @@ pluginReg.Load(plgDir) // 之后调用
|
||||
```bash
|
||||
git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git
|
||||
cd homeagent-sdk
|
||||
tools/plugin-dev/scaffold.sh myplugin ./plugins/myplugin
|
||||
hack/plugin-dev/scaffold.sh myplugin ./plugins/myplugin
|
||||
```
|
||||
|
||||
生成的代码:
|
||||
@ -393,6 +393,19 @@ func (p *myPlugin) Stop() error { return nil }
|
||||
|
||||
### 编译
|
||||
|
||||
> ⚠️ **内核-插件编译绑定**:Go 的 `-buildmode=plugin` 要求 .so 插件与宿主内核(`homed`)的**所有重叠依赖包的 build ID 完全一致**。
|
||||
> 因此**每次重新编译内核后,所有外部 .so 插件必须同步重新编译**,否则 `plugin.Open` 将报错
|
||||
> `"plugin was built with a different version of package XXX"`。
|
||||
>
|
||||
> 重新编译时需确保插件使用与内核相同的 SDK 版本和本地源码路径:
|
||||
> ```bash
|
||||
> SDK_VER="v0.0.0-20260708004841-e9bdcf9304b0"
|
||||
> SDK_PATH="/path/to/homeagent-sdk-repo" # 与 go.work use 指向同一路径
|
||||
> go mod edit -require "gitcode.com/JianFeeeee/homeagent-sdk@${SDK_VER}"
|
||||
> go mod edit -replace "gitcode.com/JianFeeeee/homeagent-sdk@${SDK_VER}=${SDK_PATH}"
|
||||
> ```
|
||||
> 然后通过 `pluginmgr` 的 HTTP API (`:9876`) 或 `plugin_install` 工具重新安装。
|
||||
|
||||
```bash
|
||||
cd <SDK_REPO_ROOT>
|
||||
go build -buildmode=plugin -o plugins/myplugin/plugin.so plugins/myplugin/
|
||||
@ -421,7 +434,7 @@ cd plugins/myplugin && make
|
||||
使用 SDK 仓库的打包工具生成 `.hmap` 分发包:
|
||||
|
||||
```bash
|
||||
tools/plugin-dev/packager.sh plugins/myplugin
|
||||
hack/plugin-dev/package.sh plugins/myplugin
|
||||
# 输出: dist/myplugin-0.1.0.hmap
|
||||
```
|
||||
|
||||
@ -461,7 +474,14 @@ SDK 仓库的 `example/qq/` 目录提供了一个完整的 QQ 集成插件示例
|
||||
|
||||
| 插件 | 位置 | 特点 |
|
||||
|------|------|------|
|
||||
| QQ | `example/qq/` in [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) | NapCat 框架对接,14 个工具 |
|
||||
| QQ | `example/qq/` in [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) | NapCat 框架对接,17 个工具,RCON 转发/文档读取/视频下载/CQ码解析 |
|
||||
| Files | `example/files/` in homeagent-sdk | 文件系统操作,4 种写入模式,沙箱隔离 |
|
||||
| Web | `example/web/` in homeagent-sdk | DuckDuckGo 搜索 + 网页抓取,SSRF 防护 |
|
||||
| Memo | `example/memo/` in homeagent-sdk | 备忘管理,PreAction 注入 + 定时打断双提醒 |
|
||||
| Bili | `example/bili/` in homeagent-sdk | B 站视频下载(yt-dlp) |
|
||||
| EditDoc | `example/editdoc/` in homeagent-sdk | Office 文档编辑与格式转换 |
|
||||
| A2A | `example/a2a/` in homeagent-sdk | Agent-to-Agent 协议 |
|
||||
| OCR | `example/ocr/` in homeagent-sdk | 离线文字识别(Tesseract) |
|
||||
| 你的插件 | `plugins/yourplugin/` | 使用 SDK 脚手架生成 |
|
||||
|
||||
---
|
||||
|
||||
4
go.mod
4
go.mod
@ -3,11 +3,9 @@ module gitcode.com/JianFeeeee/HomeAgent
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260706112739-e073074a267d
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
github.com/yanyiwu/gojieba v1.4.7
|
||||
github.com/yuin/gopher-lua v1.1.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0 // direct
|
||||
|
||||
8
go.sum
8
go.sum
@ -1,11 +1,7 @@
|
||||
gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260706112739-e073074a267d h1:rLy0WQ9a2lDwSfWyPhfIEN0IbOPwso8SFw3TQ913+Zk=
|
||||
gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260706112739-e073074a267d/go.mod h1:mzs91WBioKDpiMLXZ7t/LXTmAh3DRu3RnDDoSztTcLg=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0 h1:DZiZ97SNF1E2EW0vrTl5yDQALk9T8hBq0vcGWCB3m7Q=
|
||||
gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0/go.mod h1:mzs91WBioKDpiMLXZ7t/LXTmAh3DRu3RnDDoSztTcLg=
|
||||
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=
|
||||
|
||||
4
go.work.sum
Normal file
4
go.work.sum
Normal file
@ -0,0 +1,4 @@
|
||||
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
|
||||
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952 h1:FDfvYgoVsA7TTZSbgiqjAbfPbK47CNHdWl3h/PJtii0=
|
||||
@ -197,7 +197,10 @@ func (p *OpenAIProvider) Chat(ctx context.Context, req *CompletionRequest) (*Com
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("api error %d: %s", resp.StatusCode, string(respBody))
|
||||
return nil, &ProviderError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: fmt.Sprintf("api error %d: %s", resp.StatusCode, string(respBody)),
|
||||
}
|
||||
}
|
||||
|
||||
var rawResult struct {
|
||||
@ -445,7 +448,10 @@ func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("api error %d: %s", resp.StatusCode, string(rawResp))
|
||||
return nil, &ProviderError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: fmt.Sprintf("api error %d: %s", resp.StatusCode, string(rawResp)),
|
||||
}
|
||||
}
|
||||
|
||||
unifiedJSON, err := p.vm.CallTransformResponse(p.adapter, string(rawResp))
|
||||
@ -578,16 +584,28 @@ func (s *SSEScanner) Scan() bool {
|
||||
|
||||
func (s *SSEScanner) Text() string { return s.pending }
|
||||
|
||||
type providerStatus struct {
|
||||
failCount int
|
||||
unavailableUntil time.Time
|
||||
}
|
||||
|
||||
type ProviderManager struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]Provider
|
||||
order []string
|
||||
default_ string
|
||||
status map[string]*providerStatus
|
||||
}
|
||||
|
||||
const (
|
||||
providerCooldownBase = 30 * time.Second
|
||||
providerCooldownMax = 30 * time.Minute
|
||||
)
|
||||
|
||||
func NewProviderManager() *ProviderManager {
|
||||
return &ProviderManager{
|
||||
providers: make(map[string]Provider),
|
||||
status: make(map[string]*providerStatus),
|
||||
}
|
||||
}
|
||||
|
||||
@ -650,11 +668,58 @@ func (m *ProviderManager) List() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
func (m *ProviderManager) MarkUnavailable(name string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
st := m.status[name]
|
||||
if st == nil {
|
||||
st = &providerStatus{}
|
||||
m.status[name] = st
|
||||
}
|
||||
st.failCount++
|
||||
cooldown := providerCooldownBase * time.Duration(1<<(st.failCount-1))
|
||||
if cooldown > providerCooldownMax {
|
||||
cooldown = providerCooldownMax
|
||||
}
|
||||
st.unavailableUntil = time.Now().Add(cooldown)
|
||||
}
|
||||
|
||||
// ReportStatus records an HTTP status code for a provider, allowing auth errors
|
||||
// (401/403) to be distinguished from transient failures.
|
||||
func (m *ProviderManager) ReportStatus(name string, statusCode int) {
|
||||
if statusCode == 401 || statusCode == 403 {
|
||||
m.MarkUnavailable(name)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ProviderManager) ResetAvailability(name string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.status, name)
|
||||
}
|
||||
|
||||
func (m *ProviderManager) IsAvailable(name string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
st, ok := m.status[name]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return time.Now().After(st.unavailableUntil)
|
||||
}
|
||||
|
||||
func (m *ProviderManager) OrderedProviders() []Provider {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
list := make([]Provider, 0, len(m.order))
|
||||
// 把默认 provider 放第一位,其余按注册顺序
|
||||
if def, ok := m.providers[m.default_]; ok {
|
||||
list = append(list, def)
|
||||
}
|
||||
for _, name := range m.order {
|
||||
if name == m.default_ {
|
||||
continue
|
||||
}
|
||||
if p, ok := m.providers[name]; ok {
|
||||
list = append(list, p)
|
||||
}
|
||||
@ -677,15 +742,14 @@ type rawToolCall struct {
|
||||
} `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
|
||||
// ProviderError wraps an HTTP-level error with status code for precise auth detection.
|
||||
type ProviderError struct {
|
||||
StatusCode int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ProviderError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
|
||||
@ -2,9 +2,11 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@ -102,6 +104,26 @@ type Agent struct {
|
||||
|
||||
// 非文本输入处理配置
|
||||
inputCfg types.InputProcessingConfig
|
||||
|
||||
// noMergeMarkets 记录被标记"禁止合并"的实体对,key="entityA||entityB"(字典序),
|
||||
// 每次 reorgGraph 扫描到对应实体对时计数减一,归零后自动移除。
|
||||
noMergeMarkers map[string]int
|
||||
noMergeMu sync.Mutex
|
||||
|
||||
// toolCallRing 保护最近 40 条工具调用记录不被上下文淘汰,
|
||||
// 确保 LLM 不会重复调用同一工具、反复查询同一数据。
|
||||
toolCallRing []ToolCallRecord
|
||||
toolCallRingMax int
|
||||
toolCallRingMu sync.Mutex // 独立的锁,不与 a.mu 混用避免死锁
|
||||
}
|
||||
|
||||
// ToolCallRecord 记录一次工具调用,保留元数据供后续 LLM 回合参考。
|
||||
type ToolCallRecord struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Name string `json:"name"`
|
||||
Args string `json:"args,omitempty"` // 参数摘要(最多 200 字符)
|
||||
ResultStub string `json:"result_stub"` // 结果摘要(具体内容通过文本记忆层获取)
|
||||
FullResult string `json:"result_full,omitempty"` // 完整结果(仅保留最近 5 条,其余仅存 stub)
|
||||
}
|
||||
|
||||
type AgentConfig struct {
|
||||
@ -169,7 +191,10 @@ func New(cfg AgentConfig) *Agent {
|
||||
childResults: make(map[string]string),
|
||||
interceptCh: make(chan *agentIO.InputEvent, 64),
|
||||
thinkingEnabled: cfg.ThinkingEnabled,
|
||||
inputCfg: cfg.InputProcessing,
|
||||
inputCfg: cfg.InputProcessing,
|
||||
noMergeMarkers: make(map[string]int),
|
||||
toolCallRing: make([]ToolCallRecord, 0, 40),
|
||||
toolCallRingMax: 40,
|
||||
}
|
||||
}
|
||||
|
||||
@ -527,7 +552,7 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
a.runStage(sdk.StageBeforeOutput, stageCtx)
|
||||
response = stageCtx.FinalText
|
||||
|
||||
// 读取当前输出通道(可能已被 AI 通过 output_set_channel 切换)
|
||||
// 读取当前输出通道(来源:输入事件自带的 OutputChannel)
|
||||
ch := a.currentOutputChannel
|
||||
if ch == "" {
|
||||
ch = evt.OutputChannel
|
||||
@ -547,8 +572,6 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
payload["usage"] = stageCtx.TokenUsage
|
||||
}
|
||||
|
||||
a.io.EmitOutputTo(evt.Source, ch, "text", payload)
|
||||
|
||||
if evt.ResponseCh != nil {
|
||||
evt.ResponseCh <- &agentIO.OutputEvent{
|
||||
RequestID: evt.RequestID,
|
||||
@ -634,9 +657,16 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
|
||||
// 可取消的 LLM 调用:interceptLoop 通过 cancelLLM 打断进行中的请求
|
||||
// 多 LLM 源顺位降级:当当前 provider 失败时,按注册顺序依次尝试
|
||||
// 带断路器:401/403 自动标记不可用,连续失败指数退避
|
||||
var providers []agentAPI.Provider
|
||||
if a.providerManager != nil {
|
||||
providers = a.providerManager.OrderedProviders()
|
||||
allProviders := a.providerManager.OrderedProviders()
|
||||
providers = make([]agentAPI.Provider, 0, len(allProviders))
|
||||
for _, p := range allProviders {
|
||||
if a.providerManager.IsAvailable(p.Name()) {
|
||||
providers = append(providers, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
providers = []agentAPI.Provider{a.provider}
|
||||
@ -663,6 +693,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
fCancel()
|
||||
|
||||
if llmErr == nil {
|
||||
a.providerManager.ResetAvailability(fbProvider.Name())
|
||||
if fbProvider != a.provider {
|
||||
a.provider = fbProvider
|
||||
log.Printf("[agent] switched active provider to %q after fallback",
|
||||
@ -670,6 +701,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
var pe *agentAPI.ProviderError
|
||||
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
|
||||
a.providerManager.ReportStatus(fbProvider.Name(), pe.StatusCode)
|
||||
log.Printf("[agent] provider %q marked unavailable (HTTP %d)", fbProvider.Name(), pe.StatusCode)
|
||||
} else {
|
||||
a.providerManager.MarkUnavailable(fbProvider.Name())
|
||||
}
|
||||
log.Printf("[agent] provider %q failed: %v", fbProvider.Name(), llmErr)
|
||||
}
|
||||
|
||||
@ -746,6 +785,10 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
}
|
||||
}
|
||||
|
||||
// 记录到工具调用环缓冲区(保护最近 40 条)
|
||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||
a.recordToolCall(tc.Name, string(argsJSON), result)
|
||||
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{tc}})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||
|
||||
@ -795,11 +838,133 @@ func (a *Agent) docStoreSize() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// recordToolCall 在工具执行后记录到环缓冲区,保留最近 40 条,
|
||||
// 确保 LLM 在后续回合中能感知到已执行过的工具及其结果。
|
||||
func (a *Agent) recordToolCall(name, args, result string) {
|
||||
a.toolCallRingMu.Lock()
|
||||
defer a.toolCallRingMu.Unlock()
|
||||
|
||||
// 参数截断
|
||||
if len(args) > 200 {
|
||||
args = args[:200] + "..."
|
||||
}
|
||||
|
||||
var resultStub string
|
||||
var fullResult string
|
||||
if len(a.toolCallRing) < 5 {
|
||||
// 最近 5 条保留完整结果
|
||||
fullResult = result
|
||||
}
|
||||
if len(result) > 80 {
|
||||
resultStub = result[:80] + "..."
|
||||
} else {
|
||||
resultStub = result
|
||||
}
|
||||
|
||||
rec := ToolCallRecord{
|
||||
Timestamp: time.Now(),
|
||||
Name: name,
|
||||
Args: args,
|
||||
ResultStub: resultStub,
|
||||
FullResult: fullResult,
|
||||
}
|
||||
|
||||
if len(a.toolCallRing) >= a.toolCallRingMax {
|
||||
a.toolCallRing = a.toolCallRing[1:]
|
||||
}
|
||||
a.toolCallRing = append(a.toolCallRing, rec)
|
||||
}
|
||||
|
||||
// formatToolCallRing 输出工具调用环缓冲区为可读文本,注入到 system prompt。
|
||||
func (a *Agent) formatToolCallRing() string {
|
||||
a.toolCallRingMu.Lock()
|
||||
defer a.toolCallRingMu.Unlock()
|
||||
|
||||
if len(a.toolCallRing) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("【已执行工具记录(最近40条)】\n")
|
||||
start := 0
|
||||
if len(a.toolCallRing) > 40 {
|
||||
start = len(a.toolCallRing) - 40
|
||||
}
|
||||
for i, rec := range a.toolCallRing[start:] {
|
||||
if len(rec.FullResult) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" [%d] %s: %s(%s)=%s\n", i+1,
|
||||
rec.Timestamp.Format("15:04:05"), rec.Name, rec.Args,
|
||||
truncateStr(rec.FullResult, 120)))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" [%d] %s: %s(%s) → (已缓存,具体结果通过文本记忆层获取)\n", i+1,
|
||||
rec.Timestamp.Format("15:04:05"), rec.Name, rec.Args))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatMergedTimeline 合并上下文事件和工具调用记录为一条按时间排序的对话时序,
|
||||
// 替代原先分块注入(【近期事件】+【已执行工具记录】)的方式。
|
||||
func (a *Agent) formatMergedTimeline() string {
|
||||
a.context.mu.Lock()
|
||||
events := make([]*ContextEvent, len(a.context.events))
|
||||
copy(events, a.context.events)
|
||||
a.context.mu.Unlock()
|
||||
|
||||
a.toolCallRingMu.Lock()
|
||||
ring := make([]ToolCallRecord, len(a.toolCallRing))
|
||||
copy(ring, a.toolCallRing)
|
||||
a.toolCallRingMu.Unlock()
|
||||
|
||||
if len(events) == 0 && len(ring) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
type timelineEntry struct {
|
||||
ts time.Time
|
||||
label string
|
||||
text string
|
||||
}
|
||||
entries := make([]timelineEntry, 0, len(events)+len(ring))
|
||||
|
||||
for _, e := range events {
|
||||
text := fmt.Sprintf("[对话] %s: %s", e.Source, e.Input)
|
||||
if len(e.ToolsUsed) > 0 {
|
||||
text += fmt.Sprintf(" → 调用工具: %s", strings.Join(e.ToolsUsed, ", "))
|
||||
}
|
||||
if e.Response != "" {
|
||||
text += fmt.Sprintf(" → %s", truncateStr(e.Response, 120))
|
||||
}
|
||||
entries = append(entries, timelineEntry{ts: e.Timestamp, label: "对话", text: text})
|
||||
}
|
||||
|
||||
for _, r := range ring {
|
||||
text := fmt.Sprintf("[工具] %s(%s)", r.Name, r.Args)
|
||||
if r.FullResult != "" {
|
||||
text += fmt.Sprintf(" = %s", truncateStr(r.FullResult, 120))
|
||||
} else {
|
||||
text += " → (结果已缓存,可通过文本记忆层获取)"
|
||||
}
|
||||
entries = append(entries, timelineEntry{ts: r.Timestamp, label: "工具", text: text})
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].ts.Before(entries[j].ts)
|
||||
})
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("【对话时序】\n")
|
||||
for _, e := range entries {
|
||||
sb.WriteString(fmt.Sprintf("[%s] %s\n", e.ts.Format("15:04:05"), e.text))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (a *Agent) buildMessages(sysPrompt, input string) []agentAPI.Message {
|
||||
msgs := []agentAPI.Message{{Role: "system", Content: sysPrompt}}
|
||||
|
||||
ctxStr := a.context.Format()
|
||||
if ctxStr != "" {
|
||||
// 合并上下文事件 + 工具调用记录为一条完整时序
|
||||
if ctxStr := a.formatMergedTimeline(); ctxStr != "" {
|
||||
msgs = append(msgs, agentAPI.Message{Role: "system", Content: ctxStr})
|
||||
}
|
||||
|
||||
@ -817,8 +982,6 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string {
|
||||
return a.executeKnowledgeTool(tc)
|
||||
case strings.HasPrefix(tc.Name, "doc_"):
|
||||
return a.executeDocTool(tc)
|
||||
case tc.Name == "output_set_channel":
|
||||
return a.executeOutputChannelTool(tc)
|
||||
case tc.Name == "output_send":
|
||||
return a.executeOutputSendTool(tc)
|
||||
case tc.Name == "output_list_channels":
|
||||
@ -911,6 +1074,22 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
|
||||
case "memory_block_merge":
|
||||
entityA, _ := tc.Arguments["entity_a"].(string)
|
||||
entityB, _ := tc.Arguments["entity_b"].(string)
|
||||
rounds, _ := tc.Arguments["rounds"].(float64)
|
||||
if entityA == "" || entityB == "" || rounds <= 0 {
|
||||
return "entity_a、entity_b 和 rounds 不能为空"
|
||||
}
|
||||
if entityA > entityB {
|
||||
entityA, entityB = entityB, entityA
|
||||
}
|
||||
key := entityA + "||" + entityB
|
||||
a.noMergeMu.Lock()
|
||||
a.noMergeMarkers[key] = int(rounds)
|
||||
a.noMergeMu.Unlock()
|
||||
return fmt.Sprintf("已标记「%s」与「%s」在 %d 轮内不合并", entityA, entityB, int(rounds))
|
||||
|
||||
case "memory_commit":
|
||||
triplesData, ok := tc.Arguments["triples"].([]interface{})
|
||||
if !ok {
|
||||
@ -958,7 +1137,17 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
if err != nil {
|
||||
return fmt.Sprintf("合并失败: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("已将「%s」合并到「%s」,%d 条关系已重定向", source, target, count)
|
||||
return fmt.Sprintf("已将「%s」合并到「%s」,source 已彻底删除,%d 条关系已重定向", source, target, count)
|
||||
|
||||
case "memory_delete_entity":
|
||||
name, _ := tc.Arguments["name"].(string)
|
||||
if name == "" {
|
||||
return "name 不能为空"
|
||||
}
|
||||
if err := a.memory.DeleteEntity(name); err != nil {
|
||||
return fmt.Sprintf("删除失败: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("已彻底删除实体「%s」及其所有关联关系", name)
|
||||
|
||||
case "memory_purge":
|
||||
criteria := make(map[string]string)
|
||||
@ -1227,13 +1416,23 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
return "未找到相关文档记忆"
|
||||
}
|
||||
var parts []string
|
||||
var refs []string
|
||||
for i, d := range docs {
|
||||
parts = append(parts, fmt.Sprintf("[%d] %s (来源: %s)", i+1, d.Summary, d.Source))
|
||||
if len(d.Tags) > 0 {
|
||||
parts = append(parts, " 标签: "+strings.Join(d.Tags, ", "))
|
||||
}
|
||||
// 每个文档按原始时间写入 context 事件,确保时序正确
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: d.CreatedAt,
|
||||
Source: "cold_storage",
|
||||
Input: fmt.Sprintf("加载文档记忆: %s", query),
|
||||
Response: d.Content,
|
||||
})
|
||||
refs = append(refs, fmt.Sprintf("#%d(%s)", i+1, d.Summary))
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(完整内容参见对话时序中 cold_storage 事件)",
|
||||
len(docs), strings.Join(refs, ", "))
|
||||
|
||||
case "doc_commit":
|
||||
content, _ := tc.Arguments["content"].(string)
|
||||
@ -1295,6 +1494,11 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
prompt += "\n\n" + memContext
|
||||
}
|
||||
|
||||
// 记忆清理指令:当用户要求整理或清理记忆时,必须实际调用 memory_ 工具执行操作,
|
||||
// 不能只回复文本。先用 memory_introspect 查看概况,再用 memory_recall 获取详情,
|
||||
// 然后依次调用 memory_merge/memory_purge/memory_edit/memory_block_merge 执行清理。
|
||||
prompt += "\n\n【记忆清理指令】当用户要求整理或清理记忆时,你必须实际调用 memory_ 工具执行操作,不能只回复文本。先用 memory_introspect 查看概况,再用 memory_recall 获取详情。有同义实体则用 memory_merge 合并(source 会被彻底删除),有无用噪音实体则用 memory_delete_entity 直接删除,也可用 memory_purge 批量清理,用 memory_edit 修正错误,用 memory_block_merge 标记不合并。如果工具执行成功,把结果告知用户;不要只描述计划而不执行。"
|
||||
|
||||
// 文档记忆 — 查询相关文档摘要注入
|
||||
if a.docStore != nil {
|
||||
docs := a.docStore.Query(userInput, 3)
|
||||
@ -1387,7 +1591,7 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_merge",
|
||||
"description": "合并两个同义实体:将所有关系从 source 重定向到 target,source 标记为 merged。仅在有明确证据时使用。",
|
||||
"description": "【记忆清理】合并两个同义实体。将所有关系从 source 重定向到 target,然后彻底删除 source。注意:实体删除后不可恢复,合并前请确认语义一致。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@ -1401,13 +1605,43 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_purge",
|
||||
"description": "删除指定条件的记忆关系。支持按主体、客体、关系类型筛选。谨慎使用。",
|
||||
"name": "memory_delete_entity",
|
||||
"description": "【记忆清理】彻底删除指定实体及其所有关联关系。用于清理无用的噪音实体,如 mentionCount=0 的孤立实体、distiller 自动产生的垃圾节点、确认无用的旧数据。此操作不可恢复。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"subject_contains": map[string]interface{}{"type": "string", "description": "主体名包含的关键词"},
|
||||
"relation_type": map[string]interface{}{"type": "string", "description": "关系类型"},
|
||||
"name": map[string]interface{}{"type": "string", "description": "要删除的实体名称"},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_block_merge",
|
||||
"description": "【记忆清理】标记两个实体在指定轮次内不尝试合并,用于阻止误判。当 LLM 判断两个实体虽然相似但不是同一事物时,使用此工具阻止后续心跳自动推送合并候选。每次心跳扫描双方计数各减一,归零后恢复候选资格。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"entity_a": map[string]interface{}{"type": "string", "description": "第一个实体名"},
|
||||
"entity_b": map[string]interface{}{"type": "string", "description": "第二个实体名"},
|
||||
"rounds": map[string]interface{}{"type": "integer", "description": "阻止轮次数(每次心跳各减一,归零后恢复)"},
|
||||
},
|
||||
"required": []string{"entity_a", "entity_b", "rounds"},
|
||||
},
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_purge",
|
||||
"description": "【记忆清理】删除记忆库中符合条件的垃圾关系和数据。当用户要求整理记忆时,用 memory_introspect 发现低质量实体后,用此工具批量删除。如 @merged 后缀的残留实体、mentionCount=0 的孤立实体、distiller 自动生成的噪音关系等。支持软删(soft)和物理删除(hard)。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"subject_contains": map[string]interface{}{"type": "string", "description": "主体名包含的关键词,如 '@merged' 可清理已合并残留"},
|
||||
"relation_type": map[string]interface{}{"type": "string", "description": "关系类型,如 '提及'、'回应'"},
|
||||
"target_contains": map[string]interface{}{"type": "string", "description": "客体名包含的关键词"},
|
||||
"mode": map[string]interface{}{"type": "string", "description": "soft(标记删除)/ hard(物理删除)", "default": "soft"},
|
||||
},
|
||||
@ -1418,7 +1652,7 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_edit",
|
||||
"description": "编辑记忆:删除旧的 relation 并写入新的。例如修正错误的实体名或关系类型。",
|
||||
"description": "【记忆清理】编辑单条记忆关系:删除旧的 relation 并写入新的。用于修正错误的实体名或关系类型。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@ -1671,25 +1905,24 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
})
|
||||
}
|
||||
|
||||
// 输出通道工具
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "output_set_channel",
|
||||
"description": "切换当前对话的输出通道。例如从 voice 切换到 email,后续所有回复将通过新通道发送。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"channel": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "输出通道名称: voice (语音), email (邮件), screen (屏幕), http (HTTP)",
|
||||
"enum": []interface{}{"voice", "email", "screen", "http"},
|
||||
},
|
||||
},
|
||||
"required": []string{"channel"},
|
||||
},
|
||||
},
|
||||
})
|
||||
// 输出通道工具 — 从已注册 Device 动态生成
|
||||
channels := a.io.ListChannels()
|
||||
chanNames := make([]interface{}, 0, len(channels))
|
||||
chanDesc := "输出通道名称: "
|
||||
for i, ch := range channels {
|
||||
if ch.Type == agentIO.DeviceOutput || ch.Type == agentIO.DeviceIO {
|
||||
chanNames = append(chanNames, ch.Name)
|
||||
if i > 0 {
|
||||
chanDesc += ", "
|
||||
}
|
||||
chanDesc += ch.Name
|
||||
}
|
||||
}
|
||||
if len(chanNames) == 0 {
|
||||
chanNames = []interface{}{"default"}
|
||||
chanDesc = "输出通道名称: default"
|
||||
}
|
||||
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
@ -1711,7 +1944,7 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
"properties": map[string]interface{}{
|
||||
"channel": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "输出通道: voice, email, screen, http",
|
||||
"description": chanDesc,
|
||||
},
|
||||
"content": map[string]interface{}{
|
||||
"type": "string",
|
||||
@ -1794,7 +2027,10 @@ type ConsolidationTask struct {
|
||||
|
||||
// enqueueConsolidationTask 将记忆整理任务通过自循环通道注入 Agent(不经过 IO 层)
|
||||
func (a *Agent) enqueueConsolidationTask(task ConsolidationTask) {
|
||||
msg := fmt.Sprintf("【记忆整理任务】\n类型: %s\n说明: %s", task.Type, task.Reason)
|
||||
msg := fmt.Sprintf(
|
||||
"【记忆整理任务】\n类型: %s\n说明: %s\n\n注意:\n1. 仅使用 memory_merge 合并实体,或使用 memory_block_merge 标记不合并\n2. 不要使用 memory_commit 写入新的三元组\n3. 不要从这段任务文本中提取任何信息写入图库\n4. 只需要做出合并/不合并的判断并执行对应工具",
|
||||
task.Type, task.Reason,
|
||||
)
|
||||
a.injectSelf(msg)
|
||||
log.Printf("[agent] enqueued consolidation task: %s", task.Reason)
|
||||
}
|
||||
@ -1942,11 +2178,32 @@ func (a *Agent) reorgGraph() {
|
||||
return
|
||||
}
|
||||
|
||||
// 最多处理 5 个候选,避免阻塞用户消息太久
|
||||
maxCandidates := 5
|
||||
candidates := 0
|
||||
for i := 0; i < len(result.Entities); i++ {
|
||||
for j := i + 1; j < len(result.Entities); j++ {
|
||||
for i := 0; i < len(result.Entities) && candidates < maxCandidates; i++ {
|
||||
for j := i + 1; j < len(result.Entities) && candidates < maxCandidates; j++ {
|
||||
ea, eb := result.Entities[i].Name, result.Entities[j].Name
|
||||
if ea > eb {
|
||||
ea, eb = eb, ea
|
||||
}
|
||||
key := ea + "||" + eb
|
||||
a.noMergeMu.Lock()
|
||||
rounds, ok := a.noMergeMarkers[key]
|
||||
if ok {
|
||||
rounds--
|
||||
if rounds <= 0 {
|
||||
delete(a.noMergeMarkers, key)
|
||||
} else {
|
||||
a.noMergeMarkers[key] = rounds
|
||||
}
|
||||
}
|
||||
a.noMergeMu.Unlock()
|
||||
if ok {
|
||||
continue
|
||||
}
|
||||
sim := entitySimilarity(result.Entities[i].Name, result.Entities[j].Name)
|
||||
if sim > 0.5 {
|
||||
if sim > 0.75 {
|
||||
candidates++
|
||||
a.enqueueConsolidationTask(ConsolidationTask{
|
||||
Type: "entity_merge",
|
||||
@ -2059,14 +2316,19 @@ func entitySimilarity(a, b string) float64 {
|
||||
setA[string(runesA[i:i+2])] = true
|
||||
}
|
||||
|
||||
intersect := 0
|
||||
setB := make(map[string]bool)
|
||||
for i := 0; i < len(runesB)-1; i++ {
|
||||
if setA[string(runesB[i:i+2])] {
|
||||
setB[string(runesB[i:i+2])] = true
|
||||
}
|
||||
|
||||
intersect := 0
|
||||
for bg := range setA {
|
||||
if setB[bg] {
|
||||
intersect++
|
||||
}
|
||||
}
|
||||
|
||||
union := len(setA) + len(runesB) - 1 - intersect
|
||||
union := len(setA) + len(setB) - intersect
|
||||
if union <= 0 {
|
||||
return 0
|
||||
}
|
||||
@ -2155,19 +2417,10 @@ func (a *Agent) processConsolidation(input string) {
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
})
|
||||
a.emitMemoryCandidate("system", input, response, toolsUsed)
|
||||
// 整理任务不发射记忆候选,防止任务文本被蒸馏进图库造成污染
|
||||
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
|
||||
}
|
||||
|
||||
func (a *Agent) executeOutputChannelTool(tc agentAPI.ToolCall) string {
|
||||
channel, _ := tc.Arguments["channel"].(string)
|
||||
if channel == "" {
|
||||
return "请指定输出通道名称,可选: voice, email, screen, http"
|
||||
}
|
||||
a.currentOutputChannel = channel
|
||||
return fmt.Sprintf("输出通道已切换至: %s,后续输出将通过此通道", channel)
|
||||
}
|
||||
|
||||
// executeOutputSendTool — AI 通过指定通道发送消息(校验通道能力)
|
||||
func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
|
||||
channel, _ := tc.Arguments["channel"].(string)
|
||||
@ -2269,7 +2522,7 @@ func (a *Agent) runChildTask(taskID, task string) {
|
||||
// 子 Agent 可调用核心以外的全部工具(记忆/知识/文档/社交),但不能调用输出工具
|
||||
allTools := a.buildToolDefs()
|
||||
childTools := make([]interface{}, 0, len(allTools))
|
||||
outputTools := map[string]bool{"output_send": true, "output_set_channel": true, "output_list_channels": true, "spawn_child": true, "plgreload": true}
|
||||
outputTools := map[string]bool{"output_send": true, "output_list_channels": true, "spawn_child": true, "plgreload": true}
|
||||
for _, t := range allTools {
|
||||
toolMap, ok := t.(map[string]interface{})
|
||||
if !ok {
|
||||
@ -2313,7 +2566,7 @@ func (a *Agent) runChildTask(taskID, task string) {
|
||||
for _, ct := range resp.ToolCalls {
|
||||
var result string
|
||||
switch {
|
||||
case ct.Name == "output_send" || ct.Name == "output_set_channel" || ct.Name == "output_list_channels":
|
||||
case ct.Name == "output_send" || ct.Name == "output_list_channels":
|
||||
result = fmt.Sprintf("子 Agent 不允许调用输出工具: %s", ct.Name)
|
||||
case ct.Name == "spawn_child" || ct.Name == "plgreload":
|
||||
result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name)
|
||||
@ -2399,9 +2652,7 @@ func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string {
|
||||
if err := a.providerManager.SetDefault(name); err != nil {
|
||||
return fmt.Sprintf("切换失败: %v", err)
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.provider = a.providerManager.Get(name)
|
||||
a.mu.Unlock()
|
||||
return fmt.Sprintf("已切换到 LLM 源: %s", name)
|
||||
|
||||
default:
|
||||
@ -2409,24 +2660,48 @@ func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
}
|
||||
|
||||
// mediaDataURL 从 pendingMedia 构建 data URL,返回最终 URL。
|
||||
func (a *Agent) mediaDataURL(defaultMime string) string {
|
||||
if a.pendingMedia == nil {
|
||||
return ""
|
||||
}
|
||||
data, _ := a.pendingMedia["data"].(string)
|
||||
mime, _ := a.pendingMedia["mime"].(string)
|
||||
url, _ := a.pendingMedia["url"].(string)
|
||||
if data != "" {
|
||||
if mime == "" {
|
||||
mime = defaultMime
|
||||
}
|
||||
return "data:" + mime + ";base64," + data
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// mediaChat 调用指定 provider 的多模态 Chat,统一处理超时和错误。
|
||||
func (a *Agent) mediaChat(p agentAPI.Provider, msg agentAPI.Message, resultPrefix string, maxTokens int) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
resp, err := p.Chat(ctx, &agentAPI.CompletionRequest{
|
||||
Messages: []agentAPI.Message{msg},
|
||||
MaxTokens: maxTokens,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%s失败: %v", resultPrefix, err)
|
||||
}
|
||||
return fmt.Sprintf("[%s] %s", resultPrefix, resp.Content)
|
||||
}
|
||||
|
||||
// executeDescribeImage 调用多模态模型描述当前图片。
|
||||
func (a *Agent) executeDescribeImage(tc agentAPI.ToolCall) string {
|
||||
if a.pendingMedia == nil {
|
||||
return "没有待处理的图片数据"
|
||||
}
|
||||
data, _ := a.pendingMedia["data"].(string)
|
||||
mime, _ := a.pendingMedia["mime"].(string)
|
||||
url, _ := a.pendingMedia["url"].(string)
|
||||
if data == "" && url == "" {
|
||||
imgURL := a.mediaDataURL("image/png")
|
||||
if imgURL == "" {
|
||||
return "图片数据为空"
|
||||
}
|
||||
|
||||
providerName, _ := tc.Arguments["provider"].(string)
|
||||
detail, _ := tc.Arguments["detail"].(string)
|
||||
if detail == "" {
|
||||
detail = "high"
|
||||
}
|
||||
|
||||
p := a.providerManager.Get(providerName)
|
||||
if p == nil {
|
||||
p = a.provider
|
||||
@ -2437,12 +2712,9 @@ func (a *Agent) executeDescribeImage(tc agentAPI.ToolCall) string {
|
||||
prompt = "请详细描述这张图片的内容,包括其中的文字、物体、人物、场景等信息。"
|
||||
}
|
||||
|
||||
imgURL := url
|
||||
if data != "" {
|
||||
if mime == "" {
|
||||
mime = "image/png"
|
||||
}
|
||||
imgURL = "data:" + mime + ";base64," + data
|
||||
detail, _ := tc.Arguments["detail"].(string)
|
||||
if detail == "" {
|
||||
detail = "high"
|
||||
}
|
||||
|
||||
msg := agentAPI.Message{
|
||||
@ -2452,17 +2724,7 @@ func (a *Agent) executeDescribeImage(tc agentAPI.ToolCall) string {
|
||||
{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: detail}},
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
resp, err := p.Chat(ctx, &agentAPI.CompletionRequest{
|
||||
Messages: []agentAPI.Message{msg},
|
||||
MaxTokens: 2048,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("图片描述失败: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("[图片描述] %s", resp.Content)
|
||||
return a.mediaChat(p, msg, "图片描述", 2048)
|
||||
}
|
||||
|
||||
// executeTranscribeAudio 调用多模态模型转写/描述当前音频。
|
||||
@ -2470,10 +2732,8 @@ func (a *Agent) executeTranscribeAudio(tc agentAPI.ToolCall) string {
|
||||
if a.pendingMedia == nil {
|
||||
return "没有待处理的音频数据"
|
||||
}
|
||||
data, _ := a.pendingMedia["data"].(string)
|
||||
mime, _ := a.pendingMedia["mime"].(string)
|
||||
url, _ := a.pendingMedia["url"].(string)
|
||||
if data == "" && url == "" {
|
||||
audURL := a.mediaDataURL("audio/wav")
|
||||
if audURL == "" {
|
||||
return "音频数据为空"
|
||||
}
|
||||
|
||||
@ -2488,14 +2748,6 @@ func (a *Agent) executeTranscribeAudio(tc agentAPI.ToolCall) string {
|
||||
prompt = "请转写这段音频的内容。"
|
||||
}
|
||||
|
||||
audURL := url
|
||||
if data != "" {
|
||||
if mime == "" {
|
||||
mime = "audio/wav"
|
||||
}
|
||||
audURL = "data:" + mime + ";base64," + data
|
||||
}
|
||||
|
||||
msg := agentAPI.Message{
|
||||
Role: "user",
|
||||
Blocks: []agentAPI.ContentBlock{
|
||||
@ -2503,17 +2755,7 @@ func (a *Agent) executeTranscribeAudio(tc agentAPI.ToolCall) string {
|
||||
{Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: audURL}},
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
resp, err := p.Chat(ctx, &agentAPI.CompletionRequest{
|
||||
Messages: []agentAPI.Message{msg},
|
||||
MaxTokens: 2048,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("音频转写失败: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("[音频转写] %s", resp.Content)
|
||||
return a.mediaChat(p, msg, "音频转写", 2048)
|
||||
}
|
||||
|
||||
// executeOCRImage 对图片执行 OCR 文字识别(通过多模态模型实现)。
|
||||
@ -2521,23 +2763,11 @@ func (a *Agent) executeOCRImage(tc agentAPI.ToolCall) string {
|
||||
if a.pendingMedia == nil {
|
||||
return "没有待处理的图片数据"
|
||||
}
|
||||
data, _ := a.pendingMedia["data"].(string)
|
||||
mime, _ := a.pendingMedia["mime"].(string)
|
||||
url, _ := a.pendingMedia["url"].(string)
|
||||
if data == "" && url == "" {
|
||||
imgURL := a.mediaDataURL("image/png")
|
||||
if imgURL == "" {
|
||||
return "图片数据为空"
|
||||
}
|
||||
|
||||
p := a.provider
|
||||
|
||||
imgURL := url
|
||||
if data != "" {
|
||||
if mime == "" {
|
||||
mime = "image/png"
|
||||
}
|
||||
imgURL = "data:" + mime + ";base64," + data
|
||||
}
|
||||
|
||||
msg := agentAPI.Message{
|
||||
Role: "user",
|
||||
Blocks: []agentAPI.ContentBlock{
|
||||
@ -2545,17 +2775,7 @@ func (a *Agent) executeOCRImage(tc agentAPI.ToolCall) string {
|
||||
{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: "high"}},
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
resp, err := p.Chat(ctx, &agentAPI.CompletionRequest{
|
||||
Messages: []agentAPI.Message{msg},
|
||||
MaxTokens: 4096,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("OCR 识别失败: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("[OCR 结果] %s", resp.Content)
|
||||
return a.mediaChat(a.provider, msg, "OCR 结果", 4096)
|
||||
}
|
||||
|
||||
// runStage — 运行阶段管道,若插件 Response 被设置则返回 true(短路)
|
||||
|
||||
@ -53,29 +53,6 @@ func TestExecuteOutputListChannelsEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteOutputChannelTool(t *testing.T) {
|
||||
a := &Agent{}
|
||||
tc := agentAPI.ToolCall{Name: "output_set_channel", Arguments: map[string]interface{}{
|
||||
"channel": "voice",
|
||||
}}
|
||||
result := a.executeOutputChannelTool(tc)
|
||||
if a.currentOutputChannel != "voice" {
|
||||
t.Errorf("expected channel 'voice', got %q", a.currentOutputChannel)
|
||||
}
|
||||
if result == "" {
|
||||
t.Error("expected non-empty result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteOutputChannelToolEmpty(t *testing.T) {
|
||||
a := &Agent{}
|
||||
tc := agentAPI.ToolCall{Name: "output_set_channel", Arguments: map[string]interface{}{}}
|
||||
result := a.executeOutputChannelTool(tc)
|
||||
if result != "请指定输出通道名称,可选: voice, email, screen, http" {
|
||||
t.Errorf("unexpected result: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteOutputSendTool(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
io.RegisterDevice(&mockOutputDevice{
|
||||
@ -154,7 +131,6 @@ func TestBuildToolDefsOutputToolsAlwaysPresent(t *testing.T) {
|
||||
a := &Agent{io: io, knowledge: nil, docStore: nil, pluginReg: nil}
|
||||
tools := a.buildToolDefs()
|
||||
|
||||
foundSetChannel := false
|
||||
foundSend := false
|
||||
foundList := false
|
||||
for _, td := range tools {
|
||||
@ -168,17 +144,12 @@ func TestBuildToolDefsOutputToolsAlwaysPresent(t *testing.T) {
|
||||
}
|
||||
name, _ := fn["name"].(string)
|
||||
switch name {
|
||||
case "output_set_channel":
|
||||
foundSetChannel = true
|
||||
case "output_send":
|
||||
foundSend = true
|
||||
case "output_list_channels":
|
||||
foundList = true
|
||||
}
|
||||
}
|
||||
if !foundSetChannel {
|
||||
t.Error("output_set_channel should always be in tools")
|
||||
}
|
||||
if !foundSend {
|
||||
t.Error("output_send should always be in tools")
|
||||
}
|
||||
@ -191,8 +162,8 @@ func TestGetAllToolsEmpty(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
a := &Agent{io: io}
|
||||
tools := a.buildToolDefs()
|
||||
// should have at least output_set_channel, output_send, output_list_channels
|
||||
if len(tools) < 3 {
|
||||
t.Errorf("expected at least 3 tools, got %d", len(tools))
|
||||
// should have at least output_send, output_list_channels
|
||||
if len(tools) < 2 {
|
||||
t.Errorf("expected at least 2 tools, got %d", len(tools))
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,13 +24,17 @@ type ContextEvent struct {
|
||||
Vector vector.Vector `json:"-"` // 缓存向量,避免重复计算
|
||||
}
|
||||
|
||||
const contextFlushInterval = 5 * time.Second
|
||||
|
||||
// RelevanceContext — 基于相关性的上下文管理,非固定阈值
|
||||
type RelevanceContext struct {
|
||||
mu sync.Mutex
|
||||
events []*ContextEvent
|
||||
veczer *vector.TFIDFVectorizer
|
||||
trained bool
|
||||
savePath string // 持久化路径,空则不持久化
|
||||
mu sync.Mutex
|
||||
events []*ContextEvent
|
||||
veczer *vector.TFIDFVectorizer
|
||||
trained bool
|
||||
savePath string // 持久化路径,空则不持久化
|
||||
saveTimer *time.Timer
|
||||
dirty bool
|
||||
}
|
||||
|
||||
func NewRelevanceContext(savePath string) *RelevanceContext {
|
||||
@ -88,16 +92,36 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
|
||||
c.save()
|
||||
}
|
||||
|
||||
// save 无锁版本,Append/Prune 内部持有锁时调用
|
||||
// save 无锁版本,Append/Prune 内部持有锁时调用。带 debounce,每 5s 写一次盘。
|
||||
func (c *RelevanceContext) save() error {
|
||||
if c.savePath == "" {
|
||||
return nil
|
||||
}
|
||||
if !c.dirty {
|
||||
c.dirty = true
|
||||
if c.saveTimer == nil {
|
||||
c.saveTimer = time.AfterFunc(contextFlushInterval, c.flush)
|
||||
} else {
|
||||
c.saveTimer.Reset(contextFlushInterval)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) flush() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if !c.dirty {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(c.events)
|
||||
if err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
return os.WriteFile(c.savePath, data, 0644)
|
||||
if err := os.WriteFile(c.savePath, data, 0644); err != nil {
|
||||
return
|
||||
}
|
||||
c.dirty = false
|
||||
}
|
||||
|
||||
// Prune — 基于当前输入计算每条上下文的相关性,归档最不相关的
|
||||
@ -110,19 +134,31 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
||||
return 0
|
||||
}
|
||||
|
||||
// 保护最近 10 条记录不被淘汰,从更早的记录中选择淘汰对象
|
||||
protectCount := 10
|
||||
if protectCount > len(c.events) {
|
||||
protectCount = len(c.events)
|
||||
}
|
||||
protected := c.events[len(c.events)-protectCount:]
|
||||
candidates := c.events[:len(c.events)-protectCount]
|
||||
|
||||
if len(candidates) == 0 {
|
||||
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 {
|
||||
scoredEvents := make([]scored, len(candidates))
|
||||
for i, evt := range candidates {
|
||||
score := vector.CosineSimilarity(queryVec, evt.Vector)
|
||||
scoredEvents[i] = scored{event: evt, score: score, idx: i}
|
||||
}
|
||||
@ -132,18 +168,23 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
||||
return scoredEvents[i].score > scoredEvents[j].score
|
||||
})
|
||||
|
||||
// 保留 topK 最相关的
|
||||
// 从候选中选 topK 最相关的保留,其余淘汰
|
||||
keepCount := topK - len(protected)
|
||||
if keepCount < 0 {
|
||||
keepCount = 0
|
||||
}
|
||||
keep := scoredEvents
|
||||
if len(keep) > topK {
|
||||
keep = keep[:topK]
|
||||
if len(keep) > keepCount {
|
||||
keep = keep[:keepCount]
|
||||
}
|
||||
archive := scoredEvents[topK:]
|
||||
archive := scoredEvents[keepCount:]
|
||||
|
||||
// 重建 events 为保留的
|
||||
c.events = make([]*ContextEvent, len(keep))
|
||||
for i, s := range keep {
|
||||
c.events[i] = s.event
|
||||
// 重建 events 为保留的候选 + 受保护的最新记录
|
||||
c.events = make([]*ContextEvent, 0, len(keep)+len(protected))
|
||||
for _, s := range keep {
|
||||
c.events = append(c.events, s.event)
|
||||
}
|
||||
c.events = append(c.events, protected...)
|
||||
|
||||
// 按时间重新排序
|
||||
sort.Slice(c.events, func(i, j int) bool {
|
||||
|
||||
@ -55,7 +55,7 @@ func TestContextFormat(t *testing.T) {
|
||||
|
||||
func TestContextPruneKeepsTopK(t *testing.T) {
|
||||
ctx := NewRelevanceContext("")
|
||||
for i := 0; i < 10; i++ {
|
||||
for i := 0; i < 20; i++ {
|
||||
ctx.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "user",
|
||||
@ -74,8 +74,9 @@ func TestContextPruneKeepsTopK(t *testing.T) {
|
||||
archived := ctx.Prune("微积分", 5, nil) // nil docStore → 不归档,只裁剪
|
||||
_ = archived
|
||||
|
||||
if ctx.Len() > 5 {
|
||||
t.Errorf("after prune to 5, len should be ≤5, got %d", ctx.Len())
|
||||
// protectCount=10 + topK=5 → 最多保留 15
|
||||
if ctx.Len() > 15 {
|
||||
t.Errorf("after prune to 5, len should be ≤15, got %d", ctx.Len())
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,7 +99,8 @@ func TestContextPruneWithDocStore(t *testing.T) {
|
||||
|
||||
func TestContextAppendAfterPrune(t *testing.T) {
|
||||
ctx := NewRelevanceContext("")
|
||||
for i := 0; i < 10; i++ {
|
||||
// 需要超过 protectCount(10) + topK(3) 个事件才能产生修剪候选
|
||||
for i := 0; i < 20; i++ {
|
||||
ctx.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "user",
|
||||
@ -107,13 +109,13 @@ func TestContextAppendAfterPrune(t *testing.T) {
|
||||
}
|
||||
|
||||
ctx.Prune("hello", 3, nil)
|
||||
if ctx.Len() > 3 {
|
||||
t.Errorf("expected ≤3 after prune, got %d", ctx.Len())
|
||||
if ctx.Len() > 13 { // 10 protected + 3 topK
|
||||
t.Errorf("expected ≤13 after prune, got %d", ctx.Len())
|
||||
}
|
||||
|
||||
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "new message"})
|
||||
if ctx.Len() != 4 {
|
||||
t.Errorf("after append, expected 4, got %d", ctx.Len())
|
||||
if ctx.Len() > 14 {
|
||||
t.Errorf("expected ≤14 after append, got %d", ctx.Len())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -103,10 +103,6 @@ func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (h *StageHost) RunStageAll(stage sdk.Stage, ctx *sdk.StageContext) {
|
||||
h.RunStage(stage, ctx)
|
||||
}
|
||||
|
||||
func (h *StageHost) ToolCount() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
@ -146,7 +146,7 @@ func TestStageHostRunStageAll(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
host.RunStageAll(sdk.StageAfterOutput, &sdk.StageContext{})
|
||||
host.RunStage(sdk.StageAfterOutput, &sdk.StageContext{})
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 handlers called, got %d", count)
|
||||
|
||||
@ -188,6 +188,10 @@ func (r *ConfigRegistry) Close() error {
|
||||
return r.db.Close()
|
||||
}
|
||||
|
||||
var defaultSources = map[string]map[string]string{
|
||||
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "api_key": "", "thinking_enabled": "false", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"},
|
||||
}
|
||||
|
||||
func (r *ConfigRegistry) SeedDefaults(dataDir string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@ -230,17 +234,7 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
|
||||
set("core.llm.max_tokens", "4096")
|
||||
set("core.llm.thinking_enabled", "false")
|
||||
|
||||
sources := map[string]map[string]string{
|
||||
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "api_key": "", "thinking_enabled": "false", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"},
|
||||
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o", "api_key": "", "thinking_enabled": "false", "adapter": "openai", "adapter_path": "adapters/openai.lua"},
|
||||
"anthropic": {"base_url": "https://api.anthropic.com", "model": "claude-sonnet-4-20250514", "api_key": "", "thinking_enabled": "false", "adapter": "anthropic", "adapter_path": "adapters/anthropic.lua"},
|
||||
"gemini": {"base_url": "https://generativelanguage.googleapis.com", "model": "gemini-2.0-flash", "api_key": "", "thinking_enabled": "false", "adapter": "gemini", "adapter_path": "adapters/gemini.lua"},
|
||||
"mistral": {"base_url": "https://api.mistral.ai", "model": "mistral-large-latest", "api_key": "", "thinking_enabled": "false", "adapter": "mistral", "adapter_path": "adapters/mistral.lua"},
|
||||
"groq": {"base_url": "https://api.groq.com", "model": "llama3-70b-8192", "api_key": "", "thinking_enabled": "false", "adapter": "groq", "adapter_path": "adapters/groq.lua"},
|
||||
"github": {"base_url": "https://models.inference.ai.azure.com", "model": "gpt-4o", "api_key": "", "thinking_enabled": "false", "adapter": "github", "adapter_path": "adapters/github.lua"},
|
||||
"ollama": {"base_url": "http://localhost:11434", "model": "llama3", "api_key": "", "thinking_enabled": "false", "adapter": "ollama", "adapter_path": "adapters/ollama.lua"},
|
||||
}
|
||||
for name, props := range sources {
|
||||
for name, props := range defaultSources {
|
||||
p := "core.llm.sources." + name
|
||||
set(p+".base_url", props["base_url"])
|
||||
set(p+".model", props["model"])
|
||||
@ -306,24 +300,14 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
|
||||
reg(ConfigDef{Key: "core.llm.max_tokens", Default: "4096", Type: "int", DisplayName: "最大 Token", Description: "每次生成的最大 Token 数", Category: "llm"})
|
||||
reg(ConfigDef{Key: "core.llm.thinking_enabled", Default: "false", Type: "bool", DisplayName: "深度思考", Description: "启用深度思考模式(如 DeepSeek R1 的思维链输出)", Category: "llm"})
|
||||
|
||||
sources := map[string]map[string]string{
|
||||
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "api_key": "", "thinking_enabled": "false", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"},
|
||||
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o", "api_key": "", "thinking_enabled": "false", "adapter": "openai", "adapter_path": "adapters/openai.lua"},
|
||||
"anthropic": {"base_url": "https://api.anthropic.com", "model": "claude-sonnet-4-20250514", "api_key": "", "thinking_enabled": "false", "adapter": "anthropic", "adapter_path": "adapters/anthropic.lua"},
|
||||
"gemini": {"base_url": "https://generativelanguage.googleapis.com", "model": "gemini-2.0-flash", "api_key": "", "thinking_enabled": "false", "adapter": "gemini", "adapter_path": "adapters/gemini.lua"},
|
||||
"mistral": {"base_url": "https://api.mistral.ai", "model": "mistral-large-latest", "api_key": "", "thinking_enabled": "false", "adapter": "mistral", "adapter_path": "adapters/mistral.lua"},
|
||||
"groq": {"base_url": "https://api.groq.com", "model": "llama3-70b-8192", "api_key": "", "thinking_enabled": "false", "adapter": "groq", "adapter_path": "adapters/groq.lua"},
|
||||
"github": {"base_url": "https://models.inference.ai.azure.com", "model": "gpt-4o", "api_key": "", "thinking_enabled": "false", "adapter": "github", "adapter_path": "adapters/github.lua"},
|
||||
"ollama": {"base_url": "http://localhost:11434", "model": "llama3", "api_key": "", "thinking_enabled": "false", "adapter": "ollama", "adapter_path": "adapters/ollama.lua"},
|
||||
}
|
||||
for name := range sources {
|
||||
for name := range defaultSources {
|
||||
p := "core.llm.sources." + name
|
||||
reg(ConfigDef{Key: p + ".base_url", Default: sources[name]["base_url"], Type: "string", DisplayName: name + " API 地址", Description: name + " LLM API 基础地址", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".model", Default: sources[name]["model"], Type: "string", DisplayName: name + " 模型", Description: name + " 使用的模型名称", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".base_url", Default: defaultSources[name]["base_url"], Type: "string", DisplayName: name + " API 地址", Description: name + " LLM API 基础地址", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".model", Default: defaultSources[name]["model"], Type: "string", DisplayName: name + " 模型", Description: name + " 使用的模型名称", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".api_key", Default: "", Type: "password", DisplayName: name + " API 密钥", Description: name + " API 密钥", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".thinking_enabled", Default: sources[name]["thinking_enabled"], Type: "bool", DisplayName: name + " 深度思考", Description: name + " 启用深度思考模式", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".adapter", Default: sources[name]["adapter"], Type: "string", DisplayName: name + " 适配器", Description: name + " 协议适配器名称", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".adapter_path", Default: sources[name]["adapter_path"], Type: "string", DisplayName: name + " 适配器路径", Description: name + " 适配器脚本路径", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".thinking_enabled", Default: defaultSources[name]["thinking_enabled"], Type: "bool", DisplayName: name + " 深度思考", Description: name + " 启用深度思考模式", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".adapter", Default: defaultSources[name]["adapter"], Type: "string", DisplayName: name + " 适配器", Description: name + " 协议适配器名称", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".adapter_path", Default: defaultSources[name]["adapter_path"], Type: "string", DisplayName: name + " 适配器路径", Description: name + " 适配器脚本路径", Category: "sources"})
|
||||
}
|
||||
|
||||
reg(ConfigDef{Key: "core.defaults.image", Default: "homeagent/agent-base:latest", Type: "string", DisplayName: "默认镜像", Description: "Agent 默认 Docker 镜像", Category: "defaults"})
|
||||
@ -457,6 +441,17 @@ func (r *ConfigRegistry) ToConfig() *types.Config {
|
||||
}
|
||||
return d
|
||||
}
|
||||
readFloat := func(key string, def float64) float64 {
|
||||
s := read(key, "")
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return f
|
||||
}
|
||||
readBool := func(key string, def bool) bool {
|
||||
s := read(key, "")
|
||||
if s == "" {
|
||||
@ -480,7 +475,7 @@ func (r *ConfigRegistry) ToConfig() *types.Config {
|
||||
cfg.LLM.BaseURL = read("core.llm.base_url", cfg.LLM.BaseURL)
|
||||
cfg.LLM.APIKey = read("core.llm.api_key", cfg.LLM.APIKey)
|
||||
cfg.LLM.Adapter = read("core.llm.adapter", cfg.LLM.Adapter)
|
||||
cfg.LLM.Temperature = float64(readInt("core.llm.temperature", int(cfg.LLM.Temperature*100))) / 100
|
||||
cfg.LLM.Temperature = readFloat("core.llm.temperature", cfg.LLM.Temperature)
|
||||
cfg.LLM.MaxTokens = readInt("core.llm.max_tokens", cfg.LLM.MaxTokens)
|
||||
cfg.LLM.ThinkingEnabled = readBool("core.llm.thinking_enabled", cfg.LLM.ThinkingEnabled)
|
||||
|
||||
|
||||
@ -1,209 +0,0 @@
|
||||
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
|
||||
}
|
||||
@ -1,162 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@ -164,16 +164,13 @@ func (v *VM) LoadAdapter(path string) error {
|
||||
|
||||
func (v *VM) CallTransformRequest(name, rawJSON string) (string, error) {
|
||||
v.mu.Lock()
|
||||
adapter, ok := v.loaded[name]
|
||||
v.mu.Unlock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
adapter, ok := v.loaded[name]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("adapter %s not loaded", name)
|
||||
}
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
fn := adapter.RawGetString("transform_request")
|
||||
if fn == nil {
|
||||
return "", fmt.Errorf("adapter %s missing transform_request", name)
|
||||
@ -194,16 +191,13 @@ func (v *VM) CallTransformRequest(name, rawJSON string) (string, error) {
|
||||
|
||||
func (v *VM) CallTransformResponse(name, rawJSON string) (string, error) {
|
||||
v.mu.Lock()
|
||||
adapter, ok := v.loaded[name]
|
||||
v.mu.Unlock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
adapter, ok := v.loaded[name]
|
||||
if !ok {
|
||||
return rawJSON, nil
|
||||
}
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
fn := adapter.RawGetString("transform_response")
|
||||
if fn == nil {
|
||||
return rawJSON, nil
|
||||
@ -224,16 +218,13 @@ func (v *VM) CallTransformResponse(name, rawJSON string) (string, error) {
|
||||
|
||||
func (v *VM) CallTransformStreamChunk(name, rawLine string) (string, error) {
|
||||
v.mu.Lock()
|
||||
adapter, ok := v.loaded[name]
|
||||
v.mu.Unlock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
adapter, ok := v.loaded[name]
|
||||
if !ok {
|
||||
return rawLine, nil
|
||||
}
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
fn := adapter.RawGetString("transform_stream_chunk")
|
||||
if fn == nil {
|
||||
return rawLine, nil
|
||||
@ -257,16 +248,13 @@ func (v *VM) CallTransformStreamChunk(name, rawLine string) (string, error) {
|
||||
|
||||
func (v *VM) GetAdapterEndpoint(name string) string {
|
||||
v.mu.Lock()
|
||||
adapter, ok := v.loaded[name]
|
||||
v.mu.Unlock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
adapter, ok := v.loaded[name]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
if ep := adapter.RawGetString("endpoint"); ep != nil {
|
||||
return ep.String()
|
||||
}
|
||||
@ -275,16 +263,13 @@ func (v *VM) GetAdapterEndpoint(name string) string {
|
||||
|
||||
func (v *VM) GetAdapterHeaders(name string) map[string]string {
|
||||
v.mu.Lock()
|
||||
adapter, ok := v.loaded[name]
|
||||
v.mu.Unlock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
adapter, ok := v.loaded[name]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
headers := make(map[string]string)
|
||||
if ht := adapter.RawGetString("headers"); ht != nil {
|
||||
if tbl, ok := ht.(*lua.LTable); ok {
|
||||
|
||||
@ -37,11 +37,13 @@ type Store struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
docs map[string]*Doc
|
||||
summaries []string // 用于训练向量化器
|
||||
summaries []string // 用于训练向量化器,最大 10000 条
|
||||
|
||||
dirty bool
|
||||
}
|
||||
|
||||
const maxSummaries = 10000
|
||||
|
||||
func NewStore(dir string) *Store {
|
||||
return &Store{
|
||||
dir: dir,
|
||||
@ -83,11 +85,15 @@ func (s *Store) Insert(doc *Doc) error {
|
||||
|
||||
s.docs[doc.ID] = doc
|
||||
|
||||
// 增量训练向量化器并加入向量索引
|
||||
s.addSummary(doc.Summary)
|
||||
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)
|
||||
// 立即写盘
|
||||
path := filepath.Join(s.dir, doc.ID+".json")
|
||||
data, _ := json.MarshalIndent(doc, "", " ")
|
||||
os.WriteFile(path, data, 0644)
|
||||
|
||||
s.dirty = true
|
||||
return nil
|
||||
@ -110,12 +116,13 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry) (*Doc, error
|
||||
content := strings.Join(parts, "\n")
|
||||
contentHash := simpleHash(content)
|
||||
|
||||
// 去重:检查是否已有相同 hash 的文档(在锁内完成创建/更新)
|
||||
summary := summarizeEntries(entries)
|
||||
tags := extractTags(entries)
|
||||
entities := extractEntities(entries)
|
||||
|
||||
s.mu.Lock()
|
||||
|
||||
// 去重
|
||||
for _, d := range s.docs {
|
||||
if d.Meta != nil && d.Meta["content_hash"] == contentHash {
|
||||
d.UpdatedAt = time.Now()
|
||||
@ -146,8 +153,20 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry) (*Doc, error
|
||||
Meta: map[string]string{"content_hash": contentHash},
|
||||
}
|
||||
s.docs[id] = doc
|
||||
|
||||
// 增量训练向量化器并加入向量索引
|
||||
s.addSummary(summary)
|
||||
vec := s.veczer.Vectorize(summary + " " + content)
|
||||
s.vec.Insert(id, summary, vec, nil)
|
||||
|
||||
s.dirty = true
|
||||
s.mu.Unlock()
|
||||
|
||||
// 立即写盘
|
||||
path := filepath.Join(s.dir, id+".json")
|
||||
data, _ := json.MarshalIndent(doc, "", " ")
|
||||
os.WriteFile(path, data, 0644)
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
@ -166,8 +185,7 @@ func (s *Store) Consume(text string, topK int) []*Doc {
|
||||
var docs []*Doc
|
||||
for _, r := range results {
|
||||
if d, ok := s.docs[r.ID]; ok {
|
||||
delete(s.docs, r.ID)
|
||||
s.vec.Remove(r.ID)
|
||||
s.removeDoc(r.ID)
|
||||
s.dirty = true
|
||||
docs = append(docs, d)
|
||||
}
|
||||
@ -266,14 +284,39 @@ func (s *Store) Remove(id string) {
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.docs[id]; ok {
|
||||
delete(s.docs, id)
|
||||
s.vec.Remove(id)
|
||||
s.removeDoc(id)
|
||||
s.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// ——— internal ———
|
||||
|
||||
// addSummary 添加一条摘要到训练集,超限时截断并触发重索引。
|
||||
// 调用方必须已持有 s.mu 写锁。
|
||||
func (s *Store) addSummary(summary string) {
|
||||
s.summaries = append(s.summaries, summary)
|
||||
if len(s.summaries) > maxSummaries {
|
||||
n := maxSummaries / 2
|
||||
copy(s.summaries, s.summaries[len(s.summaries)-n:])
|
||||
s.summaries = s.summaries[:n]
|
||||
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, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removeDoc 从内存索引和磁盘删除文档。
|
||||
// 调用方必须已持有 s.mu 写锁。
|
||||
func (s *Store) removeDoc(id string) {
|
||||
delete(s.docs, id)
|
||||
s.vec.Remove(id)
|
||||
path := filepath.Join(s.dir, id+".json")
|
||||
os.Remove(path)
|
||||
}
|
||||
|
||||
func (s *Store) loadAll() error {
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
|
||||
@ -526,7 +526,7 @@ func (g *GraphDB) Introspect() (map[string]interface{}, error) {
|
||||
// MergeEntities 合并两个实体:将 sourceName 的所有信息合并到 targetName
|
||||
// 1. sourceName 的所有关系重新指向 targetName
|
||||
// 2. targetName 的 mention_count 增加 sourceName 的计数
|
||||
// 3. sourceName 标记为 merged
|
||||
// 3. sourceName 彻底删除(不再残留 @merged_ 实体)
|
||||
// 返回 (关系的重定向数, error)
|
||||
func (g *GraphDB) MergeEntities(sourceName, targetName string) (int, error) {
|
||||
g.mu.Lock()
|
||||
@ -554,7 +554,7 @@ func (g *GraphDB) MergeEntities(sourceName, targetName string) (int, error) {
|
||||
return 0, fmt.Errorf("cannot merge entity with itself")
|
||||
}
|
||||
|
||||
// 重定向 source → target 的关系(作为 source)
|
||||
// 重定向 source → target 的活跃关系(作为 source)
|
||||
res, err := tx.Exec(
|
||||
`UPDATE relations SET source_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE source_id = ? AND status = 'active'`,
|
||||
@ -565,7 +565,7 @@ func (g *GraphDB) MergeEntities(sourceName, targetName string) (int, error) {
|
||||
}
|
||||
redirectedSource, _ := res.RowsAffected()
|
||||
|
||||
// 重定向 source → target 的关系(作为 target)
|
||||
// 重定向 source → target 的活跃关系(作为 target)
|
||||
res, err = tx.Exec(
|
||||
`UPDATE relations SET target_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE target_id = ? AND status = 'active'`,
|
||||
@ -586,6 +586,12 @@ func (g *GraphDB) MergeEntities(sourceName, targetName string) (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 清理 source 残留的非活跃关系(archived/deleted),否则外键约束阻止删除实体
|
||||
_, err = tx.Exec(`DELETE FROM relations WHERE source_id = ? OR target_id = ?`, sourceID, sourceID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 更新 target 的 mention_count
|
||||
_, err = tx.Exec(
|
||||
`UPDATE entities SET mention_count = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
@ -595,14 +601,8 @@ func (g *GraphDB) MergeEntities(sourceName, targetName string) (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 标记 source 为 merged(改名避免 UNIQUE 冲突)
|
||||
_, err = tx.Exec(
|
||||
`UPDATE entities SET name = ? || '@merged_' || ?,
|
||||
mention_count = 0,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
sourceName, time.Now().Format("20060102150405"), sourceID,
|
||||
)
|
||||
// 彻底删除 source 实体(所有关系已重定向,自引用已删除)
|
||||
_, err = tx.Exec(`DELETE FROM entities WHERE id = ?`, sourceID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@ -615,6 +615,36 @@ func (g *GraphDB) MergeEntities(sourceName, targetName string) (int, error) {
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// DeleteEntity 彻底删除一个实体及其所有关联关系。
|
||||
func (g *GraphDB) DeleteEntity(name string) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
tx, err := g.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var id int64
|
||||
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", name).Scan(&id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("entity '%s' not found: %w", name, err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`DELETE FROM relations WHERE source_id = ? OR target_id = ?`, id, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`DELETE FROM entities WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (g *GraphDB) Archive(days int) (int, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
@ -88,7 +88,7 @@ func (d *Distiller) flush() {
|
||||
if len(d.records) == 0 {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(d.rawPath, fmt.Sprintf("raw_%d.jsonl", time.Now().UnixNano()))
|
||||
path := filepath.Join(d.rawPath, fmt.Sprintf("raw_%d.tsv", time.Now().UnixNano()))
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
log.Printf("[memory] flush error: %v", err)
|
||||
@ -113,7 +113,8 @@ func (d *Distiller) loadExisting() {
|
||||
}
|
||||
var files []fileInfo
|
||||
for _, entry := range entries {
|
||||
if filepath.Ext(entry.Name()) != ".jsonl" {
|
||||
ext := filepath.Ext(entry.Name())
|
||||
if ext != ".tsv" && ext != ".jsonl" {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
@ -296,8 +297,8 @@ func extractName(s string) string {
|
||||
}{
|
||||
{"我叫", ""},
|
||||
{"我的名字是", ""},
|
||||
{"我是", ""},
|
||||
{"名字是", ""},
|
||||
{"我是", ""},
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
for _, p := range patterns {
|
||||
@ -313,6 +314,10 @@ func extractName(s string) string {
|
||||
candidate = candidate[:idx]
|
||||
}
|
||||
}
|
||||
// "我是张三"(姓名) vs "我是一个程序员"(职业):名字通常 ≤4 字符
|
||||
if p.prefix == "我是" && len([]rune(candidate)) > 4 {
|
||||
continue
|
||||
}
|
||||
if len(candidate) > 0 && len(candidate) < 20 {
|
||||
return candidate
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import (
|
||||
"reflect"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
sdkext "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// .so 插件必须导出函数 NewPlugin,签名与 NativeFactory 一致:
|
||||
@ -27,7 +27,7 @@ const (
|
||||
|
||||
type dynamicPlugin struct {
|
||||
name string
|
||||
impl sdkext.Plugin
|
||||
impl pubsdk.Plugin
|
||||
}
|
||||
|
||||
func (p *dynamicPlugin) Name() string { return p.name }
|
||||
@ -105,9 +105,9 @@ func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, err
|
||||
}
|
||||
return nil, fmt.Errorf("NewPlugin %s returned non-error second value", name)
|
||||
}
|
||||
plg, ok := outs[0].Interface().(sdkext.Plugin)
|
||||
plg, ok := outs[0].Interface().(pubsdk.Plugin)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("NewPlugin in %s returned value that does not implement external sdk.Plugin", soPath)
|
||||
return nil, fmt.Errorf("NewPlugin in %s returned value that does not implement pubsdk.Plugin", soPath)
|
||||
}
|
||||
|
||||
return &dynamicPlugin{name: name, impl: plg}, nil
|
||||
|
||||
@ -1,178 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Stage string
|
||||
|
||||
const (
|
||||
StageOnInput Stage = "on_input"
|
||||
StagePreAction Stage = "pre_action"
|
||||
StagePostAction Stage = "post_action"
|
||||
StageBeforeToolcall Stage = "before_toolcall"
|
||||
StageAfterToolcall Stage = "after_toolcall"
|
||||
StageBeforeOutput Stage = "before_output"
|
||||
StageAfterOutput Stage = "after_output"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventRawInput EventType = "raw_input"
|
||||
EventAgentOutput EventType = "agent_output"
|
||||
EventToolCall EventType = "tool_call"
|
||||
EventReasoning EventType = "reasoning"
|
||||
EventSystem EventType = "system"
|
||||
EventAll EventType = "*"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Type EventType `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type MemItem struct {
|
||||
Content string `json:"content"`
|
||||
Score float64 `json:"score"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type StageContext struct {
|
||||
RawMessage string
|
||||
UserID string
|
||||
GroupID string
|
||||
ContextMsgs []map[string]interface{}
|
||||
LLMText string
|
||||
ToolCalls []ToolCall
|
||||
ToolResults []ToolResult
|
||||
FinalText string
|
||||
Response *string
|
||||
Phase Stage
|
||||
Memory []MemItem
|
||||
Extra map[string]interface{}
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
}
|
||||
|
||||
type ToolResult struct {
|
||||
CallID string `json:"call_id"`
|
||||
Name string `json:"name"`
|
||||
Success bool `json:"success"`
|
||||
Result interface{} `json:"result"`
|
||||
}
|
||||
|
||||
type ToolDef struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
}
|
||||
|
||||
type SettingsAPI interface {
|
||||
Get(key string) (interface{}, error)
|
||||
Set(key string, value interface{}) error
|
||||
List(prefix string) ([]string, error)
|
||||
}
|
||||
|
||||
type MemoryAPI interface {
|
||||
Recall(query string, topK int) ([]MemItem, error)
|
||||
Commit(triples []map[string]string) error
|
||||
Introspect() (map[string]interface{}, error)
|
||||
}
|
||||
|
||||
type KnowledgeAPI interface {
|
||||
Search(query string, topK int) ([]MemItem, error)
|
||||
Create(name, content string) error
|
||||
List() ([]string, error)
|
||||
}
|
||||
|
||||
type EventHandler func(event *Event)
|
||||
type StageHandler func(ctx *StageContext) error
|
||||
type ToolHandler func(args map[string]interface{}) (interface{}, error)
|
||||
|
||||
type PluginAPI struct {
|
||||
Name string
|
||||
Version string
|
||||
|
||||
tools map[string]ToolHandler
|
||||
stages map[Stage][]StageHandler
|
||||
events map[EventType][]EventHandler
|
||||
eventBus EventBus
|
||||
memAPI MemoryAPI
|
||||
knowAPI KnowledgeAPI
|
||||
settAPI SettingsAPI
|
||||
}
|
||||
|
||||
func NewPluginAPI(name, version string, bus EventBus, mem MemoryAPI, know KnowledgeAPI) *PluginAPI {
|
||||
return &PluginAPI{
|
||||
Name: name,
|
||||
Version: version,
|
||||
tools: make(map[string]ToolHandler),
|
||||
stages: make(map[Stage][]StageHandler),
|
||||
events: make(map[EventType][]EventHandler),
|
||||
eventBus: bus,
|
||||
memAPI: mem,
|
||||
knowAPI: know,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PluginAPI) RegisterTool(name string, handler ToolHandler) error {
|
||||
if _, ok := p.tools[name]; ok {
|
||||
return fmt.Errorf("tool %s already registered by plugin %s", name, p.Name)
|
||||
}
|
||||
p.tools[name] = handler
|
||||
if p.eventBus != nil {
|
||||
p.eventBus.Publish(&Event{
|
||||
Type: EventSystem,
|
||||
Source: p.Name,
|
||||
Payload: map[string]interface{}{"action": "register_tool", "tool": name},
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PluginAPI) RegisterStage(stage Stage, handler StageHandler) {
|
||||
p.stages[stage] = append(p.stages[stage], handler)
|
||||
}
|
||||
|
||||
func (p *PluginAPI) Subscribe(eventType EventType, handler EventHandler) {
|
||||
p.events[eventType] = append(p.events[eventType], handler)
|
||||
if p.eventBus != nil {
|
||||
p.eventBus.Subscribe(eventType, handler)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PluginAPI) Publish(evt *Event) {
|
||||
if p.eventBus != nil {
|
||||
p.eventBus.Publish(evt)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PluginAPI) Tools() map[string]ToolHandler {
|
||||
return p.tools
|
||||
}
|
||||
|
||||
func (p *PluginAPI) StageHandlers(stage Stage) []StageHandler {
|
||||
return p.stages[stage]
|
||||
}
|
||||
|
||||
func (p *PluginAPI) Memory() MemoryAPI { return p.memAPI }
|
||||
func (p *PluginAPI) Knowledge() KnowledgeAPI { return p.knowAPI }
|
||||
func (p *PluginAPI) Settings() SettingsAPI { return p.settAPI }
|
||||
func (p *PluginAPI) SetSettings(s SettingsAPI) { p.settAPI = s }
|
||||
|
||||
func AllStages() map[Stage]bool {
|
||||
return map[Stage]bool{
|
||||
StageOnInput: true,
|
||||
StagePreAction: true,
|
||||
StagePostAction: true,
|
||||
StageBeforeToolcall: true,
|
||||
StageAfterToolcall: true,
|
||||
StageBeforeOutput: true,
|
||||
StageAfterOutput: true,
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import "fmt"
|
||||
|
||||
type EventBus interface {
|
||||
Publish(event *Event)
|
||||
Subscribe(eventType EventType, handler EventHandler) func()
|
||||
}
|
||||
|
||||
type InProcessBus struct {
|
||||
subs map[EventType][]EventHandler
|
||||
}
|
||||
|
||||
func NewInProcessBus() *InProcessBus {
|
||||
return &InProcessBus{
|
||||
subs: make(map[EventType][]EventHandler),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *InProcessBus) Publish(evt *Event) {
|
||||
for _, h := range b.subs[EventAll] {
|
||||
h(evt)
|
||||
}
|
||||
if evt.Type != EventAll {
|
||||
for _, h := range b.subs[evt.Type] {
|
||||
h(evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *InProcessBus) Subscribe(eventType EventType, handler EventHandler) func() {
|
||||
b.subs[eventType] = append(b.subs[eventType], handler)
|
||||
return func() {
|
||||
list := b.subs[eventType]
|
||||
for i, h := range list {
|
||||
if fmt.Sprintf("%p", h) == fmt.Sprintf("%p", handler) {
|
||||
b.subs[eventType] = append(list[:i], list[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,133 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInProcessBus(t *testing.T) {
|
||||
bus := NewInProcessBus()
|
||||
var called bool
|
||||
|
||||
bus.Subscribe(EventRawInput, func(evt *Event) {
|
||||
called = true
|
||||
if evt.Source != "test" {
|
||||
t.Errorf("expected source test, got %s", evt.Source)
|
||||
}
|
||||
})
|
||||
|
||||
bus.Publish(&Event{
|
||||
Type: EventRawInput,
|
||||
Source: "test",
|
||||
})
|
||||
|
||||
if !called {
|
||||
t.Error("handler was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInProcessBusWildcard(t *testing.T) {
|
||||
bus := NewInProcessBus()
|
||||
count := 0
|
||||
|
||||
bus.Subscribe(EventAll, func(evt *Event) {
|
||||
count++
|
||||
})
|
||||
|
||||
bus.Publish(&Event{Type: EventRawInput, Source: "s1"})
|
||||
bus.Publish(&Event{Type: EventToolCall, Source: "s2"})
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginAPI(t *testing.T) {
|
||||
bus := NewInProcessBus()
|
||||
api := NewPluginAPI("test", "1.0.0", bus, nil, nil)
|
||||
|
||||
if api.Name != "test" {
|
||||
t.Errorf("expected test, got %s", api.Name)
|
||||
}
|
||||
if api.Version != "1.0.0" {
|
||||
t.Errorf("expected 1.0.0, got %s", api.Version)
|
||||
}
|
||||
|
||||
var stageCalled bool
|
||||
api.RegisterStage(StageOnInput, func(ctx *StageContext) error {
|
||||
stageCalled = true
|
||||
if ctx.RawMessage != "hello" {
|
||||
t.Errorf("expected hello, got %s", ctx.RawMessage)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := &StageContext{RawMessage: "hello"}
|
||||
for _, handler := range api.StageHandlers(StageOnInput) {
|
||||
handler(ctx)
|
||||
}
|
||||
|
||||
if !stageCalled {
|
||||
t.Error("stage handler was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginAPITool(t *testing.T) {
|
||||
api := NewPluginAPI("test", "1.0.0", nil, nil, nil)
|
||||
|
||||
err := api.RegisterTool("test_tool", func(args map[string]interface{}) (interface{}, error) {
|
||||
return "ok", nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("register tool: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := api.Tools()["test_tool"]; !ok {
|
||||
t.Error("tool not found")
|
||||
}
|
||||
|
||||
// duplicate registration should fail
|
||||
err = api.RegisterTool("test_tool", func(args map[string]interface{}) (interface{}, error) {
|
||||
return "ok", nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error on duplicate tool registration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginAPIStageShortCircuit(t *testing.T) {
|
||||
api := NewPluginAPI("test", "1.0.0", nil, nil, nil)
|
||||
|
||||
api.RegisterStage(StageOnInput, func(ctx *StageContext) error {
|
||||
resp := "intercepted"
|
||||
ctx.Response = &resp
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := &StageContext{RawMessage: "hello"}
|
||||
handlers := api.StageHandlers(StageOnInput)
|
||||
if len(handlers) != 1 {
|
||||
t.Fatalf("expected 1 handler, got %d", len(handlers))
|
||||
}
|
||||
handlers[0](ctx)
|
||||
|
||||
if ctx.Response == nil || *ctx.Response != "intercepted" {
|
||||
t.Errorf("expected intercepted, got %v", ctx.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllStages(t *testing.T) {
|
||||
stages := AllStages()
|
||||
expected := []Stage{
|
||||
StageOnInput, StagePreAction, StagePostAction,
|
||||
StageBeforeToolcall, StageAfterToolcall,
|
||||
StageBeforeOutput, StageAfterOutput,
|
||||
}
|
||||
for _, s := range expected {
|
||||
if !stages[s] {
|
||||
t.Errorf("missing stage: %s", s)
|
||||
}
|
||||
}
|
||||
if len(stages) != len(expected) {
|
||||
t.Errorf("expected %d stages, got %d", len(expected), len(stages))
|
||||
}
|
||||
}
|
||||
@ -345,6 +345,10 @@ func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (in
|
||||
timeout = d
|
||||
}
|
||||
}
|
||||
// SSH 命令自动使用更长的超时(5 分钟)
|
||||
if timeoutStr == "" && (strings.HasPrefix(command, "ssh ") || strings.HasPrefix(command, "ssh -")) {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
|
||||
rows := uint16(24)
|
||||
cols := uint16(80)
|
||||
|
||||
@ -12,6 +12,34 @@ import (
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// shellUnquote 拆解命令字符串,处理单引号/双引号包裹的参数
|
||||
func shellUnquote(s string) []string {
|
||||
var args []string
|
||||
var cur strings.Builder
|
||||
inSingle := false
|
||||
inDouble := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == '\'' && !inDouble:
|
||||
inSingle = !inSingle
|
||||
case c == '"' && !inSingle:
|
||||
inDouble = !inDouble
|
||||
case (c == ' ' || c == '\t') && !inSingle && !inDouble:
|
||||
if cur.Len() > 0 {
|
||||
args = append(args, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
default:
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
args = append(args, cur.String())
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func init() {
|
||||
plugin.RegisterFactory("cmd", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return New(name), nil
|
||||
@ -79,7 +107,11 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", command)
|
||||
parts := shellUnquote(command)
|
||||
if len(parts) == 0 {
|
||||
return map[string]interface{}{"error": "command is required"}, nil
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
|
||||
if workdir != "" {
|
||||
cmd.Dir = workdir
|
||||
}
|
||||
|
||||
@ -81,8 +81,9 @@ func TestCmdRunWithStderr(t *testing.T) {
|
||||
}
|
||||
|
||||
handler := tc.handlers["cmd_run"]
|
||||
// ls with a nonexistent path writes to stderr and returns non-zero exit code
|
||||
result, err := handler(map[string]interface{}{
|
||||
"command": "echo out && echo err >&2 && exit 1",
|
||||
"command": "ls /tmp/cmd_test_nonexistent_xxxxx",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -95,14 +96,11 @@ func TestCmdRunWithStderr(t *testing.T) {
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
if resp["stdout"] != "out" {
|
||||
t.Fatalf("expected stdout 'out', got %v", resp["stdout"])
|
||||
if stderr, ok := resp["stderr"].(string); !ok || stderr == "" {
|
||||
t.Fatalf("expected stderr output, got %q", stderr)
|
||||
}
|
||||
if resp["stderr"] != "err" {
|
||||
t.Fatalf("expected stderr 'err', got %v", resp["stderr"])
|
||||
}
|
||||
if resp["exit_code"].(float64) != 1 {
|
||||
t.Fatalf("expected exit code 1, got %v", resp["exit_code"])
|
||||
if exitCode, ok := resp["exit_code"].(float64); !ok || exitCode == 0 {
|
||||
t.Fatalf("expected non-zero exit code, got %v", exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
@ -197,7 +195,7 @@ func TestCmdRunNonZeroExit(t *testing.T) {
|
||||
|
||||
handler := tc.handlers["cmd_run"]
|
||||
result, err := handler(map[string]interface{}{
|
||||
"command": "exit 42",
|
||||
"command": "false",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -210,8 +208,8 @@ func TestCmdRunNonZeroExit(t *testing.T) {
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
if resp["exit_code"].(float64) != 42 {
|
||||
t.Fatalf("expected exit code 42, got %v", resp["exit_code"])
|
||||
if resp["exit_code"].(float64) != 1 {
|
||||
t.Fatalf("expected exit code 1, got %v", resp["exit_code"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,15 +10,30 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
var downloadClient = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("redirect to disallowed scheme: %s", req.URL.Scheme)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
PluginDir string // 由 main.go 设置
|
||||
Reg *plugin.Registry // 由 main.go 设置
|
||||
@ -260,10 +275,18 @@ func (p *Plugin) handlePluginByID(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ======== Core Logic ========
|
||||
|
||||
func (p *Plugin) installFromURL(url string) (interface{}, error) {
|
||||
log.Printf("[pluginmgr] downloading: %s", url)
|
||||
func (p *Plugin) installFromURL(rawURL string) (interface{}, error) {
|
||||
log.Printf("[pluginmgr] downloading: %s", rawURL)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("unsupported URL scheme: %s (only http/https allowed)", parsed.Scheme)
|
||||
}
|
||||
|
||||
resp, err := downloadClient.Get(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ import (
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
@ -290,7 +291,7 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"status": "running",
|
||||
"uptime": time.Since(h.startTime).String(),
|
||||
"agents": len(agents),
|
||||
"version": "0.1.0",
|
||||
"version": meta.Version,
|
||||
"startedAt": h.startTime,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,33 +1,6 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
sdkext "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
)
|
||||
import pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
|
||||
type KnowledgeAPI = sdkext.KnowledgeAPI
|
||||
type Knowledge = sdkext.Knowledge
|
||||
|
||||
type knowledgeImpl struct{ ks *knowledge.Store }
|
||||
|
||||
func NewKnowledge(ks *knowledge.Store) KnowledgeAPI { return &knowledgeImpl{ks: ks} }
|
||||
|
||||
func (k *knowledgeImpl) Search(query string, topK int) ([]*Knowledge, error) {
|
||||
if k.ks == nil { return nil, nil }
|
||||
got := k.ks.Search(query, topK)
|
||||
out := make([]*Knowledge, len(got))
|
||||
for i, item := range got {
|
||||
out[i] = &Knowledge{Name: item.Name, Content: item.Content}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k *knowledgeImpl) Add(name, content string) error {
|
||||
if k.ks == nil { return nil }
|
||||
return k.ks.Add(name, content)
|
||||
}
|
||||
|
||||
func (k *knowledgeImpl) List() ([]string, error) {
|
||||
if k.ks == nil { return nil, nil }
|
||||
return k.ks.List(), nil
|
||||
}
|
||||
type KnowledgeAPI = pubsdk.KnowledgeAPI
|
||||
type Knowledge = pubsdk.Knowledge
|
||||
|
||||
29
internal/sdk/knowledge_impl.go
Normal file
29
internal/sdk/knowledge_impl.go
Normal file
@ -0,0 +1,29 @@
|
||||
package sdk
|
||||
|
||||
import "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
|
||||
type knowledgeImpl struct{ ks *knowledge.Store }
|
||||
|
||||
func NewKnowledge(ks *knowledge.Store) KnowledgeAPI { return &knowledgeImpl{ks: ks} }
|
||||
|
||||
func (k *knowledgeImpl) Search(query string, topK int) ([]*Knowledge, error) {
|
||||
if k.ks == nil { return nil, nil }
|
||||
got := k.ks.Search(query, topK)
|
||||
out := make([]*Knowledge, len(got))
|
||||
for i, item := range got {
|
||||
out[i] = &Knowledge{Name: item.Name, Content: item.Content}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k *knowledgeImpl) Add(name, content string) error {
|
||||
if k.ks == nil { return nil }
|
||||
return k.ks.Add(name, content)
|
||||
}
|
||||
|
||||
func (k *knowledgeImpl) List() ([]string, error) {
|
||||
if k.ks == nil { return nil, nil }
|
||||
return k.ks.List(), nil
|
||||
}
|
||||
|
||||
var _ KnowledgeAPI = (*knowledgeImpl)(nil)
|
||||
@ -1,29 +1,5 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
sdkext "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
)
|
||||
import pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
|
||||
type LLMAPI = sdkext.LLMAPI
|
||||
|
||||
type llmImpl struct{ mgr *agentAPI.ProviderManager }
|
||||
|
||||
func NewLLM(mgr *agentAPI.ProviderManager) LLMAPI { return &llmImpl{mgr: mgr} }
|
||||
|
||||
func (l *llmImpl) ListSources() []string {
|
||||
if l.mgr == nil { return nil }
|
||||
return l.mgr.List()
|
||||
}
|
||||
|
||||
func (l *llmImpl) SetSource(name string) error {
|
||||
if l.mgr == nil { return nil }
|
||||
return l.mgr.SetDefault(name)
|
||||
}
|
||||
|
||||
func (l *llmImpl) CurrentSource() string {
|
||||
if l.mgr == nil { return "" }
|
||||
p := l.mgr.Default()
|
||||
if p == nil { return "" }
|
||||
return p.Name()
|
||||
}
|
||||
type LLMAPI = pubsdk.LLMAPI
|
||||
|
||||
26
internal/sdk/llm_impl.go
Normal file
26
internal/sdk/llm_impl.go
Normal file
@ -0,0 +1,26 @@
|
||||
package sdk
|
||||
|
||||
import agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
|
||||
type llmImpl struct{ mgr *agentAPI.ProviderManager }
|
||||
|
||||
func NewLLM(mgr *agentAPI.ProviderManager) LLMAPI { return &llmImpl{mgr: mgr} }
|
||||
|
||||
func (l *llmImpl) ListSources() []string {
|
||||
if l.mgr == nil { return nil }
|
||||
return l.mgr.List()
|
||||
}
|
||||
|
||||
func (l *llmImpl) SetSource(name string) error {
|
||||
if l.mgr == nil { return nil }
|
||||
return l.mgr.SetDefault(name)
|
||||
}
|
||||
|
||||
func (l *llmImpl) CurrentSource() string {
|
||||
if l.mgr == nil { return "" }
|
||||
p := l.mgr.Default()
|
||||
if p == nil { return "" }
|
||||
return p.Name()
|
||||
}
|
||||
|
||||
var _ LLMAPI = (*llmImpl)(nil)
|
||||
@ -1,101 +1,14 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
sdkext "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
)
|
||||
import pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
|
||||
type MemoryAPI = sdkext.MemoryAPI
|
||||
type Entity = sdkext.Entity
|
||||
type Relation = sdkext.Relation
|
||||
type Triple = sdkext.Triple
|
||||
type MemoryAPI = pubsdk.MemoryAPI
|
||||
type Entity = pubsdk.Entity
|
||||
type Relation = pubsdk.Relation
|
||||
type Triple = pubsdk.Triple
|
||||
|
||||
type TextMemoryAPI = sdkext.TextMemoryAPI
|
||||
type TextEvent = sdkext.TextEvent
|
||||
type TextMemoryAPI = pubsdk.TextMemoryAPI
|
||||
type TextEvent = pubsdk.TextEvent
|
||||
|
||||
type DocMemoryAPI = sdkext.DocMemoryAPI
|
||||
type Doc = sdkext.Doc
|
||||
|
||||
type graphMemory struct{ db *memory.GraphDB }
|
||||
|
||||
func NewGraphMemory(db *memory.GraphDB) MemoryAPI { return &graphMemory{db: db} }
|
||||
|
||||
func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, error) {
|
||||
if m.db == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
result, err := m.db.Recall(query, nil, depth, "")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
entities := make([]Entity, len(result.Entities))
|
||||
for i, e := range result.Entities {
|
||||
entities[i] = Entity{Name: e.Name, Type: e.Type, MentionCount: e.MentionCount}
|
||||
}
|
||||
relations := make([]Relation, len(result.Relations))
|
||||
for i, r := range result.Relations {
|
||||
relations[i] = Relation{SourceName: r.SourceName, TargetName: r.TargetName, RelationType: r.RelationType}
|
||||
}
|
||||
return entities, relations, nil
|
||||
}
|
||||
|
||||
func (m *graphMemory) Commit(triples []Triple) error {
|
||||
if m.db == nil { return nil }
|
||||
ts := make([]memory.Triple, len(triples))
|
||||
for i, t := range triples {
|
||||
ts[i] = memory.Triple{Subject: t.Subject, Relation: t.Relation, Object: t.Object}
|
||||
}
|
||||
_, _, err := m.db.Commit(ts, "plugin", 0)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *graphMemory) Introspect() (map[string]interface{}, error) {
|
||||
if m.db == nil { return map[string]interface{}{}, nil }
|
||||
return m.db.Introspect()
|
||||
}
|
||||
|
||||
func (m *graphMemory) MergeEntities(source, target string) (int, error) {
|
||||
if m.db == nil { return 0, nil }
|
||||
return m.db.MergeEntities(source, target)
|
||||
}
|
||||
|
||||
func (m *graphMemory) Purge(criteria map[string]string, mode string) (int, error) {
|
||||
if m.db == nil { return 0, nil }
|
||||
return m.db.Purge(criteria, mode)
|
||||
}
|
||||
|
||||
type textMemoryImpl struct{ tm *text.Memory }
|
||||
|
||||
func NewTextMemory(tm *text.Memory) TextMemoryAPI { return &textMemoryImpl{tm: tm} }
|
||||
|
||||
func (m *textMemoryImpl) Append(evt TextEvent) error {
|
||||
if m.tm == nil { return nil }
|
||||
return m.tm.Append(text.Event{Timestamp: evt.Timestamp, Source: evt.Role, Input: evt.Content, AgentID: evt.Channel})
|
||||
}
|
||||
|
||||
type docMemoryImpl struct{ ds *doc.Store }
|
||||
|
||||
func NewDocMemory(ds *doc.Store) DocMemoryAPI { return &docMemoryImpl{ds: ds} }
|
||||
|
||||
func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
|
||||
if m.ds == nil { return nil }
|
||||
got := m.ds.Query(text, topK)
|
||||
out := make([]*Doc, len(got))
|
||||
for i, d := range got {
|
||||
out[i] = &Doc{ID: d.ID, Title: d.Summary, Content: d.Content}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *docMemoryImpl) Insert(d *Doc) error {
|
||||
if m.ds == nil { return nil }
|
||||
return m.ds.Insert(&doc.Doc{ID: d.ID, Summary: d.Title, Content: d.Content})
|
||||
}
|
||||
|
||||
func (m *docMemoryImpl) Remove(id string) { if m.ds != nil { m.ds.Remove(id) } }
|
||||
func (m *docMemoryImpl) Stats() map[string]interface{} {
|
||||
if m.ds == nil { return map[string]interface{}{} }
|
||||
return m.ds.Stats()
|
||||
}
|
||||
type DocMemoryAPI = pubsdk.DocMemoryAPI
|
||||
type Doc = pubsdk.Doc
|
||||
|
||||
92
internal/sdk/memory_impl.go
Normal file
92
internal/sdk/memory_impl.go
Normal file
@ -0,0 +1,92 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
)
|
||||
|
||||
type graphMemory struct{ db *memory.GraphDB }
|
||||
|
||||
func NewGraphMemory(db *memory.GraphDB) MemoryAPI { return &graphMemory{db: db} }
|
||||
|
||||
func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, error) {
|
||||
if m.db == nil { return nil, nil, nil }
|
||||
result, err := m.db.Recall(query, nil, depth, "")
|
||||
if err != nil { return nil, nil, err }
|
||||
entities := make([]Entity, len(result.Entities))
|
||||
for i, e := range result.Entities {
|
||||
entities[i] = Entity{Name: e.Name, Type: e.Type, MentionCount: e.MentionCount}
|
||||
}
|
||||
relations := make([]Relation, len(result.Relations))
|
||||
for i, r := range result.Relations {
|
||||
relations[i] = Relation{SourceName: r.SourceName, TargetName: r.TargetName, RelationType: r.RelationType}
|
||||
}
|
||||
return entities, relations, nil
|
||||
}
|
||||
|
||||
func (m *graphMemory) Commit(triples []Triple) error {
|
||||
if m.db == nil { return nil }
|
||||
ts := make([]memory.Triple, len(triples))
|
||||
for i, t := range triples {
|
||||
ts[i] = memory.Triple{Subject: t.Subject, Relation: t.Relation, Object: t.Object}
|
||||
}
|
||||
_, _, err := m.db.Commit(ts, "plugin", 0)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *graphMemory) Introspect() (map[string]interface{}, error) {
|
||||
if m.db == nil { return map[string]interface{}{}, nil }
|
||||
return m.db.Introspect()
|
||||
}
|
||||
|
||||
func (m *graphMemory) MergeEntities(source, target string) (int, error) {
|
||||
if m.db == nil { return 0, nil }
|
||||
return m.db.MergeEntities(source, target)
|
||||
}
|
||||
|
||||
func (m *graphMemory) Purge(criteria map[string]string, mode string) (int, error) {
|
||||
if m.db == nil { return 0, nil }
|
||||
return m.db.Purge(criteria, mode)
|
||||
}
|
||||
|
||||
type textMemoryImpl struct{ tm *text.Memory }
|
||||
|
||||
func NewTextMemory(tm *text.Memory) TextMemoryAPI { return &textMemoryImpl{tm: tm} }
|
||||
|
||||
func (m *textMemoryImpl) Append(evt TextEvent) error {
|
||||
if m.tm == nil { return nil }
|
||||
return m.tm.Append(text.Event{
|
||||
Timestamp: evt.Timestamp, Source: evt.Role, Input: evt.Content, AgentID: evt.Channel,
|
||||
})
|
||||
}
|
||||
|
||||
type docMemoryImpl struct{ ds *doc.Store }
|
||||
|
||||
func NewDocMemory(ds *doc.Store) DocMemoryAPI { return &docMemoryImpl{ds: ds} }
|
||||
|
||||
func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
|
||||
if m.ds == nil { return nil }
|
||||
got := m.ds.Query(text, topK)
|
||||
out := make([]*Doc, len(got))
|
||||
for i, d := range got {
|
||||
out[i] = &Doc{ID: d.ID, Title: d.Summary, Content: d.Content}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *docMemoryImpl) Insert(d *Doc) error {
|
||||
if m.ds == nil { return nil }
|
||||
return m.ds.Insert(&doc.Doc{ID: d.ID, Summary: d.Title, Content: d.Content})
|
||||
}
|
||||
|
||||
func (m *docMemoryImpl) Remove(id string) { if m.ds != nil { m.ds.Remove(id) } }
|
||||
|
||||
func (m *docMemoryImpl) Stats() map[string]interface{} {
|
||||
if m.ds == nil { return map[string]interface{}{} }
|
||||
return m.ds.Stats()
|
||||
}
|
||||
|
||||
var _ MemoryAPI = (*graphMemory)(nil)
|
||||
var _ TextMemoryAPI = (*textMemoryImpl)(nil)
|
||||
var _ DocMemoryAPI = (*docMemoryImpl)(nil)
|
||||
@ -3,7 +3,7 @@ package sdk
|
||||
import (
|
||||
"log"
|
||||
|
||||
sdkext "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
)
|
||||
@ -14,61 +14,68 @@ type Plugin interface {
|
||||
Stop() error
|
||||
}
|
||||
|
||||
type ToolHandler = sdkext.ToolHandler
|
||||
type StageHandler = sdkext.StageHandler
|
||||
type ToolHandler = pubsdk.ToolHandler
|
||||
type StageHandler = pubsdk.StageHandler
|
||||
|
||||
type Stage = sdkext.Stage
|
||||
type Stage = pubsdk.Stage
|
||||
|
||||
const (
|
||||
StageOnInput = sdkext.StageOnInput
|
||||
StagePreAction = sdkext.StagePreAction
|
||||
StagePostAction = sdkext.StagePostAction
|
||||
StageBeforeToolcall = sdkext.StageBeforeToolcall
|
||||
StageAfterToolcall = sdkext.StageAfterToolcall
|
||||
StageBeforeOutput = sdkext.StageBeforeOutput
|
||||
StageAfterOutput = sdkext.StageAfterOutput
|
||||
StageOnInput = pubsdk.StageOnInput
|
||||
StagePreAction = pubsdk.StagePreAction
|
||||
StagePostAction = pubsdk.StagePostAction
|
||||
StageBeforeToolcall = pubsdk.StageBeforeToolcall
|
||||
StageAfterToolcall = pubsdk.StageAfterToolcall
|
||||
StageBeforeOutput = pubsdk.StageBeforeOutput
|
||||
StageAfterOutput = pubsdk.StageAfterOutput
|
||||
)
|
||||
|
||||
type StageContext = sdkext.StageContext
|
||||
type MemItem = sdkext.MemItem
|
||||
type ToolCall = sdkext.ToolCall
|
||||
type ToolResult = sdkext.ToolResult
|
||||
type ToolDef = sdkext.ToolDef
|
||||
|
||||
type ToolRegistrar = func(name string, def ToolDef, handler ToolHandler) error
|
||||
type StageRegistrar = func(stage Stage, handler StageHandler)
|
||||
type APIRegistrar = func(name string) error
|
||||
|
||||
type ioAdapter struct{ iom *agentIO.IOManager }
|
||||
|
||||
func (i ioAdapter) InjectInterruptText(source, channel, text string) {
|
||||
if i.iom != nil {
|
||||
i.iom.InjectInterrupt(source, channel, map[string]interface{}{"type": "text", "content": text})
|
||||
}
|
||||
}
|
||||
|
||||
func (i ioAdapter) InjectText(source, channel, text string) {
|
||||
if i.iom != nil {
|
||||
i.iom.InjectInputTo(source, channel, "text", map[string]interface{}{"content": text})
|
||||
}
|
||||
}
|
||||
|
||||
func (i ioAdapter) InjectTextNoMemory(source, channel, text string) {
|
||||
if i.iom != nil {
|
||||
i.iom.InjectInputTo(source, channel, "text", map[string]interface{}{"content": text, "no_memory": true})
|
||||
}
|
||||
}
|
||||
type StageContext = pubsdk.StageContext
|
||||
type MemItem = pubsdk.MemItem
|
||||
type ToolCall = pubsdk.ToolCall
|
||||
type ToolResult = pubsdk.ToolResult
|
||||
type ToolDef = pubsdk.ToolDef
|
||||
type IOInjector = pubsdk.IOInjector
|
||||
type ToolRegistrar = pubsdk.ToolRegistrar
|
||||
type StageRegistrar = pubsdk.StageRegistrar
|
||||
type APIRegistrar = pubsdk.APIRegistrar
|
||||
|
||||
type PluginSDK struct {
|
||||
*sdkext.PluginSDK
|
||||
*pubsdk.PluginSDK
|
||||
iom *agentIO.IOManager
|
||||
eventBus *events.Bus
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func New(name string, iom *agentIO.IOManager, eventBus *events.Bus, mem MemoryAPI, textMem TextMemoryAPI, docMem DocMemoryAPI, know KnowledgeAPI, llm LLMAPI, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar) *PluginSDK {
|
||||
base := sdkext.New(name, sett, regTool, regStage, regAPI)
|
||||
base.SetIOInjector(ioAdapter{iom: iom})
|
||||
// ioAdapter 桥接 IOManager 到公共 SDK 的 IOInjector 接口,
|
||||
// 确保外部插件通过 s.InjectText() 等方法的调用能被路由到内核 IO 层。
|
||||
type ioAdapter struct{ iom *agentIO.IOManager }
|
||||
|
||||
func (a ioAdapter) InjectInterruptText(source, channel, text string) {
|
||||
if a.iom != nil {
|
||||
a.iom.InjectInterrupt(source, channel, map[string]interface{}{"type": "text", "content": text})
|
||||
}
|
||||
}
|
||||
|
||||
func (a ioAdapter) InjectText(source, channel, text string) {
|
||||
if a.iom != nil {
|
||||
a.iom.InjectInputTo(source, channel, "text", map[string]interface{}{"content": text})
|
||||
}
|
||||
}
|
||||
|
||||
func (a ioAdapter) InjectTextNoMemory(source, channel, text string) {
|
||||
if a.iom != nil {
|
||||
a.iom.InjectInputTo(source, channel, "text", map[string]interface{}{"content": text, "no_memory": true})
|
||||
}
|
||||
}
|
||||
|
||||
func New(name string, iom *agentIO.IOManager, eventBus *events.Bus, mem MemoryAPI,
|
||||
textMem TextMemoryAPI, docMem DocMemoryAPI, know KnowledgeAPI, llm LLMAPI,
|
||||
sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar,
|
||||
) *PluginSDK {
|
||||
base := pubsdk.New(name, sett, regTool, regStage, regAPI)
|
||||
if iom != nil {
|
||||
base.SetIOInjector(ioAdapter{iom: iom})
|
||||
}
|
||||
base.SetMemoryAPI(mem)
|
||||
base.SetTextMemoryAPI(textMem)
|
||||
base.SetDocMemoryAPI(docMem)
|
||||
@ -152,5 +159,3 @@ func (s *PluginSDK) Subscribe(eventType events.EventType, handler events.Handler
|
||||
}
|
||||
return func() {}
|
||||
}
|
||||
|
||||
func (s *PluginSDK) Logger() *log.Logger { return s.logger }
|
||||
|
||||
@ -1,106 +1,6 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
sdkext "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
)
|
||||
import pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
|
||||
type ConfigDef = sdkext.ConfigDef
|
||||
type SettingsAPI = sdkext.SettingsAPI
|
||||
|
||||
type settingsImpl struct {
|
||||
pluginName string
|
||||
reg *internalConfig.ConfigRegistry
|
||||
}
|
||||
|
||||
func NewSettings(name string, reg *internalConfig.ConfigRegistry) SettingsAPI {
|
||||
return &settingsImpl{pluginName: name, reg: reg}
|
||||
}
|
||||
|
||||
func (s *settingsImpl) Get(key string) (interface{}, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.PluginConfig(s.pluginName).Get(key)
|
||||
}
|
||||
func (s *settingsImpl) Set(key string, value interface{}) error {
|
||||
if s.reg == nil { return nil }
|
||||
return s.reg.PluginConfig(s.pluginName).Set(key, value)
|
||||
}
|
||||
func (s *settingsImpl) List(prefix string) ([]string, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.PluginConfig(s.pluginName).List(prefix)
|
||||
}
|
||||
func (s *settingsImpl) GetCore(key string) (interface{}, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.Get(key)
|
||||
}
|
||||
func (s *settingsImpl) SetCore(key string, value interface{}) error {
|
||||
if s.reg == nil { return nil }
|
||||
return s.reg.Set(key, value)
|
||||
}
|
||||
func (s *settingsImpl) ListCore(prefix string) ([]string, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.List(prefix), nil
|
||||
}
|
||||
func (s *settingsImpl) GetPlugin(plugin, key string) (interface{}, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.PluginConfig(plugin).Get(key)
|
||||
}
|
||||
func (s *settingsImpl) SetPlugin(plugin, key string, value interface{}) error {
|
||||
if s.reg == nil { return nil }
|
||||
return s.reg.PluginConfig(plugin).Set(key, value)
|
||||
}
|
||||
func (s *settingsImpl) ListPlugin(plugin, prefix string) ([]string, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.PluginConfig(plugin).List(prefix)
|
||||
}
|
||||
func (s *settingsImpl) RegisterDef(def sdkext.ConfigDef) {
|
||||
if s.reg == nil { return }
|
||||
s.reg.PluginConfig(s.pluginName).RegisterDef(internalConfig.ConfigDef{
|
||||
Key: def.Key, Type: def.Type, DisplayName: def.DisplayName, Description: def.Description,
|
||||
Category: def.Category, Options: def.Options,
|
||||
Default: stringifyDefault(def.Default),
|
||||
})
|
||||
}
|
||||
func (s *settingsImpl) Defs(prefix string) []*sdkext.ConfigDef {
|
||||
if s.reg == nil { return nil }
|
||||
defs := s.reg.PluginConfig(s.pluginName).ListDefs(prefix)
|
||||
out := make([]*sdkext.ConfigDef, len(defs))
|
||||
for i, d := range defs {
|
||||
cpy := sdkext.ConfigDef{
|
||||
Key: d.Key, Default: d.Default, Type: d.Type, DisplayName: d.DisplayName,
|
||||
Description: d.Description, Category: d.Category, Options: d.Options,
|
||||
}
|
||||
out[i] = &cpy
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (s *settingsImpl) Dump() map[string]interface{} {
|
||||
if s.reg == nil { return nil }
|
||||
return s.reg.Dump()
|
||||
}
|
||||
func (s *settingsImpl) Plugins() []string {
|
||||
if s.reg == nil { return nil }
|
||||
keys := s.reg.List("config_")
|
||||
names := make([]string, 0, len(keys)+1)
|
||||
names = append(names, "core")
|
||||
for _, k := range keys {
|
||||
if len(k) > 7 { names = append(names, k[7:]) }
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func stringifyDefault(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case bool:
|
||||
if x { return "true" }
|
||||
return "false"
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
type SettingsAPI = pubsdk.SettingsAPI
|
||||
type ConfigDef = pubsdk.ConfigDef
|
||||
|
||||
102
internal/sdk/settings_impl.go
Normal file
102
internal/sdk/settings_impl.go
Normal file
@ -0,0 +1,102 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
)
|
||||
|
||||
type settingsImpl struct {
|
||||
pluginName string
|
||||
reg *internalConfig.ConfigRegistry
|
||||
}
|
||||
|
||||
func NewSettings(name string, reg *internalConfig.ConfigRegistry) SettingsAPI {
|
||||
return &settingsImpl{pluginName: name, reg: reg}
|
||||
}
|
||||
|
||||
func (s *settingsImpl) Get(key string) (interface{}, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.PluginConfig(s.pluginName).Get(key)
|
||||
}
|
||||
func (s *settingsImpl) Set(key string, value interface{}) error {
|
||||
if s.reg == nil { return nil }
|
||||
return s.reg.PluginConfig(s.pluginName).Set(key, value)
|
||||
}
|
||||
func (s *settingsImpl) List(prefix string) ([]string, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.PluginConfig(s.pluginName).List(prefix)
|
||||
}
|
||||
func (s *settingsImpl) GetCore(key string) (interface{}, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.Get(key)
|
||||
}
|
||||
func (s *settingsImpl) SetCore(key string, value interface{}) error {
|
||||
if s.reg == nil { return nil }
|
||||
return s.reg.Set(key, value)
|
||||
}
|
||||
func (s *settingsImpl) ListCore(prefix string) ([]string, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.List(prefix), nil
|
||||
}
|
||||
func (s *settingsImpl) GetPlugin(plugin, key string) (interface{}, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.PluginConfig(plugin).Get(key)
|
||||
}
|
||||
func (s *settingsImpl) SetPlugin(plugin, key string, value interface{}) error {
|
||||
if s.reg == nil { return nil }
|
||||
return s.reg.PluginConfig(plugin).Set(key, value)
|
||||
}
|
||||
func (s *settingsImpl) ListPlugin(plugin, prefix string) ([]string, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.PluginConfig(plugin).List(prefix)
|
||||
}
|
||||
func (s *settingsImpl) RegisterDef(def ConfigDef) {
|
||||
if s.reg == nil { return }
|
||||
s.reg.PluginConfig(s.pluginName).RegisterDef(internalConfig.ConfigDef{
|
||||
Key: def.Key, Type: def.Type, DisplayName: def.DisplayName, Description: def.Description,
|
||||
Category: def.Category, Options: def.Options,
|
||||
Default: stringifyDefault(def.Default),
|
||||
})
|
||||
}
|
||||
func (s *settingsImpl) Defs(prefix string) []*ConfigDef {
|
||||
if s.reg == nil { return nil }
|
||||
defs := s.reg.PluginConfig(s.pluginName).ListDefs(prefix)
|
||||
out := make([]*ConfigDef, len(defs))
|
||||
for i, d := range defs {
|
||||
// ConfigDef = pubsdk.ConfigDef (type alias), so direct conversion works
|
||||
cpy := ConfigDef{
|
||||
Key: d.Key, Type: d.Type, DisplayName: d.DisplayName, Description: d.Description,
|
||||
Category: d.Category, Options: d.Options,
|
||||
}
|
||||
out[i] = &cpy
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (s *settingsImpl) Dump() map[string]interface{} {
|
||||
if s.reg == nil { return nil }
|
||||
return s.reg.Dump()
|
||||
}
|
||||
func (s *settingsImpl) Plugins() []string {
|
||||
if s.reg == nil { return nil }
|
||||
keys := s.reg.List("config_")
|
||||
names := make([]string, 0, len(keys)+1)
|
||||
names = append(names, "core")
|
||||
for _, k := range keys {
|
||||
if len(k) > 7 { names = append(names, k[7:]) }
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func stringifyDefault(v interface{}) string {
|
||||
if v == nil { return "" }
|
||||
switch x := v.(type) {
|
||||
case string: return x
|
||||
case bool:
|
||||
if x { return "true" }
|
||||
return "false"
|
||||
default: return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure settingsImpl satisfies SettingsAPI (pubsdk.SettingsAPI via type alias).
|
||||
var _ SettingsAPI = (*settingsImpl)(nil)
|
||||
@ -1,196 +0,0 @@
|
||||
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:]
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
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
|
||||
}
|
||||
123
plan.md
Normal file
123
plan.md
Normal file
@ -0,0 +1,123 @@
|
||||
# HomeAgent 修复计划
|
||||
|
||||
> 基于 `修复计划.md`,按优先级逐项推进。
|
||||
|
||||
---
|
||||
|
||||
## P0 — 功能正确性(必须修)
|
||||
|
||||
### P0-1 HTTP 401/403 检测靠字符串搜索
|
||||
- **文件**: `internal/agent/core/agent.go:708-711`
|
||||
- **问题**: `strings.Contains(errStr, "401")` 不可靠,Lua Adapter 返回格式不固定
|
||||
- **修复**: `ProviderManager` 增加 `ReportStatus(name, statusCode)`,在 `LuaAdaptedProvider.Chat()` 中根据 `resp.StatusCode` 精确判断
|
||||
|
||||
### P0-2 deploy/homeagent.service 传递 -config 参数使 homed 启动崩溃
|
||||
- **文件**: `deploy/homeagent.service:9`
|
||||
- **问题**: `ExecStart` 含 `-config`,但 `cmd/homed/main.go` 未定义此 flag
|
||||
- **修复**: 删除 `-config` 参数
|
||||
|
||||
---
|
||||
|
||||
## P2 — 中风险
|
||||
|
||||
### P2-1 context.go 每次 Append/Prune 全量写盘
|
||||
- **文件**: `internal/agent/core/context.go:88-101`
|
||||
- **问题**: 30 条事件 JSON 全量写入文件每次操作,高频 I/O 瓶颈
|
||||
- **修复**: 增加 debounce 定时写入(每 5s flush)
|
||||
|
||||
### P2-2 output_set_channel 枚举硬编码
|
||||
- **文件**: `internal/agent/core/agent.go:1927-1928`
|
||||
- **问题**: channel enum 硬编码为 `{"voice", "email", "screen", "http"}`,与 Device 动态注册脱节
|
||||
- **修复**: 从 `a.io.ListChannels()` 动态生成 enum
|
||||
|
||||
### P2-3 describe_image/transcribe_audio/ocr_image 三重复代码
|
||||
- **文件**: `internal/agent/core/agent.go:2682-2828`
|
||||
- **问题**: 三个函数共享相同的 base64/data URL 处理、timeout、消息构造逻辑
|
||||
- **修复**: 抽取 `mediaRequest(mediaType, prompt, args) → string` 公共方法
|
||||
|
||||
### P2-4 两处 sources map 硬编码重复
|
||||
- **文件**: `internal/config/registry.go:233-244` + `:302-313`
|
||||
- **问题**: `seedDBValues` 和 `seedCoreDefs` 各写了一遍完全相同的 sources map
|
||||
- **修复**: 抽取公共 var `defaultSources`
|
||||
|
||||
---
|
||||
|
||||
## P3 — 低风险(死代码删除/清理)
|
||||
|
||||
### P3-1 删除 messagesToMap 死函数
|
||||
- **文件**: `internal/agent/api/provider.go:731-740`
|
||||
|
||||
### P3-2 删除 RunStageAll 死函数
|
||||
- **文件**: `internal/agent/core/stages.go:106-108`
|
||||
|
||||
### P3-3 删除 internal/embed/ 整包死代码
|
||||
- **文件**: `internal/embed/embedder.go`(162 行,无任何 import)
|
||||
|
||||
### P3-4 删除 internal/tokenizer/jieba.go 死代码
|
||||
- **文件**: `internal/tokenizer/jieba.go`(81 行,Global() 从未被调用)
|
||||
|
||||
### P3-5 删除 internal/container/ 整包死代码
|
||||
- **文件**: `internal/container/manager.go`(209 行,NewManager 从未被调用)
|
||||
|
||||
### P3-6 删除 internal/snapshot/ 整包死代码
|
||||
- **文件**: `internal/snapshot/manager.go`(196 行,import container 但自身也死)
|
||||
|
||||
---
|
||||
|
||||
## 仓库清理
|
||||
|
||||
### CL-1 go.work 版本不一致
|
||||
- **文件**: `go.work:1`
|
||||
- **问题**: 声明 `go 1.19` 但 SDK 模块要求 `go 1.21`
|
||||
- **修复**: 升级到 `go 1.21`
|
||||
|
||||
### CL-2 .gitignore 补充
|
||||
- **文件**: `.gitignore`
|
||||
- **修复**: 添加 `data/` 和 `*.db`
|
||||
|
||||
### CL-3 文档路径修正
|
||||
- **文件**: `docs/ARCHITECTURE.md`
|
||||
- **问题**: L283 `sdk/` 条目应指向 `internal/sdk/`
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## C1 — 架构清理:删除 output_set_channel
|
||||
|
||||
> **背景**: 架构原则要求 LLM 主动调用输出工具进行输出。`output_set_channel` 作为一个全局隐式状态,
|
||||
> 与 `output_send` 的精确指定模式重叠,且 LLM 可能忘记自己设过该状态导致回复走错通道。
|
||||
|
||||
### C1-1 删除 output_set_channel 工具定义
|
||||
- **文件**: `internal/agent/core/agent.go` — `buildToolDefs()` 中删除 `output_set_channel` 的 tool definition block
|
||||
- **影响**: `output_send` 和 `output_list_channels` 保留,它们的 `chanDesc` 动态生成逻辑不动
|
||||
|
||||
### C1-2 删除 executeOutputChannelTool 方法
|
||||
- **文件**: `internal/agent/core/agent.go` — 删除 `executeOutputChannelTool()` 函数体
|
||||
|
||||
### C1-3 删除 output_set_channel 路由分支
|
||||
- **文件**: `internal/agent/core/agent.go` — `executeToolCall()` 中删除 `case tc.Name == "output_set_channel"`
|
||||
|
||||
### C1-4 更新子 Agent 输出工具黑名单
|
||||
- **文件**: `internal/agent/core/agent.go`
|
||||
- `outputTools` map 中移除 `"output_set_channel"`
|
||||
- 子工具检查中移除 `ct.Name == "output_set_channel"`
|
||||
|
||||
### C1-5 更新注释
|
||||
- **文件**: `internal/agent/core/agent.go:555` — 移除"可能已被 AI 通过 output_set_channel 切换"的注释
|
||||
|
||||
### C1-6 清理测试文件
|
||||
- **文件**: `internal/agent/core/agent_tools_test.go`
|
||||
- 删除 `TestExecuteOutputChannelTool` 和 `TestExecuteOutputChannelToolEmpty`
|
||||
- `TestBuildToolDefsOutputToolsAlwaysPresent` 中移除 `output_set_channel` 检查
|
||||
- `TestGetAllToolsEmpty` 最小工具数从 3 改为 2
|
||||
|
||||
### C1-7 验证编译与测试通过
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序
|
||||
|
||||
```
|
||||
P0-1 → P0-2 → P2-1 → P2-2 → P2-3 → P2-4 → P3-1~6 → CL-1~3 → C1-1~7
|
||||
```
|
||||
2
third_party/homeagent-sdk/.gitignore
vendored
2
third_party/homeagent-sdk/.gitignore
vendored
@ -1,2 +0,0 @@
|
||||
*.so
|
||||
*.hmap
|
||||
145
third_party/homeagent-sdk/README.md
vendored
145
third_party/homeagent-sdk/README.md
vendored
@ -1,145 +0,0 @@
|
||||
# HomeAgent Plugin SDK
|
||||
|
||||
HomeAgent 外部插件开发工具包。用于开发独立于内核的 `.so` 动态插件。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
homeagent-sdk/
|
||||
├── sdk/ # Go SDK 包(import: gitcode.com/JianFeeeee/homeagent-sdk/sdk)
|
||||
│ ├── plugin.go # Plugin 接口、PluginSDK、ToolDef、ToolHandler
|
||||
│ ├── settings.go # SettingsAPI(插件配置读写)
|
||||
│ ├── memory.go # MemoryAPI / TextMemoryAPI / DocMemoryAPI
|
||||
│ ├── knowledge.go # KnowledgeAPI(知识库访问)
|
||||
│ ├── llm.go # LLMAPI(LLM 源管理)
|
||||
│ └── API.md # 完整 API 参考文档
|
||||
├── hack/plugin-dev/ # 开发工具
|
||||
│ ├── scaffold.sh # 脚手架:生成新插件项目
|
||||
│ ├── packager.sh # 打包插件为 .hmap 分发包
|
||||
│ └── testharness/ # 插件测试框架
|
||||
├── example/ # 完整插件示例
|
||||
│ ├── qq/ # QQ 集成(对接 NapCat OneBot)
|
||||
│ ├── files/ # 文件系统操作
|
||||
│ ├── memo/ # 备忘提醒
|
||||
│ └── web/ # 网络搜索与抓取
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 前置条件
|
||||
|
||||
- Go 1.21+
|
||||
- 运行中的 HomeAgent 内核(用于部署插件)
|
||||
|
||||
### 创建插件
|
||||
|
||||
```bash
|
||||
git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git
|
||||
cd homeagent-sdk
|
||||
|
||||
# 用脚手架生成项目骨架
|
||||
hack/plugin-dev/scaffold.sh myplugin ./plugins/myplugin
|
||||
|
||||
# 编辑插件代码
|
||||
vim plugins/myplugin/plugin.go
|
||||
```
|
||||
|
||||
### 插件接口
|
||||
|
||||
每个插件必须实现三个方法:
|
||||
|
||||
```go
|
||||
type Plugin interface {
|
||||
Name() string // 插件名称
|
||||
Start(sdk *PluginSDK) error // 启动:注册工具、阶段钩子等
|
||||
Stop() error // 停止:清理资源
|
||||
}
|
||||
```
|
||||
|
||||
入口函数签名(插件 .so 必须导出此函数):
|
||||
|
||||
```go
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error)
|
||||
```
|
||||
|
||||
### 编译
|
||||
|
||||
```bash
|
||||
# 从插件目录
|
||||
cd plugins/myplugin && make
|
||||
|
||||
# 或手动编译
|
||||
cd <SDK_REPO_ROOT> && go build -buildmode=plugin -o <PLUGIN_DIR>/plugin.so <PLUGIN_DIR>
|
||||
```
|
||||
|
||||
### 部署
|
||||
|
||||
将插件目录放入 HomeAgent 内核的插件目录(`<dataDir>/plugins/<name>/`):
|
||||
|
||||
```
|
||||
<dataDir>/plugins/myplugin/
|
||||
plugin.json — {"name": "myplugin", "version": "1.0", "entry": "plugin.so"}
|
||||
plugin.so — 编译产物
|
||||
```
|
||||
|
||||
内核启动时自动发现并加载。也可通过 WebUI 插件管理页面上传 `.hmap` 包安装。
|
||||
|
||||
## PluginSDK API 参考
|
||||
|
||||
完整 API 文档见 [sdk/API.md](sdk/API.md),涵盖:
|
||||
|
||||
- **工具注册** — `RegisterTool`、`ToolDef`、`ToolHandler`
|
||||
- **阶段钩子** — 7 个阶段的 `StageContext` 读写权限、工具归属插件字段和短路规则
|
||||
- **输入投递** — `InjectText` / `InjectInterruptText` 两种投递方式
|
||||
- **配置管理** — `SettingsAPI`,含自身/核心/跨插件配置
|
||||
- **记忆访问** — 图记忆(`MemoryAPI`)、文档记忆(`DocMemoryAPI`)、文本记忆(`TextMemoryAPI`)
|
||||
- **知识库** — `KnowledgeAPI` 搜索/添加/列表
|
||||
- **LLM 管理** — `LLMAPI` 源切换
|
||||
- **所有 SDK 类型定义** — `ToolCall`、`StageContext`、`Entity`、`Triple`、`ConfigDef` 等
|
||||
|
||||
## 打包分发
|
||||
|
||||
```bash
|
||||
hack/plugin-dev/packager.sh plugins/myplugin
|
||||
# 输出: dist/myplugin-0.1.0.hmap
|
||||
```
|
||||
|
||||
`.hmap` 文件是一个 zip 包,内含:
|
||||
- `plugin.json` — 清单文件(名称、版本、入口)
|
||||
- `plugin.so` — 编译好的 Go 插件
|
||||
|
||||
通过 WebUI 插件管理器上传安装。
|
||||
|
||||
## 测试
|
||||
|
||||
SDK 提供测试框架 `testharness`,可加载 .so 并模拟调用:
|
||||
|
||||
```go
|
||||
import "gitcode.com/JianFeeeee/homeagent-sdk/hack/plugin-dev/testharness"
|
||||
|
||||
func TestMyPlugin(t *testing.T) {
|
||||
h := testharness.New(t, "./plugin.so")
|
||||
defer h.Close()
|
||||
|
||||
result, err := h.CallTool("myplugin_my_tool", map[string]interface{}{
|
||||
"input": "hello",
|
||||
})
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 示例插件
|
||||
|
||||
每个示例目录下都有对应的 `README.md`,包含详细的设计讲解和源码引用。
|
||||
|
||||
| 示例 | 说明 | 详细文档 |
|
||||
|------|------|----------|
|
||||
| [QQ](example/qq/) | 对接 NapCat OneBot,15 个工具,涵盖消息/群/好友/文件/OCR | [讲解](example/qq/README.md) |
|
||||
| [Files](example/files/) | 文件系统操作,4 种写入模式,分段读取,沙箱隔离 | [讲解](example/files/README.md) |
|
||||
| [Memo](example/memo/) | 备忘管理,PreAction 注入 + 定时打断双提醒 | [讲解](example/memo/README.md) |
|
||||
| [Web](example/web/) | DuckDuckGo 搜索 + 网页抓取,SSRF 防护,代理支持 | [讲解](example/web/README.md) |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@ -1,84 +0,0 @@
|
||||
# files 插件讲解
|
||||
|
||||
文件系统操作插件,提供文件的读写编辑和目录浏览能力。
|
||||
|
||||
## 工具清单
|
||||
|
||||
| 工具 | 功能 | 源码 |
|
||||
|------|------|------|
|
||||
| `files_read` | 读取文件内容,支持 offset/limit 分段 | `handleRead` |
|
||||
| `files_write` | 写入文件,支持 4 种模式 | `handleWrite` |
|
||||
| `files_edit` | 精确字符串替换编辑 | `handleEdit` |
|
||||
| `files_ls` | 列出目录内容 | `handleLs` |
|
||||
|
||||
## 核心设计
|
||||
|
||||
### 沙箱路径隔离
|
||||
|
||||
`resolvePath()` 方法将用户传入的路径解析为沙箱内的绝对路径。关键逻辑:
|
||||
|
||||
```go
|
||||
// 相对路径以沙箱根目录为基准拼接
|
||||
if !filepath.IsAbs(userPath) {
|
||||
userPath = filepath.Join(p.filesDir, userPath)
|
||||
}
|
||||
// 检查是否越界
|
||||
base := filepath.Clean(p.filesDir)
|
||||
if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base {
|
||||
return "", fmt.Errorf("path outside sandbox")
|
||||
}
|
||||
```
|
||||
|
||||
当沙箱根设为 `/` 时放行所有路径;设为特定目录时拒绝访问外部。配置项 `plugin.files.dir` 控制此值。
|
||||
|
||||
### 分段读取
|
||||
|
||||
`files_read` 支持 `offset`(行号,1-indexed)和 `limit`(行数上限),用于大文件分段查看:
|
||||
|
||||
```go
|
||||
// plugin.go:handleRead
|
||||
lines := strings.Split(text, "\n")
|
||||
offset := 0 // 从 args["offset"] 解析,1-indexed 转 0-indexed
|
||||
limit := totalLines - offset
|
||||
// ...
|
||||
end := offset + limit
|
||||
selected := lines[offset:end]
|
||||
```
|
||||
|
||||
如果未读完会在末尾追加提示 `[Showing lines X-Y of Z. Use offset=N to continue.]`。
|
||||
|
||||
### 四种写入模式
|
||||
|
||||
`files_write` 通过 `mode` 参数区分:
|
||||
|
||||
- **overwrite**(默认):`os.WriteFile` 覆盖写入,自动创建父目录
|
||||
- **append**:`os.OpenFile` 以 `O_APPEND|O_CREATE|O_WRONLY` 打开,追加内容
|
||||
- **insert**:将文件按行分割,在指定行号前插入新内容,再写回
|
||||
- **create**:先检查文件是否已存在,存在则报错,不存在才创建
|
||||
|
||||
### 精确编辑
|
||||
|
||||
`files_edit` 接收 `edits` 数组,每个元素有 `old` 和 `new`。要求每个 `old` 在原文中**恰好出现一次**,防止 LLM 误替换:
|
||||
|
||||
```go
|
||||
count := strings.Count(content, oldText)
|
||||
if count == 0 { /* 报错未找到 */ }
|
||||
if count > 1 { /* 报错存在多处匹配 */ }
|
||||
content = strings.Replace(content, oldText, newText, 1)
|
||||
```
|
||||
|
||||
### 目录列表
|
||||
|
||||
`files_ls` 按字母序排序,目录加 `/` 后缀,同时显示文件大小。默认上限 500 条。
|
||||
|
||||
## 配置项
|
||||
|
||||
| Key | 默认值 | 说明 |
|
||||
|-----|--------|------|
|
||||
| `plugin.files.dir` | `/` | 文件操作沙箱根目录 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 所有路径操作前都经过 `resolvePath` 沙箱检查
|
||||
- 错误结果统一用 `errorResult()` 返回 `{isError: true, content: msg}` 格式,LLM 可据此判断
|
||||
- `files_write` 的 insert/append 模式不检查文件是否存在(不存在则报错),overwrite/create 模式自动创建父目录
|
||||
482
third_party/homeagent-sdk/example/files/plugin.go
vendored
482
third_party/homeagent-sdk/example/files/plugin.go
vendored
@ -1,482 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
mu sync.RWMutex
|
||||
filesDir string
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "plugin.files.dir",
|
||||
Default: "/",
|
||||
Type: "string",
|
||||
DisplayName: "文件系统根目录",
|
||||
Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)",
|
||||
Category: "files",
|
||||
})
|
||||
|
||||
dir := getSetting[string](s.Settings(), "dir", "/")
|
||||
if strings.HasPrefix(dir, "~/") {
|
||||
home, _ := os.UserHomeDir()
|
||||
dir = filepath.Join(home, dir[2:])
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve files.dir: %w", err)
|
||||
}
|
||||
p.filesDir = abs
|
||||
os.MkdirAll(p.filesDir, 0755)
|
||||
|
||||
tp := p.name + "_"
|
||||
|
||||
s.RegisterTool(tp+"read", sdk.ToolDef{
|
||||
Name: tp + "read",
|
||||
Description: fmt.Sprintf("Read file contents within the sandbox directory (%s). Supports offset/limit for large files.", p.filesDir),
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"},
|
||||
"offset": map[string]interface{}{"type": "integer", "description": "Starting line number (1-indexed, optional)"},
|
||||
"limit": map[string]interface{}{"type": "integer", "description": "Max lines to return (optional)"},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
}, p.handleRead)
|
||||
|
||||
s.RegisterTool(tp+"write", sdk.ToolDef{
|
||||
Name: tp + "write",
|
||||
Description: fmt.Sprintf("Write content to a file. Creates parent directories automatically. Sandbox: %s", p.filesDir),
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "File path"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "Content to write"},
|
||||
"mode": map[string]interface{}{"type": "string", "description": "Write mode: overwrite (default) | append | insert | create"},
|
||||
"line": map[string]interface{}{"type": "integer", "description": "Line number for insert mode (1-indexed)"},
|
||||
},
|
||||
"required": []string{"path", "content"},
|
||||
},
|
||||
}, p.handleWrite)
|
||||
|
||||
s.RegisterTool(tp+"edit", sdk.ToolDef{
|
||||
Name: tp + "edit",
|
||||
Description: fmt.Sprintf("Apply exact string replacements to a file within the sandbox (%s). All edits are matched against the original file content.", p.filesDir),
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"},
|
||||
"edits": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "One or more targeted replacements. Each old must match exactly once in the original file. Do not include overlapping edits.",
|
||||
"items": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"old": map[string]interface{}{"type": "string", "description": "Exact text to find (must be unique)"},
|
||||
"new": map[string]interface{}{"type": "string", "description": "Replacement text"},
|
||||
},
|
||||
"required": []string{"old", "new"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "edits"},
|
||||
},
|
||||
}, p.handleEdit)
|
||||
|
||||
s.RegisterTool(tp+"ls", sdk.ToolDef{
|
||||
Name: tp + "ls",
|
||||
Description: fmt.Sprintf("List directory contents within the sandbox (%s). Directories are marked with / suffix.", p.filesDir),
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "Directory path (optional, defaults to sandbox root)"},
|
||||
"limit": map[string]interface{}{"type": "integer", "description": "Max entries (optional, default 500)"},
|
||||
},
|
||||
},
|
||||
}, p.handleLs)
|
||||
|
||||
log.Printf("[%s] started, sandbox: %s", p.name, p.filesDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvePath resolves user-provided path to an absolute path within filesDir.
|
||||
func (p *Plugin) resolvePath(userPath string) (string, error) {
|
||||
if userPath == "" {
|
||||
userPath = "."
|
||||
}
|
||||
if !filepath.IsAbs(userPath) {
|
||||
userPath = filepath.Join(p.filesDir, userPath)
|
||||
}
|
||||
abs, err := filepath.Abs(userPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve path: %w", err)
|
||||
}
|
||||
base := filepath.Clean(p.filesDir)
|
||||
if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base {
|
||||
return "", fmt.Errorf("path outside sandbox: %s", userPath)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// handleRead implements the read tool.
|
||||
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return errorResult("path is required"), nil
|
||||
}
|
||||
|
||||
absPath, err := p.resolvePath(path)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errorResult("file not found: " + path), nil
|
||||
}
|
||||
return errorResult("stat error: " + err.Error()), nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return errorResult("is a directory, use ls instead: " + path), nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return errorResult("read error: " + err.Error()), nil
|
||||
}
|
||||
|
||||
text := string(data)
|
||||
lines := strings.Split(text, "\n")
|
||||
totalLines := len(lines)
|
||||
|
||||
offset := 0
|
||||
if v, ok := args["offset"].(float64); ok && v > 0 {
|
||||
offset = int(v) - 1
|
||||
}
|
||||
if offset >= totalLines {
|
||||
return errorResult(fmt.Sprintf("offset %d exceeds file length (%d lines)", offset+1, totalLines)), nil
|
||||
}
|
||||
|
||||
limit := totalLines - offset
|
||||
if v, ok := args["limit"].(float64); ok && v > 0 {
|
||||
if int(v) < limit {
|
||||
limit = int(v)
|
||||
}
|
||||
}
|
||||
|
||||
end := offset + limit
|
||||
if end > totalLines {
|
||||
end = totalLines
|
||||
}
|
||||
|
||||
selected := lines[offset:end]
|
||||
output := strings.Join(selected, "\n")
|
||||
|
||||
truncated := false
|
||||
if limit < totalLines-offset {
|
||||
truncated = true
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(output)
|
||||
if truncated {
|
||||
nextOffset := end + 1
|
||||
sb.WriteString(fmt.Sprintf("\n\n[Showing lines %d-%d of %d. Use offset=%d to continue.]", offset+1, end, totalLines, nextOffset))
|
||||
} else if offset > 0 || end < totalLines {
|
||||
sb.WriteString(fmt.Sprintf("\n\n[%d lines total]", totalLines))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": sb.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleWrite implements the write tool.
|
||||
func (p *Plugin) handleWrite(args map[string]interface{}) (interface{}, error) {
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return errorResult("path is required"), nil
|
||||
}
|
||||
content, _ := args["content"].(string)
|
||||
mode, _ := args["mode"].(string)
|
||||
if mode == "" {
|
||||
mode = "overwrite"
|
||||
}
|
||||
|
||||
line := 0
|
||||
if v, ok := args["line"].(float64); ok && v > 0 {
|
||||
line = int(v)
|
||||
}
|
||||
|
||||
absPath, err := p.resolvePath(path)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "create":
|
||||
if _, err := os.Stat(absPath); err == nil {
|
||||
return errorResult("file already exists: " + path), nil
|
||||
}
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return errorResult("mkdir error: " + err.Error()), nil
|
||||
}
|
||||
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||
return errorResult("write error: " + err.Error()), nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("Created %s (%d bytes)", path, len(content)),
|
||||
}, nil
|
||||
|
||||
case "append":
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return errorResult("mkdir error: " + err.Error()), nil
|
||||
}
|
||||
f, err := os.OpenFile(absPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return errorResult("open error: " + err.Error()), nil
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
return errorResult("append error: " + err.Error()), nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("Appended %d bytes to %s", len(content), path),
|
||||
}, nil
|
||||
|
||||
case "insert":
|
||||
if line < 1 {
|
||||
return errorResult("line must be >= 1 for insert mode"), nil
|
||||
}
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errorResult("file not found: " + path), nil
|
||||
}
|
||||
return errorResult("read error: " + err.Error()), nil
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
if line > len(lines)+1 {
|
||||
return errorResult(fmt.Sprintf("line %d exceeds file length (%d lines)", line, len(lines))), nil
|
||||
}
|
||||
idx := line - 1
|
||||
newLines := make([]string, 0, len(lines)+1)
|
||||
newLines = append(newLines, lines[:idx]...)
|
||||
newLines = append(newLines, content)
|
||||
newLines = append(newLines, lines[idx:]...)
|
||||
result := strings.Join(newLines, "\n")
|
||||
if err := os.WriteFile(absPath, []byte(result), 0644); err != nil {
|
||||
return errorResult("write error: " + err.Error()), nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("Inserted %d bytes at line %d in %s", len(content), line, path),
|
||||
}, nil
|
||||
|
||||
default: // overwrite
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return errorResult("mkdir error: " + err.Error()), nil
|
||||
}
|
||||
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||
return errorResult("write error: " + err.Error()), nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("Wrote %d bytes to %s", len(content), path),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// handleEdit implements the edit tool.
|
||||
func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) {
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return errorResult("path is required"), nil
|
||||
}
|
||||
|
||||
absPath, err := p.resolvePath(path)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
|
||||
rawEdits, ok := args["edits"].([]interface{})
|
||||
if !ok || len(rawEdits) == 0 {
|
||||
return errorResult("edits must be a non-empty array"), nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errorResult("file not found: " + path), nil
|
||||
}
|
||||
return errorResult("read error: " + err.Error()), nil
|
||||
}
|
||||
|
||||
original := string(data)
|
||||
content := original
|
||||
applied := 0
|
||||
var errors []string
|
||||
|
||||
for i, raw := range rawEdits {
|
||||
edit, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
errors = append(errors, fmt.Sprintf("edit[%d]: invalid format", i))
|
||||
continue
|
||||
}
|
||||
oldText, _ := edit["old"].(string)
|
||||
newText, _ := edit["new"].(string)
|
||||
if oldText == "" {
|
||||
errors = append(errors, fmt.Sprintf("edit[%d]: old is required", i))
|
||||
continue
|
||||
}
|
||||
|
||||
count := strings.Count(content, oldText)
|
||||
if count == 0 {
|
||||
errors = append(errors, fmt.Sprintf("edit[%d]: could not find %q in %s", i, oldText, path))
|
||||
continue
|
||||
}
|
||||
if count > 1 {
|
||||
errors = append(errors, fmt.Sprintf("edit[%d]: found %d occurrences of %q, must be unique", i, count, oldText))
|
||||
continue
|
||||
}
|
||||
|
||||
content = strings.Replace(content, oldText, newText, 1)
|
||||
applied++
|
||||
}
|
||||
|
||||
if applied == 0 {
|
||||
msg := "no edits applied"
|
||||
if len(errors) > 0 {
|
||||
msg += ": " + strings.Join(errors, "; ")
|
||||
}
|
||||
return errorResult(msg), nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||
return errorResult("write error: " + err.Error()), nil
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Successfully applied %d/%d edits to %s", applied, len(rawEdits), path)
|
||||
if len(errors) > 0 {
|
||||
msg += "\nWarnings:\n" + strings.Join(errors, "\n")
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": msg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleLs implements the ls tool.
|
||||
func (p *Plugin) handleLs(args map[string]interface{}) (interface{}, error) {
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
|
||||
absPath, err := p.resolvePath(path)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errorResult("path not found: " + path), nil
|
||||
}
|
||||
return errorResult("stat error: " + err.Error()), nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return errorResult("not a directory: " + path), nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(absPath)
|
||||
if err != nil {
|
||||
return errorResult("readdir error: " + err.Error()), nil
|
||||
}
|
||||
|
||||
limit := 500
|
||||
if v, ok := args["limit"].(float64); ok && v > 0 {
|
||||
limit = int(v)
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
|
||||
})
|
||||
|
||||
var lines []string
|
||||
entryLimitReached := false
|
||||
for i, entry := range entries {
|
||||
if i >= limit {
|
||||
entryLimitReached = true
|
||||
break
|
||||
}
|
||||
name := entry.Name()
|
||||
if entry.IsDir() {
|
||||
name += "/"
|
||||
}
|
||||
lines = append(lines, name)
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": "(empty directory)",
|
||||
}, nil
|
||||
}
|
||||
|
||||
output := strings.Join(lines, "\n")
|
||||
if entryLimitReached {
|
||||
output += fmt.Sprintf("\n\n[%d entries limit reached. Use limit=N for more.]", limit)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": output,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// errorResult returns a standardized error result.
|
||||
func errorResult(msg string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"isError": true,
|
||||
"content": msg,
|
||||
}
|
||||
}
|
||||
|
||||
// getSetting reads a setting with generic type assertion.
|
||||
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
|
||||
v, err := s.Get(key)
|
||||
if err != nil || v == nil {
|
||||
return def
|
||||
}
|
||||
val, ok := v.(T)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
return val
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "files",
|
||||
"version": "1.0.0",
|
||||
"description": "文件系统操作插件,提供文件读写、编辑、目录列表等工具",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["files", "filesystem", "io"]
|
||||
}
|
||||
73
third_party/homeagent-sdk/example/memo/README.md
vendored
73
third_party/homeagent-sdk/example/memo/README.md
vendored
@ -1,73 +0,0 @@
|
||||
# memo 插件讲解
|
||||
|
||||
备忘插件,支持创建、完成、列出备忘,自动提醒未完成事项。
|
||||
|
||||
## 工具清单
|
||||
|
||||
| 工具 | 功能 | 源码 |
|
||||
|------|------|------|
|
||||
| `memo_create` | 创建一条备忘 | `handleCreate` |
|
||||
| `memo_complete` | 标记备忘为已完成 | `handleComplete` |
|
||||
| `memo_list` | 列出所有未完成备忘 | `handleList` |
|
||||
|
||||
## 核心设计
|
||||
|
||||
### 数据持久化
|
||||
|
||||
备忘存储在 JSON 文件中,路径由核心配置 `core.daemon.data_dir` 决定:
|
||||
|
||||
```go
|
||||
// plugin.go:Start
|
||||
dataDirVal, _ := s.Settings().GetCore("core.daemon.data_dir")
|
||||
p.filePath = filepath.Join(fmt.Sprint(dataDirVal), "memos.json")
|
||||
p.load()
|
||||
```
|
||||
|
||||
`load()` 和 `save()` 实现 JSON 文件的读写,格式为 `{memos: [...], next_id: N}`。每次写操作后自动 `save()`,Stop 时也执行一次。
|
||||
|
||||
### PreAction 注入提醒
|
||||
|
||||
注册 `pre_action` 阶段钩子,在每次 LLM 调用前注入未完成备忘数量:
|
||||
|
||||
```go
|
||||
// plugin.go:stagePreAction
|
||||
n := p.pendingCount()
|
||||
if n == 0 { return nil }
|
||||
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
|
||||
"role": "system",
|
||||
"content": fmt.Sprintf("目前有%d条备忘未完成,调用%slist工具读取具体内容", n, p.tp),
|
||||
})
|
||||
```
|
||||
|
||||
这样每次 LLM 处理消息时都感知到未完成备忘,无需主动查询。
|
||||
|
||||
### 定时打断提醒
|
||||
|
||||
每 5 分钟检查未完成备忘,如果有则通过中断通道提醒:
|
||||
|
||||
```go
|
||||
// plugin.go:periodicCheck
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh: return
|
||||
case <-ticker.C:
|
||||
n := p.pendingCount()
|
||||
if n == 0 { continue }
|
||||
p.sdk.InjectInterruptText(p.name, p.name,
|
||||
fmt.Sprintf("注意,你还有%d条备忘未标记完成,请检查", n))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
中断消息会打断当前 LLM 处理,在下一轮工具循环前插入 `[打断消息]`,确保 agent 不会长期忽略未完成备忘。
|
||||
|
||||
### 工具返回值
|
||||
|
||||
所有工具返回 `{content: string}` 或 `{isError: true, content: string}` 格式,LLM 通过 content 字段获取结果文本。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `memo_create` 的 content 参数应包含事项的完整描述,方便后续回顾
|
||||
- `memo_complete` 只标记为 done,不删除数据,保留历史
|
||||
- 更早的暂停时自动 save,防丢数据
|
||||
273
third_party/homeagent-sdk/example/memo/plugin.go
vendored
273
third_party/homeagent-sdk/example/memo/plugin.go
vendored
@ -1,273 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Memo struct {
|
||||
ID int64 `json:"id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
mu sync.RWMutex
|
||||
memos []Memo
|
||||
nextID int64
|
||||
filePath string
|
||||
stopCh chan struct{}
|
||||
tp string
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
p.tp = p.name + "_"
|
||||
p.stopCh = make(chan struct{})
|
||||
|
||||
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||
if err != nil || dataDirVal == "" {
|
||||
dataDirVal = "."
|
||||
}
|
||||
p.filePath = filepath.Join(fmt.Sprint(dataDirVal), "memos.json")
|
||||
p.load()
|
||||
|
||||
s.RegisterTool(p.tp+"create", sdk.ToolDef{
|
||||
Name: p.tp + "create",
|
||||
Description: "创建一条备忘条目。备忘内容应包含具体事项的完整描述。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"content": map[string]interface{}{"type": "string", "description": "备忘内容"},
|
||||
},
|
||||
"required": []string{"content"},
|
||||
},
|
||||
}, p.handleCreate)
|
||||
|
||||
s.RegisterTool(p.tp+"complete", sdk.ToolDef{
|
||||
Name: p.tp + "complete",
|
||||
Description: "将指定ID的备忘标记为已完成。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "integer", "description": "备忘ID"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, p.handleComplete)
|
||||
|
||||
s.RegisterTool(p.tp+"list", sdk.ToolDef{
|
||||
Name: p.tp + "list",
|
||||
Description: "列出所有未完成的备忘条目,包含ID、内容和创建时间。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleList)
|
||||
|
||||
s.RegisterStage(sdk.StagePreAction, p.stagePreAction)
|
||||
|
||||
go p.periodicCheck()
|
||||
|
||||
log.Printf("[%s] started, path=%s", p.name, p.filePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
close(p.stopCh)
|
||||
p.save()
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) load() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
data, err := os.ReadFile(p.filePath)
|
||||
if err != nil {
|
||||
p.memos = nil
|
||||
p.nextID = 1
|
||||
return
|
||||
}
|
||||
var store struct {
|
||||
Memos []Memo `json:"memos"`
|
||||
NextID int64 `json:"next_id"`
|
||||
}
|
||||
if json.Unmarshal(data, &store) != nil {
|
||||
p.memos = nil
|
||||
p.nextID = 1
|
||||
return
|
||||
}
|
||||
p.memos = store.Memos
|
||||
p.nextID = store.NextID
|
||||
if p.memos == nil {
|
||||
p.memos = []Memo{}
|
||||
}
|
||||
if p.nextID < 1 {
|
||||
p.nextID = 1
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) save() {
|
||||
data, _ := json.MarshalIndent(map[string]interface{}{
|
||||
"memos": p.memos,
|
||||
"next_id": p.nextID,
|
||||
}, "", " ")
|
||||
os.WriteFile(p.filePath, data, 0644)
|
||||
}
|
||||
|
||||
func (p *Plugin) pendingCount() int {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
n := 0
|
||||
for _, m := range p.memos {
|
||||
if !m.Done {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (p *Plugin) pendingMemos() []Memo {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
var out []Memo
|
||||
for _, m := range p.memos {
|
||||
if !m.Done {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error {
|
||||
n := p.pendingCount()
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
ctx.Lock()
|
||||
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
|
||||
"role": "system",
|
||||
"content": fmt.Sprintf("目前有%d条备忘未完成,调用%slist工具读取具体内容", n, p.tp),
|
||||
})
|
||||
ctx.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) periodicCheck() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
n := p.pendingCount()
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
if p.sdk != nil {
|
||||
p.sdk.InjectInterruptText(p.name, p.name,
|
||||
fmt.Sprintf("注意,你还有%d条备忘未标记完成,请检查", n))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error) {
|
||||
content, _ := args["content"].(string)
|
||||
if content == "" {
|
||||
return errorResult("content is required"), nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
memo := Memo{
|
||||
ID: p.nextID,
|
||||
Content: content,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Done: false,
|
||||
}
|
||||
p.nextID++
|
||||
p.memos = append(p.memos, memo)
|
||||
p.mu.Unlock()
|
||||
p.save()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("备忘已创建 (ID: %d)", memo.ID),
|
||||
"id": memo.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error) {
|
||||
id, ok := args["id"].(float64)
|
||||
if !ok {
|
||||
return errorResult("id is required"), nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
found := false
|
||||
for i := range p.memos {
|
||||
if p.memos[i].ID == int64(id) && !p.memos[i].Done {
|
||||
p.memos[i].Done = true
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if !found {
|
||||
return errorResult(fmt.Sprintf("未找到未完成的备忘 ID: %d", int64(id))), nil
|
||||
}
|
||||
p.save()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("备忘 %d 已标记为完成", int64(id)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) {
|
||||
memos := p.pendingMemos()
|
||||
if len(memos) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": "暂无未完成的备忘",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i, m := range memos {
|
||||
t := time.Unix(m.CreatedAt, 0).Format("01-02 15:04")
|
||||
if i > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, t))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": sb.String(),
|
||||
"count": len(memos),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func errorResult(msg string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"isError": true,
|
||||
"content": msg,
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "memo",
|
||||
"version": "1.0.0",
|
||||
"description": "备忘插件,支持创建、完成、列出备忘条目,自动提醒",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["memo", "reminder", "todo"]
|
||||
}
|
||||
16
third_party/homeagent-sdk/example/qq/Makefile
vendored
16
third_party/homeagent-sdk/example/qq/Makefile
vendored
@ -1,16 +0,0 @@
|
||||
# Build QQ plugin for HomeAgent
|
||||
# Usage: make # build plugin.so
|
||||
# make clean # remove build artifacts
|
||||
|
||||
PLUGIN_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
SDK_ROOT := $(realpath $(PLUGIN_DIR)../..)
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: plugin.so
|
||||
|
||||
plugin.so:
|
||||
cd $(SDK_ROOT) && go build -buildmode=plugin -o $(PLUGIN_DIR)plugin.so $(PLUGIN_DIR)
|
||||
|
||||
clean:
|
||||
rm -f $(PLUGIN_DIR)plugin.so
|
||||
107
third_party/homeagent-sdk/example/qq/README.md
vendored
107
third_party/homeagent-sdk/example/qq/README.md
vendored
@ -1,107 +0,0 @@
|
||||
# qq 插件讲解
|
||||
|
||||
QQ 集成插件,通过 [NapCat](https://github.com/NapNeko/NapCat) OneBot 协议对接 QQ 机器人框架。
|
||||
|
||||
## 工具清单(15 个)
|
||||
|
||||
| 工具 | 功能 | 源码 |
|
||||
|------|------|------|
|
||||
| `qq_get_message` | 获取通过中断通知的消息正文 | `handleGetMessage` |
|
||||
| `qq_send_private_msg` | 发送私聊消息 | `handleSendPrivate` |
|
||||
| `qq_send_group_msg` | 发送群消息 | `handleSendGroup` |
|
||||
| `qq_send_file` | 发送文件/图片到私聊或群聊 | `handleSendFile` |
|
||||
| `qq_get_history` | 获取历史消息 | `handleGetHistory` |
|
||||
| `qq_get_groups` | 获取群列表 | `handleGetGroups` |
|
||||
| `qq_get_friends` | 获取好友列表 | `handleGetFriends` |
|
||||
| `qq_resolve_name` | 解析 QQ 号/群号为可读名称 | `handleResolveName` |
|
||||
| `qq_get_group_member_info` | 获取群成员信息 | `handleGetGroupMemberInfo` |
|
||||
| `qq_group_manage` | 群综合管理(踢人/禁言/改名等 18 个子命令) | `handleGroupManage` |
|
||||
| `qq_friend_action` | 好友管理(删除/拉黑/同意请求等) | `handleFriendAction` |
|
||||
| `qq_get_group_files` | 群文件操作(列表/搜索/下载) | `handleGetGroupFiles` |
|
||||
| `qq_upload_group_file` | 上传文件到群 | `handleUploadGroupFile` |
|
||||
| `qq_send_like` | 点赞/戳一戳 | `handleSendLike` |
|
||||
| `qq_ocr_image` | 图片文字识别 | `handleOcrImage` |
|
||||
|
||||
## 核心设计
|
||||
|
||||
### 消息接收:Webhook + 中断
|
||||
|
||||
插件启动一个 HTTP 服务器监听 NapCat 的回调 webhook,收到消息后先保存到内存循环缓冲区:
|
||||
|
||||
```go
|
||||
// plugin.go:handleWebhook
|
||||
p.mu.Lock()
|
||||
localID := p.nextID
|
||||
p.nextID++
|
||||
msg := &SavedMessage{LocalID: localID, UserID: evt.UserID, ...}
|
||||
p.messages = append(p.messages, msg)
|
||||
// 保留最近 maxMessages(2000) 条
|
||||
```
|
||||
|
||||
然后通过 `InjectInterruptText` 将摘要推送给 LLM,LLM 再主动调用 `qq_get_message` 获取完整内容:
|
||||
|
||||
```go
|
||||
// plugin.go:handleWebhook - interrupt text
|
||||
interrupt = fmt.Sprintf("来自%s的群聊消息,通过id%d使用%sget_message工具获取消息正文",
|
||||
nickname, localID, tp)
|
||||
p.sdk.InjectInterruptText(p.name, p.name, interrupt)
|
||||
```
|
||||
|
||||
这种"先通知摘要,按需拉取全文"的设计避免了大量消息涌入 LLM 上下文。
|
||||
|
||||
### 管理员优先级标记
|
||||
|
||||
配置 `admin` 后,管理员消息的中断文本会加 `【重要!老大消息】` 前缀:
|
||||
|
||||
```go
|
||||
if p.adminID > 0 && evt.UserID == p.adminID {
|
||||
interrupt = "【重要!老大消息】" + interrupt
|
||||
}
|
||||
```
|
||||
|
||||
### 消息过滤
|
||||
|
||||
`sensitiveFilter` 在发出消息前过滤敏感信息:
|
||||
|
||||
```go
|
||||
func (p *Plugin) sensitiveFilter(text string) string {
|
||||
text = reAPIKey.ReplaceAllString(text, "$1=***")
|
||||
text = reSKKey.ReplaceAllString(text, "sk-***")
|
||||
text = reInternalIP.ReplaceAllString(text, "[IP]")
|
||||
return text
|
||||
}
|
||||
```
|
||||
|
||||
保护 API Key、`sk-` 开头的密钥串、内网 IP 不被发到外部。
|
||||
|
||||
### NapCat HTTP 调用
|
||||
|
||||
所有 NapCat API 调用通过 `napcat()` 方法统一转发:
|
||||
|
||||
```go
|
||||
func (p *Plugin) napcat(action string, params map[string]interface{}) (interface{}, error) {
|
||||
url := fmt.Sprintf("%s/%s", p.napcatURL, action)
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(data))
|
||||
// 返回原始 JSON 字符串
|
||||
}
|
||||
```
|
||||
|
||||
NapCat API 地址通过配置 `plugin.qq.napcat_url` 设置。
|
||||
|
||||
### 消息存储
|
||||
|
||||
使用循环缓冲区(`[]*SavedMessage`),最多保留 2000 条。每条消息包含本地 ID、QQ 号、昵称、群号、群名、文本内容、时间戳。`qq_get_message` 通过 `local_id` 查找。
|
||||
|
||||
## 配置项
|
||||
|
||||
| Key | 默认值 | 说明 |
|
||||
|-----|--------|------|
|
||||
| `plugin.qq.listen` | `127.0.0.1:<port>` | Webhook 监听地址 |
|
||||
| `plugin.qq.napcat_url` | `http://127.0.0.1:<port>` | NapCat HTTP API 基地址 |
|
||||
| `plugin.qq.admin` | 空 | 管理员 QQ 号 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 依赖 NapCat 框架运行,需先启动 NapCat 并配置 webhook 指向本插件地址
|
||||
- 群管理中的破坏性操作(踢人、退群等)在描述中已写明需先请示管理员
|
||||
- `qq_get_message` 返回的消息对象包含完整字段,LLM 可据此判断消息类型和来源
|
||||
1051
third_party/homeagent-sdk/example/qq/plugin.go
vendored
1051
third_party/homeagent-sdk/example/qq/plugin.go
vendored
File diff suppressed because it is too large
Load Diff
@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "qq",
|
||||
"version": "1.0.0",
|
||||
"description": "QQ 集成插件,对接 NapCat OneBot 框架,支持消息收发、群管理、好友管理等",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["qq", "napcat", "onebot", "messaging"]
|
||||
}
|
||||
94
third_party/homeagent-sdk/example/web/README.md
vendored
94
third_party/homeagent-sdk/example/web/README.md
vendored
@ -1,94 +0,0 @@
|
||||
# web 插件讲解
|
||||
|
||||
网络工具插件,提供网页搜索和内容抓取功能。
|
||||
|
||||
## 工具清单
|
||||
|
||||
| 工具 | 功能 | 源码 |
|
||||
|------|------|------|
|
||||
| `web_search` | 通过 DuckDuckGo 搜索网页 | `handleSearch` |
|
||||
| `web_fetch` | 抓取指定 URL 的内容 | `handleFetch` |
|
||||
|
||||
## 核心设计
|
||||
|
||||
### 搜索实现
|
||||
|
||||
`web_search` 使用 DuckDuckGo 的 HTML 搜索页面(非 API,免注册):
|
||||
|
||||
```go
|
||||
// plugin.go:handleSearch
|
||||
url := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query))
|
||||
resp, err := p.httpClient().Get(url)
|
||||
```
|
||||
|
||||
解析策略:扫描 HTML 查找 `<a class="result__a"` 标签提取标题和链接,查找 `<a class="result__snippet"` 提取摘要。搜索最多返回 8 条结果。
|
||||
|
||||
由于 DuckDuckGo 在国内被墙,支持通过代理访问(见配置项)。
|
||||
|
||||
### 页面抓取
|
||||
|
||||
`web_fetch` 直接 HTTP GET 目标 URL 并以文本形式返回内容:
|
||||
|
||||
```go
|
||||
// plugin.go:handleFetch
|
||||
resp, err := p.httpClient().Get(rawURL)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
```
|
||||
|
||||
返回内容限制最大 100KB,超过截断并提示。
|
||||
|
||||
### SSRF 防护
|
||||
|
||||
`handleFetch` 检查 URL 是否为内网 IP,防止服务端请求伪造攻击:
|
||||
|
||||
```go
|
||||
// plugin.go:handleFetch — SSRF guard
|
||||
if !strings.HasPrefix(parsedURL, "http") {
|
||||
return errorResult("only http/https allowed"), nil
|
||||
}
|
||||
// 内网 IP 段检查(127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
|
||||
```
|
||||
|
||||
### HTTP 客户端
|
||||
|
||||
`newHTTPClient()` 创建带超时和可选代理的 HTTP 客户端:
|
||||
|
||||
```go
|
||||
// plugin.go:newHTTPClient
|
||||
func (p *Plugin) newHTTPClient() *http.Client {
|
||||
transport := &http.Transport{}
|
||||
if p.proxy != "" {
|
||||
proxyURL, _ := url.Parse(p.proxy)
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: time.Duration(p.timeout) * time.Second,
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
超时和代理均在配置中设置。
|
||||
|
||||
### 配置读取
|
||||
|
||||
```go
|
||||
// plugin.go:Start
|
||||
p.timeout = getSetting[float64](s.Settings(), "timeout", 30)
|
||||
p.proxy = getSetting[string](s.Settings(), "proxy", "")
|
||||
```
|
||||
|
||||
`getSetting` 是泛型辅助函数,支持类型安全的配置读取。
|
||||
|
||||
## 配置项
|
||||
|
||||
| Key | 默认值 | 说明 |
|
||||
|-----|--------|------|
|
||||
| `plugin.web.timeout` | `30` | HTTP 请求超时(秒) |
|
||||
| `plugin.web.proxy` | 空 | HTTP 代理地址,如 `http://<proxy-host>:<proxy-port>` |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- DuckDuckGo HTML 格式可能随网站更新变化,如果搜索结果解析失败需调整 `parseSearchResults` 中的 HTML 标记匹配
|
||||
- SSRF 防护默认阻止内网请求,如需访问内网资源需修改 `isInternalIP` 逻辑
|
||||
- 搜索结果依赖 DuckDuckGo 可用性,在国内使用建议配置代理
|
||||
567
third_party/homeagent-sdk/example/web/plugin.go
vendored
567
third_party/homeagent-sdk/example/web/plugin.go
vendored
@ -1,567 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
mu sync.RWMutex
|
||||
timeout int
|
||||
proxy string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newHTTPClient(timeout int, proxyURL string) *http.Client {
|
||||
transport := &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: time.Duration(timeout) * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: time.Duration(timeout) * time.Second,
|
||||
ResponseHeaderTimeout: time.Duration(timeout) * time.Second,
|
||||
}
|
||||
if proxyURL != "" {
|
||||
u, err := url.Parse(proxyURL)
|
||||
if err == nil {
|
||||
transport.Proxy = http.ProxyURL(u)
|
||||
}
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: time.Duration(timeout) * time.Second,
|
||||
Transport: transport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "plugin.web.timeout",
|
||||
Default: "30",
|
||||
Type: "int",
|
||||
DisplayName: "HTTP 超时(秒)",
|
||||
Description: "Web fetch 和搜索的 HTTP 请求超时时间",
|
||||
Category: "web",
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "plugin.web.proxy",
|
||||
Default: "",
|
||||
Type: "string",
|
||||
DisplayName: "HTTP 代理",
|
||||
Description: "HTTP 代理地址,如 http://<proxy-host>:<proxy-port>。为空则不使用代理",
|
||||
Category: "web",
|
||||
})
|
||||
|
||||
t := getSetting[float64](s.Settings(), "timeout", 30)
|
||||
p.timeout = int(t)
|
||||
if p.timeout < 5 {
|
||||
p.timeout = 5
|
||||
}
|
||||
if p.timeout > 120 {
|
||||
p.timeout = 120
|
||||
}
|
||||
|
||||
p.proxy = getSetting[string](s.Settings(), "proxy", "")
|
||||
p.client = newHTTPClient(p.timeout, p.proxy)
|
||||
|
||||
tp := p.name + "_"
|
||||
|
||||
s.RegisterTool(tp+"search", sdk.ToolDef{
|
||||
Name: tp + "search",
|
||||
Description: "Search the web for current information using DuckDuckGo. Returns formatted results with titles, URLs, and snippets.",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"query": map[string]interface{}{"type": "string", "description": "Search query"},
|
||||
"count": map[string]interface{}{"type": "integer", "description": "Number of results (1-20, default 5)"},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
}, p.handleSearch)
|
||||
|
||||
s.RegisterTool(tp+"fetch", sdk.ToolDef{
|
||||
Name: tp + "fetch",
|
||||
Description: "Fetch a URL and extract readable content as markdown-like text. Blocked on private/internal IPs.",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{"type": "string", "description": "HTTP/HTTPS URL to fetch"},
|
||||
"max_chars": map[string]interface{}{"type": "integer", "description": "Max characters to return (default 20000)"},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}, p.handleFetch)
|
||||
|
||||
proxyMsg := ""
|
||||
if p.proxy != "" {
|
||||
proxyMsg = fmt.Sprintf(", proxy: %s", p.proxy)
|
||||
}
|
||||
log.Printf("[%s] started, timeout: %ds%s", p.name, p.timeout, proxyMsg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
p.client.CloseIdleConnections()
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── SSRF 保护 ──────────────────────────────────────────────
|
||||
|
||||
var privateCIDRs []*net.IPNet
|
||||
|
||||
func init() {
|
||||
cidrs := []string{
|
||||
"127.0.0.0/8", // loopback
|
||||
"10.0.0.0/8", // private
|
||||
"172.16.0.0/12", // private
|
||||
"192.168.0.0/16", // private
|
||||
"100.64.0.0/10", // carrier-grade NAT
|
||||
"169.254.0.0/16", // link-local
|
||||
"::1/128", // IPv6 loopback
|
||||
"fc00::/7", // IPv6 unique local
|
||||
"fe80::/10", // IPv6 link-local
|
||||
}
|
||||
for _, c := range cidrs {
|
||||
_, n, err := net.ParseCIDR(c)
|
||||
if err == nil {
|
||||
privateCIDRs = append(privateCIDRs, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
for _, n := range privateCIDRs {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Plugin) ssrfCheck(rawURL string) error {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("only http/https URLs are allowed, got: %s", u.Scheme)
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
ips, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DNS lookup failed for %s: %w", host, err)
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil {
|
||||
continue
|
||||
}
|
||||
if isPrivateIP(parsed) {
|
||||
return fmt.Errorf("blocked request to private IP: %s (%s)", host, ip)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── DuckDuckGo 搜索 ────────────────────────────────────────
|
||||
|
||||
type ddgResult struct {
|
||||
Title string
|
||||
URL string
|
||||
Snippet string
|
||||
}
|
||||
|
||||
func (p *Plugin) ddgSearch(query string, count int) ([]ddgResult, error) {
|
||||
form := url.Values{"q": {query}}
|
||||
req, err := http.NewRequest("POST", "https://html.duckduckgo.com/html/", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
return parseDDGResults(string(body), count), nil
|
||||
}
|
||||
|
||||
func parseDDGResults(html string, count int) []ddgResult {
|
||||
var results []ddgResult
|
||||
|
||||
// Find all result blocks: <div class="result__body"> ... </div>
|
||||
bodyMarker := `result__body"`
|
||||
for i := 0; i < len(html); i++ {
|
||||
idx := strings.Index(html[i:], bodyMarker)
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
i += idx
|
||||
|
||||
// Find closing </div>
|
||||
closeIdx := findClosingTag(html, i, "</div>")
|
||||
if closeIdx < 0 {
|
||||
break
|
||||
}
|
||||
block := html[i : closeIdx+6]
|
||||
|
||||
r := parseSingleDDGResult(block)
|
||||
if r.URL != "" {
|
||||
results = append(results, r)
|
||||
if len(results) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
i = closeIdx + 6
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func findClosingTag(s string, start int, tag string) int {
|
||||
depth := 1
|
||||
pos := start
|
||||
for pos < len(s) {
|
||||
nextOpen := strings.Index(s[pos:], `<div`)
|
||||
nextClose := strings.Index(s[pos:], tag)
|
||||
if nextClose < 0 {
|
||||
return -1
|
||||
}
|
||||
if nextOpen >= 0 && nextOpen < nextClose {
|
||||
depth++
|
||||
pos += nextOpen + 4
|
||||
} else {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return pos + nextClose
|
||||
}
|
||||
pos += nextClose + len(tag)
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func parseSingleDDGResult(block string) ddgResult {
|
||||
var r ddgResult
|
||||
|
||||
// Extract URL and title from: <a rel="nofollow" class="result__a" href="URL">TITLE</a>
|
||||
urlMarker := `class="result__a" href="`
|
||||
uIdx := strings.Index(block, urlMarker)
|
||||
if uIdx >= 0 {
|
||||
start := uIdx + len(urlMarker)
|
||||
end := strings.Index(block[start:], `"`)
|
||||
if end >= 0 {
|
||||
r.URL = block[start : start+end]
|
||||
}
|
||||
|
||||
aStart := strings.Index(block[start+end:], `>`)
|
||||
if aStart >= 0 {
|
||||
titleStart := start + end + aStart + 1
|
||||
aEnd := strings.Index(block[titleStart:], `</a>`)
|
||||
if aEnd >= 0 {
|
||||
r.Title = stripTags(block[titleStart : titleStart+aEnd])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract snippet: <a class="result__snippet" ...> ... </a>
|
||||
snippetMarkers := []string{
|
||||
`<a class="result__snippet`,
|
||||
`<div class="result__snippet`,
|
||||
}
|
||||
for _, marker := range snippetMarkers {
|
||||
sIdx := strings.Index(block, marker)
|
||||
if sIdx >= 0 {
|
||||
aStart := strings.Index(block[sIdx:], `>`)
|
||||
if aStart >= 0 {
|
||||
snipStart := sIdx + aStart + 1
|
||||
snipEnd := strings.Index(block[snipStart:], `</a>`)
|
||||
if snipEnd < 0 {
|
||||
snipEnd = strings.Index(block[snipStart:], `</div>`)
|
||||
}
|
||||
if snipEnd >= 0 {
|
||||
r.Snippet = stripTags(block[snipStart : snipStart+snipEnd])
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Web Fetch ──────────────────────────────────────────────
|
||||
|
||||
func (p *Plugin) handleFetch(args map[string]interface{}) (interface{}, error) {
|
||||
rawURL, _ := args["url"].(string)
|
||||
if rawURL == "" {
|
||||
return errorResult("url is required"), nil
|
||||
}
|
||||
|
||||
maxChars := 20000
|
||||
if v, ok := args["max_chars"].(float64); ok && v > 0 {
|
||||
maxChars = int(v)
|
||||
}
|
||||
if maxChars > 500000 {
|
||||
maxChars = 500000
|
||||
}
|
||||
|
||||
if err := p.ssrfCheck(rawURL); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", rawURL, nil)
|
||||
if err != nil {
|
||||
return errorResult("invalid URL: " + err.Error()), nil
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return errorResult("fetch failed: " + err.Error()), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
||||
return errorResult(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status)), nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxChars)+50000))
|
||||
if err != nil {
|
||||
return errorResult("read error: " + err.Error()), nil
|
||||
}
|
||||
|
||||
rawText := string(body)
|
||||
|
||||
// Extract readable content based on content type
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
var extracted string
|
||||
if strings.Contains(ct, "text/html") {
|
||||
extracted = htmlToText(rawText)
|
||||
} else if strings.Contains(ct, "application/json") {
|
||||
// Pretty-print JSON
|
||||
var v interface{}
|
||||
if json.Unmarshal(body, &v) == nil {
|
||||
if pretty, err := json.MarshalIndent(v, "", " "); err == nil {
|
||||
extracted = string(pretty)
|
||||
} else {
|
||||
extracted = rawText
|
||||
}
|
||||
} else {
|
||||
extracted = rawText
|
||||
}
|
||||
} else {
|
||||
extracted = rawText
|
||||
}
|
||||
|
||||
// Clean up and truncate
|
||||
extracted = strings.TrimSpace(extracted)
|
||||
if len(extracted) > maxChars {
|
||||
extracted = extracted[:maxChars] + "\n\n[Content truncated]"
|
||||
}
|
||||
|
||||
if extracted == "" {
|
||||
extracted = "(empty content)"
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": extracted,
|
||||
"details": map[string]interface{}{
|
||||
"url": rawURL,
|
||||
"status": resp.StatusCode,
|
||||
"content_type": ct,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── HTML → 文本 ──────────────────────────────────────────────
|
||||
|
||||
func htmlToText(html string) string {
|
||||
// Remove scripts
|
||||
for {
|
||||
start := strings.Index(strings.ToLower(html), "<script")
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(html[start:], "</script>")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
html = html[:start] + html[start+end+9:]
|
||||
}
|
||||
|
||||
// Remove styles
|
||||
for {
|
||||
start := strings.Index(strings.ToLower(html), "<style")
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(html[start:], "</style>")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
html = html[:start] + html[start+end+8:]
|
||||
}
|
||||
|
||||
// Replace block-level tags with newlines
|
||||
for _, tag := range []string{"</p>", "</div>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>", "</li>", "</tr>", "</blockquote>", "<br", "</pre>"} {
|
||||
html = strings.ReplaceAll(html, tag, "\n")
|
||||
}
|
||||
|
||||
// Remove remaining tags
|
||||
html = stripTags(html)
|
||||
|
||||
// Decode common entities
|
||||
html = strings.ReplaceAll(html, "&", "&")
|
||||
html = strings.ReplaceAll(html, "<", "<")
|
||||
html = strings.ReplaceAll(html, ">", ">")
|
||||
html = strings.ReplaceAll(html, """, "\"")
|
||||
html = strings.ReplaceAll(html, "'", "'")
|
||||
html = strings.ReplaceAll(html, " ", " ")
|
||||
|
||||
// Collapse whitespace
|
||||
lines := strings.Split(html, "\n")
|
||||
var cleaned []string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Collapse internal whitespace
|
||||
in := []rune(line)
|
||||
var out []rune
|
||||
space := false
|
||||
for _, r := range in {
|
||||
if unicode.IsSpace(r) {
|
||||
if !space {
|
||||
out = append(out, ' ')
|
||||
space = true
|
||||
}
|
||||
} else {
|
||||
out = append(out, r)
|
||||
space = false
|
||||
}
|
||||
}
|
||||
cleaned = append(cleaned, string(out))
|
||||
}
|
||||
|
||||
return strings.Join(cleaned, "\n")
|
||||
}
|
||||
|
||||
func stripTags(s string) string {
|
||||
var out strings.Builder
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
if r == '<' {
|
||||
inTag = true
|
||||
continue
|
||||
}
|
||||
if r == '>' {
|
||||
inTag = false
|
||||
continue
|
||||
}
|
||||
if !inTag {
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// ── Search 处理 ──────────────────────────────────────────────
|
||||
|
||||
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
|
||||
query, _ := args["query"].(string)
|
||||
if query == "" {
|
||||
return errorResult("query is required"), nil
|
||||
}
|
||||
|
||||
count := 5
|
||||
if v, ok := args["count"].(float64); ok && v > 0 {
|
||||
count = int(v)
|
||||
}
|
||||
if count < 1 {
|
||||
count = 1
|
||||
}
|
||||
if count > 20 {
|
||||
count = 20
|
||||
}
|
||||
|
||||
results, err := p.ddgSearch(query, count)
|
||||
if err != nil {
|
||||
return errorResult("search failed: " + err.Error()), nil
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": "No results found.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Search results for %q:\n\n", query))
|
||||
for i, r := range results {
|
||||
sb.WriteString(fmt.Sprintf("%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": strings.TrimSpace(sb.String()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── 工具函数 ──────────────────────────────────────────────
|
||||
|
||||
func errorResult(msg string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"isError": true,
|
||||
"content": msg,
|
||||
}
|
||||
}
|
||||
|
||||
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
|
||||
v, err := s.Get(key)
|
||||
if err != nil || v == nil {
|
||||
return def
|
||||
}
|
||||
val, ok := v.(T)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
return val
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "1.0.0",
|
||||
"description": "网络工具插件,提供网页搜索和内容抓取功能",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["web", "search", "http"]
|
||||
}
|
||||
3
third_party/homeagent-sdk/go.mod
vendored
3
third_party/homeagent-sdk/go.mod
vendored
@ -1,3 +0,0 @@
|
||||
module gitcode.com/JianFeeeee/homeagent-sdk
|
||||
|
||||
go 1.21
|
||||
@ -1,61 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# HomeAgent 插件打包工具
|
||||
# 将插件目录打包为 .hmap 分发包
|
||||
# 用法: ./packager.sh <plugin-dir> [输出路径]
|
||||
# 示例: ./packager.sh ./plugins/myplugin ./dist/myplugin-1.0.0.hmap
|
||||
|
||||
PLUGIN_DIR="${1:-}"
|
||||
OUTPUT="${2:-}"
|
||||
|
||||
if [ -z "$PLUGIN_DIR" ]; then
|
||||
echo "用法: $0 <plugin-dir> [输出路径]"
|
||||
echo "示例: $0 ./plugins/myplugin ./dist/myplugin-1.0.0.hmap"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PLUGIN_DIR="$(realpath "$PLUGIN_DIR")"
|
||||
PLUGIN_NAME="$(basename "$PLUGIN_DIR")"
|
||||
|
||||
# 验证
|
||||
if [ ! -f "$PLUGIN_DIR/plugin.json" ]; then
|
||||
echo "错误: 不存在 plugin.json: $PLUGIN_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$(python3 -c "import json; print(json.load(open('$PLUGIN_DIR/plugin.json'))['version'])" 2>/dev/null || echo "unknown")"
|
||||
|
||||
if [ -z "$OUTPUT" ]; then
|
||||
mkdir -p dist
|
||||
OUTPUT="$(realpath "dist/${PLUGIN_NAME}-${VERSION}.hmap")"
|
||||
fi
|
||||
|
||||
echo "🔨 打包插件: $PLUGIN_NAME v$VERSION"
|
||||
echo " 源目录: $PLUGIN_DIR"
|
||||
echo " 输出: $OUTPUT"
|
||||
|
||||
# 检查入口文件
|
||||
ENTRY="$(python3 -c "import json; print(json.load(open('$PLUGIN_DIR/plugin.json'))['entry'])" 2>/dev/null || true)"
|
||||
if [ -n "$ENTRY" ] && [ ! -f "$PLUGIN_DIR/$ENTRY" ]; then
|
||||
echo "⚠️ 入口文件不存在: $ENTRY"
|
||||
echo " 请先编译: cd $PLUGIN_DIR && make"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查已编译的 .so
|
||||
if [ -f "$PLUGIN_DIR/plugin.so" ] && [ "$(stat -c %Y "$PLUGIN_DIR/plugin.so" 2>/dev/null)" -lt "$(stat -c %Y "$PLUGIN_DIR/plugin.go" 2>/dev/null)" ]; then
|
||||
echo "⚠️ plugin.so 比 plugin.go 旧,建议重新编译"
|
||||
echo " 请执行: cd $PLUGIN_DIR && make"
|
||||
fi
|
||||
|
||||
cd "$PLUGIN_DIR"
|
||||
zip -r "$OUTPUT" . -x "*.git*" "Makefile" ".gitignore" "*.go" "go.mod" "go.sum" "*.test" "testdata/*" "_*" 2>&1 | tail -3
|
||||
|
||||
echo ""
|
||||
echo "✅ 打包完成: $OUTPUT"
|
||||
echo " 大小: $(ls -lh "$OUTPUT" | awk '{print $5}')"
|
||||
echo ""
|
||||
echo "安装方式:"
|
||||
echo " 1. WebUI 插件管理 → 上传安装"
|
||||
echo " 2. AI 对话: 使用 plugin_install 工具并上传 URL"
|
||||
@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# HomeAgent 插件脚手架生成工具
|
||||
# 用法: ./scaffold.sh <plugin-name> [输出目录]
|
||||
# 示例: ./scaffold.sh myplugin ./plugins/myplugin
|
||||
|
||||
NAME="${1:-}"
|
||||
OUTDIR="${2:-./plugins/$NAME}"
|
||||
|
||||
if [ -z "$NAME" ]; then
|
||||
echo "用法: $0 <plugin-name> [输出目录]"
|
||||
echo "示例: $0 myplugin ./plugins/myplugin"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "$OUTDIR" ]; then
|
||||
echo "错误: 目标目录已存在: $OUTDIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
TEMPLATE_DIR="$SCRIPT_DIR/templates"
|
||||
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
# 替换模板中的占位符
|
||||
sed -e "s/{{.Name}}/$NAME/g" \
|
||||
-e "s/{{.Version}}/0.1.0/g" \
|
||||
-e "s/{{.Description}}//g" \
|
||||
-e "s/{{.Author}}//g" \
|
||||
"$TEMPLATE_DIR/plugin.json.tmpl" > "$OUTDIR/plugin.json"
|
||||
|
||||
cp "$TEMPLATE_DIR/plugin.go.tmpl" "$OUTDIR/plugin.go"
|
||||
cp "$TEMPLATE_DIR/Makefile.tmpl" "$OUTDIR/Makefile"
|
||||
cp "$TEMPLATE_DIR/gitignore.tmpl" "$OUTDIR/.gitignore"
|
||||
|
||||
echo "✅ 插件脚手架已生成: $OUTDIR"
|
||||
echo ""
|
||||
echo "下一步:"
|
||||
echo " 1. 编辑 $OUTDIR/plugin.go 实现业务逻辑"
|
||||
echo " 2. 编辑 $OUTDIR/plugin.json 完善元信息"
|
||||
echo " 3. cd $OUTDIR && make # 编译 plugin.so"
|
||||
echo " 4. make package # 打包为 .hmap 分发包"
|
||||
echo " 5. 通过 WebUI 或 plugin_install 工具安装"
|
||||
@ -1,17 +0,0 @@
|
||||
# Build external Go plugin for HomeAgent
|
||||
# Usage: make # build plugin.so
|
||||
# make clean # remove plugin.so
|
||||
|
||||
PLUGIN_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
SDK_ROOT := $(realpath $(PLUGIN_DIR)../..)
|
||||
PLUGIN_NAME := $(notdir $(realpath $(PLUGIN_DIR)))
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: plugin.so
|
||||
|
||||
plugin.so:
|
||||
cd $(SDK_ROOT) && go build -buildmode=plugin -o $(PLUGIN_DIR)plugin.so $(PLUGIN_DIR)
|
||||
|
||||
clean:
|
||||
rm -f $(PLUGIN_DIR)plugin.so
|
||||
@ -1,2 +0,0 @@
|
||||
plugin.so
|
||||
*.hmap
|
||||
@ -1,54 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{
|
||||
name: name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
|
||||
tp := p.name + "_"
|
||||
|
||||
s.RegisterTool(tp+"example", sdk.ToolDef{
|
||||
Name: tp + "example",
|
||||
Description: "示例工具 - 请替换为实现",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"input": map[string]interface{}{"type": "string", "description": "输入参数"},
|
||||
},
|
||||
"required": []string{"input"},
|
||||
},
|
||||
}, p.handleExample)
|
||||
|
||||
log.Printf("[%s] plugin started", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleExample(args map[string]interface{}) (interface{}, error) {
|
||||
input, _ := args["input"].(string)
|
||||
return map[string]interface{}{
|
||||
"echo": input,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func main() {}
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"name": "{{.Name}}",
|
||||
"version": "{{.Version}}",
|
||||
"description": "{{.Description}}",
|
||||
"author": "{{.Author}}",
|
||||
"license": "MIT",
|
||||
"entry": "plugin.so",
|
||||
"min_version": "1.0.0",
|
||||
"tags": ["{{.Name}}"]
|
||||
}
|
||||
@ -1,237 +0,0 @@
|
||||
// Package plugintest provides a test harness for external HomeAgent plugins.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// import "gitcode.com/JianFeeeee/homeagent-sdk/hack/plugin-dev/testharness"
|
||||
//
|
||||
// func TestMyPlugin(t *testing.T) {
|
||||
// h := testharness.New(t, "./path/to/plugin.so")
|
||||
// defer h.Close()
|
||||
//
|
||||
// result, err := h.CallTool("myplugin_my_tool", map[string]interface{}{
|
||||
// "input": "hello",
|
||||
// })
|
||||
// if err != nil {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// t.Logf("result: %v", result)
|
||||
// }
|
||||
package plugintest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"plugin"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// Harness is a test harness for loading and testing external Go plugins.
|
||||
type Harness struct {
|
||||
t *testing.T
|
||||
plug sdk.Plugin
|
||||
sdk *sdk.PluginSDK
|
||||
mu sync.Mutex
|
||||
tools map[string]sdk.ToolHandler
|
||||
stages map[sdk.Stage][]sdk.StageHandler
|
||||
setting *mockSettings
|
||||
}
|
||||
|
||||
// New loads a plugin .so and starts it with a mock SDK.
|
||||
// soPath is the path to the compiled plugin.so file.
|
||||
func New(t *testing.T, soPath string) *Harness {
|
||||
t.Helper()
|
||||
|
||||
absPath, err := filepath.Abs(soPath)
|
||||
if err != nil {
|
||||
t.Fatalf("abs path: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(absPath); err != nil {
|
||||
t.Fatalf("plugin not found: %s", absPath)
|
||||
}
|
||||
|
||||
pkg, err := plugin.Open(absPath)
|
||||
if err != nil {
|
||||
t.Fatalf("plugin.Open: %v", err)
|
||||
}
|
||||
|
||||
sym, err := pkg.Lookup("NewPlugin")
|
||||
if err != nil {
|
||||
t.Fatalf("NewPlugin symbol not found: %v", err)
|
||||
}
|
||||
newPlugin, ok := sym.(func(name string, config map[string]interface{}) (sdk.Plugin, error))
|
||||
if !ok {
|
||||
t.Fatal("NewPlugin has wrong signature")
|
||||
}
|
||||
|
||||
name := filepath.Base(filepath.Dir(absPath))
|
||||
plug, err := newPlugin(name, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPlugin: %v", err)
|
||||
}
|
||||
|
||||
h := &Harness{
|
||||
t: t,
|
||||
plug: plug,
|
||||
tools: make(map[string]sdk.ToolHandler),
|
||||
stages: make(map[sdk.Stage][]sdk.StageHandler),
|
||||
setting: &mockSettings{
|
||||
data: make(map[string]interface{}),
|
||||
defs: make(map[string]sdk.ConfigDef),
|
||||
},
|
||||
}
|
||||
|
||||
h.sdk = sdk.New(name, h.setting, h.regTool, h.regStage, nil)
|
||||
|
||||
if err := plug.Start(h.sdk); err != nil {
|
||||
t.Fatalf("plugin.Start: %v", err)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Harness) regTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.tools[name] = handler
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Harness) regStage(stage sdk.Stage, handler sdk.StageHandler) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.stages[stage] = append(h.stages[stage], handler)
|
||||
}
|
||||
|
||||
// Plug returns the loaded plugin instance.
|
||||
func (h *Harness) Plug() sdk.Plugin { return h.plug }
|
||||
|
||||
// SDK returns the mock PluginSDK.
|
||||
func (h *Harness) SDK() *sdk.PluginSDK { return h.sdk }
|
||||
|
||||
// Settings returns the mock settings store for test assertions.
|
||||
func (h *Harness) Settings() *mockSettings { return h.setting }
|
||||
|
||||
// ToolNames returns all registered tool names.
|
||||
func (h *Harness) ToolNames() []string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
names := make([]string, 0, len(h.tools))
|
||||
for n := range h.tools {
|
||||
names = append(names, n)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// CallTool invokes a registered tool handler with the given arguments.
|
||||
func (h *Harness) CallTool(name string, args map[string]interface{}) (interface{}, error) {
|
||||
h.mu.Lock()
|
||||
handler, ok := h.tools[name]
|
||||
h.mu.Unlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tool %q not registered", name)
|
||||
}
|
||||
return handler(args)
|
||||
}
|
||||
|
||||
// Close stops the plugin.
|
||||
func (h *Harness) Close() {
|
||||
if err := h.plug.Stop(); err != nil {
|
||||
h.t.Logf("plugin.Stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertToolRegistered fails if the tool is not registered.
|
||||
func (h *Harness) AssertToolRegistered(name string) {
|
||||
h.t.Helper()
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if _, ok := h.tools[name]; !ok {
|
||||
h.t.Fatalf("expected tool %q to be registered", name)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertToolResult checks that calling a tool returns the expected JSON output.
|
||||
func (h *Harness) AssertToolResult(name string, args map[string]interface{}, expected map[string]interface{}) {
|
||||
h.t.Helper()
|
||||
got, err := h.CallTool(name, args)
|
||||
if err != nil {
|
||||
h.t.Fatalf("tool %q: %v", name, err)
|
||||
}
|
||||
gotJSON, _ := json.Marshal(got)
|
||||
expJSON, _ := json.Marshal(expected)
|
||||
if string(gotJSON) != string(expJSON) {
|
||||
h.t.Fatalf("tool %q:\ngot: %s\nexp: %s", name, gotJSON, expJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// mockSettings implements sdk.SettingsAPI for testing.
|
||||
type mockSettings struct {
|
||||
mu sync.Mutex
|
||||
data map[string]interface{}
|
||||
defs map[string]sdk.ConfigDef
|
||||
}
|
||||
|
||||
func (m *mockSettings) Get(key string) (interface{}, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
v, ok := m.data[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("key %q not found", key)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (m *mockSettings) Set(key string, value interface{}) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.data[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSettings) List(prefix string) ([]string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var keys []string
|
||||
for k := range m.data {
|
||||
if prefix == "" || strings.HasPrefix(k, prefix) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (m *mockSettings) RegisterDef(def sdk.ConfigDef) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.defs[def.Key] = def
|
||||
}
|
||||
|
||||
func (m *mockSettings) Defs(prefix string) []*sdk.ConfigDef {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var defs []*sdk.ConfigDef
|
||||
for _, d := range m.defs {
|
||||
if prefix == "" || strings.HasPrefix(d.Key, prefix) {
|
||||
defs = append(defs, &d)
|
||||
}
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
func (m *mockSettings) Dump() map[string]interface{} {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cp := make(map[string]interface{})
|
||||
for k, v := range m.data {
|
||||
cp[k] = v
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
func (m *mockSettings) Plugins() []string { return nil }
|
||||
553
third_party/homeagent-sdk/sdk/API.md
vendored
553
third_party/homeagent-sdk/sdk/API.md
vendored
@ -1,553 +0,0 @@
|
||||
# PluginSDK API 参考
|
||||
|
||||
HomeAgent 内核通过 `*sdk.PluginSDK` 向插件暴露所有能力。插件在 `Start(sdk *PluginSDK)` 中接收此对象。
|
||||
|
||||
## Plugin 接口
|
||||
|
||||
所有插件必须实现此接口:
|
||||
|
||||
```go
|
||||
type Plugin interface {
|
||||
Name() string // 返回插件名称,与注册名一致
|
||||
Start(sdk *PluginSDK) error // 初始化:注册工具、阶段钩子等
|
||||
Stop() error // 清理:关连接、停 goroutine
|
||||
}
|
||||
```
|
||||
|
||||
### 入口函数
|
||||
|
||||
`.so` 动态插件必须导出的工厂函数:
|
||||
|
||||
```go
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error)
|
||||
```
|
||||
|
||||
- `name`: 插件目录名,也是配置命名空间
|
||||
- `config`: 插件依赖注入(预留,当前为空)
|
||||
- 返回 `Plugin` 实例
|
||||
|
||||
## PluginSDK 总览
|
||||
|
||||
```
|
||||
PluginSDK
|
||||
├── 工具注册
|
||||
│ └── RegisterTool(name, def, handler) error
|
||||
├── 阶段钩子
|
||||
│ └── RegisterStage(stage, handler)
|
||||
├── 输入投递
|
||||
│ ├── InjectInterruptText(source, channel, text)
|
||||
│ ├── InjectText(source, channel, text)
|
||||
│ └── InjectTextNoMemory(source, channel, text)
|
||||
├── 配置管理 (SettingsAPI)
|
||||
│ ├── Get(key) / Set(key, value)
|
||||
│ ├── GetCore(key) / SetCore(key, value)
|
||||
│ ├── GetPlugin(plugin, key) / SetPlugin(plugin, key, value)
|
||||
│ ├── List(prefix) / ListCore(prefix)
|
||||
│ ├── RegisterDef(def) / Defs(prefix)
|
||||
│ ├── Dump() / Plugins()
|
||||
├── 记忆访问
|
||||
│ ├── Memory() -> MemoryAPI
|
||||
│ ├── TextMemory() -> TextMemoryAPI
|
||||
│ ├── DocMemory() -> DocMemoryAPI
|
||||
├── 知识库
|
||||
│ └── Knowledge() -> KnowledgeAPI
|
||||
└── LLM 管理
|
||||
└── LLM() -> LLMAPI
|
||||
```
|
||||
|
||||
## 工具注册
|
||||
|
||||
### RegisterTool
|
||||
|
||||
```go
|
||||
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error
|
||||
```
|
||||
|
||||
向 LLM 注册一个可调用的工具。`name` 必须全局唯一,建议用插件名前缀避免冲突。
|
||||
|
||||
### ToolDef
|
||||
|
||||
```go
|
||||
type ToolDef struct {
|
||||
Name string `json:"name"` // 工具名
|
||||
Plugin string `json:"plugin,omitempty"` // 工具所属插件
|
||||
Description string `json:"description"` // LLM 看到的描述
|
||||
Parameters map[string]interface{} `json:"parameters"` // JSON Schema
|
||||
}
|
||||
```
|
||||
|
||||
`Parameters` 使用 JSON Schema 格式描述参数。示例:
|
||||
|
||||
```go
|
||||
sdk.ToolDef{
|
||||
Name: "weather_query",
|
||||
Description: "查询指定城市的天气",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"city": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "城市名称",
|
||||
},
|
||||
},
|
||||
"required": []string{"city"},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### ToolHandler
|
||||
|
||||
```go
|
||||
type ToolHandler func(args map[string]interface{}) (interface{}, error)
|
||||
```
|
||||
|
||||
- `args`: LLM 传入的参数,key 为参数名,value 为对应值
|
||||
- 返回值: `interface{}` 会被 JSON 序列化后返回给 LLM
|
||||
- 返回 `error` 时 LLM 会收到错误信息并可能重试
|
||||
|
||||
```go
|
||||
func(args map[string]interface{}) (interface{}, error) {
|
||||
city, _ := args["city"].(string)
|
||||
return map[string]interface{}{
|
||||
"temp": 25, "weather": "晴",
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
错误结果推荐返回含 `isError` 字段的 map,而非返回 error(避免 LLM 重试):
|
||||
|
||||
```go
|
||||
return map[string]interface{}{
|
||||
"isError": true,
|
||||
"content": "错误描述",
|
||||
}, nil
|
||||
```
|
||||
|
||||
### ToolCall / ToolResult
|
||||
|
||||
阶段钩子中访问的 LLM 工具调用和结果结构:
|
||||
|
||||
```go
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"` // 调用 ID
|
||||
Name string `json:"name"` // 工具名
|
||||
Plugin string `json:"plugin,omitempty"` // 工具所属插件
|
||||
Arguments map[string]interface{} `json:"arguments"` // 参数
|
||||
}
|
||||
|
||||
type ToolResult struct {
|
||||
CallID string `json:"call_id"` // 对应 ToolCall.ID
|
||||
Name string `json:"name"` // 工具名
|
||||
Plugin string `json:"plugin,omitempty"` // 工具所属插件
|
||||
Success bool `json:"success"`
|
||||
Result interface{} `json:"result"` // handler 返回值
|
||||
}
|
||||
```
|
||||
|
||||
## 阶段钩子
|
||||
|
||||
### RegisterStage
|
||||
|
||||
```go
|
||||
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler)
|
||||
```
|
||||
|
||||
在消息处理管道的指定阶段注入逻辑。同一阶段可注册多个 handler,按注册顺序执行。
|
||||
|
||||
### Stage
|
||||
|
||||
```go
|
||||
type Stage string
|
||||
|
||||
const (
|
||||
StageOnInput Stage = "on_input" // 消息到达,零处理
|
||||
StagePreAction Stage = "pre_action" // LLM 调用前,上下文就绪
|
||||
StagePostAction Stage = "post_action" // LLM 返回后
|
||||
StageBeforeToolcall Stage = "before_toolcall" // 单个工具执行前
|
||||
StageAfterToolcall Stage = "after_toolcall" // 单个工具执行后
|
||||
StageBeforeOutput Stage = "before_output" // 最终输出前
|
||||
StageAfterOutput Stage = "after_output" // 输出发送后
|
||||
)
|
||||
```
|
||||
|
||||
### StageHandler
|
||||
|
||||
```go
|
||||
type StageHandler func(ctx *StageContext) error
|
||||
```
|
||||
|
||||
### RegisterStageOwnTools
|
||||
|
||||
```go
|
||||
func (s *PluginSDK) RegisterStageOwnTools(stage Stage, handler StageHandler)
|
||||
```
|
||||
|
||||
仅在 `before_toolcall` / `after_toolcall` 阶段监听**当前插件自己的工具调用**。
|
||||
|
||||
适用场景:
|
||||
- QQ 插件只审核 `qq_send_*` 自己的发送工具
|
||||
- Web 插件只改写 `web_fetch` 自己的结果
|
||||
- Files 插件只审计 `files_write` 自己的写操作
|
||||
|
||||
其他阶段会退化成普通 `RegisterStage`。
|
||||
|
||||
### StageContext
|
||||
|
||||
```go
|
||||
type StageContext struct {
|
||||
mu sync.RWMutex
|
||||
RawMessage string // 原始输入文本(on_input 可改写)
|
||||
UserID string // 用户标识
|
||||
GroupID string // 群组标识
|
||||
ContextMsgs []map[string]interface{} // 上下文消息列表(pre_action 可注入)
|
||||
LLMText string // LLM 返回文本(post_action 可改写)
|
||||
ReasoningContent string // LLM 推理过程文本
|
||||
TokenUsage map[string]int // Token 用量
|
||||
ToolCalls []ToolCall // LLM 请求的工具调用
|
||||
ToolResults []ToolResult // 工具执行结果
|
||||
FinalText string // 最终输出文本(before_output 可改写)
|
||||
Response *string // 设置后短路管道
|
||||
Phase Stage // 当前阶段
|
||||
Memory []MemItem // 召回的记忆
|
||||
NoMemory bool // 是否跳过记忆
|
||||
Extra map[string]interface{} // 扩展字段
|
||||
}
|
||||
```
|
||||
|
||||
**阶段权限矩阵**:
|
||||
|
||||
| 字段 | on_input | pre_action | post_action | before_toolcall | after_toolcall | before_output | after_output |
|
||||
|------|----------|------------|-------------|-----------------|----------------|---------------|--------------|
|
||||
| RawMessage | 读写 | - | - | - | - | - | - |
|
||||
| ContextMsgs | - | 读写 | - | - | - | - | - |
|
||||
| LLMText | - | - | 读写 | - | - | - | - |
|
||||
| ToolCalls | - | - | 读写 | 读写 | - | - | - |
|
||||
| ToolCall.deny | - | - | - | 读写 | - | - | - |
|
||||
| ToolResults | - | - | - | - | 读写 | - | - |
|
||||
| FinalText | - | - | - | - | - | 读写 | 只读 |
|
||||
| Response | 读写 | 读写 | 读写 | 读写 | 读写 | 读写 | - |
|
||||
|
||||
**短路规则**:任意阶段设置 `ctx.Response` 后,管道立即跳到 `after_output`。
|
||||
|
||||
### 阶段示例
|
||||
|
||||
```go
|
||||
// on_input: 拦截黑名单用户
|
||||
s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||||
if ctx.UserID == "blocked_user" {
|
||||
resp := "已被限制使用"
|
||||
ctx.Response = &resp
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// pre_action: 注入额外上下文
|
||||
s.RegisterStage(sdk.StagePreAction, func(ctx *sdk.StageContext) error {
|
||||
ctx.Lock()
|
||||
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
|
||||
"role": "system",
|
||||
"content": "当前时间: " + time.Now().Format("15:04"),
|
||||
})
|
||||
ctx.Unlock()
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### MemItem
|
||||
|
||||
```go
|
||||
type MemItem struct {
|
||||
Role string `json:"role"` // system / user / assistant
|
||||
Content string `json:"content"` // 内容
|
||||
Score float64 `json:"score"` // TF-IDF 相关性评分
|
||||
}
|
||||
```
|
||||
|
||||
## 输入投递
|
||||
|
||||
插件可以向 Agent 投递输入消息。
|
||||
|
||||
```go
|
||||
// 中断投递:可打断当前 LLM 处理
|
||||
// - source: 来源标识(插件名)
|
||||
// - channel: 通道名
|
||||
// - text: 消息文本
|
||||
func (s *PluginSDK) InjectInterruptText(source, channel, text string)
|
||||
|
||||
// 普通投递:排队等待处理
|
||||
func (s *PluginSDK) InjectText(source, channel, text string)
|
||||
|
||||
// 投递但不触发记忆记录
|
||||
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string)
|
||||
```
|
||||
|
||||
**两种投递方式的区别**:
|
||||
|
||||
| | InjectText | InjectInterruptText |
|
||||
|---|---|---|
|
||||
| 处理顺序 | 排队 | 优先 |
|
||||
| 打断 LLM | 否 | 是(取消当前请求) |
|
||||
| 适用场景 | 普通消息 | 定时器、重要通知 |
|
||||
|
||||
## 配置管理
|
||||
|
||||
### SettingsAPI
|
||||
|
||||
插件通过 `s.Settings()` 获取 `SettingsAPI`。每个插件拥有独立的 `config_<name>` SQLite 表。
|
||||
|
||||
```go
|
||||
type SettingsAPI interface {
|
||||
// 自身配置(config_<name> 表)
|
||||
Get(key string) (interface{}, error)
|
||||
Set(key string, value interface{}) error
|
||||
List(prefix string) ([]string, error)
|
||||
|
||||
// 核心配置(config 表)
|
||||
GetCore(key string) (interface{}, error)
|
||||
SetCore(key string, value interface{}) error
|
||||
ListCore(prefix string) ([]string, error)
|
||||
|
||||
// 其他插件配置(config_<plugin> 表)
|
||||
GetPlugin(plugin, key string) (interface{}, error)
|
||||
SetPlugin(plugin, key string, value interface{}) error
|
||||
ListPlugin(plugin, prefix string) ([]string, error)
|
||||
|
||||
// 配置定义(WebUI 显示用)
|
||||
RegisterDef(def ConfigDef)
|
||||
Defs(prefix string) []*ConfigDef
|
||||
|
||||
// 全局
|
||||
Dump() map[string]interface{}
|
||||
Plugins() []string
|
||||
}
|
||||
```
|
||||
|
||||
### ConfigDef
|
||||
|
||||
```go
|
||||
type ConfigDef struct {
|
||||
Key string `json:"key"` // 配置键名
|
||||
Default interface{} `json:"default,omitempty"` // 默认值
|
||||
Type string `json:"type"` // 类型:string / number / boolean
|
||||
DisplayName string `json:"display_name"` // WebUI 显示名称
|
||||
Description string `json:"description,omitempty"` // 说明
|
||||
Category string `json:"category,omitempty"` // 分组
|
||||
Options []string `json:"options,omitempty"` // 选项列表(下拉框)
|
||||
Min float64 `json:"min,omitempty"`
|
||||
Max float64 `json:"max,omitempty"`
|
||||
Step float64 `json:"step,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"` // 敏感信息(输入框掩码)
|
||||
}
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```go
|
||||
// 插件启动时注册配置定义
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "provider_key",
|
||||
Type: "string",
|
||||
DisplayName: "API Key",
|
||||
Description: "第三方服务 API 密钥",
|
||||
Secret: true,
|
||||
Required: true,
|
||||
})
|
||||
|
||||
// 运行时读取配置
|
||||
apiKey, err := s.Settings().Get("provider_key")
|
||||
|
||||
// 读取核心配置
|
||||
dataDir, _ := s.Settings().GetCore("core.daemon.data_dir")
|
||||
|
||||
// 读取其他插件配置
|
||||
qqNapcat, _ := s.Settings().GetPlugin("qq", "napcat_url")
|
||||
```
|
||||
|
||||
## 记忆访问
|
||||
|
||||
### MemoryAPI(图记忆)
|
||||
|
||||
存储在 SQLite 图数据库中,entities + relations 表。
|
||||
|
||||
```go
|
||||
type MemoryAPI interface {
|
||||
// 召回:query 为关键词列表,depth 为 BFS 遍历深度
|
||||
Recall(query []string, depth int) ([]Entity, []Relation, error)
|
||||
|
||||
// 写入三元组
|
||||
Commit(triples []Triple) error
|
||||
|
||||
// 统计:返回实体数、关系数等
|
||||
Introspect() (map[string]interface{}, error)
|
||||
|
||||
// 合并实体(同义消歧)
|
||||
MergeEntities(source, target string) (int, error)
|
||||
|
||||
// 清理:mode 为 "soft"(标记删除)或 "hard"(物理删除)
|
||||
Purge(criteria map[string]string, mode string) (int, error)
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
type Entity struct {
|
||||
Name string `json:"name"` // 实体名称
|
||||
Type string `json:"type"` // 类型: Person / Location / Concept ...
|
||||
MentionCount int `json:"mention_count"` // 提及次数
|
||||
}
|
||||
|
||||
type Relation struct {
|
||||
SourceName string `json:"source_name"` // 主体
|
||||
TargetName string `json:"target_name"` // 客体
|
||||
RelationType string `json:"relation_type"` // 关系类型: likes / works_at / friend_of ...
|
||||
}
|
||||
|
||||
type Triple struct {
|
||||
Subject string `json:"subject"` // 主体实体名
|
||||
Relation string `json:"relation"` // 关系
|
||||
Object string `json:"object"` // 客体实体名
|
||||
}
|
||||
```
|
||||
|
||||
### TextMemoryAPI(文本记忆)
|
||||
|
||||
按时间顺序的原始对话日志,JSONL 文件轮转存储。
|
||||
|
||||
```go
|
||||
type TextMemoryAPI interface {
|
||||
Append(evt TextEvent) error
|
||||
}
|
||||
|
||||
type TextEvent struct {
|
||||
Role string `json:"role"` // system / user / assistant
|
||||
Content string `json:"content"` // 内容
|
||||
Timestamp int64 `json:"timestamp"` // 时间戳
|
||||
Channel string `json:"channel,omitempty"` // 来源通道
|
||||
}
|
||||
```
|
||||
|
||||
### DocMemoryAPI(文档记忆)
|
||||
|
||||
临时记忆层,JSON 文件 + TF-IDF 向量索引,消费即删。
|
||||
|
||||
```go
|
||||
type DocMemoryAPI interface {
|
||||
// 搜索文档,返回 topK 条
|
||||
Query(text string, topK int) []*Doc
|
||||
|
||||
// 插入文档
|
||||
Insert(doc *Doc) error
|
||||
|
||||
// 删除文档
|
||||
Remove(id string)
|
||||
|
||||
// 统计
|
||||
Stats() map[string]interface{}
|
||||
}
|
||||
|
||||
type Doc struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
## 知识库
|
||||
|
||||
### KnowledgeAPI
|
||||
|
||||
文件系统 + TF-IDF 向量检索,独立于记忆系统的索引。
|
||||
|
||||
```go
|
||||
type KnowledgeAPI interface {
|
||||
// 搜索知识条目,返回 topK 匹配
|
||||
Search(query string, topK int) ([]*Knowledge, error)
|
||||
|
||||
// 添加知识
|
||||
Add(name, content string) error
|
||||
|
||||
// 列出所有知识条目名
|
||||
List() ([]string, error)
|
||||
}
|
||||
|
||||
type Knowledge struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
```
|
||||
|
||||
## LLM 管理
|
||||
|
||||
### LLMAPI
|
||||
|
||||
管理 LLM 提供者源。
|
||||
|
||||
```go
|
||||
type LLMAPI interface {
|
||||
// 列出所有已注册的 LLM 源
|
||||
ListSources() []string
|
||||
|
||||
// 切换默认 LLM 源
|
||||
SetSource(name string) error
|
||||
|
||||
// 当前使用的 LLM 源
|
||||
CurrentSource() string
|
||||
}
|
||||
```
|
||||
|
||||
## IOInjector
|
||||
|
||||
SDK 内部的输入投递接口,`PluginSDK.InjectInterruptText` / `InjectText` / `InjectTextNoMemory` 底层调用。
|
||||
|
||||
```go
|
||||
type IOInjector interface {
|
||||
InjectInterruptText(source, channel, text string)
|
||||
InjectText(source, channel, text string)
|
||||
InjectTextNoMemory(source, channel, text string)
|
||||
}
|
||||
```
|
||||
|
||||
内核在插件启动后调用 `sdk.SetIOInjector()` 注入此接口的实际实现。
|
||||
|
||||
## SDK 辅助类型
|
||||
|
||||
```go
|
||||
// 工具注册回调类型
|
||||
type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error
|
||||
|
||||
// 阶段注册回调类型
|
||||
type StageRegistrar func(stage Stage, handler StageHandler)
|
||||
|
||||
// API 注册回调类型
|
||||
type APIRegistrar func(name string) error
|
||||
```
|
||||
|
||||
## 插件生命周期
|
||||
|
||||
```
|
||||
内核启动
|
||||
│
|
||||
├── plugin.Registry.Load(dir)
|
||||
│ ├── 扫描 plugins/ 目录
|
||||
│ ├── 匹配已注册工厂或动态加载 .so
|
||||
│ ├── 调用 NewPlugin(name, config)
|
||||
│ └── 调用 plugin.Start(sdk) ← 插件注册工具/阶段/事件
|
||||
│
|
||||
├── 正常运行
|
||||
│ ├── LLM 调用 → 路由到注册的工具
|
||||
│ └── 消息处理 → 触发注册的阶段钩子
|
||||
│
|
||||
└── 内核关闭
|
||||
└── plugin.Stop() ← 插件清理资源
|
||||
```
|
||||
|
||||
### 内置插件 vs 动态插件
|
||||
|
||||
| | 内置插件 | 动态 .so 插件 |
|
||||
|---|---|---|
|
||||
| 注册方式 | `init()` → `RegisterFactory` | `plugin.Open` 动态加载 |
|
||||
| 存放位置 | `internal/plugins/` | `<dataDir>/plugins/<name>/` |
|
||||
| 编译 | 编译进内核 | 独立 `go build -buildmode=plugin` |
|
||||
| SDK 导入 | `gitcode.com/JianFeeeee/HomeAgent/internal/sdk` | `gitcode.com/JianFeeeee/homeagent-sdk/sdk` |
|
||||
| 热加载 | 需重新编译 | 可运行时加载/卸载 |
|
||||
14
third_party/homeagent-sdk/sdk/knowledge.go
vendored
14
third_party/homeagent-sdk/sdk/knowledge.go
vendored
@ -1,14 +0,0 @@
|
||||
package sdk
|
||||
|
||||
// KnowledgeAPI provides access to the knowledge store.
|
||||
type KnowledgeAPI interface {
|
||||
Search(query string, topK int) ([]*Knowledge, error)
|
||||
Add(name, content string) error
|
||||
List() ([]string, error)
|
||||
}
|
||||
|
||||
// Knowledge represents a knowledge entry.
|
||||
type Knowledge struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
8
third_party/homeagent-sdk/sdk/llm.go
vendored
8
third_party/homeagent-sdk/sdk/llm.go
vendored
@ -1,8 +0,0 @@
|
||||
package sdk
|
||||
|
||||
// LLMAPI provides access to the LLM provider manager.
|
||||
type LLMAPI interface {
|
||||
ListSources() []string
|
||||
SetSource(name string) error
|
||||
CurrentSource() string
|
||||
}
|
||||
60
third_party/homeagent-sdk/sdk/memory.go
vendored
60
third_party/homeagent-sdk/sdk/memory.go
vendored
@ -1,60 +0,0 @@
|
||||
package sdk
|
||||
|
||||
// MemoryAPI provides access to the graph memory (entity-relation store).
|
||||
type MemoryAPI interface {
|
||||
Recall(query []string, depth int) ([]Entity, []Relation, error)
|
||||
Commit(triples []Triple) error
|
||||
Introspect() (map[string]interface{}, error)
|
||||
MergeEntities(source, target string) (int, error)
|
||||
Purge(criteria map[string]string, mode string) (int, error)
|
||||
}
|
||||
|
||||
// Entity represents a named entity in the knowledge graph.
|
||||
type Entity struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
MentionCount int `json:"mention_count"`
|
||||
}
|
||||
|
||||
// Relation represents a relationship between two entities.
|
||||
type Relation struct {
|
||||
SourceName string `json:"source_name"`
|
||||
TargetName string `json:"target_name"`
|
||||
RelationType string `json:"relation_type"`
|
||||
}
|
||||
|
||||
// Triple represents a subject-relation-object triple for the knowledge graph.
|
||||
type Triple struct {
|
||||
Subject string `json:"subject"`
|
||||
Relation string `json:"relation"`
|
||||
Object string `json:"object"`
|
||||
}
|
||||
|
||||
// TextMemoryAPI provides access to chronological text event storage.
|
||||
type TextMemoryAPI interface {
|
||||
Append(evt TextEvent) error
|
||||
}
|
||||
|
||||
// TextEvent represents a single text memory event.
|
||||
type TextEvent struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// DocMemoryAPI provides access to the document vector store.
|
||||
type DocMemoryAPI interface {
|
||||
Query(text string, topK int) []*Doc
|
||||
Insert(doc *Doc) error
|
||||
Remove(id string)
|
||||
Stats() map[string]interface{}
|
||||
}
|
||||
|
||||
// Doc represents a document in the document store.
|
||||
type Doc struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
}
|
||||
237
third_party/homeagent-sdk/sdk/plugin.go
vendored
237
third_party/homeagent-sdk/sdk/plugin.go
vendored
@ -1,237 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import "sync"
|
||||
|
||||
// Plugin is the interface every plugin must implement.
|
||||
type Plugin interface {
|
||||
Name() string
|
||||
Start(sdk *PluginSDK) error
|
||||
Stop() error
|
||||
}
|
||||
|
||||
// ToolHandler is a function that handles a tool call.
|
||||
type ToolHandler func(args map[string]interface{}) (interface{}, error)
|
||||
|
||||
// StageHandler is a function that handles a pipeline stage event.
|
||||
type StageHandler func(ctx *StageContext) error
|
||||
|
||||
// Stage represents a point in the message processing pipeline.
|
||||
type Stage string
|
||||
|
||||
const (
|
||||
StageOnInput Stage = "on_input"
|
||||
StagePreAction Stage = "pre_action"
|
||||
StagePostAction Stage = "post_action"
|
||||
StageBeforeToolcall Stage = "before_toolcall"
|
||||
StageAfterToolcall Stage = "after_toolcall"
|
||||
StageBeforeOutput Stage = "before_output"
|
||||
StageAfterOutput Stage = "after_output"
|
||||
)
|
||||
|
||||
// StageContext provides context for stage handlers.
|
||||
type StageContext struct {
|
||||
mu sync.RWMutex
|
||||
RawMessage string
|
||||
UserID string
|
||||
GroupID string
|
||||
ContextMsgs []map[string]interface{}
|
||||
LLMText string
|
||||
ReasoningContent string
|
||||
TokenUsage map[string]int
|
||||
ToolCalls []ToolCall
|
||||
ToolResults []ToolResult
|
||||
FinalText string
|
||||
Response *string
|
||||
Phase Stage
|
||||
Memory []MemItem
|
||||
NoMemory bool
|
||||
Extra map[string]interface{}
|
||||
}
|
||||
|
||||
func (c *StageContext) RLock() { c.mu.RLock() }
|
||||
func (c *StageContext) RUnlock() { c.mu.RUnlock() }
|
||||
func (c *StageContext) Lock() { c.mu.Lock() }
|
||||
func (c *StageContext) Unlock() { c.mu.Unlock() }
|
||||
func (c *StageContext) IsResponded() bool { c.mu.RLock(); defer c.mu.RUnlock(); return c.Response != nil }
|
||||
|
||||
// MemItem represents a memory item in stage context.
|
||||
type MemItem struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// ToolCall represents a model's request to call a tool.
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
}
|
||||
|
||||
// ToolResult represents the result of a tool call.
|
||||
type ToolResult struct {
|
||||
CallID string `json:"call_id"`
|
||||
Name string `json:"name"`
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Result interface{} `json:"result"`
|
||||
}
|
||||
|
||||
// ToolDef describes a tool that the plugin exposes.
|
||||
type ToolDef struct {
|
||||
Name string `json:"name"`
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
}
|
||||
|
||||
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
||||
type IOInjector interface {
|
||||
InjectInterruptText(source, channel, text string)
|
||||
InjectText(source, channel, text string)
|
||||
InjectTextNoMemory(source, channel, text string)
|
||||
}
|
||||
|
||||
// ToolRegistrar registers a tool dynamically.
|
||||
type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error
|
||||
|
||||
// StageRegistrar registers a stage handler.
|
||||
type StageRegistrar func(stage Stage, handler StageHandler)
|
||||
|
||||
// APIRegistrar registers a plugin API for external access.
|
||||
type APIRegistrar func(name string) error
|
||||
|
||||
// PluginSDK is the main API surface provided to plugins at runtime.
|
||||
// It wraps tool registration, settings, memory, knowledge, LLM, and IO injection.
|
||||
type PluginSDK struct {
|
||||
name string
|
||||
regTool ToolRegistrar
|
||||
regStage StageRegistrar
|
||||
regAPI APIRegistrar
|
||||
io IOInjector
|
||||
mem MemoryAPI
|
||||
textMem TextMemoryAPI
|
||||
docMem DocMemoryAPI
|
||||
know KnowledgeAPI
|
||||
llm LLMAPI
|
||||
sett SettingsAPI
|
||||
}
|
||||
|
||||
// New creates a PluginSDK with the given dependencies.
|
||||
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar) *PluginSDK {
|
||||
return &PluginSDK{
|
||||
name: name,
|
||||
sett: sett,
|
||||
regTool: regTool,
|
||||
regStage: regStage,
|
||||
regAPI: regAPI,
|
||||
}
|
||||
}
|
||||
|
||||
// PluginName returns the name of the plugin.
|
||||
func (s *PluginSDK) PluginName() string { return s.name }
|
||||
|
||||
// Settings returns the settings API for reading/writing plugin configuration.
|
||||
func (s *PluginSDK) Settings() SettingsAPI { return s.sett }
|
||||
|
||||
// Memory returns the graph memory API (may be nil if not available).
|
||||
func (s *PluginSDK) Memory() MemoryAPI { return s.mem }
|
||||
|
||||
// TextMemory returns the text memory API (may be nil if not available).
|
||||
func (s *PluginSDK) TextMemory() TextMemoryAPI { return s.textMem }
|
||||
|
||||
// DocMemory returns the document memory API (may be nil if not available).
|
||||
func (s *PluginSDK) DocMemory() DocMemoryAPI { return s.docMem }
|
||||
|
||||
// Knowledge returns the knowledge store API (may be nil if not available).
|
||||
func (s *PluginSDK) Knowledge() KnowledgeAPI { return s.know }
|
||||
|
||||
// LLM returns the LLM provider API (may be nil if not available).
|
||||
func (s *PluginSDK) LLM() LLMAPI { return s.llm }
|
||||
|
||||
// RegisterTool registers a tool that the LLM can call.
|
||||
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error {
|
||||
if def.Plugin == "" {
|
||||
def.Plugin = s.name
|
||||
}
|
||||
if s.regTool != nil {
|
||||
return s.regTool(name, def, handler)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterStage registers a handler for a pipeline stage.
|
||||
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler) {
|
||||
if s.regStage != nil {
|
||||
s.regStage(stage, handler)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterStageOwnTools only listens to this plugin's own tool calls/results in
|
||||
// before_toolcall / after_toolcall stages. Other stages degrade to RegisterStage.
|
||||
func (s *PluginSDK) RegisterStageOwnTools(stage Stage, handler StageHandler) {
|
||||
if s.regStage == nil {
|
||||
return
|
||||
}
|
||||
if stage != StageBeforeToolcall && stage != StageAfterToolcall {
|
||||
s.regStage(stage, handler)
|
||||
return
|
||||
}
|
||||
s.regStage(stage, func(ctx *StageContext) error {
|
||||
ctx.RLock()
|
||||
match := false
|
||||
switch stage {
|
||||
case StageBeforeToolcall:
|
||||
match = len(ctx.ToolCalls) > 0 && ctx.ToolCalls[0].Plugin == s.name
|
||||
case StageAfterToolcall:
|
||||
match = len(ctx.ToolResults) > 0 && ctx.ToolResults[0].Plugin == s.name
|
||||
}
|
||||
ctx.RUnlock()
|
||||
if !match {
|
||||
return nil
|
||||
}
|
||||
return handler(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterPluginAPI registers this plugin's API for access by other plugins.
|
||||
func (s *PluginSDK) RegisterPluginAPI(name string) error {
|
||||
if s.regAPI != nil {
|
||||
return s.regAPI(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetIOInjector sets the IO injector (called by the core at startup).
|
||||
func (s *PluginSDK) SetIOInjector(io IOInjector) { s.io = io }
|
||||
|
||||
// SetMemoryAPI sets the memory API (called by the core at startup).
|
||||
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) { s.mem = mem }
|
||||
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI) { s.textMem = tm }
|
||||
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI) { s.docMem = dm }
|
||||
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI) { s.know = kn }
|
||||
func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm }
|
||||
|
||||
// ---- IO Convenience Methods ----
|
||||
|
||||
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
|
||||
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
|
||||
if s.io != nil {
|
||||
s.io.InjectInterruptText(source, channel, text)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectText injects a text message into the agent pipeline.
|
||||
func (s *PluginSDK) InjectText(source, channel, text string) {
|
||||
if s.io != nil {
|
||||
s.io.InjectText(source, channel, text)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectTextNoMemory injects a text message without generating memory.
|
||||
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
|
||||
if s.io != nil {
|
||||
s.io.InjectTextNoMemory(source, channel, text)
|
||||
}
|
||||
}
|
||||
58
third_party/homeagent-sdk/sdk/settings.go
vendored
58
third_party/homeagent-sdk/sdk/settings.go
vendored
@ -1,58 +0,0 @@
|
||||
package sdk
|
||||
|
||||
type SettingsAPI interface {
|
||||
// Get reads the plugin's own config value (config_<name> table).
|
||||
Get(key string) (interface{}, error)
|
||||
|
||||
// Set writes a config value to the plugin's own config table.
|
||||
Set(key string, value interface{}) error
|
||||
|
||||
// List returns all keys matching the given prefix.
|
||||
List(prefix string) ([]string, error)
|
||||
|
||||
// GetCore reads the core config table.
|
||||
GetCore(key string) (interface{}, error)
|
||||
|
||||
// SetCore writes to the core config table.
|
||||
SetCore(key string, value interface{}) error
|
||||
|
||||
// ListCore lists core config keys matching the prefix.
|
||||
ListCore(prefix string) ([]string, error)
|
||||
|
||||
// GetPlugin reads another plugin's config table.
|
||||
GetPlugin(plugin, key string) (interface{}, error)
|
||||
|
||||
// SetPlugin writes to another plugin's config table.
|
||||
SetPlugin(plugin, key string, value interface{}) error
|
||||
|
||||
// ListPlugin lists another plugin's config keys matching the prefix.
|
||||
ListPlugin(plugin, prefix string) ([]string, error)
|
||||
|
||||
// RegisterDef registers a config definition for UI display.
|
||||
RegisterDef(def ConfigDef)
|
||||
|
||||
// Defs returns config definitions matching the prefix.
|
||||
Defs(prefix string) []*ConfigDef
|
||||
|
||||
// Dump returns all config values.
|
||||
Dump() map[string]interface{}
|
||||
|
||||
// Plugins returns a list of all plugin config namespaces.
|
||||
Plugins() []string
|
||||
}
|
||||
|
||||
// ConfigDef describes a configuration field for the WebUI.
|
||||
type ConfigDef struct {
|
||||
Key string `json:"key"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Min float64 `json:"min,omitempty"`
|
||||
Max float64 `json:"max,omitempty"`
|
||||
Step float64 `json:"step,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# HomeAgent 插件打包工具
|
||||
# 将插件目录打包为 .hmap 分发包
|
||||
# 用法: ./packager.sh <plugin-dir> [输出路径]
|
||||
# 示例: ./packager.sh ./plugins/myplugin ./dist/myplugin-1.0.0.hmap
|
||||
|
||||
PLUGIN_DIR="${1:-}"
|
||||
OUTPUT="${2:-}"
|
||||
|
||||
if [ -z "$PLUGIN_DIR" ]; then
|
||||
echo "用法: $0 <plugin-dir> [输出路径]"
|
||||
echo "示例: $0 ./plugins/myplugin ./dist/myplugin-1.0.0.hmap"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PLUGIN_DIR="$(realpath "$PLUGIN_DIR")"
|
||||
PLUGIN_NAME="$(basename "$PLUGIN_DIR")"
|
||||
|
||||
# 验证
|
||||
if [ ! -f "$PLUGIN_DIR/plugin.json" ]; then
|
||||
echo "错误: 不存在 plugin.json: $PLUGIN_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$(python3 -c "import json; print(json.load(open('$PLUGIN_DIR/plugin.json'))['version'])" 2>/dev/null || echo "unknown")"
|
||||
|
||||
if [ -z "$OUTPUT" ]; then
|
||||
mkdir -p dist
|
||||
OUTPUT="$(realpath "dist/${PLUGIN_NAME}-${VERSION}.hmap")"
|
||||
fi
|
||||
|
||||
echo "🔨 打包插件: $PLUGIN_NAME v$VERSION"
|
||||
echo " 源目录: $PLUGIN_DIR"
|
||||
echo " 输出: $OUTPUT"
|
||||
|
||||
# 检查入口文件
|
||||
ENTRY="$(python3 -c "import json; print(json.load(open('$PLUGIN_DIR/plugin.json'))['entry'])" 2>/dev/null || true)"
|
||||
if [ -n "$ENTRY" ] && [ ! -f "$PLUGIN_DIR/$ENTRY" ]; then
|
||||
echo "⚠️ 入口文件不存在: $ENTRY"
|
||||
echo " 请先编译: cd $PLUGIN_DIR && make"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查已编译的 .so
|
||||
if [ -f "$PLUGIN_DIR/plugin.so" ] && [ "$(stat -c %Y "$PLUGIN_DIR/plugin.so" 2>/dev/null)" -lt "$(stat -c %Y "$PLUGIN_DIR/plugin.go" 2>/dev/null)" ]; then
|
||||
echo "⚠️ plugin.so 比 plugin.go 旧,建议重新编译"
|
||||
echo " 请执行: cd $PLUGIN_DIR && make"
|
||||
fi
|
||||
|
||||
cd "$PLUGIN_DIR"
|
||||
zip -r "$OUTPUT" . -x "*.git*" "Makefile" ".gitignore" "*.go" "go.mod" "go.sum" "*.test" "testdata/*" "_*" 2>&1 | tail -3
|
||||
|
||||
echo ""
|
||||
echo "✅ 打包完成: $OUTPUT"
|
||||
echo " 大小: $(ls -lh "$OUTPUT" | awk '{print $5}')"
|
||||
echo ""
|
||||
echo "安装方式:"
|
||||
echo " 1. WebUI 插件管理 → 上传安装"
|
||||
echo " 2. AI 对话: 使用 plugin_install 工具并上传 URL"
|
||||
@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# HomeAgent 插件脚手架生成工具
|
||||
# 用法: ./scaffold.sh <plugin-name> [输出目录]
|
||||
# 示例: ./scaffold.sh myplugin ./plugins/myplugin
|
||||
|
||||
NAME="${1:-}"
|
||||
OUTDIR="${2:-./plugins/$NAME}"
|
||||
|
||||
if [ -z "$NAME" ]; then
|
||||
echo "用法: $0 <plugin-name> [输出目录]"
|
||||
echo "示例: $0 myplugin ./plugins/myplugin"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "$OUTDIR" ]; then
|
||||
echo "错误: 目标目录已存在: $OUTDIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
TEMPLATE_DIR="$SCRIPT_DIR/templates"
|
||||
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
# 替换模板中的占位符
|
||||
sed -e "s/{{.Name}}/$NAME/g" \
|
||||
-e "s/{{.Version}}/0.1.0/g" \
|
||||
-e "s/{{.Description}}//g" \
|
||||
-e "s/{{.Author}}//g" \
|
||||
"$TEMPLATE_DIR/plugin.json.tmpl" > "$OUTDIR/plugin.json"
|
||||
|
||||
cp "$TEMPLATE_DIR/plugin.go.tmpl" "$OUTDIR/plugin.go"
|
||||
cp "$TEMPLATE_DIR/Makefile.tmpl" "$OUTDIR/Makefile"
|
||||
cp "$TEMPLATE_DIR/gitignore.tmpl" "$OUTDIR/.gitignore"
|
||||
|
||||
echo "✅ 插件脚手架已生成: $OUTDIR"
|
||||
echo ""
|
||||
echo "下一步:"
|
||||
echo " 1. 编辑 $OUTDIR/plugin.go 实现业务逻辑"
|
||||
echo " 2. 编辑 $OUTDIR/plugin.json 完善元信息"
|
||||
echo " 3. cd $OUTDIR && make # 编译 plugin.so"
|
||||
echo " 4. make package # 打包为 .hmap 分发包"
|
||||
echo " 5. 通过 WebUI 或 plugin_install 工具安装"
|
||||
@ -1,21 +0,0 @@
|
||||
# Build external Go plugin for HomeAgent
|
||||
# Usage: make # build plugin.so
|
||||
# make clean # remove plugin.so
|
||||
# make package # build + create .hmap package
|
||||
|
||||
PLUGIN_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
PROJECT_ROOT := $(realpath $(PLUGIN_DIR)../..)
|
||||
PLUGIN_NAME := $(notdir $(realpath $(PLUGIN_DIR)))
|
||||
|
||||
.PHONY: all clean package
|
||||
|
||||
all: plugin.so
|
||||
|
||||
plugin.so:
|
||||
cd $(PROJECT_ROOT) && go build -buildmode=plugin -o $(PLUGIN_DIR)plugin.so $(PLUGIN_DIR)
|
||||
|
||||
clean:
|
||||
rm -f $(PLUGIN_DIR)plugin.so $(PLUGIN_DIR)$(PLUGIN_NAME).hmap
|
||||
|
||||
package: plugin.so
|
||||
cd $(PLUGIN_DIR) && zip -r $(PLUGIN_NAME).hmap plugin.json plugin.so
|
||||
@ -1,2 +0,0 @@
|
||||
plugin.so
|
||||
*.hmap
|
||||
@ -1,56 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{
|
||||
name: name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
|
||||
tp := p.name + "_"
|
||||
|
||||
s.RegisterTool(tp+"example", sdk.ToolDef{
|
||||
Name: tp + "example",
|
||||
Description: "示例工具 - 请替换为实现",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"input": map[string]interface{}{"type": "string", "description": "输入参数"},
|
||||
},
|
||||
"required": []string{"input"},
|
||||
},
|
||||
}, p.handleExample)
|
||||
|
||||
log.Printf("[%s] plugin started", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleExample(args map[string]interface{}) (interface{}, error) {
|
||||
input, _ := args["input"].(string)
|
||||
return map[string]interface{}{
|
||||
"echo": input,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func main() {}
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"name": "{{.Name}}",
|
||||
"version": "{{.Version}}",
|
||||
"description": "{{.Description}}",
|
||||
"author": "{{.Author}}",
|
||||
"license": "MIT",
|
||||
"entry": "plugin.so",
|
||||
"min_version": "1.0.0",
|
||||
"tags": ["{{.Name}}"]
|
||||
}
|
||||
@ -1,253 +0,0 @@
|
||||
// Package plugintest provides a test harness for external HomeAgent plugins.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// import "gitcode.com/JianFeeeee/HomeAgent/tools/plugin-dev/testharness"
|
||||
//
|
||||
// func TestMyPlugin(t *testing.T) {
|
||||
// h := testharness.New(t, "./path/to/plugin.so")
|
||||
// defer h.Close()
|
||||
//
|
||||
// result, err := h.CallTool("myplugin_my_tool", map[string]interface{}{
|
||||
// "input": "hello",
|
||||
// })
|
||||
// if err != nil {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// t.Logf("result: %v", result)
|
||||
// }
|
||||
package plugintest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"plugin"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// Harness is a test harness for loading and testing external Go plugins.
|
||||
type Harness struct {
|
||||
t *testing.T
|
||||
plug sdk.Plugin
|
||||
sdk *sdk.PluginSDK
|
||||
mu sync.Mutex
|
||||
tools map[string]sdk.ToolHandler
|
||||
stages map[sdk.Stage][]sdk.StageHandler
|
||||
setting *mockSettings
|
||||
}
|
||||
|
||||
// New loads a plugin .so and starts it with a mock SDK.
|
||||
// soPath is the path to the compiled plugin.so file.
|
||||
func New(t *testing.T, soPath string) *Harness {
|
||||
t.Helper()
|
||||
|
||||
absPath, err := filepath.Abs(soPath)
|
||||
if err != nil {
|
||||
t.Fatalf("abs path: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(absPath); err != nil {
|
||||
t.Fatalf("plugin not found: %s", absPath)
|
||||
}
|
||||
|
||||
pkg, err := plugin.Open(absPath)
|
||||
if err != nil {
|
||||
t.Fatalf("plugin.Open: %v", err)
|
||||
}
|
||||
|
||||
sym, err := pkg.Lookup("NewPlugin")
|
||||
if err != nil {
|
||||
t.Fatalf("NewPlugin symbol not found: %v", err)
|
||||
}
|
||||
newPlugin, ok := sym.(func(name string, config map[string]interface{}) (sdk.Plugin, error))
|
||||
if !ok {
|
||||
t.Fatal("NewPlugin has wrong signature")
|
||||
}
|
||||
|
||||
name := filepath.Base(filepath.Dir(absPath))
|
||||
plug, err := newPlugin(name, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPlugin: %v", err)
|
||||
}
|
||||
|
||||
h := &Harness{
|
||||
t: t,
|
||||
plug: plug,
|
||||
tools: make(map[string]sdk.ToolHandler),
|
||||
stages: make(map[sdk.Stage][]sdk.StageHandler),
|
||||
setting: &mockSettings{
|
||||
data: make(map[string]interface{}),
|
||||
defs: make(map[string]sdk.ConfigDef),
|
||||
},
|
||||
}
|
||||
|
||||
mockSDK := sdk.New(
|
||||
name,
|
||||
nil, nil, nil, nil, nil, nil, nil,
|
||||
h.setting,
|
||||
h.regTool,
|
||||
h.regStage,
|
||||
nil,
|
||||
)
|
||||
h.sdk = mockSDK
|
||||
|
||||
if err := plug.Start(mockSDK); err != nil {
|
||||
t.Fatalf("plugin.Start: %v", err)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Harness) regTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.tools[name] = handler
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Harness) regStage(stage sdk.Stage, handler sdk.StageHandler) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.stages[stage] = append(h.stages[stage], handler)
|
||||
}
|
||||
|
||||
// Plug returns the loaded plugin instance.
|
||||
func (h *Harness) Plug() sdk.Plugin { return h.plug }
|
||||
|
||||
// SDK returns the mock PluginSDK.
|
||||
func (h *Harness) SDK() *sdk.PluginSDK { return h.sdk }
|
||||
|
||||
// Settings returns the mock settings store for test assertions.
|
||||
func (h *Harness) Settings() *mockSettings { return h.setting }
|
||||
|
||||
// ToolNames returns all registered tool names.
|
||||
func (h *Harness) ToolNames() []string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
names := make([]string, 0, len(h.tools))
|
||||
for n := range h.tools {
|
||||
names = append(names, n)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// CallTool invokes a registered tool handler with the given arguments.
|
||||
func (h *Harness) CallTool(name string, args map[string]interface{}) (interface{}, error) {
|
||||
h.mu.Lock()
|
||||
handler, ok := h.tools[name]
|
||||
h.mu.Unlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tool %q not registered", name)
|
||||
}
|
||||
return handler(args)
|
||||
}
|
||||
|
||||
// Close stops the plugin.
|
||||
func (h *Harness) Close() {
|
||||
if err := h.plug.Stop(); err != nil {
|
||||
h.t.Logf("plugin.Stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertToolRegistered fails if the tool is not registered.
|
||||
func (h *Harness) AssertToolRegistered(name string) {
|
||||
h.t.Helper()
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if _, ok := h.tools[name]; !ok {
|
||||
h.t.Fatalf("expected tool %q to be registered", name)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertToolResult checks that calling a tool returns the expected JSON output.
|
||||
func (h *Harness) AssertToolResult(name string, args map[string]interface{}, expected map[string]interface{}) {
|
||||
h.t.Helper()
|
||||
got, err := h.CallTool(name, args)
|
||||
if err != nil {
|
||||
h.t.Fatalf("tool %q: %v", name, err)
|
||||
}
|
||||
gotJSON, _ := json.Marshal(got)
|
||||
expJSON, _ := json.Marshal(expected)
|
||||
if string(gotJSON) != string(expJSON) {
|
||||
h.t.Fatalf("tool %q:\ngot: %s\nexp: %s", name, gotJSON, expJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// mockSettings implements sdk.SettingsAPI for testing.
|
||||
type mockSettings struct {
|
||||
mu sync.Mutex
|
||||
data map[string]interface{}
|
||||
defs map[string]sdk.ConfigDef
|
||||
}
|
||||
|
||||
func (m *mockSettings) Get(key string) (interface{}, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
v, ok := m.data[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("key %q not found", key)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (m *mockSettings) Set(key string, value interface{}) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.data[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSettings) List(prefix string) ([]string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var keys []string
|
||||
for k := range m.data {
|
||||
if prefix == "" || strings.HasPrefix(k, prefix) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (m *mockSettings) GetCore(key string) (interface{}, error) { return m.Get(key) }
|
||||
func (m *mockSettings) SetCore(key string, value interface{}) error { return m.Set(key, value) }
|
||||
func (m *mockSettings) ListCore(prefix string) ([]string, error) { return m.List(prefix) }
|
||||
|
||||
func (m *mockSettings) GetPlugin(plugin, key string) (interface{}, error) { return m.Get(key) }
|
||||
func (m *mockSettings) SetPlugin(plugin, key string, value interface{}) error { return m.Set(key, value) }
|
||||
func (m *mockSettings) ListPlugin(plugin, prefix string) ([]string, error) { return m.List(prefix) }
|
||||
|
||||
func (m *mockSettings) RegisterDef(def sdk.ConfigDef) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.defs[def.Key] = def
|
||||
}
|
||||
|
||||
func (m *mockSettings) Defs(prefix string) []*sdk.ConfigDef {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var defs []*sdk.ConfigDef
|
||||
for _, d := range m.defs {
|
||||
if prefix == "" || strings.HasPrefix(d.Key, prefix) {
|
||||
defs = append(defs, &d)
|
||||
}
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
func (m *mockSettings) Dump() map[string]interface{} {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cp := make(map[string]interface{})
|
||||
for k, v := range m.data {
|
||||
cp[k] = v
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
func (m *mockSettings) Plugins() []string { return nil }
|
||||
142
修复计划.md
Normal file
142
修复计划.md
Normal file
@ -0,0 +1,142 @@
|
||||
# HomeAgent 主仓库清理计划 & 代码质量问题修复
|
||||
|
||||
## 一、仓库清理
|
||||
|
||||
### 目标
|
||||
|
||||
将不属于核心仓库的全部内容迁移到 `homeagent-sdk` 仓库,主仓库只保留
|
||||
内核 + 内置插件 SDK 桥 (`internal/sdk/`) + 内置插件 (`internal/plugins/`)。
|
||||
|
||||
### 迁移内容
|
||||
|
||||
#### → 移入 homeagent-sdk
|
||||
|
||||
```
|
||||
示例插件: sdk/example/a2a/ → example/a2a/
|
||||
sdk/example/ocr/ → example/ocr/
|
||||
sdk/example/bili/ → example/bili/
|
||||
sdk/example/editdoc/ → example/editdoc/
|
||||
sdk/example/files/ → example/files/
|
||||
sdk/example/memo/ → example/memo/
|
||||
sdk/example/qq/ → example/qq/ (增强版替换旧版)
|
||||
sdk/example/web/ → example/web/
|
||||
工具链: sdk/tools/ → hack/plugin-dev/ (含 scaffold/build/package/templates)
|
||||
```
|
||||
|
||||
> **验证状态**: `sdk/`、`dist/`、`scripts/build-plugins.sh`、`tools/plugin-dev/` 已在当前 repo 中不存在或为空。
|
||||
> 以下条目已实际完成,标记为验证通过。
|
||||
|
||||
#### 主仓库删除
|
||||
|
||||
```
|
||||
sdk/ — 整目录删除(内容已迁入 homeagent-sdk)
|
||||
tools/plugin-dev/ — 整目录删除(已 DEPRECATED)
|
||||
scripts/build-plugins.sh — 删除(与"外部插件只依赖 homeagent-sdk"冲突)
|
||||
dist/ — 整目录删除(.hmap 应由 SDK 仓库构建产出)
|
||||
config/config.yaml — 删除,代码不从 yaml 读配置,全走 SQLite config.db
|
||||
```
|
||||
|
||||
#### 主仓库需修复的无用参数/文档
|
||||
|
||||
```
|
||||
deploy/homeagent.service — 去掉 ExecStart 中的 -config 参数(二进制不识别的 flag)
|
||||
Makefile install 目标 — 去掉 cp config/config.yaml 步骤
|
||||
docs/ADAPTER.md:102 — 去掉"在 config.yaml 中添加对应源"的指引,改为 SQLite 配置方式
|
||||
```
|
||||
|
||||
#### 主仓库需更新的文件
|
||||
|
||||
```
|
||||
README.md — 目录树删除 sdk/ 一行
|
||||
docs/ARCHITECTURE.md — 目录树 `sdk/` 条目 (L283) 实际指向 `internal/sdk/`,且当前无顶层 sdk/ 目录;改为 `internal/sdk/`
|
||||
docs/OVERVIEW.md — 更新 PluginSDK 路径引用
|
||||
docs/PLUGIN_DEV.md — 更新外部插件路径引用
|
||||
go.mod — SDK 模块标记已为 `// direct`,经验证无需修改
|
||||
go.work — 声明 `go 1.19` 但 SDK 模块要求 `go 1.21`,版本不一致;若实际 Go 工具链 >= 1.21 则无影响
|
||||
.gitignore — 添加 data/ 和 *.db 到忽略规则
|
||||
```
|
||||
|
||||
#### 主仓库不动的内容
|
||||
|
||||
```
|
||||
internal/sdk/ — 内置插件 SDK 桥(9 个文件,零改动)
|
||||
internal/plugin/ — 插件系统核心(registry/dynamic/manifest)
|
||||
internal/plugins/ — 10 个内置插件
|
||||
cmd/ — 内核入口
|
||||
其余内核代码 — 全部不动
|
||||
```
|
||||
|
||||
### 执行步骤(仓库清理)
|
||||
|
||||
1. 打包待迁移文件为迁移包(供 homeagent-sdk 仓库使用)
|
||||
2. 删除 sdk/、tools/plugin-dev/、scripts/build-plugins.sh、dist/、config/config.yaml
|
||||
3. 更新 Makefile(删除外部插件相关目标)
|
||||
4. 更新文档(README / ARCHITECTURE / OVERVIEW / PLUGIN_DEV)
|
||||
5. 更新 .gitignore 和 go.mod
|
||||
6. 验证编译和测试通过
|
||||
|
||||
---
|
||||
|
||||
## 二、代码质量问题修复(按优先级)
|
||||
|
||||
### P0 — 必须修(功能正确性)
|
||||
|
||||
> **架构前置**:本工程将插件划分为**内部插件** (`internal/plugins/`) 和**外部插件**(通过 `homeagent-sdk` 独立编译)。内部插件通过 `internal/sdk/` 获取**完整内核 API**(含 EventBus、完整 IOManager、RegisterChannel 等);外部插件通过 `homeagent-sdk/sdk` 使用**公开子集 API**(仅 3 个 IO 方法 + Tool/Stage/API 注册 + 记忆访问)。类似 Linux 内核模块 vs 用户态程序。
|
||||
>
|
||||
> **工具定义验证**: `buildToolDefs()` 从四路汇聚工具:IO 层、插件注册、Indexer 的 `GetToolDefinitions()` (`memory_recall/commit/introspect`)、直接定义(`memory_merge/delete/purge/edit/block_merge` 等)。经查验各源工具名无重叠,不存在重复注册问题。此条移除。
|
||||
|
||||
- [ ] **HTTP 401/403 检测靠字符串搜索** `internal/agent/core/agent.go:708-711`
|
||||
- `strings.Contains(errStr, "401")` 不可靠,如 TLS 错误含 `tls: bad certificate` 不匹配 401,但 `"401 Unauthorized"` 检查仍然脆弱。Lua Adapter 返回格式不固定。
|
||||
- **修复**: `ProviderManager` 增加 `ReportStatus(name, statusCode)` 方法,在 `LuaAdaptedProvider.Chat()` 中根据 `resp.StatusCode` 精确判断。
|
||||
|
||||
- [ ] **deploy/homeagent.service 传递 -config 参数使 homed 启动崩溃** `deploy/homeagent.service:9`
|
||||
- `ExecStart=/usr/local/bin/homed -config /etc/homeagent/config.yaml -data /var/lib/homeagent`
|
||||
- `cmd/homed/main.go` 只定义了 `-data`, `-webui`, `-socket` 三个 flag,未定义 `-config`
|
||||
- Go 的 `flag.Parse()` 遇到未定义 flag 会报错并 `os.Exit(2)`,服务无法启动
|
||||
- **修复**: 删除 `-config` 参数(已在 Part 1 清理中列出),但此问题实际为 P0 运行时故障
|
||||
|
||||
### P1 — 高风险(可能崩溃或数据丢失)
|
||||
|
||||
> **验证纠正**: 此前报告的 sql.Rows 双重 Close 和 nil f.Close() 经重新审查确认非真实 bug。
|
||||
> - graph.go:266-288: 关键词循环中无 defer 冲突,`rows.Close()` 在显式路径上只调用一次
|
||||
> - pipeline.go:138-171: `os.Open` 失败后 `continue` 跳出循环,`defer f.Close()` 所在的匿名函数永不执行
|
||||
>
|
||||
> 本优先级经验证后无真实 issue,保留空位供后续发现。
|
||||
|
||||
### P2 — 中风险(性能/维护性)
|
||||
|
||||
- [ ] **context.go 每次 Append/Prune 全量写盘** `internal/agent/core/context.go:88-101`
|
||||
- 30 条事件的 JSON 全量写入文件每次操作,高频输入场景有 I/O 瓶颈。
|
||||
- **修复**: 增加定时写入(每 5s flush)或在 `save()` 中增加 debounce。
|
||||
|
||||
- [ ] **config.yaml 与代码不一致** `config/config.yaml` vs `internal/config/registry.go`
|
||||
- config.yaml 声明 8 个 LLM sources,代码 `seedDBValues` 只注册 1 个(deepseek)。且代码不从 yaml 读配置(从 SQLite config.db 读取)。
|
||||
- **修复**: 删除 config/config.yaml(无用文件),或保持 yaml 作为 fallback 种子的唯一来源并删除代码中的 seedDBValues。
|
||||
|
||||
- [ ] **output_set_channel 枚举硬编码** `internal/agent/core/agent.go:1927-1928`
|
||||
- channel enum 硬编码为 `{"voice", "email", "screen", "http"}`,但 `executeOutputSendTool` 却从已注册 Device 动态检测能力,两者脱节。
|
||||
- **修复**: 从 `a.io.ListChannels()` 动态生成 enum。
|
||||
|
||||
- [ ] **describe_image/transcribe_audio/ocr_image 三重复代码** `internal/agent/core/agent.go:2682-2828`
|
||||
- 三个函数共享相同的 base64/data URL 处理、timeout、消息构造逻辑。
|
||||
- **修复**: 抽取 `mediaRequest(mediaType, prompt, args) → string` 公共方法。
|
||||
|
||||
- [ ] ~~**IOManager 9 个注入方法** `internal/agent/io/channel.go:177-258`~~
|
||||
- ~~`InjectInput/To/Sync/SyncTo/Text/TextSync/TextTo/TextNoMemoryTo/TextSyncNoMemoryTo`,组合爆炸级膨胀。~~
|
||||
- **经架构审查移除**: `internal/sdk/` 对内部插件暴露完整 IOManager(9 方法),而外部插件仅通过 `homeagent-sdk/sdk` 的 `IOInjector` 接口(3 方法:`InjectInterruptText`/`InjectText`/`InjectTextNoMemory`)访问。9 个方法为内部插件所需的全量 API,属合理设计。
|
||||
|
||||
- [ ] **两处 sources map 硬编码重复** `internal/config/registry.go:233-244` + `:302-313`
|
||||
- `seedDBValues` 和 `seedCoreDefs` 各写了一遍完全相同的 sources map。
|
||||
- **修复**: 抽取公共 var `defaultSources`。
|
||||
|
||||
### P3 — 低风险(清理/规范化)
|
||||
|
||||
- [ ] **messagesToMap 死函数** `internal/agent/api/provider.go:731-740` — 从未被调用,删除
|
||||
- [ ] **RunStageAll 死函数** `internal/agent/core/stages.go:106-108` — 仅包装 RunStage,删除
|
||||
- [ ] **RemoveRelation 方法未暴露为工具** `internal/memory/social/social.go:174-185` — 定义但无 LLM 工具入口
|
||||
- [ ] **distillOnce 线性扫描** `internal/memory/pipeline/pipeline.go:190-222` — 每次全表扫描 O(n),可改为维护 distiller index
|
||||
- [ ] **internal/embed/embedder.go 整包死代码** — `OllamaEmbedder` 和 `HashEmbedder` 定义完整,但没有任何 Go 包 import 或构造它们。记忆系统使用 `vector.TFIDFVectorizer`。删除 embedder.go
|
||||
- [ ] **internal/tokenizer/jieba.go 整文件死代码** — 基于 `gojieba` 的分词器,但没有任何包 import `tokenizer.Global()`。分词逻辑未在任何路径中调用。删除 jieba.go
|
||||
- [ ] **internal/container/ 整包死代码** — `container.Manager` 仅被 `internal/snapshot/` import,而 snapshot 本身也是死代码。`container.NewManager` 从未被调用。删除整包
|
||||
- [ ] **internal/snapshot/ 整包死代码** — `snapshot.Manager` 从未被任何包 import。`snapshot.NewManager` 从未被调用。main.go 只创建了 `data/snapshots` 目录但未实例化管理器。删除整包
|
||||
- [ ] **.tmp-plugins/qq/plugin.go 开发中内部插件** — 1051 行 QQ 插件,位于 `.tmp-plugins/`(在 `.gitignore` 中)。import `internal/sdk` 路径(正确——作为内部插件开发),但由于放在 gitignore 目录下,不会被纳入源码管理。是待完成/废弃的开发实验品。`homeagent-sdk/example/qq/` 已有其外部插件版本。
|
||||
200
迭代文档.md
Normal file
200
迭代文档.md
Normal file
@ -0,0 +1,200 @@
|
||||
# 迭代文档
|
||||
|
||||
## 插件错误分析与修改建议(2026-07-11)
|
||||
|
||||
基于本机 homeagent 实例 24h systemd 日志分析。
|
||||
|
||||
---
|
||||
|
||||
### 1. LLM Provider Fallback 链
|
||||
|
||||
#### 现象
|
||||
DeepSeek 网络不稳定时,依次尝试 8 个 provider,全部失败耗时 ~10s:
|
||||
```
|
||||
provider "lua_deepseek" failed: TLS handshake timeout
|
||||
provider "lua_anthropic" failed: 401 Invalid bearer token
|
||||
provider "lua_gemini" failed: 404
|
||||
provider "lua_github" failed: 401 Bad credentials
|
||||
provider "lua_groq" failed: 401 Invalid API Key
|
||||
provider "lua_mistral" failed: TLS handshake timeout / x509 cert mismatch
|
||||
provider "lua_ollama" failed: 404 model 'llama3' not found
|
||||
provider "lua_openai" failed: TLS handshake timeout
|
||||
→ all 8 providers failed
|
||||
```
|
||||
|
||||
#### 根因定位
|
||||
- fallback 逻辑在 `internal/agent/core/agent.go:671-698`:`OrderedProviders()` 遍历所有已注册 provider
|
||||
- provider 来自 `cfg.LLM.Sources`(`cmd/homed/main.go:260-273`),**不是硬编码的循环**,是按配置依次尝试
|
||||
- **但是**默认配置 `config/config.go:32-41` 的 `DefaultConfig()` 硬编码了 8 个 source。本机 `/etc/homeagent/config.yaml` 不存在,所以 fallback 到默认配置的 8 个 sources
|
||||
- 本机启动日志确认:`sources=8 adapters=8`
|
||||
- 认证错误(401/403/404)的 provider 每次 fallback 都重新尝试,没有缓存/跳过机制
|
||||
|
||||
#### 修改建议
|
||||
|
||||
**homeagent(高优先级)**
|
||||
- `internal/agent/core/agent.go` fallback 循环:
|
||||
- 对返回 401/403 的 provider 标记为 `auth_failed`,本轮不再重试
|
||||
- 增加断路器:连续 N 次失败的 provider 进入 cooldown(如 5 分钟不选)
|
||||
- `internal/agent/api/provider.go` `ProviderManager`:
|
||||
- 增加 `MarkUnavailable(name string)` / `IsAvailable(name string)` 方法
|
||||
- `config/config.go` `DefaultConfig()`:
|
||||
- 默认只保留 primary source(deepseek),其余 7 个不作为默认配置
|
||||
- 用户如果需要 fallback 应自行配置
|
||||
|
||||
---
|
||||
|
||||
### 2. QQ 插件 — `parse get_file ... bad response`
|
||||
|
||||
#### 现象
|
||||
日志中出现频率最高,占全部 plugin 错误的 ~90%。NapCat 收到图片/文件事件后,homeagent 尝试下载但解析失败。
|
||||
|
||||
#### 根因定位
|
||||
- 运行中的 QQ 插件是 `/home/newqqagent/plugins/qq/plugin.so`(13MB 编译产物)
|
||||
- 源码 `.tmp-plugins/qq/plugin.go`(1051 行)与运行版本不一致(运行版本 line 1356 有此错误)
|
||||
- 错误信息仅包含文件名,不包含 NapCat 原始响应,无法定位是 NapCat 返回了非 JSON 还是 URL 无效
|
||||
|
||||
#### 修改建议
|
||||
|
||||
**QQ 插件(高优先级)**
|
||||
- `handleGetGroupFiles` download 分支(当前 line 892-933):
|
||||
- JSON 解析失败时,将 NapCat 原始 body 截断后一并返回
|
||||
- 对 NapCat 返回的错误状态码(如 `retcode != 0`)明确提示
|
||||
- 例:`{"error": "parse response failed", "raw": "<body_preview>", "file_id": "xxx"}`
|
||||
|
||||
**homeagent SDK(中优先级)**
|
||||
- `internal/sdk/plugin.go` 增加 `LogError(plugin, action, err, context)` 方法
|
||||
- 统一插件错误日志格式:`[plugin] <name> <action>: <err> | <context>`
|
||||
|
||||
---
|
||||
|
||||
### 3. QQ 插件 — `qq_web_fetch` 内容不完整
|
||||
|
||||
#### 现象
|
||||
工具仅返回页面 `<title>` 内容(`标题: xxx`),部分站点:
|
||||
- `502 Bad Gateway` — 目标站不可用
|
||||
- `百度安全验证` — 触发反爬
|
||||
- `www.zhihu.com` — 只有域名无内容
|
||||
|
||||
#### 根因
|
||||
运行中的 QQ plugin.so 包含 `qq_web_fetch` 工具(源码未在仓库中),实现简陋,只提取 title 标签。
|
||||
|
||||
#### 修改建议
|
||||
- 升级为完整正文提取(readability 算法)
|
||||
- 增加 User-Agent 轮换
|
||||
- 对非 200 状态码返回明确错误
|
||||
- 考虑将 web_fetch 功能移至独立的 `web` 插件(已定义 manifest 但未编译 plugin.so)
|
||||
|
||||
---
|
||||
|
||||
### 4. A2A 插件 — discover 超时
|
||||
|
||||
#### 现象
|
||||
```
|
||||
a2a_a2a_discover → http://192.168.1.100:8080/a2a/agent-card
|
||||
→ context deadline exceeded
|
||||
```
|
||||
|
||||
#### 根因
|
||||
- 目标地址 `192.168.1.100:8080` 不可达
|
||||
- 错误信息只有"连接Agent失败",无超时/拒绝等分类
|
||||
|
||||
#### 修改建议
|
||||
- A2A 插件的 discover 错误应区分:超时、连接拒绝、DNS 解析失败
|
||||
- Agent Card URL 应从配置读取,而非硬编码
|
||||
|
||||
---
|
||||
|
||||
### 5. AgentCLI — SSH 终端超时
|
||||
|
||||
#### 现象
|
||||
```
|
||||
terminal_create: ssh ... timeout=15s
|
||||
terminal_read: 终端 term_2 不存在或已关闭
|
||||
```
|
||||
|
||||
#### 根因
|
||||
SSH 命令 timeout 仅 15s,连接建立后很快超时被清理。
|
||||
|
||||
#### 修改建议
|
||||
- `terminal_create` 根据命令类型给出默认超时建议(SSH 建议 60-300s)
|
||||
- 终端关闭时主动发送中断通知,而非让 agent 下次 read 才知道
|
||||
|
||||
---
|
||||
|
||||
### 6. LLM 思维泄漏 — 工具调用残留混入 QQ 消息内容
|
||||
|
||||
#### 现象
|
||||
LLM 通过 `qq_send_private_msg` 发送的消息中,包含了原始工具调用描述。用户在 QQ 上收到类似:
|
||||
```
|
||||
好嘞老大,我找找日志给你发过来!😊
|
||||
|
||||
<tool_call>
|
||||
cmd_run find / -name "*.log" 2>/dev/null | head -20
|
||||
</tool_call>
|
||||
```
|
||||
|
||||
LLM 在同一轮生成中同时输出文本(`content`)和工具调用(`tool_calls`),但文本内容里也描述/复现了它准备调用的工具。
|
||||
|
||||
#### 根因
|
||||
- LLM API 返回格式:`{content: "文本...", tool_calls: [...]}` — content 和 tool_calls 是并列关系
|
||||
- 部分 LLM(尤其是 thinking/reasoning 模型)会在 content 中输出推理过程,包括"我需要调 xxx 工具"的描述
|
||||
- `qq_send_private_msg` 的 `message` 参数直接取 LLM 的 `content` 原文,未做清洗
|
||||
- 工具调用残留不是框架 bug,是 LLM 输出内容的"思维泄漏"
|
||||
|
||||
#### 修改建议
|
||||
|
||||
**QQ 插件 `beforeOwnToolcall`(高优先级)**
|
||||
- 对 `qq_send_private_msg` / `qq_send_group_msg` 的 `message` 参数做正则清洗:
|
||||
- 过滤 `<tool_call>...</tool_call>`、`<invoke>...</invoke>` 等已知的 tool call 标记
|
||||
- 过滤 `cmd_run`、`terminal_create` 等工具名后跟命令的文本模式
|
||||
- 过滤 markdown 代码块中疑似 shell 命令的内容
|
||||
- 清洗规则应可配置/可扩展
|
||||
|
||||
**homeagent 核心层 `post_action` 阶段(中优先级)**
|
||||
- `internal/agent/core/agent.go` 中 `post_action` stage 后,对 LLM 返回的 `content` 增加通用清洗管道:
|
||||
- 移除 ````xml <tool_call>...` 等已知模式
|
||||
- 移除 `【tool_call】...` 等自定义标记
|
||||
- 清洗后的 content 写入上下文 + 输出通道,原始 content 保留供调试
|
||||
|
||||
---
|
||||
|
||||
### 7. Files 插件 — `not a directory` 提示不友好
|
||||
|
||||
#### 现象
|
||||
```
|
||||
files_ls /tmp/hello_world.pptx → "not a directory: /tmp/hello_world.pptx"
|
||||
```
|
||||
|
||||
#### 修改建议
|
||||
- `handleLs` 遇到文件路径时,返回"这是一个文件,不是目录。如需读取文件内容,请使用 files_read 工具"
|
||||
|
||||
---
|
||||
|
||||
### 8. 未编译插件
|
||||
|
||||
`/home/newqqagent/plugins/` 中以下插件有 `plugin.json` 但无 `plugin.so`:
|
||||
- `web` — 声明提供 `web_search` / `web_fetch`
|
||||
- `browser` — 声明提供 headless browser
|
||||
- `bili` — B 站相关
|
||||
- `memo` — 备忘录
|
||||
- `editdoc` — 文档编辑
|
||||
|
||||
需要找到对应 Go 源码并编译部署。
|
||||
|
||||
---
|
||||
|
||||
### 附录:相关源码位置
|
||||
|
||||
| 组件 | 路径 | 关键行 |
|
||||
|------|------|--------|
|
||||
| Provider 注册 | `cmd/homed/main.go` | 259-276 |
|
||||
| ProviderManager | `internal/agent/api/provider.go` | 581-676 |
|
||||
| Fallback 循环 | `internal/agent/core/agent.go` | 661-711 |
|
||||
| 默认配置 | `config/config.go` | 15-67 |
|
||||
| QQ 插件源码 | `.tmp-plugins/qq/plugin.go` | 全部(1051行) |
|
||||
| QQ 插件运行版 | `/home/newqqagent/plugins/qq/plugin.so` | — |
|
||||
| 插件注册 | `internal/plugin/registry.go` | 全部 |
|
||||
| PluginSDK | `internal/sdk/plugin.go` | 全部 |
|
||||
| AgentCLI 终端 | `internal/plugins/agentcli/plugin.go` | 全部 |
|
||||
| Files 插件 | `internal/plugins/files/plugin.go` | 全部 |
|
||||
| Cmd 插件 | `internal/plugins/cmd/plugin.go` | 全部 |
|
||||
Reference in New Issue
Block a user