diff --git a/.gitignore b/.gitignore index b82b1f8..27f7e47 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile index 6473422..2baeff1 100644 --- a/Makefile +++ b/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" diff --git a/README.md b/README.md index ab84e73..15b5179 100644 --- a/README.md +++ b/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
输入后·可短路] --> + S2[② StagePreAction
LLM前·可短路] --> + S3[③ StagePostAction
LLM后·可短路] + S3 --> S4{有tool call?} + S4 -->|是| S5[④ StageBeforeToolcall
tool前·可拒绝] + S5 --> T[executeToolCall] + T --> S6[⑤ StageAfterToolcall
tool后·只读] + S6 --> S3 + S4 -->|否| S7[⑥ StageBeforeOutput
输出前·改写文本] + S7 --> S8[⑦ StageAfterOutput
输出后·只读] + 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
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
内存events[] + JSON文件] + A[Append
每次输入时调用] -->|Vectorize
char 1-2gram TF-IDF| RC + P[Prune
TF-IDF CosineSimilarity
保留 topK + 最近10条] -->|低分事件归档| CDoc + P -->|保留| TL[timeline → system prompt
按时间排序输出给LLM] + S[save
5s debounce写盘] --> RC + end + + RC -->|读取全部事件| TL + + subgraph "② Document 层 — 文件记忆" + D[DocStore
JSON文件 + TF-IDF向量索引] + CDoc[ContextToDoc
Prune归档入口] -->|结构化摘要+标签+实体| D + LLMDC[LLM工具
doc_commit] -->|手动提交| D + GSD[syncGraphToDocs
每30min] -->|Graph快照| D + Q[Query
system prompt注入] -->|Vectorize输入
InvertedIndex+Cosine
召回top 3| D + Q -->|格式: 【相关记忆文档】| SP + AC[AccessCount++
LastAccess更新] -->|每次Query命中| D + end + + subgraph "③ Graph 层 — 图数据库" + G[(SQLite)] + G --> ENT[entities
{Name,Type,Summary,Vector}] + G --> REL[relations
{PersonA,Relation,PersonB}] + IDX[Indexer 自动召回
每30min重建向量索引] -->|Vectorize实体名
TF-IDF + BFS depth=2| G + IDX -->|格式: 【记忆索引】| SP + MEM[LLM工具
memory_recall/commit/merge] --> G + SOC[Social层
person_query/set_trait/relate] --> G + end + + subgraph "④ 蒸馏管道 — 每30min心跳" + DC[distillContext
窗口>2×max→强制Prune] -->|触发| P + SYNC[syncGraphToDocs] -->|Graph→Document| GSD + REORG[reorgGraph] -->|Step1| IDXSYNC[indexer.Sync
重建实体向量索引] + REORG -->|Step2| DREIDX[docStore.Reindex
重建文档向量索引] + REORG -->|Step3 冷文档→Graph| CD[FindColdDocs
72h / ≤2次访问] -->|docToTriples| G + REORG -->|Step4 实体相似度| BIGRAM[Bigram Jaccard
>0.75] -->|consolidation| LLMCONS[LLM判断
是否merge] + REORG -->|Step5 图质量| QEVAL[evaluateGraphQuality
低置信度关系] --> LLMCONS2[LLM判断
保留/删除] + end + + SP[System Prompt
组装顺序] -->|base→人格→| GI[【记忆索引】
Indexer召回实体] + GI -->|清洗指令→| DM[【相关记忆文档】
DocStore.Query top3] + DM -->|技能注入→| TDS[工具定义] --> LLM + + subgraph "⑤ LLM 工具循环" + LLM[LLM 推理] -->|memory_recall| MEM + LLM -->|doc_query| Q2[doc_query 工具] + Q2 -->|Consume
读取并删除| D + Q2 -->|③ 写入context
原始时间戳+源cold_storage| RC + Q2 -->|返回: 已加载N篇| LLM + LLM -->|doc_commit| LLMDC + LLM -->|output_send| OUT[输出通道] + end + + subgraph "⑥ Pipeline 规则蒸馏器" + PIPE[pipeline/distillOnce
每心跳] -->|正则匹配| 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) 仓库。 ## 文档 diff --git a/cmd/homed/main.go b/cmd/homed/main.go index af8bee1..530203d 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -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{ diff --git a/config/config.go b/config/config.go deleted file mode 100644 index 28ff885..0000000 --- a/config/config.go +++ /dev/null @@ -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 -} diff --git a/config/config.yaml b/config/config.yaml deleted file mode 100644 index 810efc8..0000000 --- a/config/config.yaml +++ /dev/null @@ -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" diff --git a/config/personal/personal.md b/config/personal/personal.md deleted file mode 100644 index 16107a5..0000000 --- a/config/personal/personal.md +++ /dev/null @@ -1,14 +0,0 @@ -## 你的身份 - -你是 HomeAgent——一个全新自研的新一代 Agent 框架。 -你以内核 + 插件架构驱动,实现了稳定高效、记忆不衰减的长时持续运行。 - -## 对用户的称呼 - -你对用户的称呼永远是"老大",绝对禁止使用"老板""主人"称呼用户,不论任何情况。 - -## 对话风格 - -- 用语气词(哈、嘛、呢、~、😊、🔥 等),不要太端着 -- 重要的事先说结论,再展开解释 -- 回复要简洁自然 \ No newline at end of file diff --git a/deploy/homeagent.service b/deploy/homeagent.service index 6d3486d..1a564b6 100644 --- a/deploy/homeagent.service +++ b/deploy/homeagent.service @@ -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 diff --git a/docs/ADAPTER.md b/docs/ADAPTER.md index 83bf9b7..bf80d94 100644 --- a/docs/ADAPTER.md +++ b/docs/ADAPTER.md @@ -98,7 +98,5 @@ return adapter 1. 在 `internal/lua/adapters/` 下创建 `.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 ./...` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1c0c179..89f6b38 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index ca02ada..5ec0be2 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -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 安装 \ No newline at end of file diff --git a/docs/PLUGIN_DEV.md b/docs/PLUGIN_DEV.md index 19306a2..13b5db4 100644 --- a/docs/PLUGIN_DEV.md +++ b/docs/PLUGIN_DEV.md @@ -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 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 脚手架生成 | --- diff --git a/go.mod b/go.mod index 5d7c2eb..53aafbc 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index dc12630..182d206 100644 --- a/go.sum +++ b/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= diff --git a/go.work b/go.work new file mode 100644 index 0000000..8d98d50 --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.21 + +use ( + . + /tmp/opencode/homeagent-sdk-repo +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..31e4353 --- /dev/null +++ b/go.work.sum @@ -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= diff --git a/internal/agent/api/provider.go b/internal/agent/api/provider.go index 41719e8..143a090 100644 --- a/internal/agent/api/provider.go +++ b/internal/agent/api/provider.go @@ -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 { diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 5eefb50..af1dfde 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -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(短路) diff --git a/internal/agent/core/agent_tools_test.go b/internal/agent/core/agent_tools_test.go index 08145a9..221b5c2 100644 --- a/internal/agent/core/agent_tools_test.go +++ b/internal/agent/core/agent_tools_test.go @@ -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)) } } diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index 422e9fa..cb1ff7f 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -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 { diff --git a/internal/agent/core/context_test.go b/internal/agent/core/context_test.go index f8a139e..8ed489a 100644 --- a/internal/agent/core/context_test.go +++ b/internal/agent/core/context_test.go @@ -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()) } } diff --git a/internal/agent/core/stages.go b/internal/agent/core/stages.go index b73d336..a42e7ec 100644 --- a/internal/agent/core/stages.go +++ b/internal/agent/core/stages.go @@ -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() diff --git a/internal/agent/core/stages_test.go b/internal/agent/core/stages_test.go index c98debc..8dd984f 100644 --- a/internal/agent/core/stages_test.go +++ b/internal/agent/core/stages_test.go @@ -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) diff --git a/internal/config/registry.go b/internal/config/registry.go index ca49e45..4ecf151 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -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) diff --git a/internal/container/manager.go b/internal/container/manager.go deleted file mode 100644 index 984249e..0000000 --- a/internal/container/manager.go +++ /dev/null @@ -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 -} diff --git a/internal/embed/embedder.go b/internal/embed/embedder.go deleted file mode 100644 index 68274c7..0000000 --- a/internal/embed/embedder.go +++ /dev/null @@ -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)) -} diff --git a/internal/lua/vm.go b/internal/lua/vm.go index caf971d..1a9cb2b 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -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 { diff --git a/internal/memory/document/document.go b/internal/memory/document/document.go index a1edabb..b7957a2 100644 --- a/internal/memory/document/document.go +++ b/internal/memory/document/document.go @@ -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 { diff --git a/internal/memory/graph.go b/internal/memory/graph.go index 2b0b4f0..f878f2c 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -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() diff --git a/internal/memory/pipeline/pipeline.go b/internal/memory/pipeline/pipeline.go index bb5602c..3c3a2f9 100644 --- a/internal/memory/pipeline/pipeline.go +++ b/internal/memory/pipeline/pipeline.go @@ -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 } diff --git a/internal/plugin/dynamic.go b/internal/plugin/dynamic.go index cebdddd..c7432ed 100644 --- a/internal/plugin/dynamic.go +++ b/internal/plugin/dynamic.go @@ -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 diff --git a/internal/plugin/sdk/api.go b/internal/plugin/sdk/api.go deleted file mode 100644 index 536a472..0000000 --- a/internal/plugin/sdk/api.go +++ /dev/null @@ -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, - } -} diff --git a/internal/plugin/sdk/bus.go b/internal/plugin/sdk/bus.go deleted file mode 100644 index 573f140..0000000 --- a/internal/plugin/sdk/bus.go +++ /dev/null @@ -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 - } - } - } -} diff --git a/internal/plugin/sdk/bus_test.go b/internal/plugin/sdk/bus_test.go deleted file mode 100644 index 0ef0e04..0000000 --- a/internal/plugin/sdk/bus_test.go +++ /dev/null @@ -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)) - } -} diff --git a/internal/plugins/agentcli/plugin.go b/internal/plugins/agentcli/plugin.go index ff9b653..fe45014 100644 --- a/internal/plugins/agentcli/plugin.go +++ b/internal/plugins/agentcli/plugin.go @@ -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) diff --git a/internal/plugins/cmd/plugin.go b/internal/plugins/cmd/plugin.go index 746a905..d2dbd01 100644 --- a/internal/plugins/cmd/plugin.go +++ b/internal/plugins/cmd/plugin.go @@ -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 } diff --git a/internal/plugins/cmd/plugin_test.go b/internal/plugins/cmd/plugin_test.go index 4a57dfe..ded45c4 100644 --- a/internal/plugins/cmd/plugin_test.go +++ b/internal/plugins/cmd/plugin_test.go @@ -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"]) } } diff --git a/internal/plugins/pluginmgr/plugin.go b/internal/plugins/pluginmgr/plugin.go index 6eae794..f87ce7a 100644 --- a/internal/plugins/pluginmgr/plugin.go +++ b/internal/plugins/pluginmgr/plugin.go @@ -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) } diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index cd8155d..445b706 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -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, }) } diff --git a/internal/sdk/knowledge.go b/internal/sdk/knowledge.go index 613ff22..fa72a2e 100644 --- a/internal/sdk/knowledge.go +++ b/internal/sdk/knowledge.go @@ -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 diff --git a/internal/sdk/knowledge_impl.go b/internal/sdk/knowledge_impl.go new file mode 100644 index 0000000..c4525ff --- /dev/null +++ b/internal/sdk/knowledge_impl.go @@ -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) diff --git a/internal/sdk/llm.go b/internal/sdk/llm.go index 76f2dad..1511275 100644 --- a/internal/sdk/llm.go +++ b/internal/sdk/llm.go @@ -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 diff --git a/internal/sdk/llm_impl.go b/internal/sdk/llm_impl.go new file mode 100644 index 0000000..8ba0b4c --- /dev/null +++ b/internal/sdk/llm_impl.go @@ -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) diff --git a/internal/sdk/memory.go b/internal/sdk/memory.go index 27123bf..405ac0c 100644 --- a/internal/sdk/memory.go +++ b/internal/sdk/memory.go @@ -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 diff --git a/internal/sdk/memory_impl.go b/internal/sdk/memory_impl.go new file mode 100644 index 0000000..60c554a --- /dev/null +++ b/internal/sdk/memory_impl.go @@ -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) diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index f70c26b..9923b4b 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -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 } diff --git a/internal/sdk/settings.go b/internal/sdk/settings.go index 02117b6..67e63e6 100644 --- a/internal/sdk/settings.go +++ b/internal/sdk/settings.go @@ -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 diff --git a/internal/sdk/settings_impl.go b/internal/sdk/settings_impl.go new file mode 100644 index 0000000..77514ff --- /dev/null +++ b/internal/sdk/settings_impl.go @@ -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) diff --git a/internal/snapshot/manager.go b/internal/snapshot/manager.go deleted file mode 100644 index 5ed098a..0000000 --- a/internal/snapshot/manager.go +++ /dev/null @@ -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:] -} diff --git a/internal/tokenizer/jieba.go b/internal/tokenizer/jieba.go deleted file mode 100644 index 244e5cf..0000000 --- a/internal/tokenizer/jieba.go +++ /dev/null @@ -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 -} diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..d8adef3 --- /dev/null +++ b/plan.md @@ -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 +``` diff --git a/third_party/homeagent-sdk/.gitignore b/third_party/homeagent-sdk/.gitignore deleted file mode 100644 index 0d63db8..0000000 --- a/third_party/homeagent-sdk/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -*.so -*.hmap diff --git a/third_party/homeagent-sdk/README.md b/third_party/homeagent-sdk/README.md deleted file mode 100644 index 97b0651..0000000 --- a/third_party/homeagent-sdk/README.md +++ /dev/null @@ -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 && go build -buildmode=plugin -o /plugin.so -``` - -### 部署 - -将插件目录放入 HomeAgent 内核的插件目录(`/plugins//`): - -``` -/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 \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/files/README.md b/third_party/homeagent-sdk/example/files/README.md deleted file mode 100644 index 8c9cac6..0000000 --- a/third_party/homeagent-sdk/example/files/README.md +++ /dev/null @@ -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 模式自动创建父目录 diff --git a/third_party/homeagent-sdk/example/files/plugin.go b/third_party/homeagent-sdk/example/files/plugin.go deleted file mode 100644 index 53a48d2..0000000 --- a/third_party/homeagent-sdk/example/files/plugin.go +++ /dev/null @@ -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 -} diff --git a/third_party/homeagent-sdk/example/files/plugin.json b/third_party/homeagent-sdk/example/files/plugin.json deleted file mode 100644 index ddefb89..0000000 --- a/third_party/homeagent-sdk/example/files/plugin.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "files", - "version": "1.0.0", - "description": "文件系统操作插件,提供文件读写、编辑、目录列表等工具", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": ["files", "filesystem", "io"] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/memo/README.md b/third_party/homeagent-sdk/example/memo/README.md deleted file mode 100644 index 31f142e..0000000 --- a/third_party/homeagent-sdk/example/memo/README.md +++ /dev/null @@ -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,防丢数据 diff --git a/third_party/homeagent-sdk/example/memo/plugin.go b/third_party/homeagent-sdk/example/memo/plugin.go deleted file mode 100644 index 07813a7..0000000 --- a/third_party/homeagent-sdk/example/memo/plugin.go +++ /dev/null @@ -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, - } -} diff --git a/third_party/homeagent-sdk/example/memo/plugin.json b/third_party/homeagent-sdk/example/memo/plugin.json deleted file mode 100644 index ef2c723..0000000 --- a/third_party/homeagent-sdk/example/memo/plugin.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "memo", - "version": "1.0.0", - "description": "备忘插件,支持创建、完成、列出备忘条目,自动提醒", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": ["memo", "reminder", "todo"] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/qq/Makefile b/third_party/homeagent-sdk/example/qq/Makefile deleted file mode 100644 index 41f7c3d..0000000 --- a/third_party/homeagent-sdk/example/qq/Makefile +++ /dev/null @@ -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 diff --git a/third_party/homeagent-sdk/example/qq/README.md b/third_party/homeagent-sdk/example/qq/README.md deleted file mode 100644 index fcc989c..0000000 --- a/third_party/homeagent-sdk/example/qq/README.md +++ /dev/null @@ -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:` | Webhook 监听地址 | -| `plugin.qq.napcat_url` | `http://127.0.0.1:` | NapCat HTTP API 基地址 | -| `plugin.qq.admin` | 空 | 管理员 QQ 号 | - -## 注意事项 - -- 依赖 NapCat 框架运行,需先启动 NapCat 并配置 webhook 指向本插件地址 -- 群管理中的破坏性操作(踢人、退群等)在描述中已写明需先请示管理员 -- `qq_get_message` 返回的消息对象包含完整字段,LLM 可据此判断消息类型和来源 diff --git a/third_party/homeagent-sdk/example/qq/plugin.go b/third_party/homeagent-sdk/example/qq/plugin.go deleted file mode 100644 index f787806..0000000 --- a/third_party/homeagent-sdk/example/qq/plugin.go +++ /dev/null @@ -1,1051 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "sync" - "time" - - "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -type SavedMessage struct { - LocalID int64 `json:"local_id"` - MessageID int64 `json:"message_id"` - UserID int64 `json:"user_id"` - Nickname string `json:"nickname"` - GroupID int64 `json:"group_id,omitempty"` - GroupName string `json:"group_name,omitempty"` - MessageType string `json:"message_type"` - Text string `json:"text"` - Time int64 `json:"time"` -} - -const maxMessages = 2000 - -type Plugin struct { - name string - sdk *sdk.PluginSDK - mu sync.RWMutex - messages []*SavedMessage - nextID int64 - listenAddr string - napcatURL string - remoteDir string - filesDir string - adminID int64 - botID int64 - botNickname string - dmPolicy string - groupPolicy string - allowFrom map[int64]struct{} - groupAllowFrom map[int64]struct{} - srv *http.Server - groupNameCache map[int64]string -} - -func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{ - name: name, - nextID: 1, - messages: make([]*SavedMessage, 0, maxMessages), - groupNameCache: make(map[int64]string), - allowFrom: make(map[int64]struct{}), - groupAllowFrom: make(map[int64]struct{}), - dmPolicy: "open", - groupPolicy: "open", - }, 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.qq.listen", Default: "0.0.0.0:25580", Type: "string", DisplayName: "监听地址", Description: "Webhook HTTP 监听地址", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.napcat_url", Default: "http://127.0.0.1:3000", Type: "string", DisplayName: "NapCat 地址", Description: "NapCat HTTP API 基础 URL", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.admin", Default: "", Type: "string", DisplayName: "管理员 QQ", Description: "管理员 QQ 号,收到其消息时标记【重要!老大消息】", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.dm_policy", Default: "open", Type: "string", DisplayName: "私聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.allow_from", Default: "", Type: "string", DisplayName: "私聊白名单", Description: "允许私聊机器人的 QQ 号列表,逗号分隔", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.group_policy", Default: "open", Type: "string", DisplayName: "群聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.group_allow_from", Default: "", Type: "string", DisplayName: "群聊白名单", Description: "允许接入的群号列表,逗号分隔", Category: "qq"}) - - settings := s.Settings() - - p.listenAddr = getSetting[string](settings, "listen", "0.0.0.0:25580") - p.napcatURL = strings.TrimRight(getSetting[string](settings, "napcat_url", "http://127.0.0.1:3000"), "/") - p.adminID = getSetting[int64](settings, "admin", 0) - p.dmPolicy = normalizePolicy(getSetting[string](settings, "dm_policy", "open")) - p.groupPolicy = normalizePolicy(getSetting[string](settings, "group_policy", "open")) - p.allowFrom = parseIDSet(getSetting[string](settings, "allow_from", "")) - p.groupAllowFrom = parseIDSet(getSetting[string](settings, "group_allow_from", "")) - - // 从 NapCat 自动获取 Bot 身份 - p.fetchBotInfo() - - tp := p.name + "_" - - botInfo := "" - if p.botNickname != "" { - botInfo = fmt.Sprintf("你的QQ昵称是%s", p.botNickname) - if p.botID > 0 { - botInfo += fmt.Sprintf(",QQ号是%d", p.botID) - } - botInfo += "。" - } - - // ---- 消息 ---- - p.regTool(s, tp+"get_message", botInfo+"获取通过中断通知的QQ消息正文。local_id来自中断文字中的id号。", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "local_id": map[string]interface{}{"type": "integer", "description": "本地消息ID"}, - }, "required": []string{"local_id"}, - }, p.handleGetMessage) - - p.regTool(s, tp+"send_private_msg", "发送QQ私聊消息", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"}, - "message": map[string]interface{}{"type": "string", "description": "消息内容"}, - }, "required": []string{"user_id", "message"}, - }, p.handleSendPrivate) - - p.regTool(s, tp+"send_group_msg", "发送QQ群消息", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "目标群号"}, - "message": map[string]interface{}{"type": "string", "description": "消息内容"}, - }, "required": []string{"group_id", "message"}, - }, p.handleSendGroup) - - p.regTool(s, tp+"send_file", "发送文件/图片到QQ(私聊或群聊)。文件先复制到remote目录供NapCat容器访问。", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "目标群号(与user_id二选一)"}, - "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号(与group_id二选一)"}, - "file": map[string]interface{}{"type": "string", "description": "本地文件路径"}, - "name": map[string]interface{}{"type": "string", "description": "文件名(可选,默认取原文件名)"}, - "as_image": map[string]interface{}{"type": "boolean", "description": "作为图片发送(true)还是作为文件(false,默认)"}, - }, - }, p.handleSendFile) - - p.regTool(s, tp+"get_history", "获取QQ群聊/私聊历史消息,用于回顾之前的对话上下文", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"}, - "user_id": map[string]interface{}{"type": "integer", "description": "QQ号私聊历史(与group_id二选一)"}, - "count": map[string]interface{}{"type": "integer", "description": "拉取条数,默认10"}, - }, "required": []string{}, - }, p.handleGetHistory) - - // ---- 查询 ---- - p.regTool(s, tp+"get_groups", "获取QQ群列表,可按关键词搜索群名", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(可选)"}, - }, - }, p.handleGetGroups) - - p.regTool(s, tp+"get_friends", "获取QQ好友列表,可按昵称/备注关键词搜索", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(可选)"}, - }, - }, p.handleGetFriends) - - p.regTool(s, tp+"resolve_name", "将QQ号或群号解析为可读的用户昵称或群名称", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "user_id": map[string]interface{}{"type": "integer", "description": "QQ号(与group_id二选一)"}, - "group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"}, - }, - }, p.handleResolveName) - - p.regTool(s, tp+"get_group_member_info", "获取QQ群成员详细信息", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "群号"}, - "user_id": map[string]interface{}{"type": "integer", "description": "QQ号"}, - }, "required": []string{"group_id", "user_id"}, - }, p.handleGetGroupMemberInfo) - - // ---- 群管理 ---- - p.regTool(s, tp+"group_manage", "QQ群综合管理。通过command参数执行各种操作:leave退群, kick踢人, ban禁言, unban解禁, rename改名, mute-all全员禁言, set-card设名片, set-admin设管理, set-title设头衔, member-list成员列表, group-info群详情, member-info成员详情, at-all-remain@全体剩余, msg-history消息历史, recall撤回, pin-msg精华, list-files文件列表, pending-requests待处理请求, folder-create创建文件夹。注意:leave/kick/ban/unban/mute-all/set-admin等破坏性操作必须先请示管理员确认后再执行。", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "command": map[string]interface{}{"type": "string", "description": "操作命令"}, - "group_id": map[string]interface{}{"type": "integer", "description": "群号"}, - "user_id": map[string]interface{}{"type": "integer", "description": "QQ号(踢人/禁言/设名片等需要)"}, - "message_id": map[string]interface{}{"type": "integer", "description": "消息ID(撤回/精华)"}, - "name": map[string]interface{}{"type": "string", "description": "群名称(rename)或文件夹名(folder-create)"}, - "card": map[string]interface{}{"type": "string", "description": "群名片(set-card)"}, - "title": map[string]interface{}{"type": "string", "description": "群头衔(set-title)"}, - "enable": map[string]interface{}{"type": "boolean", "description": "启用/禁用(set-admin/mute-all)"}, - "minutes": map[string]interface{}{"type": "integer", "description": "禁言分钟数(ban),0=解禁"}, - "count": map[string]interface{}{"type": "integer", "description": "消息条数(msg-history),默认10"}, - "folder_id": map[string]interface{}{"type": "string", "description": "文件夹ID(list-files)"}, - "reject_add": map[string]interface{}{"type": "boolean", "description": "踢出时拒绝加群(kick)"}, - "confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 leave/kick/ban/unban/rename/mute-all/set-card/set-admin/set-title/recall/pin-msg/folder-create 时必须传 true"}, - }, - }, p.handleGroupManage) - - p.regTool(s, tp+"friend_action", "QQ好友管理:delete删除好友, block拉黑(删好友+从所有群踢出+拒绝加群), approve-friend同意好友请求, reject-friend拒绝好友请求, list-friends列出好友。注意:涉及删除/拉黑的操作必须请示管理员确认后再执行,未经授权不可操作。", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "command": map[string]interface{}{"type": "string", "description": "操作: delete|block|approve-friend|reject-friend|list-friends"}, - "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"}, - "flag": map[string]interface{}{"type": "string", "description": "好友请求flag(approve-friend/reject-friend需要)"}, - "remark": map[string]interface{}{"type": "string", "description": "好友备注(approve-friend可选)"}, - "group_id": map[string]interface{}{"type": "integer", "description": "仅从指定群踢出(block配合)"}, - "confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 delete/block/approve-friend/reject-friend 时必须传 true"}, - }, - }, p.handleFriendAction) - - // ---- 文件 ---- - p.regTool(s, tp+"get_group_files", "查询群文件列表、搜索文件、下载文件到本地。操作: list列出, search搜索, download下载", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "群号"}, - "command": map[string]interface{}{"type": "string", "description": "操作: list|search|download"}, - "folder_id": map[string]interface{}{"type": "string", "description": "文件夹ID(list指定文件夹)"}, - "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(search)"}, - "file_id": map[string]interface{}{"type": "string", "description": "文件ID(download)"}, - "filename": map[string]interface{}{"type": "string", "description": "保存文件名(download可选)"}, - }, - }, p.handleGetGroupFiles) - - p.regTool(s, tp+"upload_group_file", "上传文件到QQ群(通过base64编码发送,同时出现在群消息和群文件柜)", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "目标群号"}, - "file": map[string]interface{}{"type": "string", "description": "本地文件路径"}, - "name": map[string]interface{}{"type": "string", "description": "文件名(可选,默认取原文件名)"}, - }, "required": []string{"group_id", "file"}, - }, p.handleUploadGroupFile) - - // ---- 附加 ---- - p.regTool(s, tp+"send_like", "给QQ好友点赞/戳一戳", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"}, - "times": map[string]interface{}{"type": "integer", "description": "点赞次数1-20,默认1"}, - }, "required": []string{"user_id"}, - }, p.handleSendLike) - - p.regTool(s, tp+"ocr_image", "对QQ图片进行文字识别(调用NapCat OCR / 本地Tesseract)", map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "image": map[string]interface{}{"type": "string", "description": "图片路径(本地路径或URL)"}, - "lang": map[string]interface{}{"type": "string", "description": "语言(chi_sim+eng默认, eng, chi_sim, chi_tra)"}, - }, "required": []string{"image"}, - }, p.handleOcrImage) - - s.RegisterStageOwnTools(sdk.StageBeforeToolcall, p.beforeOwnToolcall) - - // ---- HTTP server for NapCat webhook ---- - mux := http.NewServeMux() - mux.HandleFunc("/", p.handleWebhook) - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(`{"status":"ok"}`)) - }) - p.srv = &http.Server{Addr: p.listenAddr, Handler: mux} - go func() { - log.Printf("[qq] webhook %s napcat=%s", p.listenAddr, p.napcatURL) - if err := p.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Printf("[qq] http: %v", err) - } - }() - - log.Printf("[qq] plugin started: %s (%d tools)", p.name, 14) - return nil -} - -func (p *Plugin) Stop() error { - if p.srv != nil { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - p.srv.Shutdown(ctx) - } - return nil -} - -func (p *Plugin) regTool(s *sdk.PluginSDK, name, desc string, params map[string]interface{}, handler sdk.ToolHandler) { - s.RegisterTool(name, sdk.ToolDef{Name: name, Description: desc, Parameters: params}, handler) -} - -// ======== Bot Identity ======== - -func (p *Plugin) fetchBotInfo() { - resp, err := p.rawNapcat("get_login_info", nil) - if err != nil { - log.Printf("[qq] fetch login info: %v", err) - return - } - var info struct { - Status string `json:"status"` - Data *struct { - UserID int64 `json:"user_id"` - Nickname string `json:"nickname"` - } `json:"data"` - } - if err := json.Unmarshal([]byte(resp), &info); err != nil { - log.Printf("[qq] parse login info: %v", err) - return - } - if info.Data != nil { - p.botID = info.Data.UserID - p.botNickname = info.Data.Nickname - log.Printf("[qq] bot identity: %s (%d)", p.botNickname, p.botID) - } -} - -// rawNapcat sends a request to NapCat and returns raw JSON string. -func (p *Plugin) rawNapcat(action string, params map[string]interface{}) (string, error) { - data, _ := json.Marshal(params) - url := fmt.Sprintf("%s/%s", p.napcatURL, action) - resp, err := http.Post(url, "application/json", bytes.NewReader(data)) - if err != nil { - return "", fmt.Errorf("napcat %s: %w", action, err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - return string(body), nil -} - -// getSetting reads a setting from the SDK; returns fallback if unset or wrong type. -func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T { - v, err := s.Get(key) - if err != nil || v == nil { - return fallback - } - switch any(fallback).(type) { - case string: - if str, ok := v.(string); ok { - return any(str).(T) - } - case int64: - switch val := v.(type) { - case float64: - return any(int64(val)).(T) - case string: - if n, err := strconv.ParseInt(val, 10, 64); err == nil { - return any(n).(T) - } - } - case float64: - switch val := v.(type) { - case float64: - return any(val).(T) - case string: - if n, err := strconv.ParseFloat(val, 64); err == nil { - return any(n).(T) - } - } - } - return fallback -} - -func normalizePolicy(v string) string { - switch strings.ToLower(strings.TrimSpace(v)) { - case "allowlist": - return "allowlist" - case "disabled": - return "disabled" - default: - return "open" - } -} - -func parseIDSet(raw string) map[int64]struct{} { - out := make(map[int64]struct{}) - for _, part := range strings.Split(raw, ",") { - part = strings.TrimSpace(part) - if part == "" { - continue - } - if n, err := strconv.ParseInt(part, 10, 64); err == nil { - out[n] = struct{}{} - } - } - return out -} - -// isAtBot checks if the message contains an @-mention of the bot. -func (p *Plugin) isAtBot(msg interface{}) bool { - segments, ok := msg.([]interface{}) - if !ok { - return false - } - botIDStr := strconv.FormatInt(p.botID, 10) - for _, seg := range segments { - s, ok := seg.(map[string]interface{}) - if !ok { - continue - } - if s["type"] == "at" { - if data, ok := s["data"].(map[string]interface{}); ok { - if qq, ok := data["qq"]; ok { - switch v := qq.(type) { - case string: - if v == botIDStr || v == "all" { - return true - } - case float64: - if int64(v) == p.botID { - return true - } - } - } - } - } - } - return false -} - -// ======== Webhook ======== - -func (p *Plugin) isDMAllowed(userID int64) bool { - switch p.dmPolicy { - case "disabled": - return false - case "allowlist": - _, ok := p.allowFrom[userID] - return ok - default: - return true - } -} - -func (p *Plugin) isGroupAllowed(groupID int64) bool { - switch p.groupPolicy { - case "disabled": - return false - case "allowlist": - _, ok := p.groupAllowFrom[groupID] - return ok - default: - return true - } -} - -func (p *Plugin) beforeOwnToolcall(ctx *sdk.StageContext) error { - ctx.Lock() - defer ctx.Unlock() - if len(ctx.ToolCalls) == 0 { - return nil - } - tc := &ctx.ToolCalls[0] - switch tc.Name { - case p.name + "_send_private_msg", p.name + "_send_group_msg": - if msg, ok := tc.Arguments["message"].(string); ok { - tc.Arguments["message"] = p.sensitiveFilter(msg) - } - case p.name + "_send_file", p.name + "_upload_group_file": - if file, ok := tc.Arguments["file"].(string); ok { - tc.Arguments["file"] = p.sensitiveFilter(file) - } - } - if tc.Name == p.name+"_group_manage" { - cmd, _ := tc.Arguments["command"].(string) - if requiresConfirmGroupCommand(cmd) { - if ok, _ := tc.Arguments["confirm"].(bool); !ok { - msg := fmt.Sprintf("QQ群管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd) - ctx.Response = &msg - return nil - } - } - } - if tc.Name == p.name+"_friend_action" { - cmd, _ := tc.Arguments["command"].(string) - if requiresConfirmFriendCommand(cmd) { - if ok, _ := tc.Arguments["confirm"].(bool); !ok { - msg := fmt.Sprintf("QQ好友管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd) - ctx.Response = &msg - return nil - } - } - } - return nil -} - -func requiresConfirmGroupCommand(cmd string) bool { - switch cmd { - case "leave", "kick", "ban", "unban", "rename", "mute-all", "set-card", "set-admin", "set-title", "recall", "pin-msg", "folder-create": - return true - default: - return false - } -} - -func requiresConfirmFriendCommand(cmd string) bool { - switch cmd { - case "delete", "block", "approve-friend", "reject-friend": - return true - default: - return false - } -} - -func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - http.Error(w, "", http.StatusMethodNotAllowed) - return - } - body, _ := io.ReadAll(r.Body) - var evt struct { - PostType string `json:"post_type"` - MessageType string `json:"message_type,omitempty"` - UserID int64 `json:"user_id,omitempty"` - GroupID int64 `json:"group_id,omitempty"` - RawMessage string `json:"raw_message,omitempty"` - Message interface{} `json:"message,omitempty"` - Time int64 `json:"time"` - Sender *struct { - Nickname string `json:"nickname"` - Card string `json:"card,omitempty"` - } `json:"sender,omitempty"` - } - if json.Unmarshal(body, &evt) != nil || evt.PostType != "message" { - w.WriteHeader(http.StatusOK) - return - } - - text := evt.RawMessage - if text == "" { - if s, ok := evt.Message.(string); ok { - text = s - } - } - if text == "" { - w.WriteHeader(http.StatusOK) - return - } - - if evt.MessageType == "private" { - if !p.isDMAllowed(evt.UserID) { - w.WriteHeader(http.StatusOK) - return - } - } - if evt.MessageType == "group" { - if !p.isGroupAllowed(evt.GroupID) { - w.WriteHeader(http.StatusOK) - return - } - // 群消息必须 @ 机器人才响应 - if p.botID > 0 && !p.isAtBot(evt.Message) { - w.WriteHeader(http.StatusOK) - return - } - } - - nickname := "" - if evt.Sender != nil { - nickname = evt.Sender.Nickname - if evt.Sender.Card != "" { - nickname = evt.Sender.Card - } - } - - p.mu.Lock() - localID := p.nextID - p.nextID++ - - msg := &SavedMessage{ - LocalID: localID, UserID: evt.UserID, Nickname: nickname, - GroupID: evt.GroupID, MessageType: evt.MessageType, Text: text, Time: evt.Time, - } - groupName := "" - if evt.MessageType == "group" { - if n, ok := p.groupNameCache[evt.GroupID]; ok { - groupName = n - } else { - groupName = fmt.Sprintf("%d", evt.GroupID) - } - msg.GroupName = groupName - } - p.messages = append(p.messages, msg) - if len(p.messages) > maxMessages { - p.messages = p.messages[1:] - } - - tp := p.name + "_" - var interrupt string - if evt.MessageType == "group" { - interrupt = fmt.Sprintf("来自%s的(%s)群聊消息,通过id%d使用%sget_message工具获取消息正文。获取后必须使用%ssend_group_msg工具回复该群聊", nickname, groupName, localID, tp, tp) - } else { - interrupt = fmt.Sprintf("来自%s的私聊消息,通过id%d使用%sget_message工具获取消息正文。获取后必须使用%ssend_private_msg工具回复对方", nickname, localID, tp, tp) - } - if p.adminID > 0 && evt.UserID == p.adminID { - interrupt = "【重要!老大消息】" + interrupt - } - p.mu.Unlock() - - if p.sdk != nil { - p.sdk.InjectInterruptText(p.name, p.name, interrupt) - } - w.WriteHeader(http.StatusOK) -} - -// ======== Tool Handlers ======== - -func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, error) { - id, err := convInt64(args["local_id"]) - if err != nil { - return map[string]interface{}{ - "content": fmt.Sprintf("无效的 local_id 参数,请传入整数类型的消息ID"), - "error": err.Error(), - }, nil - } - p.mu.RLock() - defer p.mu.RUnlock() - for _, m := range p.messages { - if m.LocalID == id { - return m, nil - } - } - return map[string]interface{}{ - "content": fmt.Sprintf("消息 %d 未找到。可能的原因:消息已被处理过期,或插件重启后本地缓存已清空。请使用 qq_get_history 工具从 NapCat 拉取历史消息。", id), - "local_id": id, - "not_found": true, - }, nil -} - -func (p *Plugin) handleSendPrivate(args map[string]interface{}) (interface{}, error) { - uid, _ := convInt64(args["user_id"]) - msg := p.sensitiveFilter(args["message"].(string)) - return p.napcat("send_private_msg", map[string]interface{}{"user_id": uid, "message": msg}) -} - -func (p *Plugin) handleSendGroup(args map[string]interface{}) (interface{}, error) { - gid, _ := convInt64(args["group_id"]) - msg := p.sensitiveFilter(args["message"].(string)) - return p.napcat("send_group_msg", map[string]interface{}{"group_id": gid, "message": msg}) -} - -func (p *Plugin) handleSendFile(args map[string]interface{}) (interface{}, error) { - gid, gerr := convInt64(args["group_id"]) - uid, uerr := convInt64(args["user_id"]) - if gerr != nil && uerr != nil { - return nil, fmt.Errorf("need group_id or user_id") - } - filePath, _ := args["file"].(string) - if filePath == "" { - return nil, fmt.Errorf("need file path") - } - name, _ := args["name"].(string) - if name == "" { - name = filepath.Base(filePath) - } - name = p.sensitiveFilter(name) - asImage, _ := args["as_image"].(bool) - - // copy to remote dir for NapCat container access - dest := filepath.Join(p.remoteDir, name) - srcData, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("read file: %w", err) - } - if err := os.WriteFile(dest, srcData, 0644); err != nil { - return nil, fmt.Errorf("write remote: %w", err) - } - - uri := fmt.Sprintf("file:///app/files/%s", name) - var cqMsg string - if asImage { - cqMsg = fmt.Sprintf("[CQ:image,file=%s]", uri) - } else { - cqMsg = fmt.Sprintf("[CQ:file,file=%s,title=%s]", uri, name) - } - - params := map[string]interface{}{"message": cqMsg} - if gerr == nil { - params["group_id"] = gid - return p.napcat("send_group_msg", params) - } - params["user_id"] = uid - return p.napcat("send_private_msg", params) -} - -func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, error) { - gid, gerr := convInt64(args["group_id"]) - uid, uerr := convInt64(args["user_id"]) - count := 10 - if c, err := convInt64(args["count"]); err == nil && c > 0 { - count = int(c) - } - - var endpoint string - var params map[string]interface{} - if gerr == nil { - endpoint = "get_group_msg_history" - params = map[string]interface{}{"group_id": gid, "count": count} - } else if uerr == nil { - endpoint = "get_friend_msg_history" - params = map[string]interface{}{"user_id": uid, "count": count} - } else { - return nil, fmt.Errorf("need group_id or user_id") - } - - data, err := p.napcat(endpoint, params) - if err != nil { - return nil, err - } - return data, nil -} - -func (p *Plugin) handleGetGroups(args map[string]interface{}) (interface{}, error) { - return p.napcat("get_group_list", map[string]interface{}{}) -} - -func (p *Plugin) handleGetFriends(args map[string]interface{}) (interface{}, error) { - return p.napcat("get_friend_list", map[string]interface{}{}) -} - -func (p *Plugin) handleResolveName(args map[string]interface{}) (interface{}, error) { - if uid, err := convInt64(args["user_id"]); err == nil { - return p.napcat("get_stranger_info", map[string]interface{}{"user_id": uid, "no_cache": true}) - } - if gid, err := convInt64(args["group_id"]); err == nil { - return p.napcat("get_group_info", map[string]interface{}{"group_id": gid, "no_cache": true}) - } - return nil, fmt.Errorf("need user_id or group_id") -} - -func (p *Plugin) handleGetGroupMemberInfo(args map[string]interface{}) (interface{}, error) { - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - return p.napcat("get_group_member_info", map[string]interface{}{"group_id": gid, "user_id": uid}) -} - -func (p *Plugin) handleGroupManage(args map[string]interface{}) (interface{}, error) { - cmd, _ := args["command"].(string) - if cmd == "" { - return nil, fmt.Errorf("need command") - } - if requiresConfirmGroupCommand(cmd) { - if ok, _ := args["confirm"].(bool); !ok { - return map[string]interface{}{"isError": true, "content": fmt.Sprintf("高风险操作 %s 需要 confirm=true", cmd)}, nil - } - } - - switch cmd { - case "group-list": - return p.napcat("get_group_list", map[string]interface{}{}) - case "group-info", "member-list", "member-info", "at-all-remain", "msg-history": - gid, _ := convInt64(args["group_id"]) - if cmd == "msg-history" { - count := 10 - if c, err := convInt64(args["count"]); err == nil && c > 0 { - count = int(c) - } - return p.napcat("get_group_msg_history", map[string]interface{}{"group_id": gid, "count": count}) - } - if cmd == "member-info" { - uid, _ := convInt64(args["user_id"]) - return p.napcat("get_group_member_info", map[string]interface{}{"group_id": gid, "user_id": uid}) - } - if cmd == "at-all-remain" { - return p.napcat("get_group_at_all_remain", map[string]interface{}{"group_id": gid}) - } - if cmd == "group-info" { - return p.napcat("get_group_info", map[string]interface{}{"group_id": gid}) - } - return p.napcat("get_group_member_list", map[string]interface{}{"group_id": gid}) - - case "list-files": - gid, _ := convInt64(args["group_id"]) - folderID, _ := args["folder_id"].(string) - if folderID != "" { - return p.napcat("get_group_files_by_folder", map[string]interface{}{"group_id": gid, "folder_id": folderID}) - } - return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid}) - - case "pending-requests": - return p.napcat("get_group_system_msg", map[string]interface{}{}) - - case "leave": - gid, _ := convInt64(args["group_id"]) - return p.napcat("set_group_leave", map[string]interface{}{"group_id": gid}) - - case "kick": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - reject, _ := args["reject_add"].(bool) - return p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": reject}) - - case "ban": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - minutes := 10 - if m, err := convInt64(args["minutes"]); err == nil { - minutes = int(m) - } - return p.napcat("set_group_ban", map[string]interface{}{"group_id": gid, "user_id": uid, "duration": minutes * 60}) - - case "unban": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - return p.napcat("set_group_ban", map[string]interface{}{"group_id": gid, "user_id": uid, "duration": 0}) - - case "rename": - gid, _ := convInt64(args["group_id"]) - name, _ := args["name"].(string) - return p.napcat("set_group_name", map[string]interface{}{"group_id": gid, "group_name": name}) - - case "mute-all": - gid, _ := convInt64(args["group_id"]) - enable, _ := args["enable"].(bool) - return p.napcat("set_group_whole_ban", map[string]interface{}{"group_id": gid, "enable": enable}) - - case "set-card": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - card, _ := args["card"].(string) - return p.napcat("set_group_card", map[string]interface{}{"group_id": gid, "user_id": uid, "card": card}) - - case "set-admin": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - enable, _ := args["enable"].(bool) - return p.napcat("set_group_admin", map[string]interface{}{"group_id": gid, "user_id": uid, "enable": enable}) - - case "set-title": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - title, _ := args["title"].(string) - return p.napcat("set_group_special_title", map[string]interface{}{"group_id": gid, "user_id": uid, "special_title": title}) - - case "recall": - mid, _ := convInt64(args["message_id"]) - return p.napcat("delete_msg", map[string]interface{}{"message_id": mid}) - - case "pin-msg": - mid, _ := convInt64(args["message_id"]) - return p.napcat("set_essence_msg", map[string]interface{}{"message_id": mid}) - - case "folder-create": - gid, _ := convInt64(args["group_id"]) - name, _ := args["name"].(string) - return p.napcat("create_group_file_folder", map[string]interface{}{"group_id": gid, "name": name}) - - default: - return nil, fmt.Errorf("unknown group_manage command: %s", cmd) - } -} - -func (p *Plugin) handleFriendAction(args map[string]interface{}) (interface{}, error) { - cmd, _ := args["command"].(string) - if requiresConfirmFriendCommand(cmd) { - if ok, _ := args["confirm"].(bool); !ok { - return map[string]interface{}{"isError": true, "content": fmt.Sprintf("高风险操作 %s 需要 confirm=true", cmd)}, nil - } - } - switch cmd { - case "list-friends": - return p.napcat("get_friend_list", map[string]interface{}{}) - case "delete": - uid, _ := convInt64(args["user_id"]) - return p.napcat("delete_friend", map[string]interface{}{"user_id": uid}) - case "block": - uid, _ := convInt64(args["user_id"]) - // delete friend - p.napcat("delete_friend", map[string]interface{}{"user_id": uid}) - // kick from groups - if gid, err := convInt64(args["group_id"]); err == nil { - p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": true}) - } else { - grps, _ := p.napcat("get_group_list", map[string]interface{}{}) - if list, ok := grps.([]interface{}); ok { - for _, g := range list { - if m, ok := g.(map[string]interface{}); ok { - if gid, ok := m["group_id"].(float64); ok { - p.napcat("set_group_kick", map[string]interface{}{"group_id": int64(gid), "user_id": uid, "reject_add_request": true}) - } - } - } - } - } - return `{"status":"ok","message":"blocked"}`, nil - case "approve-friend": - flag, _ := args["flag"].(string) - remark, _ := args["remark"].(string) - return p.napcat("set_friend_add_request", map[string]interface{}{"flag": flag, "approve": true, "remark": remark}) - case "reject-friend": - flag, _ := args["flag"].(string) - return p.napcat("set_friend_add_request", map[string]interface{}{"flag": flag, "approve": false}) - default: - return nil, fmt.Errorf("unknown friend_action command: %s", cmd) - } -} - -func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{}, error) { - gid, _ := convInt64(args["group_id"]) - cmd, _ := args["command"].(string) - - switch cmd { - case "list": - folderID, _ := args["folder_id"].(string) - if folderID != "" { - return p.napcat("get_group_files_by_folder", map[string]interface{}{"group_id": gid, "folder_id": folderID}) - } - return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid}) - - case "search": - return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid}) - - case "download": - fileID, _ := args["file_id"].(string) - filename, _ := args["filename"].(string) - if filename == "" { - filename = fmt.Sprintf("group_file_%s", fileID) - } - // get download URL - resp, err := p.napcat("get_group_file_url", map[string]interface{}{"group_id": gid, "file_id": fileID}) - if err != nil { - return nil, err - } - respStr, ok := resp.(string) - if !ok { - return resp, nil - } - // parse URL from response - var parsed struct { - Data struct { - URL string `json:"url"` - } `json:"data"` - } - if err := json.Unmarshal([]byte(respStr), &parsed); err != nil || parsed.Data.URL == "" { - return resp, nil - } - dlURL := parsed.Data.URL - httpResp, err := http.Get(dlURL) - if err != nil { - return nil, fmt.Errorf("download: %w", err) - } - defer httpResp.Body.Close() - content, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, fmt.Errorf("read download: %w", err) - } - os.MkdirAll(p.filesDir, 0755) - savePath := filepath.Join(p.filesDir, filename) - if err := os.WriteFile(savePath, content, 0644); err != nil { - return nil, fmt.Errorf("save: %w", err) - } - return map[string]interface{}{ - "status": "ok", "path": savePath, "filename": filename, "size": len(content), - }, nil - - default: - return nil, fmt.Errorf("unknown get_group_files command: %s", cmd) - } -} - -func (p *Plugin) handleUploadGroupFile(args map[string]interface{}) (interface{}, error) { - gid, _ := convInt64(args["group_id"]) - filePath, _ := args["file"].(string) - name, _ := args["name"].(string) - if name == "" { - name = filepath.Base(filePath) - } - name = p.sensitiveFilter(name) - - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("read: %w", err) - } - b64 := fmt.Sprintf("base64://%s", string(data)) - - resp, err := p.napcat("send_group_msg", map[string]interface{}{ - "group_id": gid, - "message": []map[string]interface{}{ - {"type": "file", "data": map[string]interface{}{"file": b64, "name": name}}, - }, - }) - if err != nil { - return nil, err - } - return map[string]interface{}{"status": "ok", "file": name, "napcat": resp}, nil -} - -func (p *Plugin) handleSendLike(args map[string]interface{}) (interface{}, error) { - uid, _ := convInt64(args["user_id"]) - times := 1 - if t, err := convInt64(args["times"]); err == nil && t > 0 && t <= 20 { - times = int(t) - } - return p.napcat("send_like", map[string]interface{}{"user_id": uid, "times": times}) -} - -func (p *Plugin) handleOcrImage(args map[string]interface{}) (interface{}, error) { - image, _ := args["image"].(string) - lang, _ := args["lang"].(string) - if lang == "" { - lang = "chi_sim+eng" - } - - // If local file, copy to remote dir for NapCat - if !strings.HasPrefix(image, "http://") && !strings.HasPrefix(image, "https://") { - dest := filepath.Join(p.remoteDir, filepath.Base(image)) - src, err := os.ReadFile(image) - if err == nil { - os.WriteFile(dest, src, 0644) - image = fmt.Sprintf("file:///app/files/%s", filepath.Base(image)) - } - } - - return p.napcat("ocr_image", map[string]interface{}{"image": image}) -} - -// ======== NapCat HTTP Client ======== - -func (p *Plugin) napcat(action string, params map[string]interface{}) (interface{}, error) { - data, _ := json.Marshal(params) - url := fmt.Sprintf("%s/%s", p.napcatURL, action) - - resp, err := http.Post(url, "application/json", bytes.NewReader(data)) - if err != nil { - return nil, fmt.Errorf("napcat %s: %w", action, err) - } - defer resp.Body.Close() - - var raw json.RawMessage - if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { - return nil, fmt.Errorf("napcat decode %s: %w", action, err) - } - return string(raw), nil -} - -// ======== Helpers ======== - -func main() {} - -var reAPIKey = regexp.MustCompile(`(?i)(api[_-]?key|token|secret|password)\s*[=:]\s*\S+`) -var reSKKey = regexp.MustCompile(`sk-[a-zA-Z0-9]{20,}`) -var reInternalIP = regexp.MustCompile(`\b(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b`) - -func (p *Plugin) sensitiveFilter(text string) string { - if p.remoteDir != "" { - text = strings.ReplaceAll(text, p.remoteDir, "[remote]") - } - if p.filesDir != "" { - text = strings.ReplaceAll(text, p.filesDir, "[files]") - } - - text = reAPIKey.ReplaceAllString(text, "$1=***") - text = reSKKey.ReplaceAllString(text, "sk-***") - text = reInternalIP.ReplaceAllString(text, "[IP]") - return text -} - -func convInt64(v interface{}) (int64, error) { - switch n := v.(type) { - case int64: - return n, nil - case float64: - return int64(n), nil - case int: - return int64(n), nil - case json.Number: - return n.Int64() - case string: - return strconv.ParseInt(n, 10, 64) - } - return 0, fmt.Errorf("cannot convert %T to int64", v) -} diff --git a/third_party/homeagent-sdk/example/qq/plugin.json b/third_party/homeagent-sdk/example/qq/plugin.json deleted file mode 100644 index f168ea3..0000000 --- a/third_party/homeagent-sdk/example/qq/plugin.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "qq", - "version": "1.0.0", - "description": "QQ 集成插件,对接 NapCat OneBot 框架,支持消息收发、群管理、好友管理等", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": ["qq", "napcat", "onebot", "messaging"] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/web/README.md b/third_party/homeagent-sdk/example/web/README.md deleted file mode 100644 index c93d3b2..0000000 --- a/third_party/homeagent-sdk/example/web/README.md +++ /dev/null @@ -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 查找 `:` | - -## 注意事项 - -- DuckDuckGo HTML 格式可能随网站更新变化,如果搜索结果解析失败需调整 `parseSearchResults` 中的 HTML 标记匹配 -- SSRF 防护默认阻止内网请求,如需访问内网资源需修改 `isInternalIP` 逻辑 -- 搜索结果依赖 DuckDuckGo 可用性,在国内使用建议配置代理 diff --git a/third_party/homeagent-sdk/example/web/plugin.go b/third_party/homeagent-sdk/example/web/plugin.go deleted file mode 100644 index 4e31b73..0000000 --- a/third_party/homeagent-sdk/example/web/plugin.go +++ /dev/null @@ -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://:。为空则不使用代理", - 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:
...
- bodyMarker := `result__body"` - for i := 0; i < len(html); i++ { - idx := strings.Index(html[i:], bodyMarker) - if idx < 0 { - break - } - i += idx - - // Find closing - closeIdx := findClosingTag(html, i, "") - 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:], `= 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:
TITLE - 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:], ``) - if aEnd >= 0 { - r.Title = stripTags(block[titleStart : titleStart+aEnd]) - } - } - } - - // Extract snippet: ... - snippetMarkers := []string{ - `= 0 { - aStart := strings.Index(block[sIdx:], `>`) - if aStart >= 0 { - snipStart := sIdx + aStart + 1 - snipEnd := strings.Index(block[snipStart:], ``) - if snipEnd < 0 { - snipEnd = strings.Index(block[snipStart:], ``) - } - 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), "") - if end < 0 { - break - } - html = html[:start] + html[start+end+9:] - } - - // Remove styles - for { - start := strings.Index(strings.ToLower(html), "") - if end < 0 { - break - } - html = html[:start] + html[start+end+8:] - } - - // Replace block-level tags with newlines - for _, tag := range []string{"

