// 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