Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -0,0 +1,64 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import {
|
||||
getPluginComponent,
|
||||
getPluginLoadError,
|
||||
onPluginRegistered,
|
||||
} from "./registry";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Translations } from "@/i18n/types";
|
||||
|
||||
/** Renders a plugin tab once its bundle has called `register()`. */
|
||||
export function PluginPage({ name }: { name: string }) {
|
||||
const { t } = useI18n();
|
||||
// Subscribe in render (via useSyncExternalStore) so we never miss
|
||||
// `register()` if the script loads before a useEffect would run.
|
||||
const Component = useSyncExternalStore(
|
||||
(onChange) => onPluginRegistered(onChange),
|
||||
() => getPluginComponent(name) ?? null,
|
||||
() => null,
|
||||
);
|
||||
const loadError = useSyncExternalStore(
|
||||
(onChange) => onPluginRegistered(onChange),
|
||||
() => getPluginLoadError(name) ?? null,
|
||||
() => null,
|
||||
);
|
||||
|
||||
if (Component) {
|
||||
return <Component />;
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
const message = formatPluginError(loadError, t);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-lg p-4",
|
||||
"font-mondwest text-sm tracking-[0.08em] text-text-secondary",
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-4",
|
||||
"font-mondwest text-sm tracking-[0.1em] text-text-tertiary",
|
||||
)}
|
||||
>
|
||||
<Spinner className="shrink-0" />
|
||||
<span>{t.common.loading}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPluginError(code: string, t: Translations): string {
|
||||
if (code === "LOAD_FAILED") return t.common.pluginLoadFailed;
|
||||
if (code === "NO_REGISTER") return t.common.pluginNotRegistered;
|
||||
return code;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { exposePluginSDK, getPluginComponent, onPluginRegistered, getRegisteredCount } from "./registry";
|
||||
export { PluginPage } from "./PluginPage";
|
||||
export { usePlugins } from "./usePlugins";
|
||||
export { PluginSlot, KNOWN_SLOT_NAMES, registerSlot, getSlotEntries, onSlotRegistered, unregisterPluginSlots } from "./slots";
|
||||
export type { KnownSlotName } from "./slots";
|
||||
export type { PluginManifest, RegisteredPlugin } from "./types";
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Smoke test for the plugin SDK surface additions.
|
||||
*
|
||||
* Verifies that `exposePluginSDK()` writes the new dialog/toast primitives to
|
||||
* `window.__HERMES_PLUGIN_SDK__`. Each new key is checked individually so a
|
||||
* regression in one helper doesn't mask the others.
|
||||
*
|
||||
* Companion to the additive PR "feat(plugins): expose Dialog/ConfirmDialog/
|
||||
* Toast/useToast/useConfirmDelete on the plugin SDK". See issue #50547.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { exposePluginSDK } from "./registry";
|
||||
|
||||
describe("plugin SDK dialog/toast surface", () => {
|
||||
beforeEach(() => {
|
||||
// Reset window between tests so exposePluginSDK() writes fresh.
|
||||
(globalThis as unknown as { window: Record<string, unknown> }).window = {
|
||||
__HERMES_PLUGINS__: undefined,
|
||||
__HERMES_PLUGIN_SDK__: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
it("exposes Dialog + subcomponents on components", () => {
|
||||
exposePluginSDK();
|
||||
const sdk = (globalThis as unknown as { window: { __HERMES_PLUGIN_SDK__: { components: Record<string, unknown>; hooks: Record<string, unknown> } } }).window.__HERMES_PLUGIN_SDK__;
|
||||
expect(sdk.components.Dialog).toBeDefined();
|
||||
expect(sdk.components.DialogContent).toBeDefined();
|
||||
expect(sdk.components.DialogHeader).toBeDefined();
|
||||
expect(sdk.components.DialogTitle).toBeDefined();
|
||||
expect(sdk.components.DialogDescription).toBeDefined();
|
||||
expect(sdk.components.DialogFooter).toBeDefined();
|
||||
expect(sdk.components.DialogClose).toBeDefined();
|
||||
expect(sdk.components.ConfirmDialog).toBeDefined();
|
||||
expect(sdk.components.Toast).toBeDefined();
|
||||
});
|
||||
|
||||
it("exposes useToast and useConfirmDelete on hooks", () => {
|
||||
exposePluginSDK();
|
||||
const sdk = (globalThis as unknown as { window: { __HERMES_PLUGIN_SDK__: { components: Record<string, unknown>; hooks: Record<string, unknown> } } }).window.__HERMES_PLUGIN_SDK__;
|
||||
expect(typeof sdk.hooks.useToast).toBe("function");
|
||||
expect(typeof sdk.hooks.useConfirmDelete).toBe("function");
|
||||
// Original React hooks still present (no accidental removal).
|
||||
expect(typeof sdk.hooks.useState).toBe("function");
|
||||
expect(typeof sdk.hooks.useCallback).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Dashboard Plugin SDK + Registry
|
||||
*
|
||||
* Exposes React, UI components, hooks, and utilities on the window so
|
||||
* that plugin bundles can use them without bundling their own copies.
|
||||
*
|
||||
* Plugins call window.__HERMES_PLUGINS__.register(name, Component)
|
||||
* to register their tab component.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useContext,
|
||||
createContext,
|
||||
} from "react";
|
||||
import { api, fetchJSON, authedFetch, buildWsUrl, buildWsAuthParam } from "@/lib/api";
|
||||
import { cn, timeAgo, isoTimeAgo } from "@/lib/utils";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
|
||||
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
|
||||
import {
|
||||
Dialog, DialogClose, DialogContent, DialogDescription,
|
||||
DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@nous-research/ui/ui/components/dialog";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { Separator } from "@nous-research/ui/ui/components/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@nous-research/ui/ui/components/tabs";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { registerSlot, PluginSlot } from "./slots";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin registry — plugins call register() to add their component.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RegistryListener = () => void;
|
||||
|
||||
const _registered: Map<string, React.ComponentType> = new Map();
|
||||
const _loadErrors: Map<string, string> = new Map();
|
||||
const _listeners: Set<RegistryListener> = new Set();
|
||||
|
||||
function _notify() {
|
||||
for (const fn of _listeners) {
|
||||
try { fn(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-run registry subscribers (e.g. after a plugin script onload, or dev HMR re-inject). */
|
||||
export function notifyPluginRegistry() {
|
||||
_notify();
|
||||
}
|
||||
|
||||
/** Register a plugin component. Called by plugin JS bundles. */
|
||||
function registerPlugin(name: string, component: React.ComponentType) {
|
||||
_loadErrors.delete(name);
|
||||
_registered.set(name, component);
|
||||
_notify();
|
||||
}
|
||||
|
||||
/** Get a registered component by plugin name. */
|
||||
export function getPluginComponent(name: string): React.ComponentType | undefined {
|
||||
return _registered.get(name);
|
||||
}
|
||||
|
||||
export function getPluginLoadError(name: string): string | undefined {
|
||||
return _loadErrors.get(name);
|
||||
}
|
||||
|
||||
export function setPluginLoadError(name: string, message: string) {
|
||||
_loadErrors.set(name, message);
|
||||
_notify();
|
||||
}
|
||||
|
||||
/** Subscribe to registry changes (returns unsubscribe fn). */
|
||||
export function onPluginRegistered(fn: RegistryListener): () => void {
|
||||
_listeners.add(fn);
|
||||
return () => _listeners.delete(fn);
|
||||
}
|
||||
|
||||
/** Get current count of registered plugins. */
|
||||
export function getRegisteredCount(): number {
|
||||
return _registered.size;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expose SDK + registry on window
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Version of the plugin SDK contract (see ``plugins/sdk.d.ts``). Bump the
|
||||
* major on any backwards-incompatible change to the exposed surface;
|
||||
* additive changes (new optional fields / helpers) don't require a bump.
|
||||
* Exposed at runtime as ``window.__HERMES_PLUGIN_SDK__.sdkVersion`` so a
|
||||
* plugin (or a future host-side compatibility gate) can read it.
|
||||
*/
|
||||
export const SDK_CONTRACT_VERSION = "1.1.0";
|
||||
|
||||
// Window globals for the plugin SDK are declared in ``plugins/sdk.d.ts`` —
|
||||
// the single source of truth for the public contract. Don't redeclare them
|
||||
// here (duplicate ambient declarations with differing modifiers conflict).
|
||||
|
||||
export function exposePluginSDK() {
|
||||
window.__HERMES_PLUGINS__ = {
|
||||
register: registerPlugin,
|
||||
registerSlot,
|
||||
};
|
||||
|
||||
window.__HERMES_PLUGIN_SDK__ = {
|
||||
// Contract version of the plugin SDK surface (see plugins/sdk.d.ts).
|
||||
// Bump on backwards-incompatible changes; additive changes don't need it.
|
||||
sdkVersion: SDK_CONTRACT_VERSION,
|
||||
// React core — plugins use these instead of importing react
|
||||
React,
|
||||
hooks: {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useContext,
|
||||
createContext,
|
||||
// useToast returns { showToast, toast } where toast is the current
|
||||
// visible toast (or null) and showToast(message, 'success'|'error')
|
||||
// replaces it. useConfirmDelete<TId>({ onDelete }) returns the state
|
||||
// machine (requestDelete / confirm / cancel / isOpen / isDeleting /
|
||||
// pendingId) for single-id delete confirmations.
|
||||
useToast,
|
||||
useConfirmDelete,
|
||||
},
|
||||
|
||||
// Hermes API client
|
||||
api,
|
||||
// Raw fetchJSON for plugin-specific JSON endpoints
|
||||
fetchJSON,
|
||||
// Authenticated fetch for non-JSON endpoints (uploads / blob downloads).
|
||||
// Handles loopback-token vs gated-cookie auth so plugins never read
|
||||
// window.__HERMES_SESSION_TOKEN__ directly.
|
||||
authedFetch,
|
||||
// Build a ws(s):// URL with the correct auth param for the active mode
|
||||
// (single-use ticket in gated mode, token in loopback). Use this for any
|
||||
// plugin WebSocket instead of hand-assembling the URL.
|
||||
buildWsUrl,
|
||||
// Lower-level: resolve just the [authParamName, authParamValue] pair, for
|
||||
// plugins that need to build the WS URL themselves.
|
||||
buildWsAuthParam,
|
||||
|
||||
// UI components — Nous DS where available, shadcn/ui primitives elsewhere.
|
||||
components: {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectOption,
|
||||
Separator,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Toast,
|
||||
PluginSlot,
|
||||
},
|
||||
|
||||
// Utilities
|
||||
utils: { cn, timeAgo, isoTimeAgo },
|
||||
|
||||
// Hooks
|
||||
useI18n,
|
||||
};
|
||||
}
|
||||
Vendored
+184
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Hermes Dashboard Plugin SDK — typed contract (SPIKE)
|
||||
* ====================================================
|
||||
*
|
||||
* This is the public type surface for ``window.__HERMES_PLUGIN_SDK__`` and
|
||||
* ``window.__HERMES_PLUGINS__``, the globals the dashboard host exposes to
|
||||
* plugin bundles (see ``web/src/plugins/registry.ts::exposePluginSDK``).
|
||||
*
|
||||
* STATUS: spike. This file documents the contract and gives plugin authors
|
||||
* (in-repo IIFEs and external bundles alike) editor types without bundling
|
||||
* their own copies of React / the API client. It is intentionally a
|
||||
* hand-authored ambient declaration rather than ``typeof
|
||||
* window.__HERMES_PLUGIN_SDK__`` because:
|
||||
* 1. The runtime object is assembled from many internal modules
|
||||
* (``@/lib/api``, ``@nous-research/ui``, …). Deriving the type would
|
||||
* leak those internal import paths into the public contract and couple
|
||||
* external plugins to the host's internal module layout.
|
||||
* 2. A hand-authored contract is the *versioned API boundary* — changing
|
||||
* it is a deliberate act, visible in review, not an accidental
|
||||
* consequence of refactoring an internal helper.
|
||||
*
|
||||
* Versioning: bump ``HermesPluginSDK["sdkVersion"]`` (and the
|
||||
* ``SDK_CONTRACT_VERSION`` const the host exposes) on any
|
||||
* backwards-incompatible change to this surface. Additive changes
|
||||
* (new optional fields, new helpers) don't require a major bump.
|
||||
*
|
||||
* OPEN QUESTIONS for productionising this spike (do not block the auth fix):
|
||||
* - Ship as a published ``@hermes/dashboard-plugin-sdk`` types package, or
|
||||
* keep in-repo and copy into external plugin repos?
|
||||
* - Should the host assert at runtime that a plugin's declared
|
||||
* ``manifest.sdk_version`` is compatible before executing it?
|
||||
* - The ``components`` map is typed loosely as ``Record<string,
|
||||
* ComponentType>`` here; do we want exact per-component prop types
|
||||
* (pulls @nous-research/ui types into the contract) or is the loose
|
||||
* shape the right boundary for external authors?
|
||||
*/
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth-relevant helpers (the surface this PR adds/sanctions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* JSON ``fetch`` for dashboard ``/api/...`` endpoints. Handles auth in both
|
||||
* modes (loopback session-token header / gated cookie), throws
|
||||
* ``Error("<status>: <body>")`` on non-2xx, and triggers the global
|
||||
* 401 → /login redirect in gated mode. Use for all JSON plugin endpoints.
|
||||
*/
|
||||
export type FetchJSON = <T = unknown>(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
options?: { allowUnauthorized?: boolean },
|
||||
) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Authenticated ``fetch`` for NON-JSON endpoints (uploads via ``FormData``,
|
||||
* binary/blob downloads). Same auth handling as ``fetchJSON`` but returns
|
||||
* the raw ``Response``, does not parse, does not throw on non-2xx, and does
|
||||
* not run the 401 redirect. Plugins MUST use this (or ``fetchJSON``) instead
|
||||
* of calling ``fetch`` with a hand-read ``window.__HERMES_SESSION_TOKEN__``.
|
||||
*/
|
||||
export type AuthedFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint with
|
||||
* the correct auth query param for the active mode (single-use ``ticket`` in
|
||||
* gated OAuth mode, ``token`` in loopback). Plugins MUST use this for any
|
||||
* WebSocket instead of hand-assembling the URL + reading the session token.
|
||||
*/
|
||||
export type BuildWsUrl = (
|
||||
path: string,
|
||||
params?: Record<string, string>,
|
||||
) => Promise<string>;
|
||||
|
||||
/** Lower-level: just the ``[authParamName, authParamValue]`` pair. */
|
||||
export type BuildWsAuthParam = () => Promise<[string, string]>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry surface (window.__HERMES_PLUGINS__)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PluginRegistry {
|
||||
/** Register the plugin's main tab component by manifest name. */
|
||||
register(name: string, component: ComponentType<Record<string, never>>): void;
|
||||
/** Register a component into a named host slot. */
|
||||
registerSlot(slot: string, name: string, component: ComponentType): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SDK surface (window.__HERMES_PLUGIN_SDK__)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HermesPluginSDK {
|
||||
/** Contract version of this SDK surface (see SDK_CONTRACT_VERSION). */
|
||||
readonly sdkVersion: string;
|
||||
|
||||
/** React core — use instead of importing/bundling react. */
|
||||
React: typeof import("react").default;
|
||||
hooks: {
|
||||
useState: typeof import("react").useState;
|
||||
useEffect: typeof import("react").useEffect;
|
||||
useCallback: typeof import("react").useCallback;
|
||||
useMemo: typeof import("react").useMemo;
|
||||
useRef: typeof import("react").useRef;
|
||||
useContext: typeof import("react").useContext;
|
||||
createContext: typeof import("react").createContext;
|
||||
/**
|
||||
* Toast feedback. Returns ``{ showToast, toast }`` where ``toast`` is the
|
||||
* current visible toast (or null) and ``showToast(message, 'success' | 'error')``
|
||||
* replaces it. Mount the ``Toast`` component (also on ``components``) somewhere
|
||||
* in your plugin's tree to render the visible toast.
|
||||
*/
|
||||
useToast: () => {
|
||||
showToast: (message: string, type: "success" | "error") => void;
|
||||
toast: { message: string; type: "success" | "error" } | null;
|
||||
};
|
||||
/**
|
||||
* Single-id delete-confirm state machine. Pass ``onDelete(id)`` (an async
|
||||
* function). Returns ``{ requestDelete, confirm, cancel, isOpen, isDeleting,
|
||||
* pendingId }``. Pair with the ``ConfirmDialog`` primitive (passed as
|
||||
* ``open={isOpen}`` etc.) or any custom dialog.
|
||||
*/
|
||||
useConfirmDelete: <TId,>(opts: { onDelete: (id: TId) => Promise<void> }) => {
|
||||
requestDelete: (id: TId) => void;
|
||||
confirm: () => Promise<void>;
|
||||
cancel: () => void;
|
||||
isOpen: boolean;
|
||||
isDeleting: boolean;
|
||||
pendingId: TId | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Typed convenience client for core dashboard endpoints. Typed permissively
|
||||
* at the boundary (methods vary in arity and return type — most return
|
||||
* ``Promise<T>``, a few return a URL string synchronously); plugins call the
|
||||
* specific methods they need. See ``web/src/lib/api.ts`` for the concrete shape.
|
||||
*/
|
||||
api: Record<string, (...args: never[]) => unknown>;
|
||||
|
||||
/** JSON fetch with host auth handling. */
|
||||
fetchJSON: FetchJSON;
|
||||
/** Authenticated raw fetch for uploads / blob downloads. */
|
||||
authedFetch: AuthedFetch;
|
||||
/** Build an auth'd WebSocket URL for the active mode. */
|
||||
buildWsUrl: BuildWsUrl;
|
||||
/** Resolve just the WS auth query-param pair. */
|
||||
buildWsAuthParam: BuildWsAuthParam;
|
||||
|
||||
/**
|
||||
* Shared UI primitives (Nous DS / shadcn). Typed permissively at the
|
||||
* boundary: the host's concrete components (some of which require props like
|
||||
* ``active``/``value``/``name``) must be assignable here, and external plugin
|
||||
* authors render them dynamically without the host's internal prop types.
|
||||
* ``ComponentType<never>`` accepts any component regardless of its prop
|
||||
* requirements (props are contravariant).
|
||||
*/
|
||||
components: Record<string, ComponentType<never>>;
|
||||
|
||||
utils: {
|
||||
cn: (...classes: Array<string | false | null | undefined>) => string;
|
||||
/** Relative-time formatter. Accepts an epoch-ms number. */
|
||||
timeAgo: (ts: number) => string;
|
||||
/** Relative-time formatter for an ISO-8601 string. */
|
||||
isoTimeAgo: (iso: string) => string;
|
||||
};
|
||||
|
||||
/**
|
||||
* i18n hook. Returns the host's i18n context value; typed loosely at the
|
||||
* boundary so the contract doesn't couple to the host's internal
|
||||
* ``I18nContextValue`` shape. Plugins typically call ``useI18n().t(...)``.
|
||||
*/
|
||||
useI18n: () => unknown;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_PLUGIN_SDK__?: HermesPluginSDK;
|
||||
__HERMES_PLUGINS__?: PluginRegistry;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Plugin slot registry.
|
||||
*
|
||||
* Plugins can inject components into named locations in the app shell
|
||||
* (header-left, sidebar, backdrop, etc.) by calling
|
||||
* `window.__HERMES_PLUGINS__.registerSlot(pluginName, slotName, Component)`
|
||||
* from their JS bundle. Multiple plugins can populate the same slot — they
|
||||
* render stacked in registration order.
|
||||
*
|
||||
* The canonical slot names are documented in `KNOWN_SLOT_NAMES` below. The
|
||||
* registry accepts any string so plugin ecosystems can define their own
|
||||
* slots; the shell only renders `<PluginSlot name="..." />` for the slots
|
||||
* it knows about.
|
||||
*/
|
||||
|
||||
import React, { Fragment, useEffect, useState } from "react";
|
||||
|
||||
/** Slot locations the built-in shell renders. Plugins declaring any of
|
||||
* these in their manifest's `slots` field get wired in automatically.
|
||||
*
|
||||
* Shell-wide slots:
|
||||
* - `backdrop` — optional full-viewport background decoration;
|
||||
* mounted behind shell chrome at z-0
|
||||
* - `header-left` — injected before the Hermes brand in the top bar
|
||||
* - `header-right` — injected before the theme/language switchers
|
||||
* - `header-banner` — injected below the top nav bar, full-width
|
||||
* - `sidebar` — the cockpit sidebar rail (only rendered when
|
||||
* `layoutVariant === "cockpit"`)
|
||||
* - `pre-main` — rendered above the route outlet (inside `<main>`)
|
||||
* - `post-main` — rendered below the route outlet (inside `<main>`)
|
||||
* - `footer-left` — replaces the left footer cell content
|
||||
* - `footer-right` — replaces the right footer cell content
|
||||
* - `overlay` — fixed-position layer above everything else;
|
||||
* useful for chrome (scanlines, vignettes) the
|
||||
* theme's customCSS can't achieve alone
|
||||
*
|
||||
* Page-scoped slots (rendered inside a specific built-in page — use these
|
||||
* to inject widgets, cards, or toolbars into existing pages without
|
||||
* overriding the whole route):
|
||||
* - `sessions:top` — top of /sessions page (above session list)
|
||||
* - `sessions:bottom` — bottom of /sessions page
|
||||
* - `analytics:top` — top of /analytics page
|
||||
* - `analytics:bottom` — bottom of /analytics page
|
||||
* - `logs:top` — top of /logs page (above filter toolbar)
|
||||
* - `logs:bottom` — bottom of /logs page (below log viewer)
|
||||
* - `cron:top` — top of /cron page
|
||||
* - `cron:bottom` — bottom of /cron page
|
||||
* - `skills:top` — top of /skills page
|
||||
* - `skills:bottom` — bottom of /skills page
|
||||
* - `plugins:top` — top of /plugins page
|
||||
* - `plugins:bottom` — bottom of /plugins page
|
||||
* - `config:top` — top of /config page
|
||||
* - `config:bottom` — bottom of /config page
|
||||
* - `env:top` — top of /env (Keys) page
|
||||
* - `env:bottom` — bottom of /env (Keys) page
|
||||
* - `docs:top` — top of /docs page (above the docs iframe)
|
||||
* - `docs:bottom` — bottom of /docs page
|
||||
* - `chat:top` — top of /chat page (above the composer, when embedded chat is on)
|
||||
* - `chat:bottom` — bottom of /chat page
|
||||
*/
|
||||
export const KNOWN_SLOT_NAMES = [
|
||||
// Shell-wide
|
||||
"backdrop",
|
||||
"header-left",
|
||||
"header-right",
|
||||
"header-banner",
|
||||
"sidebar",
|
||||
"pre-main",
|
||||
"post-main",
|
||||
"footer-left",
|
||||
"footer-right",
|
||||
"overlay",
|
||||
// Page-scoped
|
||||
"sessions:top",
|
||||
"sessions:bottom",
|
||||
"analytics:top",
|
||||
"analytics:bottom",
|
||||
"logs:top",
|
||||
"logs:bottom",
|
||||
"cron:top",
|
||||
"cron:bottom",
|
||||
"skills:top",
|
||||
"skills:bottom",
|
||||
"plugins:top",
|
||||
"plugins:bottom",
|
||||
"config:top",
|
||||
"config:bottom",
|
||||
"env:top",
|
||||
"env:bottom",
|
||||
"docs:top",
|
||||
"docs:bottom",
|
||||
"chat:top",
|
||||
"chat:bottom",
|
||||
] as const;
|
||||
|
||||
export type KnownSlotName = (typeof KNOWN_SLOT_NAMES)[number];
|
||||
|
||||
type SlotListener = () => void;
|
||||
|
||||
interface SlotEntry {
|
||||
plugin: string;
|
||||
component: React.ComponentType;
|
||||
}
|
||||
|
||||
/** Map<slotName, SlotEntry[]>. Entries are appended in registration order. */
|
||||
const _slotRegistry: Map<string, SlotEntry[]> = new Map();
|
||||
const _slotListeners: Set<SlotListener> = new Set();
|
||||
|
||||
function _notifySlots() {
|
||||
for (const fn of _slotListeners) {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a component for a slot. Called by plugin bundles via
|
||||
* `window.__HERMES_PLUGINS__.registerSlot(...)`.
|
||||
*
|
||||
* If the same (plugin, slot) pair is registered twice, the later call
|
||||
* replaces the earlier one — this matches how React HMR expects plugin
|
||||
* re-mounts to behave. */
|
||||
export function registerSlot(
|
||||
plugin: string,
|
||||
slot: string,
|
||||
component: React.ComponentType,
|
||||
): void {
|
||||
const existing = _slotRegistry.get(slot) ?? [];
|
||||
const filtered = existing.filter((e) => e.plugin !== plugin);
|
||||
filtered.push({ plugin, component });
|
||||
_slotRegistry.set(slot, filtered);
|
||||
_notifySlots();
|
||||
}
|
||||
|
||||
/** Read current entries for a slot. Returns a copy so callers can't mutate
|
||||
* registry state. */
|
||||
export function getSlotEntries(slot: string): SlotEntry[] {
|
||||
return (_slotRegistry.get(slot) ?? []).slice();
|
||||
}
|
||||
|
||||
/** Subscribe to registry changes. Returns an unsubscribe function. */
|
||||
export function onSlotRegistered(fn: SlotListener): () => void {
|
||||
_slotListeners.add(fn);
|
||||
return () => {
|
||||
_slotListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
/** Clear a specific plugin's slot registrations. Useful for HMR /
|
||||
* plugin reload flows — not wired in by default. */
|
||||
export function unregisterPluginSlots(plugin: string): void {
|
||||
let changed = false;
|
||||
for (const [slot, entries] of _slotRegistry.entries()) {
|
||||
const kept = entries.filter((e) => e.plugin !== plugin);
|
||||
if (kept.length !== entries.length) {
|
||||
changed = true;
|
||||
if (kept.length === 0) _slotRegistry.delete(slot);
|
||||
else _slotRegistry.set(slot, kept);
|
||||
}
|
||||
}
|
||||
if (changed) _notifySlots();
|
||||
}
|
||||
|
||||
interface PluginSlotProps {
|
||||
/** Slot identifier (e.g. `"sidebar"`, `"header-left"`). */
|
||||
name: string;
|
||||
/** Optional content rendered when no plugins have claimed the slot.
|
||||
* Useful for built-in defaults the plugin would replace. */
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Render all components registered for a given slot, stacked in order.
|
||||
*
|
||||
* Component re-renders when the slot registry changes so plugins that
|
||||
* arrive after initial mount show up without a manual refresh. */
|
||||
export function PluginSlot({ name, fallback }: PluginSlotProps) {
|
||||
const [entries, setEntries] = useState<SlotEntry[]>(() => getSlotEntries(name));
|
||||
|
||||
useEffect(() => {
|
||||
// Pick up anything registered between the initial `useState` call
|
||||
// and the first effect tick, then subscribe for future changes.
|
||||
setEntries(getSlotEntries(name));
|
||||
const unsub = onSlotRegistered(() => setEntries(getSlotEntries(name)));
|
||||
return unsub;
|
||||
}, [name]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return fallback ? React.createElement(Fragment, null, fallback) : null;
|
||||
}
|
||||
|
||||
return React.createElement(
|
||||
Fragment,
|
||||
null,
|
||||
...entries.map((entry) =>
|
||||
React.createElement(entry.component, { key: entry.plugin }),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Types for the dashboard plugin system. */
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
export interface PluginManifest {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
version: string;
|
||||
tab: {
|
||||
path: string;
|
||||
/** "end", "after:<pathSegment>", "before:<pathSegment>" (e.g. "after:skills" → after `/skills`) */
|
||||
position?: string;
|
||||
/** When set to a built-in route path, this plugin replaces that page instead of adding a new tab. */
|
||||
override?: string;
|
||||
/** When true, the plugin may register without a sidebar tab (slot-only, etc.). */
|
||||
hidden?: boolean;
|
||||
};
|
||||
/** Declared for discovery; actual slots use registerSlot in the plugin bundle. */
|
||||
slots?: string[];
|
||||
entry: string;
|
||||
css?: string | null;
|
||||
has_api: boolean;
|
||||
/**
|
||||
* Optional Subresource Integrity hash (e.g. "sha384-..."). When set,
|
||||
* the browser will refuse to execute the plugin bundle if its hash
|
||||
* does not match. This protects against tampered plugin delivery.
|
||||
*/
|
||||
integrity?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface RegisteredPlugin {
|
||||
manifest: PluginManifest;
|
||||
component: ComponentType;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
getCachedManifests,
|
||||
cacheManifests,
|
||||
canSeedLoadedFromCache,
|
||||
MANIFEST_CACHE_KEY,
|
||||
} from "./usePlugins";
|
||||
import type { PluginManifest } from "./types";
|
||||
|
||||
function makeStorage(): Storage {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return store.get(key) ?? null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key(index: number) {
|
||||
return Array.from(store.keys())[index] ?? null;
|
||||
},
|
||||
} as Storage;
|
||||
}
|
||||
|
||||
const exampleManifest: PluginManifest = {
|
||||
name: "test",
|
||||
label: "Test",
|
||||
description: "A test plugin",
|
||||
icon: "Puzzle",
|
||||
version: "1.0.0",
|
||||
tab: { path: "/test" },
|
||||
entry: "index.js",
|
||||
has_api: false,
|
||||
source: "local",
|
||||
};
|
||||
|
||||
describe("plugin manifest cache helpers", () => {
|
||||
let storage: Storage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = makeStorage();
|
||||
vi.stubGlobal("sessionStorage", storage);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("getCachedManifests returns null when nothing is cached", () => {
|
||||
expect(getCachedManifests()).toBeNull();
|
||||
});
|
||||
|
||||
it("getCachedManifests returns null for invalid JSON", () => {
|
||||
storage.setItem(MANIFEST_CACHE_KEY, "not-json");
|
||||
expect(getCachedManifests()).toBeNull();
|
||||
});
|
||||
|
||||
it("getCachedManifests returns null for non-array JSON", () => {
|
||||
storage.setItem(MANIFEST_CACHE_KEY, JSON.stringify({ foo: "bar" }));
|
||||
expect(getCachedManifests()).toBeNull();
|
||||
});
|
||||
|
||||
it("getCachedManifests returns null for scalar JSON", () => {
|
||||
storage.setItem(MANIFEST_CACHE_KEY, JSON.stringify(42));
|
||||
expect(getCachedManifests()).toBeNull();
|
||||
});
|
||||
|
||||
it("getCachedManifests returns a valid manifest array", () => {
|
||||
const list: PluginManifest[] = [exampleManifest];
|
||||
cacheManifests(list);
|
||||
expect(getCachedManifests()).toEqual(list);
|
||||
});
|
||||
|
||||
it("cacheManifests overwrites a previous cache on refresh", () => {
|
||||
const first: PluginManifest[] = [exampleManifest];
|
||||
cacheManifests(first);
|
||||
expect(getCachedManifests()).toEqual(first);
|
||||
|
||||
const second: PluginManifest[] = [
|
||||
{ ...exampleManifest, name: "updated", label: "Updated" },
|
||||
];
|
||||
cacheManifests(second);
|
||||
expect(getCachedManifests()).toEqual(second);
|
||||
});
|
||||
|
||||
it("cacheManifests swallows storage errors", () => {
|
||||
const badStorage = makeStorage();
|
||||
badStorage.setItem = () => {
|
||||
throw new Error("QuotaExceededError");
|
||||
};
|
||||
vi.stubGlobal("sessionStorage", badStorage);
|
||||
expect(() => cacheManifests([exampleManifest])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canSeedLoadedFromCache (loading seed gate)", () => {
|
||||
it("returns false when there is no cache (first visit keeps loading=true)", () => {
|
||||
expect(canSeedLoadedFromCache(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for an empty cached list", () => {
|
||||
expect(canSeedLoadedFromCache([])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when no cached manifest overrides /chat", () => {
|
||||
const list: PluginManifest[] = [
|
||||
exampleManifest,
|
||||
{
|
||||
...exampleManifest,
|
||||
name: "other",
|
||||
tab: { path: "/other", override: "/skills" },
|
||||
},
|
||||
];
|
||||
expect(canSeedLoadedFromCache(list)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when a cached manifest overrides /chat — loading must stay true so App.tsx's pluginsLoading gate keeps the persistent chat host unmounted", () => {
|
||||
const list: PluginManifest[] = [
|
||||
exampleManifest,
|
||||
{
|
||||
...exampleManifest,
|
||||
name: "chat-replacer",
|
||||
tab: { path: "/chat-alt", override: "/chat" },
|
||||
},
|
||||
];
|
||||
expect(canSeedLoadedFromCache(list)).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates malformed cached entries missing a tab object", () => {
|
||||
const malformed = [
|
||||
{ ...exampleManifest, tab: undefined },
|
||||
] as unknown as PluginManifest[];
|
||||
expect(canSeedLoadedFromCache(malformed)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* usePlugins hook — discovers and loads dashboard plugins.
|
||||
*
|
||||
* 1. Fetches plugin manifests from GET /api/dashboard/plugins
|
||||
* 2. Injects CSS <link> tags for plugins that declare css
|
||||
* 3. Loads plugin JS bundles via <script> tags
|
||||
* 4. Waits for plugins to call register() and resolves them
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { api, HERMES_BASE_PATH } from "@/lib/api";
|
||||
import type { PluginManifest, RegisteredPlugin } from "./types";
|
||||
import {
|
||||
getPluginComponent,
|
||||
onPluginRegistered,
|
||||
notifyPluginRegistry,
|
||||
setPluginLoadError,
|
||||
} from "./registry";
|
||||
|
||||
export const MANIFEST_CACHE_KEY = "hermes:plugin-manifests";
|
||||
|
||||
export function getCachedManifests(): PluginManifest[] | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(MANIFEST_CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as PluginManifest[]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function cacheManifests(manifests: PluginManifest[]): void {
|
||||
try {
|
||||
sessionStorage.setItem(MANIFEST_CACHE_KEY, JSON.stringify(manifests));
|
||||
} catch {
|
||||
// sessionStorage unavailable (private browsing, storage full, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether it is safe to skip the initial plugin-loading gate for a set of
|
||||
* cached manifests.
|
||||
*
|
||||
* App.tsx waits on `pluginsLoading` before mounting the persistent ChatPage
|
||||
* host: if a plugin overrides /chat (`tab.override === "/chat"`), mounting
|
||||
* the built-in chat first would spawn a PTY and then yank it out from under
|
||||
* the user when the plugin resolves. That gate is load-bearing — so we may
|
||||
* only seed `loading = false` from the cache when no cached manifest
|
||||
* declares a /chat override. Manifests are still seeded either way; only
|
||||
* the loading flag stays conservative.
|
||||
*/
|
||||
export function canSeedLoadedFromCache(
|
||||
cached: PluginManifest[] | null,
|
||||
): boolean {
|
||||
if (cached === null) return false;
|
||||
return !cached.some((m) => m.tab?.override === "/chat");
|
||||
}
|
||||
|
||||
export function usePlugins() {
|
||||
// Lazy initialisers run once at mount — safe to read sessionStorage here.
|
||||
// This avoids the "cannot access ref during render" lint error that would
|
||||
// occur if we stored the cached value in a useRef and read .current in the
|
||||
// useState initial value expression.
|
||||
const [manifests, setManifests] = useState<PluginManifest[]>(
|
||||
() => getCachedManifests() ?? [],
|
||||
);
|
||||
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
|
||||
// Start loading=false when the cache has manifests so plugin routes are
|
||||
// registered synchronously on the first render after a refresh.
|
||||
// The catch-all in App.tsx is only a safety net for the very first visit
|
||||
// (no cache yet). On subsequent visits this flag starts false immediately.
|
||||
//
|
||||
// Exception: if any cached manifest overrides /chat we must keep
|
||||
// loading=true — App.tsx's pluginsLoading gate around the persistent
|
||||
// ChatPage host is load-bearing (see canSeedLoadedFromCache).
|
||||
const [loading, setLoading] = useState<boolean>(
|
||||
() => !canSeedLoadedFromCache(getCachedManifests()),
|
||||
);
|
||||
const loadedScripts = useRef<Set<string>>(new Set());
|
||||
|
||||
// Always re-fetch in the background to keep the cache fresh.
|
||||
// This handles: new plugins added, plugins removed, manifest changes.
|
||||
// setManifests(list) will update routes if the server list differs from cache.
|
||||
useEffect(() => {
|
||||
api
|
||||
.getPlugins()
|
||||
.then((list) => {
|
||||
cacheManifests(list);
|
||||
setManifests(list);
|
||||
if (list.length === 0) setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Load plugin assets when manifests arrive.
|
||||
useEffect(() => {
|
||||
if (manifests.length === 0) return;
|
||||
|
||||
const injectedScripts: HTMLScriptElement[] = [];
|
||||
|
||||
for (const manifest of manifests) {
|
||||
// Inject CSS if specified.
|
||||
if (manifest.css) {
|
||||
const cssUrl = `${HERMES_BASE_PATH}/dashboard-plugins/${manifest.name}/${manifest.css}`;
|
||||
if (!document.querySelector(`link[href="${cssUrl}"]`)) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = cssUrl;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
// Load JS bundle. In dev, cache-bust so Vite HMR can clear the
|
||||
// in-memory registry while the browser would otherwise never
|
||||
// re-execute a previously cached <script> URL.
|
||||
const baseUrl = `${HERMES_BASE_PATH}/dashboard-plugins/${manifest.name}/${manifest.entry}`;
|
||||
const scriptSrc = import.meta.env.DEV
|
||||
? `${baseUrl}?hermes_dv=${Date.now()}`
|
||||
: baseUrl;
|
||||
if (!import.meta.env.DEV) {
|
||||
if (loadedScripts.current.has(baseUrl)) continue;
|
||||
loadedScripts.current.add(baseUrl);
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.setAttribute("data-hermes-plugin", manifest.name);
|
||||
script.src = scriptSrc;
|
||||
script.async = true;
|
||||
// SRI integrity verification — defense against compromised plugin
|
||||
// delivery. Plugin manifests can declare an integrity hash
|
||||
// (e.g. "sha384-...") which the browser verifies before executing.
|
||||
// Without this, a man-in-the-middle or compromised plugin server
|
||||
// can substitute the JS bundle silently. Opt-in: when no integrity
|
||||
// is declared in the manifest, behavior is unchanged.
|
||||
if (manifest.integrity && typeof manifest.integrity === "string") {
|
||||
script.integrity = manifest.integrity;
|
||||
script.crossOrigin = "anonymous";
|
||||
}
|
||||
script.onerror = () => {
|
||||
setPluginLoadError(manifest.name, "LOAD_FAILED");
|
||||
console.warn(
|
||||
`[plugins] Failed to load ${manifest.name} from ${scriptSrc} (open Network tab)`,
|
||||
);
|
||||
};
|
||||
script.onload = () => {
|
||||
notifyPluginRegistry();
|
||||
queueMicrotask(() => {
|
||||
if (getPluginComponent(manifest.name)) return;
|
||||
setPluginLoadError(manifest.name, "NO_REGISTER");
|
||||
});
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
injectedScripts.push(script);
|
||||
}
|
||||
|
||||
// Give plugins a moment to load and register, then stop loading state.
|
||||
const timeout = setTimeout(() => setLoading(false), 2000);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
if (import.meta.env.DEV) {
|
||||
for (const el of injectedScripts) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [manifests]);
|
||||
|
||||
// Listen for plugin registrations and resolve them against manifests.
|
||||
useEffect(() => {
|
||||
function resolvePlugins() {
|
||||
const resolved: RegisteredPlugin[] = [];
|
||||
for (const manifest of manifests) {
|
||||
const component = getPluginComponent(manifest.name);
|
||||
if (component) {
|
||||
resolved.push({ manifest, component });
|
||||
}
|
||||
}
|
||||
setPlugins(resolved);
|
||||
// If all plugins registered, stop loading early.
|
||||
if (resolved.length === manifests.length && manifests.length > 0) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
resolvePlugins();
|
||||
const unsub = onPluginRegistered(resolvePlugins);
|
||||
return unsub;
|
||||
}, [manifests]);
|
||||
|
||||
return { plugins, manifests, loading };
|
||||
}
|
||||
Reference in new issue
Block a user