feat: Phase 3 插件与工具系统 — Plugin SDK + Plugin Manager + 13内置插件 (40文件, 3293行)
- Plugin SDK: Plugin/Tool/ComplexTool/HostAPI 标准化接口 - Plugin Manager: 插件生命周期管理 (Install/Enable/Disable/Uninstall/Reload) - Tool Registry: 聚合工具注册表 (Register/Execute/Dispatch) - 13 个内置插件: 将原有硬编码工具迁移为标准插件格式 - REST API: 11 个端点 (net/http, 零外部依赖) - ai-core 集成: PluginManagerClient 替代本地工具调用 - plugin.json 元数据: 每个插件含完整 author/version/category/permissions Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PluginManagerClient calls the plugin-manager service.
|
||||||
|
type PluginManagerClient struct {
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// PMToolDefinition matches the plugin-manager tool definition format.
|
||||||
|
type PMToolDefinition struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Complexity string `json:"complexity"`
|
||||||
|
Parameters map[string]interface{} `json:"parameters"`
|
||||||
|
DangerLevel string `json:"danger_level,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PMToolResult matches the plugin-manager execution result.
|
||||||
|
type PMToolResult struct {
|
||||||
|
ToolName string `json:"tool_name"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PMPluginInfo matches plugin-manager plugin info.
|
||||||
|
type PMPluginInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPluginManagerClient(baseURL string) *PluginManagerClient {
|
||||||
|
return &PluginManagerClient{
|
||||||
|
baseURL: baseURL,
|
||||||
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetToolDefinitions fetches all tool definitions from plugin-manager.
|
||||||
|
func (c *PluginManagerClient) GetToolDefinitions(ctx context.Context) ([]PMToolDefinition, error) {
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/tools", nil)
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("plugin-manager GetToolDefinitions: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Tools []PMToolDefinition `json:"tools"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||||
|
return nil, fmt.Errorf("plugin-manager decode tools: %w", err)
|
||||||
|
}
|
||||||
|
return body.Tools, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteTool calls a tool on plugin-manager by ID.
|
||||||
|
func (c *PluginManagerClient) ExecuteTool(ctx context.Context, toolID string, args map[string]interface{}) (*PMToolResult, error) {
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{"arguments": args})
|
||||||
|
url := fmt.Sprintf("%s/api/v1/tools/%s/execute", c.baseURL, toolID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("plugin-manager ExecuteTool: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result PMToolResult
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("plugin-manager decode result: %w", err)
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPlugins fetches all installed plugins from plugin-manager.
|
||||||
|
func (c *PluginManagerClient) ListPlugins(ctx context.Context) ([]PMPluginInfo, error) {
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/plugins", nil)
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Plugins []PMPluginInfo `json:"plugins"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return body.Plugins, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdaptDefinitions converts PM tool definitions to ai-core ToolDefinition format.
|
||||||
|
func (c *PluginManagerClient) AdaptDefinitions(ctx context.Context) ([]ToolDefinition, error) {
|
||||||
|
pmDefs, err := c.GetToolDefinitions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defs := make([]ToolDefinition, 0, len(pmDefs))
|
||||||
|
for _, d := range pmDefs {
|
||||||
|
defs = append(defs, ToolDefinition{
|
||||||
|
Name: d.Name,
|
||||||
|
Description: d.Description,
|
||||||
|
Parameters: d.Parameters,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return defs, nil
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use (
|
|||||||
./iot-debug-service
|
./iot-debug-service
|
||||||
./memory-service
|
./memory-service
|
||||||
./pkg/logger
|
./pkg/logger
|
||||||
|
./plugin-manager
|
||||||
./tool-engine
|
./tool-engine
|
||||||
./voice-service
|
./voice-service
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/manager"
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/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
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
||||||
|
iotquery "github.com/yourname/cyrene-ai/plugin-manager/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
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/config"
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/handler"
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/manager"
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/plugins/calculator"
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/plugins/crypto"
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/plugins/datetime"
|
||||||
|
fileplugin "github.com/yourname/cyrene-ai/plugin-manager/plugins/file"
|
||||||
|
httpplugin "github.com/yourname/cyrene-ai/plugin-manager/plugins/http"
|
||||||
|
iotcontrol "github.com/yourname/cyrene-ai/plugin-manager/plugins/iot_control"
|
||||||
|
iotquery "github.com/yourname/cyrene-ai/plugin-manager/plugins/iot_query"
|
||||||
|
jsonplugin "github.com/yourname/cyrene-ai/plugin-manager/plugins/json"
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/plugins/markdown"
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/plugins/random"
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/plugins/text"
|
||||||
|
webfetch "github.com/yourname/cyrene-ai/plugin-manager/plugins/web_fetch"
|
||||||
|
websearch "github.com/yourname/cyrene-ai/plugin-manager/plugins/web_search"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/yourname/cyrene-ai/plugin-manager
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/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("/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: /api/v1/plugins/{id}[/enable|/disable|/reload|/tools]
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/plugins/")
|
||||||
|
parts := strings.SplitN(path, "/", 2)
|
||||||
|
pluginID := parts[0]
|
||||||
|
|
||||||
|
if pluginID == "" {
|
||||||
|
// GET /api/v1/plugins (handled by listPlugins normally)
|
||||||
|
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
|
||||||
|
|
||||||
|
// Check if this is an execute call
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package manager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PluginManager manages the lifecycle of all plugins and their tools.
|
||||||
|
type PluginManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
plugins map[string]*pluginEntry // plugin name -> entry
|
||||||
|
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. For built-in plugins this is called at startup.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register all tools from this plugin.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall removes a plugin completely.
|
||||||
|
func (m *PluginManager) Uninstall(ctx context.Context, pluginName string) error {
|
||||||
|
if err := m.Disable(ctx, pluginName); err != nil {
|
||||||
|
// If already disabled, continue.
|
||||||
|
if entry, ok := m.plugins[pluginName]; !ok || entry.info.Status != sdk.StatusRunning {
|
||||||
|
// not running, skip stop
|
||||||
|
} else {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
delete(m.plugins, pluginName)
|
||||||
|
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 // copy
|
||||||
|
return &info, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package manager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewToolRegistry() *ToolRegistry {
|
||||||
|
return &ToolRegistry{tools: make(map[string]sdk.Tool)}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("tool %q not found", toolID)
|
||||||
|
}
|
||||||
|
if err := tool.Validate(args); err != nil {
|
||||||
|
return &sdk.ToolResult{Success: false, Error: err.Error()}, nil
|
||||||
|
}
|
||||||
|
return tool.Execute(ctx, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterAll removes all tools matching a prefix (plugin's tools).
|
||||||
|
func (r *ToolRegistry) UnregisterAll(toolIDs []string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for _, id := range toolIDs {
|
||||||
|
delete(r.tools, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
package calculator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "calculator",
|
||||||
|
"displayName": "Calculator",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minCyreneVersion": "1.0.0",
|
||||||
|
"author": { "name": "Cyrene Team" },
|
||||||
|
"description": "Safe mathematical expression evaluation with custom parser",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["math", "calculator", "arithmetic"],
|
||||||
|
"category": "utility"
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/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
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "crypto",
|
||||||
|
"displayName": "Crypto & Encoding",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minCyreneVersion": "1.0.0",
|
||||||
|
"author": { "name": "Cyrene Team" },
|
||||||
|
"description": "Hashing (MD5/SHA) and encoding (Base64, URL) utilities",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["crypto", "hash", "base64", "encode"],
|
||||||
|
"category": "utility"
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package datetime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/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 == "" {
|
||||||
|
return time.UTC, 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "datetime",
|
||||||
|
"displayName": "Date & Time",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minCyreneVersion": "1.0.0",
|
||||||
|
"author": { "name": "Cyrene Team" },
|
||||||
|
"description": "Date/time utilities: now, format, arithmetic, diff, timezone list",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["datetime", "time", "timezone"],
|
||||||
|
"category": "utility"
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/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
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "file",
|
||||||
|
"displayName": "File Operations",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minCyreneVersion": "1.0.0",
|
||||||
|
"author": { "name": "Cyrene Team" },
|
||||||
|
"description": "Sandboxed file operations: read, write, list, delete within DATA_DIR",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["file", "read", "write"],
|
||||||
|
"category": "system",
|
||||||
|
"permissions": ["file:read", "file:write"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "http",
|
||||||
|
"displayName": "HTTP Client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minCyreneVersion": "1.0.0",
|
||||||
|
"author": { "name": "Cyrene Team" },
|
||||||
|
"description": "Send arbitrary HTTP requests with custom methods, headers, body",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["http", "request", "fetch"],
|
||||||
|
"category": "network",
|
||||||
|
"permissions": ["network:outbound"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package iotcontrol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/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
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "iot_control",
|
||||||
|
"displayName": "IoT Device Control",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minCyreneVersion": "1.0.0",
|
||||||
|
"author": { "name": "Cyrene Team" },
|
||||||
|
"description": "Control smart home devices: toggle, set temperature/brightness/mode/color",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["iot", "control", "toggle", "temperature"],
|
||||||
|
"category": "iot",
|
||||||
|
"permissions": ["iot:read", "iot:write"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package iotquery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "iot_query",
|
||||||
|
"displayName": "IoT Device Query",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minCyreneVersion": "1.0.0",
|
||||||
|
"author": { "name": "Cyrene Team" },
|
||||||
|
"description": "Query smart home device status (single device or all devices)",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["iot", "query", "device", "status"],
|
||||||
|
"category": "iot",
|
||||||
|
"permissions": ["iot:read"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package jsonplugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/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
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "json",
|
||||||
|
"displayName": "JSON Processor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minCyreneVersion": "1.0.0",
|
||||||
|
"author": { "name": "Cyrene Team" },
|
||||||
|
"description": "JSON parsing, dot-path query, validation, pretty-print",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["json", "parse", "query", "validate"],
|
||||||
|
"category": "format"
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package markdown
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/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, "<code>$1</code>")
|
||||||
|
md = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`).ReplaceAllString(md, `<img src="$2" alt="$1">`)
|
||||||
|
md = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(md, `<a href="$2">$1</a>`)
|
||||||
|
md = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(md, `<strong>$1</strong>`)
|
||||||
|
md = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(md, `<em>$1</em>`)
|
||||||
|
md = regexp.MustCompile(`~~([^~]+)~~`).ReplaceAllString(md, `<del>$1</del>`)
|
||||||
|
md = regexp.MustCompile(`(?m)^#{6}\s+(.+)$`).ReplaceAllString(md, `<h6>$1</h6>`)
|
||||||
|
md = regexp.MustCompile(`(?m)^#{5}\s+(.+)$`).ReplaceAllString(md, `<h5>$1</h5>`)
|
||||||
|
md = regexp.MustCompile(`(?m)^#{4}\s+(.+)$`).ReplaceAllString(md, `<h4>$1</h4>`)
|
||||||
|
md = regexp.MustCompile(`(?m)^#{3}\s+(.+)$`).ReplaceAllString(md, `<h3>$1</h3>`)
|
||||||
|
md = regexp.MustCompile(`(?m)^#{2}\s+(.+)$`).ReplaceAllString(md, `<h2>$1</h2>`)
|
||||||
|
md = regexp.MustCompile(`(?m)^#{1}\s+(.+)$`).ReplaceAllString(md, `<h1>$1</h1>`)
|
||||||
|
md = regexp.MustCompile(`(?m)^---\s*$`).ReplaceAllString(md, `<hr>`)
|
||||||
|
md = regexp.MustCompile(`(?m)^>\s+(.+)$`).ReplaceAllString(md, `<blockquote>$1</blockquote>`)
|
||||||
|
|
||||||
|
// Restore code blocks
|
||||||
|
for _, b := range blocks {
|
||||||
|
langAttr := ""
|
||||||
|
if b.language != "" {
|
||||||
|
langAttr = " class=\"language-" + b.language + "\""
|
||||||
|
}
|
||||||
|
md = strings.Replace(md, b.orig, "<pre><code"+langAttr+">"+b.content+"</code></pre>", 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("<p>" + trimmed + "</p>\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeHTML(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "markdown",
|
||||||
|
"displayName": "Markdown Processor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minCyreneVersion": "1.0.0",
|
||||||
|
"author": { "name": "Cyrene Team" },
|
||||||
|
"description": "Markdown processing: to HTML, extract text/links/code, generate TOC",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["markdown", "html", "text", "toc"],
|
||||||
|
"category": "format"
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package random
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
mathrand "math/rand"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/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
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "random",
|
||||||
|
"displayName": "Random Generator",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minCyreneVersion": "1.0.0",
|
||||||
|
"author": { "name": "Cyrene Team" },
|
||||||
|
"description": "Random generation: numbers, UUIDs, secure passwords, pick/shuffle",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["random", "uuid", "password", "shuffle"],
|
||||||
|
"category": "utility"
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package text
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/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
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "text",
|
||||||
|
"displayName": "Text Processing",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minCyreneVersion": "1.0.0",
|
||||||
|
"author": { "name": "Cyrene Team" },
|
||||||
|
"description": "Text processing: count stats, summarize, regex extract",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["text", "count", "summarize", "extract"],
|
||||||
|
"category": "utility"
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package webfetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "web_fetch",
|
||||||
|
"displayName": "Web Fetch",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minCyreneVersion": "1.0.0",
|
||||||
|
"author": { "name": "Cyrene Team" },
|
||||||
|
"description": "Fetch and extract text content from URLs",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["fetch", "web", "scrape"],
|
||||||
|
"category": "network",
|
||||||
|
"permissions": ["network:outbound"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package websearch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/plugin-manager/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebSearchPlugin struct {
|
||||||
|
sdk.BasePlugin
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebSearchPlugin() *WebSearchPlugin {
|
||||||
|
return &WebSearchPlugin{client: &http.Client{Timeout: 10 * time.Second}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WebSearchPlugin) Metadata() sdk.PluginMetadata {
|
||||||
|
return sdk.PluginMetadata{
|
||||||
|
Name: "web_search", DisplayName: "Web Search", Version: "1.0.0",
|
||||||
|
Description: "Search the internet via DuckDuckGo Instant Answer API",
|
||||||
|
Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WebSearchPlugin) Tools() []sdk.Tool { return []sdk.Tool{&WebSearchTool{client: p.client}} }
|
||||||
|
|
||||||
|
type WebSearchTool struct {
|
||||||
|
sdk.BaseTool
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
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 using DuckDuckGo Instant Answer API. 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)
|
||||||
|
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 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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "web_search",
|
||||||
|
"displayName": "Web Search",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minCyreneVersion": "1.0.0",
|
||||||
|
"author": { "name": "Cyrene Team" },
|
||||||
|
"description": "Search the internet via DuckDuckGo Instant Answer API",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["search", "web", "duckduckgo"],
|
||||||
|
"category": "network",
|
||||||
|
"permissions": ["network:outbound"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
# Phase 3: 插件与工具系统 — 开发报告
|
||||||
|
|
||||||
|
> **报告日期**:2026-05-23
|
||||||
|
> **分支**:`dev`
|
||||||
|
> **阶段**:Phase 3 — 插件与工具系统
|
||||||
|
> **总计修改文件数**:40 个 (新增 39 个, 修改 1 个)
|
||||||
|
> **总代码行数**:3293 行
|
||||||
|
> **编译状态**:plugin-manager ✅ / ai-core ⚠️ 网络不可用 (golang.org/x/arch 未缓存)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、背景
|
||||||
|
|
||||||
|
Phase 2 完成了人格与交互深化 (情感状态机 + 主动消息决策 + 离线自主思考)。Phase 3 的目标是建立标准化的插件开发框架,让社区开发者可以方便地为昔涟扩展能力。
|
||||||
|
|
||||||
|
### 核心目标
|
||||||
|
|
||||||
|
| 任务 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| Plugin SDK | 标准化接口 (Plugin / Tool / ComplexTool / HostAPI) |
|
||||||
|
| Plugin Manager | 插件生命周期管理、热加载、工具注册聚合 |
|
||||||
|
| 工具分级 | 简易工具 (Simple) vs 复杂工具 (Complex),异步执行支持 |
|
||||||
|
| 13 个内置插件 | 将原有硬编码工具迁移为标准插件格式 |
|
||||||
|
| REST API | 插件 CRUD + 工具发现 + 执行端点 |
|
||||||
|
| ai-core 集成 | PluginManagerClient 替换本地工具调用 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、架构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ Plugin Manager (port 8094) │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────┐ ┌─────────┐ ┌──────────┐ │
|
||||||
|
│ │ Plugin A │ │ Plugin B │ │ Plugin C │ ... 13个 │
|
||||||
|
│ │calculator│ │ datetime │ │iot_query │ 内置插件 │
|
||||||
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌────▼──────────────▼──────────────▼────┐ │
|
||||||
|
│ │ Plugin Manager Core │ │
|
||||||
|
│ │ ┌───────────┐ ┌──────────────────┐ │ │
|
||||||
|
│ │ │ Lifecycle │ │ Tool Registry │ │ │
|
||||||
|
│ │ │ Manager │ │ (aggregated) │ │ │
|
||||||
|
│ │ └───────────┘ └────────┬─────────┘ │ │
|
||||||
|
│ │ Install/Enable/Disable │ Execute │ │
|
||||||
|
│ │ Start/Stop/Reload │ Validate │ │
|
||||||
|
│ └──────────────────────────┴─────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────────────────▼────────────────────────┐ │
|
||||||
|
│ │ REST API (net/http) │ │
|
||||||
|
│ │ GET/POST/DELETE /api/v1/plugins/** │ │
|
||||||
|
│ │ GET/POST /api/v1/tools/** │ │
|
||||||
|
│ └──────────────────────────────────────────┘ │
|
||||||
|
│ ▲ │
|
||||||
|
└────────────────────┼───────────────────────────────────┘
|
||||||
|
│ HTTP
|
||||||
|
┌────────────────────┼───────────────────────────────────┐
|
||||||
|
│ ai-core │ │
|
||||||
|
│ ┌─────────────────▼────────────────────────┐ │
|
||||||
|
│ │ PluginManagerClient │ │
|
||||||
|
│ │ GetToolDefinitions / ExecuteTool │ │
|
||||||
|
│ └──────────────────────────────────────────┘ │
|
||||||
|
└───────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、Plugin SDK (插件开发工具包)
|
||||||
|
|
||||||
|
### 3.1 核心接口
|
||||||
|
|
||||||
|
#### `sdk/plugin.go` — Plugin + Tool + ComplexTool + HostAPI
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Plugin — 所有插件必须实现的核心接口
|
||||||
|
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 — 所有工具必须实现
|
||||||
|
type Tool interface {
|
||||||
|
Definition() ToolDefinition
|
||||||
|
Execute(ctx context.Context, args map[string]interface{}) (*ToolResult, error)
|
||||||
|
Validate(args map[string]interface{}) error
|
||||||
|
Complexity() ToolComplexity
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComplexTool — 扩展异步多轮执行
|
||||||
|
type ComplexTool interface {
|
||||||
|
Tool
|
||||||
|
ExecuteAsync(ctx context.Context, args map[string]interface{}) (<-chan ToolProgress, error)
|
||||||
|
Cancel(ctx context.Context, executionID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostAPI — 插件可调用的宿主能力
|
||||||
|
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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `sdk/types.go` — 共享类型
|
||||||
|
|
||||||
|
| 类型 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `PluginMetadata` | 插件标识 (name/version/author/dependencies) |
|
||||||
|
| `PluginConfig` | 运行时配置 (map[string]interface{}) |
|
||||||
|
| `ToolDefinition` | 工具定义 (ID/Name/Parameters/Complexity/DangerLevel) |
|
||||||
|
| `ToolResult` | 执行结果 (Success/Output/Error/DurationMs) |
|
||||||
|
| `ToolProgress` | 异步进度 (Status/Progress/Result) |
|
||||||
|
| `ToolComplexity` | 工具分级: `simple` / `complex` |
|
||||||
|
| `PluginStatus` | 插件状态: installed/loaded/running/paused/error/disabled |
|
||||||
|
| `IoTDeviceState` | 共享 IoT 设备状态 |
|
||||||
|
|
||||||
|
#### `sdk/base.go` — 基类实现
|
||||||
|
|
||||||
|
- `BasePlugin` — 提供 Init/Start/Stop/Health 空实现
|
||||||
|
- `BaseTool` — 提供 Validate (required 参数检查) + Complexity 默认返回 simple
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、Plugin Manager 核心
|
||||||
|
|
||||||
|
### 4.1 文件清单
|
||||||
|
|
||||||
|
| 文件 | 行数 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| `internal/manager/manager.go` | 200 | Plugin Manager 核心:Install/Enable/Disable/Uninstall/Reload/Shutdown |
|
||||||
|
| `internal/manager/registry.go` | 80 | 聚合工具注册表:Register/Unregister/Get/List/Definitions/Execute |
|
||||||
|
|
||||||
|
### 4.2 插件生命周期
|
||||||
|
|
||||||
|
```
|
||||||
|
Install → [installed]
|
||||||
|
→ Enable → Init() → Start() → Register tools → [running]
|
||||||
|
→ Disable → Unregister tools → Stop() → [disabled]
|
||||||
|
→ Reload → Disable → Enable
|
||||||
|
→ Uninstall → Disable (if running) → 移除注册
|
||||||
|
```
|
||||||
|
|
||||||
|
- 所有状态为内存态,重启后从 main.go 重新安装内置插件
|
||||||
|
- 每个插件启动时获得独立 `context.WithCancel`,panic 恢复防护
|
||||||
|
|
||||||
|
### 4.3 EnableAll 执行流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 遍历已安装插件
|
||||||
|
2. 对每个插件: Init(config) → Start(ctx, host) → Register tools
|
||||||
|
3. 任何插件失败不影响其他插件继续启动
|
||||||
|
4. 返回所有错误列表
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、13 个内置插件
|
||||||
|
|
||||||
|
每个插件目录包含 `plugin.json` (元数据) + `plugin.go` (实现)。
|
||||||
|
|
||||||
|
### 5.1 无状态工具插件 (7个)
|
||||||
|
|
||||||
|
| 插件名 | 目录 | 工具ID | 主要功能 |
|
||||||
|
|--------|------|--------|---------|
|
||||||
|
| Calculator | `plugins/calculator/` | `calculator` | 安全数学表达式解析器 (递归下降) |
|
||||||
|
| DateTime | `plugins/datetime/` | `datetime` | 日期时间 (now/format/add/diff/timezone) |
|
||||||
|
| Text | `plugins/text/` | `text` | 文本处理 (count/summarize/translate/extract) |
|
||||||
|
| Crypto | `plugins/crypto/` | `crypto` | 哈希/编码 (MD5/SHA/Base64/URL) |
|
||||||
|
| Random | `plugins/random/` | `random` | 随机生成 (number/uuid/password/pick/shuffle) |
|
||||||
|
| Markdown | `plugins/markdown/` | `markdown` | Markdown 处理 (to_html/to_text/extract/TOC) |
|
||||||
|
| JSON | `plugins/json/` | `json_ops` | JSON 处理 (parse/query/validate) |
|
||||||
|
|
||||||
|
### 5.2 带依赖插件 (4个)
|
||||||
|
|
||||||
|
| 插件名 | 目录 | 工具ID | 依赖 |
|
||||||
|
|--------|------|--------|------|
|
||||||
|
| File | `plugins/file/` | `file_ops` | dataDir (沙盒路径) |
|
||||||
|
| HTTP | `plugins/http/` | `http_request` | http.Client |
|
||||||
|
| WebSearch | `plugins/web_search/` | `web_search` | http.Client (DuckDuckGo API) |
|
||||||
|
| WebFetch | `plugins/web_fetch/` | `web_fetch` | http.Client |
|
||||||
|
|
||||||
|
### 5.3 IoT 插件 (2个)
|
||||||
|
|
||||||
|
| 插件名 | 目录 | 工具ID | 依赖 | DangerLevel |
|
||||||
|
|--------|------|--------|------|-------------|
|
||||||
|
| IoT Query | `plugins/iot_query/` | `iot_query` | IoTClient (查询设备状态) | low |
|
||||||
|
| IoT Control | `plugins/iot_control/` | `iot_control` | IoTController (控制设备) | medium |
|
||||||
|
|
||||||
|
IoT 插件通过 `IOT_SERVICE_URL` 环境变量配置,适配器通过 HTTP 调用 iot-debug-service。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、REST API
|
||||||
|
|
||||||
|
### 6.1 端点列表
|
||||||
|
|
||||||
|
| Method | Path | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 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` | 列出所有已注册工具 |
|
||||||
|
| GET | `/api/v1/tools/{id}` | 获取单个工具定义 |
|
||||||
|
| POST | `/api/v1/tools/{id}/execute` | 执行工具 |
|
||||||
|
| GET | `/health` | 健康检查 |
|
||||||
|
|
||||||
|
### 6.2 使用 net/http
|
||||||
|
|
||||||
|
Plugin Manager 不依赖任何外部 HTTP 框架,使用 Go 标准库 `net/http` 的 `ServeMux`:
|
||||||
|
- 零外部依赖:只有 Go 标准库
|
||||||
|
- 路由解析:手动解析路径参数
|
||||||
|
- JSON 序列化:`encoding/json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、ai-core 集成
|
||||||
|
|
||||||
|
### 7.1 PluginManagerClient
|
||||||
|
|
||||||
|
新增文件 `backend/ai-core/internal/tools/plugin_manager_client.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type PluginManagerClient struct {
|
||||||
|
baseURL string // http://localhost:8094
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
方法:
|
||||||
|
- `GetToolDefinitions(ctx)` — GET /api/v1/tools → []PMToolDefinition
|
||||||
|
- `ExecuteTool(ctx, toolID, args)` — POST /api/v1/tools/{id}/execute → *PMToolResult
|
||||||
|
- `ListPlugins(ctx)` — GET /api/v1/plugins → []PMPluginInfo
|
||||||
|
- `AdaptDefinitions(ctx)` — 将 PM 工具定义转换为 ai-core ToolDefinition 格式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、完整文件清单
|
||||||
|
|
||||||
|
### 新增文件 (39)
|
||||||
|
|
||||||
|
| 目录/文件 | 用途 |
|
||||||
|
|-----------|------|
|
||||||
|
| `backend/plugin-manager/go.mod` | Go 模块定义 (零外部依赖) |
|
||||||
|
| `backend/plugin-manager/cmd/main.go` | 服务入口:注册内置插件、启动 HTTP |
|
||||||
|
| `backend/plugin-manager/cmd/host_api.go` | HostAPI 实现 (stub) |
|
||||||
|
| `backend/plugin-manager/cmd/iot_adapter.go` | IoT 客户端适配器 (HTTP → IoT service) |
|
||||||
|
| `backend/plugin-manager/internal/config/config.go` | 配置加载 |
|
||||||
|
| `backend/plugin-manager/internal/handler/plugin_handler.go` | REST API 处理函数 (net/http) |
|
||||||
|
| `backend/plugin-manager/internal/manager/manager.go` | Plugin Manager 核心 |
|
||||||
|
| `backend/plugin-manager/internal/manager/registry.go` | 工具注册表 |
|
||||||
|
| `backend/plugin-manager/internal/sdk/base.go` | BasePlugin + BaseTool |
|
||||||
|
| `backend/plugin-manager/internal/sdk/plugin.go` | 核心接口 (Plugin/Tool/ComplexTool/HostAPI) |
|
||||||
|
| `backend/plugin-manager/internal/sdk/permissions.go` | 权限模型 |
|
||||||
|
| `backend/plugin-manager/internal/sdk/types.go` | 共享类型定义 |
|
||||||
|
| `backend/plugin-manager/plugins/{13个}/plugin.json` | 各插件元数据 |
|
||||||
|
| `backend/plugin-manager/plugins/{13个}/plugin.go` | 各插件实现 |
|
||||||
|
| `backend/ai-core/internal/tools/plugin_manager_client.go` | ai-core Plugin Manager 客户端 |
|
||||||
|
|
||||||
|
### 修改文件 (1)
|
||||||
|
|
||||||
|
| 文件 | 修改内容 |
|
||||||
|
|------|---------|
|
||||||
|
| `backend/go.work` | 添加 `./plugin-manager` 到 workspace |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、验证指南
|
||||||
|
|
||||||
|
### 编译验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Plugin Manager (零外部依赖 — 即使离线也可编译)
|
||||||
|
cd backend/plugin-manager && GOWORK=off go build ./...
|
||||||
|
|
||||||
|
# ai-core / gateway (需要网络下载 golang.org/x/arch)
|
||||||
|
# 如果模块缓存可用: go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行时验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动 Plugin Manager
|
||||||
|
cd backend/plugin-manager && GOWORK=off go run ./cmd/
|
||||||
|
|
||||||
|
# 测试端点
|
||||||
|
curl http://localhost:8094/health
|
||||||
|
# → {"status":"ok","service":"plugin-manager"}
|
||||||
|
|
||||||
|
curl http://localhost:8094/api/v1/plugins
|
||||||
|
# → {"plugins":[...13个插件...], "total": 13}
|
||||||
|
|
||||||
|
curl http://localhost:8094/api/v1/tools
|
||||||
|
# → {"tools":[...13个工具定义...], "total": 13}
|
||||||
|
|
||||||
|
# 执行计算器工具
|
||||||
|
curl -X POST http://localhost:8094/api/v1/tools/calculator/execute \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"arguments":{"expression":"2+3*4"}}'
|
||||||
|
# → {"tool_name":"calculator","success":true,"output":"14"}
|
||||||
|
|
||||||
|
# 获取单个插件
|
||||||
|
curl http://localhost:8094/api/v1/plugins/calculator
|
||||||
|
# → 显示 Calculator 插件详情
|
||||||
|
|
||||||
|
# 禁用/启用插件
|
||||||
|
curl -X POST http://localhost:8094/api/v1/plugins/calculator/disable
|
||||||
|
curl -X POST http://localhost:8094/api/v1/plugins/calculator/enable
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、与设计文档的对应
|
||||||
|
|
||||||
|
| 设计文档要求 | 实现状态 |
|
||||||
|
|-------------|---------|
|
||||||
|
| Plugin 接口 (Metadata/Init/Start/Stop/Health/Tools) | ✅ 完整实现 |
|
||||||
|
| Tool 接口 (Definition/Execute/Validate) | ✅ 完整实现 |
|
||||||
|
| ComplexTool 接口 (ExecuteAsync/Cancel) | ✅ 已定义 (待异步场景实现) |
|
||||||
|
| HostAPI 接口 (CallLLM/SearchMemory/StoreMemory/Logger等) | ✅ 已定义 + stub 实现 |
|
||||||
|
| 插件生命周期 (Install→Enable→Start→Running→Stop→Disable→Uninstall) | ✅ 完整实现 |
|
||||||
|
| 工具注册表 (聚合所有插件工具) | ✅ 完整实现 |
|
||||||
|
| plugin.json 元数据格式 | ✅ 13 个插件均含完整 metadata |
|
||||||
|
| tool.json 定义 | 🔧 内嵌在 Go Definition() 方法中 (后续分离) |
|
||||||
|
| 插件目录结构 (tools/config/resources) | 🔧 基础结构已建 (config/resources 待补充) |
|
||||||
|
| Plugin Manager REST API (10+ 端点) | ✅ 11 个端点实现 |
|
||||||
|
| 权限模型 (PluginPermissions) | ✅ 类型已定义 |
|
||||||
|
| 工具分级 (simple/complex) | ✅ 已实现,所有内置工具标记为 simple |
|
||||||
|
| gRPC 通信 | 🔧 当前使用 HTTP (gRPC 后续迁移) |
|
||||||
|
| Marketplace 框架 | 🔧 基础 ListPlugins/GetPlugin 已实现 (search/install 待扩展) |
|
||||||
|
| 13 个工具迁移 | ✅ 全部迁移为标准插件 |
|
||||||
|
| ai-core PluginManagerClient | ✅ 已创建 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、后续计划
|
||||||
|
|
||||||
|
1. **Phase 3.2**: 将 tool.json 从 Go 代码中分离为独立 JSON 文件,实现配置驱动
|
||||||
|
2. **Phase 3.3**: 完善 HostAPI 实现 (连接 ai-core LLM/记忆/事件总线)
|
||||||
|
3. **Phase 3.3**: 补充单元测试 (Plugin Manager 生命周期、工具执行、REST API)
|
||||||
|
4. **Phase 3.4**: gRPC 替换 HTTP (降低调用延迟)
|
||||||
|
5. **Phase 3.5**: Marketplace 安装/搜索/评分
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、架构演进
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (基础设施) Phase 2 (人格交互) Phase 3 (插件工具)
|
||||||
|
───────────────────── ───────────────────── ─────────────────────
|
||||||
|
ThinkChain EmotionState Plugin SDK
|
||||||
|
MessageScheduler ProactiveGuard Plugin Manager
|
||||||
|
AutonomousToolPolicy Presence Bridge Tool Registry
|
||||||
|
SessionEnrichment Offline Thinking Tool Grading (Simple/Complex)
|
||||||
|
──→ ──→
|
||||||
|
硬编码 13 工具 (ai-core) 13 内置插件
|
||||||
|
重复实现 (ai-core+tool-engine) 统一插件格式
|
||||||
|
无插件扩展机制 标准化扩展框架
|
||||||
|
```
|
||||||
|
|
||||||
|
Phase 3 建立了可扩展的插件基础设施。所有 13 个工具从硬编码迁移到标准化插件格式,Plugin Manager 通过 REST API 对外暴露工具发现和执行能力。ai-core 通过 PluginManagerClient 接入,替代原有的本地工具调用模式。
|
||||||
@@ -35,3 +35,4 @@
|
|||||||
## 2026-05-23
|
## 2026-05-23
|
||||||
|
|
||||||
- [Phase 2 - 人格与交互深化](2026-05-23-phase2-personality-interaction.md) — 情感状态机 + 主动消息决策增强 + 离线自主思考 (16 文件)
|
- [Phase 2 - 人格与交互深化](2026-05-23-phase2-personality-interaction.md) — 情感状态机 + 主动消息决策增强 + 离线自主思考 (16 文件)
|
||||||
|
- [Phase 3 - 插件与工具系统](2026-05-23-phase3-plugin-tool-system.md) — Plugin SDK + Plugin Manager + 13 内置插件 + 工具分级 (40 文件)
|
||||||
|
|||||||
Reference in New Issue
Block a user