@ai-gui/vue 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
@@ -13,13 +13,18 @@ pnpm add @ai-gui/core @ai-gui/vue
13
13
  ```vue
14
14
  <script setup lang="ts">
15
15
  import { ref } from "vue"
16
- import { CardRegistry } from "@ai-gui/core"
17
- import { AIRenderer } from "@ai-gui/vue"
16
+ import { ActionRegistry, CardRegistry, CardStore, createActionRuntime } from "@ai-gui/core"
17
+ import { AIRenderer, useActionState } from "@ai-gui/vue"
18
18
 
19
19
  const registry = new CardRegistry()
20
20
  // A card render component receives props `data` and emits `action`.
21
21
  registry.register({ type: "weather", description: "Weather summary", render: WeatherCard })
22
22
 
23
+ const actions = new ActionRegistry()
24
+ actions.register({ type: "refresh", run: async (params, { signal }) => fetch("/api/weather", { signal }) })
25
+ const cardStore = new CardStore({ registry })
26
+ const actionRuntime = createActionRuntime({ registry: actions, cardStore })
27
+
23
28
  const r = ref<InstanceType<typeof AIRenderer>>()
24
29
 
25
30
  async function ask() {
@@ -30,13 +35,16 @@ async function ask() {
30
35
  </script>
31
36
 
32
37
  <template>
33
- <AIRenderer ref="r" :registry="registry" @card-action="(a) => {/* app makes the real request */}" />
38
+ <AIRenderer ref="r" :registry="registry" :card-store="cardStore" :action-runtime="actionRuntime" @card-action="(a) => console.log(a)" />
34
39
  </template>
35
40
  ```
36
41
 
37
42
  ## Exports
38
43
 
39
- - `<AIRenderer :registry :plugins :sanitize @card-action />` — a render-function component; imperative `push` / `feed` / `reset` via a template ref (exposed).
44
+ - `<AIRenderer :registry :card-store :plugins :sanitize :action-runtime @card-action />` — a render-function component; imperative `push` / `feed` / `reset` via a template ref (exposed).
40
45
  - `useAIRenderer()` — composable returning `{ nodes, push, feed, reset }`.
46
+ - `useActionState(runtime, key)` — reactive action lifecycle state.
47
+
48
+ Cards with a top-level `id` subscribe to the supplied `CardStore`. Their Vue component receives `data` and `state` props and emits `action`; store updates preserve the component instance and local refs.
41
49
 
42
50
  Card component contract: props `data`, `emits: ['action']`. See the [root README](../../README.md) for cards, plugins, and `buildSystemPrompt`.
package/dist/index.cjs CHANGED
@@ -28,24 +28,46 @@ const __ai_gui_core = __toESM(require("@ai-gui/core"));
28
28
  //#region src/use-ai-renderer.ts
29
29
  function useAIRenderer(options = {}) {
30
30
  const nodes = (0, vue.shallowRef)([]);
31
+ let active = true;
31
32
  const renderer = new __ai_gui_core.Renderer({
32
33
  ...options,
33
34
  plugins: options.plugins,
34
35
  onPatch: (_patches, snapshot) => {
35
- nodes.value = snapshot;
36
+ if (active) nodes.value = snapshot;
36
37
  }
37
38
  });
38
39
  return {
40
+ renderer,
39
41
  nodes,
40
- push: (c) => renderer.push(c),
41
- feed: (s) => renderer.feed(s),
42
+ push: (c) => {
43
+ if (active) renderer.push(c);
44
+ },
45
+ feed: (source, feedOptions) => active ? renderer.feed(source, feedOptions) : Promise.resolve(),
42
46
  reset: () => {
47
+ if (active) {
48
+ renderer.reset();
49
+ nodes.value = [];
50
+ }
51
+ },
52
+ destroy: () => {
53
+ active = false;
43
54
  renderer.reset();
44
55
  nodes.value = [];
45
56
  }
46
57
  };
47
58
  }
48
59
 
60
+ //#endregion
61
+ //#region src/use-action-state.ts
62
+ function useActionState(runtime, key) {
63
+ const state = (0, vue.shallowRef)(runtime.getState(key));
64
+ const unsubscribe = runtime.subscribe((next) => {
65
+ if (next.key === key) state.value = next;
66
+ });
67
+ (0, vue.onScopeDispose)(unsubscribe);
68
+ return state;
69
+ }
70
+
49
71
  //#endregion
50
72
  //#region src/render-output.ts
51
73
  /** Host element for imperative `mount` outputs, wired to Vue's lifecycle. */
