From 5c807d76a05c1e020f6c1a9713f827f422c25d6b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 6 Jun 2026 09:49:12 +0800 Subject: [PATCH] Initial commit: Cyrene Plugins SDK + Plugin Manager Extracted from Cyrene main repo (backend/pkg/plugins + backend/plugin-manager). Contains SDK interfaces (Plugin/Tool/HostAPI), 13 built-in plugins, ToolRegistry with call log ring buffer, and Plugin Manager REST API service. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 38 ++ README.md | 83 +++++ calculator/plugin.go | 279 +++++++++++++++ cmd/plugin-manager/host_api.go | 47 +++ cmd/plugin-manager/internal/config/config.go | 32 ++ .../internal/handler/plugin_handler.go | 210 +++++++++++ cmd/plugin-manager/iot_adapter.go | 112 ++++++ cmd/plugin-manager/main.go | 100 ++++++ crypto/plugin.go | 116 +++++++ datetime/plugin.go | 170 +++++++++ file/plugin.go | 158 +++++++++ go.mod | 3 + http/plugin.go | 122 +++++++ iot_control/plugin.go | 189 ++++++++++ iot_query/plugin.go | 120 +++++++ json/plugin.go | 132 +++++++ manager/manager.go | 226 ++++++++++++ manager/registry.go | 326 ++++++++++++++++++ markdown/plugin.go | 184 ++++++++++ random/plugin.go | 175 ++++++++++ sdk/base.go | 40 +++ sdk/permissions.go | 35 ++ sdk/plugin.go | 49 +++ sdk/types.go | 134 +++++++ text/plugin.go | 177 ++++++++++ web_fetch/plugin.go | 113 ++++++ web_search/plugin.go | 239 +++++++++++++ 27 files changed, 3609 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 calculator/plugin.go create mode 100644 cmd/plugin-manager/host_api.go create mode 100644 cmd/plugin-manager/internal/config/config.go create mode 100644 cmd/plugin-manager/internal/handler/plugin_handler.go create mode 100644 cmd/plugin-manager/iot_adapter.go create mode 100644 cmd/plugin-manager/main.go create mode 100644 crypto/plugin.go create mode 100644 datetime/plugin.go create mode 100644 file/plugin.go create mode 100644 go.mod create mode 100644 http/plugin.go create mode 100644 iot_control/plugin.go create mode 100644 iot_query/plugin.go create mode 100644 json/plugin.go create mode 100644 manager/manager.go create mode 100644 manager/registry.go create mode 100644 markdown/plugin.go create mode 100644 random/plugin.go create mode 100644 sdk/base.go create mode 100644 sdk/permissions.go create mode 100644 sdk/plugin.go create mode 100644 sdk/types.go create mode 100644 text/plugin.go create mode 100644 web_fetch/plugin.go create mode 100644 web_search/plugin.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2edda73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# cyrene-plugins +# Community plugin SDK and plugin-manager service for Cyrene AI + +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +/plugin-manager +/plugin-manager.exe + +# Test binary +*.test + +# Output of go test +*.out + +# Dependencies +vendor/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local + +# Data +data/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e0020d5 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Cyrene Plugins + +Cyrene AI 的插件系统:社区可扩展工具 SDK + Plugin Manager 服务。 + +## 结构 + +``` +├── sdk/ # 插件 SDK (Plugin, Tool, HostAPI 接口 + 类型定义) +├── manager/ # ToolRegistry (调用日志环形缓冲区) + PluginManager (生命周期管理) +├── calculator/ # 内置插件 (13 个) +├── crypto/ # - 加密/哈希 +├── datetime/ # - 日期时间 +├── file/ # - 文件操作 +├── http/ # - HTTP 请求 +├── iot_control/ # - IoT 设备控制 +├── iot_query/ # - IoT 设备查询 +├── json/ # - JSON 处理 +├── markdown/ # - Markdown 渲染 +├── random/ # - 随机数生成 +├── text/ # - 文本处理 +├── web_fetch/ # - 网页抓取 +├── web_search/ # - 网页搜索 +├── cmd/ +│ └── plugin-manager/ # Plugin Manager 服务 (REST API, 端口 8094) +└── go.mod +``` + +## 快速开始 + +```bash +git clone git@git.yeij.top:AskaEth/Cyrene-Plugins.git +cd Cyrene-Plugins +go build ./... +``` + +### 运行 Plugin Manager + +```bash +go run ./cmd/plugin-manager/ +# 监听 :8094,提供 REST API 管理插件 +``` + +### API 端点 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/v1/plugins` | 列出所有插件 | +| GET | `/api/v1/plugins/:id` | 插件详情 | +| POST | `/api/v1/plugins/:id/enable` | 启用插件 | +| POST | `/api/v1/plugins/:id/disable` | 禁用插件 | +| POST | `/api/v1/plugins/:id/reload` | 热重载 | +| DELETE | `/api/v1/plugins/:id` | 卸载 | +| GET | `/api/v1/plugins/:id/tools` | 列出插件工具 | +| GET | `/api/v1/tools` | 列出所有工具 | +| POST | `/api/v1/tools/:id/execute` | 执行工具 | +| GET | `/api/v1/health` | 健康检查 | + +## 开发插件 + +实现 `sdk.Plugin` 接口(`Metadata`, `Init`, `Start`, `Stop`, `Health`, `Tools`),然后在 `cmd/plugin-manager/main.go` 中注册。 + +```go +import "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" + +type MyPlugin struct{} + +func (p *MyPlugin) Metadata() sdk.PluginMetadata { + return sdk.PluginMetadata{Name: "my-plugin", Version: "1.0.0"} +} +// ... 实现其余接口 +``` + +## 与 Cyrene 主项目的集成 + +主项目 `ai-core` 通过 `go.mod` replace 指令引用本仓库进行本地开发: + +``` +replace git.yeij.top/AskaEth/Cyrene-Plugins => ../../../cyrene-plugins +``` + +## 许可证 + +MIT diff --git a/calculator/plugin.go b/calculator/plugin.go new file mode 100644 index 0000000..8224a21 --- /dev/null +++ b/calculator/plugin.go @@ -0,0 +1,279 @@ +package calculator + +import ( + "context" + "fmt" + "math" + "strconv" + "strings" + "unicode" + + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +type CalculatorPlugin struct { + sdk.BasePlugin +} + +func (p *CalculatorPlugin) Metadata() sdk.PluginMetadata { + return sdk.PluginMetadata{ + Name: "calculator", DisplayName: "Calculator", Version: "1.0.0", + Description: "Safe mathematical expression evaluation with custom parser", + Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"}, + } +} + +func (p *CalculatorPlugin) Tools() []sdk.Tool { + return []sdk.Tool{&CalculatorTool{}} +} + +type CalculatorTool struct { + sdk.BaseTool +} + +func (t *CalculatorTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{ + ID: "calculator", Name: "calculator", DisplayName: "Calculator", + Description: "Execute mathematical calculations. Supports arithmetic, trig, logs, powers.", + Category: "utility", Complexity: sdk.ComplexitySimple, + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{"expression": map[string]interface{}{"type": "string"}}, + "required": []string{"expression"}, + }, + } +} + +func (t *CalculatorTool) Validate(args map[string]interface{}) error { + if _, ok := args["expression"]; !ok { + return fmt.Errorf("missing required parameter: expression") + } + return nil +} + +func (t *CalculatorTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) { + expr, _ := args["expression"].(string) + result, err := evalExpression(expr) + if err != nil { + return &sdk.ToolResult{ToolName: "calculator", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "calculator", Success: true, Output: fmt.Sprintf("%v", result)}, nil +} + +// Expression parser supporting +, -, *, /, %, ^, functions, constants. +type exprParser struct { + s string + pos int +} + +func evalExpression(s string) (float64, error) { + p := &exprParser{s: strings.TrimSpace(s)} + result, err := p.parseAddSub() + if err != nil { + return 0, err + } + if p.pos < len(p.s) { + return 0, fmt.Errorf("unexpected character at position %d: %c", p.pos, p.s[p.pos]) + } + return result, nil +} + +func (p *exprParser) peek() byte { + if p.pos < len(p.s) { + return p.s[p.pos] + } + return 0 +} + +func (p *exprParser) skipSpaces() { + for p.pos < len(p.s) && p.s[p.pos] == ' ' { + p.pos++ + } +} + +func (p *exprParser) parseAddSub() (float64, error) { + left, err := p.parseMulDiv() + if err != nil { + return 0, err + } + for { + p.skipSpaces() + op := p.peek() + if op != '+' && op != '-' { + break + } + p.pos++ + right, err := p.parseMulDiv() + if err != nil { + return 0, err + } + if op == '+' { + left += right + } else { + left -= right + } + } + return left, nil +} + +func (p *exprParser) parseMulDiv() (float64, error) { + left, err := p.parsePower() + if err != nil { + return 0, err + } + for { + p.skipSpaces() + op := p.peek() + if op != '*' && op != '/' && op != '%' { + break + } + p.pos++ + right, err := p.parsePower() + if err != nil { + return 0, err + } + switch op { + case '*': + left *= right + case '/': + if right == 0 { + return 0, fmt.Errorf("division by zero") + } + left /= right + case '%': + left = math.Mod(left, right) + } + } + return left, nil +} + +func (p *exprParser) parsePower() (float64, error) { + left, err := p.parseUnary() + if err != nil { + return 0, err + } + p.skipSpaces() + if p.peek() == '^' { + p.pos++ + right, err := p.parseUnary() + if err != nil { + return 0, err + } + return math.Pow(left, right), nil + } + return left, nil +} + +func (p *exprParser) parseUnary() (float64, error) { + p.skipSpaces() + if p.peek() == '-' { + p.pos++ + val, err := p.parseAtom() + if err != nil { + return 0, err + } + return -val, nil + } + if p.peek() == '+' { + p.pos++ + } + return p.parseAtom() +} + +func (p *exprParser) parseAtom() (float64, error) { + p.skipSpaces() + if p.peek() == '(' { + p.pos++ + result, err := p.parseAddSub() + if err != nil { + return 0, err + } + p.skipSpaces() + if p.peek() != ')' { + return 0, fmt.Errorf("missing closing parenthesis") + } + p.pos++ + return result, nil + } + if p.peek() == 0 { + return 0, fmt.Errorf("unexpected end of expression") + } + if unicode.IsDigit(rune(p.peek())) || p.peek() == '.' { + return p.parseNumber() + } + return p.parseFuncOrConst() +} + +func (p *exprParser) parseNumber() (float64, error) { + start := p.pos + for p.pos < len(p.s) && (unicode.IsDigit(rune(p.s[p.pos])) || p.s[p.pos] == '.') { + p.pos++ + } + return strconv.ParseFloat(p.s[start:p.pos], 64) +} + +func (p *exprParser) parseFuncOrConst() (float64, error) { + start := p.pos + for p.pos < len(p.s) && (unicode.IsLetter(rune(p.s[p.pos])) || p.s[p.pos] == '_') { + p.pos++ + } + name := p.s[start:p.pos] + p.skipSpaces() + + switch name { + case "pi": + return math.Pi, nil + case "e": + return math.E, nil + case "sqrt", "sin", "cos", "tan", "abs", "floor", "ceil", "round", "log", "ln": + if p.peek() != '(' { + return 0, fmt.Errorf("expected '(' after function %s", name) + } + p.pos++ + arg, err := p.parseAddSub() + if err != nil { + return 0, err + } + if p.peek() != ')' { + return 0, fmt.Errorf("missing ')' after function argument") + } + p.pos++ + return applyFunc(name, arg) + default: + return 0, fmt.Errorf("unknown function or constant: %s", name) + } +} + +func applyFunc(name string, x float64) (float64, error) { + switch name { + case "sqrt": + if x < 0 { + return 0, fmt.Errorf("square root of negative number") + } + return math.Sqrt(x), nil + case "sin": + return math.Sin(x), nil + case "cos": + return math.Cos(x), nil + case "tan": + return math.Tan(x), nil + case "abs": + return math.Abs(x), nil + case "floor": + return math.Floor(x), nil + case "ceil": + return math.Ceil(x), nil + case "round": + return math.Round(x), nil + case "log": + if x <= 0 { + return 0, fmt.Errorf("log of non-positive number") + } + return math.Log10(x), nil + case "ln": + if x <= 0 { + return 0, fmt.Errorf("ln of non-positive number") + } + return math.Log(x), nil + } + return 0, fmt.Errorf("unknown function: %s", name) +} diff --git a/cmd/plugin-manager/host_api.go b/cmd/plugin-manager/host_api.go new file mode 100644 index 0000000..5893529 --- /dev/null +++ b/cmd/plugin-manager/host_api.go @@ -0,0 +1,47 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + + "git.yeij.top/AskaEth/Cyrene-Plugins/manager" + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +type hostAPI struct { + registry *manager.ToolRegistry +} + +func newHostAPI(registry *manager.ToolRegistry) *hostAPI { + return &hostAPI{registry: registry} +} + +func (h *hostAPI) CallLLM(_ context.Context, _ []sdk.LLMMessage) (*sdk.LLMResponse, error) { + return nil, fmt.Errorf("LLM call not available in plugin host") +} + +func (h *hostAPI) SearchMemory(_ context.Context, _, _ string, _ int) ([]sdk.MemoryEntry, error) { + return nil, fmt.Errorf("memory search not available in plugin host") +} + +func (h *hostAPI) StoreMemory(_ context.Context, _ sdk.MemoryEntry) error { + return fmt.Errorf("memory store not available in plugin host") +} + +func (h *hostAPI) Logger() sdk.Logger { + return log.Default() +} + +func (h *hostAPI) GetConfig(key string) (string, error) { + return "", fmt.Errorf("config key not found: %s", key) +} + +func (h *hostAPI) SetConfig(_, _ string) error { return nil } + +func (h *hostAPI) PublishEvent(_ context.Context, _ map[string]interface{}) error { return nil } + +func (h *hostAPI) HTTPClient() *http.Client { + return http.DefaultClient +} diff --git a/cmd/plugin-manager/internal/config/config.go b/cmd/plugin-manager/internal/config/config.go new file mode 100644 index 0000000..fa32861 --- /dev/null +++ b/cmd/plugin-manager/internal/config/config.go @@ -0,0 +1,32 @@ +package config + +import "os" + +type Config struct { + Port string + Env string + DataDir string + IoTSvcURL string +} + +func Load() *Config { + cfg := &Config{ + Port: "8094", + Env: "development", + DataDir: "./data", + IoTSvcURL: "http://localhost:8093", + } + if v := os.Getenv("PORT"); v != "" { + cfg.Port = v + } + if v := os.Getenv("ENV"); v != "" { + cfg.Env = v + } + if v := os.Getenv("DATA_DIR"); v != "" { + cfg.DataDir = v + } + if v := os.Getenv("IOT_SERVICE_URL"); v != "" { + cfg.IoTSvcURL = v + } + return cfg +} diff --git a/cmd/plugin-manager/internal/handler/plugin_handler.go b/cmd/plugin-manager/internal/handler/plugin_handler.go new file mode 100644 index 0000000..9b58313 --- /dev/null +++ b/cmd/plugin-manager/internal/handler/plugin_handler.go @@ -0,0 +1,210 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strings" + + "git.yeij.top/AskaEth/Cyrene-Plugins/manager" +) + +// PluginHandler exposes the Plugin Manager REST API via net/http. +type PluginHandler struct { + mgr *manager.PluginManager +} + +func NewPluginHandler(mgr *manager.PluginManager) *PluginHandler { + return &PluginHandler{mgr: mgr} +} + +func (h *PluginHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/plugins", h.listPlugins) + mux.HandleFunc("/api/v1/plugins/", h.pluginRoute) + mux.HandleFunc("/api/v1/tools", h.listTools) + mux.HandleFunc("/api/v1/tools/", h.toolRoute) + mux.HandleFunc("/api/v1/health", h.health) +} + +func (h *PluginHandler) health(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]interface{}{"status": "ok", "service": "plugin-manager"}) +} + +func (h *PluginHandler) listPlugins(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed")) + return + } + plugins := h.mgr.List() + writeJSON(w, http.StatusOK, map[string]interface{}{"plugins": plugins, "total": len(plugins)}) +} + +func (h *PluginHandler) pluginRoute(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/v1/plugins/") + parts := strings.SplitN(path, "/", 2) + pluginID := parts[0] + + if pluginID == "" { + h.listPlugins(w, r) + return + } + + if len(parts) == 1 { + switch r.Method { + case "GET": + h.getPlugin(w, pluginID) + case "DELETE": + h.uninstallPlugin(w, r, pluginID) + default: + writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed")) + } + return + } + + action := parts[1] + switch action { + case "enable": + if r.Method != "POST" { + writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed")) + return + } + h.enablePlugin(w, r, pluginID) + case "disable": + if r.Method != "POST" { + writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed")) + return + } + h.disablePlugin(w, r, pluginID) + case "reload": + if r.Method != "POST" { + writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed")) + return + } + h.reloadPlugin(w, r, pluginID) + case "tools": + if r.Method != "GET" { + writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed")) + return + } + h.pluginTools(w, pluginID) + default: + writeJSON(w, http.StatusNotFound, errResp("not found")) + } +} + +func (h *PluginHandler) getPlugin(w http.ResponseWriter, id string) { + info, ok := h.mgr.Get(id) + if !ok { + writeJSON(w, http.StatusNotFound, errResp("plugin not found")) + return + } + writeJSON(w, http.StatusOK, info) +} + +func (h *PluginHandler) enablePlugin(w http.ResponseWriter, r *http.Request, id string) { + if err := h.mgr.Enable(r.Context(), id); err != nil { + writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "enabled"}) +} + +func (h *PluginHandler) disablePlugin(w http.ResponseWriter, r *http.Request, id string) { + if err := h.mgr.Disable(r.Context(), id); err != nil { + writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "disabled"}) +} + +func (h *PluginHandler) reloadPlugin(w http.ResponseWriter, r *http.Request, id string) { + if err := h.mgr.Reload(r.Context(), id); err != nil { + writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "reloaded"}) +} + +func (h *PluginHandler) uninstallPlugin(w http.ResponseWriter, r *http.Request, id string) { + if err := h.mgr.Uninstall(r.Context(), id); err != nil { + writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "uninstalled"}) +} + +func (h *PluginHandler) pluginTools(w http.ResponseWriter, id string) { + info, ok := h.mgr.Get(id) + if !ok { + writeJSON(w, http.StatusNotFound, errResp("plugin not found")) + return + } + registry := h.mgr.Registry() + tools := make([]interface{}, 0) + for _, toolID := range info.Tools { + if t, ok := registry.Get(toolID); ok { + tools = append(tools, t.Definition()) + } + } + writeJSON(w, http.StatusOK, map[string]interface{}{"tools": tools, "total": len(tools)}) +} + +func (h *PluginHandler) listTools(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed")) + return + } + defs := h.mgr.Registry().Definitions() + writeJSON(w, http.StatusOK, map[string]interface{}{"tools": defs, "total": len(defs)}) +} + +func (h *PluginHandler) toolRoute(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/v1/tools/") + toolID := path + + if strings.HasSuffix(path, "/execute") { + toolID = strings.TrimSuffix(path, "/execute") + if r.Method != "POST" { + writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed")) + return + } + h.executeTool(w, r, toolID) + return + } + + if r.Method != "GET" { + writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed")) + return + } + tool, ok := h.mgr.Registry().Get(toolID) + if !ok { + writeJSON(w, http.StatusNotFound, errResp("tool not found")) + return + } + writeJSON(w, http.StatusOK, tool.Definition()) +} + +func (h *PluginHandler) executeTool(w http.ResponseWriter, r *http.Request, toolID string) { + var body struct { + Arguments map[string]interface{} `json:"arguments"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, errResp("invalid request body")) + return + } + result, err := h.mgr.Registry().Execute(r.Context(), toolID, body.Arguments) + if err != nil { + writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) + return + } + writeJSON(w, http.StatusOK, result) +} + +func errResp(msg string) map[string]string { + return map[string]string{"error": msg} +} + +func writeJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} diff --git a/cmd/plugin-manager/iot_adapter.go b/cmd/plugin-manager/iot_adapter.go new file mode 100644 index 0000000..0865c54 --- /dev/null +++ b/cmd/plugin-manager/iot_adapter.go @@ -0,0 +1,112 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" + iotquery "git.yeij.top/AskaEth/Cyrene-Plugins/iot_query" +) + +type iotClient struct { + baseURL string + httpClient *http.Client +} + +func newIoTClient(baseURL string) *iotClient { + return &iotClient{ + baseURL: baseURL, + httpClient: &http.Client{Timeout: 5 * time.Second}, + } +} + +func (c *iotClient) GetAllDevices(ctx context.Context) ([]sdk.IoTDeviceState, error) { + url := c.baseURL + "/api/v1/devices" + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Devices []sdk.IoTDeviceState `json:"devices"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Devices, nil +} + +func (c *iotClient) GetDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error) { + url := fmt.Sprintf("%s/api/v1/devices/%s", c.baseURL, deviceID) + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var dev sdk.IoTDeviceState + if err := json.NewDecoder(resp.Body).Decode(&dev); err != nil { + return nil, err + } + return &dev, nil +} + +// iotControllerAdapter adapts IoTClient to iotcontrol.IoTController. +type iotControllerAdapter struct { + query iotquery.IoTClient + client *http.Client + baseURL string +} + +func newIoTControllerAdapter(query iotquery.IoTClient, baseURL string) *iotControllerAdapter { + return &iotControllerAdapter{ + query: query, + client: &http.Client{Timeout: 5 * time.Second}, + baseURL: baseURL, + } +} + +func (a *iotControllerAdapter) GetDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error) { + return a.query.GetDevice(ctx, deviceID) +} + +func (a *iotControllerAdapter) SetDeviceProperty(ctx context.Context, deviceID, property string, value interface{}) error { + url := fmt.Sprintf("%s/api/v1/devices/%s/property", a.baseURL, deviceID) + body, _ := json.Marshal(map[string]interface{}{"property": property, "value": value}) + req, _ := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + resp, err := a.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + msg, _ := io.ReadAll(resp.Body) + return fmt.Errorf("set property failed: HTTP %d - %s", resp.StatusCode, string(msg)) + } + return nil +} + +func (a *iotControllerAdapter) ToggleDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error) { + url := fmt.Sprintf("%s/api/v1/devices/%s/toggle", a.baseURL, deviceID) + req, _ := http.NewRequestWithContext(ctx, "POST", url, nil) + resp, err := a.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var dev sdk.IoTDeviceState + if err := json.NewDecoder(resp.Body).Decode(&dev); err != nil { + return nil, err + } + return &dev, nil +} diff --git a/cmd/plugin-manager/main.go b/cmd/plugin-manager/main.go new file mode 100644 index 0000000..746eb42 --- /dev/null +++ b/cmd/plugin-manager/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "git.yeij.top/AskaEth/Cyrene-Plugins/calculator" + "git.yeij.top/AskaEth/Cyrene-Plugins/crypto" + "git.yeij.top/AskaEth/Cyrene-Plugins/datetime" + fileplugin "git.yeij.top/AskaEth/Cyrene-Plugins/file" + httpplugin "git.yeij.top/AskaEth/Cyrene-Plugins/http" + iotcontrol "git.yeij.top/AskaEth/Cyrene-Plugins/iot_control" + iotquery "git.yeij.top/AskaEth/Cyrene-Plugins/iot_query" + jsonplugin "git.yeij.top/AskaEth/Cyrene-Plugins/json" + "git.yeij.top/AskaEth/Cyrene-Plugins/manager" + "git.yeij.top/AskaEth/Cyrene-Plugins/markdown" + "git.yeij.top/AskaEth/Cyrene-Plugins/random" + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" + "git.yeij.top/AskaEth/Cyrene-Plugins/text" + webfetch "git.yeij.top/AskaEth/Cyrene-Plugins/web_fetch" + websearch "git.yeij.top/AskaEth/Cyrene-Plugins/web_search" + + "git.yeij.top/AskaEth/Cyrene-Plugins/cmd/plugin-manager/internal/config" + "git.yeij.top/AskaEth/Cyrene-Plugins/cmd/plugin-manager/internal/handler" +) + +func main() { + cfg := config.Load() + + var iotAPI iotquery.IoTClient + if cfg.IoTSvcURL != "" { + iotAPI = newIoTClient(cfg.IoTSvcURL) + } + + registry := manager.NewToolRegistry() + host := newHostAPI(registry) + mgr := manager.NewPluginManager(registry, host) + + builtins := []sdk.Plugin{ + &calculator.CalculatorPlugin{}, + &datetime.DatetimePlugin{}, + &text.TextPlugin{}, + &crypto.CryptoPlugin{}, + &random.RandomPlugin{}, + &markdown.MarkdownPlugin{}, + &jsonplugin.JSONPlugin{}, + fileplugin.NewFilePlugin(cfg.DataDir), + httpplugin.NewHTTPPlugin(), + websearch.NewWebSearchPlugin(), + webfetch.NewWebFetchPlugin(), + iotquery.NewIoTQueryPlugin(iotAPI), + } + for _, p := range builtins { + if err := mgr.Install(p); err != nil { + println("WARN: install plugin failed:", err.Error()) + } + } + if iotAPI != nil { + ctrlPlugin := iotcontrol.NewIoTControlPlugin(newIoTControllerAdapter(iotAPI, cfg.IoTSvcURL)) + if err := mgr.Install(ctrlPlugin); err != nil { + println("WARN: install plugin failed:", err.Error()) + } + } + + ctx := context.Background() + errs := mgr.EnableAll(ctx) + for _, e := range errs { + println("WARN: enable plugin failed:", e.Error()) + } + println("Plugin Manager: all built-in plugins enabled") + + mux := http.NewServeMux() + ph := handler.NewPluginHandler(mgr) + ph.RegisterRoutes(mux) + + println("Plugin Manager listening on port", cfg.Port) + srv := &http.Server{Addr: ":" + cfg.Port, Handler: mux} + + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + println("FATAL:", err.Error()) + os.Exit(1) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + println("Shutting down Plugin Manager...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + mgr.Shutdown(shutdownCtx) + srv.Shutdown(shutdownCtx) + println("Plugin Manager stopped") +} diff --git a/crypto/plugin.go b/crypto/plugin.go new file mode 100644 index 0000000..8f0bca1 --- /dev/null +++ b/crypto/plugin.go @@ -0,0 +1,116 @@ +package crypto + +import ( + "context" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "fmt" + "hash" + "net/url" + + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +type CryptoPlugin struct{ sdk.BasePlugin } + +func (p *CryptoPlugin) Metadata() sdk.PluginMetadata { + return sdk.PluginMetadata{ + Name: "crypto", DisplayName: "Crypto & Encoding", Version: "1.0.0", + Description: "Hashing (MD5/SHA) and encoding (Base64, URL) utilities", + Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"}, + } +} + +func (p *CryptoPlugin) Tools() []sdk.Tool { return []sdk.Tool{&CryptoTool{}} } + +type CryptoTool struct{ sdk.BaseTool } + +func (t *CryptoTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{ + ID: "crypto", Name: "crypto", DisplayName: "Crypto & Encoding", + Description: "Crypto hash and encoding utilities. MD5/SHA hashing, Base64 encode/decode, URL encode/decode.", + Category: "utility", Complexity: sdk.ComplexitySimple, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{"type": "string", "enum": []string{"hash", "base64_encode", "base64_decode", "url_encode", "url_decode"}}, + "input": map[string]interface{}{"type": "string"}, + "algorithm": map[string]interface{}{"type": "string", "enum": []string{"md5", "sha1", "sha256", "sha512"}}, + }, + "required": []string{"action", "input"}, + }, + } +} + +func (t *CryptoTool) Validate(args map[string]interface{}) error { + for _, k := range []string{"action", "input"} { + if _, ok := args[k]; !ok { + return fmt.Errorf("missing required parameter: %s", k) + } + } + return nil +} + +func (t *CryptoTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) { + action, _ := args["action"].(string) + input, _ := args["input"].(string) + + switch action { + case "hash": + alg, _ := args["algorithm"].(string) + if alg == "" { + alg = "sha256" + } + var h hash.Hash + switch alg { + case "md5": + h = md5.New() + case "sha1": + h = sha1.New() + case "sha256": + h = sha256.New() + case "sha512": + h = sha512.New() + default: + return &sdk.ToolResult{ToolName: "crypto", Success: false, Error: "unsupported algorithm: " + alg}, nil + } + h.Write([]byte(input)) + return &sdk.ToolResult{ToolName: "crypto", Success: true, + Output: fmt.Sprintf("%s: %x", alg, h.Sum(nil))}, nil + + case "base64_encode": + return &sdk.ToolResult{ToolName: "crypto", Success: true, + Output: base64.StdEncoding.EncodeToString([]byte(input))}, nil + + case "base64_decode": + for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} { + if decoded, err := enc.DecodeString(input); err == nil { + return &sdk.ToolResult{ToolName: "crypto", Success: true, Output: truncate(string(decoded), 200)}, nil + } + } + return &sdk.ToolResult{ToolName: "crypto", Success: false, Error: "failed to decode base64"}, nil + + case "url_encode": + return &sdk.ToolResult{ToolName: "crypto", Success: true, + Output: url.QueryEscape(input)}, nil + + case "url_decode": + decoded, err := url.QueryUnescape(input) + if err != nil { + return &sdk.ToolResult{ToolName: "crypto", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "crypto", Success: true, Output: decoded}, nil + } + return &sdk.ToolResult{ToolName: "crypto", Success: false, Error: "unknown action: " + action}, nil +} + +func truncate(s string, n int) string { + runes := []rune(s) + if len(runes) > n { + return string(runes[:n]) + "..." + } + return s +} diff --git a/datetime/plugin.go b/datetime/plugin.go new file mode 100644 index 0000000..5bda000 --- /dev/null +++ b/datetime/plugin.go @@ -0,0 +1,170 @@ +package datetime + +import ( + "context" + "fmt" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +type DatetimePlugin struct{ sdk.BasePlugin } + +func (p *DatetimePlugin) Metadata() sdk.PluginMetadata { + return sdk.PluginMetadata{ + Name: "datetime", DisplayName: "Date & Time", Version: "1.0.0", + Description: "Date/time utilities: now, format, arithmetic, diff, timezone list", + Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"}, + } +} + +func (p *DatetimePlugin) Tools() []sdk.Tool { return []sdk.Tool{&DatetimeTool{}} } + +type DatetimeTool struct{ sdk.BaseTool } + +func (t *DatetimeTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{ + ID: "datetime", Name: "datetime", DisplayName: "Date & Time", + Description: "Date/time utility. Get current time, format dates, date arithmetic, date diff, list timezones.", + Category: "utility", Complexity: sdk.ComplexitySimple, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{"type": "string", "enum": []string{"now", "format", "add", "diff", "timezone_list"}}, + "format": map[string]interface{}{"type": "string"}, + "timezone": map[string]interface{}{"type": "string"}, + "date": map[string]interface{}{"type": "string"}, + "duration": map[string]interface{}{"type": "string"}, + "date2": map[string]interface{}{"type": "string"}, + }, + "required": []string{"action"}, + }, + } +} + +func (t *DatetimeTool) Validate(args map[string]interface{}) error { + if _, ok := args["action"]; !ok { + return fmt.Errorf("missing required parameter: action") + } + return nil +} + +func (t *DatetimeTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) { + action, _ := args["action"].(string) + tzStr, _ := args["timezone"].(string) + loc, _ := parseLocation(tzStr) + now := time.Now().In(loc) + + switch action { + case "now": + return &sdk.ToolResult{ToolName: "datetime", Success: true, + Output: fmt.Sprintf("Current time: %s (unix: %d, zone: %s)", now.Format(time.RFC3339), now.Unix(), loc.String())}, nil + + case "format": + dateStr, _ := args["date"].(string) + format, _ := args["format"].(string) + if format == "" { + format = time.RFC3339 + } + parsed, err := parseDate(dateStr, loc) + if err != nil { + return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "datetime", Success: true, + Output: fmt.Sprintf("Formatted: %s", parsed.Format(format))}, nil + + case "add": + dateStr, _ := args["date"].(string) + durStr, _ := args["duration"].(string) + base := now + if dateStr != "" { + var err error + base, err = parseDate(dateStr, loc) + if err != nil { + return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil + } + } + result, err := addDuration(base, durStr) + if err != nil { + return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "datetime", Success: true, + Output: fmt.Sprintf("%s + %s = %s", base.Format(time.RFC3339), durStr, result.Format(time.RFC3339))}, nil + + case "diff": + d1, _ := args["date"].(string) + d2, _ := args["date2"].(string) + t1, err := parseDate(d1, loc) + if err != nil { + return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil + } + t2, err := parseDate(d2, loc) + if err != nil { + return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil + } + diff := t2.Sub(t1) + if diff < 0 { + diff = -diff + } + days := int(diff.Hours()) / 24 + hours := int(diff.Hours()) % 24 + minutes := int(diff.Minutes()) % 60 + seconds := int(diff.Seconds()) % 60 + return &sdk.ToolResult{ToolName: "datetime", Success: true, + Output: fmt.Sprintf("Difference: %d days, %d hours, %d minutes, %d seconds", days, hours, minutes, seconds)}, nil + + case "timezone_list": + return &sdk.ToolResult{ToolName: "datetime", Success: true, + Output: "Common timezones: UTC, Asia/Shanghai, Asia/Tokyo, Asia/Seoul, Asia/Singapore, Asia/Kolkata, Asia/Dubai, Europe/London, Europe/Paris, Europe/Moscow, America/New_York, America/Chicago, America/Los_Angeles, America/Sao_Paulo, Australia/Sydney, Pacific/Auckland, Africa/Cairo, Africa/Lagos"}, nil + + default: + return &sdk.ToolResult{ToolName: "datetime", Success: false, + Error: fmt.Sprintf("unknown action: %s", action)}, nil + } +} + +func parseLocation(tz string) (*time.Location, error) { + if tz == "" { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + return time.UTC, nil + } + return loc, nil + } + return time.LoadLocation(tz) +} + +func parseDate(s string, loc *time.Location) (time.Time, error) { + formats := []string{time.RFC3339, "2006-01-02T15:04:05", "2006-01-02 15:04:05", "2006-01-02", "2006/01/02"} + for _, f := range formats { + if t, err := time.ParseInLocation(f, s, loc); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("cannot parse date: %s", s) +} + +func addDuration(t time.Time, durStr string) (time.Time, error) { + durStr = strings.TrimSpace(durStr) + if durStr == "" { + return t, nil + } + // Handle months and years + if strings.Contains(durStr, "M") || strings.Contains(durStr, "y") { + months := 0 + years := 0 + if strings.Contains(durStr, "y") { + fmt.Sscanf(durStr, "%dy", &years) + } + if strings.Contains(durStr, "M") { + fmt.Sscanf(durStr, "%dM", &months) + } + return t.AddDate(years, months, 0), nil + } + d, err := time.ParseDuration(durStr) + if err != nil { + return t, fmt.Errorf("invalid duration: %s", durStr) + } + return t.Add(d), nil +} diff --git a/file/plugin.go b/file/plugin.go new file mode 100644 index 0000000..3dd7c86 --- /dev/null +++ b/file/plugin.go @@ -0,0 +1,158 @@ +package file + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +type FilePlugin struct { + sdk.BasePlugin + dataDir string +} + +func NewFilePlugin(dataDir string) *FilePlugin { + if dataDir == "" { + dataDir = "/tmp/cyrene_data" + } + return &FilePlugin{dataDir: dataDir} +} + +func (p *FilePlugin) Metadata() sdk.PluginMetadata { + return sdk.PluginMetadata{ + Name: "file", DisplayName: "File Operations", Version: "1.0.0", + Description: "Sandboxed file operations: read, write, list, delete within DATA_DIR", + Category: "system", Author: sdk.PluginAuthor{Name: "Cyrene Team"}, + } +} + +func (p *FilePlugin) Tools() []sdk.Tool { return []sdk.Tool{&FileTool{dataDir: p.dataDir}} } + +type FileTool struct { + sdk.BaseTool + dataDir string +} + +func (t *FileTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{ + ID: "file_ops", Name: "file_ops", DisplayName: "File Operations", + Description: "File operations within a sandboxed data directory. Read, write, list, check existence, delete.", + Category: "system", Complexity: sdk.ComplexitySimple, + DangerLevel: "medium", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{"type": "string", "enum": []string{"read", "write", "list", "exists", "delete"}}, + "path": map[string]interface{}{"type": "string"}, + "content": map[string]interface{}{"type": "string"}, + }, + "required": []string{"action", "path"}, + }, + } +} + +func (t *FileTool) Validate(args map[string]interface{}) error { + for _, k := range []string{"action", "path"} { + if _, ok := args[k]; !ok { + return fmt.Errorf("missing required parameter: %s", k) + } + } + return nil +} + +func (t *FileTool) safePath(p string) (string, error) { + clean := filepath.Clean(p) + abs, err := filepath.Abs(filepath.Join(t.dataDir, clean)) + if err != nil { + return "", fmt.Errorf("path resolution failed: %w", err) + } + if !strings.HasPrefix(abs, filepath.Clean(t.dataDir)+string(os.PathSeparator)) && abs != filepath.Clean(t.dataDir) { + return "", fmt.Errorf("path traversal denied: %s", p) + } + return abs, nil +} + +func (t *FileTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) { + action, _ := args["action"].(string) + pathStr, _ := args["path"].(string) + + safePath, err := t.safePath(pathStr) + if err != nil { + return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil + } + + switch action { + case "read": + info, err := os.Stat(safePath) + if err != nil { + return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil + } + if info.IsDir() { + return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: "cannot read a directory"}, nil + } + if info.Size() > 100*1024 { + return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: "file too large (>100KB)"}, nil + } + data, err := os.ReadFile(safePath) + if err != nil { + return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: string(data)}, nil + + case "write": + content, _ := args["content"].(string) + dir := filepath.Dir(safePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil + } + if err := os.WriteFile(safePath, []byte(content), 0644); err != nil { + return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: fmt.Sprintf("Written %d bytes to %s", len(content), pathStr)}, nil + + case "list": + entries, err := os.ReadDir(safePath) + if err != nil { + return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil + } + var out strings.Builder + for _, e := range entries { + info, _ := e.Info() + if e.IsDir() { + out.WriteString(fmt.Sprintf("[DIR] %s/\n", e.Name())) + } else { + out.WriteString(fmt.Sprintf("[FILE] %s (%d bytes)\n", e.Name(), info.Size())) + } + } + return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: out.String()}, nil + + case "exists": + info, err := os.Stat(safePath) + if err != nil { + return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: fmt.Sprintf("Path does not exist: %s", pathStr)}, nil + } + kind := "file" + if info.IsDir() { + kind = "directory" + } + return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: fmt.Sprintf("Path exists (%s): %s", kind, pathStr)}, nil + + case "delete": + info, err := os.Stat(safePath) + if err != nil { + return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil + } + if info.IsDir() { + return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: "cannot delete a directory"}, nil + } + if err := os.Remove(safePath); err != nil { + return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: fmt.Sprintf("Deleted: %s", pathStr)}, nil + } + return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: "unknown action: " + action}, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c89ab60 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.yeij.top/AskaEth/Cyrene-Plugins + +go 1.21 diff --git a/http/plugin.go b/http/plugin.go new file mode 100644 index 0000000..0be100e --- /dev/null +++ b/http/plugin.go @@ -0,0 +1,122 @@ +package http + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +type HTTPPlugin struct { + sdk.BasePlugin + client *http.Client +} + +func NewHTTPPlugin() *HTTPPlugin { + return &HTTPPlugin{client: &http.Client{Timeout: 10 * time.Second}} +} + +func (p *HTTPPlugin) Metadata() sdk.PluginMetadata { + return sdk.PluginMetadata{ + Name: "http", DisplayName: "HTTP Client", Version: "1.0.0", + Description: "Send arbitrary HTTP requests with custom methods, headers, body", + Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"}, + } +} + +func (p *HTTPPlugin) Tools() []sdk.Tool { return []sdk.Tool{&HTTPTool{client: p.client}} } + +type HTTPTool struct { + sdk.BaseTool + client *http.Client +} + +func (t *HTTPTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{ + ID: "http_request", Name: "http_request", DisplayName: "HTTP Client", + Description: "Send arbitrary HTTP requests. Supports custom methods, headers, and body.", + Category: "network", Complexity: sdk.ComplexitySimple, + DangerLevel: "low", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "url": map[string]interface{}{"type": "string"}, + "method": map[string]interface{}{"type": "string", "enum": []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}}, + "headers": map[string]interface{}{"type": "object"}, + "body": map[string]interface{}{"type": "string"}, + "timeout": map[string]interface{}{"type": "number"}, + }, + "required": []string{"url"}, + }, + } +} + +var allowedMethods = map[string]bool{ + "GET": true, "POST": true, "PUT": true, "DELETE": true, + "PATCH": true, "HEAD": true, "OPTIONS": true, +} + +func (t *HTTPTool) Validate(args map[string]interface{}) error { + if _, ok := args["url"]; !ok { + return fmt.Errorf("missing required parameter: url") + } + return nil +} + +func (t *HTTPTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) { + urlStr, _ := args["url"].(string) + method, _ := args["method"].(string) + if method == "" { + method = "GET" + } + if !allowedMethods[method] { + return &sdk.ToolResult{ToolName: "http_request", Success: false, Error: "invalid method: " + method}, nil + } + if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") { + return &sdk.ToolResult{ToolName: "http_request", Success: false, Error: "only http/https URLs allowed"}, nil + } + + var bodyReader io.Reader + if body, _ := args["body"].(string); body != "" { + bodyReader = strings.NewReader(body) + } + + req, err := http.NewRequest(method, urlStr, bodyReader) + if err != nil { + return &sdk.ToolResult{ToolName: "http_request", Success: false, Error: err.Error()}, nil + } + req.Header.Set("User-Agent", "CyreneBot/1.0") + + if headers, ok := args["headers"].(map[string]interface{}); ok { + for k, v := range headers { + req.Header.Set(k, fmt.Sprint(v)) + } + } + + client := t.client + if timeout, _ := args["timeout"].(float64); timeout > 0 { + client = &http.Client{Timeout: time.Duration(timeout) * time.Second} + } + + resp, err := client.Do(req) + if err != nil { + return &sdk.ToolResult{ToolName: "http_request", Success: false, Error: err.Error()}, nil + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 50*1024)) + return &sdk.ToolResult{ToolName: "http_request", Success: resp.StatusCode < 500, Output: fmt.Sprintf( + "HTTP %d\n%s\n\n%s", resp.StatusCode, formatHeaders(resp.Header), string(bodyBytes))}, nil +} + +func formatHeaders(h http.Header) string { + var lines []string + for k, v := range h { + lines = append(lines, fmt.Sprintf("%s: %s", k, strings.Join(v, ", "))) + } + return strings.Join(lines, "\n") +} diff --git a/iot_control/plugin.go b/iot_control/plugin.go new file mode 100644 index 0000000..0ed00de --- /dev/null +++ b/iot_control/plugin.go @@ -0,0 +1,189 @@ +package iotcontrol + +import ( + "context" + "fmt" + + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +// IoTController extends IoTClient with control operations. +type IoTController interface { + GetDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error) + SetDeviceProperty(ctx context.Context, deviceID, property string, value interface{}) error + ToggleDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error) +} + +type IoTControlPlugin struct { + sdk.BasePlugin + iotClient IoTController +} + +func NewIoTControlPlugin(client IoTController) *IoTControlPlugin { + return &IoTControlPlugin{iotClient: client} +} + +func (p *IoTControlPlugin) Metadata() sdk.PluginMetadata { + return sdk.PluginMetadata{ + Name: "iot_control", DisplayName: "IoT Device Control", Version: "1.0.0", + Description: "Control smart home devices: toggle, set temperature/brightness/mode/color", + Category: "iot", Author: sdk.PluginAuthor{Name: "Cyrene Team"}, + } +} + +func (p *IoTControlPlugin) Tools() []sdk.Tool { + return []sdk.Tool{&IoTControlTool{iotClient: p.iotClient}} +} + +type IoTControlTool struct { + sdk.BaseTool + iotClient IoTController +} + +func (t *IoTControlTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{ + ID: "iot_control", Name: "iot_control", DisplayName: "IoT Device Control", + Description: "Control smart home devices. Supports toggle, turn_on, turn_off, set_temperature, set_brightness, set_position, set_mode, set_color.", + Category: "iot", Complexity: sdk.ComplexitySimple, + DangerLevel: "medium", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "device_id": map[string]interface{}{"type": "string"}, + "action": map[string]interface{}{"type": "string", "enum": []string{"toggle", "turn_on", "turn_off", "set_temperature", "set_brightness", "set_position", "set_mode", "set_color"}}, + "value": map[string]interface{}{}, + }, + "required": []string{"device_id", "action"}, + }, + } +} + +func (t *IoTControlTool) Validate(args map[string]interface{}) error { + for _, k := range []string{"device_id", "action"} { + if _, ok := args[k]; !ok { + return fmt.Errorf("missing required parameter: %s", k) + } + } + return nil +} + +func (t *IoTControlTool) Execute(ctx context.Context, args map[string]interface{}) (*sdk.ToolResult, error) { + if t.iotClient == nil { + return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: "IoT client not configured"}, nil + } + + deviceID, _ := args["device_id"].(string) + if deviceID == "" { + deviceID, _ = args["entity_id"].(string) + } + action := normalizeAction(args) + + switch action { + case "turn_on", "turn_off": + status := "on" + if action == "turn_off" { + status = "off" + } + if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "status", status); err != nil { + return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "iot_control", Success: true, + Output: fmt.Sprintf("Device %s turned %s", deviceID, status)}, nil + + case "set_temperature": + value := toFloat64(args["value"]) + old := "" + if dev, err := t.iotClient.GetDevice(ctx, deviceID); err == nil { + old = fmt.Sprintf(" (was %.1fC)", dev.Temperature) + } + if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "temperature", value); err != nil { + return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "iot_control", Success: true, + Output: fmt.Sprintf("Temperature set to %.1fC%s", value, old)}, nil + + case "set_brightness": + value := toFloat64(args["value"]) + if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "brightness", value); err != nil { + return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "iot_control", Success: true, + Output: fmt.Sprintf("Brightness set to %.0f%%", value)}, nil + + case "set_position": + value := toFloat64(args["value"]) + if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "position", value); err != nil { + return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "iot_control", Success: true, + Output: fmt.Sprintf("Position set to %.0f%%", value)}, nil + + case "set_mode": + value, _ := args["value"].(string) + if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "mode", value); err != nil { + return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "iot_control", Success: true, + Output: fmt.Sprintf("Mode set to %s", value)}, nil + + case "set_color": + value, _ := args["value"].(string) + if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "color", value); err != nil { + return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "iot_control", Success: true, + Output: fmt.Sprintf("Color set to %s", value)}, nil + + case "toggle": + dev, err := t.iotClient.ToggleDevice(ctx, deviceID) + if err != nil { + return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "iot_control", Success: true, + Output: fmt.Sprintf("Device %s toggled to %s", deviceID, dev.Status)}, nil + + default: + return &sdk.ToolResult{ToolName: "iot_control", Success: false, + Error: fmt.Sprintf("unknown action: %s", action)}, nil + } +} + +func normalizeAction(args map[string]interface{}) string { + action, _ := args["action"].(string) + // Chinese aliases + switch action { + case "打开": + return "turn_on" + case "关闭", "关掉", "关上": + return "turn_off" + case "设置温度", "调温度": + return "set_temperature" + case "设置亮度", "调亮度": + return "set_brightness" + case "设置位置": + return "set_position" + case "设置模式": + return "set_mode" + case "设置颜色": + return "set_color" + case "开关", "切换": + return "toggle" + } + return action +} + +func toFloat64(v interface{}) float64 { + switch n := v.(type) { + case float64: + return n + case int: + return float64(n) + case int64: + return float64(n) + case string: + var f float64 + fmt.Sscanf(n, "%f", &f) + return f + } + return 0 +} diff --git a/iot_query/plugin.go b/iot_query/plugin.go new file mode 100644 index 0000000..4492766 --- /dev/null +++ b/iot_query/plugin.go @@ -0,0 +1,120 @@ +package iotquery + +import ( + "context" + "fmt" + + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +// IoTClient is the interface for IoT device access. +type IoTClient interface { + GetAllDevices(ctx context.Context) ([]sdk.IoTDeviceState, error) + GetDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error) +} + +type IoTQueryPlugin struct { + sdk.BasePlugin + iotClient IoTClient +} + +func NewIoTQueryPlugin(client IoTClient) *IoTQueryPlugin { + return &IoTQueryPlugin{iotClient: client} +} + +func (p *IoTQueryPlugin) Metadata() sdk.PluginMetadata { + return sdk.PluginMetadata{ + Name: "iot_query", DisplayName: "IoT Device Query", Version: "1.0.0", + Description: "Query smart home device status (single device or all devices)", + Category: "iot", Author: sdk.PluginAuthor{Name: "Cyrene Team"}, + } +} + +func (p *IoTQueryPlugin) Tools() []sdk.Tool { return []sdk.Tool{&IoTQueryTool{iotClient: p.iotClient}} } + +type IoTQueryTool struct { + sdk.BaseTool + iotClient IoTClient +} + +func (t *IoTQueryTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{ + ID: "iot_query", Name: "iot_query", DisplayName: "IoT Device Query", + Description: "Query smart home device status. Device status is typically auto-injected; use this only when status is stale.", + Category: "iot", Complexity: sdk.ComplexitySimple, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{"device_id": map[string]interface{}{"type": "string"}}, + }, + } +} + +func (t *IoTQueryTool) Validate(args map[string]interface{}) error { return nil } + +func (t *IoTQueryTool) Execute(ctx context.Context, args map[string]interface{}) (*sdk.ToolResult, error) { + if t.iotClient == nil { + return &sdk.ToolResult{ToolName: "iot_query", Success: false, Error: "IoT client not configured"}, nil + } + + deviceID, _ := args["device_id"].(string) + if deviceID != "" { + dev, err := t.iotClient.GetDevice(ctx, deviceID) + if err != nil { + return &sdk.ToolResult{ToolName: "iot_query", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "iot_query", Success: true, Output: formatDevice(dev)}, nil + } + + devices, err := t.iotClient.GetAllDevices(ctx) + if err != nil { + return &sdk.ToolResult{ToolName: "iot_query", Success: false, Error: err.Error()}, nil + } + if len(devices) == 0 { + return &sdk.ToolResult{ToolName: "iot_query", Success: true, Output: "No devices found"}, nil + } + var out string + for _, d := range devices { + out += formatDeviceLine(&d) + "\n" + } + return &sdk.ToolResult{ToolName: "iot_query", Success: true, Output: out}, nil +} + +func formatDevice(d *sdk.IoTDeviceState) string { + emoji := deviceEmoji(d.Type) + return fmt.Sprintf("%s %s (%s)\n Status: %s\n ID: %s", emoji, d.Name, d.Type, d.Status, d.ID) +} + +func formatDeviceLine(d *sdk.IoTDeviceState) string { + emoji := deviceEmoji(d.Type) + switch d.Type { + case "light": + return fmt.Sprintf("%s %s: %s (brightness: %d, color: %s)", emoji, d.Name, d.Status, d.Brightness, d.Color) + case "ac": + return fmt.Sprintf("%s %s: %s (mode: %s, temp: %.1fC)", emoji, d.Name, d.Status, d.Mode, d.Temperature) + case "curtain": + return fmt.Sprintf("%s %s: %s (position: %d%%)", emoji, d.Name, d.Status, d.Position) + case "sensor": + return fmt.Sprintf("%s %s: %.1f%s", emoji, d.Name, d.Value, d.Unit) + case "lock": + return fmt.Sprintf("%s %s: %s (battery: %d%%)", emoji, d.Name, d.Status, d.Battery) + default: + return fmt.Sprintf("%s %s: %s", emoji, d.Name, d.Status) + } +} + +func deviceEmoji(t string) string { + switch t { + case "light": + return "\U0001F4A1" + case "ac": + return "❄️" + case "curtain": + return "\U0001F3E0" + case "sensor": + return "\U0001F4CA" + case "lock": + return "\U0001F512" + default: + return "\U0001F4E6" + } +} diff --git a/json/plugin.go b/json/plugin.go new file mode 100644 index 0000000..5d8933f --- /dev/null +++ b/json/plugin.go @@ -0,0 +1,132 @@ +package jsonplugin + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +type JSONPlugin struct{ sdk.BasePlugin } + +func (p *JSONPlugin) Metadata() sdk.PluginMetadata { + return sdk.PluginMetadata{ + Name: "json", DisplayName: "JSON Processor", Version: "1.0.0", + Description: "JSON parsing, dot-path query, validation, pretty-print", + Category: "format", Author: sdk.PluginAuthor{Name: "Cyrene Team"}, + } +} + +func (p *JSONPlugin) Tools() []sdk.Tool { return []sdk.Tool{&JSONTool{}} } + +type JSONTool struct{ sdk.BaseTool } + +func (t *JSONTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{ + ID: "json_ops", Name: "json_ops", DisplayName: "JSON Processor", + Description: "JSON processing. Parse/pretty-print, query by dot-notation path, validate.", + Category: "format", Complexity: sdk.ComplexitySimple, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{"type": "string", "enum": []string{"parse", "query", "validate"}}, + "json_string": map[string]interface{}{"type": "string"}, + "path": map[string]interface{}{"type": "string"}, + }, + "required": []string{"action", "json_string"}, + }, + } +} + +func (t *JSONTool) Validate(args map[string]interface{}) error { + for _, k := range []string{"action", "json_string"} { + if _, ok := args[k]; !ok { + return fmt.Errorf("missing required parameter: %s", k) + } + } + return nil +} + +func (t *JSONTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) { + action, _ := args["action"].(string) + jsonStr, _ := args["json_string"].(string) + + switch action { + case "parse": + var v interface{} + if err := json.Unmarshal([]byte(jsonStr), &v); err != nil { + return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: err.Error()}, nil + } + pretty, err := json.MarshalIndent(v, "", " ") + if err != nil { + return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "json_ops", Success: true, Output: string(pretty)}, nil + + case "query": + var v interface{} + if err := json.Unmarshal([]byte(jsonStr), &v); err != nil { + return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: err.Error()}, nil + } + path, _ := args["path"].(string) + if path == "" { + return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: "path is required for query"}, nil + } + result, err := jsonPathQuery(v, path) + if err != nil { + return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: err.Error()}, nil + } + out, _ := json.Marshal(result) + return &sdk.ToolResult{ToolName: "json_ops", Success: true, Output: string(out)}, nil + + case "validate": + var v interface{} + if err := json.Unmarshal([]byte(jsonStr), &v); err != nil { + return &sdk.ToolResult{ToolName: "json_ops", Success: true, Output: "Invalid JSON: " + err.Error()}, nil + } + typeStr := "unknown" + switch v.(type) { + case map[string]interface{}: + typeStr = "object" + case []interface{}: + typeStr = "array" + case string: + typeStr = "string" + case float64: + typeStr = "number" + case bool: + typeStr = "boolean" + } + return &sdk.ToolResult{ToolName: "json_ops", Success: true, + Output: fmt.Sprintf("Valid JSON (type: %s, size: %d bytes)", typeStr, len(jsonStr))}, nil + } + return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: "unknown action: " + action}, nil +} + +func jsonPathQuery(root interface{}, path string) (interface{}, error) { + path = strings.TrimPrefix(path, "$.") + parts := strings.Split(path, ".") + current := root + for _, part := range parts { + switch v := current.(type) { + case map[string]interface{}: + var ok bool + current, ok = v[part] + if !ok { + return nil, fmt.Errorf("key %q not found", part) + } + case []interface{}: + idx, err := strconv.Atoi(part) + if err != nil || idx < 0 || idx >= len(v) { + return nil, fmt.Errorf("invalid array index: %s", part) + } + current = v[idx] + default: + return nil, fmt.Errorf("cannot traverse into %T at path segment %q", current, part) + } + } + return current, nil +} diff --git a/manager/manager.go b/manager/manager.go new file mode 100644 index 0000000..0b15703 --- /dev/null +++ b/manager/manager.go @@ -0,0 +1,226 @@ +package manager + +import ( + "context" + "fmt" + "sync" + "time" + + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +// PluginManager manages the lifecycle of all plugins and their tools. +type PluginManager struct { + mu sync.RWMutex + plugins map[string]*pluginEntry + registry *ToolRegistry + host sdk.HostAPI +} + +type pluginEntry struct { + instance sdk.Plugin + info sdk.PluginInfo + cancel context.CancelFunc +} + +func NewPluginManager(registry *ToolRegistry, host sdk.HostAPI) *PluginManager { + return &PluginManager{ + plugins: make(map[string]*pluginEntry), + registry: registry, + host: host, + } +} + +// Install registers a plugin instance. +func (m *PluginManager) Install(plugin sdk.Plugin) error { + meta := plugin.Metadata() + m.mu.Lock() + defer m.mu.Unlock() + + if _, exists := m.plugins[meta.Name]; exists { + return fmt.Errorf("plugin %q is already installed", meta.Name) + } + + m.plugins[meta.Name] = &pluginEntry{ + instance: plugin, + info: sdk.PluginInfo{ + Metadata: meta, + Status: sdk.StatusInstalled, + InstalledAt: time.Now(), + Enabled: false, + }, + } + return nil +} + +// Enable activates a plugin: Init → register tools → Start. +func (m *PluginManager) Enable(ctx context.Context, pluginName string) error { + m.mu.Lock() + entry, ok := m.plugins[pluginName] + m.mu.Unlock() + if !ok { + return fmt.Errorf("plugin %q not found", pluginName) + } + + m.mu.Lock() + entry.info.Status = sdk.StatusLoaded + m.mu.Unlock() + + meta := entry.instance.Metadata() + if err := entry.instance.Init(ctx, nil); err != nil { + m.mu.Lock() + entry.info.Status = sdk.StatusError + m.mu.Unlock() + return fmt.Errorf("plugin %q init failed: %w", meta.Name, err) + } + + pluginCtx, cancel := context.WithCancel(context.Background()) + if err := entry.instance.Start(pluginCtx, m.host); err != nil { + cancel() + m.mu.Lock() + entry.info.Status = sdk.StatusError + m.mu.Unlock() + return fmt.Errorf("plugin %q start failed: %w", meta.Name, err) + } + + tools := entry.instance.Tools() + toolIDs := make([]string, 0, len(tools)) + for _, t := range tools { + if err := m.registry.Register(t); err != nil { + m.registry.UnregisterAll(toolIDs) + cancel() + m.mu.Lock() + entry.info.Status = sdk.StatusError + m.mu.Unlock() + return fmt.Errorf("plugin %q tool register failed: %w", meta.Name, err) + } + toolIDs = append(toolIDs, t.Definition().ID) + } + + m.mu.Lock() + entry.cancel = cancel + entry.info.Status = sdk.StatusRunning + entry.info.Enabled = true + entry.info.Tools = toolIDs + m.mu.Unlock() + return nil +} + +// Disable stops a plugin and unregisters its tools. +func (m *PluginManager) Disable(ctx context.Context, pluginName string) error { + m.mu.Lock() + entry, ok := m.plugins[pluginName] + m.mu.Unlock() + if !ok { + return fmt.Errorf("plugin %q not found", pluginName) + } + + if err := entry.instance.Stop(ctx); err != nil { + return fmt.Errorf("plugin %q stop failed: %w", pluginName, err) + } + if entry.cancel != nil { + entry.cancel() + } + + m.registry.UnregisterAll(entry.info.Tools) + + m.mu.Lock() + entry.info.Status = sdk.StatusDisabled + entry.info.Enabled = false + entry.info.Tools = nil + m.mu.Unlock() + return nil +} + +// List returns info for all installed plugins. +func (m *PluginManager) List() []sdk.PluginInfo { + m.mu.RLock() + defer m.mu.RUnlock() + result := make([]sdk.PluginInfo, 0, len(m.plugins)) + for _, entry := range m.plugins { + result = append(result, entry.info) + } + return result +} + +// Get returns info for a single plugin. +func (m *PluginManager) Get(pluginName string) (*sdk.PluginInfo, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + entry, ok := m.plugins[pluginName] + if !ok { + return nil, false + } + info := entry.info + return &info, true +} + +// EnableAll starts all installed plugins. +func (m *PluginManager) EnableAll(ctx context.Context) []error { + m.mu.RLock() + names := make([]string, 0, len(m.plugins)) + for name := range m.plugins { + names = append(names, name) + } + m.mu.RUnlock() + + var errs []error + for _, name := range names { + if err := m.Enable(ctx, name); err != nil { + errs = append(errs, fmt.Errorf("%s: %w", name, err)) + } + } + return errs +} + +// Uninstall removes a plugin completely. +func (m *PluginManager) Uninstall(ctx context.Context, pluginName string) error { + m.mu.RLock() + entry, ok := m.plugins[pluginName] + m.mu.RUnlock() + if !ok { + return fmt.Errorf("plugin %q not found", pluginName) + } + if entry.info.Status == sdk.StatusRunning { + if err := m.Disable(ctx, pluginName); err != nil { + return err + } + } + m.mu.Lock() + defer m.mu.Unlock() + delete(m.plugins, pluginName) + return nil +} + +// Reload stops and re-starts a plugin. +func (m *PluginManager) Reload(ctx context.Context, pluginName string) error { + if err := m.Disable(ctx, pluginName); err != nil { + return fmt.Errorf("reload disable: %w", err) + } + return m.Enable(ctx, pluginName) +} + +// Shutdown stops all running plugins gracefully. +func (m *PluginManager) Shutdown(ctx context.Context) []error { + m.mu.RLock() + names := make([]string, 0, len(m.plugins)) + for name, entry := range m.plugins { + if entry.info.Status == sdk.StatusRunning { + names = append(names, name) + } + } + m.mu.RUnlock() + + var errs []error + for _, name := range names { + if err := m.Disable(ctx, name); err != nil { + errs = append(errs, err) + } + } + return errs +} + +// Registry returns the aggregated tool registry. +func (m *PluginManager) Registry() *ToolRegistry { + return m.registry +} diff --git a/manager/registry.go b/manager/registry.go new file mode 100644 index 0000000..b61a675 --- /dev/null +++ b/manager/registry.go @@ -0,0 +1,326 @@ +package manager + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +// CtxKeyIsAdmin is the context key for the admin flag. +type ctxKey string + +const CtxKeyIsAdmin ctxKey = "isAdmin" + +// adminOnlyTools lists tools that require admin permission to execute. +var adminOnlyTools = map[string]bool{ + "host_exec": true, + "os_exec": true, + "host_file": true, +} + +// IsAdminFromCtx returns true if the context carries an admin flag. +func IsAdminFromCtx(ctx context.Context) bool { + v, _ := ctx.Value(CtxKeyIsAdmin).(bool) + return v +} + +// CallLogRecord 工具调用记录 +type CallLogRecord struct { + CallID string `json:"call_id"` + ToolName string `json:"tool_name"` + Arguments string `json:"arguments"` + Output string `json:"output"` + Error string `json:"error"` + Success bool `json:"success"` + DurationMs int `json:"duration_ms"` + Timestamp int64 `json:"timestamp"` +} + +// callLogRing 线程安全的环形缓冲区 +type callLogRing struct { + mu sync.Mutex + records []CallLogRecord + capacity int + head int + size int +} + +func newCallLogRing(capacity int) *callLogRing { + return &callLogRing{capacity: capacity, records: make([]CallLogRecord, capacity)} +} + +func (r *callLogRing) push(rec CallLogRecord) { + r.mu.Lock() + defer r.mu.Unlock() + rec.CallID = fmt.Sprintf("%d", time.Now().UnixNano()) + rec.Timestamp = time.Now().UnixMilli() + r.records[r.head] = rec + r.head = (r.head + 1) % r.capacity + if r.size < r.capacity { + r.size++ + } +} + +func (r *callLogRing) getAll() []CallLogRecord { + r.mu.Lock() + defer r.mu.Unlock() + result := make([]CallLogRecord, r.size) + for i := 0; i < r.size; i++ { + idx := (r.head - 1 - i) % r.capacity + if idx < 0 { + idx += r.capacity + } + result[i] = r.records[idx] + } + return result +} + +func (r *callLogRing) statsByTool() map[string]map[string]interface{} { + r.mu.Lock() + defer r.mu.Unlock() + byTool := make(map[string]map[string]interface{}) + for i := 0; i < r.size; i++ { + idx := (r.head - 1 - i) % r.capacity + if idx < 0 { + idx += r.capacity + } + rec := r.records[idx] + if _, ok := byTool[rec.ToolName]; !ok { + byTool[rec.ToolName] = map[string]interface{}{ + "tool_name": rec.ToolName, "count": 0, "success_count": 0, + "fail_count": 0, "total_duration_ms": 0, + } + } + s := byTool[rec.ToolName] + s["count"] = s["count"].(int) + 1 + if rec.Success { + s["success_count"] = s["success_count"].(int) + 1 + } else { + s["fail_count"] = s["fail_count"].(int) + 1 + } + s["total_duration_ms"] = s["total_duration_ms"].(int) + rec.DurationMs + } + return byTool +} + +// ToolRegistry aggregates tool definitions from all running plugins and dispatches execution. +type ToolRegistry struct { + mu sync.RWMutex + tools map[string]sdk.Tool // tool ID -> Tool + callLog *callLogRing + enabled bool +} + +func NewToolRegistry() *ToolRegistry { + return &ToolRegistry{ + tools: make(map[string]sdk.Tool), + callLog: newCallLogRing(500), + enabled: true, + } +} + +// IsEnabled returns whether tool execution is enabled. +func (r *ToolRegistry) IsEnabled() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.enabled +} + +// SetEnabled enables or disables tool execution. +func (r *ToolRegistry) SetEnabled(enabled bool) { + r.mu.Lock() + defer r.mu.Unlock() + r.enabled = enabled +} + +// DefinitionNames returns all registered tool names. +func (r *ToolRegistry) DefinitionNames() []string { + r.mu.RLock() + defer r.mu.RUnlock() + names := make([]string, 0, len(r.tools)) + for id := range r.tools { + names = append(names, id) + } + return names +} + +func (r *ToolRegistry) Register(tool sdk.Tool) error { + r.mu.Lock() + defer r.mu.Unlock() + id := tool.Definition().ID + if _, exists := r.tools[id]; exists { + return fmt.Errorf("tool %q already registered", id) + } + r.tools[id] = tool + return nil +} + +func (r *ToolRegistry) Unregister(toolID string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.tools, toolID) +} + +func (r *ToolRegistry) Get(toolID string) (sdk.Tool, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + t, ok := r.tools[toolID] + return t, ok +} + +func (r *ToolRegistry) List() []sdk.Tool { + r.mu.RLock() + defer r.mu.RUnlock() + result := make([]sdk.Tool, 0, len(r.tools)) + for _, t := range r.tools { + result = append(result, t) + } + return result +} + +func (r *ToolRegistry) Definitions() []sdk.ToolDefinition { + r.mu.RLock() + defer r.mu.RUnlock() + defs := make([]sdk.ToolDefinition, 0, len(r.tools)) + for _, t := range r.tools { + defs = append(defs, t.Definition()) + } + return defs +} + +func (r *ToolRegistry) Execute(ctx context.Context, toolID string, args map[string]interface{}) (*sdk.ToolResult, error) { + r.mu.RLock() + tool, ok := r.tools[toolID] + r.mu.RUnlock() + + startTime := time.Now() + + if !ok { + r.callLog.push(CallLogRecord{ + ToolName: toolID, Error: fmt.Sprintf("tool %q not found", toolID), + Success: false, DurationMs: int(time.Since(startTime).Milliseconds()), + }) + return nil, fmt.Errorf("tool %q not found", toolID) + } + + if err := tool.Validate(args); err != nil { + r.callLog.push(CallLogRecord{ + ToolName: toolID, Error: err.Error(), Success: false, + DurationMs: int(time.Since(startTime).Milliseconds()), + }) + return &sdk.ToolResult{Success: false, Error: err.Error()}, nil + } + + // Admin-only tools: deny non-admin callers. + if adminOnlyTools[toolID] && !IsAdminFromCtx(ctx) { + errMsg := fmt.Sprintf("工具 %s 仅限管理员使用", toolID) + r.callLog.push(CallLogRecord{ + ToolName: toolID, Error: errMsg, Success: false, + DurationMs: int(time.Since(startTime).Milliseconds()), + }) + return &sdk.ToolResult{Success: false, Error: errMsg}, nil + } + + result, err := tool.Execute(ctx, args) + durationMs := int(time.Since(startTime).Milliseconds()) + + if err != nil { + r.callLog.push(CallLogRecord{ + ToolName: toolID, Error: err.Error(), Success: false, DurationMs: durationMs, + }) + return result, err + } + + var argsJSON string + if args != nil { + if b, _ := json.Marshal(args); b != nil { + argsJSON = string(b) + } + } + r.callLog.push(CallLogRecord{ + ToolName: toolID, Arguments: argsJSON, Output: result.Output, + Error: result.Error, Success: result.Success, DurationMs: durationMs, + }) + + return result, nil +} + +// UnregisterAll removes all tools matching given IDs. +func (r *ToolRegistry) UnregisterAll(toolIDs []string) { + r.mu.Lock() + defer r.mu.Unlock() + for _, id := range toolIDs { + delete(r.tools, id) + } +} + +// GetCallLogs 获取工具调用记录(最新在前,支持按工具名过滤、分页) +func (r *ToolRegistry) GetCallLogs(toolName string, limit, offset int) ([]CallLogRecord, int) { + all := r.callLog.getAll() + + // 过滤 + var filtered []CallLogRecord + if toolName == "" { + filtered = all + } else { + filtered = make([]CallLogRecord, 0) + for _, rec := range all { + if rec.ToolName == toolName { + filtered = append(filtered, rec) + } + } + } + + total := len(filtered) + + // 分页 + if offset >= len(filtered) { + return []CallLogRecord{}, total + } + page := filtered[offset:] + if limit > 0 && limit < len(page) { + page = page[:limit] + } + + return page, total +} + +// GetCallStats 获取工具调用统计 +func (r *ToolRegistry) GetCallStats() map[string]interface{} { + byTool := r.callLog.statsByTool() + totalCalls, successCount, failCount, totalDurationMs := 0, 0, 0, 0 + toolStats := make([]map[string]interface{}, 0, len(byTool)) + for _, s := range byTool { + count := s["count"].(int) + success := s["success_count"].(int) + fail := s["fail_count"].(int) + totalDur := s["total_duration_ms"].(int) + avgDur := 0.0 + if count > 0 { + avgDur = float64(totalDur) / float64(count) + } + s["avg_duration_ms"] = avgDur + delete(s, "total_duration_ms") + toolStats = append(toolStats, s) + totalCalls += count + successCount += success + failCount += fail + totalDurationMs += totalDur + } + avgDuration := 0.0 + if totalCalls > 0 { + avgDuration = float64(totalDurationMs) / float64(totalCalls) + } + successRate := 0.0 + if totalCalls > 0 { + successRate = float64(successCount) / float64(totalCalls) * 100 + } + return map[string]interface{}{ + "total_calls": totalCalls, "success_count": successCount, "fail_count": failCount, + "success_rate": successRate, "avg_duration_ms": avgDuration, "by_tool": toolStats, + } +} diff --git a/markdown/plugin.go b/markdown/plugin.go new file mode 100644 index 0000000..30513c4 --- /dev/null +++ b/markdown/plugin.go @@ -0,0 +1,184 @@ +package markdown + +import ( + "context" + "fmt" + "regexp" + "strings" + + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +type MarkdownPlugin struct{ sdk.BasePlugin } + +func (p *MarkdownPlugin) Metadata() sdk.PluginMetadata { + return sdk.PluginMetadata{ + Name: "markdown", DisplayName: "Markdown Processor", Version: "1.0.0", + Description: "Markdown processing: to HTML, extract text/links/code, generate TOC", + Category: "format", Author: sdk.PluginAuthor{Name: "Cyrene Team"}, + } +} + +func (p *MarkdownPlugin) Tools() []sdk.Tool { return []sdk.Tool{&MarkdownTool{}} } + +type MarkdownTool struct{ sdk.BaseTool } + +func (t *MarkdownTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{ + ID: "markdown", Name: "markdown", DisplayName: "Markdown Processor", + Description: "Markdown processing. Convert to HTML, extract plain text, extract links/code blocks, generate TOC.", + Category: "format", Complexity: sdk.ComplexitySimple, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{"type": "string", "enum": []string{"to_html", "to_text", "extract_links", "extract_code", "table_of_contents"}}, + "markdown": map[string]interface{}{"type": "string"}, + }, + "required": []string{"action", "markdown"}, + }, + } +} + +func (t *MarkdownTool) Validate(args map[string]interface{}) error { + for _, k := range []string{"action", "markdown"} { + if _, ok := args[k]; !ok { + return fmt.Errorf("missing required parameter: %s", k) + } + } + return nil +} + +func (t *MarkdownTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) { + action, _ := args["action"].(string) + md, _ := args["markdown"].(string) + + switch action { + case "to_html": + return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: mdToHTML(md)}, nil + + case "to_text": + text := md + reCode := regexp.MustCompile("(?s)```.*?```") + text = reCode.ReplaceAllString(text, "") + text = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "$1") + text = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(text, "$1") + text = regexp.MustCompile(`~~([^~]+)~~`).ReplaceAllString(text, "$1") + text = regexp.MustCompile(`^#{1,6}\s+`).ReplaceAllString(text, "") + text = regexp.MustCompile(`^[*-]\s+`).ReplaceAllString(text, "- ") + text = regexp.MustCompile(`^>\s+`).ReplaceAllString(text, "") + text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n") + return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: strings.TrimSpace(text)}, nil + + case "extract_links": + re := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) + matches := re.FindAllStringSubmatch(md, -1) + if len(matches) == 0 { + return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: "No links found"}, nil + } + var out strings.Builder + for i, m := range matches { + out.WriteString(fmt.Sprintf("%d. %s -> %s\n", i+1, m[1], m[2])) + } + return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: out.String()}, nil + + case "extract_code": + re := regexp.MustCompile("(?s)```(\\w*)\n?(.*?)```") + matches := re.FindAllStringSubmatch(md, -1) + if len(matches) == 0 { + return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: "No code blocks found"}, nil + } + var out strings.Builder + for i, m := range matches { + lang := m[1] + if lang == "" { + lang = "text" + } + code := m[2] + if len([]rune(code)) > 500 { + code = string([]rune(code)[:500]) + "..." + } + out.WriteString(fmt.Sprintf("--- Block %d (%s) ---\n%s\n\n", i+1, lang, code)) + } + return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: out.String()}, nil + + case "table_of_contents": + re := regexp.MustCompile(`(?m)^(#{1,6})\s+(.+)$`) + matches := re.FindAllStringSubmatch(md, -1) + if len(matches) == 0 { + return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: "No headings found"}, nil + } + var out strings.Builder + for _, m := range matches { + depth := len(m[1]) + indent := strings.Repeat(" ", depth-1) + out.WriteString(fmt.Sprintf("%s- %s\n", indent, m[2])) + } + return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: out.String()}, nil + } + return &sdk.ToolResult{ToolName: "markdown", Success: false, Error: "unknown action: " + action}, nil +} + +func mdToHTML(md string) string { + // Save code blocks + type placeholder struct { + orig string + content string + language string + } + blocks := []*placeholder{} + reCode := regexp.MustCompile("(?s)```(\\w*)\n?(.*?)```") + md = reCode.ReplaceAllStringFunc(md, func(s string) string { + m := reCode.FindStringSubmatch(s) + b := &placeholder{orig: fmt.Sprintf("\x00CODE%d\x00", len(blocks)), language: m[1], content: escapeHTML(m[2])} + blocks = append(blocks, b) + return b.orig + }) + + // Inline elements + md = regexp.MustCompile("`([^`]+)`").ReplaceAllString(md, "$1") + md = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`).ReplaceAllString(md, `$1`) + md = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(md, `$1`) + md = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(md, `$1`) + md = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(md, `$1`) + md = regexp.MustCompile(`~~([^~]+)~~`).ReplaceAllString(md, `$1`) + md = regexp.MustCompile(`(?m)^#{6}\s+(.+)$`).ReplaceAllString(md, `
$1
`) + md = regexp.MustCompile(`(?m)^#{5}\s+(.+)$`).ReplaceAllString(md, `
$1
`) + md = regexp.MustCompile(`(?m)^#{4}\s+(.+)$`).ReplaceAllString(md, `

