// Package plugins 实现插件宿主(4.6):manifest + 声明资产 + 可选代码。
// fail-closed:任一资产解析失败整批拒绝该插件并保留上一状态;卸载注销注册项。
// 声明资产热加载:Reload 重扫目录;工具/面板/卡片/意图/工作流/设置全部数据化。
package plugins
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
core "agent-system-core"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
)
// Loaded 是一个已装载插件的内存态(注册结果快照)。
type Loaded struct {
Manifest core.PluginManifest
Dir string
Panels []core.PluginPanel
Cards map[string][]core.UINode // kind → 模板节点
Workflows []core.WorkflowDef
Intents []json.RawMessage // agent-intent overlay 形态(目录侧消费)
Commands []json.RawMessage // Agent-Command spec overlay(cmdbus 注册)
Workspaces []core.PluginWorkspace // 工作区弹层声明(OverlayHost 渲染)
Prompts map[string]string // 文件名 → 内容(画像模板)
PluginSettings []core.SettingDesc
Defaults map[string]any // 插件设置默认值(并入有效设置)
}
// Host 管理插件目录装载与注册。
type Host struct {
mu sync.RWMutex
st *store.Store
registry *tools.Registry
settings *settings.Service
dir string
loaded map[string]*Loaded // id → loaded
// OnIntents 在装载集合变化后回调全部意图 overlay(宿主接识别闸门;nil = 不接)。
OnIntents func(overlay []json.RawMessage) error
// OnCommands 在装载集合变化后回调全部命令 spec overlay(宿主接命令总线;nil = 不接)。
OnCommands func(overlay []json.RawMessage) error
}
func New(st *store.Store, reg *tools.Registry, s *settings.Service, dir string) *Host {
return &Host{st: st, registry: reg, settings: s, dir: dir, loaded: map[string]*Loaded{}}
}
// LoadDir 全量装载目录(启动与热加载共用);返回每个插件的结果(失败不中断其他)。
func (h *Host) LoadDir() map[string]error {
entries, err := os.ReadDir(h.dir)
if err != nil {
if os.IsNotExist(err) {
return map[string]error{}
}
return map[string]error{"
": err}
}
results := map[string]error{}
seen := map[string]bool{}
for _, e := range entries {
if !e.IsDir() {
continue
}
id := e.Name()
seen[id] = true
results[id] = h.loadOne(filepath.Join(h.dir, id))
}
// 移除已消失的插件(卸载语义)。
h.mu.Lock()
for id := range h.loaded {
if !seen[id] {
delete(h.loaded, id)
h.registry.UnregisterBySource(core.SourcePlugin, id)
}
}
overlay := h.collectIntentsLocked()
cmdOverlay := h.collectCommandsLocked()
onIntents, onCommands := h.OnIntents, h.OnCommands
h.mu.Unlock()
if onCommands != nil {
if err := onCommands(cmdOverlay); err != nil {
results[""] = err
}
}
if onIntents != nil {
if err := onIntents(overlay); err != nil {
results[""] = err
}
}
return results
}
// loadOne 装载单个插件;任何失败返回错误且不改变既有注册(fail-closed)。
func (h *Host) loadOne(dir string) error {
manifestPath := filepath.Join(dir, "plugin.json")
raw, err := os.ReadFile(manifestPath)
if err != nil {
return fmt.Errorf("plugins: %s: %w", filepath.Base(dir), err)
}
var m core.PluginManifest
if err := json.Unmarshal(raw, &m); err != nil {
return fmt.Errorf("plugins: %s: manifest json: %w", m.ID, err)
}
if m.ID == "" || m.Name == "" || m.Version == "" {
return fmt.Errorf("plugins: %s: manifest missing id/name/version", m.ID)
}
if m.APIVersion == "" {
m.APIVersion = core.PluginAPIVersion
}
if m.APIVersion != core.PluginAPIVersion {
return fmt.Errorf("plugins: %s: apiVersion %s != host %s(兼容窗口外)", m.ID, m.APIVersion, core.PluginAPIVersion)
}
ld := &Loaded{Manifest: m, Dir: dir, Cards: map[string][]core.UINode{}, Prompts: map[string]string{}, Defaults: map[string]any{}}
// 同 ID 重载:先注销旧注册(含旧版本工具),再装载新资产。
h.registry.UnregisterBySource(core.SourcePlugin, m.ID)
// tools/*.json:声明式 HTTP 工具(或含 parameters 的描述型工具)。
for _, name := range m.Provides.Tools {
var desc core.ToolDesc
if err := readAsset(dir, "tools", name, &desc); err != nil {
return fmt.Errorf("plugins: %s: tool %s: %w", m.ID, name, err)
}
desc.Source = core.SourcePlugin
desc.PluginID = m.ID
exec, err := h.bindToolExecutor(desc)
if err != nil {
return fmt.Errorf("plugins: %s: tool %s: %w", m.ID, name, err)
}
if err := h.registry.Register(desc, exec); err != nil {
return fmt.Errorf("plugins: %s: register tool %s: %w", m.ID, name, err)
}
}
// panels/*.json
for _, name := range m.Provides.Panels {
var panel core.PluginPanel
if err := readAsset(dir, "panels", name, &panel); err != nil {
return fmt.Errorf("plugins: %s: panel %s: %w", m.ID, name, err)
}
ld.Panels = append(ld.Panels, panel)
}
// cards/*.json:{kind, data:[UINode]}
for _, name := range m.Provides.Cards {
var card struct {
Kind string `json:"kind"`
Data []core.UINode `json:"data"`
}
if err := readAsset(dir, "cards", name, &card); err != nil {
return fmt.Errorf("plugins: %s: card %s: %w", m.ID, name, err)
}
if card.Kind == "" {
return fmt.Errorf("plugins: %s: card %s: missing kind", m.ID, name)
}
ld.Cards[card.Kind] = card.Data
}
// workflows/*.json
for _, name := range m.Provides.Workflows {
var wf core.WorkflowDef
if err := readAsset(dir, "workflows", name, &wf); err != nil {
return fmt.Errorf("plugins: %s: workflow %s: %w", m.ID, name, err)
}
ld.Workflows = append(ld.Workflows, wf)
}
// intents/*.json:agent-intent overlay 形态原样透出(目录侧消费,不在此解析)。
for _, name := range m.Provides.Intents {
var specs []json.RawMessage
if err := readAsset(dir, "intents", name, &specs); err != nil {
return fmt.Errorf("plugins: %s: intents %s: %w", m.ID, name, err)
}
ld.Intents = append(ld.Intents, specs...)
}
// commands/*.json:Agent-Command spec overlay 形态透出(cmdbuf 注册;不在此校验语义)。
for _, name := range m.Provides.Commands {
var specs []json.RawMessage
if err := readAsset(dir, "commands", name, &specs); err != nil {
return fmt.Errorf("plugins: %s: commands %s: %w", m.ID, name, err)
}
ld.Commands = append(ld.Commands, specs...)
}
// workspaces/*.json:工作区弹层声明(T4;OverlayHost 渲染,声明权威在 Core 类型)。
for _, name := range m.Provides.Workspaces {
var ws []core.PluginWorkspace
if err := readAsset(dir, "workspaces", name, &ws); err != nil {
return fmt.Errorf("plugins: %s: workspaces %s: %w", m.ID, name, err)
}
ld.Workspaces = append(ld.Workspaces, ws...)
}
// prompts/*.md:画像模板(source=plugin,可被用户复制微调)。
for _, name := range m.Provides.Prompts {
content, err := os.ReadFile(filepath.Join(dir, "prompts", name+".md"))
if err != nil {
return fmt.Errorf("plugins: %s: prompt %s: %w", m.ID, name, err)
}
ld.Prompts[name] = string(content)
}
// settings.json:插件设置声明(并入设置中心 schema;默认值进覆盖链底层)。
if len(m.Provides.Settings) > 0 {
var descs []core.SettingDesc
if err := readAsset(dir, "", "settings", &descs); err != nil {
return fmt.Errorf("plugins: %s: settings: %w", m.ID, err)
}
for i := range descs {
descs[i].Group = "plugin:" + m.ID
ld.PluginSettings = append(ld.PluginSettings, descs[i])
}
}
h.mu.Lock()
h.loaded[m.ID] = ld
overlay := h.collectIntentsLocked()
cmdOverlay := h.collectCommandsLocked()
onIntents, onCommands := h.OnIntents, h.OnCommands
h.mu.Unlock()
_ = h.st.UpsertPlugin(m.ID, dir, m)
if onCommands != nil {
if err := onCommands(cmdOverlay); err != nil {
return fmt.Errorf("plugins: %s: commands rejected by command bus (kept previous): %w", m.ID, err)
}
}
if onIntents != nil {
if err := onIntents(overlay); err != nil {
return fmt.Errorf("plugins: %s: intents rejected by host gate (kept previous): %w", m.ID, err)
}
}
return nil
}
// collectIntentsLocked 汇总全部已装载插件的意图 overlay(调用方持锁)。
func (h *Host) collectIntentsLocked() []json.RawMessage {
var out []json.RawMessage
for _, ld := range h.loaded {
out = append(out, ld.Intents...)
}
return out
}
// ListWorkspaces 全部已装载插件的工作区声明(GET /api/workspaces 数据源)。
func (h *Host) ListWorkspaces() []core.PluginWorkspace {
h.mu.RLock()
defer h.mu.RUnlock()
var out []core.PluginWorkspace
for _, ld := range h.loaded {
out = append(out, ld.Workspaces...)
}
return out
}
// collectCommandsLocked 汇总全部已装载插件的命令 overlay(调用方持锁)。
func (h *Host) collectCommandsLocked() []json.RawMessage {
var out []json.RawMessage
for _, ld := range h.loaded {
out = append(out, ld.Commands...)
}
return out
}
// bindToolExecutor:声明式 HTTP 工具绑定执行器;纯描述型(无 http)工具暂不支持(代码插件为远期 sidecar 档)。
func (h *Host) bindToolExecutor(desc core.ToolDesc) (tools.Executor, error) {
if desc.HTTP == nil {
return nil, fmt.Errorf("v1 仅支持声明式 HTTP 工具(缺少 http 段)")
}
if desc.HTTP.URL == "" || desc.HTTP.Method == "" {
return nil, fmt.Errorf("http 工具缺少 url/method")
}
lookup := func(key string) string {
// AuthRef 走用户层设置(如 models.chat.apiKey 或自定义 secret key)。
eff := h.settings.Effective(store.OwnerDefault, "")
return eff.GetString(key, "")
}
return tools.HTTPExecutor(*desc.HTTP, lookup), nil
}
func readAsset(dir, sub, name string, v any) error {
p := filepath.Join(dir, sub, name+".json")
if sub == "" {
p = filepath.Join(dir, name+".json")
}
raw, err := os.ReadFile(p)
if err != nil {
return err
}
return json.Unmarshal(raw, v)
}
// ---- 查询接口 ----
func (h *Host) List() []*Loaded {
h.mu.RLock()
defer h.mu.RUnlock()
out := make([]*Loaded, 0, len(h.loaded))
for _, ld := range h.loaded {
out = append(out, ld)
}
sort.Slice(out, func(i, j int) bool { return out[i].Manifest.ID < out[j].Manifest.ID })
return out
}
// SettingsSchema 追加插件设置项(settings schema 汇总用)。
func (h *Host) SettingsSchema() []core.SettingDesc {
h.mu.RLock()
defer h.mu.RUnlock()
var out []core.SettingDesc
for _, ld := range h.List() {
out = append(out, ld.PluginSettings...)
}
return out
}
// Cards 返回 kind → 模板(server 演示卡片注入用)。
func (h *Host) Cards() map[string][]core.UINode {
h.mu.RLock()
defer h.mu.RUnlock()
out := map[string][]core.UINode{}
for _, ld := range h.loaded {
for k, v := range ld.Cards {
out[k] = v
}
}
return out
}
// Unregister 注销插件全部注册项(卸载语义:在途引用由 registry miss 稳定报错)。
func (h *Host) Unregister(id string) {
h.mu.Lock()
delete(h.loaded, id)
h.mu.Unlock()
h.registry.UnregisterBySource(core.SourcePlugin, id)
_ = h.st.RemovePlugin(id)
}
var _ = strings.TrimSpace