feat(plugin): add plugin interface system and iot-mock implementation

- internal/plugin: Plugin/IoTProvider/ToolProvider/RAGProvider interfaces
- internal/plugin: Registry with type-safe auto-detection, 11 tests
- plugins/iot-mock: 8 simulated smart home devices, 11 tests
- 63 total tests, build + vet clean
This commit is contained in:
Aska committed 2026-08-09 20:38:37 +08:00
1 parent 7943e290ec
commit 750d594055
8 files changed
+1109

No files matched your search

View File
Whitespace-only changes.
+25
View File
@@ -0,0 +1,25 @@
package plugin
import "context"
// IoTProvider is implemented by plugins that manage IoT devices.
// Only one IoTProvider can be active at a time.
type IoTProvider interface {
Plugin
// ListDevices returns all managed devices.
ListDevices(ctx context.Context) ([]Device, error)
// GetDevice returns a single device by ID.
GetDevice(ctx context.Context, id string) (*Device, error)
// SetDevice sets a property on a device (e.g., brightness=80).
SetDevice(ctx context.Context, id, field string, value any) error
// ToggleDevice toggles a device's power state.
ToggleDevice(ctx context.Context, id string) error
// SubscribeChanges returns a channel that emits device state changes.
// The channel is closed when the context is cancelled.
SubscribeChanges(ctx context.Context) (<-chan DeviceEvent, error)
}
+109
View File
@@ -0,0 +1,109 @@
// Package plugin defines the core interfaces and types for Cyrene's plugin system.
// Plugins extend Cyrene with IoT device control, LLM tools, and RAG capabilities.
// Each plugin type implements one or more of the Provider interfaces.
package plugin
import "context"
// Plugin is the base interface that all plugins must implement.
type Plugin interface {
// Name returns the unique plugin identifier (e.g., "iot-mock", "calculator").
Name() string
// Version returns the semantic version string (e.g., "1.0.0").
Version() string
// Description returns a human-readable description of the plugin.
Description() string
// Init initializes the plugin. Called once at startup.
// Return an error to skip this plugin (other plugins are unaffected).
Init(ctx context.Context, cfg PluginConfig) error
// Close cleans up plugin resources. Called on shutdown.
Close() error
// Enabled returns whether the plugin is currently available.
// Used for conditional plugins (e.g., web-search disabled when SearXNG is down).
Enabled() bool
}
// PluginConfig provides shared configuration to all plugins during Init.
type PluginConfig struct {
// DataDir is the base directory for plugin data storage.
DataDir string
// NATSURL is the NATS connection address.
NATSURL string
// DatabaseURL is the PostgreSQL connection string.
DatabaseURL string
// LogLevel is the current log level string.
LogLevel string
// Extra holds plugin-specific config from environment variables
// (keys prefixed with PLUGIN_{NAME}_).
Extra map[string]string
}
// --- Device types shared across IoT plugins ---
// DeviceType categorizes an IoT device.
type DeviceType string
const (
DeviceLight DeviceType = "light"
DeviceAC DeviceType = "ac"
DeviceCurtain DeviceType = "curtain"
DeviceSensor DeviceType = "sensor"
DeviceLock DeviceType = "lock"
DeviceSwitch DeviceType = "switch"
DeviceHumidifier DeviceType = "humidifier"
DeviceTV DeviceType = "tv"
)
// Device represents an IoT device.
type Device struct {
ID string `json:"id"`
Name string `json:"name"`
Type DeviceType `json:"type"`
State map[string]any `json:"state"` // Dynamic state fields
Online bool `json:"online"`
History []DeviceEvent `json:"history"` // Recent events
}
// DeviceEvent represents a single state change on a device.
type DeviceEvent struct {
DeviceID string `json:"device_id"`
Field string `json:"field"`
OldValue any `json:"old_value"`
NewValue any `json:"new_value"`
Timestamp int64 `json:"timestamp"` // Unix timestamp
}
// --- Tool types ---
// ToolDefinition describes an LLM-callable tool.
type ToolDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
// Parameters is a JSON Schema object describing the tool's input parameters.
Parameters map[string]any `json:"parameters"`
}
// RAGDocument represents a document in the RAG knowledge base.
type RAGDocument struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Metadata map[string]string `json:"metadata"`
Source string `json:"source"`
}
// RAGSearchResult represents a single search result from RAG.
type RAGSearchResult struct {
Document RAGDocument `json:"document"`
Score float64 `json:"score"`
Chunk string `json:"chunk"` // The relevant chunk from the document
}
+192
View File
@@ -0,0 +1,192 @@
package plugin
import (
"context"
"fmt"
"log"
"sync"
)
// Registry manages all loaded plugins and provides type-safe access to providers.
type Registry struct {
mu sync.RWMutex
plugins map[string]Plugin
iotProviders []IoTProvider
toolProviders []ToolProvider
ragProviders []RAGProvider
}
// NewRegistry creates a new empty plugin registry.
func NewRegistry() *Registry {
return &Registry{
plugins: make(map[string]Plugin),
iotProviders: make([]IoTProvider, 0),
toolProviders: make([]ToolProvider, 0),
ragProviders: make([]RAGProvider, 0),
}
}
// Register adds a plugin to the registry after calling Init.
// If Init fails or the plugin is disabled, it is not added.
func (r *Registry) Register(p Plugin, cfg PluginConfig) error {
ctx := context.Background()
if err := p.Init(ctx, cfg); err != nil {
return fmt.Errorf("plugin %s init failed: %w", p.Name(), err)
}
if !p.Enabled() {
log.Printf("[plugin] %s is disabled, skipping", p.Name())
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
// Check for duplicate
if _, exists := r.plugins[p.Name()]; exists {
return fmt.Errorf("plugin %s already registered", p.Name())
}
r.plugins[p.Name()] = p
// Auto-detect provider interfaces
if iot, ok := p.(IoTProvider); ok {
r.iotProviders = append(r.iotProviders, iot)
log.Printf("[plugin] registered IoT provider: %s v%s", p.Name(), p.Version())
}
if tool, ok := p.(ToolProvider); ok {
r.toolProviders = append(r.toolProviders, tool)
log.Printf("[plugin] registered tool provider: %s v%s (%d tools)", p.Name(), p.Version(), len(tool.Tools()))
}
if rag, ok := p.(RAGProvider); ok {
r.ragProviders = append(r.ragProviders, rag)
log.Printf("[plugin] registered RAG provider: %s v%s", p.Name(), p.Version())
}
log.Printf("[plugin] %s v%s loaded successfully", p.Name(), p.Version())
return nil
}
// Get returns a plugin by name, or nil if not found.
func (r *Registry) Get(name string) Plugin {
r.mu.RLock()
defer r.mu.RUnlock()
return r.plugins[name]
}
// ListNames returns the names of all loaded plugins.
func (r *Registry) ListNames() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.plugins))
for name := range r.plugins {
names = append(names, name)
}
return names
}
// GetIoTProvider returns the first registered IoT provider, or nil.
// When multiple IoT providers are registered, the first one wins.
func (r *Registry) GetIoTProvider() IoTProvider {
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.iotProviders) > 0 {
return r.iotProviders[0]
}
return nil
}
// GetAllToolProviders returns all registered tool providers.
func (r *Registry) GetAllToolProviders() []ToolProvider {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ToolProvider, len(r.toolProviders))
copy(result, r.toolProviders)
return result
}
// GetAllToolDefinitions returns a flat list of all tool definitions from all tool providers.
func (r *Registry) GetAllToolDefinitions() []ToolDefinition {
r.mu.RLock()
defer r.mu.RUnlock()
defs := make([]ToolDefinition, 0)
for _, tp := range r.toolProviders {
defs = append(defs, tp.Tools()...)
}
return defs
}
// ExecuteTool finds a tool by name across all tool providers and executes it.
func (r *Registry) ExecuteTool(ctx context.Context, toolName string, params map[string]any) (string, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, tp := range r.toolProviders {
for _, def := range tp.Tools() {
if def.Name == toolName {
return tp.Execute(ctx, toolName, params)
}
}
}
return "", fmt.Errorf("tool %s not found in any provider", toolName)
}
// GetRAGProviders returns all registered RAG providers.
func (r *Registry) GetRAGProviders() []RAGProvider {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]RAGProvider, len(r.ragProviders))
copy(result, r.ragProviders)
return result
}
// Count returns the total number of loaded plugins.
func (r *Registry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.plugins)
}
// Shutdown calls Close on all plugins in reverse registration order.
func (r *Registry) Shutdown() {
r.mu.Lock()
defer r.mu.Unlock()
// Close base plugins that are not specialized providers
for _, p := range r.plugins {
// Check if it was already closed via a provider list
if _, ok := p.(IoTProvider); ok {
continue
}
if _, ok := p.(ToolProvider); ok {
continue
}
if _, ok := p.(RAGProvider); ok {
continue
}
if err := p.Close(); err != nil {
log.Printf("[plugin] error closing %s: %v", p.Name(), err)
}
}
// Close IoT providers
for _, p := range r.iotProviders {
if err := p.Close(); err != nil {
log.Printf("[plugin] error closing %s: %v", p.Name(), err)
}
}
// Close tool providers
for _, p := range r.toolProviders {
if err := p.Close(); err != nil {
log.Printf("[plugin] error closing %s: %v", p.Name(), err)
}
}
// Close RAG providers
for _, p := range r.ragProviders {
if err := p.Close(); err != nil {
log.Printf("[plugin] error closing %s: %v", p.Name(), err)
}
}
log.Printf("[plugin] all plugins shut down")
}
+301
View File
@@ -0,0 +1,301 @@
package plugin
import (
"context"
"fmt"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
// --- Test helpers: mock plugins ---
type mockBasePlugin struct {
name string
version string
desc string
enabled bool
initErr error
closeCalled bool
}
func (m *mockBasePlugin) Name() string { return m.name }
func (m *mockBasePlugin) Version() string { return m.version }
func (m *mockBasePlugin) Description() string { return m.desc }
func (m *mockBasePlugin) Init(_ context.Context, _ PluginConfig) error {
return m.initErr
}
func (m *mockBasePlugin) Close() error {
m.closeCalled = true
return nil
}
func (m *mockBasePlugin) Enabled() bool { return m.enabled }
type mockIoTPlugin struct {
mockBasePlugin
devices []Device
mu sync.Mutex
eventCh chan DeviceEvent
}
func (m *mockIoTPlugin) ListDevices(_ context.Context) ([]Device, error) {
return m.devices, nil
}
func (m *mockIoTPlugin) GetDevice(_ context.Context, id string) (*Device, error) {
for _, d := range m.devices {
if d.ID == id {
return &d, nil
}
}
return nil, errors.NewNotFound("device " + id)
}
func (m *mockIoTPlugin) SetDevice(_ context.Context, id, field string, value any) error {
m.mu.Lock()
defer m.mu.Unlock()
for i, d := range m.devices {
if d.ID == id {
if m.devices[i].State == nil {
m.devices[i].State = make(map[string]any)
}
m.devices[i].State[field] = value
if m.eventCh != nil {
m.eventCh <- DeviceEvent{DeviceID: id, Field: field, NewValue: value}
}
return nil
}
}
return nil
}
func (m *mockIoTPlugin) ToggleDevice(_ context.Context, id string) error {
return m.SetDevice(context.Background(), id, "power", true)
}
func (m *mockIoTPlugin) SubscribeChanges(_ context.Context) (<-chan DeviceEvent, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.eventCh = make(chan DeviceEvent, 10)
return m.eventCh, nil
}
type mockToolPlugin struct {
mockBasePlugin
tools []ToolDefinition
execFunc func(ctx context.Context, toolName string, params map[string]any) (string, error)
}
func (m *mockToolPlugin) Tools() []ToolDefinition { return m.tools }
func (m *mockToolPlugin) Execute(ctx context.Context, toolName string, params map[string]any) (string, error) {
if m.execFunc != nil {
return m.execFunc(ctx, toolName, params)
}
return "ok", nil
}
// --- Tests ---
func TestRegistryRegister(t *testing.T) {
reg := NewRegistry()
p := &mockBasePlugin{name: "test", version: "1.0", enabled: true}
err := reg.Register(p, PluginConfig{})
if err != nil {
t.Fatalf("Register failed: %v", err)
}
if reg.Count() != 1 {
t.Errorf("expected 1 plugin, got %d", reg.Count())
}
got := reg.Get("test")
if got == nil {
t.Error("expected to find plugin 'test'")
}
}
func TestRegistryDuplicateRegister(t *testing.T) {
reg := NewRegistry()
p1 := &mockBasePlugin{name: "test", version: "1.0", enabled: true}
p2 := &mockBasePlugin{name: "test", version: "2.0", enabled: true}
_ = reg.Register(p1, PluginConfig{})
err := reg.Register(p2, PluginConfig{})
if err == nil {
t.Error("expected error for duplicate plugin name")
}
}
func TestRegistryInitError(t *testing.T) {
reg := NewRegistry()
p := &mockBasePlugin{name: "bad", version: "1.0", enabled: true, initErr: fmt.Errorf("init error")}
err := reg.Register(p, PluginConfig{})
if err == nil {
t.Error("expected error for init failure")
}
if reg.Count() != 0 {
t.Errorf("plugin should not be registered on init error, got %d", reg.Count())
}
}
func TestRegistryDisabledPlugin(t *testing.T) {
reg := NewRegistry()
p := &mockBasePlugin{name: "disabled", version: "1.0", enabled: false}
err := reg.Register(p, PluginConfig{})
if err != nil {
t.Fatalf("Register should not error for disabled plugin: %v", err)
}
if reg.Count() != 0 {
t.Errorf("disabled plugin should not be counted, got %d", reg.Count())
}
}
func TestRegistryListNames(t *testing.T) {
reg := NewRegistry()
p1 := &mockBasePlugin{name: "plugin-a", version: "1.0", enabled: true}
p2 := &mockBasePlugin{name: "plugin-b", version: "1.0", enabled: true}
reg.Register(p1, PluginConfig{})
reg.Register(p2, PluginConfig{})
names := reg.ListNames()
if len(names) != 2 {
t.Errorf("expected 2 names, got %d", len(names))
}
}
func TestRegistryIoTProvider(t *testing.T) {
reg := NewRegistry()
p := &mockIoTPlugin{
mockBasePlugin: mockBasePlugin{name: "iot-mock", version: "1.0", enabled: true},
devices: []Device{
{ID: "light-1", Name: "客厅灯", Type: DeviceLight, State: map[string]any{"power": false}},
},
}
err := reg.Register(p, PluginConfig{})
if err != nil {
t.Fatalf("Register failed: %v", err)
}
iot := reg.GetIoTProvider()
if iot == nil {
t.Fatal("expected IoT provider to be available")
}
devices, err := iot.ListDevices(context.Background())
if err != nil {
t.Fatalf("ListDevices failed: %v", err)
}
if len(devices) != 1 {
t.Errorf("expected 1 device, got %d", len(devices))
}
if devices[0].Name != "客厅灯" {
t.Errorf("expected device name 客厅灯, got %s", devices[0].Name)
}
}
func TestRegistrySetDevice(t *testing.T) {
reg := NewRegistry()
p := &mockIoTPlugin{
mockBasePlugin: mockBasePlugin{name: "iot-mock", version: "1.0", enabled: true},
devices: []Device{
{ID: "light-1", Name: "客厅灯", Type: DeviceLight, State: map[string]any{"power": false}},
},
}
reg.Register(p, PluginConfig{})
iot := reg.GetIoTProvider()
err := iot.SetDevice(context.Background(), "light-1", "power", true)
if err != nil {
t.Fatalf("SetDevice failed: %v", err)
}
d, err := iot.GetDevice(context.Background(), "light-1")
if err != nil {
t.Fatalf("GetDevice failed: %v", err)
}
if d.State["power"] != true {
t.Errorf("expected power=true, got %v", d.State["power"])
}
}
func TestRegistryToolProvider(t *testing.T) {
reg := NewRegistry()
p := &mockToolPlugin{
mockBasePlugin: mockBasePlugin{name: "calculator", version: "1.0", enabled: true},
tools: []ToolDefinition{
{Name: "calc", Description: "Calculate math expressions"},
},
}
err := reg.Register(p, PluginConfig{})
if err != nil {
t.Fatalf("Register failed: %v", err)
}
providers := reg.GetAllToolProviders()
if len(providers) != 1 {
t.Errorf("expected 1 tool provider, got %d", len(providers))
}
defs := reg.GetAllToolDefinitions()
if len(defs) != 1 {
t.Errorf("expected 1 tool definition, got %d", len(defs))
}
if defs[0].Name != "calc" {
t.Errorf("expected tool name 'calc', got %s", defs[0].Name)
}
}
func TestRegistryExecuteTool(t *testing.T) {
reg := NewRegistry()
p := &mockToolPlugin{
mockBasePlugin: mockBasePlugin{name: "calc", version: "1.0", enabled: true},
tools: []ToolDefinition{
{Name: "add", Description: "Add two numbers"},
},
execFunc: func(_ context.Context, name string, params map[string]any) (string, error) {
return "42", nil
},
}
reg.Register(p, PluginConfig{})
result, err := reg.ExecuteTool(context.Background(), "add", nil)
if err != nil {
t.Fatalf("ExecuteTool failed: %v", err)
}
if result != "42" {
t.Errorf("expected '42', got %s", result)
}
}
func TestRegistryExecuteToolNotFound(t *testing.T) {
reg := NewRegistry()
_, err := reg.ExecuteTool(context.Background(), "nonexistent", nil)
if err == nil {
t.Error("expected error for unknown tool")
}
}
func TestRegistryShutdown(t *testing.T) {
reg := NewRegistry()
p1 := &mockBasePlugin{name: "p1", version: "1.0", enabled: true}
p2 := &mockToolPlugin{
mockBasePlugin: mockBasePlugin{name: "p2", version: "1.0", enabled: true},
}
reg.Register(p1, PluginConfig{})
reg.Register(p2, PluginConfig{})
reg.Shutdown()
if !p1.closeCalled {
t.Error("p1.Close should have been called")
}
if !p2.closeCalled {
t.Error("p2.Close should have been called")
}
}
+27
View File
@@ -0,0 +1,27 @@
package plugin
import "context"
// ToolProvider is implemented by plugins that provide LLM-callable tools.
// Multiple ToolProviders can be active simultaneously.
type ToolProvider interface {
Plugin
// Tools returns the list of tool definitions this plugin provides.
Tools() []ToolDefinition
// Execute invokes a tool by name with the given parameters.
// Returns the result string or an error.
Execute(ctx context.Context, toolName string, params map[string]any) (string, error)
}
// RAGProvider is implemented by plugins that provide RAG (Retrieval-Augmented Generation).
type RAGProvider interface {
Plugin
// Ingest adds documents to the knowledge base.
Ingest(ctx context.Context, docs []RAGDocument) error
// Search performs a semantic search over the knowledge base.
Search(ctx context.Context, query string, topK int) ([]RAGSearchResult, error)
}