@ai-gui/react 0.2.0 → 0.4.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/README.md CHANGED
@@ -11,8 +11,8 @@ pnpm add @ai-gui/core @ai-gui/react
11
11
  ## Usage
12
12
 
13
13
  ```tsx
14
- import { CardRegistry } from "@ai-gui/core"
15
- import { AIRenderer } from "@ai-gui/react"
14
+ import { ActionRegistry, CardRegistry, CardStore, createActionRuntime } from "@ai-gui/core"
15
+ import { AIRenderer, useActionState } from "@ai-gui/react"
16
16
  import { useRef } from "react"
17
17
 
18
18
  const registry = new CardRegistry()
@@ -28,6 +28,14 @@ registry.register({
28
28
  ),
29
29
  })
30
30
 
31
+ const actions = new ActionRegistry()
32
+ actions.register({
33
+ type: "refresh",
34
+ run: async (params, { signal }) => fetch("/api/weather", { signal }).then((r) => r.json()),
35
+ })
36
+ const cardStore = new CardStore({ registry })
37
+ const actionRuntime = createActionRuntime({ registry: actions, cardStore })
38
+
31
39
  function Chat() {
32
40
  const ref = useRef<React.ComponentRef<typeof AIRenderer>>(null)
33
41
  // ref.current?.push(chunk) / feed(source) / reset()
@@ -35,7 +43,9 @@ function Chat() {
35
43
  <AIRenderer
36
44
  ref={ref}
37
45
  registry={registry}
38
- onCardAction={({ type, params, cardType }) => {/* app makes the real request */}}
46
+ cardStore={cardStore}
47
+ actionRuntime={actionRuntime}
48
+ onCardAction={(action) => console.log("observed", action)}
39
49
  />
40
50
  )
41
51
  }
@@ -43,7 +53,10 @@ function Chat() {
43
53
 
44
54
  ## Exports
45
55
 
46
- - `<AIRenderer ref registry plugins sanitize onCardAction />` — imperative `ref.current.push/feed/reset`.
56
+ - `<AIRenderer ref registry cardStore plugins sanitize actionRuntime onCardAction />` — imperative `ref.current.push/feed/reset`.
47
57
  - `useAIRenderer(options)` → `{ nodes, push, feed, reset }` — the hook form when you want to render `nodes` yourself.
58
+ - `useActionState(runtime, key)` — subscribe to one action's `idle | pending | success | error | cancelled` state.
59
+
60
+ Cards with a top-level `id` use the supplied `CardStore`. Their React component receives `{ data, state, onAction }`; patches update props while preserving component and DOM state.
48
61
 
49
62
  See the [root README](../../README.md) for cards, plugins, and `buildSystemPrompt`.
package/dist/index.cjs CHANGED
@@ -24,6 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  //#endregion
25
25
  const react = __toESM(require("react"));
26
26
  const __ai_gui_core = __toESM(require("@ai-gui/core"));
27
+ const react_dom = __toESM(require("react-dom"));
27
28
  const react_jsx_runtime = __toESM(require("react/jsx-runtime"));
28
29
 
29
30
  //#region src/apply-patches.ts
@@ -66,7 +67,9 @@ function useAIRenderer(options = {}) {
66
67
  options.registry,
67
68
  options.sanitize,
68
69
  options.plugins,
69
- options.scheduler
70
+ options.scheduler,
71
+ options.debug,
72
+ options.onDebugEvent
70
73
  ]);
71
74
  (0, react.useEffect)(() => {
72
75
  setNodes([]);
@@ -84,6 +87,7 @@ function useAIRenderer(options = {}) {
84
87
  setNodes([]);
85
88
  }, [session]);
86
89
  return {
90
+ renderer: session.renderer,
87
91
  nodes,
88
92
  push,
89
93
  feed,
@@ -91,40 +95,104 @@ function useAIRenderer(options = {}) {
91
95
  };
92
96
  }
93
97
 
98
+ //#endregion
99
+ //#region src/use-action-state.ts
100
+ function useActionState(runtime, key) {
101
+ const idle = (0, react.useMemo)(() => (0, __ai_gui_core.getIdleActionState)(key), [key]);
102
+ const subscribe = (0, react.useCallback)((notify) => runtime?.subscribe(() => notify()) ?? (() => {}), [runtime]);
103
+ const getSnapshot = (0, react.useCallback)(() => {
104
+ const state = runtime?.getState(key) ?? idle;
105
+ return state.status === "idle" ? idle : state;
106
+ }, [
107
+ idle,
108
+ key,
109
+ runtime
110
+ ]);
111
+ return (0, react.useSyncExternalStore)(subscribe, getSnapshot, () => idle);
112
+ }
113
+
94
114
  //#endregion
95
115
  //#region src/render-output.tsx
96
116
  /** Translate a framework-neutral RenderOutput into React nodes. */
97
- function renderOutput(out, key, sanitize) {
117
+ function renderOutput(out, key, sanitize, context) {
98
118
  switch (out.kind) {
99
119
  case "html": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { dangerouslySetInnerHTML: { __html: sanitizeOutput(out.html, sanitize) } }, key);
100
120
  case "element": return (0, react.createElement)(out.tag, {
101
121
  key,
102
122
  ...out.props
103
- }, (out.children ?? []).map((c, i) => renderOutput(c, String(i), sanitize)));
123
+ }, (out.children ?? []).map((c, i) => renderOutput(c, String(i), sanitize, context)));
104
124
  case "card": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
105
125
  "data-aigui-card-fallback": true,
106
126
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: JSON.stringify(out.data, null, 2) })
107
127
  }, key);
108
- case "mount": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MountHost, { mount: out.mount }, key);
128
+ case "mount": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MountHost, {
129
+ mount: out.mount,
130
+ context
131
+ }, key);
109
132
  }
110
133
  }
111
134
  /** Host a framework-neutral imperative mount into a managed DOM element. */
112
- function MountHost({ mount }) {
135
+ function MountHost({ mount, context }) {
113
136
  const ref = (0, react.useRef)(null);
137
+ const [slots, setSlots] = (0, react.useState)([]);
114
138
  (0, react.useEffect)(() => {
115
139
  if (!ref.current) return;
116
- const cleanup = mount(ref.current);
140
+ const mountContext = createMountContext(context, setSlots);
141
+ const cleanup = mount(ref.current, mountContext);
117
142
  return () => {
118
143
  if (typeof cleanup === "function") cleanup();
119
144
  };
120
- }, [mount]);
121
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
145
+ }, [
146
+ mount,
147
+ context?.registry,
148
+ context?.onCardAction
149
+ ]);
150
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
122
151
  ref,
123
152
  "data-aigui-mount": true
124
- });
153
+ }), slots.map((slot) => (0, react_dom.createPortal)((0, react.createElement)(slot.Comp, {
154
+ data: slot.data,
155
+ onAction: (action) => context?.onCardAction?.({
156
+ ...action,
157
+ cardType: slot.type
158
+ })
159
+ }), slot.host, slot.id))] });
160
+ }
161
+ let nextCardSlotId = 0;
162
+ function createMountContext(context, setSlots) {
163
+ return { mountCard(host, request) {
164
+ return mountCard(host, request, context, setSlots);
165
+ } };
166
+ }
167
+ function mountCard(host, request, context, setSlots) {
168
+ const Comp = context?.registry?.getRender(request.type);
169
+ if (!Comp) return void 0;
170
+ const id = String(nextCardSlotId++);
171
+ let destroyed = false;
172
+ setSlots((slots) => [...slots, {
173
+ id,
174
+ host,
175
+ type: request.type,
176
+ data: request.data,
177
+ Comp
178
+ }]);
179
+ return {
180
+ update(data) {
181
+ if (destroyed) return;
182
+ setSlots((slots) => slots.map((slot) => slot.id === id ? {
183
+ ...slot,
184
+ data
185
+ } : slot));
186
+ },
187
+ destroy() {
188
+ if (destroyed) return;
189
+ destroyed = true;
190
+ setSlots((slots) => slots.filter((slot) => slot.id !== id));
191
+ }
192
+ };
125
193
  }
126
194
  /** Await an async RenderOutput, rendering a placeholder until it resolves. */
127
- function AsyncOutput({ promise, sanitize }) {
195
+ function AsyncOutput({ promise, sanitize, context }) {
128
196
  const [resolved, setResolved] = (0, react.useState)(null);
129
197
  const [failed, setFailed] = (0, react.useState)(false);
130
198
  (0, react.useEffect)(() => {
@@ -143,7 +211,7 @@ function AsyncOutput({ promise, sanitize }) {
143
211
  if (failed) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { "data-aigui-async-error": true });
144
212
  if (resolved === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { "data-aigui-async-pending": true });
145
213
  try {
146
- return renderOutput(resolved, void 0, sanitize);
214
+ return renderOutput(resolved, void 0, sanitize, context);
147
215
  } catch {
148
216
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { "data-aigui-async-error": true });
149
217
  }
@@ -157,15 +225,22 @@ function sanitizeOutput(html, sanitize) {
157
225
  //#region src/render-node.tsx
158
226
  function renderNode(node, ctx) {
159
227
  const r = (ctx.nodeRenderers ?? (0, __ai_gui_core.collectNodeRenderers)(ctx.plugins))[node.type];
160
- if (r) try {
161
- const out = r(node);
162
- if (out && typeof out.then === "function") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AsyncOutput, {
163
- promise: out,
164
- sanitize: ctx.sanitize
228
+ if (r) {
229
+ if (node.complete === false) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
230
+ "data-aigui-block-loading": "",
231
+ "data-block-type": node.type
165
232
  }, node.key);
166
- return renderOutput(out, node.key, ctx.sanitize);
167
- } catch {
168
- return renderFallback(node, ctx);
233
+ try {
234
+ const out = r(node);
235
+ if (out && typeof out.then === "function") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AsyncOutput, {
236
+ promise: out,
237
+ sanitize: ctx.sanitize,
238
+ context: ctx
239
+ }, node.key);
240
+ return renderOutput(out, node.key, ctx.sanitize, ctx);
241
+ } catch {
242
+ return renderFallback(node, ctx);
243
+ }
169
244
  }
170
245
  switch (node.type) {
171
246
  case "heading": return (0, react.createElement)(node.tag ?? "h1", {
@@ -203,10 +278,15 @@ function renderCard(node, ctx) {
203
278
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: JSON.stringify(card.data, null, 2) })
204
279
  }, node.key);
205
280
  const Comp = getCardComponent(ctx.registry, card.type);
206
- if (!Comp) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
207
- "data-aigui-card-fallback": true,
208
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: JSON.stringify(card.data, null, 2) })
281
+ if (card.id && ctx.cardStore) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatefulCardHost, {
282
+ cardStore: ctx.cardStore,
283
+ cardId: card.id,
284
+ cardType: card.type,
285
+ initialData: card.data,
286
+ Comp,
287
+ onCardAction: ctx.onCardAction
209
288
  }, node.key);
289
+ if (!Comp) return renderCardFallback(node.key, card.data);
210
290
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Comp, {
211
291
  data: card.data,
212
292
  onAction: (a) => ctx.onCardAction?.({
@@ -215,6 +295,49 @@ function renderCard(node, ctx) {
215
295
  })
216
296
  }, node.key);
217
297
  }
298
+ const getServerCardSnapshot = () => void 0;
299
+ function StatefulCardHost({ cardStore, cardId, cardType, initialData, Comp, onCardAction }) {
300
+ const subscribe = (0, react.useCallback)((notify) => cardStore.subscribe(cardId, notify), [cardStore, cardId]);
301
+ const getSnapshot = (0, react.useCallback)(() => cardStore.get(cardId), [cardStore, cardId]);
302
+ const record = (0, react.useSyncExternalStore)(subscribe, getSnapshot, getServerCardSnapshot);
303
+ (0, react.useEffect)(() => {
304
+ if (cardStore.get(cardId)) return;
305
+ try {
306
+ cardStore.register({
307
+ id: cardId,
308
+ type: cardType,
309
+ data: initialData
310
+ });
311
+ } catch {}
312
+ }, [
313
+ cardStore,
314
+ cardId,
315
+ cardType,
316
+ initialData
317
+ ]);
318
+ if (!record) return renderCardFallback(void 0, initialData);
319
+ if (record.type !== cardType) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
320
+ "data-aigui-card-invalid": true,
321
+ "data-card-type": cardType,
322
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: JSON.stringify(initialData, null, 2) })
323
+ });
324
+ if (!Comp) return renderCardFallback(void 0, record.data);
325
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Comp, {
326
+ data: record.data,
327
+ state: record.action,
328
+ onAction: (action) => onCardAction?.({
329
+ ...action,
330
+ cardType,
331
+ cardId
332
+ })
333
+ });
334
+ }
335
+ function renderCardFallback(key, data) {
336
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
337
+ "data-aigui-card-fallback": true,
338
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: JSON.stringify(data, null, 2) })
339
+ }, key);
340
+ }
218
341
  function getCardComponent(registry, type) {
219
342
  if (!registry) return void 0;
220
343
  return registry.getRender(type);
@@ -222,29 +345,71 @@ function getCardComponent(registry, type) {
222
345
 
223
346
  //#endregion
224
347
  //#region src/ai-renderer.tsx
348
+ function createActionScope() {
349
+ return {
350
+ controller: new AbortController(),
351
+ owner: {}
352
+ };
353
+ }
225
354
  const AIRenderer = (0, react.forwardRef)(function AIRenderer$1(props, ref) {
226
- const { registry, sanitize, plugins, onCardAction, className } = props;
355
+ const { registry, cardStore, sanitize, plugins, actionRuntime, onCardAction, className, debug, onDebugEvent } = props;
227
356
  const opts = {
228
357
  registry,
229
358
  sanitize,
230
- plugins
359
+ plugins,
360
+ debug,
361
+ onDebugEvent
231
362
  };
232
- const { nodes, push, feed, reset } = useAIRenderer(opts);
363
+ const { renderer, nodes, push, feed, reset: resetRenderer } = useAIRenderer(opts);
364
+ const actionScope = (0, react.useRef)(createActionScope());
365
+ (0, react.useEffect)(() => () => {
366
+ actionScope.current.controller.abort();
367
+ actionScope.current = createActionScope();
368
+ }, [
369
+ actionRuntime,
370
+ registry,
371
+ sanitize,
372
+ plugins
373
+ ]);
374
+ const reset = (0, react.useCallback)(() => {
375
+ actionScope.current.controller.abort();
376
+ actionScope.current = createActionScope();
377
+ resetRenderer();
378
+ }, [resetRenderer]);
379
+ const handleCardAction = (0, react.useCallback)((action) => {
380
+ if (actionRuntime) {
381
+ const scope = actionScope.current;
382
+ actionRuntime.dispatch({
383
+ type: action.type,
384
+ params: action.params,
385
+ cardType: action.cardType,
386
+ cardId: action.cardId
387
+ }, {
388
+ signal: scope.controller.signal,
389
+ owner: scope.owner
390
+ }).catch(() => {});
391
+ }
392
+ onCardAction?.(action);
393
+ }, [actionRuntime, onCardAction]);
233
394
  (0, react.useImperativeHandle)(ref, () => ({
395
+ debugSource: "renderer",
396
+ subscribeDebug: (listener) => renderer.subscribeDebug(listener),
234
397
  push,
235
398
  feed,
236
399
  reset
237
400
  }), [
401
+ renderer,
238
402
  push,
239
403
  feed,
240
404
  reset
241
405
  ]);
242
- const nodeRenderers = (0, react.useMemo)(() => (0, __ai_gui_core.collectNodeRenderers)(plugins), [plugins]);
406
+ const nodeRenderers = (0, react.useMemo)(() => (0, __ai_gui_core.collectNodeRenderers)(plugins, { debugTarget: renderer }), [plugins, renderer]);
243
407
  const ctx = {
244
408
  registry,
409
+ cardStore,
245
410
  plugins,
246
411
  nodeRenderers,
247
- onCardAction,
412
+ onCardAction: handleCardAction,
248
413
  sanitize,
249
414
  sanitized: true
250
415
  };
@@ -260,4 +425,5 @@ exports.AIRenderer = AIRenderer
260
425
  exports.applyPatches = applyPatches
261
426
  exports.renderNode = renderNode
262
427
  exports.renderOutput = renderOutput
263
- exports.useAIRenderer = useAIRenderer
428
+ exports.useAIRenderer = useAIRenderer
429
+ exports.useActionState = useActionState
package/dist/index.d.cts CHANGED
@@ -1,10 +1,11 @@
1
- import { AIGuiPlugin, ASTNode, CardRegistry, FeedOptions, FeedSource, NodeRenderer, Patch, RenderOutput, RendererOptions } from "@ai-gui/core";
1
+ import { AIGuiPlugin, ASTNode, ActionRuntime, ActionState, CardAction, CardRegistry, CardStore, DebugEventListener, FeedOptions, FeedSource, NodeRenderer, Patch, RenderOutput, Renderer, RendererOptions } from "@ai-gui/core";
2
2
  import * as react0 from "react";
3
3
  import * as react1 from "react";
4
4
  import { ReactNode } from "react";
5
5
 
6
6
  //#region src/use-ai-renderer.d.ts
7
7
  interface UseAIRendererResult {
8
+ renderer: Renderer;
8
9
  nodes: ASTNode[];
9
10
  push: (chunk: string) => void;
10
11
  feed: (source: FeedSource, options?: FeedOptions) => Promise<void>;
@@ -12,46 +13,67 @@ interface UseAIRendererResult {
12
13
  }
13
14
  declare function useAIRenderer(options?: Omit<RendererOptions, "onPatch">): UseAIRendererResult;
14
15
 
16
+ //#endregion
17
+ //#region src/use-action-state.d.ts
18
+ declare function useActionState(runtime: ActionRuntime | undefined, key: string): ActionState;
19
+
15
20
  //#endregion
16
21
  //#region src/render-node.d.ts
22
+ interface CardActionPayload {
23
+ type: string;
24
+ params?: unknown;
25
+ cardType: string;
26
+ cardId?: string;
27
+ }
17
28
  interface RenderContext {
18
29
  registry?: CardRegistry;
30
+ cardStore?: CardStore;
19
31
  plugins?: AIGuiPlugin[];
20
32
  nodeRenderers?: Record<string, NodeRenderer>;
21
- onCardAction?: (action: {
22
- type: string;
23
- params?: unknown;
24
- cardType: string;
25
- }) => void;
33
+ onCardAction?: (action: CardActionPayload) => void;
26
34
  sanitize?: RendererOptions["sanitize"];
27
35
  sanitized?: boolean;
28
36
  }
29
37
  declare function renderNode(node: ASTNode, ctx: RenderContext): ReactNode;
38
+ interface CardComponentProps {
39
+ data: unknown;
40
+ state?: CardAction;
41
+ onAction: (a: {
42
+ type: string;
43
+ params?: unknown;
44
+ }) => void;
45
+ }
30
46
 
31
47
  //#endregion
32
48
  //#region src/ai-renderer.d.ts
33
49
  interface AIRendererHandle {
50
+ readonly debugSource: "renderer";
51
+ subscribeDebug: (listener: DebugEventListener) => () => void;
34
52
  push: (chunk: string) => void;
35
53
  feed: (source: FeedSource, options?: FeedOptions) => Promise<void>;
36
54
  reset: () => void;
37
55
  }
38
56
  interface AIRendererProps {
39
57
  registry?: CardRegistry;
58
+ cardStore?: CardStore;
40
59
  sanitize?: RendererOptions["sanitize"];
41
60
  plugins?: AIGuiPlugin[];
61
+ actionRuntime?: ActionRuntime;
42
62
  onCardAction?: RenderContext["onCardAction"];
43
63
  className?: string;
64
+ debug?: RendererOptions["debug"];
65
+ onDebugEvent?: RendererOptions["onDebugEvent"];
44
66
  }
45
67
  declare const AIRenderer: react1.ForwardRefExoticComponent<AIRendererProps & react0.RefAttributes<AIRendererHandle>>;
46
68
 
47
69
  //#endregion
48
70
  //#region src/render-output.d.ts
49
71
  /** Translate a framework-neutral RenderOutput into React nodes. */
50
- declare function renderOutput(out: RenderOutput, key?: string, sanitize?: RendererOptions["sanitize"]): ReactNode;
72
+ declare function renderOutput(out: RenderOutput, key?: string, sanitize?: RendererOptions["sanitize"], context?: RenderContext): ReactNode;
51
73
 
52
74
  //#endregion
53
75
  //#region src/apply-patches.d.ts
54
76
  declare function applyPatches(nodes: ASTNode[], patches: Patch[]): ASTNode[];
55
77
 
56
78
  //#endregion
57
- export { AIRenderer, AIRendererHandle, AIRendererProps, RenderContext, UseAIRendererResult, applyPatches, renderNode, renderOutput, useAIRenderer };
79
+ export { AIRenderer, AIRendererHandle, AIRendererProps, CardActionPayload, CardComponentProps, RenderContext, UseAIRendererResult, applyPatches, renderNode, renderOutput, useAIRenderer, useActionState };
package/dist/index.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import * as react0 from "react";
2
2
  import * as react1 from "react";
3
3
  import { ReactNode } from "react";
4
- import { AIGuiPlugin, ASTNode, CardRegistry, FeedOptions, FeedSource, NodeRenderer, Patch, RenderOutput, RendererOptions } from "@ai-gui/core";
4
+ import { AIGuiPlugin, ASTNode, ActionRuntime, ActionState, CardAction, CardRegistry, CardStore, DebugEventListener, FeedOptions, FeedSource, NodeRenderer, Patch, RenderOutput, Renderer, RendererOptions } from "@ai-gui/core";
5
5
 
6
6
  //#region src/use-ai-renderer.d.ts
7
7
  interface UseAIRendererResult {
8
+ renderer: Renderer;
8
9
  nodes: ASTNode[];
9
10
  push: (chunk: string) => void;
10
11
  feed: (source: FeedSource, options?: FeedOptions) => Promise<void>;
@@ -12,46 +13,67 @@ interface UseAIRendererResult {
12
13
  }
13
14
  declare function useAIRenderer(options?: Omit<RendererOptions, "onPatch">): UseAIRendererResult;
14
15
 
16
+ //#endregion
17
+ //#region src/use-action-state.d.ts
18
+ declare function useActionState(runtime: ActionRuntime | undefined, key: string): ActionState;
19
+
15
20
  //#endregion
16
21
  //#region src/render-node.d.ts
22
+ interface CardActionPayload {
23
+ type: string;
24
+ params?: unknown;
25
+ cardType: string;
26
+ cardId?: string;
27
+ }
17
28
  interface RenderContext {
18
29
  registry?: CardRegistry;
30
+ cardStore?: CardStore;
19
31
  plugins?: AIGuiPlugin[];
20
32
  nodeRenderers?: Record<string, NodeRenderer>;
21
- onCardAction?: (action: {
22
- type: string;
23
- params?: unknown;
24
- cardType: string;
25
- }) => void;
33
+ onCardAction?: (action: CardActionPayload) => void;
26
34
  sanitize?: RendererOptions["sanitize"];
27
35
  sanitized?: boolean;
28
36
  }
29
37
  declare function renderNode(node: ASTNode, ctx: RenderContext): ReactNode;
38
+ interface CardComponentProps {
39
+ data: unknown;
40
+ state?: CardAction;
41
+ onAction: (a: {
42
+ type: string;
43
+ params?: unknown;
44
+ }) => void;
45
+ }
30
46
 
31
47
  //#endregion
32
48
  //#region src/ai-renderer.d.ts
33
49
  interface AIRendererHandle {
50
+ readonly debugSource: "renderer";
51
+ subscribeDebug: (listener: DebugEventListener) => () => void;
34
52
  push: (chunk: string) => void;
35
53
  feed: (source: FeedSource, options?: FeedOptions) => Promise<void>;
36
54
  reset: () => void;
37
55
  }
38
56
  interface AIRendererProps {
39
57
  registry?: CardRegistry;
58
+ cardStore?: CardStore;
40
59
  sanitize?: RendererOptions["sanitize"];
41
60
  plugins?: AIGuiPlugin[];
61
+ actionRuntime?: ActionRuntime;
42
62
  onCardAction?: RenderContext["onCardAction"];
43
63
  className?: string;
64
+ debug?: RendererOptions["debug"];
65
+ onDebugEvent?: RendererOptions["onDebugEvent"];
44
66
  }
45
67
  declare const AIRenderer: react1.ForwardRefExoticComponent<AIRendererProps & react0.RefAttributes<AIRendererHandle>>;
46
68
 
47
69
  //#endregion
48
70
  //#region src/render-output.d.ts
49
71
  /** Translate a framework-neutral RenderOutput into React nodes. */
50
- declare function renderOutput(out: RenderOutput, key?: string, sanitize?: RendererOptions["sanitize"]): ReactNode;
72
+ declare function renderOutput(out: RenderOutput, key?: string, sanitize?: RendererOptions["sanitize"], context?: RenderContext): ReactNode;
51
73
 
52
74
  //#endregion
53
75
  //#region src/apply-patches.d.ts
54
76
  declare function applyPatches(nodes: ASTNode[], patches: Patch[]): ASTNode[];
55
77
 
56
78
  //#endregion
57
- export { AIRenderer, AIRendererHandle, AIRendererProps, RenderContext, UseAIRendererResult, applyPatches, renderNode, renderOutput, useAIRenderer };
79
+ export { AIRenderer, AIRendererHandle, AIRendererProps, CardActionPayload, CardComponentProps, RenderContext, UseAIRendererResult, applyPatches, renderNode, renderOutput, useAIRenderer, useActionState };
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
- import { createElement, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
2
- import { Renderer, collectNodeRenderers, sanitizeHtml } from "@ai-gui/core";
3
- import { jsx } from "react/jsx-runtime";
1
+ import { Fragment, createElement, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ import { Renderer, collectNodeRenderers, getIdleActionState, sanitizeHtml } from "@ai-gui/core";
3
+ import { createPortal } from "react-dom";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
4
5
 
5
6
  //#region src/apply-patches.ts
6
7
  function applyPatches(nodes, patches) {
@@ -42,7 +43,9 @@ function useAIRenderer(options = {}) {
42
43
  options.registry,
43
44
  options.sanitize,
44
45
  options.plugins,
45
- options.scheduler
46
+ options.scheduler,
47
+ options.debug,
48
+ options.onDebugEvent
46
49
  ]);
47
50
  useEffect(() => {
48
51
  setNodes([]);
@@ -60,6 +63,7 @@ function useAIRenderer(options = {}) {
60
63
  setNodes([]);
61
64
  }, [session]);
62
65
  return {
66
+ renderer: session.renderer,
63
67
  nodes,
64
68
  push,
65
69
  feed,
@@ -67,40 +71,104 @@ function useAIRenderer(options = {}) {
67
71
  };
68
72
  }
69
73
 
74
+ //#endregion
75
+ //#region src/use-action-state.ts
76
+ function useActionState(runtime, key) {
77
+ const idle = useMemo(() => getIdleActionState(key), [key]);
78
+ const subscribe = useCallback((notify) => runtime?.subscribe(() => notify()) ?? (() => {}), [runtime]);
79
+ const getSnapshot = useCallback(() => {
80
+ const state = runtime?.getState(key) ?? idle;
81
+ return state.status === "idle" ? idle : state;
82
+ }, [
83
+ idle,
84
+ key,
85
+ runtime
86
+ ]);
87
+ return useSyncExternalStore(subscribe, getSnapshot, () => idle);
88
+ }
89
+
70
90
  //#endregion
71
91
  //#region src/render-output.tsx
72
92
  /** Translate a framework-neutral RenderOutput into React nodes. */
73
- function renderOutput(out, key, sanitize) {
93
+ function renderOutput(out, key, sanitize, context) {
74
94
  switch (out.kind) {
75
95
  case "html": return /* @__PURE__ */ jsx("div", { dangerouslySetInnerHTML: { __html: sanitizeOutput(out.html, sanitize) } }, key);
76
96
  case "element": return createElement(out.tag, {
77
97
  key,
78
98
  ...out.props
79
- }, (out.children ?? []).map((c, i) => renderOutput(c, String(i), sanitize)));
99
+ }, (out.children ?? []).map((c, i) => renderOutput(c, String(i), sanitize, context)));
80
100
  case "card": return /* @__PURE__ */ jsx("pre", {
81
101
  "data-aigui-card-fallback": true,
82
102
  children: /* @__PURE__ */ jsx("code", { children: JSON.stringify(out.data, null, 2) })
83
103
  }, key);
84
- case "mount": return /* @__PURE__ */ jsx(MountHost, { mount: out.mount }, key);
104
+ case "mount": return /* @__PURE__ */ jsx(MountHost, {
105
+ mount: out.mount,
106
+ context
107
+ }, key);
85
108
  }
86
109
  }
87
110
  /** Host a framework-neutral imperative mount into a managed DOM element. */
88
- function MountHost({ mount }) {
111
+ function MountHost({ mount, context }) {
89
112
  const ref = useRef(null);
113
+ const [slots, setSlots] = useState([]);
90
114
  useEffect(() => {
91
115
  if (!ref.current) return;
92
- const cleanup = mount(ref.current);
116
+ const mountContext = createMountContext(context, setSlots);
117
+ const cleanup = mount(ref.current, mountContext);
93
118
  return () => {
94
119
  if (typeof cleanup === "function") cleanup();
95
120
  };
96
- }, [mount]);
97
- return /* @__PURE__ */ jsx("div", {
121
+ }, [
122
+ mount,
123
+ context?.registry,
124
+ context?.onCardAction
125
+ ]);
126
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
98
127
  ref,
99
128
  "data-aigui-mount": true
100
- });
129
+ }), slots.map((slot) => createPortal(createElement(slot.Comp, {
130
+ data: slot.data,
131
+ onAction: (action) => context?.onCardAction?.({
132
+ ...action,
133
+ cardType: slot.type
134
+ })
135
+ }), slot.host, slot.id))] });
136
+ }
137
+ let nextCardSlotId = 0;
138
+ function createMountContext(context, setSlots) {
139
+ return { mountCard(host, request) {
140
+ return mountCard(host, request, context, setSlots);
141
+ } };
142
+ }
143
+ function mountCard(host, request, context, setSlots) {
144
+ const Comp = context?.registry?.getRender(request.type);
145
+ if (!Comp) return void 0;
146
+ const id = String(nextCardSlotId++);
147
+ let destroyed = false;
148
+ setSlots((slots) => [...slots, {
149
+ id,
150
+ host,
151
+ type: request.type,
152
+ data: request.data,
153
+ Comp
154
+ }]);
155
+ return {
156
+ update(data) {
157
+ if (destroyed) return;
158
+ setSlots((slots) => slots.map((slot) => slot.id === id ? {
159
+ ...slot,
160
+ data
161
+ } : slot));
162
+ },
163
+ destroy() {
164
+ if (destroyed) return;
165
+ destroyed = true;
166
+ setSlots((slots) => slots.filter((slot) => slot.id !== id));
167
+ }
168
+ };
101
169
  }
102
170
  /** Await an async RenderOutput, rendering a placeholder until it resolves. */
103
- function AsyncOutput({ promise, sanitize }) {
171
+ function AsyncOutput({ promise, sanitize, context }) {
104
172
  const [resolved, setResolved] = useState(null);
105
173
  const [failed, setFailed] = useState(false);
106
174
  useEffect(() => {
@@ -119,7 +187,7 @@ function AsyncOutput({ promise, sanitize }) {
119
187
  if (failed) return /* @__PURE__ */ jsx("span", { "data-aigui-async-error": true });
120
188
  if (resolved === null) return /* @__PURE__ */ jsx("span", { "data-aigui-async-pending": true });
121
189
  try {
122
- return renderOutput(resolved, void 0, sanitize);
190
+ return renderOutput(resolved, void 0, sanitize, context);
123
191
  } catch {
124
192
  return /* @__PURE__ */ jsx("span", { "data-aigui-async-error": true });
125
193
  }
@@ -133,15 +201,22 @@ function sanitizeOutput(html, sanitize) {
133
201
  //#region src/render-node.tsx
134
202
  function renderNode(node, ctx) {
135
203
  const r = (ctx.nodeRenderers ?? collectNodeRenderers(ctx.plugins))[node.type];
136
- if (r) try {
137
- const out = r(node);
138
- if (out && typeof out.then === "function") return /* @__PURE__ */ jsx(AsyncOutput, {
139
- promise: out,
140
- sanitize: ctx.sanitize
204
+ if (r) {
205
+ if (node.complete === false) return /* @__PURE__ */ jsx("div", {
206
+ "data-aigui-block-loading": "",
207
+ "data-block-type": node.type
141
208
  }, node.key);
142
- return renderOutput(out, node.key, ctx.sanitize);
143
- } catch {
144
- return renderFallback(node, ctx);
209
+ try {
210
+ const out = r(node);
211
+ if (out && typeof out.then === "function") return /* @__PURE__ */ jsx(AsyncOutput, {
212
+ promise: out,
213
+ sanitize: ctx.sanitize,
214
+ context: ctx
215
+ }, node.key);
216
+ return renderOutput(out, node.key, ctx.sanitize, ctx);
217
+ } catch {
218
+ return renderFallback(node, ctx);
219
+ }
145
220
  }
146
221
  switch (node.type) {
147
222
  case "heading": return createElement(node.tag ?? "h1", {
@@ -179,10 +254,15 @@ function renderCard(node, ctx) {
179
254
  children: /* @__PURE__ */ jsx("code", { children: JSON.stringify(card.data, null, 2) })
180
255
  }, node.key);
181
256
  const Comp = getCardComponent(ctx.registry, card.type);
182
- if (!Comp) return /* @__PURE__ */ jsx("pre", {
183
- "data-aigui-card-fallback": true,
184
- children: /* @__PURE__ */ jsx("code", { children: JSON.stringify(card.data, null, 2) })
257
+ if (card.id && ctx.cardStore) return /* @__PURE__ */ jsx(StatefulCardHost, {
258
+ cardStore: ctx.cardStore,
259
+ cardId: card.id,
260
+ cardType: card.type,
261
+ initialData: card.data,
262
+ Comp,
263
+ onCardAction: ctx.onCardAction
185
264
  }, node.key);
265
+ if (!Comp) return renderCardFallback(node.key, card.data);
186
266
  return /* @__PURE__ */ jsx(Comp, {
187
267
  data: card.data,
188
268
  onAction: (a) => ctx.onCardAction?.({
@@ -191,6 +271,49 @@ function renderCard(node, ctx) {
191
271
  })
192
272
  }, node.key);
193
273
  }
274
+ const getServerCardSnapshot = () => void 0;
275
+ function StatefulCardHost({ cardStore, cardId, cardType, initialData, Comp, onCardAction }) {
276
+ const subscribe = useCallback((notify) => cardStore.subscribe(cardId, notify), [cardStore, cardId]);
277
+ const getSnapshot = useCallback(() => cardStore.get(cardId), [cardStore, cardId]);
278
+ const record = useSyncExternalStore(subscribe, getSnapshot, getServerCardSnapshot);
279
+ useEffect(() => {
280
+ if (cardStore.get(cardId)) return;
281
+ try {
282
+ cardStore.register({
283
+ id: cardId,
284
+ type: cardType,
285
+ data: initialData
286
+ });
287
+ } catch {}
288
+ }, [
289
+ cardStore,
290
+ cardId,
291
+ cardType,
292
+ initialData
293
+ ]);
294
+ if (!record) return renderCardFallback(void 0, initialData);
295
+ if (record.type !== cardType) return /* @__PURE__ */ jsx("pre", {
296
+ "data-aigui-card-invalid": true,
297
+ "data-card-type": cardType,
298
+ children: /* @__PURE__ */ jsx("code", { children: JSON.stringify(initialData, null, 2) })
299
+ });
300
+ if (!Comp) return renderCardFallback(void 0, record.data);
301
+ return /* @__PURE__ */ jsx(Comp, {
302
+ data: record.data,
303
+ state: record.action,
304
+ onAction: (action) => onCardAction?.({
305
+ ...action,
306
+ cardType,
307
+ cardId
308
+ })
309
+ });
310
+ }
311
+ function renderCardFallback(key, data) {
312
+ return /* @__PURE__ */ jsx("pre", {
313
+ "data-aigui-card-fallback": true,
314
+ children: /* @__PURE__ */ jsx("code", { children: JSON.stringify(data, null, 2) })
315
+ }, key);
316
+ }
194
317
  function getCardComponent(registry, type) {
195
318
  if (!registry) return void 0;
196
319
  return registry.getRender(type);
@@ -198,29 +321,71 @@ function getCardComponent(registry, type) {
198
321
 
199
322
  //#endregion
200
323
  //#region src/ai-renderer.tsx
324
+ function createActionScope() {
325
+ return {
326
+ controller: new AbortController(),
327
+ owner: {}
328
+ };
329
+ }
201
330
  const AIRenderer = forwardRef(function AIRenderer$1(props, ref) {
202
- const { registry, sanitize, plugins, onCardAction, className } = props;
331
+ const { registry, cardStore, sanitize, plugins, actionRuntime, onCardAction, className, debug, onDebugEvent } = props;
203
332
  const opts = {
204
333
  registry,
205
334
  sanitize,
206
- plugins
335
+ plugins,
336
+ debug,
337
+ onDebugEvent
207
338
  };
208
- const { nodes, push, feed, reset } = useAIRenderer(opts);
339
+ const { renderer, nodes, push, feed, reset: resetRenderer } = useAIRenderer(opts);
340
+ const actionScope = useRef(createActionScope());
341
+ useEffect(() => () => {
342
+ actionScope.current.controller.abort();
343
+ actionScope.current = createActionScope();
344
+ }, [
345
+ actionRuntime,
346
+ registry,
347
+ sanitize,
348
+ plugins
349
+ ]);
350
+ const reset = useCallback(() => {
351
+ actionScope.current.controller.abort();
352
+ actionScope.current = createActionScope();
353
+ resetRenderer();
354
+ }, [resetRenderer]);
355
+ const handleCardAction = useCallback((action) => {
356
+ if (actionRuntime) {
357
+ const scope = actionScope.current;
358
+ actionRuntime.dispatch({
359
+ type: action.type,
360
+ params: action.params,
361
+ cardType: action.cardType,
362
+ cardId: action.cardId
363
+ }, {
364
+ signal: scope.controller.signal,
365
+ owner: scope.owner
366
+ }).catch(() => {});
367
+ }
368
+ onCardAction?.(action);
369
+ }, [actionRuntime, onCardAction]);
209
370
  useImperativeHandle(ref, () => ({
371
+ debugSource: "renderer",
372
+ subscribeDebug: (listener) => renderer.subscribeDebug(listener),
210
373
  push,
211
374
  feed,
212
375
  reset
213
376
  }), [
377
+ renderer,
214
378
  push,
215
379
  feed,
216
380
  reset
217
381
  ]);
218
- const nodeRenderers = useMemo(() => collectNodeRenderers(plugins), [plugins]);
382
+ const nodeRenderers = useMemo(() => collectNodeRenderers(plugins, { debugTarget: renderer }), [plugins, renderer]);
219
383
  const ctx = {
220
384
  registry,
385
+ cardStore,
221
386
  plugins,
222
387
  nodeRenderers,
223
- onCardAction,
388
+ onCardAction: handleCardAction,
224
389
  sanitize,
225
390
  sanitized: true
226
391
  };
@@ -232,4 +397,4 @@ const AIRenderer = forwardRef(function AIRenderer$1(props, ref) {
232
397
  });
233
398
 
234
399
  //#endregion
235
- export { AIRenderer, applyPatches, renderNode, renderOutput, useAIRenderer };
400
+ export { AIRenderer, applyPatches, renderNode, renderOutput, useAIRenderer, useActionState };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-gui/react",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "React adapter for AIGUI — stream LLM output into live React components with cards, plugins, and interactive widgets.",
5
5
  "keywords": [
6
6
  "llm",
@@ -50,10 +50,11 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@ai-gui/core": "0.2.0"
53
+ "@ai-gui/core": "0.4.0"
54
54
  },
55
55
  "peerDependencies": {
56
- "react": ">=18"
56
+ "react": ">=18",
57
+ "react-dom": ">=18"
57
58
  },
58
59
  "devDependencies": {
59
60
  "react": "^18.3.1",