Files
Agent-System-Backend/internal/plugins/host.go
T
weichen e08d9b091d Load plugin workspace declarations and expose GET /api/workspaces (T4)
Why: T4 workspaces are declared by plugins via provides.workspaces (Core PluginWorkspace is the authoritative type); the host must surface the merged overlay for the frontend to render in OverlayHost.
Evidence: go test green - plugins suite new TestWorkspacesOverlay (manifest + workspaces/main.json loads, ListWorkspaces returns the declaration with shortcut and body); full internal sweep unchanged.
Impact: plugins host loads workspaces/*.json assets and exposes ListWorkspaces; server adds GET /api/workspaces passthrough; no behavior change without the manifest key.
Refs: docs/proposals/2026-09-30-ui-inbox-command-workspace.md T4; task card feature/workspaces-r1
2026-09-30 08:01:31 +00:00

346 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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{"<dir>": 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["<commands>"] = err
}
}
if onIntents != nil {
if err := onIntents(overlay); err != nil {
results["<intents>"] = 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