@ai-gui/react 0.4.2 → 0.5.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,12 +67,13 @@ 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
73
74
  ]);
74
75
  (0, react.useEffect)(() => {
76
+ active.current = session.token;
75
77
  setNodes([]);
76
78
  return () => {
77
79
  session.renderer.reset();
@@ -94,6 +96,14 @@ function useAIRenderer(options = {}) {
94
96
  reset
95
97
  };
96
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
+ }
97
107
 
98
108
  //#endregion
99
109
  //#region src/use-action-state.ts
@@ -255,11 +265,13 @@ function renderNode(node, ctx) {
255
265
  function PluginOutputHost({ node, renderer, context }) {
256
266
  const cache = (0, react.useRef)();
257
267
  const signature = nodeSignature(node);
268
+ const theme = context.theme;
258
269
  try {
259
- if (!cache.current || cache.current.renderer !== renderer || cache.current.signature !== signature) cache.current = {
270
+ if (!cache.current || cache.current.renderer !== renderer || cache.current.signature !== signature || cache.current.theme !== theme) cache.current = {
260
271
  renderer,
261
272
  signature,
262
- output: renderer(node)
273
+ theme,
274
+ output: renderer(node, { theme })
263
275
  };
264
276
  const output = cache.current.output;
265
277
  if (output && typeof output.then === "function") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AsyncOutput, {
@@ -373,7 +385,7 @@ function createActionScope() {
373
385
  };
374
386
  }
375
387
  const AIRenderer = (0, react.forwardRef)(function AIRenderer$1(props, ref) {
376
- const { registry, cardStore, sanitize, plugins, actionRuntime, onCardAction, className, debug, onDebugEvent } = props;
388
+ const { text, registry, cardStore, sanitize, plugins, actionRuntime, onCardAction, onRender, theme, className, debug, onDebugEvent } = props;
377
389
  const opts = {
378
390
  registry,
379
391
  sanitize,
@@ -383,6 +395,7 @@ const AIRenderer = (0, react.forwardRef)(function AIRenderer$1(props, ref) {
383
395
  };
384
396
  const { renderer, nodes, push, feed, reset: resetRenderer } = useAIRenderer(opts);
385
397
  const actionScope = (0, react.useRef)(createActionScope());
398
+ const rendered = (0, react.useRef)("");
386
399
  (0, react.useEffect)(() => () => {
387
400
  actionScope.current.controller.abort();
388
401
  actionScope.current = createActionScope();
@@ -395,8 +408,30 @@ const AIRenderer = (0, react.forwardRef)(function AIRenderer$1(props, ref) {
395
408
  const reset = (0, react.useCallback)(() => {
396
409
  actionScope.current.controller.abort();
397
410
  actionScope.current = createActionScope();
411
+ rendered.current = "";
398
412
  resetRenderer();
399
413
  }, [resetRenderer]);
414
+ (0, react.useEffect)(() => {
415
+ if (text === void 0 || text === rendered.current) return;
416
+ if (text.startsWith(rendered.current)) push(text.slice(rendered.current.length));
417
+ else {
418
+ reset();
419
+ push(text);
420
+ }
421
+ rendered.current = text;
422
+ }, [
423
+ text,
424
+ push,
425
+ reset
426
+ ]);
427
+ const onRenderRef = (0, react.useRef)(onRender);
428
+ onRenderRef.current = onRender;
429
+ (0, react.useEffect)(() => {
430
+ onRenderRef.current?.(nodes);
431
+ }, [nodes]);
432
+ (0, react.useEffect)(() => () => {
433
+ rendered.current = "";
434
+ }, [renderer]);
400
435
  const handleCardAction = (0, react.useCallback)((action) => {
401
436
  if (actionRuntime) {
402
437
  const scope = actionScope.current;
@@ -432,7 +467,8 @@ const AIRenderer = (0, react.forwardRef)(function AIRenderer$1(props, ref) {
432
467
  nodeRenderers,
433
468
  onCardAction: handleCardAction,
434
469
  sanitize,
435
- sanitized: true
470
+ sanitized: true,
471
+ theme
436
472
  };
437
473
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
438
474
  className,
package/dist/index.d.cts CHANGED
@@ -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 {
@@ -54,12 +56,36 @@ interface AIRendererHandle {
54
56
  reset: () => void;
55
57
  }
56
58
  interface AIRendererProps {
59
+ /**
60
+ * The whole text to render, as a controlled component.
61
+ *
62
+ * Streaming a model's answer means re-rendering with a longer string, and only the added part
63
+ * is new. Working that out is the renderer's job: pass the full text on every update and it
64
+ * pushes the delta, or starts over when the new text is not a continuation of what it holds.
65
+ * Leave it undefined to drive the renderer through the imperative handle instead.
66
+ */
67
+ text?: string;
57
68
  registry?: CardRegistry;
58
69
  cardStore?: CardStore;
59
70
  sanitize?: RendererOptions["sanitize"];
60
71
  plugins?: AIGuiPlugin[];
61
72
  actionRuntime?: ActionRuntime;
62
73
  onCardAction?: RenderContext["onCardAction"];
74
+ /**
75
+ * Called with the nodes currently on screen, whenever they change.
76
+ *
77
+ * What a model produced is only knowable from the parsed nodes: a host that wants to offer
78
+ * "export this chart" or count the diagrams in an answer would otherwise have to watch the DOM
79
+ * for the elements a plugin happened to create.
80
+ */
81
+ onRender?: (nodes: ASTNode[]) => void;
82
+ /**
83
+ * The host's colour scheme, "light" or "dark" by convention, handed to every plugin.
84
+ *
85
+ * Charts and diagrams choose their own colours and cannot see the page they sit on, so an
86
+ * answer rendered on a dark page comes back with white plot areas until the host says so.
87
+ */
88
+ theme?: string;
63
89
  className?: string;
64
90
  debug?: RendererOptions["debug"];
65
91
  onDebugEvent?: RendererOptions["onDebugEvent"];
package/dist/index.d.ts CHANGED
@@ -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 {
@@ -54,12 +56,36 @@ interface AIRendererHandle {
54
56
  reset: () => void;
55
57
  }
56
58
  interface AIRendererProps {
59
+ /**
60
+ * The whole text to render, as a controlled component.
61
+ *
62
+ * Streaming a model's answer means re-rendering with a longer string, and only the added part
63
+ * is new. Working that out is the renderer's job: pass the full text on every update and it
64
+ * pushes the delta, or starts over when the new text is not a continuation of what it holds.
65
+ * Leave it undefined to drive the renderer through the imperative handle instead.
66
+ */
67
+ text?: string;
57
68
  registry?: CardRegistry;
58
69
  cardStore?: CardStore;
59
70
  sanitize?: RendererOptions["sanitize"];
60
71
  plugins?: AIGuiPlugin[];
61
72
  actionRuntime?: ActionRuntime;
62
73
  onCardAction?: RenderContext["onCardAction"];
74
+ /**
75
+ * Called with the nodes currently on screen, whenever they change.
76
+ *
77
+ * What a model produced is only knowable from the parsed nodes: a host that wants to offer
78
+ * "export this chart" or count the diagrams in an answer would otherwise have to watch the DOM
79
+ * for the elements a plugin happened to create.
80
+ */
81
+ onRender?: (nodes: ASTNode[]) => void;
82
+ /**
83
+ * The host's colour scheme, "light" or "dark" by convention, handed to every plugin.
84
+ *
85
+ * Charts and diagrams choose their own colours and cannot see the page they sit on, so an
86
+ * answer rendered on a dark page comes back with white plot areas until the host says so.
87
+ */
88
+ theme?: string;
63
89
  className?: string;
64
90
  debug?: RendererOptions["debug"];
65
91
  onDebugEvent?: RendererOptions["onDebugEvent"];
package/dist/index.js CHANGED
@@ -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,12 +43,13 @@ 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
49
50
  ]);
50
51
  useEffect(() => {
52
+ active.current = session.token;
51
53
  setNodes([]);
52
54
  return () => {
53
55
  session.renderer.reset();
@@ -70,6 +72,14 @@ function useAIRenderer(options = {}) {
70
72
  reset
71
73
  };
72
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
+ }
73
83
 
74
84
  //#endregion
75
85
  //#region src/use-action-state.ts
@@ -231,11 +241,13 @@ function renderNode(node, ctx) {
231
241
  function PluginOutputHost({ node, renderer, context }) {
232
242
  const cache = useRef();
233
243
  const signature = nodeSignature(node);
244
+ const theme = context.theme;
234
245
  try {
235
- if (!cache.current || cache.current.renderer !== renderer || cache.current.signature !== signature) cache.current = {
246
+ if (!cache.current || cache.current.renderer !== renderer || cache.current.signature !== signature || cache.current.theme !== theme) cache.current = {
236
247
  renderer,
237
248
  signature,
238
- output: renderer(node)
249
+ theme,
250
+ output: renderer(node, { theme })
239
251
  };
240
252
  const output = cache.current.output;
241
253
  if (output && typeof output.then === "function") return /* @__PURE__ */ jsx(AsyncOutput, {
@@ -349,7 +361,7 @@ function createActionScope() {
349
361
  };
350
362
  }
351
363
  const AIRenderer = forwardRef(function AIRenderer$1(props, ref) {
352
- const { registry, cardStore, sanitize, plugins, actionRuntime, onCardAction, className, debug, onDebugEvent } = props;
364
+ const { text, registry, cardStore, sanitize, plugins, actionRuntime, onCardAction, onRender, theme, className, debug, onDebugEvent } = props;
353
365
  const opts = {
354
366
  registry,
355
367
  sanitize,
@@ -359,6 +371,7 @@ const AIRenderer = forwardRef(function AIRenderer$1(props, ref) {
359
371
  };
360
372
  const { renderer, nodes, push, feed, reset: resetRenderer } = useAIRenderer(opts);
361
373
  const actionScope = useRef(createActionScope());
374
+ const rendered = useRef("");
362
375
  useEffect(() => () => {
363
376
  actionScope.current.controller.abort();
364
377
  actionScope.current = createActionScope();
@@ -371,8 +384,30 @@ const AIRenderer = forwardRef(function AIRenderer$1(props, ref) {
371
384
  const reset = useCallback(() => {
372
385
  actionScope.current.controller.abort();
373
386
  actionScope.current = createActionScope();
387
+ rendered.current = "";
374
388
  resetRenderer();
375
389
  }, [resetRenderer]);
390
+ useEffect(() => {
391
+ if (text === void 0 || text === rendered.current) return;
392
+ if (text.startsWith(rendered.current)) push(text.slice(rendered.current.length));
393
+ else {
394
+ reset();
395
+ push(text);
396
+ }
397
+ rendered.current = text;
398
+ }, [
399
+ text,
400
+ push,
401
+ reset
402
+ ]);
403
+ const onRenderRef = useRef(onRender);
404
+ onRenderRef.current = onRender;
405
+ useEffect(() => {
406
+ onRenderRef.current?.(nodes);
407
+ }, [nodes]);
408
+ useEffect(() => () => {
409
+ rendered.current = "";
410
+ }, [renderer]);
376
411
  const handleCardAction = useCallback((action) => {
377
412
  if (actionRuntime) {
378
413
  const scope = actionScope.current;
@@ -408,7 +443,8 @@ const AIRenderer = forwardRef(function AIRenderer$1(props, ref) {
408
443
  nodeRenderers,
409
444
  onCardAction: handleCardAction,
410
445
  sanitize,
411
- sanitized: true
446
+ sanitized: true,
447
+ theme
412
448
  };
413
449
  return /* @__PURE__ */ jsx("div", {
414
450
  className,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-gui/react",
3
- "version": "0.4.2",
3
+ "version": "0.5.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.2"
53
+ "@ai-gui/core": "0.5.0"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "react": ">=18",