feat: 插件-工具合并 — 创建 pkg/plugins 共享模块并移除 tool-engine

- 新增 backend/pkg/plugins/ 共享模块:SDK 接口、PluginManager、ToolRegistry(含环形缓冲区调用日志)
- 13 个通用插件从 plugin-manager 迁移至共享模块(import 路径统一)
- ai-core 切换至共享 ToolRegistry,进程内执行(零网络开销),包装 6 个专属工具
- plugin-manager 迁移至共享模块,保留管理 REST API
- 新增 DevTools 插件管理面板(侧边栏 → 🔌 插件管理)
- 移除 tool-engine 服务(从 go.work、DevTools 配置、编译系统)
- 工具调用记录 API 从 Tool-Engine 迁至 AI-Core(/api/v1/tools/calls)
- ai-core ContextStore 启动时从 PostgreSQL 恢复会话历史
- 清理所有过时引用和备份文件

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 20:52:39 +08:00
parent 5325eaca3f
commit 673ff752c5
78 changed files with 1313 additions and 5187 deletions
@@ -5,7 +5,7 @@ import (
"net/http"
"strings"
"github.com/yourname/cyrene-ai/plugin-manager/internal/manager"
"github.com/yourname/cyrene-ai/pkg/plugins/manager"
)
// PluginHandler exposes the Plugin Manager REST API via net/http.
@@ -39,13 +39,11 @@ func (h *PluginHandler) listPlugins(w http.ResponseWriter, r *http.Request) {
}
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
}
@@ -163,7 +161,6 @@ 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" {
@@ -1,224 +0,0 @@
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
}
@@ -1,85 +0,0 @@
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)
}
}
@@ -1,40 +0,0 @@
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")
}
@@ -1,35 +0,0 @@
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,
}
}
@@ -1,49 +0,0 @@
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{})
}
@@ -1,134 +0,0 @@
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"`
}