@@ -61,6 +83,10 @@ const MountHost = (0, vue.defineComponent)({
61
83
  (0, vue.onMounted)(() => {
62
84
  if (elRef.value) cleanup = props.mount(elRef.value);
63
85
  });
86
+ (0, vue.watch)(() => props.mount, (mount) => {
87
+ if (typeof cleanup === "function") cleanup();
88
+ cleanup = elRef.value ? mount(elRef.value) : void 0;
89
+ });
64
90
  (0, vue.onBeforeUnmount)(() => {
65
91
  if (typeof cleanup === "function") cleanup();
66
92
  });
@@ -71,10 +97,10 @@ const MountHost = (0, vue.defineComponent)({
71
97
  }
72
98
  });
73
99
  /** Translate a framework-neutral RenderOutput into a Vue VNode. */
74
- function renderOutput(out) {
100
+ function renderOutput(out, sanitize) {
75
101
  switch (out.kind) {
76
- case "html": return (0, vue.h)("div", { innerHTML: (0, __ai_gui_core.sanitizeHtml)(out.html) });
77
- case "element": return (0, vue.h)(out.tag, out.props, (out.children ?? []).map(renderOutput));
102
+ case "html": return (0, vue.h)("div", { innerHTML: sanitizeOutput(out.html, sanitize) });
103
+ case "element": return (0, vue.h)(out.tag, out.props, (out.children ?? []).map((child) => renderOutput(child, sanitize)));
78
104
  case "mount": return (0, vue.h)(MountHost, { mount: out.mount });
79
105
  case "card": return (0, vue.h)("pre", { "data-aigui-card-fallback": "" }, [(0, vue.h)("code", JSON.stringify(out.data, null, 2))]);
80
106
  }
@@ -82,39 +108,84 @@ function renderOutput(out) {
82
108
  /** Await an async RenderOutput, rendering a placeholder until it resolves. */
83
109
  const AsyncOutput = (0, vue.defineComponent)({
84
110
  name: "AsyncOutput",
85
- props: { promise: {
86
- type: Object,
87
- required: true
88
- } },
111
+ props: {
112
+ promise: {
113
+ type: Object,
114
+ required: true
115
+ },
116
+ sanitize: {
117
+ type: [Boolean, Object],
118
+ default: void 0
119
+ }
120
+ },
89
121
  setup(props) {
90
122
  const resolved = (0, vue.ref)(null);
91
- props.promise.then((v) => {
92
- resolved.value = v;
123
+ const failed = (0, vue.ref)(false);
124
+ let generation = 0;
125
+ let active = true;
126
+ const awaitPromise = (promise) => {
127
+ const current = ++generation;
128
+ resolved.value = null;
129
+ failed.value = false;
130
+ promise.then((value) => {
131
+ if (active && current === generation) resolved.value = value;
132
+ }, () => {
133
+ if (active && current === generation) failed.value = true;
134
+ });
135
+ };
136
+ (0, vue.watch)(() => [(0, vue.toRaw)(props.promise)], ([promise]) => awaitPromise(promise), { immediate: true });
137
+ (0, vue.onBeforeUnmount)(() => {
138
+ active = false;
139
+ generation++;
93
140
  });
94
- return () => resolved.value ? renderOutput(resolved.value) : (0, vue.h)("span", { "data-aigui-async-pending": "" });
141
+ return () => {
142
+ if (failed.value) return (0, vue.h)("span", { "data-aigui-async-error": "" });
143
+ if (!resolved.value) return (0, vue.h)("span", { "data-aigui-async-pending": "" });
144
+ try {
145
+ return renderOutput(resolved.value, props.sanitize);
146
+ } catch {
147
+ return (0, vue.h)("span", { "data-aigui-async-error": "" });
148
+ }
149
+ };
95
150
  }
96
151
  });
152
+ function sanitizeOutput(html, sanitize) {
153
+ if (sanitize === false) return html;
154
+ return (0, __ai_gui_core.sanitizeHtml)(html, typeof sanitize === "object" ? sanitize : void 0);
155
+ }
97
156
 
98
157
  //#endregion
99
158
  //#region src/render-node.ts
100
159
  function renderNode(node, ctx) {
101
- const r = (0, __ai_gui_core.collectNodeRenderers)(ctx.plugins)[node.type];
160
+ const r = (ctx.nodeRenderers ?? (0, __ai_gui_core.collectNodeRenderers)(ctx.plugins))[node.type];
102
161
  if (r) {
103
- const out = r(node);
104
- if (out && typeof out.then === "function") return (0, vue.h)(AsyncOutput, {
162
+ if (node.complete === false) return (0, vue.h)("div", {
105
163
  key: node.key,
106
- promise: out
164
+ "data-aigui-block-loading": "",
165
+ "data-block-type": node.type
107
166
  });
108
- return renderOutput(out);
167
+ try {
168
+ const out = r(node);
169
+ if (out && typeof out.then === "function") return (0, vue.h)(AsyncOutput, {
170
+ key: node.key,
171
+ promise: out,
172
+ sanitize: ctx.sanitize
173
+ });
174
+ const vnode = renderOutput(out, ctx.sanitize);
175
+ vnode.key = node.key;
176
+ return vnode;
177
+ } catch {
178
+ return renderFallback(node, ctx);
179
+ }
109
180
  }
110
181
  switch (node.type) {
111
182
  case "heading": return (0, vue.h)(node.tag ?? "h1", {
112
183
  key: node.key,
113
- innerHTML: node.html ?? ""
184
+ innerHTML: renderHtml(node.html ?? "", ctx)
114
185
  });
115
186
  case "paragraph": return (0, vue.h)("p", {
116
187
  key: node.key,
117
- innerHTML: node.html ?? ""
188
+ innerHTML: renderHtml(node.html ?? "", ctx)
118
189
  });
119
190
  case "code": return (0, vue.h)("pre", {
120
191
  key: node.key,
@@ -123,13 +194,10 @@ function renderNode(node, ctx) {
123
194
  case "hr": return (0, vue.h)("hr", { key: node.key });
124
195
  case "html": return (0, vue.h)("div", {
125
196
  key: node.key,
126
- innerHTML: node.content ?? ""
197
+ innerHTML: renderHtml(node.content ?? "", ctx)
127
198
  });
128
199
  case "card": return renderCard(node, ctx);
129
- default: return (0, vue.h)("div", {
130
- key: node.key,
131
- innerHTML: node.html ?? (0, __ai_gui_core.sanitizeHtml)(node.content ?? "")
132
- });
200
+ default: return renderFallback(node, ctx);
133
201
  }
134
202
  }
135
203
  function renderCard(node, ctx) {
@@ -145,11 +213,24 @@ function renderCard(node, ctx) {
145
213
  "data-aigui-card-invalid": ""
146
214
  }, [(0, vue.h)("code", JSON.stringify(card.data, null, 2))]);
147
215
  const Comp = ctx.registry?.getRender(card.type);
216
+ if (card.id && ctx.cardStore) return (0, vue.h)(CardHost, {
217
+ key: node.key,
218
+ component: Comp ? (0, vue.markRaw)((0, vue.toRaw)(Comp)) : void 0,
219
+ store: ctx.cardStore,
220
+ cardId: card.id,
221
+ cardType: card.type,
222
+ initialData: card.data,
223
+ onAction: (a) => ctx.onCardAction?.({
224
+ ...a,
225
+ cardType: card.type,
226
+ cardId: card.id
227
+ })
228
+ });
148
229
  if (!Comp) return (0, vue.h)("pre", {
149
230
  key: node.key,
150
231
  "data-aigui-card-fallback": ""
151
232
  }, [(0, vue.h)("code", JSON.stringify(card.data, null, 2))]);
152
- return (0, vue.h)(Comp, {
233
+ return (0, vue.h)((0, vue.markRaw)((0, vue.toRaw)(Comp)), {
153
234
  key: node.key,
154
235
  data: card.data,
155
236
  onAction: (a) => ctx.onCardAction?.({
@@ -158,11 +239,101 @@ function renderCard(node, ctx) {
158
239
  })
159
240
  });
160
241
  }
242
+ const CardHost = (0, vue.defineComponent)({
243
+ name: "AIGuiCardHost",
244
+ props: {
245
+ component: {
246
+ type: [Object, Function],
247
+ default: void 0
248
+ },
249
+ store: {
250
+ type: Object,
251
+ required: true
252
+ },
253
+ cardId: {
254
+ type: String,
255
+ required: true
256
+ },
257
+ cardType: {
258
+ type: String,
259
+ required: true
260
+ },
261
+ initialData: {
262
+ type: null,
263
+ required: true
264
+ }
265
+ },
266
+ emits: ["action"],
267
+ setup(props, { emit }) {
268
+ const record = (0, vue.shallowRef)();
269
+ const failed = (0, vue.shallowRef)(false);
270
+ let unsubscribe;
271
+ let stopWatch;
272
+ const bind = ([store, cardId, cardType, initialData]) => {
273
+ unsubscribe?.();
274
+ unsubscribe = void 0;
275
+ failed.value = false;
276
+ record.value = void 0;
277
+ unsubscribe = store.subscribe(cardId, (next) => {
278
+ if (!next || next.type !== cardType) {
279
+ failed.value = true;
280
+ record.value = void 0;
281
+ return;
282
+ }
283
+ failed.value = false;
284
+ record.value = next;
285
+ });
286
+ try {
287
+ const current = store.register({
288
+ id: cardId,
289
+ type: cardType,
290
+ data: initialData
291
+ });
292
+ if (current.type !== cardType) throw new Error(`Card type mismatch for "${cardId}"`);
293
+ record.value = current;
294
+ } catch {
295
+ failed.value = true;
296
+ record.value = void 0;
297
+ }
298
+ };
299
+ (0, vue.onMounted)(() => {
300
+ stopWatch = (0, vue.watch)(() => [
301
+ props.store,
302
+ props.cardId,
303
+ props.cardType,
304
+ props.initialData
305
+ ], bind, { immediate: true });
306
+ });
307
+ (0, vue.onBeforeUnmount)(() => {
308
+ stopWatch?.();
309
+ unsubscribe?.();
310
+ });
311
+ return () => {
312
+ if (failed.value || !record.value || !props.component) return (0, vue.h)("pre", { "data-aigui-card-fallback": "" }, [(0, vue.h)("code", JSON.stringify(record.value?.data ?? props.initialData, null, 2))]);
313
+ return (0, vue.h)((0, vue.markRaw)((0, vue.toRaw)(props.component)), {
314
+ data: record.value.data,
315
+ state: record.value.action,
316
+ onAction: (action) => emit("action", action)
317
+ });
318
+ };
319
+ }
320
+ });
321
+ function renderFallback(node, ctx) {
322
+ return (0, vue.h)("div", {
323
+ key: node.key,
324
+ innerHTML: renderHtml(node.html ?? node.content ?? "", ctx)
325
+ });
326
+ }
327
+ function renderHtml(html, ctx) {
328
+ if (ctx.sanitized || ctx.sanitize === false) return html;
329
+ return (0, __ai_gui_core.sanitizeHtml)(html, typeof ctx.sanitize === "object" ? ctx.sanitize : void 0);
330
+ }
161
331
 
162
332
  //#endregion
163
333
  //#region src/ai-renderer.ts
164
334
  const AIRenderer = (0, vue.defineComponent)({
165
335
  name: "AIRenderer",
336
+ emits: ["card-action"],
166
337
  props: {
167
338
  registry: {
168
339
  type: Object,
@@ -173,32 +344,108 @@ const AIRenderer = (0, vue.defineComponent)({
173
344
  default: void 0
174
345
  },
175
346
  sanitize: {
176
- type: Boolean,
347
+ type: [Boolean, Object],
177
348
  default: void 0
178
349
  },
179
- onCardAction: {
350
+ actionRuntime: {
351
+ type: Object,
352
+ default: void 0
353
+ },
354
+ cardStore: {
355
+ type: Object,
356
+ default: void 0
357
+ },
358
+ debug: {
359
+ type: Boolean,
360
+ default: false
361
+ },
362
+ onDebugEvent: {
180
363
  type: Function,
181
364
  default: void 0
182
365
  }
183
366
  },
184
- setup(props, { expose }) {
185
- const { nodes, push, feed, reset } = useAIRenderer({
367
+ setup(props, { emit, expose }) {
368
+ const current = (0, vue.shallowRef)(useAIRenderer({
186
369
  registry: props.registry,
187
370
  sanitize: props.sanitize,
188
- plugins: props.plugins
371
+ plugins: props.plugins,
372
+ debug: props.debug,
373
+ onDebugEvent: props.onDebugEvent
374
+ }));
375
+ const nodeRenderers = (0, vue.shallowRef)((0, __ai_gui_core.collectNodeRenderers)(props.plugins, { debugTarget: current.value.renderer }));
376
+ let actionScope = {
377
+ controller: new AbortController(),
378
+ owner: {}
379
+ };
380
+ const resetActionScope = () => {
381
+ actionScope.controller.abort();
382
+ actionScope = {
383
+ controller: new AbortController(),
384
+ owner: {}
385
+ };
386
+ };
387
+ (0, vue.watch)(() => [
388
+ props.registry,
389
+ props.sanitize,
390
+ props.plugins,
391
+ props.debug,
392
+ props.onDebugEvent
393
+ ], () => {
394
+ resetActionScope();
395
+ current.value.destroy();
396
+ current.value = useAIRenderer({
397
+ registry: props.registry,
398
+ sanitize: props.sanitize,
399
+ plugins: props.plugins,
400
+ debug: props.debug,
401
+ onDebugEvent: props.onDebugEvent
402
+ });
403
+ nodeRenderers.value = (0, __ai_gui_core.collectNodeRenderers)(props.plugins, { debugTarget: current.value.renderer });
189
404
  });
405
+ (0, vue.watch)(() => props.actionRuntime, resetActionScope);
406
+ (0, vue.onBeforeUnmount)(() => {
407
+ resetActionScope();
408
+ current.value.destroy();
409
+ });
410
+ const push = (chunk) => current.value.push(chunk);
411
+ const feed = (source, options) => current.value.feed(source, options);
412
+ const reset = () => {
413
+ resetActionScope();
414
+ current.value.reset();
415
+ };
190
416
  expose({
417
+ debugSource: "renderer",
418
+ subscribeDebug: (listener) => current.value.renderer.subscribeDebug(listener),
191
419
  push,
192
420
  feed,
193
421
  reset
194
422
  });
195
423
  return () => {
424
+ const onCardAction = (action) => {
425
+ if (props.actionRuntime) {
426
+ const request = {
427
+ type: action.type,
428
+ params: action.params,
429
+ cardType: action.cardType,
430
+ ...action.cardId === void 0 ? {} : { cardId: action.cardId }
431
+ };
432
+ props.actionRuntime.dispatch(request, {
433
+ signal: actionScope.controller.signal,
434
+ owner: actionScope.owner
435
+ }).catch(() => {});
436
+ }
437
+ emit("card-action", action);
438
+ };
196
439
  const ctx = {
197
440
  registry: props.registry,
441
+ cardStore: props.cardStore,
198
442
  plugins: props.plugins,
199
- onCardAction: props.onCardAction
443
+ nodeRenderers: nodeRenderers.value,
444
+ onCardAction,
445
+ sanitize: props.sanitize,
446
+ sanitized: true
200
447
  };
201
- return (0, vue.h)("div", { "data-aigui-renderer": "" }, nodes.value.map((n) => renderNode(n, ctx)));
448
+ return (0, vue.h)("div", { "data-aigui-renderer": "" }, current.value.nodes.value.map((n) => renderNode(n, ctx)));
202
449
  };
203
450
  }
204
451
  });
@@ -207,4 +454,5 @@ const AIRenderer = (0, vue.defineComponent)({
207
454
  exports.AIRenderer = AIRenderer
208
455
  exports.renderNode = renderNode
209
456
  exports.renderOutput = renderOutput
210
- exports.useAIRenderer = useAIRenderer
457
+ exports.useAIRenderer = useAIRenderer
458
+ exports.useActionState = useActionState
package/dist/index.d.cts CHANGED
@@ -1,15 +1,21 @@
1
1
  import { ShallowRef, VNode } from "vue";
2
- import { AIGuiPlugin, ASTNode, CardRegistry, RenderOutput, RendererOptions } from "@ai-gui/core";
2
+ import { AIGuiPlugin, ASTNode, ActionRuntime, ActionState, CardRegistry, CardStore, FeedOptions, FeedSource, NodeRenderer, RenderOutput, Renderer, RendererOptions } from "@ai-gui/core";
3
3
 
4
4
  //#region src/use-ai-renderer.d.ts
5
5
  interface UseAIRendererResult {
6
+ renderer: Renderer;
6
7
  nodes: ShallowRef<ASTNode[]>;
7
8
  push: (chunk: string) => void;
8
- feed: (source: AsyncIterable<string> | ReadableStream) => Promise<void>;
9
+ feed: (source: FeedSource, options?: FeedOptions) => Promise<void>;
9
10
  reset: () => void;
11
+ destroy: () => void;
10
12
  }
11
13
  declare function useAIRenderer(options?: Omit<RendererOptions, "onPatch">): UseAIRendererResult;
12
14
 
15
+ //#endregion
16
+ //#region src/use-action-state.d.ts
17
+ declare function useActionState(runtime: ActionRuntime, key: string): ShallowRef<ActionState>;
18
+
13
19
  //#endregion
14
20
  //#region src/ai-renderer.d.ts
15
21
  declare const AIRenderer: any;
@@ -18,21 +24,26 @@ declare const AIRenderer: any;
18
24
  //#region src/render-node.d.ts
19
25
  interface RenderContext {
20
26
  registry?: CardRegistry;
27
+ cardStore?: CardStore;
21
28
  plugins?: AIGuiPlugin[];
29
+ nodeRenderers?: Record<string, NodeRenderer>;
22
30
  onCardAction?: (action: {
23
31
  type: string;
24
32
  params?: unknown;
25
33
  cardType: string;
34
+ cardId?: string;
26
35
  }) => void;
36
+ sanitize?: RendererOptions["sanitize"];
37
+ sanitized?: boolean;
27
38
  }
28
39
  declare function renderNode(node: ASTNode, ctx: RenderContext): VNode;
29
40
 
30
41
  //#endregion
31
42
  //#region src/render-output.d.ts
32
43
  /** Translate a framework-neutral RenderOutput into a Vue VNode. */
33
- declare function renderOutput(out: RenderOutput): VNode;
44
+ declare function renderOutput(out: RenderOutput, sanitize?: RendererOptions["sanitize"]): VNode;
34
45
 
35
46
  //#endregion
36
47
  /** Await an async RenderOutput, rendering a placeholder until it resolves. */
37
48
 
38
- export { AIRenderer, RenderContext, UseAIRendererResult, renderNode, renderOutput, useAIRenderer };
49
+ export { AIRenderer, RenderContext, UseAIRendererResult, renderNode, renderOutput, useAIRenderer, useActionState };
package/dist/index.d.ts CHANGED
@@ -1,15 +1,21 @@
1
1
  import { ShallowRef, VNode } from "vue";
2
- import { AIGuiPlugin, ASTNode, CardRegistry, RenderOutput, RendererOptions } from "@ai-gui/core";
2
+ import { AIGuiPlugin, ASTNode, ActionRuntime, ActionState, CardRegistry, CardStore, FeedOptions, FeedSource, NodeRenderer, RenderOutput, Renderer, RendererOptions } from "@ai-gui/core";
3
3
 
4
4
  //#region src/use-ai-renderer.d.ts
5
5
  interface UseAIRendererResult {
6
+ renderer: Renderer;
6
7
  nodes: ShallowRef<ASTNode[]>;
7
8
  push: (chunk: string) => void;
8
- feed: (source: AsyncIterable<string> | ReadableStream) => Promise<void>;
9
+ feed: (source: FeedSource, options?: FeedOptions) => Promise<void>;
9
10
  reset: () => void;
11
+ destroy: () => void;
10
12
  }
11
13
  declare function useAIRenderer(options?: Omit<RendererOptions, "onPatch">): UseAIRendererResult;
12
14
 
15
+ //#endregion
16
+ //#region src/use-action-state.d.ts
17
+ declare function useActionState(runtime: ActionRuntime, key: string): ShallowRef<ActionState>;
18
+
13
19
  //#endregion
14
20
  //#region src/ai-renderer.d.ts
15
21
  declare const AIRenderer: any;
@@ -18,21 +24,26 @@ declare const AIRenderer: any;
18
24
  //#region src/render-node.d.ts
19
25
  interface RenderContext {
20
26
  registry?: CardRegistry;
27
+ cardStore?: CardStore;
21
28
  plugins?: AIGuiPlugin[];
29
+ nodeRenderers?: Record<string, NodeRenderer>;
22
30
  onCardAction?: (action: {
23
31
  type: string;
24
32
  params?: unknown;
25
33
  cardType: string;
34
+ cardId?: string;
26
35
  }) => void;
36
+ sanitize?: RendererOptions["sanitize"];
37
+ sanitized?: boolean;
27
38
  }
28
39
  declare function renderNode(node: ASTNode, ctx: RenderContext): VNode;
29
40
 
30
41
  //#endregion
31
42
  //#region src/render-output.d.ts
32
43
  /** Translate a framework-neutral RenderOutput into a Vue VNode. */
33
- declare function renderOutput(out: RenderOutput): VNode;
44
+ declare function renderOutput(out: RenderOutput, sanitize?: RendererOptions["sanitize"]): VNode;
34
45
 
35
46
  //#endregion
36
47
  /** Await an async RenderOutput, rendering a placeholder until it resolves. */
37
48
 
38
- export { AIRenderer, RenderContext, UseAIRendererResult, renderNode, renderOutput, useAIRenderer };
49
+ export { AIRenderer, RenderContext, UseAIRendererResult, renderNode, renderOutput, useAIRenderer, useActionState };
package/dist/index.js CHANGED
@@ -1,27 +1,49 @@
1
- import { defineComponent, h, onBeforeUnmount, onMounted, ref, shallowRef } from "vue";
1
+ import { defineComponent, h, markRaw, onBeforeUnmount, onMounted, onScopeDispose, ref, shallowRef, toRaw, watch } from "vue";
2
2
  import { Renderer, collectNodeRenderers, sanitizeHtml } from "@ai-gui/core";
3
3
 
4
4
  //#region src/use-ai-renderer.ts
5
5
  function useAIRenderer(options = {}) {
6
6
  const nodes = shallowRef([]);
7
+ let active = true;
7
8
  const renderer = new Renderer({
8
9
  ...options,
9
10
  plugins: options.plugins,
10
11
  onPatch: (_patches, snapshot) => {
11
- nodes.value = snapshot;
12
+ if (active) nodes.value = snapshot;
12
13
  }
13
14
  });
14
15
  return {
16
+ renderer,
15
17
  nodes,
16
- push: (c) => renderer.push(c),
17
- feed: (s) => renderer.feed(s),
18
+ push: (c) => {
19
+ if (active) renderer.push(c);
20
+ },
21
+ feed: (source, feedOptions) => active ? renderer.feed(source, feedOptions) : Promise.resolve(),
18
22
  reset: () => {
23
+ if (active) {
24
+ renderer.reset();
25
+ nodes.value = [];
26
+ }
27
+ },
28
+ destroy: () => {
29
+ active = false;
19
30
  renderer.reset();
20
31
  nodes.value = [];
21
32
  }
22
33
  };
23
34
  }
24
35
 
36
+ //#endregion
37
+ //#region src/use-action-state.ts
38
+ function useActionState(runtime, key) {
39
+ const state = shallowRef(runtime.getState(key));
40
+ const unsubscribe = runtime.subscribe((next) => {
41
+ if (next.key === key) state.value = next;
42
+ });
43
+ onScopeDispose(unsubscribe);
44
+ return state;
45
+ }
46
+
25
47
  //#endregion
26
48
  //#region src/render-output.ts
27
49
  /** Host element for imperative `mount` outputs, wired to Vue's lifecycle. */
@@ -37,6 +59,10 @@ const MountHost = defineComponent({
37
59
  onMounted(() => {
38
60
  if (elRef.value) cleanup = props.mount(elRef.value);
39
61
  });
62
+ watch(() => props.mount, (mount) => {
63
+ if (typeof cleanup === "function") cleanup();
64
+ cleanup = elRef.value ? mount(elRef.value) : void 0;
65
+ });
40
66
  onBeforeUnmount(() => {
41
67
  if (typeof cleanup === "function") cleanup();
42
68
  });
@@ -47,10 +73,10 @@ const MountHost = defineComponent({
47
73
  }
48
74
  });
49
75
  /** Translate a framework-neutral RenderOutput into a Vue VNode. */
50
- function renderOutput(out) {
76
+ function renderOutput(out, sanitize) {
51
77
  switch (out.kind) {
52
- case "html": return h("div", { innerHTML: sanitizeHtml(out.html) });
53
- case "element": return h(out.tag, out.props, (out.children ?? []).map(renderOutput));
78
+ case "html": return h("div", { innerHTML: sanitizeOutput(out.html, sanitize) });
79
+ case "element": return h(out.tag, out.props, (out.children ?? []).map((child) => renderOutput(child, sanitize)));
54
80
  case "mount": return h(MountHost, { mount: out.mount });
55
81
  case "card": return h("pre", { "data-aigui-card-fallback": "" }, [h("code", JSON.stringify(out.data, null, 2))]);
56
82
  }
@@ -58,39 +84,84 @@ function renderOutput(out) {
58
84
  /** Await an async RenderOutput, rendering a placeholder until it resolves. */
59
85
  const AsyncOutput = defineComponent({
60
86
  name: "AsyncOutput",
61
- props: { promise: {
62
- type: Object,
63
- required: true
64
- } },
87
+ props: {
88
+ promise: {
89
+ type: Object,
90
+ required: true
91
+ },
92
+ sanitize: {
93
+ type: [Boolean, Object],
94
+ default: void 0
95
+ }
96
+ },
65
97
  setup(props) {
66
98
  const resolved = ref(null);
67
- props.promise.then((v) => {
68
- resolved.value = v;
99
+ const failed = ref(false);
100
+ let generation = 0;
101
+ let active = true;
102
+ const awaitPromise = (promise) => {
103
+ const current = ++generation;
104
+ resolved.value = null;
105
+ failed.value = false;
106
+ promise.then((value) => {
107
+ if (active && current === generation) resolved.value = value;
108
+ }, () => {
109
+ if (active && current === generation) failed.value = true;
110
+ });
111
+ };
112
+ watch(() => [toRaw(props.promise)], ([promise]) => awaitPromise(promise), { immediate: true });
113
+ onBeforeUnmount(() => {
114
+ active = false;
115
+ generation++;
69
116
  });
70
- return () => resolved.value ? renderOutput(resolved.value) : h("span", { "data-aigui-async-pending": "" });
117
+ return () => {
118
+ if (failed.value) return h("span", { "data-aigui-async-error": "" });
119
+ if (!resolved.value) return h("span", { "data-aigui-async-pending": "" });
120
+ try {
121
+ return renderOutput(resolved.value, props.sanitize);
122
+ } catch {
123
+ return h("span", { "data-aigui-async-error": "" });
124
+ }
125
+ };
71
126
  }
72
127
  });
128
+ function sanitizeOutput(html, sanitize) {
129
+ if (sanitize === false) return html;
130
+ return sanitizeHtml(html, typeof sanitize === "object" ? sanitize : void 0);
131
+ }
73
132
 
74
133
  //#endregion
75
134
  //#region src/render-node.ts
76
135
  function renderNode(node, ctx) {
77
- const r = collectNodeRenderers(ctx.plugins)[node.type];
136
+ const r = (ctx.nodeRenderers ?? collectNodeRenderers(ctx.plugins))[node.type];
78
137
  if (r) {
79
- const out = r(node);
80
- if (out && typeof out.then === "function") return h(AsyncOutput, {
138
+ if (node.complete === false) return h("div", {
81
139
  key: node.key,
82
- promise: out
140
+ "data-aigui-block-loading": "",
141
+ "data-block-type": node.type
83
142
  });
84
- return renderOutput(out);
143
+ try {
144
+ const out = r(node);
145
+ if (out && typeof out.then === "function") return h(AsyncOutput, {
146
+ key: node.key,
147
+ promise: out,
148
+ sanitize: ctx.sanitize
149
+ });
150
+ const vnode = renderOutput(out, ctx.sanitize);
151
+ vnode.key = node.key;
152
+ return vnode;
153
+ } catch {
154
+ return renderFallback(node, ctx);
155
+ }
85
156
  }
86
157
  switch (node.type) {
87
158
  case "heading": return h(node.tag ?? "h1", {
88
159
  key: node.key,
89
- innerHTML: node.html ?? ""
160
+ innerHTML: renderHtml(node.html ?? "", ctx)
90
161
  });
91
162
  case "paragraph": return h("p", {
92
163
  key: node.key,
93
- innerHTML: node.html ?? ""
164
+ innerHTML: renderHtml(node.html ?? "", ctx)
94
165
  });
95
166
  case "code": return h("pre", {
96
167
  key: node.key,
@@ -99,13 +170,10 @@ function renderNode(node, ctx) {
99
170
  case "hr": return h("hr", { key: node.key });
100
171
  case "html": return h("div", {
101
172
  key: node.key,
102
- innerHTML: node.content ?? ""
173
+ innerHTML: renderHtml(node.content ?? "", ctx)
103
174
  });
104
175
  case "card": return renderCard(node, ctx);
105
- default: return h("div", {
106
- key: node.key,
107
- innerHTML: node.html ?? sanitizeHtml(node.content ?? "")
108
- });
176
+ default: return renderFallback(node, ctx);
109
177
  }
110
178
  }
111
179
  function renderCard(node, ctx) {
@@ -121,11 +189,24 @@ function renderCard(node, ctx) {
121
189
  "data-aigui-card-invalid": ""
122
190
  }, [h("code", JSON.stringify(card.data, null, 2))]);
123
191
  const Comp = ctx.registry?.getRender(card.type);
192
+ if (card.id && ctx.cardStore) return h(CardHost, {
193
+ key: node.key,
194
+ component: Comp ? markRaw(toRaw(Comp)) : void 0,
195
+ store: ctx.cardStore,
196
+ cardId: card.id,
197
+ cardType: card.type,
198
+ initialData: card.data,
199
+ onAction: (a) => ctx.onCardAction?.({
200
+ ...a,
201
+ cardType: card.type,
202
+ cardId: card.id
203
+ })
204
+ });
124
205
  if (!Comp) return h("pre", {
125
206
  key: node.key,
126
207
  "data-aigui-card-fallback": ""
127
208
  }, [h("code", JSON.stringify(card.data, null, 2))]);
128
- return h(Comp, {
209
+ return h(markRaw(toRaw(Comp)), {
129
210
  key: node.key,
130
211
  data: card.data,
131
212
  onAction: (a) => ctx.onCardAction?.({
@@ -134,11 +215,101 @@ function renderCard(node, ctx) {
134
215
  })
135
216
  });
136
217
  }
218
+ const CardHost = defineComponent({
219
+ name: "AIGuiCardHost",
220
+ props: {
221
+ component: {
222
+ type: [Object, Function],
223
+ default: void 0
224
+ },
225
+ store: {
226
+ type: Object,
227
+ required: true
228
+ },
229
+ cardId: {
230
+ type: String,
231
+ required: true
232
+ },
233
+ cardType: {
234
+ type: String,
235
+ required: true
236
+ },
237
+ initialData: {
238
+ type: null,
239
+ required: true
240
+ }
241
+ },
242
+ emits: ["action"],
243
+ setup(props, { emit }) {
244
+ const record = shallowRef();
245
+ const failed = shallowRef(false);
246
+ let unsubscribe;
247
+ let stopWatch;
248
+ const bind = ([store, cardId, cardType, initialData]) => {
249
+ unsubscribe?.();
250
+ unsubscribe = void 0;
251
+ failed.value = false;
252
+ record.value = void 0;
253
+ unsubscribe = store.subscribe(cardId, (next) => {
254
+ if (!next || next.type !== cardType) {
255
+ failed.value = true;
256
+ record.value = void 0;
257
+ return;
258
+ }
259
+ failed.value = false;
260
+ record.value = next;
261
+ });
262
+ try {
263
+ const current = store.register({
264
+ id: cardId,
265
+ type: cardType,
266
+ data: initialData
267
+ });
268
+ if (current.type !== cardType) throw new Error(`Card type mismatch for "${cardId}"`);
269
+ record.value = current;
270
+ } catch {
271
+ failed.value = true;
272
+ record.value = void 0;
273
+ }
274
+ };
275
+ onMounted(() => {
276
+ stopWatch = watch(() => [
277
+ props.store,
278
+ props.cardId,
279
+ props.cardType,
280
+ props.initialData
281
+ ], bind, { immediate: true });
282
+ });
283
+ onBeforeUnmount(() => {
284
+ stopWatch?.();
285
+ unsubscribe?.();
286
+ });
287
+ return () => {
288
+ if (failed.value || !record.value || !props.component) return h("pre", { "data-aigui-card-fallback": "" }, [h("code", JSON.stringify(record.value?.data ?? props.initialData, null, 2))]);
289
+ return h(markRaw(toRaw(props.component)), {
290
+ data: record.value.data,
291
+ state: record.value.action,
292
+ onAction: (action) => emit("action", action)
293
+ });
294
+ };
295
+ }
296
+ });
297
+ function renderFallback(node, ctx) {
298
+ return h("div", {
299
+ key: node.key,
300
+ innerHTML: renderHtml(node.html ?? node.content ?? "", ctx)
301
+ });
302
+ }
303
+ function renderHtml(html, ctx) {
304
+ if (ctx.sanitized || ctx.sanitize === false) return html;
305
+ return sanitizeHtml(html, typeof ctx.sanitize === "object" ? ctx.sanitize : void 0);
306
+ }
137
307
 
138
308
  //#endregion
139
309
  //#region src/ai-renderer.ts
140
310
  const AIRenderer = defineComponent({
141
311
  name: "AIRenderer",
312
+ emits: ["card-action"],
142
313
  props: {
143
314
  registry: {
144
315
  type: Object,
@@ -149,35 +320,111 @@ const AIRenderer = defineComponent({
149
320
  default: void 0
150
321
  },
151
322
  sanitize: {
152
- type: Boolean,
323
+ type: [Boolean, Object],
153
324
  default: void 0
154
325
  },
155
- onCardAction: {
326
+ actionRuntime: {
327
+ type: Object,
328
+ default: void 0
329
+ },
330
+ cardStore: {
331
+ type: Object,
332
+ default: void 0
333
+ },
334
+ debug: {
335
+ type: Boolean,
336
+ default: false
337
+ },
338
+ onDebugEvent: {
156
339
  type: Function,
157
340
  default: void 0
158
341
  }
159
342
  },
160
- setup(props, { expose }) {
161
- const { nodes, push, feed, reset } = useAIRenderer({
343
+ setup(props, { emit, expose }) {
344
+ const current = shallowRef(useAIRenderer({
162
345
  registry: props.registry,
163
346
  sanitize: props.sanitize,
164
- plugins: props.plugins
347
+ plugins: props.plugins,
348
+ debug: props.debug,
349
+ onDebugEvent: props.onDebugEvent
350
+ }));
351
+ const nodeRenderers = shallowRef(collectNodeRenderers(props.plugins, { debugTarget: current.value.renderer }));
352
+ let actionScope = {
353
+ controller: new AbortController(),
354
+ owner: {}
355
+ };
356
+ const resetActionScope = () => {
357
+ actionScope.controller.abort();
358
+ actionScope = {
359
+ controller: new AbortController(),
360
+ owner: {}
361
+ };
362
+ };
363
+ watch(() => [
364
+ props.registry,
365
+ props.sanitize,
366
+ props.plugins,
367
+ props.debug,
368
+ props.onDebugEvent
369
+ ], () => {
370
+ resetActionScope();
371
+ current.value.destroy();
372
+ current.value = useAIRenderer({
373
+ registry: props.registry,
374
+ sanitize: props.sanitize,
375
+ plugins: props.plugins,
376
+ debug: props.debug,
377
+ onDebugEvent: props.onDebugEvent
378
+ });
379
+ nodeRenderers.value = collectNodeRenderers(props.plugins, { debugTarget: current.value.renderer });
165
380
  });
381
+ watch(() => props.actionRuntime, resetActionScope);
382
+ onBeforeUnmount(() => {
383
+ resetActionScope();
384
+ current.value.destroy();
385
+ });
386
+ const push = (chunk) => current.value.push(chunk);
387
+ const feed = (source, options) => current.value.feed(source, options);
388
+ const reset = () => {
389
+ resetActionScope();
390
+ current.value.reset();
391
+ };
166
392
  expose({
393
+ debugSource: "renderer",
394
+ subscribeDebug: (listener) => current.value.renderer.subscribeDebug(listener),
167
395
  push,
168
396
  feed,
169
397
  reset
170
398
  });
171
399
  return () => {
400
+ const onCardAction = (action) => {
401
+ if (props.actionRuntime) {
402
+ const request = {
403
+ type: action.type,
404
+ params: action.params,
405
+ cardType: action.cardType,
406
+ ...action.cardId === void 0 ? {} : { cardId: action.cardId }
407
+ };
408
+ props.actionRuntime.dispatch(request, {
409
+ signal: actionScope.controller.signal,
410
+ owner: actionScope.owner
411
+ }).catch(() => {});
412
+ }
413
+ emit("card-action", action);
414
+ };
172
415
  const ctx = {
173
416
  registry: props.registry,
417
+ cardStore: props.cardStore,
174
418
  plugins: props.plugins,
175
- onCardAction: props.onCardAction
419
+ nodeRenderers: nodeRenderers.value,
420
+ onCardAction,
421
+ sanitize: props.sanitize,
422
+ sanitized: true
176
423
  };
177
- return h("div", { "data-aigui-renderer": "" }, nodes.value.map((n) => renderNode(n, ctx)));
424
+ return h("div", { "data-aigui-renderer": "" }, current.value.nodes.value.map((n) => renderNode(n, ctx)));
178
425
  };
179
426
  }
180
427
  });
181
428
 
182
429
  //#endregion
183
- export { AIRenderer, renderNode, renderOutput, useAIRenderer };
430
+ export { AIRenderer, renderNode, renderOutput, useAIRenderer, useActionState };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-gui/vue",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Vue 3 adapter for AIGUI — stream LLM output into live Vue components with cards and plugins.",
5
5
  "keywords": [
6
6
  "llm",
@@ -23,14 +23,23 @@
23
23
  "homepage": "https://github.com/liliang-cn/aigui#readme",
24
24
  "bugs": "https://github.com/liliang-cn/aigui/issues",
25
25
  "type": "module",
26
+ "sideEffects": false,
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
26
30
  "main": "./dist/index.cjs",
27
31
  "module": "./dist/index.js",
28
32
  "types": "./dist/index.d.ts",
29
33
  "exports": {
30
34
  ".": {
31
- "types": "./dist/index.d.ts",
32
- "import": "./dist/index.js",
33
- "require": "./dist/index.cjs"
35
+ "import": {
36
+ "types": "./dist/index.d.ts",
37
+ "default": "./dist/index.js"
38
+ },
39
+ "require": {
40
+ "types": "./dist/index.d.cts",
41
+ "default": "./dist/index.cjs"
42
+ }
34
43
  }
35
44
  },
36
45
  "files": [
@@ -42,7 +51,7 @@
42
51
  "access": "public"
43
52
  },
44
53
  "dependencies": {
45
- "@ai-gui/core": "0.1.0"
54
+ "@ai-gui/core": "0.3.0"
46
55
  },
47
56
  "peerDependencies": {
48
57
  "vue": ">=3.3"
@@ -53,6 +62,7 @@
53
62
  },
54
63
  "scripts": {
55
64
  "build": "tsdown",
65
+ "test": "pnpm --dir ../.. exec vitest run --project vue",
56
66
  "typecheck": "tsc --noEmit"
57
67
  }
58
68
  }