@ai-gui/react 0.4.4 → 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,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
@@ -256,11 +265,13 @@ function renderNode(node, ctx) {
256
265
  function PluginOutputHost({ node, renderer, context }) {
257
266
  const cache = (0, react.useRef)();
258
267
  const signature = nodeSignature(node);
268
+ const theme = context.theme;
259
269
  try {
260
- 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 = {
261
271
  renderer,
262
272
  signature,
263
- output: renderer(node)
273
+ theme,
274
+ output: renderer(node, { theme })
264
275
  };
265
276
  const output = cache.current.output;
266
277
  if (output && typeof output.then === "function") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AsyncOutput, {
@@ -374,7 +385,7 @@ function createActionScope() {
374
385
  };
375
386
  }
376
387
  const AIRenderer = (0, react.forwardRef)(function AIRenderer$1(props, ref) {
377
- 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;
378
389
  const opts = {
379
390
  registry,
380
391
  sanitize,
@@ -384,6 +395,7 @@ const AIRenderer = (0, react.forwardRef)(function AIRenderer$1(props, ref) {
384
395
  };
385
396
  const { renderer, nodes, push, feed, reset: resetRenderer } = useAIRenderer(opts);
386
397
  const actionScope = (0, react.useRef)(createActionScope());
398
+ const rendered = (0, react.useRef)("");
387
399
  (0, react.useEffect)(() => () => {
388
400
  actionScope.current.controller.abort();
389
401
  actionScope.current = createActionScope();
@@ -396,8 +408,30 @@ const AIRenderer = (0, react.forwardRef)(function AIRenderer$1(props, ref) {
396
408
  const reset = (0, react.useCallback)(() => {
397
409
  actionScope.current.controller.abort();
398
410
  actionScope.current = createActionScope();
411
+ rendered.current = "";
399
412
  resetRenderer();
400
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]);
401
435
  const handleCardAction = (0, react.useCallback)((action) => {
402
436
  if (actionRuntime) {
403
437
  const scope = actionScope.current;
@@ -433,7 +467,8 @@ 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,
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,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
@@ -232,11 +241,13 @@ function renderNode(node, ctx) {
232
241
  function PluginOutputHost({ node, renderer, context }) {
233
242
  const cache = useRef();
234
243
  const signature = nodeSignature(node);
244
+ const theme = context.theme;
235
245
  try {
236
- 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 = {
237
247
  renderer,
238
248
  signature,
239
- output: renderer(node)
249
+ theme,
250
+ output: renderer(node, { theme })
240
251
  };
241
252
  const output = cache.current.output;
242
253
  if (output && typeof output.then === "function") return /* @__PURE__ */ jsx(AsyncOutput, {
@@ -350,7 +361,7 @@ function createActionScope() {
350
361
  };
351
362
  }
352
363
  const AIRenderer = forwardRef(function AIRenderer$1(props, ref) {
353
- 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;
354
365
  const opts = {
355
366
  registry,
356
367
  sanitize,
@@ -360,6 +371,7 @@ const AIRenderer = forwardRef(function AIRenderer$1(props, ref) {
360
371
  };
361
372
  const { renderer, nodes, push, feed, reset: resetRenderer } = useAIRenderer(opts);
362
373
  const actionScope = useRef(createActionScope());
374
+ const rendered = useRef("");
363
375
  useEffect(() => () => {
364
376
  actionScope.current.controller.abort();
365
377
  actionScope.current = createActionScope();
@@ -372,8 +384,30 @@ const AIRenderer = forwardRef(function AIRenderer$1(props, ref) {
372
384
  const reset = useCallback(() => {
373
385
  actionScope.current.controller.abort();
374
386
  actionScope.current = createActionScope();
387
+ rendered.current = "";
375
388
  resetRenderer();
376
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]);
377
411
  const handleCardAction = useCallback((action) => {
378
412
  if (actionRuntime) {
379
413
  const scope = actionScope.current;
@@ -409,7 +443,8 @@ 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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-gui/react",
3
- "version": "0.4.4",
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.4"
53
+ "@ai-gui/core": "0.5.0"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "react": ">=18",