@ai-gui/vanilla 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,24 +11,37 @@ pnpm add @ai-gui/core @ai-gui/vanilla
11
11
  ## Usage
12
12
 
13
13
  ```ts
14
- import { CardRegistry } from "@ai-gui/core"
14
+ import { ActionRegistry, CardRegistry, CardStore, createActionRuntime } from "@ai-gui/core"
15
15
  import { createRenderer } from "@ai-gui/vanilla"
16
16
 
17
17
  const registry = new CardRegistry()
18
18
  registry.register({
19
19
  type: "weather",
20
20
  description: "Weather summary",
21
- // card render = (data, { onAction }) => HTMLElement
22
- render: (data: any, { onAction }: any) => {
21
+ // HTMLElement remains supported. Return a VanillaCardInstance for stateful cards.
22
+ render: (data: any, { state, onAction }: any) => {
23
23
  const el = document.createElement("div")
24
24
  el.textContent = `${data.city} — ${data.tempC}°C`
25
- return el
25
+ return {
26
+ element: el,
27
+ update(next: any, context: any) {
28
+ el.textContent = `${next.city} — ${next.tempC}°C (${context.state.status})`
29
+ },
30
+ destroy() {},
31
+ }
26
32
  },
27
33
  })
28
34
 
35
+ const actions = new ActionRegistry()
36
+ actions.register({ type: "refresh", run: async (params, { signal }) => fetch("/api/weather", { signal }) })
37
+ const cardStore = new CardStore({ registry })
38
+ const actionRuntime = createActionRuntime({ registry: actions, cardStore })
39
+
29
40
  const r = createRenderer(document.getElementById("out")!, {
30
41
  registry,
31
- onCardAction: ({ type, params, cardType }) => {/* app makes the real request */},
42
+ cardStore,
43
+ actionRuntime,
44
+ onCardAction: (action) => console.log("observed", action),
32
45
  })
33
46
 
34
47
  await r.feed(response.body!) // r.push(chunk) / r.reset() / r.destroy()
@@ -36,6 +49,9 @@ await r.feed(response.body!) // r.push(chunk) / r.reset() / r.destroy()
36
49
 
37
50
  ## Exports
38
51
 
39
- - `createRenderer(el, { registry?, plugins?, sanitize?, onCardAction? })` → `{ push, feed, reset, destroy }`.
52
+ - `createRenderer(el, { registry?, cardStore?, plugins?, sanitize?, actionRuntime?, onCardAction? })` → `{ push, feed, reset, destroy }`.
53
+ - `VanillaCardInstance` is `{ element, update(data, { state, onAction }), destroy? }`. Its host and `element` stay mounted while AST data, store data, or action state changes.
54
+ - Legacy factories returning an `HTMLElement` remain supported. For cards with an `id` and a `cardStore`, the adapter may rebuild that child element after store updates so it displays current data.
55
+ - Cards with a valid top-level `id` register their initial data in the supplied external `CardStore`. The adapter subscribes but never clears that store; a shared store updates every renderer displaying the same card id.
40
56
 
41
57
  `destroy()` tears down the host and cleans up any mounted interactive widgets. See the [root README](../../README.md) for cards, plugins, and `buildSystemPrompt`.
package/dist/index.cjs CHANGED
@@ -26,7 +26,7 @@ const __ai_gui_core = __toESM(require("@ai-gui/core"));
26
26
 
27
27
  //#region src/render-output.ts
28
28
  /** Translate a framework-neutral RenderOutput (from a plugin node renderer) into a DOM element. */
29
- function renderOutputToElement(out, sanitize) {
29
+ function renderOutputToElement(out, sanitize, mountContext = {}) {
30
30
  switch (out.kind) {
31
31
  case "html": {
32
32
  const el = document.createElement("div");
@@ -37,7 +37,7 @@ function renderOutputToElement(out, sanitize) {
37
37
  const el = document.createElement(out.tag);
38
38
  for (const [key, value] of Object.entries(out.props ?? {})) if (key === "class" || key === "className") el.className = String(value);
39
39
  else el.setAttribute(key, String(value));
40
- for (const child of out.children ?? []) el.appendChild(renderOutputToElement(child, sanitize));
40
+ for (const child of out.children ?? []) el.appendChild(renderOutputToElement(child, sanitize, mountContext));
41
41
  return el;
42
42
  }
43
43
  case "card": {
@@ -53,7 +53,7 @@ function renderOutputToElement(out, sanitize) {
53
53
  el.setAttribute("data-aigui-mount", "");
54
54
  queueMicrotask(() => {
55
55
  if (el.__aiguiDisposed) return;
56
- const c = out.mount(el);
56
+ const c = out.mount(el, mountContext);
57
57
  if (typeof c === "function") if (el.__aiguiDisposed) c();
58
58
  else el.__aiguiCleanup = c;
59
59
  });
@@ -70,25 +70,33 @@ function sanitizeOutput(html, sanitize) {
70
70
  //#region src/render-node-dom.ts
71
71
  function renderNodeToElement(node, ctx) {
72
72
  const r = (ctx.nodeRenderers ?? (0, __ai_gui_core.collectNodeRenderers)(ctx.plugins))[node.type];
73
- if (r) try {
74
- const out = r(node);
75
- if (typeof out?.then === "function") {
76
- const host = document.createElement("div");
77
- host.setAttribute("data-aigui-async-pending", "");
78
- out.then((res) => {
79
- if (host.__aiguiDisposed) return;
80
- host.removeAttribute("data-aigui-async-pending");
81
- host.replaceChildren(renderOutputToElement(res, ctx.sanitize));
82
- }, () => {
83
- if (host.__aiguiDisposed) return;
84
- host.removeAttribute("data-aigui-async-pending");
85
- host.setAttribute("data-aigui-async-error", "");
86
- });
87
- return host;
73
+ if (r) {
74
+ if (node.complete === false) {
75
+ const loading = document.createElement("div");
76
+ loading.setAttribute("data-aigui-block-loading", "");
77
+ loading.setAttribute("data-block-type", node.type);
78
+ return loading;
79
+ }
80
+ try {
81
+ const out = r(node);
82
+ if (typeof out?.then === "function") {
83
+ const host = document.createElement("div");
84
+ host.setAttribute("data-aigui-async-pending", "");
85
+ out.then((res) => {
86
+ if (host.__aiguiDisposed) return;
87
+ host.removeAttribute("data-aigui-async-pending");
88
+ host.replaceChildren(renderOutputToElement(res, ctx.sanitize, createRenderMountContext(ctx)));
89
+ }, () => {
90
+ if (host.__aiguiDisposed) return;
91
+ host.removeAttribute("data-aigui-async-pending");
92
+ host.setAttribute("data-aigui-async-error", "");
93
+ });
94
+ return host;
95
+ }
96
+ return renderOutputToElement(out, ctx.sanitize, createRenderMountContext(ctx));
97
+ } catch {
98
+ return renderFallback(node, ctx);
88
99
  }
89
- return renderOutputToElement(out, ctx.sanitize);
90
- } catch {
91
- return renderFallback(node, ctx);
92
100
  }
93
101
  switch (node.type) {
94
102
  case "heading": {
@@ -146,22 +154,191 @@ function renderCardElement(node, ctx) {
146
154
  return pre;
147
155
  }
148
156
  const factory = ctx.registry?.getRender(card.type);
149
- if (!factory) {
150
- const pre = document.createElement("pre");
151
- pre.setAttribute("data-aigui-card-fallback", "");
152
- const c = document.createElement("code");
153
- c.textContent = JSON.stringify(card.data, null, 2);
154
- pre.appendChild(c);
155
- return pre;
156
- }
157
+ if (!factory) return createCardFallback(card, ctx);
157
158
  const host = document.createElement("div");
158
159
  host.setAttribute("data-aigui-card", card.type);
159
- host.appendChild(factory(card.data, { onAction: (a) => ctx.onCardAction?.({
160
- ...a,
161
- cardType: card.type
162
- }) }));
160
+ const controller = createCardController(host, card, factory, ctx);
161
+ host.__aiguiCardController = controller;
162
+ host.__aiguiCleanup = () => controller.destroy();
163
163
  return host;
164
164
  }
165
+ function createRenderMountContext(ctx) {
166
+ return { mountCard(host, request) {
167
+ return mountCardSlot(host, request, ctx);
168
+ } };
169
+ }
170
+ function mountCardSlot(host, request, ctx) {
171
+ const factory = ctx.registry?.getRender(request.type);
172
+ if (!factory) return void 0;
173
+ return createVanillaCardSlot(host, request.data, factory, () => ({
174
+ state: { status: "idle" },
175
+ onAction: (action) => ctx.onCardAction?.({
176
+ ...action,
177
+ cardType: request.type
178
+ })
179
+ }));
180
+ }
181
+ function createVanillaCardSlot(host, initialData, factory, context) {
182
+ let destroyed = false;
183
+ let instance;
184
+ let element;
185
+ const mount = (data) => {
186
+ const rendered = factory(data, context());
187
+ if (isVanillaCardInstance(rendered)) {
188
+ instance = rendered;
189
+ element = rendered.element;
190
+ } else {
191
+ instance = void 0;
192
+ element = rendered;
193
+ }
194
+ host.replaceChildren(element);
195
+ };
196
+ mount(initialData);
197
+ return {
198
+ element: () => element,
199
+ update(data) {
200
+ if (destroyed) return;
201
+ if (instance) instance.update(data, context());
202
+ else mount(data);
203
+ },
204
+ destroy() {
205
+ if (destroyed) return;
206
+ destroyed = true;
207
+ instance?.destroy?.();
208
+ instance = void 0;
209
+ }
210
+ };
211
+ }
212
+ function createCardFallback(card, ctx) {
213
+ const pre = document.createElement("pre");
214
+ pre.setAttribute("data-aigui-card-fallback", "");
215
+ const code = document.createElement("code");
216
+ pre.appendChild(code);
217
+ const render = (data) => {
218
+ pre.removeAttribute("data-aigui-card-missing");
219
+ code.textContent = JSON.stringify(data, null, 2);
220
+ };
221
+ const missing = () => {
222
+ pre.setAttribute("data-aigui-card-missing", "");
223
+ code.textContent = "Card data is unavailable";
224
+ };
225
+ let unsubscribe = () => {};
226
+ if (card.id !== void 0 && ctx.cardStore) {
227
+ try {
228
+ const record = ctx.cardStore.register({
229
+ id: card.id,
230
+ type: card.type,
231
+ data: card.data
232
+ });
233
+ render(record.data);
234
+ } catch (error) {
235
+ pre.setAttribute("data-aigui-card-store-error", "");
236
+ code.textContent = error instanceof Error ? error.message : "Card registration failed";
237
+ }
238
+ unsubscribe = ctx.cardStore.subscribe(card.id, (next) => {
239
+ if (!next || next.type !== card.type) missing();
240
+ else {
241
+ pre.removeAttribute("data-aigui-card-store-error");
242
+ render(next.data);
243
+ }
244
+ });
245
+ pre.__aiguiCleanup = unsubscribe;
246
+ } else render(card.data);
247
+ return pre;
248
+ }
249
+ function updateCardElement(el, node) {
250
+ return el.__aiguiCardController?.updateNode(node) ?? false;
251
+ }
252
+ function createCardController(host, initialCard, factory, ctx) {
253
+ let card = initialCard;
254
+ let destroyed = false;
255
+ let unsubscribe = () => {};
256
+ let slot;
257
+ let missing = false;
258
+ const id = card.id;
259
+ const onAction = (action) => ctx.onCardAction?.({
260
+ ...action,
261
+ cardType: card.type,
262
+ ...id === void 0 ? {} : { cardId: id }
263
+ });
264
+ let data = card.data;
265
+ let state = { status: "idle" };
266
+ let registrationError;
267
+ if (id !== void 0 && ctx.cardStore) try {
268
+ const record = ctx.cardStore.register({
269
+ id,
270
+ type: card.type,
271
+ data
272
+ });
273
+ data = record.data;
274
+ state = record.action;
275
+ } catch (error) {
276
+ registrationError = error;
277
+ }
278
+ const mount = (nextData) => {
279
+ slot = createVanillaCardSlot(host, nextData, factory, () => ({
280
+ state,
281
+ onAction
282
+ }));
283
+ };
284
+ const update = (nextData, nextState) => {
285
+ if (destroyed) return;
286
+ data = nextData;
287
+ state = nextState;
288
+ host.removeAttribute("data-aigui-card-store-error");
289
+ if (missing) {
290
+ missing = false;
291
+ host.removeAttribute("data-aigui-card-missing");
292
+ const element = slot?.element();
293
+ if (element) host.replaceChildren(element);
294
+ }
295
+ if (slot) slot.update(data);
296
+ else mount(data);
297
+ };
298
+ const showMissing = () => {
299
+ if (destroyed || missing) return;
300
+ missing = true;
301
+ host.removeAttribute("data-aigui-card-store-error");
302
+ host.setAttribute("data-aigui-card-missing", "");
303
+ const fallback = document.createElement("code");
304
+ fallback.textContent = "Card data is unavailable";
305
+ host.replaceChildren(fallback);
306
+ };
307
+ if (registrationError === void 0) mount(data);
308
+ else {
309
+ host.setAttribute("data-aigui-card-store-error", "");
310
+ const code = document.createElement("code");
311
+ code.textContent = registrationError instanceof Error ? registrationError.message : "Card registration failed";
312
+ host.replaceChildren(code);
313
+ }
314
+ if (id !== void 0 && ctx.cardStore) unsubscribe = ctx.cardStore.subscribe(id, (record) => {
315
+ if (!record || record.type !== card.type) {
316
+ showMissing();
317
+ return;
318
+ }
319
+ update(record.data, record.action);
320
+ });
321
+ return {
322
+ updateNode(node) {
323
+ const next = node.card;
324
+ if (!next?.complete || !next.valid || next.type !== card.type || next.id !== id) return false;
325
+ card = next;
326
+ if (id === void 0 || !ctx.cardStore) update(next.data, state);
327
+ return true;
328
+ },
329
+ destroy() {
330
+ if (destroyed) return;
331
+ destroyed = true;
332
+ unsubscribe();
333
+ unsubscribe = () => {};
334
+ slot?.destroy();
335
+ slot = void 0;
336
+ }
337
+ };
338
+ }
339
+ function isVanillaCardInstance(value) {
340
+ return !(value instanceof HTMLElement) && value !== null && typeof value === "object" && value.element instanceof HTMLElement && typeof value.update === "function";
341
+ }
165
342
 
166
343
  //#endregion
167
344
  //#region src/reconcile.ts
@@ -239,7 +416,7 @@ function updateElementInPlace(el, node, ctx) {
239
416
  if (el.tagName !== "DIV") return false;
240
417
  el.innerHTML = node.content ?? "";
241
418
  return true;
242
- case "card": return false;
419
+ case "card": return updateCardElement(el, node);
243
420
  default:
244
421
  if (el.tagName !== "DIV") return false;
245
422
  el.innerHTML = node.html ?? node.content ?? "";
@@ -250,14 +427,20 @@ function updateElementInPlace(el, node, ctx) {
250
427
  //#endregion
251
428
  //#region src/create-renderer.ts
252
429
  function createRenderer(el, options = {}) {
253
- const { onCardAction,...rendererOpts } = options;
254
- const ctx = {
255
- registry: options.registry,
256
- onCardAction,
257
- plugins: options.plugins,
258
- nodeRenderers: (0, __ai_gui_core.collectNodeRenderers)(options.plugins),
259
- sanitize: options.sanitize,
260
- sanitized: true
430
+ const { actionRuntime, cardStore, onCardAction,...rendererOpts } = options;
431
+ let actionScope = {
432
+ owner: {},
433
+ controller: new AbortController()
434
+ };
435
+ const handleCardAction = (action) => {
436
+ if (actionRuntime) actionRuntime.dispatch({
437
+ ...action,
438
+ params: action.params
439
+ }, {
440
+ owner: actionScope.owner,
441
+ signal: actionScope.controller.signal
442
+ }).catch(() => {});
443
+ onCardAction?.(action);
261
444
  };
262
445
  const state = createReconcileState();
263
446
  let destroyed = false;
@@ -267,16 +450,35 @@ function createRenderer(el, options = {}) {
267
450
  if (!destroyed) reconcile(el, nodes, ctx, state);
268
451
  }
269
452
  });
453
+ const ctx = {
454
+ registry: options.registry,
455
+ cardStore,
456
+ onCardAction: handleCardAction,
457
+ plugins: options.plugins,
458
+ nodeRenderers: (0, __ai_gui_core.collectNodeRenderers)(options.plugins, { debugTarget: renderer }),
459
+ sanitize: options.sanitize,
460
+ sanitized: true
461
+ };
270
462
  const disposeAll = () => {
271
463
  for (const entry of state.els.values()) disposeEl(entry.el);
272
464
  };
465
+ const resetActionScope = () => {
466
+ actionScope.controller.abort();
467
+ actionScope = {
468
+ owner: {},
469
+ controller: new AbortController()
470
+ };
471
+ };
273
472
  return {
473
+ debugSource: "renderer",
474
+ subscribeDebug: (listener) => renderer.subscribeDebug(listener),
274
475
  push: (c) => {
275
476
  if (!destroyed) renderer.push(c);
276
477
  },
277
478
  feed: (source, feedOptions) => destroyed ? Promise.resolve() : renderer.feed(source, feedOptions),
278
479
  reset: () => {
279
480
  if (!destroyed) {
481
+ resetActionScope();
280
482
  disposeAll();
281
483
  renderer.reset();
282
484
  state.els.clear();
@@ -286,6 +488,7 @@ function createRenderer(el, options = {}) {
286
488
  destroy: () => {
287
489
  if (!destroyed) {
288
490
  destroyed = true;
491
+ actionScope.controller.abort();
289
492
  renderer.reset();
290
493
  disposeAll();
291
494
  state.els.clear();
package/dist/index.d.cts CHANGED
@@ -1,12 +1,28 @@
1
- import { AIGuiPlugin, ASTNode, CardRegistry, FeedOptions, FeedSource, NodeRenderer, RenderOutput, RendererOptions } from "@ai-gui/core";
1
+ import { AIGuiPlugin, ASTNode, ActionRuntime, CardAction, CardRegistry, CardStore, DebugEventListener, FeedOptions, FeedSource, NodeRenderer, RenderMountContext, RenderOutput, RendererOptions } from "@ai-gui/core";
2
2
 
3
3
  //#region src/render-node-dom.d.ts
4
+ interface VanillaCardAction {
5
+ type: string;
6
+ params?: unknown;
7
+ }
8
+ interface VanillaCardUpdateContext {
9
+ state: CardAction;
10
+ onAction: (action: VanillaCardAction) => void;
11
+ }
12
+ interface VanillaCardInstance {
13
+ element: HTMLElement;
14
+ update: (data: unknown, context: VanillaCardUpdateContext) => void;
15
+ destroy?: () => void;
16
+ }
17
+ type VanillaCardFactory = (data: unknown, context: VanillaCardUpdateContext) => HTMLElement | VanillaCardInstance;
4
18
  interface DomRenderContext {
5
19
  registry?: CardRegistry;
20
+ cardStore?: CardStore;
6
21
  onCardAction?: (action: {
7
22
  type: string;
8
23
  params?: unknown;
9
24
  cardType: string;
25
+ cardId?: string;
10
26
  }) => void;
11
27
  plugins?: AIGuiPlugin[];
12
28
  nodeRenderers?: Record<string, NodeRenderer>;
@@ -18,9 +34,13 @@ declare function renderNodeToElement(node: ASTNode, ctx: DomRenderContext): HTML
18
34
  //#endregion
19
35
  //#region src/create-renderer.d.ts
20
36
  interface CreateRendererOptions extends Omit<RendererOptions, "onPatch"> {
37
+ actionRuntime?: ActionRuntime;
38
+ cardStore?: CardStore;
21
39
  onCardAction?: DomRenderContext["onCardAction"];
22
40
  }
23
41
  interface VanillaRenderer {
42
+ readonly debugSource: "renderer";
43
+ subscribeDebug: (listener: DebugEventListener) => () => void;
24
44
  push: (chunk: string) => void;
25
45
  feed: (source: FeedSource, options?: FeedOptions) => Promise<void>;
26
46
  reset: () => void;
@@ -31,7 +51,7 @@ declare function createRenderer(el: HTMLElement, options?: CreateRendererOptions
31
51
  //#endregion
32
52
  //#region src/render-output.d.ts
33
53
  /** Translate a framework-neutral RenderOutput (from a plugin node renderer) into a DOM element. */
34
- declare function renderOutputToElement(out: RenderOutput, sanitize?: RendererOptions["sanitize"]): HTMLElement;
54
+ declare function renderOutputToElement(out: RenderOutput, sanitize?: RendererOptions["sanitize"], mountContext?: RenderMountContext): HTMLElement;
35
55
 
36
56
  //#endregion
37
- export { CreateRendererOptions, DomRenderContext, VanillaRenderer, createRenderer, renderNodeToElement, renderOutputToElement };
57
+ export { CreateRendererOptions, DomRenderContext, VanillaCardAction, VanillaCardFactory, VanillaCardInstance, VanillaCardUpdateContext, VanillaRenderer, createRenderer, renderNodeToElement, renderOutputToElement };
package/dist/index.d.ts CHANGED
@@ -1,12 +1,28 @@
1
- import { AIGuiPlugin, ASTNode, CardRegistry, FeedOptions, FeedSource, NodeRenderer, RenderOutput, RendererOptions } from "@ai-gui/core";
1
+ import { AIGuiPlugin, ASTNode, ActionRuntime, CardAction, CardRegistry, CardStore, DebugEventListener, FeedOptions, FeedSource, NodeRenderer, RenderMountContext, RenderOutput, RendererOptions } from "@ai-gui/core";
2
2
 
3
3
  //#region src/render-node-dom.d.ts
4
+ interface VanillaCardAction {
5
+ type: string;
6
+ params?: unknown;
7
+ }
8
+ interface VanillaCardUpdateContext {
9
+ state: CardAction;
10
+ onAction: (action: VanillaCardAction) => void;
11
+ }
12
+ interface VanillaCardInstance {
13
+ element: HTMLElement;
14
+ update: (data: unknown, context: VanillaCardUpdateContext) => void;
15
+ destroy?: () => void;
16
+ }
17
+ type VanillaCardFactory = (data: unknown, context: VanillaCardUpdateContext) => HTMLElement | VanillaCardInstance;
4
18
  interface DomRenderContext {
5
19
  registry?: CardRegistry;
20
+ cardStore?: CardStore;
6
21
  onCardAction?: (action: {
7
22
  type: string;
8
23
  params?: unknown;
9
24
  cardType: string;
25
+ cardId?: string;
10
26
  }) => void;
11
27
  plugins?: AIGuiPlugin[];
12
28
  nodeRenderers?: Record<string, NodeRenderer>;
@@ -18,9 +34,13 @@ declare function renderNodeToElement(node: ASTNode, ctx: DomRenderContext): HTML
18
34
  //#endregion
19
35
  //#region src/create-renderer.d.ts
20
36
  interface CreateRendererOptions extends Omit<RendererOptions, "onPatch"> {
37
+ actionRuntime?: ActionRuntime;
38
+ cardStore?: CardStore;
21
39
  onCardAction?: DomRenderContext["onCardAction"];
22
40
  }
23
41
  interface VanillaRenderer {
42
+ readonly debugSource: "renderer";
43
+ subscribeDebug: (listener: DebugEventListener) => () => void;
24
44
  push: (chunk: string) => void;
25
45
  feed: (source: FeedSource, options?: FeedOptions) => Promise<void>;
26
46
  reset: () => void;
@@ -31,7 +51,7 @@ declare function createRenderer(el: HTMLElement, options?: CreateRendererOptions
31
51
  //#endregion
32
52
  //#region src/render-output.d.ts
33
53
  /** Translate a framework-neutral RenderOutput (from a plugin node renderer) into a DOM element. */
34
- declare function renderOutputToElement(out: RenderOutput, sanitize?: RendererOptions["sanitize"]): HTMLElement;
54
+ declare function renderOutputToElement(out: RenderOutput, sanitize?: RendererOptions["sanitize"], mountContext?: RenderMountContext): HTMLElement;
35
55
 
36
56
  //#endregion
37
- export { CreateRendererOptions, DomRenderContext, VanillaRenderer, createRenderer, renderNodeToElement, renderOutputToElement };
57
+ export { CreateRendererOptions, DomRenderContext, VanillaCardAction, VanillaCardFactory, VanillaCardInstance, VanillaCardUpdateContext, VanillaRenderer, createRenderer, renderNodeToElement, renderOutputToElement };
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { Renderer, collectNodeRenderers, sanitizeHtml } from "@ai-gui/core";
2
2
 
3
3
  //#region src/render-output.ts
4
4
  /** Translate a framework-neutral RenderOutput (from a plugin node renderer) into a DOM element. */
5
- function renderOutputToElement(out, sanitize) {
5
+ function renderOutputToElement(out, sanitize, mountContext = {}) {
6
6
  switch (out.kind) {
7
7
  case "html": {
8
8
  const el = document.createElement("div");
@@ -13,7 +13,7 @@ function renderOutputToElement(out, sanitize) {
13
13
  const el = document.createElement(out.tag);
14
14
  for (const [key, value] of Object.entries(out.props ?? {})) if (key === "class" || key === "className") el.className = String(value);
15
15
  else el.setAttribute(key, String(value));
16
- for (const child of out.children ?? []) el.appendChild(renderOutputToElement(child, sanitize));
16
+ for (const child of out.children ?? []) el.appendChild(renderOutputToElement(child, sanitize, mountContext));
17
17
  return el;
18
18
  }
19
19
  case "card": {
@@ -29,7 +29,7 @@ function renderOutputToElement(out, sanitize) {
29
29
  el.setAttribute("data-aigui-mount", "");
30
30
  queueMicrotask(() => {
31
31
  if (el.__aiguiDisposed) return;
32
- const c = out.mount(el);
32
+ const c = out.mount(el, mountContext);
33
33
  if (typeof c === "function") if (el.__aiguiDisposed) c();
34
34
  else el.__aiguiCleanup = c;
35
35
  });
@@ -46,25 +46,33 @@ function sanitizeOutput(html, sanitize) {
46
46
  //#region src/render-node-dom.ts
47
47
  function renderNodeToElement(node, ctx) {
48
48
  const r = (ctx.nodeRenderers ?? collectNodeRenderers(ctx.plugins))[node.type];
49
- if (r) try {
50
- const out = r(node);
51
- if (typeof out?.then === "function") {
52
- const host = document.createElement("div");
53
- host.setAttribute("data-aigui-async-pending", "");
54
- out.then((res) => {
55
- if (host.__aiguiDisposed) return;
56
- host.removeAttribute("data-aigui-async-pending");
57
- host.replaceChildren(renderOutputToElement(res, ctx.sanitize));
58
- }, () => {
59
- if (host.__aiguiDisposed) return;
60
- host.removeAttribute("data-aigui-async-pending");
61
- host.setAttribute("data-aigui-async-error", "");
62
- });
63
- return host;
49
+ if (r) {
50
+ if (node.complete === false) {
51
+ const loading = document.createElement("div");
52
+ loading.setAttribute("data-aigui-block-loading", "");
53
+ loading.setAttribute("data-block-type", node.type);
54
+ return loading;
55
+ }
56
+ try {
57
+ const out = r(node);
58
+ if (typeof out?.then === "function") {
59
+ const host = document.createElement("div");
60
+ host.setAttribute("data-aigui-async-pending", "");
61
+ out.then((res) => {
62
+ if (host.__aiguiDisposed) return;
63
+ host.removeAttribute("data-aigui-async-pending");
64
+ host.replaceChildren(renderOutputToElement(res, ctx.sanitize, createRenderMountContext(ctx)));
65
+ }, () => {
66
+ if (host.__aiguiDisposed) return;
67
+ host.removeAttribute("data-aigui-async-pending");
68
+ host.setAttribute("data-aigui-async-error", "");
69
+ });
70
+ return host;
71
+ }
72
+ return renderOutputToElement(out, ctx.sanitize, createRenderMountContext(ctx));
73
+ } catch {
74
+ return renderFallback(node, ctx);
64
75
  }
65
- return renderOutputToElement(out, ctx.sanitize);
66
- } catch {
67
- return renderFallback(node, ctx);
68
76
  }
69
77
  switch (node.type) {
70
78
  case "heading": {
@@ -122,22 +130,191 @@ function renderCardElement(node, ctx) {
122
130
  return pre;
123
131
  }
124
132
  const factory = ctx.registry?.getRender(card.type);
125
- if (!factory) {
126
- const pre = document.createElement("pre");
127
- pre.setAttribute("data-aigui-card-fallback", "");
128
- const c = document.createElement("code");
129
- c.textContent = JSON.stringify(card.data, null, 2);
130
- pre.appendChild(c);
131
- return pre;
132
- }
133
+ if (!factory) return createCardFallback(card, ctx);
133
134
  const host = document.createElement("div");
134
135
  host.setAttribute("data-aigui-card", card.type);
135
- host.appendChild(factory(card.data, { onAction: (a) => ctx.onCardAction?.({
136
- ...a,
137
- cardType: card.type
138
- }) }));
136
+ const controller = createCardController(host, card, factory, ctx);
137
+ host.__aiguiCardController = controller;
138
+ host.__aiguiCleanup = () => controller.destroy();
139
139
  return host;
140
140
  }
141
+ function createRenderMountContext(ctx) {
142
+ return { mountCard(host, request) {
143
+ return mountCardSlot(host, request, ctx);
144
+ } };
145
+ }
146
+ function mountCardSlot(host, request, ctx) {
147
+ const factory = ctx.registry?.getRender(request.type);
148
+ if (!factory) return void 0;
149
+ return createVanillaCardSlot(host, request.data, factory, () => ({
150
+ state: { status: "idle" },
151
+ onAction: (action) => ctx.onCardAction?.({
152
+ ...action,
153
+ cardType: request.type
154
+ })
155
+ }));
156
+ }
157
+ function createVanillaCardSlot(host, initialData, factory, context) {
158
+ let destroyed = false;
159
+ let instance;
160
+ let element;
161
+ const mount = (data) => {
162
+ const rendered = factory(data, context());
163
+ if (isVanillaCardInstance(rendered)) {
164
+ instance = rendered;
165
+ element = rendered.element;
166
+ } else {
167
+ instance = void 0;
168
+ element = rendered;
169
+ }
170
+ host.replaceChildren(element);
171
+ };
172
+ mount(initialData);
173
+ return {
174
+ element: () => element,
175
+ update(data) {
176
+ if (destroyed) return;
177
+ if (instance) instance.update(data, context());
178
+ else mount(data);
179
+ },
180
+ destroy() {
181
+ if (destroyed) return;
182
+ destroyed = true;
183
+ instance?.destroy?.();
184
+ instance = void 0;
185
+ }
186
+ };
187
+ }
188
+ function createCardFallback(card, ctx) {
189
+ const pre = document.createElement("pre");
190
+ pre.setAttribute("data-aigui-card-fallback", "");
191
+ const code = document.createElement("code");
192
+ pre.appendChild(code);
193
+ const render = (data) => {
194
+ pre.removeAttribute("data-aigui-card-missing");
195
+ code.textContent = JSON.stringify(data, null, 2);
196
+ };
197
+ const missing = () => {
198
+ pre.setAttribute("data-aigui-card-missing", "");
199
+ code.textContent = "Card data is unavailable";
200
+ };
201
+ let unsubscribe = () => {};
202
+ if (card.id !== void 0 && ctx.cardStore) {
203
+ try {
204
+ const record = ctx.cardStore.register({
205
+ id: card.id,
206
+ type: card.type,
207
+ data: card.data
208
+ });
209
+ render(record.data);
210
+ } catch (error) {
211
+ pre.setAttribute("data-aigui-card-store-error", "");
212
+ code.textContent = error instanceof Error ? error.message : "Card registration failed";
213
+ }
214
+ unsubscribe = ctx.cardStore.subscribe(card.id, (next) => {
215
+ if (!next || next.type !== card.type) missing();
216
+ else {
217
+ pre.removeAttribute("data-aigui-card-store-error");
218
+ render(next.data);
219
+ }
220
+ });
221
+ pre.__aiguiCleanup = unsubscribe;
222
+ } else render(card.data);
223
+ return pre;
224
+ }
225
+ function updateCardElement(el, node) {
226
+ return el.__aiguiCardController?.updateNode(node) ?? false;
227
+ }
228
+ function createCardController(host, initialCard, factory, ctx) {
229
+ let card = initialCard;
230
+ let destroyed = false;
231
+ let unsubscribe = () => {};
232
+ let slot;
233
+ let missing = false;
234
+ const id = card.id;
235
+ const onAction = (action) => ctx.onCardAction?.({
236
+ ...action,
237
+ cardType: card.type,
238
+ ...id === void 0 ? {} : { cardId: id }
239
+ });
240
+ let data = card.data;
241
+ let state = { status: "idle" };
242
+ let registrationError;
243
+ if (id !== void 0 && ctx.cardStore) try {
244
+ const record = ctx.cardStore.register({
245
+ id,
246
+ type: card.type,
247
+ data
248
+ });
249
+ data = record.data;
250
+ state = record.action;
251
+ } catch (error) {
252
+ registrationError = error;
253
+ }
254
+ const mount = (nextData) => {
255
+ slot = createVanillaCardSlot(host, nextData, factory, () => ({
256
+ state,
257
+ onAction
258
+ }));
259
+ };
260
+ const update = (nextData, nextState) => {
261
+ if (destroyed) return;
262
+ data = nextData;
263
+ state = nextState;
264
+ host.removeAttribute("data-aigui-card-store-error");
265
+ if (missing) {
266
+ missing = false;
267
+ host.removeAttribute("data-aigui-card-missing");
268
+ const element = slot?.element();
269
+ if (element) host.replaceChildren(element);
270
+ }
271
+ if (slot) slot.update(data);
272
+ else mount(data);
273
+ };
274
+ const showMissing = () => {
275
+ if (destroyed || missing) return;
276
+ missing = true;
277
+ host.removeAttribute("data-aigui-card-store-error");
278
+ host.setAttribute("data-aigui-card-missing", "");
279
+ const fallback = document.createElement("code");
280
+ fallback.textContent = "Card data is unavailable";
281
+ host.replaceChildren(fallback);
282
+ };
283
+ if (registrationError === void 0) mount(data);
284
+ else {
285
+ host.setAttribute("data-aigui-card-store-error", "");
286
+ const code = document.createElement("code");
287
+ code.textContent = registrationError instanceof Error ? registrationError.message : "Card registration failed";
288
+ host.replaceChildren(code);
289
+ }
290
+ if (id !== void 0 && ctx.cardStore) unsubscribe = ctx.cardStore.subscribe(id, (record) => {
291
+ if (!record || record.type !== card.type) {
292
+ showMissing();
293
+ return;
294
+ }
295
+ update(record.data, record.action);
296
+ });
297
+ return {
298
+ updateNode(node) {
299
+ const next = node.card;
300
+ if (!next?.complete || !next.valid || next.type !== card.type || next.id !== id) return false;
301
+ card = next;
302
+ if (id === void 0 || !ctx.cardStore) update(next.data, state);
303
+ return true;
304
+ },
305
+ destroy() {
306
+ if (destroyed) return;
307
+ destroyed = true;
308
+ unsubscribe();
309
+ unsubscribe = () => {};
310
+ slot?.destroy();
311
+ slot = void 0;
312
+ }
313
+ };
314
+ }
315
+ function isVanillaCardInstance(value) {
316
+ return !(value instanceof HTMLElement) && value !== null && typeof value === "object" && value.element instanceof HTMLElement && typeof value.update === "function";
317
+ }
141
318
 
142
319
  //#endregion
143
320
  //#region src/reconcile.ts
@@ -215,7 +392,7 @@ function updateElementInPlace(el, node, ctx) {
215
392
  if (el.tagName !== "DIV") return false;
216
393
  el.innerHTML = node.content ?? "";
217
394
  return true;
218
- case "card": return false;
395
+ case "card": return updateCardElement(el, node);
219
396
  default:
220
397
  if (el.tagName !== "DIV") return false;
221
398
  el.innerHTML = node.html ?? node.content ?? "";
@@ -226,14 +403,20 @@ function updateElementInPlace(el, node, ctx) {
226
403
  //#endregion
227
404
  //#region src/create-renderer.ts
228
405
  function createRenderer(el, options = {}) {
229
- const { onCardAction,...rendererOpts } = options;
230
- const ctx = {
231
- registry: options.registry,
232
- onCardAction,
233
- plugins: options.plugins,
234
- nodeRenderers: collectNodeRenderers(options.plugins),
235
- sanitize: options.sanitize,
236
- sanitized: true
406
+ const { actionRuntime, cardStore, onCardAction,...rendererOpts } = options;
407
+ let actionScope = {
408
+ owner: {},
409
+ controller: new AbortController()
410
+ };
411
+ const handleCardAction = (action) => {
412
+ if (actionRuntime) actionRuntime.dispatch({
413
+ ...action,
414
+ params: action.params
415
+ }, {
416
+ owner: actionScope.owner,
417
+ signal: actionScope.controller.signal
418
+ }).catch(() => {});
419
+ onCardAction?.(action);
237
420
  };
238
421
  const state = createReconcileState();
239
422
  let destroyed = false;
@@ -243,16 +426,35 @@ function createRenderer(el, options = {}) {
243
426
  if (!destroyed) reconcile(el, nodes, ctx, state);
244
427
  }
245
428
  });
429
+ const ctx = {
430
+ registry: options.registry,
431
+ cardStore,
432
+ onCardAction: handleCardAction,
433
+ plugins: options.plugins,
434
+ nodeRenderers: collectNodeRenderers(options.plugins, { debugTarget: renderer }),
435
+ sanitize: options.sanitize,
436
+ sanitized: true
437
+ };
246
438
  const disposeAll = () => {
247
439
  for (const entry of state.els.values()) disposeEl(entry.el);
248
440
  };
441
+ const resetActionScope = () => {
442
+ actionScope.controller.abort();
443
+ actionScope = {
444
+ owner: {},
445
+ controller: new AbortController()
446
+ };
447
+ };
249
448
  return {
449
+ debugSource: "renderer",
450
+ subscribeDebug: (listener) => renderer.subscribeDebug(listener),
250
451
  push: (c) => {
251
452
  if (!destroyed) renderer.push(c);
252
453
  },
253
454
  feed: (source, feedOptions) => destroyed ? Promise.resolve() : renderer.feed(source, feedOptions),
254
455
  reset: () => {
255
456
  if (!destroyed) {
457
+ resetActionScope();
256
458
  disposeAll();
257
459
  renderer.reset();
258
460
  state.els.clear();
@@ -262,6 +464,7 @@ function createRenderer(el, options = {}) {
262
464
  destroy: () => {
263
465
  if (!destroyed) {
264
466
  destroyed = true;
467
+ actionScope.controller.abort();
265
468
  renderer.reset();
266
469
  disposeAll();
267
470
  state.els.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-gui/vanilla",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Framework-free DOM adapter for AIGUI — stream LLM output straight into the DOM.",
5
5
  "keywords": [
6
6
  "llm",
@@ -50,7 +50,7 @@
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
  "scripts": {
56
56
  "build": "tsdown",