@ai-gui/vanilla 0.1.0 → 0.3.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,18 +26,18 @@ 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) {
29
+ function renderOutputToElement(out, sanitize) {
30
30
  switch (out.kind) {
31
31
  case "html": {
32
32
  const el = document.createElement("div");
33
- el.innerHTML = (0, __ai_gui_core.sanitizeHtml)(out.html);
33
+ el.innerHTML = sanitizeOutput(out.html, sanitize);
34
34
  return el;
35
35
  }
36
36
  case "element": {
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));
40
+ for (const child of out.children ?? []) el.appendChild(renderOutputToElement(child, sanitize));
41
41
  return el;
42
42
  }
43
43
  case "card": {
@@ -52,37 +52,61 @@ function renderOutputToElement(out) {
52
52
  const el = document.createElement("div");
53
53
  el.setAttribute("data-aigui-mount", "");
54
54
  queueMicrotask(() => {
55
+ if (el.__aiguiDisposed) return;
55
56
  const c = out.mount(el);
56
- if (typeof c === "function") el.__aiguiCleanup = c;
57
+ if (typeof c === "function") if (el.__aiguiDisposed) c();
58
+ else el.__aiguiCleanup = c;
57
59
  });
58
60
  return el;
59
61
  }
60
62
  }
61
63
  }
64
+ function sanitizeOutput(html, sanitize) {
65
+ if (sanitize === false) return html;
66
+ return (0, __ai_gui_core.sanitizeHtml)(html, typeof sanitize === "object" ? sanitize : void 0);
67
+ }
62
68
 
63
69
  //#endregion
64
70
  //#region src/render-node-dom.ts
65
71
  function renderNodeToElement(node, ctx) {
66
- const r = (0, __ai_gui_core.collectNodeRenderers)(ctx.plugins)[node.type];
72
+ const r = (ctx.nodeRenderers ?? (0, __ai_gui_core.collectNodeRenderers)(ctx.plugins))[node.type];
67
73
  if (r) {
68
- const out = r(node);
69
- if (typeof out?.then === "function") {
70
- const ph = document.createElement("div");
71
- ph.setAttribute("data-aigui-async-pending", "");
72
- out.then((res) => ph.replaceWith(renderOutputToElement(res)));
73
- return ph;
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));
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);
97
+ } catch {
98
+ return renderFallback(node, ctx);
74
99
  }
75
- return renderOutputToElement(out);
76
100
  }
