diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 826d96ad..93138c19 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -161,6 +161,61 @@ description and keep ownership on the side listed here. under `~/.parsar/`; never use the repo checkout, container image working directory, or the process CWD as hidden state. +### Plugin Bundle (KindBundle) architecture + +- A Plugin Bundle is a `KindBundle` capability that packages server tools, + client UI, skills, and hooks as a single deployable unit installed via + `parsar plugin add`. +- **Server tools** run inside `server/plugin-host/` — a Node.js process + speaking MCP stdio protocol (JSON-RPC 2.0). The daemon spawns it like + any other MCP server (`{ command: "node", args: [...] }`). +- The plugin-host process is configured via `PARSAR_PLUGIN_HOST_PATH` (env + var pointing at `server/plugin-host/index.js`). When unset, bundles with + `server_entry` are silently skipped with a log warning. +- Plugin server code lives on disk at `/plugins//`. + The CLI copies files during `parsar plugin add`; the server reads them + at prompt time via the plugin-host `--plugins-dir` argument. +- Directory names strip the `@scope/` prefix from bundle names + (`@internal/hotel-ops` → `hotel-ops`). This logic is duplicated in + `apps/parsar/internal/cli/plugin.go` (`pluginDirName`) and + `server/internal/connector/agentdaemon/capability_runtime.go` + (`bundleNameToDirName`) — keep both in sync. +- `resolveBundleCapability` returns a `bundleResolution` struct containing + both system prompt injections (skills) and MCP server configs (tools). + The MCP server name is `"plugin:"`. +- Plugin SDK (`server/plugin-host/lib/sdk.js`) provides + `ctx.tools.define(name, { description, parameters, handler })`. Future + phases will add `ctx.hooks`, `ctx.credentials`, and `ctx.api`. +- Plugin tool handlers have a 30-second timeout. Errors are returned as + MCP tool-level errors (`isError: true`), not JSON-RPC errors. +- **Client UI** uses a slot-based extension system + (`apps/web/src/lib/plugin-slots.ts`). Plugins register React components + to named slots via `ctx.slots.register(slotId, { key, component, match? })`. +- Slot types: `single` (last registration replaces), `list` (all render + in order), `chain` (first match wins — used for tool-card rendering). +- Client bundles are built by the CLI during `parsar plugin add` using + esbuild (`server/plugin-host/build-client.js`). Output goes to + `//dist/client.js`. Served via + `GET /api/v1/plugins/{name}/client.js`. +- React is shared via `window.__PARSAR_PLUGIN_API__` (exposed in + `plugin-init.ts`). Plugins must NOT bundle their own React. +- Plugin client bundles use IIFE format with a `require()` shim and an + esbuild `externalize-react` plugin. Standard `import React` works; + `react-dom` specific APIs (`createPortal`, etc.) are not yet supported. +- The frontend loads plugin clients on page load via `usePluginClients` + hook. Binding/unbinding a capability triggers an immediate reload + through React Query invalidation. +- Predefined slot IDs (add new ones as FDE needs arise): + `workspace.main`, `workspace.content`, `layout.header.actions`, + `layout.nav.bottom`, `conversation.tool-card`, + `conversation.header.actions`, `conversation.input.dock`, + `conversation.composer.left/right`, `agent.workspace`, + `agent.settings.section`. +- Adding a new slot point: wrap the target area with + `} />` or insert + `` at the desired position. Each new slot is + 3–5 lines of code. + ### Human interaction lifecycle - `agent_interactions` is the canonical durable record for permission prompts diff --git a/apps/parsar/internal/cli/plugin.go b/apps/parsar/internal/cli/plugin.go new file mode 100644 index 00000000..37e6cd9f --- /dev/null +++ b/apps/parsar/internal/cli/plugin.go @@ -0,0 +1,472 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + osexec "os/exec" + "path/filepath" + "strings" + "text/tabwriter" +) + +func runPlugin(ctx *runContext, args []string) error { + if len(args) == 0 { + printPluginHelp(ctx.stdout) + return fmt.Errorf("plugin: missing subcommand") + } + if args[0] == "-h" || args[0] == "--help" || args[0] == "help" { + printPluginHelp(ctx.stdout) + return nil + } + for _, sc := range pluginSubcommands { + if sc.name == args[0] { + return sc.run(ctx, args[1:]) + } + } + printPluginHelp(ctx.stderr) + return fmt.Errorf("plugin: unknown subcommand %q", args[0]) +} + +var pluginSubcommands = []command{ + {name: "add", summary: "Install a plugin bundle from a local directory", run: runPluginAdd}, + {name: "list", summary: "List installed plugin bundles", run: runPluginList}, + {name: "remove", summary: "Remove an installed plugin bundle", run: runPluginRemove}, +} + +func printPluginHelp(w io.Writer) { + fmt.Fprintln(w, "Usage: parsar plugin [flags]") + fmt.Fprintln(w) + fmt.Fprintln(w, "Subcommands:") + for _, sc := range pluginSubcommands { + fmt.Fprintf(w, " %-9s %s\n", sc.name, sc.summary) + } +} + +// ----- plugin add ----------------------------------------------------------- + +// pluginManifest mirrors the user-authored manifest.json in a plugin directory. +type pluginManifest struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` + Server *pluginManifestEntry `json:"server,omitempty"` + Client *pluginManifestEntry `json:"client,omitempty"` + Skills []string `json:"skills,omitempty"` + Tools []string `json:"tools,omitempty"` + Hooks []string `json:"hooks,omitempty"` + Credentials []string `json:"credentials,omitempty"` +} + +type pluginManifestEntry struct { + Entry string `json:"entry,omitempty"` + Tools []string `json:"tools,omitempty"` +} + +// bundleSkillPayload mirrors canonical.BundleSkill for the API request. +type bundleSkillPayload struct { + Slug string `json:"slug"` + Instruction string `json:"instruction"` +} + +func runPluginAdd(ctx *runContext, args []string) error { + fs := newFlagSet("plugin add") + jsonOut := fs.Bool("json", false, "emit JSON of the created capability") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("plugin add: parse flags: %w", err) + } + remaining := fs.Args() + if len(remaining) == 0 { + return fmt.Errorf("plugin add: path to plugin directory is required") + } + pluginDir := remaining[0] + + // Read and parse manifest.json + manifestPath := filepath.Join(pluginDir, "manifest.json") + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("plugin add: read manifest: %w", err) + } + var manifest pluginManifest + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return fmt.Errorf("plugin add: parse manifest: %w", err) + } + if strings.TrimSpace(manifest.Name) == "" { + return fmt.Errorf("plugin add: manifest.name is required") + } + if strings.TrimSpace(manifest.Version) == "" { + return fmt.Errorf("plugin add: manifest.version is required") + } + + // Read skill files and embed content + skills, err := readPluginSkills(pluginDir, manifest.Skills) + if err != nil { + return fmt.Errorf("plugin add: %w", err) + } + + // Build the canonical_spec + bundleSpec := map[string]any{ + "name": manifest.Name, + "version": manifest.Version, + } + if manifest.Description != "" { + bundleSpec["description"] = manifest.Description + } + if manifest.Author != "" { + bundleSpec["author"] = manifest.Author + } + if manifest.Server != nil && manifest.Server.Entry != "" { + bundleSpec["server_entry"] = manifest.Server.Entry + } + if manifest.Client != nil && manifest.Client.Entry != "" { + bundleSpec["client_entry"] = manifest.Client.Entry + } + if len(skills) > 0 { + bundleSpec["skills"] = skills + } + // Collect tools from manifest top-level or server.tools + tools := manifest.Tools + if manifest.Server != nil && len(manifest.Server.Tools) > 0 { + tools = append(tools, manifest.Server.Tools...) + } + if len(tools) > 0 { + bundleSpec["tools"] = tools + } + if len(manifest.Hooks) > 0 { + bundleSpec["hooks"] = manifest.Hooks + } + if len(manifest.Credentials) > 0 { + bundleSpec["credentials"] = manifest.Credentials + } + + canonicalSpec := map[string]any{ + "schema_version": 1, + "kind": "bundle", + "bundle": bundleSpec, + } + + // Build the API request + reqBody := map[string]any{ + "type": "bundle", + "name": manifest.Name, + "description": manifest.Description, + "visibility": "workspace", + "version": manifest.Version, + "canonical_spec": canonicalSpec, + } + + // Phase 1: if the plugin has a server entry, copy the plugin directory + // to the plugins storage dir BEFORE the API call. If copy fails, we + // leave harmless files on disk rather than a DB record with no loadable + // server code (which would cause runtime spawn errors). + if manifest.Server != nil && manifest.Server.Entry != "" { + if err := copyPluginToStorage(pluginDir, manifest.Name); err != nil { + return fmt.Errorf("plugin add: copy server files: %w", err) + } + } + + // Phase 2: if the plugin has a client entry, build it with esbuild + // and copy the built bundle to the plugins storage dir. + if manifest.Client != nil && manifest.Client.Entry != "" { + if err := buildAndCopyClient(pluginDir, manifest.Name, manifest.Client.Entry); err != nil { + return fmt.Errorf("plugin add: build client: %w", err) + } + } + + cfg, err := ctx.resolveConfig() + if err != nil { + return fmt.Errorf("plugin add: %w", err) + } + if strings.TrimSpace(cfg.WorkspaceID) == "" { + return fmt.Errorf("plugin add: PARSAR_WORKSPACE_ID is required") + } + var result map[string]any + if err := newClient(cfg).do(context.Background(), "POST", "/api/v1/workspaces/"+cfg.WorkspaceID+"/capabilities/plugins/install", nil, reqBody, &result); err != nil { + return fmt.Errorf("plugin add: %w", err) + } + + if *jsonOut { + return emitJSON(ctx.stdout, result) + } + name := manifest.Name + if id, ok := result["id"].(string); ok { + fmt.Fprintf(ctx.stdout, "plugin %q installed (capability_id=%s)\n", name, id) + } else { + fmt.Fprintf(ctx.stdout, "plugin %q installed\n", name) + } + return nil +} + +// readPluginSkills reads skill markdown files from the plugin directory +// and returns them as inline payloads for the canonical_spec. +func readPluginSkills(pluginDir string, skillPaths []string) ([]bundleSkillPayload, error) { + if len(skillPaths) == 0 { + return nil, nil + } + var skills []bundleSkillPayload + for _, relPath := range skillPaths { + fullPath := filepath.Join(pluginDir, relPath) + content, err := os.ReadFile(fullPath) + if err != nil { + return nil, fmt.Errorf("read skill %s: %w", relPath, err) + } + // Derive slug from filename: "skills/customer-service.md" → "customer-service" + base := filepath.Base(relPath) + slug := strings.TrimSuffix(base, filepath.Ext(base)) + skills = append(skills, bundleSkillPayload{ + Slug: slug, + Instruction: string(content), + }) + } + return skills, nil +} + +// ----- plugin list ---------------------------------------------------------- + +func runPluginList(ctx *runContext, args []string) error { + fs := newFlagSet("plugin list") + jsonOut := fs.Bool("json", false, "emit JSON instead of the table") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("plugin list: parse flags: %w", err) + } + cfg, err := ctx.resolveConfig() + if err != nil { + return fmt.Errorf("plugin list: %w", err) + } + if strings.TrimSpace(cfg.WorkspaceID) == "" { + return fmt.Errorf("plugin list: PARSAR_WORKSPACE_ID is required") + } + var result struct { + Capabilities []struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Version string `json:"latest_version"` + } `json:"capabilities"` + } + if err := newClient(cfg).do(context.Background(), "GET", "/api/v1/workspaces/"+cfg.WorkspaceID+"/capabilities?type=bundle", nil, nil, &result); err != nil { + return fmt.Errorf("plugin list: %w", err) + } + if *jsonOut { + return emitJSON(ctx.stdout, result.Capabilities) + } + if len(result.Capabilities) == 0 { + fmt.Fprintln(ctx.stdout, "(no plugins installed)") + return nil + } + tw := tabwriter.NewWriter(ctx.stdout, 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tVERSION\tDESCRIPTION") + for _, c := range result.Capabilities { + fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Name, c.Version, truncate(c.Description, 50)) + } + return tw.Flush() +} + +// ----- plugin remove -------------------------------------------------------- + +func runPluginRemove(ctx *runContext, args []string) error { + fs := newFlagSet("plugin remove") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("plugin remove: parse flags: %w", err) + } + remaining := fs.Args() + if len(remaining) == 0 { + return fmt.Errorf("plugin remove: plugin name is required") + } + name := remaining[0] + cfg, err := ctx.resolveConfig() + if err != nil { + return fmt.Errorf("plugin remove: %w", err) + } + if strings.TrimSpace(cfg.WorkspaceID) == "" { + return fmt.Errorf("plugin remove: PARSAR_WORKSPACE_ID is required") + } + + // Resolve plugin name to capability_id via the list endpoint. + var listResult struct { + Capabilities []struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + } `json:"capabilities"` + } + c := newClient(cfg) + if err := c.do(context.Background(), "GET", "/api/v1/workspaces/"+cfg.WorkspaceID+"/capabilities?type=bundle", nil, nil, &listResult); err != nil { + return fmt.Errorf("plugin remove: list plugins: %w", err) + } + var capabilityID string + for _, cap := range listResult.Capabilities { + if cap.Name == name { + capabilityID = cap.ID + break + } + } + if capabilityID == "" { + return fmt.Errorf("plugin remove: plugin %q not found", name) + } + + // Delete by capability ID using the existing endpoint. + if err := c.do(context.Background(), "DELETE", "/api/v1/workspaces/"+cfg.WorkspaceID+"/capabilities/"+capabilityID, nil, nil, nil); err != nil { + return fmt.Errorf("plugin remove: %w", err) + } + + // Clean up on-disk plugin files (best-effort; failure is logged but + // doesn't fail the command since the DB record is already gone). + if pluginsDir, err := resolvePluginsDir(); err == nil { + dirName := pluginDirName(name) + _ = os.RemoveAll(filepath.Join(pluginsDir, dirName)) + } + + fmt.Fprintf(ctx.stdout, "plugin %q removed\n", name) + return nil +} + +// ----- plugin storage ------------------------------------------------------- + +// resolvePluginsDir determines the plugins storage directory. +// Reads PARSAR_DATA_DIR (same env the server uses), defaults to ~/.parsar. +func resolvePluginsDir() (string, error) { + dataDir := strings.TrimSpace(os.Getenv("PARSAR_DATA_DIR")) + if dataDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + dataDir = filepath.Join(home, ".parsar") + } + return filepath.Join(dataDir, "plugins"), nil +} + +// pluginDirName converts a bundle name (possibly scoped) to the directory +// name under plugins/. Strips the "@scope/" prefix. +// NOTE: duplicated in server/internal/connector/agentdaemon/capability_runtime.go +// (bundleNameToDirName). Keep both in sync until a shared package is extracted. +func pluginDirName(name string) string { + if idx := strings.LastIndex(name, "/"); idx >= 0 { + return name[idx+1:] + } + return name +} + +// copyPluginToStorage copies the plugin source directory into +// //, creating the target if needed. Existing +// contents are replaced (simple rm + copy). +func copyPluginToStorage(srcDir, pluginName string) error { + pluginsDir, err := resolvePluginsDir() + if err != nil { + return err + } + dirName := pluginDirName(pluginName) + dstDir := filepath.Join(pluginsDir, dirName) + + // Remove previous install (idempotent upgrade). + _ = os.RemoveAll(dstDir) + + if err := os.MkdirAll(dstDir, 0o755); err != nil { + return fmt.Errorf("create plugin dir: %w", err) + } + + return copyDir(srcDir, dstDir) +} + +// copyDir recursively copies src into dst. Both must exist. +// Skips node_modules, .git, and symlinks. +func copyDir(src, dst string) error { + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, entry := range entries { + // Skip symlinks — avoid traversing outside the plugin tree. + if entry.Type()&os.ModeSymlink != 0 { + continue + } + + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + if entry.IsDir() { + // Skip node_modules — never copy dependency trees. + if entry.Name() == "node_modules" || entry.Name() == ".git" { + continue + } + if err := os.MkdirAll(dstPath, 0o755); err != nil { + return err + } + if err := copyDir(srcPath, dstPath); err != nil { + return err + } + } else { + data, err := os.ReadFile(srcPath) + if err != nil { + return err + } + // Preserve execute bit for scripts. + info, _ := entry.Info() + mode := os.FileMode(0o644) + if info != nil && info.Mode()&0o111 != 0 { + mode = 0o755 + } + if err := os.WriteFile(dstPath, data, mode); err != nil { + return err + } + } + } + return nil +} + +// ----- client build --------------------------------------------------------- + +// buildAndCopyClient builds the plugin's client entry with esbuild and +// copies the output to //dist/client.js. +// +// Requires: node + esbuild available (esbuild is loaded as ESM import in +// the build-client.js script). The build script lives next to plugin-host. +func buildAndCopyClient(pluginDir, pluginName, clientEntry string) error { + pluginsDir, err := resolvePluginsDir() + if err != nil { + return err + } + dirName := pluginDirName(pluginName) + dstDir := filepath.Join(pluginsDir, dirName, "dist") + if err := os.MkdirAll(dstDir, 0o755); err != nil { + return fmt.Errorf("create dist dir: %w", err) + } + + entryPath := filepath.Join(pluginDir, clientEntry) + outPath := filepath.Join(dstDir, "client.js") + + // Locate the build-client.js script. It lives alongside plugin-host. + // Try PARSAR_PLUGIN_HOST_PATH directory first, then fallback to relative. + buildScript := resolveBuildScript() + if buildScript == "" { + return fmt.Errorf("cannot locate build-client.js; ensure PARSAR_PLUGIN_HOST_PATH is set") + } + + // Run: node build-client.js + cmd := osexec.Command("node", buildScript, entryPath, outPath) + cmd.Dir = pluginDir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("esbuild failed: %s\n%s", err, string(output)) + } + return nil +} + +// resolveBuildScript finds the build-client.js script path. +func resolveBuildScript() string { + // From PARSAR_PLUGIN_HOST_PATH (same dir as plugin-host/index.js). + hostPath := strings.TrimSpace(os.Getenv("PARSAR_PLUGIN_HOST_PATH")) + if hostPath != "" { + dir := filepath.Dir(hostPath) + candidate := filepath.Join(dir, "build-client.js") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + return "" +} diff --git a/apps/parsar/internal/cli/root.go b/apps/parsar/internal/cli/root.go index 0e843f0b..0e4fb86b 100644 --- a/apps/parsar/internal/cli/root.go +++ b/apps/parsar/internal/cli/root.go @@ -41,6 +41,7 @@ var commands = []command{ {name: "memory", summary: "Manage user / workspace memories (list / add / edit / rm)", run: runMemory}, {name: "inject", summary: "Print the injection bundle hook scripts stitch into the prompt", run: runInject}, {name: "sync", summary: "Human-readable dump of the current injection snapshot (debug)", run: runSync}, + {name: "plugin", summary: "Manage plugin bundles (add / list / remove)", run: runPlugin}, {name: "version", summary: "Print the CLI version and exit", run: runVersion}, } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c5773455..0a345af4 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,6 +9,9 @@ import { InviteAcceptPage } from "./pages/InviteAcceptPage" import { AuthProvider, useAuth } from "./lib/auth-context" import { ThemeProvider } from "./lib/theme-provider" import { useMyWorkspaces } from "./lib/api-workspaces" +import { SingleSlot } from "./components/plugin/SlotRenderer" +import { usePluginClients } from "./lib/use-plugins" +import { useWorkspaceId } from "./lib/workspace" function LoadingScreen({ message }: { message: string }) { return ( @@ -21,6 +24,8 @@ function LoadingScreen({ message }: { message: string }) { function AuthedRoot() { const { t } = useTranslation("common") const wsQuery = useMyWorkspaces() + const wsId = useWorkspaceId() + usePluginClients(wsId) if (wsQuery.isLoading) { return @@ -28,7 +33,12 @@ function AuthedRoot() { if ((wsQuery.data?.workspaces.length ?? 0) === 0) { return } - return + // workspace.main slot: when a plugin registers here, it takes over + // the entire page (full-screen). No navigation, no sidebar — the + // plugin owns everything. + return ( + } /> + ) } function Root() { diff --git a/apps/web/src/components/layout/AdminLayout.tsx b/apps/web/src/components/layout/AdminLayout.tsx index 10200af5..941885f9 100644 --- a/apps/web/src/components/layout/AdminLayout.tsx +++ b/apps/web/src/components/layout/AdminLayout.tsx @@ -19,6 +19,7 @@ import { WorkspaceSwitcher } from "./WorkspaceSwitcher" import { ThemeMenu } from "./ThemeMenu" import { UserMenu } from "./UserMenu" import { useTheme } from "../../lib/theme" +import { ListSlot } from "../plugin/SlotRenderer" interface AdminLayoutProps { children: ReactNode @@ -102,6 +103,7 @@ export function AdminLayout({
+
@@ -162,6 +164,7 @@ export function AdminLayout({ ))} + )} diff --git a/apps/web/src/components/plugin/SlotRenderer.tsx b/apps/web/src/components/plugin/SlotRenderer.tsx new file mode 100644 index 00000000..f1f05158 --- /dev/null +++ b/apps/web/src/components/plugin/SlotRenderer.tsx @@ -0,0 +1,139 @@ +/** + * SlotRenderer — renders plugin-registered components at a named slot. + * + * Usage: + * } /> + * + */ + +import { Component, type ReactNode, useSyncExternalStore } from "react" +import { slotRegistry, type SlotRegistration } from "../../lib/plugin-slots" + +// ─── Hook: subscribe to slot registry ─────────────────────────────────────── + +const subscribe = (cb: () => void) => slotRegistry.subscribe(cb) +const getSnapshot = () => slotRegistry.getVersion() + +function useSlotRegistrations(slotId: string): SlotRegistration[] { + // Subscribe to version changes; derive the list from the stable cache. + useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + return slotRegistry.getRegistrations(slotId) +} + +// ─── ErrorBoundary ────────────────────────────────────────────────────────── + +interface ErrorBoundaryProps { + pluginName: string + children: ReactNode +} +interface ErrorBoundaryState { + error: Error | null +} + +class PluginErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error } + } + + render() { + if (this.state.error) { + return ( +
+ Plugin "{this.props.pluginName}" crashed: {this.state.error.message} +
+ ) + } + return this.props.children + } +} + +// ─── ToolCardSlot (chain type) ────────────────────────────────────────────── + +interface ToolCardSlotProps { + /** The presentation metadata from the tool result. */ + presentation?: { kind?: string; data?: unknown } | null + /** Raw tool result content (fallback text). */ + content?: string + /** Rendered when no plugin claims the presentation. */ + fallback?: ReactNode +} + +/** + * Renders a plugin-registered tool card if a plugin's match() claims the + * presentation. Falls back to the default rendering otherwise. + */ +export function ToolCardSlot({ presentation, content, fallback }: ToolCardSlotProps) { + if (!presentation?.kind) return <>{fallback} + + const match = slotRegistry.matchChain("conversation.tool-card", { presentation, content }) + if (!match) return <>{fallback} + + const { registration, data } = match + const PluginComponent = registration.component + + return ( + + + + ) +} + +// ─── ListSlot ─────────────────────────────────────────────────────────────── + +interface ListSlotProps { + slotId: string + /** Extra props passed to every registered component. */ + context?: Record +} + +/** + * Renders all plugin-registered components for a "list" slot, in order. + */ +export function ListSlot({ slotId, context }: ListSlotProps) { + const registrations = useSlotRegistrations(slotId) + if (registrations.length === 0) return null + + return ( + <> + {registrations.map((reg) => { + const PluginComponent = reg.component + return ( + + + + ) + })} + + ) +} + +// ─── SingleSlot ───────────────────────────────────────────────────────────── + +interface SingleSlotProps { + slotId: string + /** Extra props passed to the registered component. */ + context?: Record + /** Rendered when no plugin has registered for this slot. */ + fallback?: ReactNode +} + +/** + * Renders the last-registered plugin component for a "single" slot. + * Falls back to children when no registration exists. + */ +export function SingleSlot({ slotId, context, fallback }: SingleSlotProps) { + const registrations = useSlotRegistrations(slotId) + if (registrations.length === 0) return <>{fallback} + + // Single slot: last registration wins. + const reg = registrations[registrations.length - 1] + const PluginComponent = reg.component + + return ( + + + + ) +} diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index b7ef6ea9..360fdb12 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -1423,7 +1423,8 @@ "mcp": "MCP", "skill": "Skill", "plugin": "Plugin", - "system_prompt": "System Prompt" + "system_prompt": "System Prompt", + "bundle": "Plugin Bundle" } }, "builtin": { diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index 8b1bd91e..0f0921a9 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -1423,7 +1423,8 @@ "mcp": "MCP", "skill": "Skill", "plugin": "Plugin", - "system_prompt": "System Prompt" + "system_prompt": "System Prompt", + "bundle": "插件包" } }, "builtin": { diff --git a/apps/web/src/lib/api-capabilities.ts b/apps/web/src/lib/api-capabilities.ts index 753a38ee..9050b01d 100644 --- a/apps/web/src/lib/api-capabilities.ts +++ b/apps/web/src/lib/api-capabilities.ts @@ -328,6 +328,7 @@ export function useEnableAgentCapabilityMutation( onSuccess: () => { if (workspaceID && agentID) { qc.invalidateQueries({ queryKey: KEY_AGENT_CAPABILITIES(workspaceID, agentID) }) + qc.invalidateQueries({ queryKey: ["plugins", "bundles", workspaceID] }) } }, }) @@ -377,6 +378,7 @@ export function useDeleteAgentCapabilityMutation( onSuccess: () => { if (workspaceID && agentID) { qc.invalidateQueries({ queryKey: KEY_AGENT_CAPABILITIES(workspaceID, agentID) }) + qc.invalidateQueries({ queryKey: ["plugins", "bundles", workspaceID] }) } }, }) diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 805201ab..c1c53694 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -245,7 +245,7 @@ export interface DeleteAgentResponse { /* --- Capabilities -------------------------------------------------------- */ -export type CapabilityType = "skill" | "mcp" | "plugin" | "system_prompt" +export type CapabilityType = "skill" | "mcp" | "plugin" | "system_prompt" | "bundle" export interface RequiredCredential { kind: string diff --git a/apps/web/src/lib/plugin-init.ts b/apps/web/src/lib/plugin-init.ts new file mode 100644 index 00000000..96e71cac --- /dev/null +++ b/apps/web/src/lib/plugin-init.ts @@ -0,0 +1,20 @@ +/** + * Plugin system initialization — exposes the shared React instance and + * plugin registration API on window so client bundles can access them. + * + * Must be imported early in main.tsx (before any plugin loading happens). + */ + +import * as React from "react" +import { createPluginClientContext, type ParsarPluginAPI } from "./plugin-slots" + +const api: ParsarPluginAPI = { + React, + createContext: createPluginClientContext, + definePlugin(pluginName, setup) { + const ctx = createPluginClientContext(pluginName) + setup(ctx) + }, +} + +window.__PARSAR_PLUGIN_API__ = api diff --git a/apps/web/src/lib/plugin-loader.ts b/apps/web/src/lib/plugin-loader.ts new file mode 100644 index 00000000..cb77ce6c --- /dev/null +++ b/apps/web/src/lib/plugin-loader.ts @@ -0,0 +1,79 @@ +/** + * Plugin Client Loader — fetches plugin client.js bundles and executes them. + * + * On page load (or when the agent's enabled plugins change), this module: + * 1. Fetches the plugin list for the current agent/workspace + * 2. For each plugin with a client_entry, fetches GET /api/v1/plugins/{name}/client.js + * 3. Executes the bundle in a function scope with access to window.__PARSAR_PLUGIN_API__ + * + * Each plugin bundle is expected to call: + * const { React, definePlugin } = window.__PARSAR_PLUGIN_API__ + * definePlugin("@internal/my-plugin", (ctx) => { ctx.slots.register(...) }) + */ + +import { slotRegistry } from "./plugin-slots" + +interface PluginManifest { + name: string + client_entry?: string +} + +const loadedPlugins = new Set() + +/** + * Load a single plugin's client bundle by name. + * Idempotent: skips if already loaded. + */ +export async function loadPluginClient(pluginName: string): Promise { + if (loadedPlugins.has(pluginName)) return + + // Derive URL-safe name (strip @scope/ prefix for the path segment). + const dirName = pluginName.includes("/") + ? pluginName.slice(pluginName.lastIndexOf("/") + 1) + : pluginName + + const url = `/api/v1/plugins/${encodeURIComponent(dirName)}/client.js` + + try { + const resp = await fetch(url) + if (!resp.ok) { + console.warn(`[plugin-loader] failed to fetch client for "${pluginName}": ${resp.status}`) + return + } + const code = await resp.text() + + // Execute the plugin code. It should call window.__PARSAR_PLUGIN_API__.definePlugin() + // or access the API directly. + const fn = new Function(code) + fn() + + loadedPlugins.add(pluginName) + console.info(`[plugin-loader] loaded client for "${pluginName}"`) + } catch (err) { + console.error(`[plugin-loader] error loading "${pluginName}":`, err) + } +} + +/** + * Load all plugin clients that have a client_entry. + * Called from the conversation view when plugins are resolved. + */ +export async function loadAllPluginClients(plugins: PluginManifest[]): Promise { + const withClient = plugins.filter((p) => p.client_entry) + await Promise.allSettled(withClient.map((p) => loadPluginClient(p.name))) +} + +/** + * Unload a plugin (remove its slot registrations). + */ +export function unloadPlugin(pluginName: string): void { + slotRegistry.unregisterPlugin(pluginName) + loadedPlugins.delete(pluginName) +} + +/** + * Check if a plugin client is already loaded. + */ +export function isPluginLoaded(pluginName: string): boolean { + return loadedPlugins.has(pluginName) +} diff --git a/apps/web/src/lib/plugin-slots.ts b/apps/web/src/lib/plugin-slots.ts new file mode 100644 index 00000000..09542c06 --- /dev/null +++ b/apps/web/src/lib/plugin-slots.ts @@ -0,0 +1,201 @@ +/** + * Plugin Slot Registry — the core client-side plugin system. + * + * Plugins register React components to named "slots" in the UI. The main + * app renders SlotRenderer at each slot position, which queries this registry + * and renders the appropriate plugin component. + * + * Slot types: + * single — only the last registration wins (replace entire area) + * list — all registrations render in order + * chain — first registration whose `match` returns truthy wins + * + * Standard slot IDs: + * workspace.main — replace entire workspace (single) + * agent.workspace — replace agent right panel (single) + * conversation.tool-card — custom tool result card (chain) + * conversation.header.actions — header action buttons (list) + * conversation.input.dock — above-input panel (list) + * conversation.composer.left — left of input (list) + * conversation.composer.right — right of input (list) + * agent.settings.section — agent settings extensions (list) + */ + +import type { ComponentType } from "react" + +// ─── Types ────────────────────────────────────────────────────────────────── + +export type SlotType = "single" | "list" | "chain" + +export interface SlotRegistration { + /** Unique key for this registration (plugin dedup). */ + key: string + /** The owning plugin name. */ + pluginName: string + /** The React component to render. */ + component: ComponentType + /** For "chain" slots: return truthy data to claim rendering. */ + match?: (props: any) => any + /** Sort order for "list" slots. Lower = earlier. Default 0. */ + order?: number +} + +export interface SlotDefinition { + id: string + type: SlotType +} + +// ─── Registry ─────────────────────────────────────────────────────────────── + +/** Predefined slot definitions. */ +export const SLOT_DEFINITIONS: Record = { + // Full-page slots + "workspace.main": { id: "workspace.main", type: "single" }, + "workspace.content": { id: "workspace.content", type: "single" }, + "agent.workspace": { id: "agent.workspace", type: "single" }, + // Layout extension slots + "layout.header.actions": { id: "layout.header.actions", type: "list" }, + "layout.nav.bottom": { id: "layout.nav.bottom", type: "list" }, + // Conversation slots + "conversation.tool-card": { id: "conversation.tool-card", type: "chain" }, + "conversation.header.actions": { id: "conversation.header.actions", type: "list" }, + "conversation.input.dock": { id: "conversation.input.dock", type: "list" }, + "conversation.composer.left": { id: "conversation.composer.left", type: "list" }, + "conversation.composer.right": { id: "conversation.composer.right", type: "list" }, + // Agent extension slots + "agent.settings.section": { id: "agent.settings.section", type: "list" }, +} + +class PluginSlotRegistry { + private slots = new Map() + private sortedCache = new Map() + private listeners = new Set<() => void>() + private version = 0 + + /** Register a component to a slot. */ + register(slotId: string, reg: Omit, pluginName: string): void { + const full: SlotRegistration = { ...reg, pluginName } + const list = this.slots.get(slotId) ?? [] + + // Dedup by key: replace if same key exists. + const idx = list.findIndex((r) => r.key === full.key) + if (idx >= 0) { + list[idx] = full + } else { + list.push(full) + } + + this.slots.set(slotId, list) + this.sortedCache.delete(slotId) + this.notify() + } + + /** Remove all registrations from a specific plugin. */ + unregisterPlugin(pluginName: string): void { + let changed = false + for (const [slotId, list] of this.slots) { + const filtered = list.filter((r) => r.pluginName !== pluginName) + if (filtered.length !== list.length) { + this.slots.set(slotId, filtered) + this.sortedCache.delete(slotId) + changed = true + } + } + if (changed) this.notify() + } + + /** Get all registrations for a slot, sorted by order. Cached for React stability. */ + getRegistrations(slotId: string): SlotRegistration[] { + const cached = this.sortedCache.get(slotId) + if (cached) return cached + + const list = this.slots.get(slotId) ?? [] + const sorted = [...list].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + this.sortedCache.set(slotId, sorted) + return sorted + } + + /** For chain slots: find the first registration whose match() returns truthy. */ + matchChain(slotId: string, props: any): { registration: SlotRegistration; data: any } | null { + const regs = this.getRegistrations(slotId) + for (const reg of regs) { + if (!reg.match) continue + const data = reg.match(props) + if (data) return { registration: reg, data } + } + return null + } + + /** Subscribe to registry changes (for React re-renders). */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + /** Get a version number for useSyncExternalStore snapshot comparison. */ + getVersion(): number { + return this.version + } + + private notify(): void { + this.version++ + for (const fn of this.listeners) fn() + } +} + +/** The singleton slot registry instance. */ +export const slotRegistry = new PluginSlotRegistry() + +// ─── Plugin API (exposed on window) ──────────────────────────────────────── + +/** + * The PluginClientContext provided to each plugin's client entry. + * Plugin code calls ctx.slots.register(...) to inject UI. + */ +export interface PluginClientContext { + slots: { + register( + slotId: string, + registration: { + key: string + component: ComponentType + match?: (props: any) => any + order?: number + } + ): void + } +} + +/** + * Creates a PluginClientContext for a specific plugin. + * Called by the plugin loader before executing each plugin's client.js. + */ +export function createPluginClientContext(pluginName: string): PluginClientContext { + return { + slots: { + register(slotId, registration) { + slotRegistry.register(slotId, registration, pluginName) + }, + }, + } +} + +// ─── Window global API ────────────────────────────────────────────────────── + +export interface ParsarPluginAPI { + /** React library — shared with plugins so they don't bundle their own. */ + React: typeof import("react") + /** Create a plugin context (used internally by the loader). */ + createContext: (pluginName: string) => PluginClientContext + /** Convenience: directly register a plugin's default export. */ + definePlugin: ( + pluginName: string, + setup: (ctx: PluginClientContext) => void + ) => void +} + +declare global { + interface Window { + __PARSAR_PLUGIN_API__?: ParsarPluginAPI + } +} diff --git a/apps/web/src/lib/use-plugins.ts b/apps/web/src/lib/use-plugins.ts new file mode 100644 index 00000000..01848c71 --- /dev/null +++ b/apps/web/src/lib/use-plugins.ts @@ -0,0 +1,77 @@ +/** + * React hook for loading plugin client bundles for the current workspace. + * Fetches the plugin list from the capabilities API and loads any that + * have client_entry defined. + * + * Plugins are loaded once on page load. After binding or unbinding a + * capability, refresh the page to pick up the change (same as DSH). + */ + +import { useEffect, useRef } from "react" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { apiRequest, noUnreachableRetry } from "./api-client" +import { loadAllPluginClients, unloadPlugin, isPluginLoaded } from "./plugin-loader" + +interface PluginCapability { + id: string + name: string + type: string +} + +interface PluginListResponse { + capabilities: PluginCapability[] +} + +async function fetchBundleCapabilities(wsId: string): Promise { + const resp = await apiRequest( + `/api/v1/workspaces/${encodeURIComponent(wsId)}/capabilities`, + { query: { type: "bundle" } } + ) + return resp.capabilities ?? [] +} + +export function usePluginClients(workspaceId: string | null) { + const prevPluginsRef = useRef>(new Set()) + + const { data: capabilities } = useQuery({ + queryKey: ["plugins", "bundles", workspaceId ?? "_none"], + queryFn: () => { + if (!workspaceId) return [] + return fetchBundleCapabilities(workspaceId) + }, + enabled: !!workspaceId, + retry: noUnreachableRetry, + staleTime: Infinity, + }) + + useEffect(() => { + if (!capabilities) return + + const currentNames = new Set( + capabilities.filter((c) => c.type === "bundle").map((c) => c.name) + ) + + // Unload plugins that were previously loaded but are no longer in the list. + for (const name of prevPluginsRef.current) { + if (!currentNames.has(name)) { + unloadPlugin(name) + } + } + + // Load new plugins that aren't loaded yet. + const toLoad = [...currentNames].filter((name) => !isPluginLoaded(name)) + if (toLoad.length > 0) { + void loadAllPluginClients(toLoad.map((name) => ({ name, client_entry: "yes" }))) + } + + prevPluginsRef.current = currentNames + }, [capabilities]) +} + +/** Invalidate the plugin bundles query to trigger reload/unload. */ +export function useInvalidatePlugins() { + const qc = useQueryClient() + return (workspaceId: string) => { + void qc.invalidateQueries({ queryKey: ["plugins", "bundles", workspaceId] }) + } +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 288fb3ae..f6b1c77d 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { App } from './App' import { bootstrapWorkspace } from './lib/bootstrap' import { prefetchProviderCatalog } from './lib/model-presets' +import './lib/plugin-init' // expose window.__PARSAR_PLUGIN_API__ before plugins load import './style.css' import './i18n' // bootstrap i18next import './i18n/types' // type-augment t() keys diff --git a/apps/web/src/pages/admin/ConversationsPage.tsx b/apps/web/src/pages/admin/ConversationsPage.tsx index 5120b13c..94a9e49a 100644 --- a/apps/web/src/pages/admin/ConversationsPage.tsx +++ b/apps/web/src/pages/admin/ConversationsPage.tsx @@ -68,6 +68,7 @@ import { writeConversationViewState, } from "../../lib/conversation-view-state" import { credentialKindLabel } from "./capability-ui" +import { ToolCardSlot, SingleSlot, ListSlot } from "../../components/plugin/SlotRenderer" const FOLD_KEY = "parsar:conv:sidebarFolded" @@ -715,6 +716,23 @@ function ConversationMain(p: MainProps) { const err = p.convError const isUnreachable = err instanceof ApiError && err.envelope.unreachable + // workspace.content slot: plugin can replace the conversation content + // area while keeping the navigation sidebar intact. + return ( + + } + /> + ) +} + +function ConversationMainInner(p: MainProps & { err: unknown; isUnreachable: boolean }) { + const { t } = useTranslation("admin") + const { err, isUnreachable } = p + return (
{p.folded && ( @@ -1021,6 +1039,7 @@ function ChatStream({ {t("conversations.detail.cancelAll", { defaultValue: "Cancel all" })} )} + @@ -1121,6 +1140,7 @@ function ChatStream({
+ {chatToast && setChatToast(null)} />} (s.status === "running" ? { ...s, status: "failed" as const } : s)) }) const failedRun = (outputRuns ?? []).find((r) => r.status === "failed") + // Extract presentation from: 1) message metadata, or 2) tool step results. + // Plugin-host embeds __parsar_presentation in the MCP tool_result content + // blocks; the daemon forwards it in the step result.content array. + const presentation = (metadata?.presentation as { kind?: string; data?: unknown } | undefined) + ?? extractPresentationFromSteps(allSteps) return (
{agentName || "Agent"}
-
-

{content}

-
+ +

{content}

+
+ } + /> {allSteps.length > 0 && } {failedRun && onOpenRun && (