From e08d9b091d0b774a5414c28f1bd55af6e122b43e Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 08:01:31 +0000 Subject: [PATCH] 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 --- internal/plugins/host.go | 21 +++++++++++++++++++++ internal/plugins/host_test.go | 30 ++++++++++++++++++++++++++++++ internal/server/server.go | 10 ++++++++++ 3 files changed, 61 insertions(+) diff --git a/internal/plugins/host.go b/internal/plugins/host.go index bc08506..d52dab1 100644 --- a/internal/plugins/host.go +++ b/internal/plugins/host.go @@ -27,6 +27,7 @@ type Loaded struct { 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 // 插件设置默认值(并入有效设置) @@ -188,6 +189,15 @@ func (h *Host) loadOne(dir string) error { 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")) @@ -238,6 +248,17 @@ func (h *Host) collectIntentsLocked() []json.RawMessage { 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 diff --git a/internal/plugins/host_test.go b/internal/plugins/host_test.go index c7d6fde..1088e57 100644 --- a/internal/plugins/host_test.go +++ b/internal/plugins/host_test.go @@ -125,3 +125,33 @@ func TestAPIVersionGuard(t *testing.T) { t.Fatal("incompatible apiVersion should be rejected") } } + +func TestWorkspacesOverlay(t *testing.T) { + dir := t.TempDir() + st, err := store.OpenSQLite(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + reg := tools.NewRegistry() + reg.RegisterBuiltin() + pluginDir := filepath.Join(dir, "plugins") + os.MkdirAll(filepath.Join(pluginDir, "wsdemo", "workspaces"), 0o755) + os.WriteFile(filepath.Join(pluginDir, "wsdemo", "plugin.json"), []byte(`{"id":"wsdemo","name":"WS","version":"1.0.0","apiVersion":"1.0","provides":{"workspaces":["main"]}}`), 0o644) + os.WriteFile(filepath.Join(pluginDir, "wsdemo", "workspaces", "main.json"), []byte(`[ + {"id":"demo.governance","title":"治理总览","icon":"🛡","shortcut":"5","body":[{"type":"markdown","markdown":"声明式工作区"}]} + ]`), 0o644) + setSvc := settings.NewService(st) + h := New(st, reg, setSvc, pluginDir) + if errs := h.LoadDir(); len(errs) > 0 { + for _, err := range errs { + if err != nil { + t.Fatalf("load errs: %v", errs) + } + } + } + ws := h.ListWorkspaces() + if len(ws) != 1 || ws[0].ID != "demo.governance" || ws[0].Shortcut != "5" || len(ws[0].Body) != 1 { + t.Fatalf("workspaces: %+v", ws) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 9510d9b..5c23d01 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -178,6 +178,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/conversations/{id}/plan", s.getPlan) mux.HandleFunc("GET /api/conversations/{id}/todos", s.getTodos) mux.HandleFunc("GET /api/commands", s.listCommands) + mux.HandleFunc("GET /api/workspaces", s.listWorkspaces) mux.HandleFunc("POST /api/commands/{id}", s.execCommand) mux.HandleFunc("GET /api/commands/audit", s.listAudits) mux.HandleFunc("GET /api/parsers", s.listParsers) @@ -587,6 +588,15 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) { writeJSON(w, code, out) } +// listWorkspaces 插件工作区声明(T4;前端合并其内置工作区后渲染 OverlayHost)。 +func (s *Server) listWorkspaces(w http.ResponseWriter, r *http.Request) { + ws := []core.PluginWorkspace{} + if s.plugins != nil { + ws = s.plugins.ListWorkspaces() + } + writeJSON(w, http.StatusOK, ws) +} + // listAudits 审计查询(会话维度或全局;入口对称的可核查凭证)。 func (s *Server) listAudits(w http.ResponseWriter, r *http.Request) { limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))