$1

`) + md = regexp.MustCompile(`(?m)^#{3}\s+(.+)$`).ReplaceAllString(md, `

$1

`) + md = regexp.MustCompile(`(?m)^#{2}\s+(.+)$`).ReplaceAllString(md, `

$1

`) + md = regexp.MustCompile(`(?m)^#{1}\s+(.+)$`).ReplaceAllString(md, `

$1

`) + md = regexp.MustCompile(`(?m)^---\s*$`).ReplaceAllString(md, `
`) + md = regexp.MustCompile(`(?m)^>\s+(.+)$`).ReplaceAllString(md, `
$1
`) + + // Restore code blocks + for _, b := range blocks { + langAttr := "" + if b.language != "" { + langAttr = " class=\"language-" + b.language + "\"" + } + md = strings.Replace(md, b.orig, "
"+b.content+"
", 1) + } + + // Paragraphs + lines := strings.Split(md, "\n") + var out strings.Builder + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "<") { + out.WriteString(trimmed + "\n") + } else { + out.WriteString("

" + trimmed + "

\n") + } + } + return out.String() +} + +func escapeHTML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} diff --git a/random/plugin.go b/random/plugin.go new file mode 100644 index 0000000..0427b2f --- /dev/null +++ b/random/plugin.go @@ -0,0 +1,175 @@ +package random + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + mathrand "math/rand" + "strings" + + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +type RandomPlugin struct{ sdk.BasePlugin } + +func (p *RandomPlugin) Metadata() sdk.PluginMetadata { + return sdk.PluginMetadata{ + Name: "random", DisplayName: "Random Generator", Version: "1.0.0", + Description: "Random generation: numbers, UUIDs, secure passwords, pick/shuffle", + Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"}, + } +} + +func (p *RandomPlugin) Tools() []sdk.Tool { return []sdk.Tool{&RandomTool{}} } + +type RandomTool struct{ sdk.BaseTool } + +func (t *RandomTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{ + ID: "random", Name: "random", DisplayName: "Random Generator", + Description: "Random generation. Random numbers, UUID v4, secure passwords, pick from list, shuffle list.", + Category: "utility", Complexity: sdk.ComplexitySimple, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{"type": "string", "enum": []string{"number", "uuid", "password", "pick", "shuffle"}}, + "min": map[string]interface{}{"type": "number"}, + "max": map[string]interface{}{"type": "number"}, + "length": map[string]interface{}{"type": "number"}, + "items": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}}, + "count": map[string]interface{}{"type": "number"}, + }, + "required": []string{"action"}, + }, + } +} + +func (t *RandomTool) Validate(args map[string]interface{}) error { + if _, ok := args["action"]; !ok { + return fmt.Errorf("missing required parameter: action") + } + return nil +} + +func (t *RandomTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) { + action, _ := args["action"].(string) + + switch action { + case "number": + min := getIntArg(args, "min", 0) + max := getIntArg(args, "max", 100) + n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1))) + if err != nil { + return &sdk.ToolResult{ToolName: "random", Success: false, Error: err.Error()}, nil + } + return &sdk.ToolResult{ToolName: "random", Success: true, + Output: fmt.Sprintf("%d", int(n.Int64())+min)}, nil + + case "uuid": + uuid := make([]byte, 16) + rand.Read(uuid) + uuid[6] = (uuid[6] & 0x0f) | 0x40 + uuid[8] = (uuid[8] & 0x3f) | 0x80 + return &sdk.ToolResult{ToolName: "random", Success: true, + Output: fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:])}, nil + + case "password": + length := getIntArg(args, "length", 16) + if length < 4 { + length = 4 + } + if length > 128 { + length = 128 + } + upper := "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + lower := "abcdefghijklmnopqrstuvwxyz" + digits := "0123456789" + symbols := "!@#$%^&*()_+-=[]{}|;:,.<>?" + all := upper + lower + digits + symbols + bytes := make([]byte, length) + for i := range bytes { + idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(all)))) + bytes[i] = all[idx.Int64()] + } + return &sdk.ToolResult{ToolName: "random", Success: true, Output: string(bytes)}, nil + + case "pick": + items := getStringSliceArg(args, "items") + if len(items) == 0 { + return &sdk.ToolResult{ToolName: "random", Success: false, Error: "items list is empty"}, nil + } + count := getIntArg(args, "count", 1) + if count > len(items) { + count = len(items) + } + indices := shuffledIndices(len(items)) + picked := make([]string, count) + for i := 0; i < count; i++ { + picked[i] = items[indices[i]] + } + return &sdk.ToolResult{ToolName: "random", Success: true, Output: strings.Join(picked, ", ")}, nil + + case "shuffle": + items := getStringSliceArg(args, "items") + indices := shuffledIndices(len(items)) + shuffled := make([]string, len(items)) + for i, idx := range indices { + shuffled[i] = items[idx] + } + return &sdk.ToolResult{ToolName: "random", Success: true, Output: strings.Join(shuffled, ", ")}, nil + } + return &sdk.ToolResult{ToolName: "random", Success: false, Error: "unknown action: " + action}, nil +} + +func getIntArg(args map[string]interface{}, key string, defaultVal int) int { + v, ok := args[key] + if !ok { + return defaultVal + } + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + case int64: + return int(n) + } + return defaultVal +} + +func getStringSliceArg(args map[string]interface{}, key string) []string { + v, ok := args[key] + if !ok { + return nil + } + switch s := v.(type) { + case []string: + return s + case []interface{}: + result := make([]string, len(s)) + for i, item := range s { + result[i] = fmt.Sprint(item) + } + return result + } + return nil +} + +func shuffledIndices(n int) []int { + indices := make([]int, n) + for i := range indices { + indices[i] = i + } + for i := n - 1; i > 0; i-- { + jBig, err := rand.Int(rand.Reader, big.NewInt(int64(i+1))) + if err != nil { + j := mathrand.Intn(i + 1) + indices[i], indices[j] = indices[j], indices[i] + continue + } + j := int(jBig.Int64()) + indices[i], indices[j] = indices[j], indices[i] + } + return indices +} diff --git a/sdk/base.go b/sdk/base.go new file mode 100644 index 0000000..b7fb5f2 --- /dev/null +++ b/sdk/base.go @@ -0,0 +1,40 @@ +package sdk + +import ( + "context" + "fmt" +) + +// BasePlugin provides default implementations for optional Plugin methods. +type BasePlugin struct{} + +func (BasePlugin) Init(_ context.Context, _ PluginConfig) error { return nil } + +func (BasePlugin) Start(_ context.Context, _ HostAPI) error { return nil } + +func (BasePlugin) Stop(_ context.Context) error { return nil } + +func (BasePlugin) Health(_ context.Context) error { return nil } + +// BaseTool provides a Validate default that checks required parameters. +type BaseTool struct { + Def ToolDefinition + Required []string +} + +func (b BaseTool) Definition() ToolDefinition { return b.Def } + +func (b BaseTool) Complexity() ToolComplexity { return ComplexitySimple } + +func (b BaseTool) Validate(args map[string]interface{}) error { + for _, key := range b.Required { + if _, ok := args[key]; !ok { + return fmt.Errorf("missing required parameter: %s", key) + } + } + return nil +} + +func (b BaseTool) Execute(_ context.Context, _ map[string]interface{}) (*ToolResult, error) { + return nil, fmt.Errorf("not implemented") +} diff --git a/sdk/permissions.go b/sdk/permissions.go new file mode 100644 index 0000000..65f5717 --- /dev/null +++ b/sdk/permissions.go @@ -0,0 +1,35 @@ +package sdk + +// PluginPermissions defines what a plugin is allowed to do. +type PluginPermissions struct { + NetworkAllowed bool `json:"networkAllowed"` + AllowedHosts []string `json:"allowedHosts,omitempty"` + IoTRead bool `json:"iotRead"` + IoTWrite bool `json:"iotWrite"` + MemoryRead bool `json:"memoryRead"` + MemoryWrite bool `json:"memoryWrite"` + FileRead bool `json:"fileRead"` + FileWrite bool `json:"fileWrite"` + AllowedPaths []string `json:"allowedPaths,omitempty"` + ExecAllowed bool `json:"execAllowed"` + MaxCPUPercent float64 `json:"maxCPUPercent"` + MaxMemoryMB int `json:"maxMemoryMB"` +} + +// DefaultPermissions returns a safe default permission set. +func DefaultPermissions() PluginPermissions { + return PluginPermissions{ + NetworkAllowed: false, + AllowedHosts: []string{}, + IoTRead: false, + IoTWrite: false, + MemoryRead: false, + MemoryWrite: false, + FileRead: false, + FileWrite: false, + AllowedPaths: []string{}, + ExecAllowed: false, + MaxCPUPercent: 10.0, + MaxMemoryMB: 128, + } +} diff --git a/sdk/plugin.go b/sdk/plugin.go new file mode 100644 index 0000000..a030f12 --- /dev/null +++ b/sdk/plugin.go @@ -0,0 +1,49 @@ +package sdk + +import ( + "context" + "net/http" +) + +// Plugin is the main interface every plugin must implement. +type Plugin interface { + Metadata() PluginMetadata + Init(ctx context.Context, config PluginConfig) error + Start(ctx context.Context, host HostAPI) error + Stop(ctx context.Context) error + Health(ctx context.Context) error + Tools() []Tool +} + +// Tool is the interface every tool must implement. +type Tool interface { + Definition() ToolDefinition + Execute(ctx context.Context, args map[string]interface{}) (*ToolResult, error) + Validate(args map[string]interface{}) error + Complexity() ToolComplexity +} + +// ComplexTool extends Tool for async multi-round execution. +type ComplexTool interface { + Tool + ExecuteAsync(ctx context.Context, args map[string]interface{}) (<-chan ToolProgress, error) + Cancel(ctx context.Context, executionID string) error +} + +// HostAPI gives plugins access to Cyrene core capabilities. +type HostAPI interface { + CallLLM(ctx context.Context, messages []LLMMessage) (*LLMResponse, error) + SearchMemory(ctx context.Context, userID, query string, limit int) ([]MemoryEntry, error) + StoreMemory(ctx context.Context, entry MemoryEntry) error + Logger() Logger + GetConfig(key string) (string, error) + SetConfig(key, value string) error + PublishEvent(ctx context.Context, event map[string]interface{}) error + HTTPClient() *http.Client +} + +// Logger is a minimal logging interface for plugins. +type Logger interface { + Printf(format string, args ...interface{}) + Println(args ...interface{}) +} diff --git a/sdk/types.go b/sdk/types.go new file mode 100644 index 0000000..6b18ccf --- /dev/null +++ b/sdk/types.go @@ -0,0 +1,134 @@ +package sdk + +import "time" + +// ToolComplexity grades tools into simple (single-call, <2s) and complex (multi-round, async). +type ToolComplexity string + +const ( + ComplexitySimple ToolComplexity = "simple" + ComplexityComplex ToolComplexity = "complex" +) + +// PluginMetadata describes a plugin's identity and requirements. +type PluginMetadata struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + MinCyreneVersion string `json:"minCyreneVersion"` + Author PluginAuthor `json:"author"` + Description string `json:"description"` + License string `json:"license"` + Keywords []string `json:"keywords,omitempty"` + Category string `json:"category"` + Dependencies map[string]string `json:"dependencies,omitempty"` // plugin name -> version range + Homepage string `json:"homepage,omitempty"` + Repository string `json:"repository,omitempty"` +} + +type PluginAuthor struct { + Name string `json:"name"` + Email string `json:"email,omitempty"` + URL string `json:"url,omitempty"` +} + +// PluginConfig holds runtime configuration for a plugin. +type PluginConfig map[string]interface{} + +// ToolDefinition describes a tool's interface for LLM function calling. +type ToolDefinition struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Category string `json:"category"` + Complexity ToolComplexity `json:"complexity"` + Parameters map[string]interface{} `json:"parameters"` + Returns map[string]interface{} `json:"returns,omitempty"` + TimeoutMs int `json:"timeout_ms,omitempty"` + MaxRetries int `json:"max_retries,omitempty"` + DangerLevel string `json:"danger_level,omitempty"` // low / medium / high +} + +// ToolResult is the standard tool execution result. +type ToolResult struct { + ToolName string `json:"tool_name"` + Success bool `json:"success"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` +} + +// ToolProgress reports execution progress for complex (async) tools. +type ToolProgress struct { + ExecutionID string `json:"execution_id"` + Status string `json:"status"` // started / running / completed / failed / cancelled + Progress float64 `json:"progress"` // 0.0 - 1.0 + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + Result *ToolResult `json:"result,omitempty"` +} + +// PluginStatus represents the current lifecycle state of a plugin. +type PluginStatus string + +const ( + StatusInstalled PluginStatus = "installed" + StatusLoaded PluginStatus = "loaded" + StatusRunning PluginStatus = "running" + StatusPaused PluginStatus = "paused" + StatusError PluginStatus = "error" + StatusDisabled PluginStatus = "disabled" +) + +// PluginInfo is the runtime view of an installed plugin. +type PluginInfo struct { + Metadata PluginMetadata `json:"metadata"` + Status PluginStatus `json:"status"` + Tools []string `json:"tools"` // tool IDs provided by this plugin + InstalledAt time.Time `json:"installed_at"` + Enabled bool `json:"enabled"` +} + +// LLMMessage is a message in an LLM conversation. +type LLMMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// LLMResponse is the result of an LLM call. +type LLMResponse struct { + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` +} + +// ToolCall represents a tool call requested by the LLM. +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments"` +} + +// IoTDeviceState is the shared device state across IoT plugins. +type IoTDeviceState struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Brightness int `json:"brightness,omitempty"` + Color string `json:"color,omitempty"` + Mode string `json:"mode,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + Position int `json:"position,omitempty"` + Value float64 `json:"value,omitempty"` + Unit string `json:"unit,omitempty"` + Battery int `json:"battery,omitempty"` +} + +// MemoryEntry is a memory record. +type MemoryEntry struct { + UserID string `json:"user_id"` + Content string `json:"content"` + Type string `json:"type"` + Meta map[string]interface{} `json:"meta,omitempty"` +} diff --git a/text/plugin.go b/text/plugin.go new file mode 100644 index 0000000..d7f7b51 --- /dev/null +++ b/text/plugin.go @@ -0,0 +1,177 @@ +package text + +import ( + "context" + "fmt" + "regexp" + "strings" + "unicode" + + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +type TextPlugin struct{ sdk.BasePlugin } + +func (p *TextPlugin) Metadata() sdk.PluginMetadata { + return sdk.PluginMetadata{ + Name: "text", DisplayName: "Text Processing", Version: "1.0.0", + Description: "Text processing: count stats, summarize, regex extract", + Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"}, + } +} + +func (p *TextPlugin) Tools() []sdk.Tool { return []sdk.Tool{&TextTool{}} } + +type TextTool struct{ sdk.BaseTool } + +func (t *TextTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{ + ID: "text", Name: "text", DisplayName: "Text Processing", + Description: "Text processing. Count stats, summarize, translate, regex extract.", + Category: "utility", Complexity: sdk.ComplexitySimple, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{"type": "string", "enum": []string{"count", "summarize", "translate", "extract"}}, + "text": map[string]interface{}{"type": "string"}, + "target_lang": map[string]interface{}{"type": "string", "enum": []string{"en", "zh", "ja", "ko", "fr", "de"}}, + "pattern": map[string]interface{}{"type": "string"}, + }, + "required": []string{"action", "text"}, + }, + } +} + +func (t *TextTool) Validate(args map[string]interface{}) error { + for _, k := range []string{"action", "text"} { + if _, ok := args[k]; !ok { + return fmt.Errorf("missing required parameter: %s", k) + } + } + return nil +} + +func (t *TextTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) { + action, _ := args["action"].(string) + txt, _ := args["text"].(string) + + switch action { + case "count": + charsNoSpace := 0 + chineseChars := 0 + for _, r := range txt { + if !unicode.IsSpace(r) { + charsNoSpace++ + } + if unicode.Is(unicode.Han, r) { + chineseChars++ + } + } + words := strings.Fields(txt) + lines := strings.Split(txt, "\n") + paragraphs := regexp.MustCompile(`\n\s*\n`).Split(txt, -1) + return &sdk.ToolResult{ToolName: "text", Success: true, Output: fmt.Sprintf( + "Characters: %d (no spaces: %d, Chinese: %d)\nBytes: %d\nWords: %d\nLines: %d\nParagraphs: %d", + len([]rune(txt)), charsNoSpace, chineseChars, len(txt), len(words), len(lines), len(paragraphs))}, nil + + case "summarize": + paragraphs := regexp.MustCompile(`\n\s*\n`).Split(txt, -1) + firstPara := "" + if len(paragraphs) > 0 { + runes := []rune(paragraphs[0]) + if len(runes) > 300 { + runes = runes[:300] + } + firstPara = string(runes) + } + sentences := regexp.MustCompile(`[。!?.!?]+`).Split(txt, -1) + keywords := []string{"重要", "关键", "因此", "总结", "important", "key", "conclusion", "therefore"} + type scored struct { + text string + score int + } + var scoredSents []scored + for _, s := range sentences { + s = strings.TrimSpace(s) + if len([]rune(s)) < 10 { + continue + } + score := len([]rune(s)) + for _, kw := range keywords { + if strings.Contains(strings.ToLower(s), strings.ToLower(kw)) { + score += 20 + } + } + scoredSents = append(scoredSents, scored{s, score}) + } + var out strings.Builder + out.WriteString(fmt.Sprintf("First paragraph: %s\n\nKey sentences:\n", firstPara)) + count := 0 + for i := 0; i < len(scoredSents) && count < 5; i++ { + out.WriteString(fmt.Sprintf("- %s\n", scoredSents[i].text)) + count++ + } + return &sdk.ToolResult{ToolName: "text", Success: true, Output: out.String()}, nil + + case "translate": + targetLang, _ := args["target_lang"].(string) + if targetLang == "" { + targetLang = "en" + } + return &sdk.ToolResult{ToolName: "text", Success: true, Output: fmt.Sprintf( + "[Translation request] Please translate the following text to %s.\n\nOriginal text:\n%s", targetLang, txt)}, nil + + case "extract": + pattern, _ := args["pattern"].(string) + var out strings.Builder + extracted := false + if pattern == "" || pattern == "email" { + re := regexp.MustCompile(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`) + if matches := re.FindAllString(txt, -1); len(matches) > 0 { + out.WriteString("Emails:\n") + for _, m := range matches { + out.WriteString(fmt.Sprintf("- %s\n", m)) + } + extracted = true + } + } + if pattern == "" || pattern == "phone" { + re := regexp.MustCompile(`1[3-9]\d{9}`) + if matches := re.FindAllString(txt, -1); len(matches) > 0 { + out.WriteString("Phone numbers:\n") + for _, m := range matches { + out.WriteString(fmt.Sprintf("- %s\n", m)) + } + extracted = true + } + } + if pattern == "" || pattern == "url" { + re := regexp.MustCompile(`https?://[^\s<>"{}|\\^` + "`" + `\[\]]+`) + if matches := re.FindAllString(txt, -1); len(matches) > 0 { + out.WriteString("URLs:\n") + for _, m := range matches { + out.WriteString(fmt.Sprintf("- %s\n", m)) + } + extracted = true + } + } + if !extracted && pattern != "" && pattern != "email" && pattern != "phone" && pattern != "url" { + re, err := regexp.Compile(pattern) + if err != nil { + return &sdk.ToolResult{ToolName: "text", Success: false, Error: "Invalid regex: " + err.Error()}, nil + } + if matches := re.FindAllString(txt, -1); len(matches) > 0 { + out.WriteString(fmt.Sprintf("Pattern matches (%s):\n", pattern)) + for _, m := range matches { + out.WriteString(fmt.Sprintf("- %s\n", m)) + } + extracted = true + } + } + if !extracted { + return &sdk.ToolResult{ToolName: "text", Success: true, Output: "No matches found"}, nil + } + return &sdk.ToolResult{ToolName: "text", Success: true, Output: out.String()}, nil + } + return &sdk.ToolResult{ToolName: "text", Success: false, Error: "unknown action: " + action}, nil +} diff --git a/web_fetch/plugin.go b/web_fetch/plugin.go new file mode 100644 index 0000000..e569760 --- /dev/null +++ b/web_fetch/plugin.go @@ -0,0 +1,113 @@ +package webfetch + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +type WebFetchPlugin struct { + sdk.BasePlugin + client *http.Client +} + +func NewWebFetchPlugin() *WebFetchPlugin { + return &WebFetchPlugin{client: &http.Client{Timeout: 15 * time.Second}} +} + +func (p *WebFetchPlugin) Metadata() sdk.PluginMetadata { + return sdk.PluginMetadata{ + Name: "web_fetch", DisplayName: "Web Fetch", Version: "1.0.0", + Description: "Fetch and extract text content from URLs", + Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"}, + } +} + +func (p *WebFetchPlugin) Tools() []sdk.Tool { return []sdk.Tool{&WebFetchTool{client: p.client}} } + +type WebFetchTool struct { + sdk.BaseTool + client *http.Client +} + +func (t *WebFetchTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{ + ID: "web_fetch", Name: "web_fetch", DisplayName: "Web Fetch", + Description: "Fetch content of a specified URL. Returns plain text summary (first 2000 characters). HTTP/HTTPS only.", + Category: "network", Complexity: sdk.ComplexitySimple, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{"url": map[string]interface{}{"type": "string"}}, + "required": []string{"url"}, + }, + } +} + +func (t *WebFetchTool) Validate(args map[string]interface{}) error { + if _, ok := args["url"]; !ok { + return fmt.Errorf("missing required parameter: url") + } + return nil +} + +func (t *WebFetchTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) { + urlStr, _ := args["url"].(string) + if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") { + return &sdk.ToolResult{ToolName: "web_fetch", Success: false, Error: "only http/https URLs allowed"}, nil + } + + req, _ := http.NewRequest("GET", urlStr, nil) + req.Header.Set("User-Agent", "CyreneBot/1.0") + resp, err := t.client.Do(req) + if err != nil { + return &sdk.ToolResult{ToolName: "web_fetch", Success: false, Error: err.Error()}, nil + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 100*1024)) + text := stripHTMLFull(string(bodyBytes)) + text = removeBlankLines(text) + runes := []rune(text) + if len(runes) > 2000 { + text = string(runes[:2000]) + "..." + } + return &sdk.ToolResult{ToolName: "web_fetch", Success: true, Output: fmt.Sprintf( + "URL: %s\nStatus: %d\nContent-Type: %s\n\n%s", + urlStr, resp.StatusCode, resp.Header.Get("Content-Type"), text)}, nil +} + +func stripHTMLFull(s string) string { + result := make([]rune, 0, len([]rune(s))) + inTag := false + for _, r := range s { + if r == '<' { + inTag = true + continue + } + if r == '>' { + inTag = false + continue + } + if !inTag { + result = append(result, r) + } + } + return string(result) +} + +func removeBlankLines(s string) string { + lines := strings.Split(s, "\n") + var result []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + result = append(result, trimmed) + } + } + return strings.Join(result, "\n") +} diff --git a/web_search/plugin.go b/web_search/plugin.go new file mode 100644 index 0000000..179a754 --- /dev/null +++ b/web_search/plugin.go @@ -0,0 +1,239 @@ +package websearch + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" +) + +type WebSearchPlugin struct { + sdk.BasePlugin + client *http.Client + searxngURL string +} + +func NewWebSearchPlugin() *WebSearchPlugin { + return &WebSearchPlugin{client: &http.Client{Timeout: 10 * time.Second}} +} + +func NewWebSearchPluginWithURL(searxngURL string) *WebSearchPlugin { + return &WebSearchPlugin{ + client: &http.Client{Timeout: 10 * time.Second}, + searxngURL: strings.TrimRight(searxngURL, "/"), + } +} + +func (p *WebSearchPlugin) Metadata() sdk.PluginMetadata { + return sdk.PluginMetadata{ + Name: "web_search", DisplayName: "Web Search", Version: "1.1.0", + Description: "Search the internet via SearXNG (or DuckDuckGo fallback)", + Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"}, + } +} + +func (p *WebSearchPlugin) Tools() []sdk.Tool { + return []sdk.Tool{&WebSearchTool{client: p.client, searxngURL: p.searxngURL}} +} + +type WebSearchTool struct { + sdk.BaseTool + client *http.Client + searxngURL string +} + +// ---- SearXNG response types ---- +type searxngResponse struct { + Query string `json:"query"` + NumberOrResults int `json:"number_of_results"` + Results []searxngResult `json:"results"` + Answers []string `json:"answers"` + Suggestions []string `json:"suggestions"` +} + +type searxngResult struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + Engine string `json:"engine"` + Score float64 `json:"score"` +} + +// ---- DuckDuckGo response types (fallback) ---- +type ddgResponse struct { + Abstract string `json:"Abstract"` + AbstractText string `json:"AbstractText"` + Answer string `json:"Answer"` + Heading string `json:"Heading"` + Results []ddgTopic `json:"Results"` + RelatedTopics []ddgTopic `json:"RelatedTopics"` +} + +type ddgTopic struct { + FirstURL string `json:"FirstURL"` + Text string `json:"Text"` +} + +func (t *WebSearchTool) Definition() sdk.ToolDefinition { + return sdk.ToolDefinition{ + ID: "web_search", Name: "web_search", DisplayName: "Web Search", + Description: "Search the internet. SearXNG backend with DuckDuckGo fallback. Returns up to 5 results.", + Category: "network", Complexity: sdk.ComplexitySimple, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{"query": map[string]interface{}{"type": "string"}}, + "required": []string{"query"}, + }, + } +} + +func (t *WebSearchTool) Validate(args map[string]interface{}) error { + if _, ok := args["query"]; !ok { + return fmt.Errorf("missing required parameter: query") + } + return nil +} + +func (t *WebSearchTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) { + query, _ := args["query"].(string) + if query == "" { + return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: "empty query"}, nil + } + + if t.searxngURL != "" { + return t.searchViaSearXNG(query) + } + return t.searchViaDuckDuckGo(query) +} + +// China-accessible SearXNG engines (baidu, sogou, 360search, bing all work from China) +const searxngEngines = "bing,sogou,360search,baidu" + +func (t *WebSearchTool) searchViaSearXNG(query string) (*sdk.ToolResult, error) { + apiURL := fmt.Sprintf("%s/search?format=json&engines=%s&q=%s", + t.searxngURL, searxngEngines, url.QueryEscape(query)) + + resp, err := t.client.Get(apiURL) + if err != nil { + return &sdk.ToolResult{ToolName: "web_search", Success: false, + Error: fmt.Sprintf("SearXNG request failed: %v", err)}, nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return &sdk.ToolResult{ToolName: "web_search", Success: false, + Error: fmt.Sprintf("SearXNG returned HTTP %d", resp.StatusCode)}, nil + } + + var result searxngResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return &sdk.ToolResult{ToolName: "web_search", Success: false, + Error: fmt.Sprintf("SearXNG parse error: %v", err)}, nil + } + + var out strings.Builder + out.WriteString(fmt.Sprintf("搜索: %s (共%d条结果)\n\n", query, result.NumberOrResults)) + + // 优先显示答案(如 Wikipedia infobox) + for _, answer := range result.Answers { + out.WriteString(fmt.Sprintf("📌 %s\n\n", answer)) + } + + // 搜索结果(最多5条,按score排序) + count := 0 + for _, r := range result.Results { + if count >= 5 { + break + } + if r.Title == "" || r.URL == "" { + continue + } + content := cleanSnippet(r.Content) + out.WriteString(fmt.Sprintf("%d. **%s**\n %s\n %s\n\n", count+1, r.Title, r.URL, content)) + count++ + } + + if out.Len() == 0 { + return &sdk.ToolResult{ToolName: "web_search", Success: true, + Output: fmt.Sprintf("未找到与「%s」相关的结果。", query)}, nil + } + return &sdk.ToolResult{ToolName: "web_search", Success: true, Output: out.String()}, nil +} + +func (t *WebSearchTool) searchViaDuckDuckGo(query string) (*sdk.ToolResult, error) { + apiURL := fmt.Sprintf("https://api.duckduckgo.com/?q=%s&format=json&no_html=1", url.QueryEscape(query)) + resp, err := t.client.Get(apiURL) + if err != nil { + return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: err.Error()}, nil + } + defer resp.Body.Close() + + var result ddgResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: err.Error()}, nil + } + + var out strings.Builder + if result.Answer != "" { + out.WriteString(fmt.Sprintf("Answer: %s\n\n", result.Answer)) + } + if result.AbstractText != "" { + text := result.AbstractText + if len([]rune(text)) > 500 { + text = string([]rune(text)[:500]) + "..." + } + out.WriteString(fmt.Sprintf("Abstract: %s\n\n", stripHTML(text))) + } + topics := result.Results + if len(topics) == 0 { + topics = result.RelatedTopics + } + count := 0 + for _, topic := range topics { + if count >= 5 { + break + } + if topic.Text == "" { + continue + } + out.WriteString(fmt.Sprintf("%d. %s (%s)\n", count+1, stripHTML(topic.Text), topic.FirstURL)) + count++ + } + if out.Len() == 0 { + return &sdk.ToolResult{ToolName: "web_search", Success: true, + Output: "No results found for: " + query}, nil + } + return &sdk.ToolResult{ToolName: "web_search", Success: true, Output: out.String()}, nil +} + +func cleanSnippet(s string) string { + runes := []rune(strings.TrimSpace(s)) + if len(runes) > 200 { + return string(runes[:200]) + "..." + } + return string(runes) +} + +func stripHTML(s string) string { + result := make([]rune, 0, len([]rune(s))) + inTag := false + for _, r := range s { + if r == '<' { + inTag = true + continue + } + if r == '>' { + inTag = false + continue + } + if !inTag { + result = append(result, r) + } + } + return strings.TrimSpace(string(result)) +}