mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-21 17:38:03 +00:00
docs: 修正全部文档使其与源码实现一致
- Plugin.Start(sdk *PluginSDK) 接口签名改为指针 - 方法表重写: 移除 CallLLM/QueryKnowledge/SetMemory 等不存在方法 - IOInjector 参数顺序修正为 (source, channel, text) - 删除虚构 SDKConfig, 替换为实际 New() 构造函数签名 - .hmap 内容描述一致化 (plugin.so + plugin.dll + main.lua) - 添加 meta/ 包元数据文件
This commit is contained in:
7
example/a2a/go.mod
Normal file
7
example/a2a/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module a2a
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||
11
example/a2a/plg.json
Normal file
11
example/a2a/plg.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "a2a",
|
||||
"name_zh": "A2A 代理通信",
|
||||
"name_en": "A2A Agent Communication",
|
||||
"version": "1.0.0",
|
||||
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["a2a", "agent", "interop"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
370
example/a2a/plugin.go
Normal file
370
example/a2a/plugin.go
Normal file
@ -0,0 +1,370 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
tp := p.name + "_"
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "plugin." + p.name + ".listen", Default: "127.0.0.1:12000",
|
||||
Type: "string", DisplayName: "监听地址",
|
||||
Description: "A2A 服务端监听地址,设为空可禁用 HTTP 服务",
|
||||
Category: p.name,
|
||||
})
|
||||
|
||||
// Outbound: query + discover
|
||||
s.RegisterTool(tp+"a2a_query", sdk.ToolDef{
|
||||
Name: tp + "a2a_query", Description: "向另一个 A2A Agent 发送查询并获取回复",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
||||
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
|
||||
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
|
||||
},
|
||||
"required": []string{"agent_url", "query"},
|
||||
},
|
||||
}, p.handleA2AQuery)
|
||||
|
||||
s.RegisterTool(tp+"a2a_discover", sdk.ToolDef{
|
||||
Name: tp + "a2a_discover", Description: "获取另一个 A2A Agent 的能力描述(Agent Card)",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
||||
},
|
||||
"required": []string{"agent_url"},
|
||||
},
|
||||
}, p.handleA2ADiscover)
|
||||
|
||||
// Inbound HTTP server
|
||||
if addr, _ := s.Settings().Get("plugin." + p.name + ".listen"); addr != nil {
|
||||
if addrStr, ok := addr.(string); ok && addrStr != "" {
|
||||
p.startServer(addrStr)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[%s] started", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
if p.server != nil {
|
||||
p.server.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Inbound HTTP Server ----
|
||||
|
||||
func (p *Plugin) startServer(addr string) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/agent-card", p.handleAgentCard)
|
||||
mux.HandleFunc("/task", p.handleIncomingTask)
|
||||
mux.HandleFunc("/a2a", p.handleIncomingA2A)
|
||||
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Printf("[%s] listen %s: %v", p.name, addr, err)
|
||||
return
|
||||
}
|
||||
|
||||
p.server = &http.Server{Handler: mux}
|
||||
go func() {
|
||||
log.Printf("[%s] A2A server on %s", p.name, listener.Addr())
|
||||
if err := p.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[%s] serve: %v", p.name, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (p *Plugin) handleAgentCard(w http.ResponseWriter, r *http.Request) {
|
||||
card := map[string]interface{}{
|
||||
"name": p.name,
|
||||
"description": "HomeAgent A2A Agent - 支持多工具调用与记忆管理",
|
||||
"url": r.Host,
|
||||
"version": "1.0.0",
|
||||
"capabilities": []map[string]string{
|
||||
{"id": "a2a_query", "name": "查询", "description": "接收并处理文本查询"},
|
||||
{"id": "a2a_stream", "name": "流式响应", "description": "支持 SSE 流式回复"},
|
||||
},
|
||||
"skills": []map[string]string{
|
||||
{"id": "chat", "name": "对话", "description": "通用对话与问题回答"},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(card)
|
||||
}
|
||||
|
||||
func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
p.handleAgentCard(w, r)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
Message *struct {
|
||||
Role string `json:"role"`
|
||||
Parts []struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
} `json:"parts"`
|
||||
} `json:"message,omitempty"`
|
||||
} `json:"params,omitempty"`
|
||||
}
|
||||
json.Unmarshal(body, &req)
|
||||
|
||||
switch req.Method {
|
||||
case "tasks.send":
|
||||
// Extract query text
|
||||
queryText := req.Params.Query
|
||||
if queryText == "" && req.Params.Message != nil {
|
||||
for _, part := range req.Params.Message.Parts {
|
||||
if part.Text != "" {
|
||||
queryText += part.Text + "\n"
|
||||
}
|
||||
}
|
||||
queryText = strings.TrimSpace(queryText)
|
||||
}
|
||||
|
||||
// Inject into agent pipeline via interrupt (preempt current processing) or direct input
|
||||
if queryText != "" {
|
||||
p.sdk.InjectInterruptText("a2a", "webui", fmt.Sprintf("[来自A2A Agent的查询]\n%s", queryText))
|
||||
}
|
||||
|
||||
// Respond with task accepted
|
||||
resp := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req.ID,
|
||||
"result": map[string]interface{}{
|
||||
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
|
||||
"status": "submitted",
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
|
||||
case "tasks.get":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"result": map[string]interface{}{"id": req.Params.Query, "status": "unknown"},
|
||||
})
|
||||
|
||||
default:
|
||||
http.Error(w, "unknown method", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) handleIncomingTask(w http.ResponseWriter, r *http.Request) {
|
||||
p.handleIncomingA2A(w, r)
|
||||
}
|
||||
|
||||
// ---- A2A Protocol Types ----
|
||||
|
||||
type A2AAgentCard struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Capabilities []A2ACapability `json:"capabilities,omitempty"`
|
||||
Skills []A2ASkill `json:"skills,omitempty"`
|
||||
}
|
||||
|
||||
type A2ACapability struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type A2ASkill struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InputSchema string `json:"input_schema,omitempty"`
|
||||
}
|
||||
|
||||
type A2ARequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params A2AParams `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type A2AParams struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
Message *A2AMessage `json:"message,omitempty"`
|
||||
TaskID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type A2AResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID string `json:"id"`
|
||||
Result *A2AResult `json:"result,omitempty"`
|
||||
Error *A2AError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type A2AResult struct {
|
||||
TaskID string `json:"id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Message *A2AMessage `json:"message,omitempty"`
|
||||
AgentCard *A2AAgentCard `json:"agent_card,omitempty"`
|
||||
}
|
||||
|
||||
type A2AMessage struct {
|
||||
Role string `json:"role"`
|
||||
Parts []A2APart `json:"parts"`
|
||||
}
|
||||
|
||||
type A2APart struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
type A2AError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ---- Outbound Handlers ----
|
||||
|
||||
func (p *Plugin) handleA2ADiscover(args map[string]interface{}) (interface{}, error) {
|
||||
agentURL, _ := args["agent_url"].(string)
|
||||
agentURL = strings.TrimRight(agentURL, "/")
|
||||
if !strings.HasPrefix(agentURL, "http://") && !strings.HasPrefix(agentURL, "https://") {
|
||||
agentURL = "http://" + agentURL
|
||||
}
|
||||
|
||||
cardURL := agentURL
|
||||
if !strings.HasSuffix(cardURL, "/agent-card") {
|
||||
cardURL = agentURL + "/agent-card"
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Get(cardURL)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("连接失败: %v", err)}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": string(body)}, nil
|
||||
}
|
||||
|
||||
var card A2AAgentCard
|
||||
if err := json.Unmarshal(body, &card); err != nil {
|
||||
var fallback map[string]interface{}
|
||||
if err2 := json.Unmarshal(body, &fallback); err2 == nil {
|
||||
return map[string]interface{}{"agent_info": fallback, "format": "非标准格式"}, nil
|
||||
}
|
||||
return map[string]interface{}{"error": fmt.Sprintf("解析失败: %v", err), "raw_body": string(body)}, nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"name": card.Name, "description": card.Description,
|
||||
"version": card.Version, "url": card.URL,
|
||||
"capabilities": card.Capabilities, "skills": card.Skills,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error) {
|
||||
agentURL, _ := args["agent_url"].(string)
|
||||
query, _ := args["query"].(string)
|
||||
timeoutSec := 60
|
||||
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
||||
timeoutSec = int(v)
|
||||
}
|
||||
|
||||
agentURL = strings.TrimRight(agentURL, "/")
|
||||
if !strings.HasPrefix(agentURL, "http://") && !strings.HasPrefix(agentURL, "https://") {
|
||||
agentURL = "http://" + agentURL
|
||||
}
|
||||
|
||||
taskURL := agentURL
|
||||
if strings.HasSuffix(agentURL, "/agent-card") {
|
||||
taskURL = strings.TrimSuffix(agentURL, "/agent-card")
|
||||
}
|
||||
taskURL = strings.TrimRight(taskURL, "/") + "/task"
|
||||
|
||||
reqBody := A2ARequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: fmt.Sprintf("a2a_%d", time.Now().UnixNano()),
|
||||
Method: "tasks.send",
|
||||
Params: A2AParams{
|
||||
Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}},
|
||||
},
|
||||
}
|
||||
|
||||
bodyData, _ := json.Marshal(reqBody)
|
||||
client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
|
||||
resp, err := client.Post(taskURL, "application/json", bytes.NewReader(bodyData))
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("请求失败(超时%d秒): %v", timeoutSec, err)}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": string(body)}, nil
|
||||
}
|
||||
|
||||
var a2aResp A2AResponse
|
||||
if err := json.Unmarshal(body, &a2aResp); err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("解析响应失败: %v", err), "raw_body": string(body)}, nil
|
||||
}
|
||||
|
||||
if a2aResp.Error != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("Agent错误 [%d]: %s", a2aResp.Error.Code, a2aResp.Error.Message)}, nil
|
||||
}
|
||||
if a2aResp.Result == nil {
|
||||
return map[string]interface{}{"error": "空结果", "raw_body": string(body)}, nil
|
||||
}
|
||||
|
||||
var replyText string
|
||||
if a2aResp.Result.Message != nil {
|
||||
for _, part := range a2aResp.Result.Message.Parts {
|
||||
if part.Text != "" {
|
||||
replyText += part.Text + "\n"
|
||||
}
|
||||
}
|
||||
replyText = strings.TrimSpace(replyText)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"task_id": a2aResp.Result.TaskID, "status": a2aResp.Result.Status,
|
||||
"response": replyText,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
7
example/bili/go.mod
Normal file
7
example/bili/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module bili
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||
11
example/bili/plg.json
Normal file
11
example/bili/plg.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "bili",
|
||||
"name_zh": "B站视频下载",
|
||||
"name_en": "Bilibili Video Downloader",
|
||||
"version": "1.1.0",
|
||||
"description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["bili", "video", "download"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
234
example/bili/plugin.go
Normal file
234
example/bili/plugin.go
Normal file
@ -0,0 +1,234 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
tp := p.name + "_"
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "plugin." + p.name + ".output_dir", Default: "/tmp/bili_videos",
|
||||
Type: "string", DisplayName: "下载目录",
|
||||
Description: "B站视频下载后的保存目录",
|
||||
Category: p.name,
|
||||
})
|
||||
|
||||
s.RegisterTool(tp+"video", sdk.ToolDef{
|
||||
Name: tp + "video",
|
||||
Description: "使用 yt-dlp 下载B站视频到本地。支持查看视频信息后再下载。下载后返回文件路径。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{"type": "string", "description": "B站视频分享链接"},
|
||||
"info_only": map[string]interface{}{"type": "boolean", "description": "仅获取视频信息(标题、清晰度列表),不下载"},
|
||||
"format": map[string]interface{}{"type": "string", "description": "视频格式ID(如 30112=高清1080P, 30080=高清1080P, 30064=高清720P, 30032=清晰480P, 30016=流畅360P),不指定则自动选最优"},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}, p.handleBiliVideo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error { return nil }
|
||||
|
||||
type ytdlpFormat struct {
|
||||
FormatID string `json:"format_id"`
|
||||
FormatNote string `json:"format_note"`
|
||||
Ext string `json:"ext"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
TBR float64 `json:"tbr"`
|
||||
Filesize int64 `json:"filesize"`
|
||||
FilesizeApprox int64 `json:"filesize_approx"`
|
||||
VCodec string `json:"vcodec"`
|
||||
ACodec string `json:"acodec"`
|
||||
FPS float64 `json:"fps"`
|
||||
}
|
||||
|
||||
type ytdlpInfo struct {
|
||||
Title string `json:"title"`
|
||||
Duration float64 `json:"duration"`
|
||||
WebpageURL string `json:"webpage_url"`
|
||||
Filename string `json:"_filename"`
|
||||
Formats []ytdlpFormat `json:"formats"`
|
||||
}
|
||||
|
||||
func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, error) {
|
||||
url, _ := args["url"].(string)
|
||||
if url == "" {
|
||||
return nil, fmt.Errorf("url is required")
|
||||
}
|
||||
infoOnly, _ := args["info_only"].(bool)
|
||||
format, _ := args["format"].(string)
|
||||
|
||||
outputDir := "/tmp/bili_videos"
|
||||
if p.sdk != nil {
|
||||
if v, _ := p.sdk.Settings().Get("plugin." + p.name + ".output_dir"); v != nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
outputDir = s
|
||||
}
|
||||
}
|
||||
}
|
||||
os.MkdirAll(outputDir, 0755)
|
||||
|
||||
var out bytes.Buffer
|
||||
ytdlpArgs := []string{"--no-warnings", "--dump-json", url}
|
||||
cmd := exec.Command("yt-dlp", ytdlpArgs...)
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
cmd.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
|
||||
}
|
||||
|
||||
var info ytdlpInfo
|
||||
if err := json.Unmarshal(out.Bytes(), &info); err != nil {
|
||||
return nil, fmt.Errorf("parse yt-dlp output: %w", err)
|
||||
}
|
||||
|
||||
if infoOnly {
|
||||
var filtered []ytdlpFormat
|
||||
for _, f := range info.Formats {
|
||||
if f.VCodec != "none" || f.ACodec != "none" {
|
||||
filtered = append(filtered, f)
|
||||
}
|
||||
}
|
||||
info.Formats = filtered
|
||||
|
||||
lines := []string{fmt.Sprintf("标题: %s", info.Title)}
|
||||
if info.Duration > 0 {
|
||||
lines = append(lines, fmt.Sprintf("时长: %.0f 秒", info.Duration))
|
||||
}
|
||||
|
||||
type fmtLine struct {
|
||||
ID string
|
||||
Note string
|
||||
Res string
|
||||
Ext string
|
||||
Size string
|
||||
}
|
||||
var seen []string
|
||||
var display []fmtLine
|
||||
for _, f := range info.Formats {
|
||||
if f.FormatNote == "" {
|
||||
continue
|
||||
}
|
||||
key := f.FormatNote + f.Ext
|
||||
if contains(seen, key) {
|
||||
continue
|
||||
}
|
||||
seen = append(seen, key)
|
||||
res := ""
|
||||
if f.Width > 0 && f.Height > 0 {
|
||||
res = fmt.Sprintf("%dx%d", f.Width, f.Height)
|
||||
}
|
||||
sz := ""
|
||||
fs := f.Filesize
|
||||
if fs == 0 {
|
||||
fs = f.FilesizeApprox
|
||||
}
|
||||
if fs > 0 {
|
||||
sz = fmt.Sprintf(" (%.1f MB)", float64(fs)/1048576)
|
||||
}
|
||||
display = append(display, fmtLine{ID: f.FormatID, Note: f.FormatNote, Res: res, Ext: f.Ext, Size: sz})
|
||||
}
|
||||
if len(display) > 0 {
|
||||
lines = append(lines, "清晰度列表:")
|
||||
for _, d := range display {
|
||||
r := d.Res
|
||||
if r != "" {
|
||||
r = " " + r
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" [%s] %s%s | %s%s", d.ID, d.Note, r, d.Ext, d.Size))
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
|
||||
}
|
||||
|
||||
dlArgs := []string{
|
||||
"--no-warnings",
|
||||
"--socket-timeout", "30",
|
||||
"--retries", "3",
|
||||
"--fragment-retries", "3",
|
||||
"-o", filepath.Join(outputDir, "%(title)s.%(ext)s"),
|
||||
"--no-overwrites",
|
||||
}
|
||||
if format != "" {
|
||||
dlArgs = append(dlArgs, "-f", format)
|
||||
}
|
||||
dlArgs = append(dlArgs, url)
|
||||
cmd2 := exec.Command("yt-dlp", dlArgs...)
|
||||
cmd2.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890")
|
||||
var dlOut bytes.Buffer
|
||||
cmd2.Stdout = &dlOut
|
||||
cmd2.Stderr = &dlOut
|
||||
if err := cmd2.Run(); err != nil {
|
||||
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(outputDir)
|
||||
var newest string
|
||||
var newestTime int64
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
fi, _ := e.Info()
|
||||
if fi == nil {
|
||||
continue
|
||||
}
|
||||
t := fi.ModTime().Unix()
|
||||
if t > newestTime {
|
||||
newestTime = t
|
||||
newest = e.Name()
|
||||
}
|
||||
}
|
||||
if newest == "" {
|
||||
return map[string]interface{}{
|
||||
"content": "下载完成,但未找到视频文件",
|
||||
}, nil
|
||||
}
|
||||
dlPath := filepath.Join(outputDir, newest)
|
||||
fi, _ := os.Stat(dlPath)
|
||||
var fileSize int64
|
||||
if fi != nil {
|
||||
fileSize = fi.Size()
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", newest, float64(fileSize)/1048576, dlPath),
|
||||
"file": dlPath,
|
||||
"filename": newest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func contains(slice []string, s string) bool {
|
||||
for _, v := range slice {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
101
example/bili/plugin.h
Normal file
101
example/bili/plugin.h
Normal file
@ -0,0 +1,101 @@
|
||||
/* Code generated by cmd/cgo; DO NOT EDIT. */
|
||||
|
||||
/* package bili */
|
||||
|
||||
|
||||
#line 1 "cgo-builtin-export-prolog"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifndef GO_CGO_EXPORT_PROLOGUE_H
|
||||
#define GO_CGO_EXPORT_PROLOGUE_H
|
||||
|
||||
#ifndef GO_CGO_GOSTRING_TYPEDEF
|
||||
typedef struct { const char *p; ptrdiff_t n; } _GoString_;
|
||||
extern size_t _GoStringLen(_GoString_ s);
|
||||
extern const char *_GoStringPtr(_GoString_ s);
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
/* Start of preamble from import "C" comments. */
|
||||
|
||||
|
||||
#line 3 "z_bridge_gen.go"
|
||||
|
||||
#include <stdlib.h>
|
||||
int ha_dispatch(int method_id, void* core_api, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error);
|
||||
|
||||
#line 1 "cgo-generated-wrapper"
|
||||
|
||||
|
||||
/* End of preamble from import "C" comments. */
|
||||
|
||||
|
||||
/* Start of boilerplate cgo prologue. */
|
||||
#line 1 "cgo-gcc-export-header-prolog"
|
||||
|
||||
#ifndef GO_CGO_PROLOGUE_H
|
||||
#define GO_CGO_PROLOGUE_H
|
||||
|
||||
typedef signed char GoInt8;
|
||||
typedef unsigned char GoUint8;
|
||||
typedef short GoInt16;
|
||||
typedef unsigned short GoUint16;
|
||||
typedef int GoInt32;
|
||||
typedef unsigned int GoUint32;
|
||||
typedef long long GoInt64;
|
||||
typedef unsigned long long GoUint64;
|
||||
typedef GoInt64 GoInt;
|
||||
typedef GoUint64 GoUint;
|
||||
typedef size_t GoUintptr;
|
||||
typedef float GoFloat32;
|
||||
typedef double GoFloat64;
|
||||
#ifdef _MSC_VER
|
||||
#if !defined(__cplusplus) || _MSVC_LANG <= 201402L
|
||||
#include <complex.h>
|
||||
typedef _Fcomplex GoComplex64;
|
||||
typedef _Dcomplex GoComplex128;
|
||||
#else
|
||||
#include <complex>
|
||||
typedef std::complex<float> GoComplex64;
|
||||
typedef std::complex<double> GoComplex128;
|
||||
#endif
|
||||
#else
|
||||
typedef float _Complex GoComplex64;
|
||||
typedef double _Complex GoComplex128;
|
||||
#endif
|
||||
|
||||
/*
|
||||
static assertion to make sure the file is being used on architecture
|
||||
at least with matching size of GoInt.
|
||||
*/
|
||||
typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1];
|
||||
|
||||
#ifndef GO_CGO_GOSTRING_TYPEDEF
|
||||
typedef _GoString_ GoString;
|
||||
#endif
|
||||
typedef void *GoMap;
|
||||
typedef void *GoChan;
|
||||
typedef struct { void *t; void *v; } GoInterface;
|
||||
typedef struct { void *data; GoInt len; GoInt cap; } GoSlice;
|
||||
|
||||
#endif
|
||||
|
||||
/* End of boilerplate cgo prologue. */
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
extern int go_init_plugin(char* name, char* configJSON, char** errorOut);
|
||||
extern int go_start_plugin(void* coreAPIptr, int coreVersion, char** errorOut);
|
||||
extern int go_stop_plugin(char** errorOut);
|
||||
extern int go_invoke_tool(char* name, char* argsJSON, char** resultOut, char** errorOut);
|
||||
extern int go_invoke_stage(char* stage, char* ctxJSON, char** errorOut);
|
||||
extern int go_invoke_output(char* channel, char* msgType, char* payloadJSON, char** errorOut);
|
||||
extern void go_free_string(char* ptr);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
7
example/editdoc/go.mod
Normal file
7
example/editdoc/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module editdoc
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||
11
example/editdoc/plg.json
Normal file
11
example/editdoc/plg.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "editdoc",
|
||||
"name_zh": "文档编辑",
|
||||
"name_en": "Document Editor",
|
||||
"version": "1.0.0",
|
||||
"description": "办公文档编辑工具(.docx),基于 Python python-docx 库实现。支持文本替换、表格操作、内容插入等。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["editdoc", "office", "document"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
129
example/editdoc/plugin.go
Normal file
129
example/editdoc/plugin.go
Normal file
@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
s.RegisterTool("edit_document", sdk.ToolDef{
|
||||
Name: "edit_document",
|
||||
Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"file": map[string]interface{}{"type": "string", "description": "文档文件路径"},
|
||||
"operation": map[string]interface{}{"type": "string", "description": "操作: replace_text(查找替换), set_cell(设置单元格), insert_row(插入行)"},
|
||||
"target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text)"},
|
||||
"replacement": map[string]interface{}{"type": "string", "description": "替换为的文本(replace_text)"},
|
||||
"sheet": map[string]interface{}{"type": "string", "description": "工作表名称(xlsx,可选)"},
|
||||
"row": map[string]interface{}{"type": "integer", "description": "行号(set_cell/insert_row)"},
|
||||
"col": map[string]interface{}{"type": "integer", "description": "列号(set_cell)"},
|
||||
"value": map[string]interface{}{"type": "string", "description": "单元格值(set_cell)"},
|
||||
},
|
||||
"required": []string{"file", "operation"},
|
||||
},
|
||||
}, p.handleEditDocument)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error { return nil }
|
||||
|
||||
func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, error) {
|
||||
file, _ := args["file"].(string)
|
||||
if file == "" {
|
||||
return nil, fmt.Errorf("file is required")
|
||||
}
|
||||
operation, _ := args["operation"].(string)
|
||||
if operation == "" {
|
||||
return nil, fmt.Errorf("operation is required")
|
||||
}
|
||||
if _, err := os.Stat(file); os.IsNotExist(err) {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("文件不存在: %s", file),
|
||||
}, nil
|
||||
}
|
||||
|
||||
pyArgs := map[string]interface{}{}
|
||||
if v, ok := args["target"]; ok {
|
||||
pyArgs["target"] = v
|
||||
}
|
||||
if v, ok := args["replacement"]; ok {
|
||||
pyArgs["replacement"] = v
|
||||
}
|
||||
if v, ok := args["sheet"]; ok {
|
||||
pyArgs["sheet"] = v
|
||||
}
|
||||
if v, ok := args["row"]; ok {
|
||||
pyArgs["row"] = v
|
||||
}
|
||||
if v, ok := args["col"]; ok {
|
||||
pyArgs["col"] = v
|
||||
}
|
||||
if v, ok := args["value"]; ok {
|
||||
pyArgs["value"] = v
|
||||
}
|
||||
pyArgsJSON, _ := json.Marshal(pyArgs)
|
||||
|
||||
scriptPath := "/home/newqqagent/plugins/editdoc/edit_doc.py"
|
||||
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("edit_doc.py not found at %s", scriptPath)
|
||||
}
|
||||
|
||||
venvPython := "/home/program/qq-workspace/self-workplace/.venv/bin/python3"
|
||||
pythonBin := "python3"
|
||||
if _, err := os.Stat(venvPython); err == nil {
|
||||
pythonBin = venvPython
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := exec.Command(pythonBin, scriptPath, file, operation, string(pyArgsJSON))
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("edit document: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Ok bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("编辑完成,输出: %s", out.String()),
|
||||
"file": file,
|
||||
}, nil
|
||||
}
|
||||
if !result.Ok {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("编辑失败: %s", result.Error),
|
||||
}, nil
|
||||
}
|
||||
msg := fmt.Sprintf("编辑完成,已保存到原文件: %s", file)
|
||||
if result.Count > 0 {
|
||||
msg += fmt.Sprintf("\n共处理 %d 处", result.Count)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"content": msg,
|
||||
"file": file,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
11
example/files/plg.json
Normal file
11
example/files/plg.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "files",
|
||||
"name_zh": "文件系统",
|
||||
"name_en": "File System",
|
||||
"version": "1.0.0",
|
||||
"description": "文件系统操作工具集(读取/写入/编辑/列表),提供沙箱化文件访问,支持配置工作目录。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["files", "filesystem"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
483
example/files/plugin.go
Normal file
483
example/files/plugin.go
Normal file
@ -0,0 +1,483 @@
|
||||
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 (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
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
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
7
example/memo/go.mod
Normal file
7
example/memo/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module memo
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||
11
example/memo/plg.json
Normal file
11
example/memo/plg.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "memo",
|
||||
"name_zh": "备忘录",
|
||||
"name_en": "Memo/Notes",
|
||||
"version": "1.0.0",
|
||||
"description": "待办事项与备忘录管理插件。支持创建、完成、列表查看。通过阶段钩子在每次对话前注入待办提醒。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["memo", "todo", "notes"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
274
example/memo/plugin.go
Normal file
274
example/memo/plugin.go
Normal file
@ -0,0 +1,274 @@
|
||||
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 (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
7
example/ocr/go.mod
Normal file
7
example/ocr/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module ocr
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||
11
example/ocr/plg.json
Normal file
11
example/ocr/plg.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "ocr",
|
||||
"name_zh": "OCR 文字识别",
|
||||
"name_en": "OCR Text Recognition",
|
||||
"version": "1.0.0",
|
||||
"description": "图片文字识别工具,基于 Tesseract OCR 引擎。支持从 URL 或 base64 图片中提取文字内容。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["ocr", "image", "text"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
134
example/ocr/plugin.go
Normal file
134
example/ocr/plugin.go
Normal file
@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
tp := p.name + "_"
|
||||
|
||||
s.RegisterTool(tp+"ocr_image", sdk.ToolDef{
|
||||
Name: tp + "ocr_image",
|
||||
Description: "对图片进行OCR文字识别,支持中文和英文。可传入图片URL或base64编码。返回识别出的文本内容。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"image_url": map[string]interface{}{"type": "string", "description": "图片的HTTP/HTTPS URL,与 image_data 二选一"},
|
||||
"image_data": map[string]interface{}{"type": "string", "description": "图片的base64编码数据(不含 data:image/... 前缀),与 image_url 二选一"},
|
||||
"language": map[string]interface{}{"type": "string", "description": "识别语言,默认 chi_sim+eng(中文简体+英文),可选 chi_sim / eng / chi_sim+eng"},
|
||||
},
|
||||
},
|
||||
}, p.handleOcrImage)
|
||||
|
||||
log.Printf("[%s] plugin started", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleOcrImage(args map[string]interface{}) (interface{}, error) {
|
||||
imageURL, _ := args["image_url"].(string)
|
||||
imageData, _ := args["image_data"].(string)
|
||||
language, _ := args["language"].(string)
|
||||
|
||||
if imageURL == "" && imageData == "" {
|
||||
return map[string]interface{}{"error": "请提供 image_url 或 image_data"}, nil
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "ocr-*")
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("创建临时目录失败: %v", err)}, nil
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
inputPath := filepath.Join(tmpDir, "input.png")
|
||||
|
||||
if imageData != "" {
|
||||
data := strings.TrimSpace(imageData)
|
||||
if idx := strings.Index(data, "base64,"); idx >= 0 {
|
||||
data = data[idx+7:]
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("base64解码失败: %v", err)}, nil
|
||||
}
|
||||
if err := os.WriteFile(inputPath, decoded, 0644); err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("写入临时文件失败: %v", err)}, nil
|
||||
}
|
||||
} else {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(imageURL)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("下载图片失败: %v", err)}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("下载图片返回状态码 %d", resp.StatusCode)}, nil
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("读取图片数据失败: %v", err)}, nil
|
||||
}
|
||||
if err := os.WriteFile(inputPath, data, 0644); err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("写入临时文件失败: %v", err)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if language == "" {
|
||||
language = "chi_sim+eng"
|
||||
}
|
||||
|
||||
outputPath := filepath.Join(tmpDir, "output")
|
||||
|
||||
argsList := []string{inputPath, outputPath, "-l", language, "--psm", "3"}
|
||||
cmd := exec.Command("tesseract", argsList...)
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("OCR识别失败: %v (stderr: %s)", err, stderr.String())}, nil
|
||||
}
|
||||
|
||||
resultFile := outputPath + ".txt"
|
||||
text, err := os.ReadFile(resultFile)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("读取OCR结果失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
recognized := strings.TrimSpace(string(text))
|
||||
if recognized == "" {
|
||||
return map[string]interface{}{"text": "", "message": "未识别出文字内容"}, nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"text": recognized,
|
||||
"length": len(recognized),
|
||||
"language": language,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
7
example/qq/go.mod
Normal file
7
example/qq/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module qq
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||
11
example/qq/plg.json
Normal file
11
example/qq/plg.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "qq",
|
||||
"name_zh": "QQ消息",
|
||||
"name_en": "qq",
|
||||
"version": "1.0.0",
|
||||
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["qq", "messaging"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
1977
example/qq/plugin.go
Normal file
1977
example/qq/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
1728
example/qq/plugin.go.bak
Normal file
1728
example/qq/plugin.go.bak
Normal file
File diff suppressed because it is too large
Load Diff
101
example/qq/plugin.h
Normal file
101
example/qq/plugin.h
Normal file
@ -0,0 +1,101 @@
|
||||
/* Code generated by cmd/cgo; DO NOT EDIT. */
|
||||
|
||||
/* package qq */
|
||||
|
||||
|
||||
#line 1 "cgo-builtin-export-prolog"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifndef GO_CGO_EXPORT_PROLOGUE_H
|
||||
#define GO_CGO_EXPORT_PROLOGUE_H
|
||||
|
||||
#ifndef GO_CGO_GOSTRING_TYPEDEF
|
||||
typedef struct { const char *p; ptrdiff_t n; } _GoString_;
|
||||
extern size_t _GoStringLen(_GoString_ s);
|
||||
extern const char *_GoStringPtr(_GoString_ s);
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
/* Start of preamble from import "C" comments. */
|
||||
|
||||
|
||||
#line 3 "z_bridge_gen.go"
|
||||
|
||||
#include <stdlib.h>
|
||||
int ha_dispatch(int method_id, void* core_api, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error);
|
||||
|
||||
#line 1 "cgo-generated-wrapper"
|
||||
|
||||
|
||||
/* End of preamble from import "C" comments. */
|
||||
|
||||
|
||||
/* Start of boilerplate cgo prologue. */
|
||||
#line 1 "cgo-gcc-export-header-prolog"
|
||||
|
||||
#ifndef GO_CGO_PROLOGUE_H
|
||||
#define GO_CGO_PROLOGUE_H
|
||||
|
||||
typedef signed char GoInt8;
|
||||
typedef unsigned char GoUint8;
|
||||
typedef short GoInt16;
|
||||
typedef unsigned short GoUint16;
|
||||
typedef int GoInt32;
|
||||
typedef unsigned int GoUint32;
|
||||
typedef long long GoInt64;
|
||||
typedef unsigned long long GoUint64;
|
||||
typedef GoInt64 GoInt;
|
||||
typedef GoUint64 GoUint;
|
||||
typedef size_t GoUintptr;
|
||||
typedef float GoFloat32;
|
||||
typedef double GoFloat64;
|
||||
#ifdef _MSC_VER
|
||||
#if !defined(__cplusplus) || _MSVC_LANG <= 201402L
|
||||
#include <complex.h>
|
||||
typedef _Fcomplex GoComplex64;
|
||||
typedef _Dcomplex GoComplex128;
|
||||
#else
|
||||
#include <complex>
|
||||
typedef std::complex<float> GoComplex64;
|
||||
typedef std::complex<double> GoComplex128;
|
||||
#endif
|
||||
#else
|
||||
typedef float _Complex GoComplex64;
|
||||
typedef double _Complex GoComplex128;
|
||||
#endif
|
||||
|
||||
/*
|
||||
static assertion to make sure the file is being used on architecture
|
||||
at least with matching size of GoInt.
|
||||
*/
|
||||
typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1];
|
||||
|
||||
#ifndef GO_CGO_GOSTRING_TYPEDEF
|
||||
typedef _GoString_ GoString;
|
||||
#endif
|
||||
typedef void *GoMap;
|
||||
typedef void *GoChan;
|
||||
typedef struct { void *t; void *v; } GoInterface;
|
||||
typedef struct { void *data; GoInt len; GoInt cap; } GoSlice;
|
||||
|
||||
#endif
|
||||
|
||||
/* End of boilerplate cgo prologue. */
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
extern int go_init_plugin(char* name, char* configJSON, char** errorOut);
|
||||
extern int go_start_plugin(void* coreAPIptr, int coreVersion, char** errorOut);
|
||||
extern int go_stop_plugin(char** errorOut);
|
||||
extern int go_invoke_tool(char* name, char* argsJSON, char** resultOut, char** errorOut);
|
||||
extern int go_invoke_stage(char* stage, char* ctxJSON, char** errorOut);
|
||||
extern int go_invoke_output(char* channel, char* msgType, char* payloadJSON, char** errorOut);
|
||||
extern void go_free_string(char* ptr);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
7
example/sanitizer/go.mod
Normal file
7
example/sanitizer/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module sanitizer
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../..
|
||||
11
example/sanitizer/plg.json
Normal file
11
example/sanitizer/plg.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "sanitizer",
|
||||
"name_zh": "输出清洗",
|
||||
"name_en": "sanitizer",
|
||||
"version": "0.1.0",
|
||||
"description": "清洗 LLM 输出中的工具调用残留(思维泄漏)",
|
||||
"author": "HomeAgent SDK",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["sanitizer"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
102
example/sanitizer/plugin.go
Normal file
102
example/sanitizer/plugin.go
Normal file
@ -0,0 +1,102 @@
|
||||
// Package main 是一个外部插件示例(编译为 .so 通过 -buildmode=plugin)。
|
||||
// 在 StagePostAction 阶段清洗 LLM 输出中的工具调用残留(思维泄漏)。
|
||||
//
|
||||
// 编译:
|
||||
//
|
||||
// go build -buildmode=plugin -o sanitizer.so .
|
||||
//
|
||||
// 安装到 HomeAgent 插件目录(如 plugins/sanitizer/plugin.so),
|
||||
// HomeAgent 自动通过 tryLoadSO 加载。
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
var (
|
||||
toolCallTagRE = regexp.MustCompile(`(?s)<tool_call[^>]*>.*?</tool_call>`)
|
||||
invokeTagRE = regexp.MustCompile(`(?s)<invoke[^>]*>.*?</invoke>`)
|
||||
toolTagRE = regexp.MustCompile(`(?s)<tool[^>]*>.*?</tool>`)
|
||||
functionTagRE = regexp.MustCompile(`(?s)<function[^>]*>.*?</function>`)
|
||||
toolCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool_call[^>]*>.*?</tool_call>\\s*```")
|
||||
invokeCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<invoke[^>]*>.*?</invoke>\\s*```")
|
||||
toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool[^>]*>.*?</tool>\\s*```")
|
||||
chineseMarkerRE = regexp.MustCompile(`(?s)【tool_call】.*?【/tool_call】`)
|
||||
multiNewlineRE = regexp.MustCompile(`\n{3,}`)
|
||||
toolNameRE = regexp.MustCompile(`^(cmd_run|terminal_create|terminal_write|memory_|knowledge_|doc_|social_|output_send|output_set_channel|llm_|plgreload|spawn_child|child_result|describe_image|transcribe_audio|ocr_image|timer_set|plugin_install|plugin_remove|qq_|a2a_|mcp_|healthcheck|files_|web_)`)
|
||||
)
|
||||
|
||||
type Plugin struct{}
|
||||
|
||||
func (p *Plugin) Name() string { return "sanitizer" }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
s.RegisterStage(sdk.StagePostAction, func(ctx *sdk.StageContext) error {
|
||||
ctx.Lock()
|
||||
before := len(ctx.LLMText)
|
||||
ctx.LLMText = cleanToolCallLeakage(ctx.LLMText)
|
||||
after := len(ctx.LLMText)
|
||||
ctx.Unlock()
|
||||
if before != after {
|
||||
log.Printf("[sanitizer] cleaned %d bytes (before=%d after=%d)", before-after, before, after)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
log.Printf("[sanitizer] stage PostAction registered")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error { return nil }
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{}, nil
|
||||
}
|
||||
|
||||
func cleanToolCallLeakage(content string) string {
|
||||
if content == "" {
|
||||
return content
|
||||
}
|
||||
|
||||
before := len(content)
|
||||
|
||||
content = toolCodeBlockRE.ReplaceAllString(content, "")
|
||||
content = invokeCodeBlockRE.ReplaceAllString(content, "")
|
||||
content = toolCodeBlockRE2.ReplaceAllString(content, "")
|
||||
|
||||
content = toolCallTagRE.ReplaceAllString(content, "")
|
||||
content = invokeTagRE.ReplaceAllString(content, "")
|
||||
content = toolTagRE.ReplaceAllString(content, "")
|
||||
content = functionTagRE.ReplaceAllString(content, "")
|
||||
|
||||
content = chineseMarkerRE.ReplaceAllString(content, "")
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
var cleaned []string
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
cleaned = append(cleaned, line)
|
||||
continue
|
||||
}
|
||||
if toolNameRE.MatchString(trimmed) {
|
||||
if strings.Contains(trimmed, "(") || strings.Contains(trimmed, "\"") || strings.Contains(trimmed, ":") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
cleaned = append(cleaned, line)
|
||||
}
|
||||
content = strings.Join(cleaned, "\n")
|
||||
|
||||
content = multiNewlineRE.ReplaceAllString(content, "\n\n")
|
||||
content = strings.TrimSpace(content)
|
||||
|
||||
if len(content) != before {
|
||||
log.Printf("[sanitizer] cleanToolCallLeakage: %d bytes removed", before-len(content))
|
||||
}
|
||||
return content
|
||||
}
|
||||
31
example/sanitizer/sanitizer_test.go
Normal file
31
example/sanitizer/sanitizer_test.go
Normal file
@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCleanToolCallLeakage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, input, want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"clean", "你好", "你好"},
|
||||
{"tool_call", "a<tool_call>x</tool_call>b", "ab"},
|
||||
{"invoke", "a<invoke>x</invoke>b", "ab"},
|
||||
{"function", "a<function>x</function>b", "ab"},
|
||||
{"xml_block", "a\n```xml\n<tool_call>x</tool_call>\n```\nb", "a\n\nb"},
|
||||
{"json_block", "a\n```json\n<invoke>x</invoke>\n```\nb", "a\n\nb"},
|
||||
{"bare_code", "```python\nprint(1)\n```", "```python\nprint(1)\n```"},
|
||||
{"chinese_marker", "a【tool_call】x【/tool_call】b", "ab"},
|
||||
{"tool_line", "cmd_run(\"ls\")\nok", "ok"},
|
||||
{"prose_kept", "cmd_run 是一个工具", "cmd_run 是一个工具"},
|
||||
{"multiline", "a\n<tool_call>\nx\n</tool_call>\nb", "a\n\nb"},
|
||||
{"whitespace", "a\n\n\n\nb", "a\n\nb"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := cleanToolCallLeakage(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
7
example/web/go.mod
Normal file
7
example/web/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module web
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../.
|
||||
11
example/web/plg.json
Normal file
11
example/web/plg.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "web",
|
||||
"name_zh": "网络搜索",
|
||||
"name_en": "web",
|
||||
"version": "1.0.0",
|
||||
"description": "网络搜索与抓取工具(web_search/web_fetch)",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["web", "search", "fetch"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
568
example/web/plugin.go
Normal file
568
example/web/plugin.go
Normal file
@ -0,0 +1,568 @@
|
||||
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 (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "plugin.web.timeout",
|
||||
Default: "30",
|
||||
Type: "int",
|
||||
DisplayName: "HTTP 超时(秒)",
|
||||
Description: "Web fetch 和搜索的 HTTP 请求超时时间",
|
||||
Category: "web",
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "plugin.web.proxy",
|
||||
Default: "",
|
||||
Type: "string",
|
||||
DisplayName: "HTTP 代理",
|
||||
Description: "HTTP 代理地址,如 http://<proxy-host>:<proxy-port>。为空则不使用代理",
|
||||
Category: "web",
|
||||
})
|
||||
|
||||
t := getSetting[float64](s.Settings(), "timeout", 30)
|
||||
p.timeout = int(t)
|
||||
if p.timeout < 5 {
|
||||
p.timeout = 5
|
||||
}
|
||||
if p.timeout > 120 {
|
||||
p.timeout = 120
|
||||
}
|
||||
|
||||
p.proxy = getSetting[string](s.Settings(), "proxy", "")
|
||||
p.client = newHTTPClient(p.timeout, p.proxy)
|
||||
|
||||
tp := p.name + "_"
|
||||
|
||||
s.RegisterTool(tp+"search", sdk.ToolDef{
|
||||
Name: tp + "search",
|
||||
Description: "Search the web for current information using DuckDuckGo. Returns formatted results with titles, URLs, and snippets.",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"query": map[string]interface{}{"type": "string", "description": "Search query"},
|
||||
"count": map[string]interface{}{"type": "integer", "description": "Number of results (1-20, default 5)"},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
}, p.handleSearch)
|
||||
|
||||
s.RegisterTool(tp+"fetch", sdk.ToolDef{
|
||||
Name: tp + "fetch",
|
||||
Description: "Fetch a URL and extract readable content as markdown-like text. Blocked on private/internal IPs.",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{"type": "string", "description": "HTTP/HTTPS URL to fetch"},
|
||||
"max_chars": map[string]interface{}{"type": "integer", "description": "Max characters to return (default 20000)"},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}, p.handleFetch)
|
||||
|
||||
proxyMsg := ""
|
||||
if p.proxy != "" {
|
||||
proxyMsg = fmt.Sprintf(", proxy: %s", p.proxy)
|
||||
}
|
||||
log.Printf("[%s] started, timeout: %ds%s", p.name, p.timeout, proxyMsg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
p.client.CloseIdleConnections()
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── SSRF 保护 ──────────────────────────────────────────────
|
||||
|
||||
var privateCIDRs []*net.IPNet
|
||||
|
||||
func init() {
|
||||
cidrs := []string{
|
||||
"127.0.0.0/8", // loopback
|
||||
"10.0.0.0/8", // private
|
||||
"172.16.0.0/12", // private
|
||||
"192.168.0.0/16", // private
|
||||
"100.64.0.0/10", // carrier-grade NAT
|
||||
"169.254.0.0/16", // link-local
|
||||
"::1/128", // IPv6 loopback
|
||||
"fc00::/7", // IPv6 unique local
|
||||
"fe80::/10", // IPv6 link-local
|
||||
}
|
||||
for _, c := range cidrs {
|
||||
_, n, err := net.ParseCIDR(c)
|
||||
if err == nil {
|
||||
privateCIDRs = append(privateCIDRs, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
for _, n := range privateCIDRs {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Plugin) ssrfCheck(rawURL string) error {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("only http/https URLs are allowed, got: %s", u.Scheme)
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
ips, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DNS lookup failed for %s: %w", host, err)
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil {
|
||||
continue
|
||||
}
|
||||
if isPrivateIP(parsed) {
|
||||
return fmt.Errorf("blocked request to private IP: %s (%s)", host, ip)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── DuckDuckGo 搜索 ────────────────────────────────────────
|
||||
|
||||
type ddgResult struct {
|
||||
Title string
|
||||
URL string
|
||||
Snippet string
|
||||
}
|
||||
|
||||
func (p *Plugin) ddgSearch(query string, count int) ([]ddgResult, error) {
|
||||
form := url.Values{"q": {query}}
|
||||
req, err := http.NewRequest("POST", "https://html.duckduckgo.com/html/", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
return parseDDGResults(string(body), count), nil
|
||||
}
|
||||
|
||||
func parseDDGResults(html string, count int) []ddgResult {
|
||||
var results []ddgResult
|
||||
|
||||
// Find all result blocks: <div class="result__body"> ... </div>
|
||||
bodyMarker := `result__body"`
|
||||
for i := 0; i < len(html); i++ {
|
||||
idx := strings.Index(html[i:], bodyMarker)
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
i += idx
|
||||
|
||||
// Find closing </div>
|
||||
closeIdx := findClosingTag(html, i, "</div>")
|
||||
if closeIdx < 0 {
|
||||
break
|
||||
}
|
||||
block := html[i : closeIdx+6]
|
||||
|
||||
r := parseSingleDDGResult(block)
|
||||
if r.URL != "" {
|
||||
results = append(results, r)
|
||||
if len(results) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
i = closeIdx + 6
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func findClosingTag(s string, start int, tag string) int {
|
||||
depth := 1
|
||||
pos := start
|
||||
for pos < len(s) {
|
||||
nextOpen := strings.Index(s[pos:], `<div`)
|
||||
nextClose := strings.Index(s[pos:], tag)
|
||||
if nextClose < 0 {
|
||||
return -1
|
||||
}
|
||||
if nextOpen >= 0 && nextOpen < nextClose {
|
||||
depth++
|
||||
pos += nextOpen + 4
|
||||
} else {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return pos + nextClose
|
||||
}
|
||||
pos += nextClose + len(tag)
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func parseSingleDDGResult(block string) ddgResult {
|
||||
var r ddgResult
|
||||
|
||||
// Extract URL and title from: <a rel="nofollow" class="result__a" href="URL">TITLE</a>
|
||||
urlMarker := `class="result__a" href="`
|
||||
uIdx := strings.Index(block, urlMarker)
|
||||
if uIdx >= 0 {
|
||||
start := uIdx + len(urlMarker)
|
||||
end := strings.Index(block[start:], `"`)
|
||||
if end >= 0 {
|
||||
r.URL = block[start : start+end]
|
||||
}
|
||||
|
||||
aStart := strings.Index(block[start+end:], `>`)
|
||||
if aStart >= 0 {
|
||||
titleStart := start + end + aStart + 1
|
||||
aEnd := strings.Index(block[titleStart:], `</a>`)
|
||||
if aEnd >= 0 {
|
||||
r.Title = stripTags(block[titleStart : titleStart+aEnd])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract snippet: <a class="result__snippet" ...> ... </a>
|
||||
snippetMarkers := []string{
|
||||
`<a class="result__snippet`,
|
||||
`<div class="result__snippet`,
|
||||
}
|
||||
for _, marker := range snippetMarkers {
|
||||
sIdx := strings.Index(block, marker)
|
||||
if sIdx >= 0 {
|
||||
aStart := strings.Index(block[sIdx:], `>`)
|
||||
if aStart >= 0 {
|
||||
snipStart := sIdx + aStart + 1
|
||||
snipEnd := strings.Index(block[snipStart:], `</a>`)
|
||||
if snipEnd < 0 {
|
||||
snipEnd = strings.Index(block[snipStart:], `</div>`)
|
||||
}
|
||||
if snipEnd >= 0 {
|
||||
r.Snippet = stripTags(block[snipStart : snipStart+snipEnd])
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Web Fetch ──────────────────────────────────────────────
|
||||
|
||||
func (p *Plugin) handleFetch(args map[string]interface{}) (interface{}, error) {
|
||||
rawURL, _ := args["url"].(string)
|
||||
if rawURL == "" {
|
||||
return errorResult("url is required"), nil
|
||||
}
|
||||
|
||||
maxChars := 20000
|
||||
if v, ok := args["max_chars"].(float64); ok && v > 0 {
|
||||
maxChars = int(v)
|
||||
}
|
||||
if maxChars > 500000 {
|
||||
maxChars = 500000
|
||||
}
|
||||
|
||||
if err := p.ssrfCheck(rawURL); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", rawURL, nil)
|
||||
if err != nil {
|
||||
return errorResult("invalid URL: " + err.Error()), nil
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return errorResult("fetch failed: " + err.Error()), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
||||
return errorResult(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status)), nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxChars)+50000))
|
||||
if err != nil {
|
||||
return errorResult("read error: " + err.Error()), nil
|
||||
}
|
||||
|
||||
rawText := string(body)
|
||||
|
||||
// Extract readable content based on content type
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
var extracted string
|
||||
if strings.Contains(ct, "text/html") {
|
||||
extracted = htmlToText(rawText)
|
||||
} else if strings.Contains(ct, "application/json") {
|
||||
// Pretty-print JSON
|
||||
var v interface{}
|
||||
if json.Unmarshal(body, &v) == nil {
|
||||
if pretty, err := json.MarshalIndent(v, "", " "); err == nil {
|
||||
extracted = string(pretty)
|
||||
} else {
|
||||
extracted = rawText
|
||||
}
|
||||
} else {
|
||||
extracted = rawText
|
||||
}
|
||||
} else {
|
||||
extracted = rawText
|
||||
}
|
||||
|
||||
// Clean up and truncate
|
||||
extracted = strings.TrimSpace(extracted)
|
||||
if len(extracted) > maxChars {
|
||||
extracted = extracted[:maxChars] + "\n\n[Content truncated]"
|
||||
}
|
||||
|
||||
if extracted == "" {
|
||||
extracted = "(empty content)"
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": extracted,
|
||||
"details": map[string]interface{}{
|
||||
"url": rawURL,
|
||||
"status": resp.StatusCode,
|
||||
"content_type": ct,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── HTML → 文本 ──────────────────────────────────────────────
|
||||
|
||||
func htmlToText(html string) string {
|
||||
// Remove scripts
|
||||
for {
|
||||
start := strings.Index(strings.ToLower(html), "<script")
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(html[start:], "</script>")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
html = html[:start] + html[start+end+9:]
|
||||
}
|
||||
|
||||
// Remove styles
|
||||
for {
|
||||
start := strings.Index(strings.ToLower(html), "<style")
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(html[start:], "</style>")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
html = html[:start] + html[start+end+8:]
|
||||
}
|
||||
|
||||
// Replace block-level tags with newlines
|
||||
for _, tag := range []string{"</p>", "</div>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>", "</li>", "</tr>", "</blockquote>", "<br", "</pre>"} {
|
||||
html = strings.ReplaceAll(html, tag, "\n")
|
||||
}
|
||||
|
||||
// Remove remaining tags
|
||||
html = stripTags(html)
|
||||
|
||||
// Decode common entities
|
||||
html = strings.ReplaceAll(html, "&", "&")
|
||||
html = strings.ReplaceAll(html, "<", "<")
|
||||
html = strings.ReplaceAll(html, ">", ">")
|
||||
html = strings.ReplaceAll(html, """, "\"")
|
||||
html = strings.ReplaceAll(html, "'", "'")
|
||||
html = strings.ReplaceAll(html, " ", " ")
|
||||
|
||||
// Collapse whitespace
|
||||
lines := strings.Split(html, "\n")
|
||||
var cleaned []string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Collapse internal whitespace
|
||||
in := []rune(line)
|
||||
var out []rune
|
||||
space := false
|
||||
for _, r := range in {
|
||||
if unicode.IsSpace(r) {
|
||||
if !space {
|
||||
out = append(out, ' ')
|
||||
space = true
|
||||
}
|
||||
} else {
|
||||
out = append(out, r)
|
||||
space = false
|
||||
}
|
||||
}
|
||||
cleaned = append(cleaned, string(out))
|
||||
}
|
||||
|
||||
return strings.Join(cleaned, "\n")
|
||||
}
|
||||
|
||||
func stripTags(s string) string {
|
||||
var out strings.Builder
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
if r == '<' {
|
||||
inTag = true
|
||||
continue
|
||||
}
|
||||
if r == '>' {
|
||||
inTag = false
|
||||
continue
|
||||
}
|
||||
if !inTag {
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// ── Search 处理 ──────────────────────────────────────────────
|
||||
|
||||
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
|
||||
query, _ := args["query"].(string)
|
||||
if query == "" {
|
||||
return errorResult("query is required"), nil
|
||||
}
|
||||
|
||||
count := 5
|
||||
if v, ok := args["count"].(float64); ok && v > 0 {
|
||||
count = int(v)
|
||||
}
|
||||
if count < 1 {
|
||||
count = 1
|
||||
}
|
||||
if count > 20 {
|
||||
count = 20
|
||||
}
|
||||
|
||||
results, err := p.ddgSearch(query, count)
|
||||
if err != nil {
|
||||
return errorResult("search failed: " + err.Error()), nil
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": "No results found.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Search results for %q:\n\n", query))
|
||||
for i, r := range results {
|
||||
sb.WriteString(fmt.Sprintf("%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": strings.TrimSpace(sb.String()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── 工具函数 ──────────────────────────────────────────────
|
||||
|
||||
func errorResult(msg string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"isError": true,
|
||||
"content": msg,
|
||||
}
|
||||
}
|
||||
|
||||
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
|
||||
v, err := s.Get(key)
|
||||
if err != nil || v == nil {
|
||||
return def
|
||||
}
|
||||
val, ok := v.(T)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
7
example/webfetch/go.mod
Normal file
7
example/webfetch/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module webfetch
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../.
|
||||
11
example/webfetch/plg.json
Normal file
11
example/webfetch/plg.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "webfetch",
|
||||
"name_zh": "网页抓取",
|
||||
"name_en": "webfetch",
|
||||
"version": "1.0.0",
|
||||
"description": "网页内容抓取工具,使用无头 Chromium 浏览器获取网页文字内容",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["web", "fetch"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
143
example/webfetch/plugin.go
Normal file
143
example/webfetch/plugin.go
Normal file
@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
|
||||
s.RegisterTool("web_fetch", sdk.ToolDef{
|
||||
Name: "web_fetch",
|
||||
Description: "获取网页文字内容。使用无头 Chromium 浏览器渲染页面后提取正文文字,返回标题和前 5000 字符。适用于需要查看网页内容的场景。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{"type": "string", "description": "要访问的网页 URL"},
|
||||
"wait": map[string]interface{}{"type": "integer", "description": "等待秒数(用于 JS 渲染页面,默认 0)"},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}, p.handleWebFetch)
|
||||
|
||||
log.Printf("[%s] plugin started", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error { return nil }
|
||||
|
||||
func convInt64(v interface{}) (int64, error) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int64(x), nil
|
||||
case int64:
|
||||
return x, nil
|
||||
case json.Number:
|
||||
return x.Int64()
|
||||
case string:
|
||||
return 0, fmt.Errorf("cannot convert string to int64")
|
||||
default:
|
||||
return 0, fmt.Errorf("cannot convert %T to int64", v)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) handleWebFetch(args map[string]interface{}) (interface{}, error) {
|
||||
url, _ := args["url"].(string)
|
||||
if url == "" {
|
||||
return nil, fmt.Errorf("url is required")
|
||||
}
|
||||
waitSec, _ := convInt64(args["wait"])
|
||||
|
||||
if waitSec > 0 {
|
||||
time.Sleep(time.Duration(waitSec) * time.Second)
|
||||
}
|
||||
|
||||
var html string
|
||||
chromiumPath := "/usr/local/bin/chromium"
|
||||
if _, err := os.Stat(chromiumPath); err == nil {
|
||||
var out bytes.Buffer
|
||||
argsList := []string{"--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", url}
|
||||
cmd := exec.Command(chromiumPath, argsList...)
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = nil
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("chromium: %w", err)
|
||||
}
|
||||
html = out.String()
|
||||
} else {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http get: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
html = string(body)
|
||||
}
|
||||
|
||||
title := ""
|
||||
if m := regexp.MustCompile(`<title>([^<]+)</title>`).FindStringSubmatch(html); len(m) > 1 {
|
||||
title = m[1]
|
||||
}
|
||||
|
||||
var textOut bytes.Buffer
|
||||
pyCmd := exec.Command("python3", "-c", `
|
||||
import sys, re, html
|
||||
raw = sys.stdin.read()
|
||||
text = re.sub(r'<[^>]+>', ' ', raw)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
text = html.unescape(text)
|
||||
sys.stdout.write(text)
|
||||
`)
|
||||
pyCmd.Stdin = strings.NewReader(html)
|
||||
pyCmd.Stdout = &textOut
|
||||
pyCmd.Stderr = nil
|
||||
pyCmd.Run()
|
||||
text := strings.TrimSpace(textOut.String())
|
||||
|
||||
origLen := len(text)
|
||||
truncated := origLen > 5000
|
||||
if truncated {
|
||||
text = text[:5000]
|
||||
}
|
||||
|
||||
result := ""
|
||||
if title != "" {
|
||||
result = fmt.Sprintf("标题: %s\nURL: %s\n\n", title, url)
|
||||
}
|
||||
result += text
|
||||
if truncated {
|
||||
result += fmt.Sprintf("\n\n...(内容过长,仅显示前 5000 字符,共 %d 字符)", origLen)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": result,
|
||||
"title": title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user