@nastechai/agent 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/eslint.config.js +23 -0
- package/index.html +24 -0
- package/package.json +54 -26
- package/package.json.bak +89 -0
- package/package.json.pub +88 -0
- package/src/App.tsx +1173 -0
- package/src/components/AuthWidget.tsx +150 -0
- package/src/components/AutoField.tsx +206 -0
- package/src/components/Backdrop.tsx +93 -0
- package/src/components/ChatSidebar.tsx +394 -0
- package/src/components/DeleteConfirmDialog.tsx +40 -0
- package/src/components/LanguageSwitcher.tsx +186 -0
- package/src/components/Markdown.tsx +383 -0
- package/src/components/ModelInfoCard.tsx +112 -0
- package/src/components/ModelPickerDialog.tsx +470 -0
- package/src/components/OAuthLoginModal.tsx +374 -0
- package/src/components/OAuthProvidersCard.tsx +287 -0
- package/src/components/PlatformsCard.tsx +97 -0
- package/src/components/ScheduleBuilder.tsx +273 -0
- package/src/components/SidebarFooter.tsx +42 -0
- package/src/components/SidebarStatusStrip.tsx +72 -0
- package/src/components/SlashPopover.tsx +171 -0
- package/src/components/ThemeSwitcher.tsx +243 -0
- package/src/components/ToolCall.tsx +228 -0
- package/src/components/ToolsetConfigDrawer.tsx +448 -0
- package/src/contexts/PageHeaderProvider.tsx +139 -0
- package/src/contexts/SystemActions.tsx +120 -0
- package/src/contexts/page-header-context.ts +12 -0
- package/src/contexts/system-actions-context.ts +18 -0
- package/src/contexts/usePageHeader.ts +10 -0
- package/src/contexts/useSystemActions.ts +15 -0
- package/src/hooks/useModalBehavior.ts +44 -0
- package/src/hooks/useSidebarStatus.ts +27 -0
- package/src/i18n/af.ts +702 -0
- package/src/i18n/context.tsx +123 -0
- package/src/i18n/de.ts +701 -0
- package/src/i18n/en.ts +708 -0
- package/src/i18n/es.ts +701 -0
- package/src/i18n/fr.ts +701 -0
- package/src/i18n/ga.ts +702 -0
- package/src/i18n/hu.ts +702 -0
- package/src/i18n/index.ts +2 -0
- package/src/i18n/it.ts +701 -0
- package/src/i18n/ja.ts +702 -0
- package/src/i18n/ko.ts +702 -0
- package/src/i18n/pt.ts +702 -0
- package/src/i18n/ru.ts +702 -0
- package/src/i18n/tr.ts +702 -0
- package/src/i18n/types.ts +710 -0
- package/src/i18n/uk.ts +702 -0
- package/src/i18n/zh-hant.ts +702 -0
- package/src/i18n/zh.ts +698 -0
- package/src/index.css +274 -0
- package/src/lib/api.ts +1585 -0
- package/src/lib/dashboard-flags.ts +15 -0
- package/src/lib/format.ts +9 -0
- package/src/lib/fuzzy.ts +192 -0
- package/src/lib/gatewayClient.ts +253 -0
- package/src/lib/nested.ts +23 -0
- package/src/lib/resolve-page-title.ts +41 -0
- package/src/lib/schedule.ts +382 -0
- package/src/lib/slashExec.ts +163 -0
- package/src/lib/utils.ts +35 -0
- package/src/main.tsx +25 -0
- package/src/pages/AnalyticsPage.tsx +601 -0
- package/src/pages/ChannelsPage.tsx +772 -0
- package/src/pages/ChatPage.tsx +889 -0
- package/src/pages/ConfigPage.tsx +660 -0
- package/src/pages/CronPage.tsx +524 -0
- package/src/pages/DocsPage.tsx +69 -0
- package/src/pages/EnvPage.tsx +918 -0
- package/src/pages/LogsPage.tsx +246 -0
- package/src/pages/McpPage.tsx +757 -0
- package/src/pages/ModelsPage.tsx +994 -0
- package/src/pages/PairingPage.tsx +276 -0
- package/src/pages/PluginsPage.tsx +580 -0
- package/src/pages/ProfilesPage.tsx +559 -0
- package/src/pages/SessionsPage.tsx +936 -0
- package/src/pages/SkillsPage.tsx +557 -0
- package/src/pages/SystemPage.tsx +1259 -0
- package/src/pages/WebhooksPage.tsx +483 -0
- package/src/plugins/PluginPage.tsx +64 -0
- package/src/plugins/index.ts +6 -0
- package/src/plugins/registry.ts +151 -0
- package/src/plugins/sdk.d.ts +160 -0
- package/src/plugins/slots.ts +199 -0
- package/src/plugins/types.ts +37 -0
- package/src/plugins/usePlugins.ts +133 -0
- package/src/themes/context.tsx +443 -0
- package/src/themes/fonts.ts +160 -0
- package/src/themes/index.ts +3 -0
- package/src/themes/presets.ts +477 -0
- package/src/themes/types.ts +187 -0
- package/tsconfig.app.json +34 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +124 -0
- package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard Plugin SDK + Registry
|
|
3
|
+
*
|
|
4
|
+
* Exposes React, UI components, hooks, and utilities on the window so
|
|
5
|
+
* that plugin bundles can use them without bundling their own copies.
|
|
6
|
+
*
|
|
7
|
+
* Plugins call window.__NASTECH_PLUGINS__.register(name, Component)
|
|
8
|
+
* to register their tab component.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React, {
|
|
12
|
+
useState,
|
|
13
|
+
useEffect,
|
|
14
|
+
useCallback,
|
|
15
|
+
useMemo,
|
|
16
|
+
useRef,
|
|
17
|
+
useContext,
|
|
18
|
+
createContext,
|
|
19
|
+
} from "react";
|
|
20
|
+
import { api, fetchJSON } from "@/lib/api";
|
|
21
|
+
import { cn, timeAgo, isoTimeAgo } from "@/lib/utils";
|
|
22
|
+
import { Badge } from "@nastechai/ui/ui/components/badge";
|
|
23
|
+
import { Button } from "@nastechai/ui/ui/components/button";
|
|
24
|
+
import { Checkbox } from "@nastechai/ui/ui/components/checkbox";
|
|
25
|
+
import { Select, SelectOption } from "@nastechai/ui/ui/components/select";
|
|
26
|
+
import { Card, CardHeader, CardTitle, CardContent } from "@nastechai/ui/ui/components/card";
|
|
27
|
+
import { Input } from "@nastechai/ui/ui/components/input";
|
|
28
|
+
import { Label } from "@nastechai/ui/ui/components/label";
|
|
29
|
+
import { Separator } from "@nastechai/ui/ui/components/separator";
|
|
30
|
+
import { Tabs, TabsList, TabsTrigger } from "@nastechai/ui/ui/components/tabs";
|
|
31
|
+
import { useI18n } from "@/i18n";
|
|
32
|
+
import { registerSlot, PluginSlot } from "./slots";
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Plugin registry — plugins call register() to add their component.
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
type RegistryListener = () => void;
|
|
39
|
+
|
|
40
|
+
const _registered: Map<string, React.ComponentType> = new Map();
|
|
41
|
+
const _loadErrors: Map<string, string> = new Map();
|
|
42
|
+
const _listeners: Set<RegistryListener> = new Set();
|
|
43
|
+
|
|
44
|
+
function _notify() {
|
|
45
|
+
for (const fn of _listeners) {
|
|
46
|
+
try { fn(); } catch { /* ignore */ }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Re-run registry subscribers (e.g. after a plugin script onload, or dev HMR re-inject). */
|
|
51
|
+
export function notifyPluginRegistry() {
|
|
52
|
+
_notify();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Register a plugin component. Called by plugin JS bundles. */
|
|
56
|
+
function registerPlugin(name: string, component: React.ComponentType) {
|
|
57
|
+
_loadErrors.delete(name);
|
|
58
|
+
_registered.set(name, component);
|
|
59
|
+
_notify();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Get a registered component by plugin name. */
|
|
63
|
+
export function getPluginComponent(name: string): React.ComponentType | undefined {
|
|
64
|
+
return _registered.get(name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getPluginLoadError(name: string): string | undefined {
|
|
68
|
+
return _loadErrors.get(name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function setPluginLoadError(name: string, message: string) {
|
|
72
|
+
_loadErrors.set(name, message);
|
|
73
|
+
_notify();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Subscribe to registry changes (returns unsubscribe fn). */
|
|
77
|
+
export function onPluginRegistered(fn: RegistryListener): () => void {
|
|
78
|
+
_listeners.add(fn);
|
|
79
|
+
return () => _listeners.delete(fn);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Get current count of registered plugins. */
|
|
83
|
+
export function getRegisteredCount(): number {
|
|
84
|
+
return _registered.size;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Expose SDK + registry on window
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
declare global {
|
|
92
|
+
interface Window {
|
|
93
|
+
__NASTECH_PLUGIN_SDK__?: unknown;
|
|
94
|
+
__NASTECH_PLUGINS__?: {
|
|
95
|
+
register: typeof registerPlugin;
|
|
96
|
+
registerSlot: typeof registerSlot;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function exposePluginSDK() {
|
|
102
|
+
window.__NASTECH_PLUGINS__ = {
|
|
103
|
+
register: registerPlugin,
|
|
104
|
+
registerSlot,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
window.__NASTECH_PLUGIN_SDK__ = {
|
|
108
|
+
// React core — plugins use these instead of importing react
|
|
109
|
+
React,
|
|
110
|
+
hooks: {
|
|
111
|
+
useState,
|
|
112
|
+
useEffect,
|
|
113
|
+
useCallback,
|
|
114
|
+
useMemo,
|
|
115
|
+
useRef,
|
|
116
|
+
useContext,
|
|
117
|
+
createContext,
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
// NasTech API client
|
|
121
|
+
api,
|
|
122
|
+
// Raw fetchJSON for plugin-specific endpoints
|
|
123
|
+
fetchJSON,
|
|
124
|
+
|
|
125
|
+
// UI components — Nous DS where available, shadcn/ui primitives elsewhere.
|
|
126
|
+
components: {
|
|
127
|
+
Card,
|
|
128
|
+
CardHeader,
|
|
129
|
+
CardTitle,
|
|
130
|
+
CardContent,
|
|
131
|
+
Badge,
|
|
132
|
+
Button,
|
|
133
|
+
Checkbox,
|
|
134
|
+
Input,
|
|
135
|
+
Label,
|
|
136
|
+
Select,
|
|
137
|
+
SelectOption,
|
|
138
|
+
Separator,
|
|
139
|
+
Tabs,
|
|
140
|
+
TabsList,
|
|
141
|
+
TabsTrigger,
|
|
142
|
+
PluginSlot,
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
// Utilities
|
|
146
|
+
utils: { cn, timeAgo, isoTimeAgo },
|
|
147
|
+
|
|
148
|
+
// Hooks
|
|
149
|
+
useI18n,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NasTech Dashboard Plugin SDK — typed contract (SPIKE)
|
|
3
|
+
* ====================================================
|
|
4
|
+
*
|
|
5
|
+
* This is the public type surface for ``window.__NASTECH_PLUGIN_SDK__`` and
|
|
6
|
+
* ``window.__NASTECH_PLUGINS__``, the globals the dashboard host exposes to
|
|
7
|
+
* plugin bundles (see ``web/src/plugins/registry.ts::exposePluginSDK``).
|
|
8
|
+
*
|
|
9
|
+
* STATUS: spike. This file documents the contract and gives plugin authors
|
|
10
|
+
* (in-repo IIFEs and external bundles alike) editor types without bundling
|
|
11
|
+
* their own copies of React / the API client. It is intentionally a
|
|
12
|
+
* hand-authored ambient declaration rather than ``typeof
|
|
13
|
+
* window.__NASTECH_PLUGIN_SDK__`` because:
|
|
14
|
+
* 1. The runtime object is assembled from many internal modules
|
|
15
|
+
* (``@/lib/api``, ``@nastechai/ui``, …). Deriving the type would
|
|
16
|
+
* leak those internal import paths into the public contract and couple
|
|
17
|
+
* external plugins to the host's internal module layout.
|
|
18
|
+
* 2. A hand-authored contract is the *versioned API boundary* — changing
|
|
19
|
+
* it is a deliberate act, visible in review, not an accidental
|
|
20
|
+
* consequence of refactoring an internal helper.
|
|
21
|
+
*
|
|
22
|
+
* Versioning: bump ``NasTechPluginSDK["sdkVersion"]`` (and the
|
|
23
|
+
* ``SDK_CONTRACT_VERSION`` const the host exposes) on any
|
|
24
|
+
* backwards-incompatible change to this surface. Additive changes
|
|
25
|
+
* (new optional fields, new helpers) don't require a major bump.
|
|
26
|
+
*
|
|
27
|
+
* OPEN QUESTIONS for productionising this spike (do not block the auth fix):
|
|
28
|
+
* - Ship as a published ``@nastech/dashboard-plugin-sdk`` types package, or
|
|
29
|
+
* keep in-repo and copy into external plugin repos?
|
|
30
|
+
* - Should the host assert at runtime that a plugin's declared
|
|
31
|
+
* ``manifest.sdk_version`` is compatible before executing it?
|
|
32
|
+
* - The ``components`` map is typed loosely as ``Record<string,
|
|
33
|
+
* ComponentType>`` here; do we want exact per-component prop types
|
|
34
|
+
* (pulls @nastechai/ui types into the contract) or is the loose
|
|
35
|
+
* shape the right boundary for external authors?
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { ComponentType } from "react";
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Auth-relevant helpers (the surface this PR adds/sanctions)
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* JSON ``fetch`` for dashboard ``/api/...`` endpoints. Handles auth in both
|
|
46
|
+
* modes (loopback session-token header / gated cookie), throws
|
|
47
|
+
* ``Error("<status>: <body>")`` on non-2xx, and triggers the global
|
|
48
|
+
* 401 → /login redirect in gated mode. Use for all JSON plugin endpoints.
|
|
49
|
+
*/
|
|
50
|
+
export type FetchJSON = <T = unknown>(
|
|
51
|
+
url: string,
|
|
52
|
+
init?: RequestInit,
|
|
53
|
+
options?: { allowUnauthorized?: boolean },
|
|
54
|
+
) => Promise<T>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Authenticated ``fetch`` for NON-JSON endpoints (uploads via ``FormData``,
|
|
58
|
+
* binary/blob downloads). Same auth handling as ``fetchJSON`` but returns
|
|
59
|
+
* the raw ``Response``, does not parse, does not throw on non-2xx, and does
|
|
60
|
+
* not run the 401 redirect. Plugins MUST use this (or ``fetchJSON``) instead
|
|
61
|
+
* of calling ``fetch`` with a hand-read ``window.__NASTECH_SESSION_TOKEN__``.
|
|
62
|
+
*/
|
|
63
|
+
export type AuthedFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint with
|
|
67
|
+
* the correct auth query param for the active mode (single-use ``ticket`` in
|
|
68
|
+
* gated OAuth mode, ``token`` in loopback). Plugins MUST use this for any
|
|
69
|
+
* WebSocket instead of hand-assembling the URL + reading the session token.
|
|
70
|
+
*/
|
|
71
|
+
export type BuildWsUrl = (
|
|
72
|
+
path: string,
|
|
73
|
+
params?: Record<string, string>,
|
|
74
|
+
) => Promise<string>;
|
|
75
|
+
|
|
76
|
+
/** Lower-level: just the ``[authParamName, authParamValue]`` pair. */
|
|
77
|
+
export type BuildWsAuthParam = () => Promise<[string, string]>;
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Registry surface (window.__NASTECH_PLUGINS__)
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export interface PluginRegistry {
|
|
84
|
+
/** Register the plugin's main tab component by manifest name. */
|
|
85
|
+
register(name: string, component: ComponentType<Record<string, never>>): void;
|
|
86
|
+
/** Register a component into a named host slot. */
|
|
87
|
+
registerSlot(slot: string, name: string, component: ComponentType): void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// SDK surface (window.__NASTECH_PLUGIN_SDK__)
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
export interface NasTechPluginSDK {
|
|
95
|
+
/** Contract version of this SDK surface (see SDK_CONTRACT_VERSION). */
|
|
96
|
+
readonly sdkVersion: string;
|
|
97
|
+
|
|
98
|
+
/** React core — use instead of importing/bundling react. */
|
|
99
|
+
React: typeof import("react").default;
|
|
100
|
+
hooks: {
|
|
101
|
+
useState: typeof import("react").useState;
|
|
102
|
+
useEffect: typeof import("react").useEffect;
|
|
103
|
+
useCallback: typeof import("react").useCallback;
|
|
104
|
+
useMemo: typeof import("react").useMemo;
|
|
105
|
+
useRef: typeof import("react").useRef;
|
|
106
|
+
useContext: typeof import("react").useContext;
|
|
107
|
+
createContext: typeof import("react").createContext;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Typed convenience client for core dashboard endpoints. Typed permissively
|
|
112
|
+
* at the boundary (methods vary in arity and return type — most return
|
|
113
|
+
* ``Promise<T>``, a few return a URL string synchronously); plugins call the
|
|
114
|
+
* specific methods they need. See ``web/src/lib/api.ts`` for the concrete shape.
|
|
115
|
+
*/
|
|
116
|
+
api: Record<string, (...args: never[]) => unknown>;
|
|
117
|
+
|
|
118
|
+
/** JSON fetch with host auth handling. */
|
|
119
|
+
fetchJSON: FetchJSON;
|
|
120
|
+
/** Authenticated raw fetch for uploads / blob downloads. */
|
|
121
|
+
authedFetch: AuthedFetch;
|
|
122
|
+
/** Build an auth'd WebSocket URL for the active mode. */
|
|
123
|
+
buildWsUrl: BuildWsUrl;
|
|
124
|
+
/** Resolve just the WS auth query-param pair. */
|
|
125
|
+
buildWsAuthParam: BuildWsAuthParam;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Shared UI primitives (Nous DS / shadcn). Typed permissively at the
|
|
129
|
+
* boundary: the host's concrete components (some of which require props like
|
|
130
|
+
* ``active``/``value``/``name``) must be assignable here, and external plugin
|
|
131
|
+
* authors render them dynamically without the host's internal prop types.
|
|
132
|
+
* ``ComponentType<never>`` accepts any component regardless of its prop
|
|
133
|
+
* requirements (props are contravariant).
|
|
134
|
+
*/
|
|
135
|
+
components: Record<string, ComponentType<never>>;
|
|
136
|
+
|
|
137
|
+
utils: {
|
|
138
|
+
cn: (...classes: Array<string | false | null | undefined>) => string;
|
|
139
|
+
/** Relative-time formatter. Accepts an epoch-ms number. */
|
|
140
|
+
timeAgo: (ts: number) => string;
|
|
141
|
+
/** Relative-time formatter for an ISO-8601 string. */
|
|
142
|
+
isoTimeAgo: (iso: string) => string;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* i18n hook. Returns the host's i18n context value; typed loosely at the
|
|
147
|
+
* boundary so the contract doesn't couple to the host's internal
|
|
148
|
+
* ``I18nContextValue`` shape. Plugins typically call ``useI18n().t(...)``.
|
|
149
|
+
*/
|
|
150
|
+
useI18n: () => unknown;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
declare global {
|
|
154
|
+
interface Window {
|
|
155
|
+
__NASTECH_PLUGIN_SDK__?: NasTechPluginSDK;
|
|
156
|
+
__NASTECH_PLUGINS__?: PluginRegistry;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export {};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin slot registry.
|
|
3
|
+
*
|
|
4
|
+
* Plugins can inject components into named locations in the app shell
|
|
5
|
+
* (header-left, sidebar, backdrop, etc.) by calling
|
|
6
|
+
* `window.__NASTECH_PLUGINS__.registerSlot(pluginName, slotName, Component)`
|
|
7
|
+
* from their JS bundle. Multiple plugins can populate the same slot — they
|
|
8
|
+
* render stacked in registration order.
|
|
9
|
+
*
|
|
10
|
+
* The canonical slot names are documented in `KNOWN_SLOT_NAMES` below. The
|
|
11
|
+
* registry accepts any string so plugin ecosystems can define their own
|
|
12
|
+
* slots; the shell only renders `<PluginSlot name="..." />` for the slots
|
|
13
|
+
* it knows about.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { Fragment, useEffect, useState } from "react";
|
|
17
|
+
|
|
18
|
+
/** Slot locations the built-in shell renders. Plugins declaring any of
|
|
19
|
+
* these in their manifest's `slots` field get wired in automatically.
|
|
20
|
+
*
|
|
21
|
+
* Shell-wide slots:
|
|
22
|
+
* - `backdrop` — rendered inside `<Backdrop />`, above the noise layer
|
|
23
|
+
* - `header-left` — injected before the NasTech brand in the top bar
|
|
24
|
+
* - `header-right` — injected before the theme/language switchers
|
|
25
|
+
* - `header-banner` — injected below the top nav bar, full-width
|
|
26
|
+
* - `sidebar` — the cockpit sidebar rail (only rendered when
|
|
27
|
+
* `layoutVariant === "cockpit"`)
|
|
28
|
+
* - `pre-main` — rendered above the route outlet (inside `<main>`)
|
|
29
|
+
* - `post-main` — rendered below the route outlet (inside `<main>`)
|
|
30
|
+
* - `footer-left` — replaces the left footer cell content
|
|
31
|
+
* - `footer-right` — replaces the right footer cell content
|
|
32
|
+
* - `overlay` — fixed-position layer above everything else;
|
|
33
|
+
* useful for chrome (scanlines, vignettes) the
|
|
34
|
+
* theme's customCSS can't achieve alone
|
|
35
|
+
*
|
|
36
|
+
* Page-scoped slots (rendered inside a specific built-in page — use these
|
|
37
|
+
* to inject widgets, cards, or toolbars into existing pages without
|
|
38
|
+
* overriding the whole route):
|
|
39
|
+
* - `sessions:top` — top of /sessions page (above session list)
|
|
40
|
+
* - `sessions:bottom` — bottom of /sessions page
|
|
41
|
+
* - `analytics:top` — top of /analytics page
|
|
42
|
+
* - `analytics:bottom` — bottom of /analytics page
|
|
43
|
+
* - `logs:top` — top of /logs page (above filter toolbar)
|
|
44
|
+
* - `logs:bottom` — bottom of /logs page (below log viewer)
|
|
45
|
+
* - `cron:top` — top of /cron page
|
|
46
|
+
* - `cron:bottom` — bottom of /cron page
|
|
47
|
+
* - `skills:top` — top of /skills page
|
|
48
|
+
* - `skills:bottom` — bottom of /skills page
|
|
49
|
+
* - `plugins:top` — top of /plugins page
|
|
50
|
+
* - `plugins:bottom` — bottom of /plugins page
|
|
51
|
+
* - `config:top` — top of /config page
|
|
52
|
+
* - `config:bottom` — bottom of /config page
|
|
53
|
+
* - `env:top` — top of /env (Keys) page
|
|
54
|
+
* - `env:bottom` — bottom of /env (Keys) page
|
|
55
|
+
* - `docs:top` — top of /docs page (above the docs iframe)
|
|
56
|
+
* - `docs:bottom` — bottom of /docs page
|
|
57
|
+
* - `chat:top` — top of /chat page (above the composer, when embedded chat is on)
|
|
58
|
+
* - `chat:bottom` — bottom of /chat page
|
|
59
|
+
*/
|
|
60
|
+
export const KNOWN_SLOT_NAMES = [
|
|
61
|
+
// Shell-wide
|
|
62
|
+
"backdrop",
|
|
63
|
+
"header-left",
|
|
64
|
+
"header-right",
|
|
65
|
+
"header-banner",
|
|
66
|
+
"sidebar",
|
|
67
|
+
"pre-main",
|
|
68
|
+
"post-main",
|
|
69
|
+
"footer-left",
|
|
70
|
+
"footer-right",
|
|
71
|
+
"overlay",
|
|
72
|
+
// Page-scoped
|
|
73
|
+
"sessions:top",
|
|
74
|
+
"sessions:bottom",
|
|
75
|
+
"analytics:top",
|
|
76
|
+
"analytics:bottom",
|
|
77
|
+
"logs:top",
|
|
78
|
+
"logs:bottom",
|
|
79
|
+
"cron:top",
|
|
80
|
+
"cron:bottom",
|
|
81
|
+
"skills:top",
|
|
82
|
+
"skills:bottom",
|
|
83
|
+
"plugins:top",
|
|
84
|
+
"plugins:bottom",
|
|
85
|
+
"config:top",
|
|
86
|
+
"config:bottom",
|
|
87
|
+
"env:top",
|
|
88
|
+
"env:bottom",
|
|
89
|
+
"docs:top",
|
|
90
|
+
"docs:bottom",
|
|
91
|
+
"chat:top",
|
|
92
|
+
"chat:bottom",
|
|
93
|
+
] as const;
|
|
94
|
+
|
|
95
|
+
export type KnownSlotName = (typeof KNOWN_SLOT_NAMES)[number];
|
|
96
|
+
|
|
97
|
+
type SlotListener = () => void;
|
|
98
|
+
|
|
99
|
+
interface SlotEntry {
|
|
100
|
+
plugin: string;
|
|
101
|
+
component: React.ComponentType;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Map<slotName, SlotEntry[]>. Entries are appended in registration order. */
|
|
105
|
+
const _slotRegistry: Map<string, SlotEntry[]> = new Map();
|
|
106
|
+
const _slotListeners: Set<SlotListener> = new Set();
|
|
107
|
+
|
|
108
|
+
function _notifySlots() {
|
|
109
|
+
for (const fn of _slotListeners) {
|
|
110
|
+
try {
|
|
111
|
+
fn();
|
|
112
|
+
} catch {
|
|
113
|
+
/* ignore */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Register a component for a slot. Called by plugin bundles via
|
|
119
|
+
* `window.__NASTECH_PLUGINS__.registerSlot(...)`.
|
|
120
|
+
*
|
|
121
|
+
* If the same (plugin, slot) pair is registered twice, the later call
|
|
122
|
+
* replaces the earlier one — this matches how React HMR expects plugin
|
|
123
|
+
* re-mounts to behave. */
|
|
124
|
+
export function registerSlot(
|
|
125
|
+
plugin: string,
|
|
126
|
+
slot: string,
|
|
127
|
+
component: React.ComponentType,
|
|
128
|
+
): void {
|
|
129
|
+
const existing = _slotRegistry.get(slot) ?? [];
|
|
130
|
+
const filtered = existing.filter((e) => e.plugin !== plugin);
|
|
131
|
+
filtered.push({ plugin, component });
|
|
132
|
+
_slotRegistry.set(slot, filtered);
|
|
133
|
+
_notifySlots();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Read current entries for a slot. Returns a copy so callers can't mutate
|
|
137
|
+
* registry state. */
|
|
138
|
+
export function getSlotEntries(slot: string): SlotEntry[] {
|
|
139
|
+
return (_slotRegistry.get(slot) ?? []).slice();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Subscribe to registry changes. Returns an unsubscribe function. */
|
|
143
|
+
export function onSlotRegistered(fn: SlotListener): () => void {
|
|
144
|
+
_slotListeners.add(fn);
|
|
145
|
+
return () => {
|
|
146
|
+
_slotListeners.delete(fn);
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Clear a specific plugin's slot registrations. Useful for HMR /
|
|
151
|
+
* plugin reload flows — not wired in by default. */
|
|
152
|
+
export function unregisterPluginSlots(plugin: string): void {
|
|
153
|
+
let changed = false;
|
|
154
|
+
for (const [slot, entries] of _slotRegistry.entries()) {
|
|
155
|
+
const kept = entries.filter((e) => e.plugin !== plugin);
|
|
156
|
+
if (kept.length !== entries.length) {
|
|
157
|
+
changed = true;
|
|
158
|
+
if (kept.length === 0) _slotRegistry.delete(slot);
|
|
159
|
+
else _slotRegistry.set(slot, kept);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (changed) _notifySlots();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface PluginSlotProps {
|
|
166
|
+
/** Slot identifier (e.g. `"sidebar"`, `"header-left"`). */
|
|
167
|
+
name: string;
|
|
168
|
+
/** Optional content rendered when no plugins have claimed the slot.
|
|
169
|
+
* Useful for built-in defaults the plugin would replace. */
|
|
170
|
+
fallback?: React.ReactNode;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Render all components registered for a given slot, stacked in order.
|
|
174
|
+
*
|
|
175
|
+
* Component re-renders when the slot registry changes so plugins that
|
|
176
|
+
* arrive after initial mount show up without a manual refresh. */
|
|
177
|
+
export function PluginSlot({ name, fallback }: PluginSlotProps) {
|
|
178
|
+
const [entries, setEntries] = useState<SlotEntry[]>(() => getSlotEntries(name));
|
|
179
|
+
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
// Pick up anything registered between the initial `useState` call
|
|
182
|
+
// and the first effect tick, then subscribe for future changes.
|
|
183
|
+
setEntries(getSlotEntries(name));
|
|
184
|
+
const unsub = onSlotRegistered(() => setEntries(getSlotEntries(name)));
|
|
185
|
+
return unsub;
|
|
186
|
+
}, [name]);
|
|
187
|
+
|
|
188
|
+
if (entries.length === 0) {
|
|
189
|
+
return fallback ? React.createElement(Fragment, null, fallback) : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return React.createElement(
|
|
193
|
+
Fragment,
|
|
194
|
+
null,
|
|
195
|
+
...entries.map((entry) =>
|
|
196
|
+
React.createElement(entry.component, { key: entry.plugin }),
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Types for the dashboard plugin system. */
|
|
2
|
+
|
|
3
|
+
import type { ComponentType } from "react";
|
|
4
|
+
|
|
5
|
+
export interface PluginManifest {
|
|
6
|
+
name: string;
|
|
7
|
+
label: string;
|
|
8
|
+
description: string;
|
|
9
|
+
icon: string;
|
|
10
|
+
version: string;
|
|
11
|
+
tab: {
|
|
12
|
+
path: string;
|
|
13
|
+
/** "end", "after:<pathSegment>", "before:<pathSegment>" (e.g. "after:skills" → after `/skills`) */
|
|
14
|
+
position?: string;
|
|
15
|
+
/** When set to a built-in route path, this plugin replaces that page instead of adding a new tab. */
|
|
16
|
+
override?: string;
|
|
17
|
+
/** When true, the plugin may register without a sidebar tab (slot-only, etc.). */
|
|
18
|
+
hidden?: boolean;
|
|
19
|
+
};
|
|
20
|
+
/** Declared for discovery; actual slots use registerSlot in the plugin bundle. */
|
|
21
|
+
slots?: string[];
|
|
22
|
+
entry: string;
|
|
23
|
+
css?: string | null;
|
|
24
|
+
has_api: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Optional Subresource Integrity hash (e.g. "sha384-..."). When set,
|
|
27
|
+
* the browser will refuse to execute the plugin bundle if its hash
|
|
28
|
+
* does not match. This protects against tampered plugin delivery.
|
|
29
|
+
*/
|
|
30
|
+
integrity?: string;
|
|
31
|
+
source: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RegisteredPlugin {
|
|
35
|
+
manifest: PluginManifest;
|
|
36
|
+
component: ComponentType;
|
|
37
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePlugins hook — discovers and loads dashboard plugins.
|
|
3
|
+
*
|
|
4
|
+
* 1. Fetches plugin manifests from GET /api/dashboard/plugins
|
|
5
|
+
* 2. Injects CSS <link> tags for plugins that declare css
|
|
6
|
+
* 3. Loads plugin JS bundles via <script> tags
|
|
7
|
+
* 4. Waits for plugins to call register() and resolves them
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState, useEffect, useRef } from "react";
|
|
11
|
+
import { api, NASTECH_BASE_PATH } from "@/lib/api";
|
|
12
|
+
import type { PluginManifest, RegisteredPlugin } from "./types";
|
|
13
|
+
import {
|
|
14
|
+
getPluginComponent,
|
|
15
|
+
onPluginRegistered,
|
|
16
|
+
notifyPluginRegistry,
|
|
17
|
+
setPluginLoadError,
|
|
18
|
+
} from "./registry";
|
|
19
|
+
|
|
20
|
+
export function usePlugins() {
|
|
21
|
+
const [manifests, setManifests] = useState<PluginManifest[]>([]);
|
|
22
|
+
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
|
|
23
|
+
const [loading, setLoading] = useState(true);
|
|
24
|
+
const loadedScripts = useRef<Set<string>>(new Set());
|
|
25
|
+
|
|
26
|
+
// Fetch manifests on mount.
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
api
|
|
29
|
+
.getPlugins()
|
|
30
|
+
.then((list) => {
|
|
31
|
+
setManifests(list);
|
|
32
|
+
if (list.length === 0) setLoading(false);
|
|
33
|
+
})
|
|
34
|
+
.catch(() => setLoading(false));
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
// Load plugin assets when manifests arrive.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (manifests.length === 0) return;
|
|
40
|
+
|
|
41
|
+
const injectedScripts: HTMLScriptElement[] = [];
|
|
42
|
+
|
|
43
|
+
for (const manifest of manifests) {
|
|
44
|
+
// Inject CSS if specified.
|
|
45
|
+
if (manifest.css) {
|
|
46
|
+
const cssUrl = `${NASTECH_BASE_PATH}/dashboard-plugins/${manifest.name}/${manifest.css}`;
|
|
47
|
+
if (!document.querySelector(`link[href="${cssUrl}"]`)) {
|
|
48
|
+
const link = document.createElement("link");
|
|
49
|
+
link.rel = "stylesheet";
|
|
50
|
+
link.href = cssUrl;
|
|
51
|
+
document.head.appendChild(link);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Load JS bundle. In dev, cache-bust so Vite HMR can clear the
|
|
56
|
+
// in-memory registry while the browser would otherwise never
|
|
57
|
+
// re-execute a previously cached <script> URL.
|
|
58
|
+
const baseUrl = `${NASTECH_BASE_PATH}/dashboard-plugins/${manifest.name}/${manifest.entry}`;
|
|
59
|
+
const scriptSrc = import.meta.env.DEV
|
|
60
|
+
? `${baseUrl}?nastech_dv=${Date.now()}`
|
|
61
|
+
: baseUrl;
|
|
62
|
+
if (!import.meta.env.DEV) {
|
|
63
|
+
if (loadedScripts.current.has(baseUrl)) continue;
|
|
64
|
+
loadedScripts.current.add(baseUrl);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const script = document.createElement("script");
|
|
68
|
+
script.setAttribute("data-nastech-plugin", manifest.name);
|
|
69
|
+
script.src = scriptSrc;
|
|
70
|
+
script.async = true;
|
|
71
|
+
// SRI integrity verification — defense against compromised plugin
|
|
72
|
+
// delivery. Plugin manifests can declare an integrity hash
|
|
73
|
+
// (e.g. "sha384-...") which the browser verifies before executing.
|
|
74
|
+
// Without this, a man-in-the-middle or compromised plugin server
|
|
75
|
+
// can substitute the JS bundle silently. Opt-in: when no integrity
|
|
76
|
+
// is declared in the manifest, behavior is unchanged.
|
|
77
|
+
if (manifest.integrity && typeof manifest.integrity === "string") {
|
|
78
|
+
script.integrity = manifest.integrity;
|
|
79
|
+
script.crossOrigin = "anonymous";
|
|
80
|
+
}
|
|
81
|
+
script.onerror = () => {
|
|
82
|
+
setPluginLoadError(manifest.name, "LOAD_FAILED");
|
|
83
|
+
console.warn(
|
|
84
|
+
`[plugins] Failed to load ${manifest.name} from ${scriptSrc} (open Network tab)`,
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
script.onload = () => {
|
|
88
|
+
notifyPluginRegistry();
|
|
89
|
+
queueMicrotask(() => {
|
|
90
|
+
if (getPluginComponent(manifest.name)) return;
|
|
91
|
+
setPluginLoadError(manifest.name, "NO_REGISTER");
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
document.body.appendChild(script);
|
|
95
|
+
injectedScripts.push(script);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Give plugins a moment to load and register, then stop loading state.
|
|
99
|
+
const timeout = setTimeout(() => setLoading(false), 2000);
|
|
100
|
+
return () => {
|
|
101
|
+
clearTimeout(timeout);
|
|
102
|
+
if (import.meta.env.DEV) {
|
|
103
|
+
for (const el of injectedScripts) {
|
|
104
|
+
el.remove();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}, [manifests]);
|
|
109
|
+
|
|
110
|
+
// Listen for plugin registrations and resolve them against manifests.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
function resolvePlugins() {
|
|
113
|
+
const resolved: RegisteredPlugin[] = [];
|
|
114
|
+
for (const manifest of manifests) {
|
|
115
|
+
const component = getPluginComponent(manifest.name);
|
|
116
|
+
if (component) {
|
|
117
|
+
resolved.push({ manifest, component });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
setPlugins(resolved);
|
|
121
|
+
// If all plugins registered, stop loading early.
|
|
122
|
+
if (resolved.length === manifests.length && manifests.length > 0) {
|
|
123
|
+
setLoading(false);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
resolvePlugins();
|
|
128
|
+
const unsub = onPluginRegistered(resolvePlugins);
|
|
129
|
+
return unsub;
|
|
130
|
+
}, [manifests]);
|
|
131
|
+
|
|
132
|
+
return { plugins, manifests, loading };
|
|
133
|
+
}
|