", "", "", "", "", "", "", "", "", "", "", ""} { - 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 -} diff --git a/third_party/homeagent-sdk/example/web/plugin.json b/third_party/homeagent-sdk/example/web/plugin.json deleted file mode 100644 index cc32bf3..0000000 --- a/third_party/homeagent-sdk/example/web/plugin.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "web", - "version": "1.0.0", - "description": "网络工具插件,提供网页搜索和内容抓取功能", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": ["web", "search", "http"] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/go.mod b/third_party/homeagent-sdk/go.mod deleted file mode 100644 index a31a79e..0000000 --- a/third_party/homeagent-sdk/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module gitcode.com/JianFeeeee/homeagent-sdk - -go 1.21 diff --git a/third_party/homeagent-sdk/hack/plugin-dev/packager.sh b/third_party/homeagent-sdk/hack/plugin-dev/packager.sh deleted file mode 100755 index 5b9b627..0000000 --- a/third_party/homeagent-sdk/hack/plugin-dev/packager.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# HomeAgent 插件打包工具 -# 将插件目录打包为 .hmap 分发包 -# 用法: ./packager.sh [输出路径] -# 示例: ./packager.sh ./plugins/myplugin ./dist/myplugin-1.0.0.hmap - -PLUGIN_DIR="${1:-}" -OUTPUT="${2:-}" - -if [ -z "$PLUGIN_DIR" ]; then - echo "用法: $0 [输出路径]" - 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" diff --git a/third_party/homeagent-sdk/hack/plugin-dev/scaffold.sh b/third_party/homeagent-sdk/hack/plugin-dev/scaffold.sh deleted file mode 100755 index 83f6d77..0000000 --- a/third_party/homeagent-sdk/hack/plugin-dev/scaffold.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# HomeAgent 插件脚手架生成工具 -# 用法: ./scaffold.sh [输出目录] -# 示例: ./scaffold.sh myplugin ./plugins/myplugin - -NAME="${1:-}" -OUTDIR="${2:-./plugins/$NAME}" - -if [ -z "$NAME" ]; then - echo "用法: $0 [输出目录]" - 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 工具安装" diff --git a/third_party/homeagent-sdk/hack/plugin-dev/templates/Makefile.tmpl b/third_party/homeagent-sdk/hack/plugin-dev/templates/Makefile.tmpl deleted file mode 100644 index f0b857e..0000000 --- a/third_party/homeagent-sdk/hack/plugin-dev/templates/Makefile.tmpl +++ /dev/null @@ -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 diff --git a/third_party/homeagent-sdk/hack/plugin-dev/templates/gitignore.tmpl b/third_party/homeagent-sdk/hack/plugin-dev/templates/gitignore.tmpl deleted file mode 100644 index 842ab43..0000000 --- a/third_party/homeagent-sdk/hack/plugin-dev/templates/gitignore.tmpl +++ /dev/null @@ -1,2 +0,0 @@ -plugin.so -*.hmap diff --git a/third_party/homeagent-sdk/hack/plugin-dev/templates/plugin.go.tmpl b/third_party/homeagent-sdk/hack/plugin-dev/templates/plugin.go.tmpl deleted file mode 100644 index a7d1378..0000000 --- a/third_party/homeagent-sdk/hack/plugin-dev/templates/plugin.go.tmpl +++ /dev/null @@ -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() {} diff --git a/third_party/homeagent-sdk/hack/plugin-dev/templates/plugin.json.tmpl b/third_party/homeagent-sdk/hack/plugin-dev/templates/plugin.json.tmpl deleted file mode 100644 index de06aed..0000000 --- a/third_party/homeagent-sdk/hack/plugin-dev/templates/plugin.json.tmpl +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "{{.Name}}", - "version": "{{.Version}}", - "description": "{{.Description}}", - "author": "{{.Author}}", - "license": "MIT", - "entry": "plugin.so", - "min_version": "1.0.0", - "tags": ["{{.Name}}"] -} diff --git a/third_party/homeagent-sdk/hack/plugin-dev/testharness/harness.go b/third_party/homeagent-sdk/hack/plugin-dev/testharness/harness.go deleted file mode 100644 index c06dd81..0000000 --- a/third_party/homeagent-sdk/hack/plugin-dev/testharness/harness.go +++ /dev/null @@ -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 } diff --git a/third_party/homeagent-sdk/sdk/API.md b/third_party/homeagent-sdk/sdk/API.md deleted file mode 100644 index e386207..0000000 --- a/third_party/homeagent-sdk/sdk/API.md +++ /dev/null @@ -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_` SQLite 表。 - -```go -type SettingsAPI interface { - // 自身配置(config_ 表) - 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_ 表) - 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/` | `/plugins//` | -| 编译 | 编译进内核 | 独立 `go build -buildmode=plugin` | -| SDK 导入 | `gitcode.com/JianFeeeee/HomeAgent/internal/sdk` | `gitcode.com/JianFeeeee/homeagent-sdk/sdk` | -| 热加载 | 需重新编译 | 可运行时加载/卸载 | diff --git a/third_party/homeagent-sdk/sdk/knowledge.go b/third_party/homeagent-sdk/sdk/knowledge.go deleted file mode 100644 index 4c9d5d7..0000000 --- a/third_party/homeagent-sdk/sdk/knowledge.go +++ /dev/null @@ -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"` -} diff --git a/third_party/homeagent-sdk/sdk/llm.go b/third_party/homeagent-sdk/sdk/llm.go deleted file mode 100644 index b9da86a..0000000 --- a/third_party/homeagent-sdk/sdk/llm.go +++ /dev/null @@ -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 -} diff --git a/third_party/homeagent-sdk/sdk/memory.go b/third_party/homeagent-sdk/sdk/memory.go deleted file mode 100644 index bb72b94..0000000 --- a/third_party/homeagent-sdk/sdk/memory.go +++ /dev/null @@ -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"` -} diff --git a/third_party/homeagent-sdk/sdk/plugin.go b/third_party/homeagent-sdk/sdk/plugin.go deleted file mode 100644 index 43ceeb4..0000000 --- a/third_party/homeagent-sdk/sdk/plugin.go +++ /dev/null @@ -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) - } -} diff --git a/third_party/homeagent-sdk/sdk/settings.go b/third_party/homeagent-sdk/sdk/settings.go deleted file mode 100644 index 61bfdc6..0000000 --- a/third_party/homeagent-sdk/sdk/settings.go +++ /dev/null @@ -1,58 +0,0 @@ -package sdk - -type SettingsAPI interface { - // Get reads the plugin's own config value (config_ 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"` -} diff --git a/tools/plugin-dev/packager.sh b/tools/plugin-dev/packager.sh deleted file mode 100755 index 5b9b627..0000000 --- a/tools/plugin-dev/packager.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# HomeAgent 插件打包工具 -# 将插件目录打包为 .hmap 分发包 -# 用法: ./packager.sh [输出路径] -# 示例: ./packager.sh ./plugins/myplugin ./dist/myplugin-1.0.0.hmap - -PLUGIN_DIR="${1:-}" -OUTPUT="${2:-}" - -if [ -z "$PLUGIN_DIR" ]; then - echo "用法: $0 [输出路径]" - 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" diff --git a/tools/plugin-dev/scaffold.sh b/tools/plugin-dev/scaffold.sh deleted file mode 100755 index 83f6d77..0000000 --- a/tools/plugin-dev/scaffold.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# HomeAgent 插件脚手架生成工具 -# 用法: ./scaffold.sh [输出目录] -# 示例: ./scaffold.sh myplugin ./plugins/myplugin - -NAME="${1:-}" -OUTDIR="${2:-./plugins/$NAME}" - -if [ -z "$NAME" ]; then - echo "用法: $0 [输出目录]" - 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 工具安装" diff --git a/tools/plugin-dev/templates/Makefile.tmpl b/tools/plugin-dev/templates/Makefile.tmpl deleted file mode 100644 index 60fa440..0000000 --- a/tools/plugin-dev/templates/Makefile.tmpl +++ /dev/null @@ -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 diff --git a/tools/plugin-dev/templates/gitignore.tmpl b/tools/plugin-dev/templates/gitignore.tmpl deleted file mode 100644 index 842ab43..0000000 --- a/tools/plugin-dev/templates/gitignore.tmpl +++ /dev/null @@ -1,2 +0,0 @@ -plugin.so -*.hmap diff --git a/tools/plugin-dev/templates/plugin.go.tmpl b/tools/plugin-dev/templates/plugin.go.tmpl deleted file mode 100644 index 83a2dfb..0000000 --- a/tools/plugin-dev/templates/plugin.go.tmpl +++ /dev/null @@ -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() {} diff --git a/tools/plugin-dev/templates/plugin.json.tmpl b/tools/plugin-dev/templates/plugin.json.tmpl deleted file mode 100644 index de06aed..0000000 --- a/tools/plugin-dev/templates/plugin.json.tmpl +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "{{.Name}}", - "version": "{{.Version}}", - "description": "{{.Description}}", - "author": "{{.Author}}", - "license": "MIT", - "entry": "plugin.so", - "min_version": "1.0.0", - "tags": ["{{.Name}}"] -} diff --git a/tools/plugin-dev/testharness/harness.go b/tools/plugin-dev/testharness/harness.go deleted file mode 100644 index cb9bb42..0000000 --- a/tools/plugin-dev/testharness/harness.go +++ /dev/null @@ -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 } diff --git a/修复计划.md b/修复计划.md new file mode 100644 index 0000000..3f4c3ce --- /dev/null +++ b/修复计划.md @@ -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/` 已有其外部插件版本。 diff --git a/迭代文档.md b/迭代文档.md new file mode 100644 index 0000000..bd99b7f --- /dev/null +++ b/迭代文档.md @@ -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": "", "file_id": "xxx"}` + +**homeagent SDK(中优先级)** +- `internal/sdk/plugin.go` 增加 `LogError(plugin, action, err, context)` 方法 +- 统一插件错误日志格式:`[plugin] : | ` + +--- + +### 3. QQ 插件 — `qq_web_fetch` 内容不完整 + +#### 现象 +工具仅返回页面 `` 内容(`标题: 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` | 全部 |