@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.
Files changed (98) hide show
  1. package/eslint.config.js +23 -0
  2. package/index.html +24 -0
  3. package/package.json +54 -26
  4. package/package.json.bak +89 -0
  5. package/package.json.pub +88 -0
  6. package/src/App.tsx +1173 -0
  7. package/src/components/AuthWidget.tsx +150 -0
  8. package/src/components/AutoField.tsx +206 -0
  9. package/src/components/Backdrop.tsx +93 -0
  10. package/src/components/ChatSidebar.tsx +394 -0
  11. package/src/components/DeleteConfirmDialog.tsx +40 -0
  12. package/src/components/LanguageSwitcher.tsx +186 -0
  13. package/src/components/Markdown.tsx +383 -0
  14. package/src/components/ModelInfoCard.tsx +112 -0
  15. package/src/components/ModelPickerDialog.tsx +470 -0
  16. package/src/components/OAuthLoginModal.tsx +374 -0
  17. package/src/components/OAuthProvidersCard.tsx +287 -0
  18. package/src/components/PlatformsCard.tsx +97 -0
  19. package/src/components/ScheduleBuilder.tsx +273 -0
  20. package/src/components/SidebarFooter.tsx +42 -0
  21. package/src/components/SidebarStatusStrip.tsx +72 -0
  22. package/src/components/SlashPopover.tsx +171 -0
  23. package/src/components/ThemeSwitcher.tsx +243 -0
  24. package/src/components/ToolCall.tsx +228 -0
  25. package/src/components/ToolsetConfigDrawer.tsx +448 -0
  26. package/src/contexts/PageHeaderProvider.tsx +139 -0
  27. package/src/contexts/SystemActions.tsx +120 -0
  28. package/src/contexts/page-header-context.ts +12 -0
  29. package/src/contexts/system-actions-context.ts +18 -0
  30. package/src/contexts/usePageHeader.ts +10 -0
  31. package/src/contexts/useSystemActions.ts +15 -0
  32. package/src/hooks/useModalBehavior.ts +44 -0
  33. package/src/hooks/useSidebarStatus.ts +27 -0
  34. package/src/i18n/af.ts +702 -0
  35. package/src/i18n/context.tsx +123 -0
  36. package/src/i18n/de.ts +701 -0
  37. package/src/i18n/en.ts +708 -0
  38. package/src/i18n/es.ts +701 -0
  39. package/src/i18n/fr.ts +701 -0
  40. package/src/i18n/ga.ts +702 -0
  41. package/src/i18n/hu.ts +702 -0
  42. package/src/i18n/index.ts +2 -0
  43. package/src/i18n/it.ts +701 -0
  44. package/src/i18n/ja.ts +702 -0
  45. package/src/i18n/ko.ts +702 -0
  46. package/src/i18n/pt.ts +702 -0
  47. package/src/i18n/ru.ts +702 -0
  48. package/src/i18n/tr.ts +702 -0
  49. package/src/i18n/types.ts +710 -0
  50. package/src/i18n/uk.ts +702 -0
  51. package/src/i18n/zh-hant.ts +702 -0
  52. package/src/i18n/zh.ts +698 -0
  53. package/src/index.css +274 -0
  54. package/src/lib/api.ts +1585 -0
  55. package/src/lib/dashboard-flags.ts +15 -0
  56. package/src/lib/format.ts +9 -0
  57. package/src/lib/fuzzy.ts +192 -0
  58. package/src/lib/gatewayClient.ts +253 -0
  59. package/src/lib/nested.ts +23 -0
  60. package/src/lib/resolve-page-title.ts +41 -0
  61. package/src/lib/schedule.ts +382 -0
  62. package/src/lib/slashExec.ts +163 -0
  63. package/src/lib/utils.ts +35 -0
  64. package/src/main.tsx +25 -0
  65. package/src/pages/AnalyticsPage.tsx +601 -0
  66. package/src/pages/ChannelsPage.tsx +772 -0
  67. package/src/pages/ChatPage.tsx +889 -0
  68. package/src/pages/ConfigPage.tsx +660 -0
  69. package/src/pages/CronPage.tsx +524 -0
  70. package/src/pages/DocsPage.tsx +69 -0
  71. package/src/pages/EnvPage.tsx +918 -0
  72. package/src/pages/LogsPage.tsx +246 -0
  73. package/src/pages/McpPage.tsx +757 -0
  74. package/src/pages/ModelsPage.tsx +994 -0
  75. package/src/pages/PairingPage.tsx +276 -0
  76. package/src/pages/PluginsPage.tsx +580 -0
  77. package/src/pages/ProfilesPage.tsx +559 -0
  78. package/src/pages/SessionsPage.tsx +936 -0
  79. package/src/pages/SkillsPage.tsx +557 -0
  80. package/src/pages/SystemPage.tsx +1259 -0
  81. package/src/pages/WebhooksPage.tsx +483 -0
  82. package/src/plugins/PluginPage.tsx +64 -0
  83. package/src/plugins/index.ts +6 -0
  84. package/src/plugins/registry.ts +151 -0
  85. package/src/plugins/sdk.d.ts +160 -0
  86. package/src/plugins/slots.ts +199 -0
  87. package/src/plugins/types.ts +37 -0
  88. package/src/plugins/usePlugins.ts +133 -0
  89. package/src/themes/context.tsx +443 -0
  90. package/src/themes/fonts.ts +160 -0
  91. package/src/themes/index.ts +3 -0
  92. package/src/themes/presets.ts +477 -0
  93. package/src/themes/types.ts +187 -0
  94. package/tsconfig.app.json +34 -0
  95. package/tsconfig.json +7 -0
  96. package/tsconfig.node.json +26 -0
  97. package/vite.config.ts +124 -0
  98. 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
+ }