77
101
  switch (node.type) {
78
102
  case "heading": {
79
103
  const el = document.createElement(node.tag ?? "h1");
80
- el.innerHTML = node.html ?? "";
104
+ el.innerHTML = renderHtml(node.html ?? "", ctx);
81
105
  return el;
82
106
  }
83
107
  case "paragraph": {
84
108
  const el = document.createElement("p");
85
- el.innerHTML = node.html ?? "";
109
+ el.innerHTML = renderHtml(node.html ?? "", ctx);
86
110
  return el;
87
111
  }
88
112
  case "code": {
@@ -96,17 +120,22 @@ function renderNodeToElement(node, ctx) {
96
120
  case "hr": return document.createElement("hr");
97
121
  case "html": {
98
122
  const el = document.createElement("div");
99
- el.innerHTML = node.content ?? "";
123
+ el.innerHTML = renderHtml(node.content ?? "", ctx);
100
124
  return el;
101
125
  }
102
126
  case "card": return renderCardElement(node, ctx);
103
- default: {
104
- const el = document.createElement("div");
105
- el.innerHTML = node.html ?? (0, __ai_gui_core.sanitizeHtml)(node.content ?? "");
106
- return el;
107
- }
127
+ default: return renderFallback(node, ctx);
108
128
  }
109
129
  }
130
+ function renderFallback(node, ctx) {
131
+ const el = document.createElement("div");
132
+ el.innerHTML = renderHtml(node.html ?? node.content ?? "", ctx);
133
+ return el;
134
+ }
135
+ function renderHtml(html, ctx) {
136
+ if (ctx.sanitized || ctx.sanitize === false) return html;
137
+ return (0, __ai_gui_core.sanitizeHtml)(html, typeof ctx.sanitize === "object" ? ctx.sanitize : void 0);
138
+ }
110
139
  function renderCardElement(node, ctx) {
111
140
  const card = node.card;
112
141
  if (!card) return document.createElement("div");
@@ -125,22 +154,153 @@ function renderCardElement(node, ctx) {
125
154
  return pre;
126
155
  }
127
156
  const factory = ctx.registry?.getRender(card.type);
128
- if (!factory) {
129
- const pre = document.createElement("pre");
130
- pre.setAttribute("data-aigui-card-fallback", "");
131
- const c = document.createElement("code");
132
- c.textContent = JSON.stringify(card.data, null, 2);
133
- pre.appendChild(c);
134
- return pre;
135
- }
157
+ if (!factory) return createCardFallback(card, ctx);
136
158
  const host = document.createElement("div");
137
159
  host.setAttribute("data-aigui-card", card.type);
138
- host.appendChild(factory(card.data, { onAction: (a) => ctx.onCardAction?.({
139
- ...a,
140
- cardType: card.type
141
- }) }));
160
+ const controller = createCardController(host, card, factory, ctx);
161
+ host.__aiguiCardController = controller;
162
+ host.__aiguiCleanup = () => controller.destroy();
142
163
  return host;
143
164
  }
165
+ function createCardFallback(card, ctx) {
166
+ const pre = document.createElement("pre");
167
+ pre.setAttribute("data-aigui-card-fallback", "");
168
+ const code = document.createElement("code");
169
+ pre.appendChild(code);
170
+ const render = (data) => {
171
+ pre.removeAttribute("data-aigui-card-missing");
172
+ code.textContent = JSON.stringify(data, null, 2);
173
+ };
174
+ const missing = () => {
175
+ pre.setAttribute("data-aigui-card-missing", "");
176
+ code.textContent = "Card data is unavailable";
177
+ };
178
+ let unsubscribe = () => {};
179
+ if (card.id !== void 0 && ctx.cardStore) {
180
+ try {
181
+ const record = ctx.cardStore.register({
182
+ id: card.id,
183
+ type: card.type,
184
+ data: card.data
185
+ });
186
+ render(record.data);
187
+ } catch (error) {
188
+ pre.setAttribute("data-aigui-card-store-error", "");
189
+ code.textContent = error instanceof Error ? error.message : "Card registration failed";
190
+ }
191
+ unsubscribe = ctx.cardStore.subscribe(card.id, (next) => {
192
+ if (!next || next.type !== card.type) missing();
193
+ else {
194
+ pre.removeAttribute("data-aigui-card-store-error");
195
+ render(next.data);
196
+ }
197
+ });
198
+ pre.__aiguiCleanup = unsubscribe;
199
+ } else render(card.data);
200
+ return pre;
201
+ }
202
+ function updateCardElement(el, node) {
203
+ return el.__aiguiCardController?.updateNode(node) ?? false;
204
+ }
205
+ function createCardController(host, initialCard, factory, ctx) {
206
+ let card = initialCard;
207
+ let destroyed = false;
208
+ let unsubscribe = () => {};
209
+ let instance;
210
+ let element;
211
+ let missing = false;
212
+ const id = card.id;
213
+ const onAction = (action) => ctx.onCardAction?.({
214
+ ...action,
215
+ cardType: card.type,
216
+ ...id === void 0 ? {} : { cardId: id }
217
+ });
218
+ let data = card.data;
219
+ let state = { status: "idle" };
220
+ let registrationError;
221
+ if (id !== void 0 && ctx.cardStore) try {
222
+ const record = ctx.cardStore.register({
223
+ id,
224
+ type: card.type,
225
+ data
226
+ });
227
+ data = record.data;
228
+ state = record.action;
229
+ } catch (error) {
230
+ registrationError = error;
231
+ }
232
+ const context = () => ({
233
+ state,
234
+ onAction
235
+ });
236
+ const mount = (nextData) => {
237
+ const rendered = factory(nextData, context());
238
+ if (isVanillaCardInstance(rendered)) {
239
+ instance = rendered;
240
+ element = rendered.element;
241
+ } else {
242
+ instance = void 0;
243
+ element = rendered;
244
+ }
245
+ host.replaceChildren(element);
246
+ };
247
+ const update = (nextData, nextState) => {
248
+ if (destroyed) return;
249
+ data = nextData;
250
+ state = nextState;
251
+ host.removeAttribute("data-aigui-card-store-error");
252
+ if (missing) {
253
+ missing = false;
254
+ host.removeAttribute("data-aigui-card-missing");
255
+ if (element) host.replaceChildren(element);
256
+ }
257
+ if (instance) instance.update(data, context());
258
+ else mount(data);
259
+ };
260
+ const showMissing = () => {
261
+ if (destroyed || missing) return;
262
+ missing = true;
263
+ host.removeAttribute("data-aigui-card-store-error");
264
+ host.setAttribute("data-aigui-card-missing", "");
265
+ const fallback = document.createElement("code");
266
+ fallback.textContent = "Card data is unavailable";
267
+ host.replaceChildren(fallback);
268
+ };
269
+ if (registrationError === void 0) mount(data);
270
+ else {
271
+ host.setAttribute("data-aigui-card-store-error", "");
272
+ const code = document.createElement("code");
273
+ code.textContent = registrationError instanceof Error ? registrationError.message : "Card registration failed";
274
+ host.replaceChildren(code);
275
+ }
276
+ if (id !== void 0 && ctx.cardStore) unsubscribe = ctx.cardStore.subscribe(id, (record) => {
277
+ if (!record || record.type !== card.type) {
278
+ showMissing();
279
+ return;
280
+ }
281
+ update(record.data, record.action);
282
+ });
283
+ return {
284
+ updateNode(node) {
285
+ const next = node.card;
286
+ if (!next?.complete || !next.valid || next.type !== card.type || next.id !== id) return false;
287
+ card = next;
288
+ if (id === void 0 || !ctx.cardStore) update(next.data, state);
289
+ return true;
290
+ },
291
+ destroy() {
292
+ if (destroyed) return;
293
+ destroyed = true;
294
+ unsubscribe();
295
+ unsubscribe = () => {};
296
+ instance?.destroy?.();
297
+ instance = void 0;
298
+ }
299
+ };
300
+ }
301
+ function isVanillaCardInstance(value) {
302
+ return !(value instanceof HTMLElement) && value !== null && typeof value === "object" && value.element instanceof HTMLElement && typeof value.update === "function";
303
+ }
144
304
 
145
305
  //#endregion
146
306
  //#region src/reconcile.ts
@@ -149,7 +309,15 @@ function createReconcileState() {
149
309
  }
150
310
  /** Run a mounted widget's cleanup (if any) before the element leaves the DOM. */
151
311
  function disposeEl(el) {
152
- el.__aiguiCleanup?.();
312
+ for (const child of el.querySelectorAll("*")) disposeManagedEl(child);
313
+ disposeManagedEl(el);
314
+ }
315
+ function disposeManagedEl(el) {
316
+ const managed = el;
317
+ if (managed.__aiguiDisposed) return;
318
+ managed.__aiguiDisposed = true;
319
+ managed.__aiguiCleanup?.();
320
+ managed.__aiguiCleanup = void 0;
153
321
  }
154
322
  function reconcile(container, nodes, ctx, state) {
155
323
  const nextKeys = new Set(nodes.map((n) => n.key));
@@ -184,6 +352,7 @@ function reconcile(container, nodes, ctx, state) {
184
352
  }
185
353
  /** Mutate an existing element to reflect the node without replacing it. Returns false when a fresh element is required. */
186
354
  function updateElementInPlace(el, node, ctx) {
355
+ if (ctx.nodeRenderers?.[node.type]) return false;
187
356
  switch (node.type) {
188
357
  case "heading":
189
358
  if (el.tagName !== (node.tag ?? "h1").toUpperCase()) return false;
@@ -209,10 +378,10 @@ function updateElementInPlace(el, node, ctx) {
209
378
  if (el.tagName !== "DIV") return false;
210
379
  el.innerHTML = node.content ?? "";
211
380
  return true;
212
- case "card": return false;
381
+ case "card": return updateCardElement(el, node);
213
382
  default:
214
383
  if (el.tagName !== "DIV") return false;
215
- el.innerHTML = node.html ?? (0, __ai_gui_core.sanitizeHtml)(node.content ?? "");
384
+ el.innerHTML = node.html ?? node.content ?? "";
216
385
  return true;
217
386
  }
218
387
  }
@@ -220,33 +389,73 @@ function updateElementInPlace(el, node, ctx) {
220
389
  //#endregion
221
390
  //#region src/create-renderer.ts
222
391
  function createRenderer(el, options = {}) {
223
- const { onCardAction,...rendererOpts } = options;
224
- const ctx = {
225
- registry: options.registry,
226
- onCardAction,
227
- plugins: options.plugins
392
+ const { actionRuntime, cardStore, onCardAction,...rendererOpts } = options;
393
+ let actionScope = {
394
+ owner: {},
395
+ controller: new AbortController()
396
+ };
397
+ const handleCardAction = (action) => {
398
+ if (actionRuntime) actionRuntime.dispatch({
399
+ ...action,
400
+ params: action.params
401
+ }, {
402
+ owner: actionScope.owner,
403
+ signal: actionScope.controller.signal
404
+ }).catch(() => {});
405
+ onCardAction?.(action);
228
406
  };
229
407
  const state = createReconcileState();
408
+ let destroyed = false;
230
409
  const renderer = new __ai_gui_core.Renderer({
231
410
  ...rendererOpts,
232
- onPatch: (_patches, nodes) => reconcile(el, nodes, ctx, state)
411
+ onPatch: (_patches, nodes) => {
412
+ if (!destroyed) reconcile(el, nodes, ctx, state);
413
+ }
233
414
  });
415
+ const ctx = {
416
+ registry: options.registry,
417
+ cardStore,
418
+ onCardAction: handleCardAction,
419
+ plugins: options.plugins,
420
+ nodeRenderers: (0, __ai_gui_core.collectNodeRenderers)(options.plugins, { debugTarget: renderer }),
421
+ sanitize: options.sanitize,
422
+ sanitized: true
423
+ };
234
424
  const disposeAll = () => {
235
425
  for (const entry of state.els.values()) disposeEl(entry.el);
236
426
  };
427
+ const resetActionScope = () => {
428
+ actionScope.controller.abort();
429
+ actionScope = {
430
+ owner: {},
431
+ controller: new AbortController()
432
+ };
433
+ };
237
434
  return {
238
- push: (c) => renderer.push(c),
239
- feed: (s) => renderer.feed(s),
435
+ debugSource: "renderer",
436
+ subscribeDebug: (listener) => renderer.subscribeDebug(listener),
437
+ push: (c) => {
438
+ if (!destroyed) renderer.push(c);
439
+ },
440
+ feed: (source, feedOptions) => destroyed ? Promise.resolve() : renderer.feed(source, feedOptions),
240
441
  reset: () => {
241
- disposeAll();
242
- renderer.reset();
243
- state.els.clear();
244
- el.replaceChildren();
442
+ if (!destroyed) {
443
+ resetActionScope();
444
+ disposeAll();
445
+ renderer.reset();
446
+ state.els.clear();
447
+ el.replaceChildren();
448
+ }
245
449
  },
246
450
  destroy: () => {
247
- disposeAll();
248
- state.els.clear();
249
- el.replaceChildren();
451
+ if (!destroyed) {
452
+ destroyed = true;
453
+ actionScope.controller.abort();
454
+ renderer.reset();
455
+ disposeAll();
456
+ state.els.clear();
457
+ el.replaceChildren();
458
+ }
250
459
  }
251
460
  };
252
461
  }
package/dist/index.d.cts CHANGED
@@ -1,25 +1,48 @@
1
- import { AIGuiPlugin, ASTNode, CardRegistry, RenderOutput, RendererOptions } from "@ai-gui/core";
1
+ import { AIGuiPlugin, ASTNode, ActionRuntime, CardAction, CardRegistry, CardStore, DebugEventListener, FeedOptions, FeedSource, NodeRenderer, 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[];
28
+ nodeRenderers?: Record<string, NodeRenderer>;
29
+ sanitize?: RendererOptions["sanitize"];
30
+ sanitized?: boolean;
12
31
  }
13
32
  declare function renderNodeToElement(node: ASTNode, ctx: DomRenderContext): HTMLElement;
14
33
 
15
34
  //#endregion
16
35
  //#region src/create-renderer.d.ts
17
36
  interface CreateRendererOptions extends Omit<RendererOptions, "onPatch"> {
37
+ actionRuntime?: ActionRuntime;
38
+ cardStore?: CardStore;
18
39
  onCardAction?: DomRenderContext["onCardAction"];
19
40
  }
20
41
  interface VanillaRenderer {
42
+ readonly debugSource: "renderer";
43
+ subscribeDebug: (listener: DebugEventListener) => () => void;
21
44
  push: (chunk: string) => void;
22
- feed: (source: AsyncIterable<string> | ReadableStream) => Promise<void>;
45
+ feed: (source: FeedSource, options?: FeedOptions) => Promise<void>;
23
46
  reset: () => void;
24
47
  destroy: () => void;
25
48
  }
@@ -28,7 +51,7 @@ declare function createRenderer(el: HTMLElement, options?: CreateRendererOptions
28
51
  //#endregion
29
52
  //#region src/render-output.d.ts
30
53
  /** Translate a framework-neutral RenderOutput (from a plugin node renderer) into a DOM element. */
31
- declare function renderOutputToElement(out: RenderOutput): HTMLElement;
54
+ declare function renderOutputToElement(out: RenderOutput, sanitize?: RendererOptions["sanitize"]): HTMLElement;
32
55
 
33
56
  //#endregion
34
- 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,25 +1,48 @@
1
- import { AIGuiPlugin, ASTNode, CardRegistry, RenderOutput, RendererOptions } from "@ai-gui/core";
1
+ import { AIGuiPlugin, ASTNode, ActionRuntime, CardAction, CardRegistry, CardStore, DebugEventListener, FeedOptions, FeedSource, NodeRenderer, 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[];
28
+ nodeRenderers?: Record<string, NodeRenderer>;
29
+ sanitize?: RendererOptions["sanitize"];
30
+ sanitized?: boolean;
12
31
  }
13
32
  declare function renderNodeToElement(node: ASTNode, ctx: DomRenderContext): HTMLElement;
14
33
 
15
34
  //#endregion
16
35
  //#region src/create-renderer.d.ts
17
36
  interface CreateRendererOptions extends Omit<RendererOptions, "onPatch"> {
37
+ actionRuntime?: ActionRuntime;
38
+ cardStore?: CardStore;
18
39
  onCardAction?: DomRenderContext["onCardAction"];
19
40
  }
20
41
  interface VanillaRenderer {
42
+ readonly debugSource: "renderer";
43
+ subscribeDebug: (listener: DebugEventListener) => () => void;
21
44
  push: (chunk: string) => void;
22
- feed: (source: AsyncIterable<string> | ReadableStream) => Promise<void>;
45
+ feed: (source: FeedSource, options?: FeedOptions) => Promise<void>;
23
46
  reset: () => void;
24
47
  destroy: () => void;
25
48
  }
@@ -28,7 +51,7 @@ declare function createRenderer(el: HTMLElement, options?: CreateRendererOptions
28
51
  //#endregion
29
52
  //#region src/render-output.d.ts
30
53
  /** Translate a framework-neutral RenderOutput (from a plugin node renderer) into a DOM element. */
31
- declare function renderOutputToElement(out: RenderOutput): HTMLElement;
54
+ declare function renderOutputToElement(out: RenderOutput, sanitize?: RendererOptions["sanitize"]): HTMLElement;
32
55
 
33
56
  //#endregion
34
- 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,18 +2,18 @@ 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) {
5
+ function renderOutputToElement(out, sanitize) {
6
6
  switch (out.kind) {
7
7
  case "html": {
8
8
  const el = document.createElement("div");
9
- el.innerHTML = sanitizeHtml(out.html);
9
+ el.innerHTML = sanitizeOutput(out.html, sanitize);
10
10
  return el;
11
11
  }
12
12
  case "element": {
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));
16
+ for (const child of out.children ?? []) el.appendChild(renderOutputToElement(child, sanitize));
17
17
  return el;
18
18
  }
19
19
  case "card": {
@@ -28,37 +28,61 @@ function renderOutputToElement(out) {
28
28
  const el = document.createElement("div");
29
29
  el.setAttribute("data-aigui-mount", "");
30
30
  queueMicrotask(() => {
31
+ if (el.__aiguiDisposed) return;
31
32
  const c = out.mount(el);
32
- if (typeof c === "function") el.__aiguiCleanup = c;
33
+ if (typeof c === "function") if (el.__aiguiDisposed) c();
34
+ else el.__aiguiCleanup = c;
33
35
  });
34
36
  return el;
35
37
  }
36
38
  }
37
39
  }
40
+ function sanitizeOutput(html, sanitize) {
41
+ if (sanitize === false) return html;
42
+ return sanitizeHtml(html, typeof sanitize === "object" ? sanitize : void 0);
43
+ }
38
44
 
39
45
  //#endregion
40
46
  //#region src/render-node-dom.ts
41
47
  function renderNodeToElement(node, ctx) {
42
- const r = collectNodeRenderers(ctx.plugins)[node.type];
48
+ const r = (ctx.nodeRenderers ?? collectNodeRenderers(ctx.plugins))[node.type];
43
49
  if (r) {
44
- const out = r(node);
45
- if (typeof out?.then === "function") {
46
- const ph = document.createElement("div");
47
- ph.setAttribute("data-aigui-async-pending", "");
48
- out.then((res) => ph.replaceWith(renderOutputToElement(res)));
49
- return ph;
50
- }
51
- return renderOutputToElement(out);
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));
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);
73
+ } catch {
74
+ return renderFallback(node, ctx);
75
+ }
52
76
  }
53
77
  switch (node.type) {
54
78
  case "heading": {
55
79
  const el = document.createElement(node.tag ?? "h1");
56
- el.innerHTML = node.html ?? "";
80
+ el.innerHTML = renderHtml(node.html ?? "", ctx);
57
81
  return el;
58
82
  }
59
83
  case "paragraph": {
60
84
  const el = document.createElement("p");
61
- el.innerHTML = node.html ?? "";
85
+ el.innerHTML = renderHtml(node.html ?? "", ctx);
62
86
  return el;
63
87
  }
64
88
  case "code": {
@@ -72,17 +96,22 @@ function renderNodeToElement(node, ctx) {
72
96
  case "hr": return document.createElement("hr");
73
97
  case "html": {
74
98
  const el = document.createElement("div");
75
- el.innerHTML = node.content ?? "";
99
+ el.innerHTML = renderHtml(node.content ?? "", ctx);
76
100
  return el;
77
101
  }
78
102
  case "card": return renderCardElement(node, ctx);
79
- default: {
80
- const el = document.createElement("div");
81
- el.innerHTML = node.html ?? sanitizeHtml(node.content ?? "");
82
- return el;
83
- }
103
+ default: return renderFallback(node, ctx);
84
104
  }
85
105
  }
106
+ function renderFallback(node, ctx) {
107
+ const el = document.createElement("div");
108
+ el.innerHTML = renderHtml(node.html ?? node.content ?? "", ctx);
109
+ return el;
110
+ }
111
+ function renderHtml(html, ctx) {
112
+ if (ctx.sanitized || ctx.sanitize === false) return html;
113
+ return sanitizeHtml(html, typeof ctx.sanitize === "object" ? ctx.sanitize : void 0);
114
+ }
86
115
  function renderCardElement(node, ctx) {
87
116
  const card = node.card;
88
117
  if (!card) return document.createElement("div");
@@ -101,22 +130,153 @@ function renderCardElement(node, ctx) {
101
130
  return pre;
102
131
  }
103
132
  const factory = ctx.registry?.getRender(card.type);
104
- if (!factory) {
105
- const pre = document.createElement("pre");
106
- pre.setAttribute("data-aigui-card-fallback", "");
107
- const c = document.createElement("code");
108
- c.textContent = JSON.stringify(card.data, null, 2);
109
- pre.appendChild(c);
110
- return pre;
111
- }
133
+ if (!factory) return createCardFallback(card, ctx);
112
134
  const host = document.createElement("div");
113
135
  host.setAttribute("data-aigui-card", card.type);
114
- host.appendChild(factory(card.data, { onAction: (a) => ctx.onCardAction?.({
115
- ...a,
116
- cardType: card.type
117
- }) }));
136
+ const controller = createCardController(host, card, factory, ctx);
137
+ host.__aiguiCardController = controller;
138
+ host.__aiguiCleanup = () => controller.destroy();
118
139
  return host;
119
140
  }
141
+ function createCardFallback(card, ctx) {
142
+ const pre = document.createElement("pre");
143
+ pre.setAttribute("data-aigui-card-fallback", "");
144
+ const code = document.createElement("code");
145
+ pre.appendChild(code);
146
+ const render = (data) => {
147
+ pre.removeAttribute("data-aigui-card-missing");
148
+ code.textContent = JSON.stringify(data, null, 2);
149
+ };
150
+ const missing = () => {
151
+ pre.setAttribute("data-aigui-card-missing", "");
152
+ code.textContent = "Card data is unavailable";
153
+ };
154
+ let unsubscribe = () => {};
155
+ if (card.id !== void 0 && ctx.cardStore) {
156
+ try {
157
+ const record = ctx.cardStore.register({
158
+ id: card.id,
159
+ type: card.type,
160
+ data: card.data
161
+ });
162
+ render(record.data);
163
+ } catch (error) {
164
+ pre.setAttribute("data-aigui-card-store-error", "");
165
+ code.textContent = error instanceof Error ? error.message : "Card registration failed";
166
+ }
167
+ unsubscribe = ctx.cardStore.subscribe(card.id, (next) => {
168
+ if (!next || next.type !== card.type) missing();
169
+ else {
170
+ pre.removeAttribute("data-aigui-card-store-error");
171
+ render(next.data);
172
+ }
173
+ });
174
+ pre.__aiguiCleanup = unsubscribe;
175
+ } else render(card.data);
176
+ return pre;
177
+ }
178
+ function updateCardElement(el, node) {
179
+ return el.__aiguiCardController?.updateNode(node) ?? false;
180
+ }
181
+ function createCardController(host, initialCard, factory, ctx) {
182
+ let card = initialCard;
183
+ let destroyed = false;
184
+ let unsubscribe = () => {};
185
+ let instance;
186
+ let element;
187
+ let missing = false;
188
+ const id = card.id;
189
+ const onAction = (action) => ctx.onCardAction?.({
190
+ ...action,
191
+ cardType: card.type,
192
+ ...id === void 0 ? {} : { cardId: id }
193
+ });
194
+ let data = card.data;
195
+ let state = { status: "idle" };
196
+ let registrationError;
197
+ if (id !== void 0 && ctx.cardStore) try {
198
+ const record = ctx.cardStore.register({
199
+ id,
200
+ type: card.type,
201
+ data
202
+ });
203
+ data = record.data;
204
+ state = record.action;
205
+ } catch (error) {
206
+ registrationError = error;
207
+ }
208
+ const context = () => ({
209
+ state,
210
+ onAction
211
+ });
212
+ const mount = (nextData) => {
213
+ const rendered = factory(nextData, context());
214
+ if (isVanillaCardInstance(rendered)) {
215
+ instance = rendered;
216
+ element = rendered.element;
217
+ } else {
218
+ instance = void 0;
219
+ element = rendered;
220
+ }
221
+ host.replaceChildren(element);
222
+ };
223
+ const update = (nextData, nextState) => {
224
+ if (destroyed) return;
225
+ data = nextData;
226
+ state = nextState;
227
+ host.removeAttribute("data-aigui-card-store-error");
228
+ if (missing) {
229
+ missing = false;
230
+ host.removeAttribute("data-aigui-card-missing");
231
+ if (element) host.replaceChildren(element);
232
+ }
233
+ if (instance) instance.update(data, context());
234
+ else mount(data);
235
+ };
236
+ const showMissing = () => {
237
+ if (destroyed || missing) return;
238
+ missing = true;
239
+ host.removeAttribute("data-aigui-card-store-error");
240
+ host.setAttribute("data-aigui-card-missing", "");
241
+ const fallback = document.createElement("code");
242
+ fallback.textContent = "Card data is unavailable";
243
+ host.replaceChildren(fallback);
244
+ };
245
+ if (registrationError === void 0) mount(data);
246
+ else {
247
+ host.setAttribute("data-aigui-card-store-error", "");
248
+ const code = document.createElement("code");
249
+ code.textContent = registrationError instanceof Error ? registrationError.message : "Card registration failed";
250
+ host.replaceChildren(code);
251
+ }
252
+ if (id !== void 0 && ctx.cardStore) unsubscribe = ctx.cardStore.subscribe(id, (record) => {
253
+ if (!record || record.type !== card.type) {
254
+ showMissing();
255
+ return;
256
+ }
257
+ update(record.data, record.action);
258
+ });
259
+ return {
260
+ updateNode(node) {
261
+ const next = node.card;
262
+ if (!next?.complete || !next.valid || next.type !== card.type || next.id !== id) return false;
263
+ card = next;
264
+ if (id === void 0 || !ctx.cardStore) update(next.data, state);
265
+ return true;
266
+ },
267
+ destroy() {
268
+ if (destroyed) return;
269
+ destroyed = true;
270
+ unsubscribe();
271
+ unsubscribe = () => {};
272
+ instance?.destroy?.();
273
+ instance = void 0;
274
+ }
275
+ };
276
+ }
277
+ function isVanillaCardInstance(value) {
278
+ return !(value instanceof HTMLElement) && value !== null && typeof value === "object" && value.element instanceof HTMLElement && typeof value.update === "function";
279
+ }
120
280
 
121
281
  //#endregion
122
282
  //#region src/reconcile.ts
@@ -125,7 +285,15 @@ function createReconcileState() {
125
285
  }
126
286
  /** Run a mounted widget's cleanup (if any) before the element leaves the DOM. */
127
287
  function disposeEl(el) {
128
- el.__aiguiCleanup?.();
288
+ for (const child of el.querySelectorAll("*")) disposeManagedEl(child);
289
+ disposeManagedEl(el);
290
+ }
291
+ function disposeManagedEl(el) {
292
+ const managed = el;
293
+ if (managed.__aiguiDisposed) return;
294
+ managed.__aiguiDisposed = true;
295
+ managed.__aiguiCleanup?.();
296
+ managed.__aiguiCleanup = void 0;
129
297
  }
130
298
  function reconcile(container, nodes, ctx, state) {
131
299
  const nextKeys = new Set(nodes.map((n) => n.key));
@@ -160,6 +328,7 @@ function reconcile(container, nodes, ctx, state) {
160
328
  }
161
329
  /** Mutate an existing element to reflect the node without replacing it. Returns false when a fresh element is required. */
162
330
  function updateElementInPlace(el, node, ctx) {
331
+ if (ctx.nodeRenderers?.[node.type]) return false;
163
332
  switch (node.type) {
164
333
  case "heading":
165
334
  if (el.tagName !== (node.tag ?? "h1").toUpperCase()) return false;
@@ -185,10 +354,10 @@ function updateElementInPlace(el, node, ctx) {
185
354
  if (el.tagName !== "DIV") return false;
186
355
  el.innerHTML = node.content ?? "";
187
356
  return true;
188
- case "card": return false;
357
+ case "card": return updateCardElement(el, node);
189
358
  default:
190
359
  if (el.tagName !== "DIV") return false;
191
- el.innerHTML = node.html ?? sanitizeHtml(node.content ?? "");
360
+ el.innerHTML = node.html ?? node.content ?? "";
192
361
  return true;
193
362
  }
194
363
  }
@@ -196,33 +365,73 @@ function updateElementInPlace(el, node, ctx) {
196
365
  //#endregion
197
366
  //#region src/create-renderer.ts
198
367
  function createRenderer(el, options = {}) {
199
- const { onCardAction,...rendererOpts } = options;
200
- const ctx = {
201
- registry: options.registry,
202
- onCardAction,
203
- plugins: options.plugins
368
+ const { actionRuntime, cardStore, onCardAction,...rendererOpts } = options;
369
+ let actionScope = {
370
+ owner: {},
371
+ controller: new AbortController()
372
+ };
373
+ const handleCardAction = (action) => {
374
+ if (actionRuntime) actionRuntime.dispatch({
375
+ ...action,
376
+ params: action.params
377
+ }, {
378
+ owner: actionScope.owner,
379
+ signal: actionScope.controller.signal
380
+ }).catch(() => {});
381
+ onCardAction?.(action);
204
382
  };
205
383
  const state = createReconcileState();
384
+ let destroyed = false;
206
385
  const renderer = new Renderer({
207
386
  ...rendererOpts,
208
- onPatch: (_patches, nodes) => reconcile(el, nodes, ctx, state)
387
+ onPatch: (_patches, nodes) => {
388
+ if (!destroyed) reconcile(el, nodes, ctx, state);
389
+ }
209
390
  });
391
+ const ctx = {
392
+ registry: options.registry,
393
+ cardStore,
394
+ onCardAction: handleCardAction,
395
+ plugins: options.plugins,
396
+ nodeRenderers: collectNodeRenderers(options.plugins, { debugTarget: renderer }),
397
+ sanitize: options.sanitize,
398
+ sanitized: true
399
+ };
210
400
  const disposeAll = () => {
211
401
  for (const entry of state.els.values()) disposeEl(entry.el);
212
402
  };
403
+ const resetActionScope = () => {
404
+ actionScope.controller.abort();
405
+ actionScope = {
406
+ owner: {},
407
+ controller: new AbortController()
408
+ };
409
+ };
213
410
  return {
214
- push: (c) => renderer.push(c),
215
- feed: (s) => renderer.feed(s),
411
+ debugSource: "renderer",
412
+ subscribeDebug: (listener) => renderer.subscribeDebug(listener),
413
+ push: (c) => {
414
+ if (!destroyed) renderer.push(c);
415
+ },
416
+ feed: (source, feedOptions) => destroyed ? Promise.resolve() : renderer.feed(source, feedOptions),
216
417
  reset: () => {
217
- disposeAll();
218
- renderer.reset();
219
- state.els.clear();
220
- el.replaceChildren();
418
+ if (!destroyed) {
419
+ resetActionScope();
420
+ disposeAll();
421
+ renderer.reset();
422
+ state.els.clear();
423
+ el.replaceChildren();
424
+ }
221
425
  },
222
426
  destroy: () => {
223
- disposeAll();
224
- state.els.clear();
225
- el.replaceChildren();
427
+ if (!destroyed) {
428
+ destroyed = true;
429
+ actionScope.controller.abort();
430
+ renderer.reset();
431
+ disposeAll();
432
+ state.els.clear();
433
+ el.replaceChildren();
434
+ }
226
435
  }
227
436
  };
228
437
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-gui/vanilla",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Framework-free DOM adapter for AIGUI — stream LLM output straight into the DOM.",
5
5
  "keywords": [
6
6
  "llm",
@@ -22,14 +22,23 @@
22
22
  "homepage": "https://github.com/liliang-cn/aigui#readme",
23
23
  "bugs": "https://github.com/liliang-cn/aigui/issues",
24
24
  "type": "module",
25
+ "sideEffects": false,
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
25
29
  "main": "./dist/index.cjs",
26
30
  "module": "./dist/index.js",
27
31
  "types": "./dist/index.d.ts",
28
32
  "exports": {
29
33
  ".": {
30
- "types": "./dist/index.d.ts",
31
- "import": "./dist/index.js",
32
- "require": "./dist/index.cjs"
34
+ "import": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ },
38
+ "require": {
39
+ "types": "./dist/index.d.cts",
40
+ "default": "./dist/index.cjs"
41
+ }
33
42
  }
34
43
  },
35
44
  "files": [
@@ -41,10 +50,11 @@
41
50
  "access": "public"
42
51
  },
43
52
  "dependencies": {
44
- "@ai-gui/core": "0.1.0"
53
+ "@ai-gui/core": "0.3.0"
45
54
  },
46
55
  "scripts": {
47
56
  "build": "tsdown",
57
+ "test": "pnpm --dir ../.. exec vitest run --project vanilla",
48
58
  "typecheck": "tsc --noEmit"
49
59
  }
50
60
  }