feat(platform): 插件协议系统 - 支持声明式渠道、工作流与参考媒体映射

This commit is contained in:
ddCat
2026-08-26 13:11:10 +08:00
parent 054e3bbbff
commit 2f3a813ccb
58 changed files with 2074 additions and 706 deletions

View File

@@ -17,7 +17,7 @@ import (
"infinite-canvas/backend/internal/protocol"
)
const protocolPluginMaxBytes = 512 << 10
const protocolPluginMaxBytes = protocol.PluginManifestMaxBytes
// PluginView is the backend representation consumed by the single frontend
// plugin center. Protocol-specific runtime data is nested under Protocol so
@@ -26,6 +26,7 @@ type PluginView struct {
Manifest PluginManifestView `json:"manifest"`
Source string `json:"source"`
FileName string `json:"fileName"`
Package string `json:"package"`
SHA256 string `json:"sha256"`
InstalledAt time.Time `json:"installedAt"`
UpdatedAt time.Time `json:"updatedAt"`
@@ -34,57 +35,65 @@ type PluginView struct {
}
type PluginManifestView struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
APIVersion string `json:"apiVersion"`
Category string `json:"category"`
Description string `json:"description,omitempty"`
Author string `json:"author,omitempty"`
Surfaces []string `json:"surfaces"`
Permissions []string `json:"permissions"`
Trusted bool `json:"trusted"`
Kind string `json:"kind"`
Protocol PluginProtocolView `json:"protocol"`
}
type PluginProtocolView struct {
Categories []string `json:"categories"`
Scopes []string `json:"scopes"`
Create string `json:"create,omitempty"`
Poll string `json:"poll,omitempty"`
Cancel string `json:"cancel,omitempty"`
ContentType string `json:"contentType,omitempty"`
Documentation string `json:"documentation,omitempty"`
Parameters []protocol.Parameter `json:"parameters,omitempty"`
APIVersion string `json:"apiVersion"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Entry string `json:"entry,omitempty"`
Surfaces []string `json:"surfaces,omitempty"`
Description string `json:"description,omitempty"`
Documentation string `json:"documentation,omitempty"`
Author string `json:"author,omitempty"`
Permissions []string `json:"permissions"`
Trusted bool `json:"trusted"`
Runtime protocol.ManifestRuntime `json:"runtime,omitempty"`
Configuration protocol.ManifestConfiguration `json:"configuration,omitempty"`
Contributes protocol.ManifestContributions `json:"contributes"`
}
type pluginRecord struct {
Raw []byte
Metadata protocol.Metadata
Source string
FileName string
SHA256 string
InstalledAt time.Time
UpdatedAt time.Time
Status string
Error string
Raw []byte
Metadata protocol.Metadata
Source string
FileName string
PackagePath string
PackageSHA256 string
SHA256 string
InstalledAt time.Time
UpdatedAt time.Time
Status string
Error string
}
type pluginRuntime struct {
mu sync.RWMutex
mutationMu sync.Mutex
dir string
plugins map[string]pluginRecord
registry *protocol.Registry
mu sync.RWMutex
mutationMu sync.Mutex
registryPath string
packageDir string
plugins map[string]pluginRecord
registry *protocol.Registry
}
type pluginRegistryRecord struct {
ID string `json:"id"`
Raw json.RawMessage `json:"manifest"`
Source string `json:"source"`
FileName string `json:"fileName,omitempty"`
PackagePath string `json:"packagePath,omitempty"`
PackageSHA256 string `json:"packageSha256,omitempty"`
InstalledAt time.Time `json:"installedAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func newPluginRuntime(dataDir string) (*pluginRuntime, error) {
dir := filepath.Join(dataDir, "plugins")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create plugin directory: %w", err)
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return nil, fmt.Errorf("create plugin registry directory: %w", err)
}
center := &pluginRuntime{dir: dir, plugins: make(map[string]pluginRecord)}
packageDir := filepath.Join(dataDir, "plugin-packages")
if err := os.MkdirAll(packageDir, 0o700); err != nil {
return nil, fmt.Errorf("create plugin package directory: %w", err)
}
center := &pluginRuntime{registryPath: filepath.Join(dataDir, "plugin_registry.json"), packageDir: packageDir, plugins: make(map[string]pluginRecord)}
if err := center.bootstrapBundledPlugins(); err != nil {
return nil, err
}
@@ -95,92 +104,122 @@ func newPluginRuntime(dataDir string) (*pluginRuntime, error) {
}
func (c *pluginRuntime) bootstrapBundledPlugins() error {
stored, err := c.readRegistry()
if err != nil {
return err
}
byID := make(map[string]pluginRegistryRecord, len(stored))
for _, record := range stored {
byID[record.ID] = record
}
items := protocol.Builtins().List("", "", true)
bundledIDs := make(map[string]struct{}, len(items))
for _, metadata := range items {
bundledIDs[metadata.ID] = struct{}{}
protocol.AttachDocumentation(&metadata)
metadata.Execution = "host:" + metadata.ID
metadata.Installable = true
path := filepath.Join(c.dir, metadata.ID+".json")
existing, err := os.ReadFile(path)
if err == nil {
manifest, declarative := protocol.BundledManifest(metadata.ID)
existing := []byte(nil)
if record, ok := byID[metadata.ID]; ok {
existing = record.Raw
var installed protocol.Manifest
if err := json.Unmarshal(existing, &installed); err != nil {
return fmt.Errorf("decode bundled protocol %s: %w", metadata.ID, err)
return fmt.Errorf("decode bundled plugin %s: %w", metadata.ID, err)
}
if installed.Metadata.Execution != metadata.Execution {
expectedBackend := "host:" + metadata.ID
if declarative {
expectedBackend = "declarative"
}
if installed.Runtime.Backend != expectedBackend {
return fmt.Errorf("protocol id %q is reserved by a bundled plugin", metadata.ID)
}
if metadata.Enabled {
metadata.Enabled = installed.Metadata.Enabled
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
manifest := protocol.Manifest{
APIVersion: "v1",
Metadata: metadata,
Create: protocol.ManifestOperation{Method: "POST", Path: "/__host__/" + metadata.ID},
Response: protocol.ManifestResponse{},
if declarative {
manifest.Metadata.Enabled = metadata.Enabled
manifest.Metadata.Installable = true
} else {
metadata.Execution = "host:" + metadata.ID
manifest = protocol.Manifest{
APIVersion: "yingce.plugin/v1",
Metadata: metadata,
Runtime: protocol.ManifestRuntime{Backend: "host:" + metadata.ID},
Permissions: []string{"generation.run"},
Contributes: protocol.ManifestContributions{Providers: []protocol.ManifestProvider{{
ID: metadata.ID, Label: metadata.Name, Capabilities: metadata.Categories, Scopes: metadata.Scopes,
Parameters: metadata.Parameters, Create: protocol.ManifestOperation{Method: "POST", Path: "/__host__/" + metadata.ID}, Response: protocol.ManifestResponse{},
}}},
}
}
data, err := json.Marshal(manifest)
if err != nil {
return fmt.Errorf("encode bundled protocol %s: %w", metadata.ID, err)
}
if bytes.Equal(existing, data) {
continue
}
if err := writePluginFile(path, data); err != nil {
return fmt.Errorf("install bundled protocol %s: %w", metadata.ID, err)
record := byID[metadata.ID]
if !bytes.Equal(record.Raw, data) {
now := time.Now().UTC()
if record.InstalledAt.IsZero() {
record.InstalledAt = now
}
record.UpdatedAt = now
}
record.ID, record.Raw, record.Source, record.PackagePath = metadata.ID, data, "bundled", ""
byID[metadata.ID] = record
}
return nil
result := make([]pluginRegistryRecord, 0, len(byID))
for _, record := range byID {
if record.Source == "bundled" {
if _, exists := bundledIDs[record.ID]; !exists {
// Bundled records are derived from the current host registry. Dropping
// an obsolete record prevents a removed built-in protocol from surviving
// as a second, stale provider after a host upgrade.
continue
}
}
result = append(result, record)
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return c.writeRegistry(result)
}
func (c *pluginRuntime) reload() error {
c.mu.Lock()
defer c.mu.Unlock()
entries, err := os.ReadDir(c.dir)
stored, err := c.readRegistry()
if err != nil {
return err
}
plugins := make(map[string]pluginRecord)
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
path := filepath.Join(c.dir, entry.Name())
data, readErr := os.ReadFile(path)
if readErr != nil {
return fmt.Errorf("read protocol plugin %s: %w", entry.Name(), readErr)
}
for _, storedRecord := range stored {
data := storedRecord.Raw
if len(data) > protocolPluginMaxBytes {
return fmt.Errorf("protocol plugin %s exceeds %d bytes", entry.Name(), protocolPluginMaxBytes)
return fmt.Errorf("plugin %s exceeds %d bytes", storedRecord.ID, protocolPluginMaxBytes)
}
var manifest protocol.Manifest
if err := json.Unmarshal(data, &manifest); err != nil {
return fmt.Errorf("decode protocol plugin %s: %w", entry.Name(), err)
return fmt.Errorf("decode plugin %s: %w", storedRecord.ID, err)
}
metadata := manifest.Metadata
if strings.TrimSpace(metadata.ID) == "" {
return fmt.Errorf("protocol plugin %s has no metadata id", entry.Name())
return fmt.Errorf("plugin %s has no metadata id", storedRecord.ID)
}
if _, exists := plugins[metadata.ID]; exists {
return fmt.Errorf("duplicate installed protocol %q", metadata.ID)
}
now := time.Now().UTC()
stat, _ := os.Stat(path)
updated := now
if stat != nil {
updated = stat.ModTime().UTC()
packageSHA256 := storedRecord.PackageSHA256
if packageSHA256 == "" {
packageSHA256 = pluginHash(data)
}
plugins[metadata.ID] = pluginRecord{Raw: data, Metadata: metadata, Source: pluginSource(metadata), FileName: entry.Name(), SHA256: pluginHash(data), InstalledAt: updated, UpdatedAt: updated, Status: "invalid"}
plugins[metadata.ID] = pluginRecord{Raw: data, Metadata: metadata, Source: storedRecord.Source, FileName: storedRecord.FileName, PackagePath: storedRecord.PackagePath, PackageSHA256: packageSHA256, SHA256: packageSHA256, InstalledAt: storedRecord.InstalledAt, UpdatedAt: storedRecord.UpdatedAt, Status: "invalid"}
}
registry, err := protocol.NewRegistry()
if err != nil {
return err
}
for id, record := range plugins {
adapter, loadErr := protocol.LoadInstalledPlugin(record.Raw, func(execution string) (protocol.Adapter, bool) {
adapters, loadErr := protocol.LoadInstalledProviders(record.Raw, func(execution string) (protocol.Adapter, bool) {
return protocol.Builtins().Resolve(execution)
})
if loadErr != nil {
@@ -193,12 +232,22 @@ func (c *pluginRuntime) reload() error {
}
if !record.Metadata.Enabled {
record.Status = "disabled"
_ = registry.Register(protocol.UnavailableAdapter{Info: record.Metadata})
for _, adapter := range adapters {
info := adapter.Metadata()
info.Enabled = false
_ = registry.Register(protocol.UnavailableAdapter{Info: info})
}
plugins[id] = record
continue
}
if err := registry.Register(adapter); err != nil {
record.Error = err.Error()
registrationFailed := false
for _, adapter := range adapters {
if err := registry.Register(adapter); err != nil {
record.Error = err.Error()
registrationFailed = true
}
}
if registrationFailed {
plugins[id] = record
continue
}
@@ -215,7 +264,7 @@ func (c *pluginRuntime) list() []PluginView {
defer c.mu.RUnlock()
items := make([]PluginView, 0, len(c.plugins))
for _, item := range c.plugins {
items = append(items, PluginView{Manifest: pluginManifestView(item.Metadata, item.Source), Source: item.Source, FileName: item.FileName, SHA256: item.SHA256, InstalledAt: item.InstalledAt, UpdatedAt: item.UpdatedAt, Status: item.Status, Error: item.Error})
items = append(items, PluginView{Manifest: pluginManifestView(item.Raw, item.Metadata, item.Source), Source: item.Source, FileName: item.FileName, Package: protocol.PluginPackageFormat, SHA256: item.SHA256, InstalledAt: item.InstalledAt, UpdatedAt: item.UpdatedAt, Status: item.Status, Error: item.Error})
}
sort.Slice(items, func(i, j int) bool { return items[i].Manifest.ID < items[j].Manifest.ID })
return items
@@ -227,37 +276,75 @@ func (c *pluginRuntime) registrySnapshot() *protocol.Registry {
return c.registry
}
func (c *pluginRuntime) install(data []byte) (PluginView, error) {
func (c *pluginRuntime) install(data []byte, fileName string) (PluginView, error) {
c.mutationMu.Lock()
defer c.mutationMu.Unlock()
if len(data) == 0 || len(data) > protocolPluginMaxBytes {
return PluginView{}, fmt.Errorf("plugin manifest must be between 1 and %d bytes", protocolPluginMaxBytes)
if len(data) == 0 || len(data) > protocol.PluginPackageMaxBytes {
return PluginView{}, fmt.Errorf("plugin package must be between 1 and %d bytes", protocol.PluginPackageMaxBytes)
}
var manifest protocol.Manifest
if err := json.Unmarshal(data, &manifest); err != nil {
return PluginView{}, fmt.Errorf("插件清单 JSON 无效:%w", err)
}
if strings.HasPrefix(strings.TrimSpace(manifest.Metadata.Execution), "host:") {
return PluginView{}, errors.New("上传插件不能使用宿主内置执行器")
}
if _, err := protocol.LoadManifest(data); err != nil {
return PluginView{}, err
}
c.mu.RLock()
_, exists := c.plugins[manifest.Metadata.ID]
c.mu.RUnlock()
if exists {
return PluginView{}, fmt.Errorf("插件 ID %q 已存在;当前上传入口不支持覆盖安装", manifest.Metadata.ID)
}
manifest.Metadata.Enabled = true
data, err := json.Marshal(manifest)
pkg, err := protocol.ParsePluginPackage(data)
if err != nil {
return PluginView{}, err
}
path := filepath.Join(c.dir, manifest.Metadata.ID+".json")
if err := c.persistAndReload(path, data, nil); err != nil {
manifest := pkg.Manifest
if strings.HasPrefix(strings.TrimSpace(manifest.Runtime.Backend), "host:") {
return PluginView{}, errors.New("上传插件不能使用宿主内置执行器")
}
if _, err := protocol.LoadInstalledProviders(pkg.ManifestRaw, nil); err != nil {
return PluginView{}, err
}
c.mu.RLock()
existing, exists := c.plugins[manifest.Metadata.ID]
c.mu.RUnlock()
if exists && existing.Source == "bundled" {
return PluginView{}, fmt.Errorf("内置插件 %q 不能通过上传覆盖", manifest.Metadata.ID)
}
manifest.Metadata.Enabled = !exists || existing.Metadata.Enabled
manifestData, err := json.Marshal(manifest)
if err != nil {
return PluginView{}, err
}
hash := pluginHash(data)
packageName := filepath.Base(strings.TrimSpace(fileName))
if packageName == "." || packageName == "" || packageName == string(filepath.Separator) {
packageName = manifest.Metadata.ID + ".yingce-plugin"
}
packagePath := filepath.Join(c.packageDir, hash+".yingce-plugin")
if err := writePluginFile(packagePath, data); err != nil {
return PluginView{}, fmt.Errorf("保存插件包失败:%w", err)
}
stored, err := c.readRegistry()
if err != nil {
_ = os.Remove(packagePath)
return PluginView{}, err
}
previousStored := append([]pluginRegistryRecord(nil), stored...)
now := time.Now().UTC()
newRecord := pluginRegistryRecord{ID: manifest.Metadata.ID, Raw: manifestData, Source: "uploaded", FileName: packageName, PackagePath: filepath.Base(packagePath), PackageSHA256: hash, InstalledAt: now, UpdatedAt: now}
if exists {
newRecord.InstalledAt = existing.InstalledAt
for index := range stored {
if stored[index].ID == manifest.Metadata.ID {
stored[index] = newRecord
break
}
}
} else {
stored = append(stored, newRecord)
}
if err := c.writeRegistry(stored); err != nil {
_ = os.Remove(packagePath)
return PluginView{}, fmt.Errorf("保存插件失败:%w", err)
}
if err := c.reload(); err != nil {
_ = c.writeRegistry(previousStored)
_ = c.reload()
_ = os.Remove(packagePath)
return PluginView{}, err
}
if exists && existing.PackagePath != "" && existing.PackagePath != filepath.Base(packagePath) {
_ = os.Remove(filepath.Join(c.packageDir, filepath.Base(existing.PackagePath)))
}
for _, item := range c.list() {
if item.Manifest.ID == manifest.Metadata.ID {
return item, nil
@@ -284,7 +371,20 @@ func (c *pluginRuntime) setEnabled(id string, enabled bool) (PluginView, error)
if err != nil {
return PluginView{}, err
}
if err := c.persistAndReload(filepath.Join(c.dir, record.FileName), data, record.Raw); err != nil {
stored, err := c.readRegistry()
if err != nil {
return PluginView{}, err
}
for index := range stored {
if stored[index].ID == record.Metadata.ID {
stored[index].Raw = data
stored[index].UpdatedAt = time.Now().UTC()
}
}
if err := c.writeRegistry(stored); err != nil {
return PluginView{}, err
}
if err := c.reload(); err != nil {
return PluginView{}, err
}
for _, item := range c.list() {
@@ -295,7 +395,9 @@ func (c *pluginRuntime) setEnabled(id string, enabled bool) (PluginView, error)
return PluginView{}, errors.New("插件状态更新后未加载")
}
func pluginManifestView(metadata protocol.Metadata, source string) PluginManifestView {
func pluginManifestView(raw []byte, metadata protocol.Metadata, source string) PluginManifestView {
var manifest protocol.Manifest
_ = json.Unmarshal(raw, &manifest)
if source == "bundled" {
if adapter, ok := protocol.Builtins().Get(metadata.ID); ok {
current := adapter.Metadata()
@@ -304,20 +406,14 @@ func pluginManifestView(metadata protocol.Metadata, source string) PluginManifes
}
protocol.AttachDocumentation(&metadata)
}
categories := make([]string, 0, len(metadata.Categories))
for _, item := range metadata.Categories {
categories = append(categories, string(item))
}
scopes := make([]string, 0, len(metadata.Scopes))
for _, item := range metadata.Scopes {
scopes = append(scopes, string(item))
if manifest.Metadata.ID == "" {
manifest.Metadata = metadata
}
return PluginManifestView{
ID: metadata.ID, Name: metadata.Name, Version: metadata.Version, APIVersion: "v1",
Category: "protocol", Description: metadata.Description, Author: metadata.Vendor,
Surfaces: []string{"hybrid"}, Permissions: []string{"generation.run"},
Trusted: source == "bundled", Kind: "protocol",
Protocol: PluginProtocolView{Categories: categories, Scopes: scopes, Create: metadata.Create, Poll: metadata.Poll, Cancel: metadata.Cancel, ContentType: metadata.ContentType, Documentation: metadata.Documentation, Parameters: metadata.Parameters},
ID: metadata.ID, Name: metadata.Name, Version: metadata.Version, APIVersion: "yingce.plugin/v1", Entry: manifest.Entry, Surfaces: manifest.Surfaces,
Description: metadata.Description, Documentation: metadata.Documentation, Author: metadata.Vendor,
Permissions: manifest.Permissions, Trusted: source == "bundled", Runtime: manifest.Runtime,
Configuration: manifest.Configuration, Contributes: manifest.Contributes,
}
}
@@ -328,37 +424,57 @@ func (c *pluginRuntime) uninstall(id string) error {
record, ok := c.plugins[strings.TrimSpace(id)]
c.mu.RUnlock()
if !ok {
return fmt.Errorf("协议插件 %q 不存在", id)
return fmt.Errorf("插件 %q 不存在", id)
}
if record.Source == "bundled" {
return fmt.Errorf("内置协议插件 %q 不能卸载,可停用该插件", id)
return fmt.Errorf("内置插件 %q 不能卸载,可停用该插件", id)
}
path := filepath.Join(c.dir, record.FileName)
if err := os.Remove(path); err != nil {
stored, err := c.readRegistry()
if err != nil {
return err
}
filtered := stored[:0]
for _, item := range stored {
if item.ID != record.Metadata.ID {
filtered = append(filtered, item)
}
}
if err := c.writeRegistry(filtered); err != nil {
return err
}
if err := c.reload(); err != nil {
_ = writePluginFile(path, record.Raw)
_ = c.reload()
return err
}
if record.PackagePath != "" {
_ = os.Remove(filepath.Join(c.packageDir, filepath.Base(record.PackagePath)))
}
return nil
}
func (c *pluginRuntime) persistAndReload(path string, data, rollback []byte) error {
if err := writePluginFile(path, data); err != nil {
func (c *pluginRuntime) readRegistry() ([]pluginRegistryRecord, error) {
data, err := os.ReadFile(c.registryPath)
if errors.Is(err, os.ErrNotExist) {
return []pluginRegistryRecord{}, nil
}
if err != nil {
return nil, err
}
if len(data) > protocolPluginMaxBytes*64 {
return nil, fmt.Errorf("插件 registry 超过大小限制")
}
var records []pluginRegistryRecord
if err := json.Unmarshal(data, &records); err != nil {
return nil, fmt.Errorf("读取插件 registry 失败:%w", err)
}
return records, nil
}
func (c *pluginRuntime) writeRegistry(records []pluginRegistryRecord) error {
data, err := json.MarshalIndent(records, "", " ")
if err != nil {
return err
}
if err := c.reload(); err != nil {
if rollback == nil {
_ = os.Remove(path)
} else {
_ = writePluginFile(path, rollback)
}
_ = c.reload()
return err
}
return nil
return writePluginFile(c.registryPath, data)
}
func writePluginFile(path string, data []byte) error {
@@ -387,7 +503,7 @@ func writePluginFile(path string, data []byte) error {
}
func pluginSource(metadata protocol.Metadata) string {
if strings.HasPrefix(strings.TrimSpace(metadata.Execution), "host:") {
if _, ok := protocol.Builtins().Get(metadata.ID); ok {
return "bundled"
}
return "uploaded"