@ai-gui/react 0.4.4 → 0.6.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/dist/index.cjs CHANGED
@@ -50,6 +50,7 @@ function applyPatches(nodes, patches) {
50
50
  function useAIRenderer(options = {}) {
51
51
  const [nodes, setNodes] = (0, react.useState)([]);
52
52
  const active = (0, react.useRef)(null);
53
+ const pluginList = useStableList(options.plugins);
53
54
  const session = (0, react.useMemo)(() => {
54
55
  const token = {};
55
56
  const renderer = new __ai_gui_core.Renderer({
@@ -66,7 +67,7 @@ function useAIRenderer(options = {}) {
66
67
  }, [
67
68
  options.registry,
68
69
  options.sanitize,
69
- options.plugins,
70
+ pluginList,
70
71
  options.scheduler,
71
72
  options.debug,
72
73
  options.onDebugEvent
@@ -95,6 +96,14 @@ function useAIRenderer(options = {}) {
95
96
  reset
96
97
  };
97
98
  }
99
+ /** Keep the same array reference for as long as it holds the same members in the same order. */
100
+ function useStableList(list) {
101
+ const held = (0, react.useRef)(list);
102
+ const current = held.current;
103
+ const same = list === current || !!list && !!current && list.length === current.length && list.every((item, index) => item === current[index]);
104
+ if (!same) held.current = list;
105
+ return held.current;
106
+ }
98
107
 
99
108
  //#endregion
100
109
  //#region src/use-action-state.ts
@@ -117,7 +126,7 @@ function useActionState(runtime, key) {
117
126
  /** Translate a framework-neutral RenderOutput into React nodes. */
118
127
  function renderOutput(out, key, sanitize, context) {
119
128
  switch (out.kind) {
120
- case "html": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { dangerouslySetInnerHTML: { __html: sanitizeOutput(out.html, sanitize) } }, key);
129
+ case "html": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { dangerouslySetInnerHTML: { __html: (0, __ai_gui_core.sanitizeRenderedHtml)(out.html, sanitize, out.trusted) } }, key);
121
130
  case "element": return (0, react.createElement)(out.tag, {
122
131
  key,
123
132
  ...out.props
@@ -217,10 +226,6 @@ function AsyncOutput({ promise, sanitize, context }) {
217
226
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { "data-aigui-async-error": true });
218
227
  }
219
228
  }
220
- function sanitizeOutput(html, sanitize) {
221
- if (sanitize === false) return html;
222
- return (0, __ai_gui_core.sanitizeHtml)(html, typeof sanitize === "object" ? sanitize : void 0);
223
- }
224
229
 
225
230
  //#endregion
226
231
  //#region src/render-node.tsx
@@ -256,11 +261,13 @@ function renderNode(node, ctx) {
256
261
  function PluginOutputHost({ node, renderer, context }) {
257
262
  const cache = (0, react.useRef)();
258
263
  const signature = nodeSignature(node);
264
+ const theme = context.theme;
259
265
  try {
260
- if (!cache.current || cache.current.renderer !== renderer || cache.current.signature !== signature) cache.current = {
266
+ if (!cache.current || cache.current.renderer !== renderer || cache.current.signature !== signature || cache.current.theme !== theme) cache.current = {
261
267
  renderer,
262
268
  signature,
263
- output: renderer(node)
269
+ theme,
270
+ output: renderer(node, { theme })
264
271
  };
265
272
  const output = cache.current.output;
266
273
  if (output && typeof output.then === "function") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AsyncOutput, {
@@ -374,7 +381,7 @@ function createActionScope() {
374
381
  };
375
382
  }
376
383
  const AIRenderer = (0, react.forwardRef)(function AIRenderer$1(props, ref) {
377
- const { registry, cardStore, sanitize, plugins, actionRuntime, onCardAction, className, debug, onDebugEvent } = props;
384
+ const { text, registry, cardStore, sanitize, plugins, actionRuntime, onCardAction, onRender, theme, className, debug, onDebugEvent } = props;
378
385
  const opts = {
379
386
  registry,
380
387
  sanitize,
@@ -384,6 +391,8 @@ const AIRenderer = (0, react.forwardRef)(function AIRenderer$1(props, ref) {
384
391
  };
385
392
  const { renderer, nodes, push, feed, reset: resetRenderer } = useAIRenderer(opts);
386
393
  const actionScope = (0, react.useRef)(createActionScope());
394
+ const rendered = (0, react.useRef)("");
395
+ const root = (0, react.useRef)(null);
387
396
  (0, react.useEffect)(() => () => {
388
397
  actionScope.current.controller.abort();
389
398
  actionScope.current = createActionScope();
@@ -396,8 +405,30 @@ const AIRenderer = (0, react.forwardRef)(function AIRenderer$1(props, ref) {
396
405
  const reset = (0, react.useCallback)(() => {
397
406
  actionScope.current.controller.abort();
398
407
  actionScope.current = createActionScope();
408
+ rendered.current = "";
399
409
  resetRenderer();
400
410
  }, [resetRenderer]);
411
+ (0, react.useEffect)(() => {
412
+ if (text === void 0 || text === rendered.current) return;
413
+ if (text.startsWith(rendered.current)) push(text.slice(rendered.current.length));
414
+ else {
415
+ reset();
416
+ push(text);
417
+ }
418
+ rendered.current = text;
419
+ }, [
420
+ text,
421
+ push,
422
+ reset
423
+ ]);
424
+ const onRenderRef = (0, react.useRef)(onRender);
425
+ onRenderRef.current = onRender;
426
+ (0, react.useEffect)(() => {
427
+ onRenderRef.current?.(nodes);
428
+ }, [nodes]);
429
+ (0, react.useEffect)(() => () => {
430
+ rendered.current = "";
431
+ }, [renderer]);
401
432
  const handleCardAction = (0, react.useCallback)((action) => {
402
433
  if (actionRuntime) {
403
434
  const scope = actionScope.current;
@@ -413,16 +444,19 @@ const AIRenderer = (0, react.forwardRef)(function AIRenderer$1(props, ref) {
413
444
  }
414
445
  onCardAction?.(action);
415
446
  }, [actionRuntime, onCardAction]);
447
+ const exportImages = (0, react.useCallback)((options) => root.current ? (0, __ai_gui_core.exportRenderedImages)(root.current, options) : Promise.resolve([]), []);
416
448
  (0, react.useImperativeHandle)(ref, () => ({
417
449
  debugSource: "renderer",
418
450
  subscribeDebug: (listener) => renderer.subscribeDebug(listener),
419
451
  push,
420
452
  feed,
453
+ exportImages,
421
454
  reset
422
455
  }), [
423
456
  renderer,
424
457
  push,
425
458
  feed,
459
+ exportImages,
426
460
  reset
427
461
  ]);
428
462
  const nodeRenderers = (0, react.useMemo)(() => (0, __ai_gui_core.collectNodeRenderers)(plugins, { debugTarget: renderer }), [plugins, renderer]);
@@ -433,11 +467,13 @@ const AIRenderer = (0, react.forwardRef)(function AIRenderer$1(props, ref) {
433
467
  nodeRenderers,
434
468
  onCardAction: handleCardAction,
435
469
  sanitize,
436
- sanitized: true
470
+ sanitized: true,
471
+ theme
437
472
  };
438
473
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
439
474
  className,
440
475
  "data-aigui-renderer": true,
476
+ ref: root,
441
477
  children: nodes.map((n) => renderNode(n, ctx))
442
478
  });
443
479
  });
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { AIGuiPlugin, ASTNode, ActionRuntime, ActionState, CardAction, CardRegistry, CardStore, DebugEventListener, FeedOptions, FeedSource, NodeRenderer, Patch, RenderOutput, Renderer, RendererOptions } from "@ai-gui/core";
1
+ import { AIGuiPlugin, ASTNode, ActionRuntime, ActionState, CardAction, CardRegistry, CardStore, DebugEventListener, ExportImageOptions, ExportedImage, 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";
@@ -33,6 +33,8 @@ interface RenderContext {
33
33
  onCardAction?: (action: CardActionPayload) => void;
34
34
  sanitize?: RendererOptions["sanitize"];
35
35
  sanitized?: boolean;
36
+ /** The host's colour scheme, handed to every plugin that renders a node. */
37
+ theme?: string;
36
38
  }
37
39
  declare function renderNode(node: ASTNode, ctx: RenderContext): ReactNode;
38
40
  interface CardComponentProps {
@@ -51,15 +53,46 @@ interface AIRendererHandle {
51
53
  subscribeDebug: (listener: DebugEventListener) => () => void;
52
54
  push: (chunk: string) => void;
53
55
  feed: (source: FeedSource, options?: FeedOptions) => Promise<void>;
56
+ /**
57
+ * Export every drawing currently rendered, as PNG data URLs.
58
+ *
59
+ * The element the drawings live in belongs to the renderer, so a host offering "save this chart"
60
+ * would otherwise have to wrap it in one of its own just to find them.
61
+ */
62
+ exportImages: (options?: ExportImageOptions) => Promise<ExportedImage[]>;
54
63
  reset: () => void;
55
64
  }
56
65
  interface AIRendererProps {
66
+ /**
67
+ * The whole text to render, as a controlled component.
68
+ *
69
+ * Streaming a model's answer means re-rendering with a longer string, and only the added part
70
+ * is new. Working that out is the renderer's job: pass the full text on every update and it
71
+ * pushes the delta, or starts over when the new text is not a continuation of what it holds.
72
+ * Leave it undefined to drive the renderer through the imperative handle instead.
73
+ */
74
+ text?: string;
57
75
  registry?: CardRegistry;
58
76
  cardStore?: CardStore;
59
77
  sanitize?: RendererOptions["sanitize"];
60
78
  plugins?: AIGuiPlugin[];
61
79
  actionRuntime?: ActionRuntime;
62
80
  onCardAction?: RenderContext["onCardAction"];
81
+ /**
82
+ * Called with the nodes currently on screen, whenever they change.
83
+ *
84
+ * What a model produced is only knowable from the parsed nodes: a host that wants to offer
85
+ * "export this chart" or count the diagrams in an answer would otherwise have to watch the DOM
86
+ * for the elements a plugin happened to create.
87
+ */
88
+ onRender?: (nodes: ASTNode[]) => void;
89
+ /**
90
+ * The host's colour scheme, "light" or "dark" by convention, handed to every plugin.
91
+ *
92
+ * Charts and diagrams choose their own colours and cannot see the page they sit on, so an
93
+ * answer rendered on a dark page comes back with white plot areas until the host says so.
94
+ */
95
+ theme?: string;
63
96
  className?: string;
64
97
  debug?: RendererOptions["debug"];
65
98
  onDebugEvent?: RendererOptions["onDebugEvent"];
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as react0 from "react";
2
2
  import * as react1 from "react";
3
3
  import { ReactNode } from "react";
4
- import { AIGuiPlugin, ASTNode, ActionRuntime, ActionState, CardAction, CardRegistry, CardStore, DebugEventListener, FeedOptions, FeedSource, NodeRenderer, Patch, RenderOutput, Renderer, RendererOptions } from "@ai-gui/core";
4
+ import { AIGuiPlugin, ASTNode, ActionRuntime, ActionState, CardAction, CardRegistry, CardStore, DebugEventListener, ExportImageOptions, ExportedImage, 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 {
@@ -33,6 +33,8 @@ interface RenderContext {
33
33
  onCardAction?: (action: CardActionPayload) => void;
34
34
  sanitize?: RendererOptions["sanitize"];
35
35
  sanitized?: boolean;
36
+ /** The host's colour scheme, handed to every plugin that renders a node. */
37
+ theme?: string;
36
38
  }
37
39
  declare function renderNode(node: ASTNode, ctx: RenderContext): ReactNode;
38
40
  interface CardComponentProps {
@@ -51,15 +53,46 @@ interface AIRendererHandle {
51
53
  subscribeDebug: (listener: DebugEventListener) => () => void;
52
54
  push: (chunk: string) => void;
53
55
  feed: (source: FeedSource, options?: FeedOptions) => Promise<void>;
56
+ /**
57
+ * Export every drawing currently rendered, as PNG data URLs.
58
+ *
59
+ * The element the drawings live in belongs to the renderer, so a host offering "save this chart"
60
+ * would otherwise have to wrap it in one of its own just to find them.
61
+ */
62
+ exportImages: (options?: ExportImageOptions) => Promise<ExportedImage[]>;
54
63
  reset: () => void;
55
64
  }
56
65
  interface AIRendererProps {
66
+ /**
67
+ * The whole text to render, as a controlled component.
68
+ *
69
+ * Streaming a model's answer means re-rendering with a longer string, and only the added part
70
+ * is new. Working that out is the renderer's job: pass the full text on every update and it
71
+ * pushes the delta, or starts over when the new text is not a continuation of what it holds.
72
+ * Leave it undefined to drive the renderer through the imperative handle instead.
73
+ */
74
+ text?: string;
57
75
  registry?: CardRegistry;
58
76
  cardStore?: CardStore;
59
77
  sanitize?: RendererOptions["sanitize"];
60
78
  plugins?: AIGuiPlugin[];
61
79
  actionRuntime?: ActionRuntime;
62
80
  onCardAction?: RenderContext["onCardAction"];
81
+ /**
82
+ * Called with the nodes currently on screen, whenever they change.
83
+ *
84
+ * What a model produced is only knowable from the parsed nodes: a host that wants to offer
85
+ * "export this chart" or count the diagrams in an answer would otherwise have to watch the DOM
86
+ * for the elements a plugin happened to create.
87
+ */
88
+ onRender?: (nodes: ASTNode[]) => void;
89
+ /**
90
+ * The host's colour scheme, "light" or "dark" by convention, handed to every plugin.
91
+ *
92
+ * Charts and diagrams choose their own colours and cannot see the page they sit on, so an
93
+ * answer rendered on a dark page comes back with white plot areas until the host says so.
94
+ */
95
+ theme?: string;
63
96
  className?: string;
64
97
  debug?: RendererOptions["debug"];
65
98
  onDebugEvent?: RendererOptions["onDebugEvent"];
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Fragment, createElement, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
- import { Renderer, collectNodeRenderers, getIdleActionState, sanitizeHtml } from "@ai-gui/core";
2
+ import { Renderer, collectNodeRenderers, exportRenderedImages, getIdleActionState, sanitizeHtml, sanitizeRenderedHtml } from "@ai-gui/core";
3
3
  import { createPortal } from "react-dom";
4
4
  import { jsx, jsxs } from "react/jsx-runtime";
5
5
 
@@ -26,6 +26,7 @@ function applyPatches(nodes, patches) {
26
26
  function useAIRenderer(options = {}) {
27
27
  const [nodes, setNodes] = useState([]);
28
28
  const active = useRef(null);
29
+ const pluginList = useStableList(options.plugins);
29
30
  const session = useMemo(() => {
30
31
  const token = {};
31
32
  const renderer = new Renderer({
@@ -42,7 +43,7 @@ function useAIRenderer(options = {}) {
42
43
  }, [
43
44
  options.registry,
44
45
  options.sanitize,
45
- options.plugins,
46
+ pluginList,
46
47
  options.scheduler,
47
48
  options.debug,
48
49
  options.onDebugEvent
@@ -71,6 +72,14 @@ function useAIRenderer(options = {}) {
71
72
  reset
72
73
  };
73
74
  }
75
+ /** Keep the same array reference for as long as it holds the same members in the same order. */
76
+ function useStableList(list) {
77
+ const held = useRef(list);
78
+ const current = held.current;
79
+ const same = list === current || !!list && !!current && list.length === current.length && list.every((item, index) => item === current[index]);
80
+ if (!same) held.current = list;
81
+ return held.current;
82
+ }
74
83
 
75
84
  //#endregion
76
85
  //#region src/use-action-state.ts
@@ -93,7 +102,7 @@ function useActionState(runtime, key) {
93
102
  /** Translate a framework-neutral RenderOutput into React nodes. */
94
103
  function renderOutput(out, key, sanitize, context) {
95
104
  switch (out.kind) {
96
- case "html": return /* @__PURE__ */ jsx("div", { dangerouslySetInnerHTML: { __html: sanitizeOutput(out.html, sanitize) } }, key);
105
+ case "html": return /* @__PURE__ */ jsx("div", { dangerouslySetInnerHTML: { __html: sanitizeRenderedHtml(out.html, sanitize, out.trusted) } }, key);
97
106
  case "element": return createElement(out.tag, {
98
107
  key,
99
108
  ...out.props
@@ -193,10 +202,6 @@ function AsyncOutput({ promise, sanitize, context }) {
193
202
  return /* @__PURE__ */ jsx("span", { "data-aigui-async-error": true });
194
203
  }
195
204
  }
196
- function sanitizeOutput(html, sanitize) {
197
- if (sanitize === false) return html;
198
- return sanitizeHtml(html, typeof sanitize === "object" ? sanitize : void 0);
199
- }
200
205
 
201
206
  //#endregion
202
207
  //#region src/render-node.tsx
@@ -232,11 +237,13 @@ function renderNode(node, ctx) {
232
237
  function PluginOutputHost({ node, renderer, context }) {
233
238
  const cache = useRef();
234
239
  const signature = nodeSignature(node);
240
+ const theme = context.theme;
235
241
  try {
236
- if (!cache.current || cache.current.renderer !== renderer || cache.current.signature !== signature) cache.current = {
242
+ if (!cache.current || cache.current.renderer !== renderer || cache.current.signature !== signature || cache.current.theme !== theme) cache.current = {
237
243
  renderer,
238
244
  signature,
239
- output: renderer(node)
245
+ theme,
246
+ output: renderer(node, { theme })
240
247
  };
241
248
  const output = cache.current.output;
242
249
  if (output && typeof output.then === "function") return /* @__PURE__ */ jsx(AsyncOutput, {
@@ -350,7 +357,7 @@ function createActionScope() {
350
357
  };
351
358
  }
352
359
  const AIRenderer = forwardRef(function AIRenderer$1(props, ref) {
353
- const { registry, cardStore, sanitize, plugins, actionRuntime, onCardAction, className, debug, onDebugEvent } = props;
360
+ const { text, registry, cardStore, sanitize, plugins, actionRuntime, onCardAction, onRender, theme, className, debug, onDebugEvent } = props;
354
361
  const opts = {
355
362
  registry,
356
363
  sanitize,
@@ -360,6 +367,8 @@ const AIRenderer = forwardRef(function AIRenderer$1(props, ref) {
360
367
  };
361
368
  const { renderer, nodes, push, feed, reset: resetRenderer } = useAIRenderer(opts);
362
369
  const actionScope = useRef(createActionScope());
370
+ const rendered = useRef("");
371
+ const root = useRef(null);
363
372
  useEffect(() => () => {
364
373
  actionScope.current.controller.abort();
365
374
  actionScope.current = createActionScope();
@@ -372,8 +381,30 @@ const AIRenderer = forwardRef(function AIRenderer$1(props, ref) {
372
381
  const reset = useCallback(() => {
373
382
  actionScope.current.controller.abort();
374
383
  actionScope.current = createActionScope();
384
+ rendered.current = "";
375
385
  resetRenderer();
376
386
  }, [resetRenderer]);
387
+ useEffect(() => {
388
+ if (text === void 0 || text === rendered.current) return;
389
+ if (text.startsWith(rendered.current)) push(text.slice(rendered.current.length));
390
+ else {
391
+ reset();
392
+ push(text);
393
+ }
394
+ rendered.current = text;
395
+ }, [
396
+ text,
397
+ push,
398
+ reset
399
+ ]);
400
+ const onRenderRef = useRef(onRender);
401
+ onRenderRef.current = onRender;
402
+ useEffect(() => {
403
+ onRenderRef.current?.(nodes);
404
+ }, [nodes]);
405
+ useEffect(() => () => {
406
+ rendered.current = "";
407
+ }, [renderer]);
377
408
  const handleCardAction = useCallback((action) => {
378
409
  if (actionRuntime) {
379
410
  const scope = actionScope.current;
@@ -389,16 +420,19 @@ const AIRenderer = forwardRef(function AIRenderer$1(props, ref) {
389
420
  }
390
421
  onCardAction?.(action);
391
422
  }, [actionRuntime, onCardAction]);
423
+ const exportImages = useCallback((options) => root.current ? exportRenderedImages(root.current, options) : Promise.resolve([]), []);
392
424
  useImperativeHandle(ref, () => ({
393
425
  debugSource: "renderer",
394
426
  subscribeDebug: (listener) => renderer.subscribeDebug(listener),
395
427
  push,
396
428
  feed,
429
+ exportImages,
397
430
  reset
398
431
  }), [
399
432
  renderer,
400
433
  push,
401
434
  feed,
435
+ exportImages,
402
436
  reset
403
437
  ]);
404
438
  const nodeRenderers = useMemo(() => collectNodeRenderers(plugins, { debugTarget: renderer }), [plugins, renderer]);
@@ -409,11 +443,13 @@ const AIRenderer = forwardRef(function AIRenderer$1(props, ref) {
409
443
  nodeRenderers,
410
444
  onCardAction: handleCardAction,
411
445
  sanitize,
412
- sanitized: true
446
+ sanitized: true,
447
+ theme
413
448
  };
414
449
  return /* @__PURE__ */ jsx("div", {
415
450
  className,
416
451
  "data-aigui-renderer": true,
452
+ ref: root,
417
453
  children: nodes.map((n) => renderNode(n, ctx))
418
454
  });
419
455
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-gui/react",
3
- "version": "0.4.4",
3
+ "version": "0.6.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,7 +50,7 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@ai-gui/core": "0.4.4"
53
+ "@ai-gui/core": "0.6.0"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "react": ">=18",