@antglobal/copilot-cards-core 0.0.0 → 1.0.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.
@@ -0,0 +1,2072 @@
1
+ /**
2
+ * Expression Engine — resolves template variables in card schemas.
3
+ *
4
+ * Supports expressions like `${user.name}`, `${items.length > 0}`, etc.
5
+ * Variables are resolved against a provided data context.
6
+ *
7
+ * Also handles the ExpressionValue type from CardSchema:
8
+ * { type: 'static', value: 'hello' } → 'hello'
9
+ * { type: 'expression', value: '${user.name}' } → resolved value
10
+ */
11
+ /**
12
+ * Resolve an ExpressionValue against a variables context.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * resolveExpressionValue({ type: 'expression', value: '${user.name}' }, { user: { name: 'Alice' } })
17
+ * // => 'Alice'
18
+ * ```
19
+ */
20
+ function resolveExpressionValue(expr, context) {
21
+ if (expr.type === 'static') {
22
+ return expr.value;
23
+ }
24
+ return resolveExpression(expr.value, context);
25
+ }
26
+ /**
27
+ * Resolve a single expression string against a context.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * resolveExpression('${user.name}', { user: { name: 'Alice' } })
32
+ * // => 'Alice'
33
+ * ```
34
+ */
35
+ function resolveExpression(expression, context) {
36
+ const trimmed = expression.trim();
37
+ // If the entire string is a SINGLE expression like `${xxx}`, return the raw value.
38
+ // Guard: no second `${` may appear — otherwise the string is a concatenation of
39
+ // multiple expressions (e.g. `${a}${b}`), which must go through interpolation
40
+ // instead of being mis-parsed as one expression. Checking for a second `${`
41
+ // (rather than an early `}`) keeps single expressions containing a literal `}`
42
+ // (e.g. `${x === '}' ? 'a' : 'b'}`) on the original single-expression path.
43
+ if (trimmed.startsWith('${') &&
44
+ trimmed.endsWith('}') &&
45
+ trimmed.indexOf('${', 2) === -1) {
46
+ const inner = trimmed.slice(2, -1).trim();
47
+ // Simple path (e.g. "user.name") — fast path
48
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$.]*$/.test(inner)) {
49
+ return getByPath$1(context, inner);
50
+ }
51
+ // Complex expression (ternary, comparison, logical, etc.)
52
+ return evaluateExpression(inner, context);
53
+ }
54
+ // Otherwise do string interpolation
55
+ return interpolate(expression, context);
56
+ }
57
+ /**
58
+ * Resolve all expressions within a template string, returning a string result.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * interpolate('Hello, ${user.name}! You have ${count} items.', { user: { name: 'Bob' }, count: 5 })
63
+ * // => 'Hello, Bob! You have 5 items.'
64
+ * ```
65
+ */
66
+ function interpolate(template, context) {
67
+ return template.replace(/\$\{([^}]+)\}/g, (_, inner) => {
68
+ const trimmed = inner.trim();
69
+ // Simple path — fast path
70
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$.]*$/.test(trimmed)) {
71
+ const value = getByPath$1(context, trimmed);
72
+ return value == null ? '' : String(value);
73
+ }
74
+ // Complex expression
75
+ const value = evaluateExpression(trimmed, context);
76
+ return value == null ? '' : String(value);
77
+ });
78
+ }
79
+ /**
80
+ * Safely access a nested value by dot-path (e.g. `user.address.city`).
81
+ */
82
+ function getByPath$1(obj, path) {
83
+ return path.split('.').reduce((current, key) => {
84
+ if (current == null)
85
+ return undefined;
86
+ return current[key];
87
+ }, obj);
88
+ }
89
+ function tokenize(expr) {
90
+ const tokens = [];
91
+ let i = 0;
92
+ while (i < expr.length) {
93
+ const ch = expr[i];
94
+ // Whitespace
95
+ if (/\s/.test(ch)) {
96
+ i++;
97
+ continue;
98
+ }
99
+ // String literal (single or double quote)
100
+ if (ch === '\'' || ch === '"') {
101
+ const quote = ch;
102
+ let str = '';
103
+ i++; // skip opening quote
104
+ while (i < expr.length && expr[i] !== quote) {
105
+ if (expr[i] === '\\' && i + 1 < expr.length) {
106
+ str += expr[i + 1];
107
+ i += 2;
108
+ }
109
+ else {
110
+ str += expr[i];
111
+ i++;
112
+ }
113
+ }
114
+ i++; // skip closing quote
115
+ tokens.push({ type: 'string', value: str });
116
+ continue;
117
+ }
118
+ // Number
119
+ if (/[0-9]/.test(ch) || (ch === '-' && i + 1 < expr.length && /[0-9]/.test(expr[i + 1]) && (tokens.length === 0 || tokens[tokens.length - 1].type === 'op' || tokens[tokens.length - 1].type === 'question' || tokens[tokens.length - 1].type === 'colon'))) {
120
+ let num = ch;
121
+ i++;
122
+ while (i < expr.length && /[0-9.]/.test(expr[i])) {
123
+ num += expr[i];
124
+ i++;
125
+ }
126
+ tokens.push({ type: 'number', value: num });
127
+ continue;
128
+ }
129
+ // Multi-char operators
130
+ const twoChar = expr.slice(i, i + 3);
131
+ if (twoChar === '===' || twoChar === '!==') {
132
+ tokens.push({ type: 'op', value: twoChar });
133
+ i += 3;
134
+ continue;
135
+ }
136
+ const pair = expr.slice(i, i + 2);
137
+ if (pair === '==' || pair === '!=' || pair === '>=' || pair === '<=' || pair === '&&' || pair === '||') {
138
+ tokens.push({ type: 'op', value: pair });
139
+ i += 2;
140
+ continue;
141
+ }
142
+ // Single-char operators
143
+ if (ch === '>' || ch === '<') {
144
+ tokens.push({ type: 'op', value: ch });
145
+ i++;
146
+ continue;
147
+ }
148
+ if (ch === '!') {
149
+ tokens.push({ type: 'op', value: '!' });
150
+ i++;
151
+ continue;
152
+ }
153
+ if (ch === '?') {
154
+ tokens.push({ type: 'question', value: '?' });
155
+ i++;
156
+ continue;
157
+ }
158
+ if (ch === ':') {
159
+ tokens.push({ type: 'colon', value: ':' });
160
+ i++;
161
+ continue;
162
+ }
163
+ if (ch === '(' || ch === ')') {
164
+ tokens.push({ type: 'paren', value: ch });
165
+ i++;
166
+ continue;
167
+ }
168
+ // Identifiers / keywords (variable paths like user.name)
169
+ if (/[a-zA-Z_$]/.test(ch)) {
170
+ let id = '';
171
+ while (i < expr.length && /[a-zA-Z0-9_$.]/.test(expr[i])) {
172
+ id += expr[i];
173
+ i++;
174
+ }
175
+ if (id === 'true' || id === 'false')
176
+ tokens.push({ type: 'boolean', value: id });
177
+ else if (id === 'null')
178
+ tokens.push({ type: 'null', value: id });
179
+ else if (id === 'undefined')
180
+ tokens.push({ type: 'undefined', value: id });
181
+ else
182
+ tokens.push({ type: 'ident', value: id });
183
+ continue;
184
+ }
185
+ // Skip unknown characters
186
+ i++;
187
+ }
188
+ return tokens;
189
+ }
190
+ /** Evaluate a tokenized expression against a variable context. */
191
+ function evaluateExpression(expr, context) {
192
+ const tokens = tokenize(expr);
193
+ let pos = 0;
194
+ function peek() { return tokens[pos]; }
195
+ function consume() { return tokens[pos++]; }
196
+ function expect(type, value) {
197
+ const t = consume();
198
+ if (!t || t.type !== type || (value !== undefined && t.value !== value)) {
199
+ throw new Error(`[ExpressionEngine] Expected ${type}${value ? ` '${value}'` : ''}, got ${t ? `'${t.value}'` : 'EOF'}`);
200
+ }
201
+ return t;
202
+ }
203
+ // Precedence (low → high): ternary → || → && → equality → relational → unary → atom
204
+ function parseTernary() {
205
+ const cond = parseOr();
206
+ if (peek()?.type === 'question') {
207
+ consume(); // ?
208
+ const truthy = parseTernary();
209
+ expect('colon');
210
+ const falsy = parseTernary();
211
+ return cond ? truthy : falsy;
212
+ }
213
+ return cond;
214
+ }
215
+ function parseOr() {
216
+ let left = parseAnd();
217
+ while (peek()?.value === '||') {
218
+ consume();
219
+ left = left || parseAnd();
220
+ }
221
+ return left;
222
+ }
223
+ function parseAnd() {
224
+ let left = parseEquality();
225
+ while (peek()?.value === '&&') {
226
+ consume();
227
+ left = left && parseEquality();
228
+ }
229
+ return left;
230
+ }
231
+ function parseEquality() {
232
+ let left = parseRelational();
233
+ while (peek()?.value === '===' || peek()?.value === '!==' || peek()?.value === '==' || peek()?.value === '!=') {
234
+ const op = consume().value;
235
+ const right = parseRelational();
236
+ if (op === '===' || op === '==')
237
+ left = left === right;
238
+ else
239
+ left = left !== right;
240
+ }
241
+ return left;
242
+ }
243
+ function parseRelational() {
244
+ let left = parseUnary();
245
+ while (peek()?.value === '>' || peek()?.value === '<' || peek()?.value === '>=' || peek()?.value === '<=') {
246
+ const op = consume().value;
247
+ const right = parseUnary();
248
+ if (op === '>')
249
+ left = left > right;
250
+ else if (op === '<')
251
+ left = left < right;
252
+ else if (op === '>=')
253
+ left = left >= right;
254
+ else
255
+ left = left <= right;
256
+ }
257
+ return left;
258
+ }
259
+ function parseUnary() {
260
+ if (peek()?.value === '!') {
261
+ consume();
262
+ return !parseUnary();
263
+ }
264
+ return parseAtom();
265
+ }
266
+ function parseAtom() {
267
+ const t = peek();
268
+ if (!t)
269
+ return undefined;
270
+ if (t.type === 'string') {
271
+ consume();
272
+ return t.value;
273
+ }
274
+ if (t.type === 'number') {
275
+ consume();
276
+ return Number(t.value);
277
+ }
278
+ if (t.type === 'boolean') {
279
+ consume();
280
+ return t.value === 'true';
281
+ }
282
+ if (t.type === 'null') {
283
+ consume();
284
+ return null;
285
+ }
286
+ if (t.type === 'undefined') {
287
+ consume();
288
+ return undefined;
289
+ }
290
+ if (t.type === 'ident') {
291
+ consume();
292
+ return getByPath$1(context, t.value);
293
+ }
294
+ if (t.type === 'paren' && t.value === '(') {
295
+ consume();
296
+ const val = parseTernary();
297
+ expect('paren', ')');
298
+ return val;
299
+ }
300
+ // Fallback
301
+ consume();
302
+ return undefined;
303
+ }
304
+ const result = parseTernary();
305
+ return result;
306
+ }
307
+ /**
308
+ * Checks whether a string contains any expression templates.
309
+ */
310
+ function hasExpression(value) {
311
+ return /\$\{[^}]+\}/.test(value);
312
+ }
313
+ /**
314
+ * Recursively resolve all expressions in a data structure (object / array / string).
315
+ */
316
+ function resolveDeep(data, context) {
317
+ if (typeof data === 'string') {
318
+ return hasExpression(data) ? resolveExpression(data, context) : data;
319
+ }
320
+ if (Array.isArray(data)) {
321
+ return data.map((item) => resolveDeep(item, context));
322
+ }
323
+ if (data !== null && typeof data === 'object') {
324
+ const result = {};
325
+ for (const [key, value] of Object.entries(data)) {
326
+ result[key] = resolveDeep(value, context);
327
+ }
328
+ return result;
329
+ }
330
+ return data;
331
+ }
332
+
333
+ /**
334
+ * Action Runner — executes ActionStep chains defined in card schemas.
335
+ *
336
+ * Supported action types:
337
+ * - **request**: HTTP request (fetch / API call)
338
+ * - **toast**: Display a toast notification
339
+ * - **url**: Navigate to a URL
340
+ * - **setVariable**: Update a variable in the card context
341
+ * - **emit**: Emit a custom event to the host environment
342
+ * - **copy**: Copy text to clipboard
343
+ *
344
+ * Each step can have `onSuccess` / `onFail` branches for chaining.
345
+ */
346
+ // ─── Constants ──────────────────────────────────────────────────
347
+ /** Default timeout (ms) for a single HTTP request when no poll is configured. */
348
+ const DEFAULT_REQUEST_TIMEOUT = 30000;
349
+ // ─── Request Deduplication ──────────────────────────────────────
350
+ /**
351
+ * Shared fallback in-flight request map keyed by responseKey. Used only when a
352
+ * context does not provide its own `inflightRequests` map. Prevents duplicate
353
+ * request actions (e.g. user double-clicks a button) from spawning concurrent
354
+ * request/polling loops that race on the same variable.
355
+ *
356
+ * Note: prefer a per-instance map via `ctx.inflightRequests` — this shared map
357
+ * would otherwise let two independent cards using the default `_response` key
358
+ * dedupe against each other.
359
+ */
360
+ const sharedInflightRequests = new Map();
361
+ // ─── Built-in Handlers ───────────────────────────────────────────
362
+ /**
363
+ * Try to parse response body as JSON, fall back to plain text.
364
+ */
365
+ async function parseResponseBody(response) {
366
+ const contentType = response.headers.get('content-type') ?? '';
367
+ if (contentType.includes('application/json')) {
368
+ return response.json();
369
+ }
370
+ // Some APIs return JSON without proper content-type; try JSON first
371
+ const text = await response.text();
372
+ try {
373
+ return JSON.parse(text);
374
+ }
375
+ catch {
376
+ return text;
377
+ }
378
+ }
379
+ /**
380
+ * Write response data into the variables store so downstream actions
381
+ * can reference it via expressions like `${_response.orderId}`.
382
+ *
383
+ * The variable key defaults to `_response` but can be overridden via
384
+ * `params.responseKey` when a card has multiple request actions.
385
+ *
386
+ * @param silent - If true, only write to ctx.variables without calling
387
+ * setVariable (avoids triggering re-render during polling iterations).
388
+ */
389
+ function writeResponseVariable(ctx, responseKey, data, silent = false) {
390
+ if (ctx.variables) {
391
+ ctx.variables[responseKey] = data;
392
+ }
393
+ if (!silent) {
394
+ ctx.setVariable?.(responseKey, data);
395
+ }
396
+ }
397
+ /**
398
+ * Read a nested field from an object by dot-path, e.g. "data.status".
399
+ */
400
+ function getByPath(obj, path) {
401
+ return path.split('.').reduce((cur, key) => cur?.[key], obj);
402
+ }
403
+ /**
404
+ * Execute a single fetch call, parse body, write to variables.
405
+ * Returns the parsed response data object `{ ok, status, data }`.
406
+ *
407
+ * @param silent - If true, write to variables without triggering re-render.
408
+ */
409
+ async function executeRequest(fetcher, url, init, ctx, varKey, silent = false, timeoutMs) {
410
+ // Combine the card-level abortSignal with a per-request timeout signal
411
+ const signals = [];
412
+ if (ctx.abortSignal)
413
+ signals.push(ctx.abortSignal);
414
+ if (timeoutMs && timeoutMs > 0)
415
+ signals.push(AbortSignal.timeout(timeoutMs));
416
+ const combinedSignal = signals.length > 0
417
+ ? (signals.length === 1 ? signals[0] : AbortSignal.any(signals))
418
+ : undefined;
419
+ const response = await fetcher(url, { ...init, signal: combinedSignal });
420
+ const body = await parseResponseBody(response);
421
+ const result = {
422
+ ok: response.ok,
423
+ status: response.status,
424
+ data: body,
425
+ };
426
+ writeResponseVariable(ctx, varKey, result, silent);
427
+ if (!response.ok) {
428
+ throw new Error(`HTTP ${response.status}`);
429
+ }
430
+ return result;
431
+ }
432
+ /**
433
+ * Create a delay that can be aborted via AbortSignal.
434
+ * Resolves after `ms` milliseconds, or rejects immediately if signal fires.
435
+ */
436
+ function abortableDelay(ms, signal) {
437
+ return new Promise((resolve, reject) => {
438
+ if (signal?.aborted) {
439
+ reject(new DOMException('Aborted', 'AbortError'));
440
+ return;
441
+ }
442
+ const timer = setTimeout(resolve, ms);
443
+ signal?.addEventListener('abort', () => {
444
+ clearTimeout(timer);
445
+ reject(new DOMException('Aborted', 'AbortError'));
446
+ }, { once: true });
447
+ });
448
+ }
449
+ const handleRequest = async (step, ctx) => {
450
+ const fetcher = ctx.fetch ?? globalThis.fetch;
451
+ if (!fetcher) {
452
+ throw new Error('[ActionRunner] No fetch implementation available');
453
+ }
454
+ const { url, method, headers, body, responseKey, poll, timeout } = step.params;
455
+ const varKey = responseKey ?? '_response';
456
+ // Per-instance dedup map when provided; shared fallback otherwise.
457
+ const inflight = ctx.inflightRequests ?? sharedInflightRequests;
458
+ // ── Deduplication: skip if same responseKey is already in-flight ──
459
+ if (inflight.has(varKey)) {
460
+ console.warn(`[ActionRunner] Request "${varKey}" already in-flight, skipping duplicate`);
461
+ return;
462
+ }
463
+ // Auto-set Content-Type for JSON body
464
+ const mergedHeaders = { ...headers };
465
+ if (body && !mergedHeaders['Content-Type'] && !mergedHeaders['content-type']) {
466
+ mergedHeaders['Content-Type'] = 'application/json';
467
+ }
468
+ const init = {
469
+ method: method ?? 'GET',
470
+ headers: mergedHeaders,
471
+ body: body ? JSON.stringify(body) : undefined,
472
+ };
473
+ // Per-request timeout: use schema-defined timeout, or default for non-poll requests
474
+ const requestTimeout = timeout ?? (poll ? undefined : DEFAULT_REQUEST_TIMEOUT);
475
+ const task = (async () => {
476
+ try {
477
+ const result = await executeRequest(fetcher, url, init, ctx, varKey, false, requestTimeout);
478
+ // ── Polling mode ──────────────────────────────────────────
479
+ if (poll) {
480
+ const { interval = 3000, maxAttempts = 10, until, stopValues = [], } = poll;
481
+ let currentValue = getByPath(result.data, until);
482
+ for (let attempt = 1; attempt < maxAttempts && !stopValues.includes(currentValue); attempt++) {
483
+ // Abortable sleep — clears timer on dispose
484
+ await abortableDelay(interval, ctx.abortSignal);
485
+ // Polling iterations write silently (no re-render) to avoid
486
+ // DOM rebuild that would destroy user focus / input state
487
+ const pollResult = await executeRequest(fetcher, url, init, ctx, varKey, /* silent */ true, requestTimeout);
488
+ currentValue = getByPath(pollResult.data, until);
489
+ }
490
+ // Polling finished — final write triggers re-render so UI shows result
491
+ if (!stopValues.includes(currentValue)) {
492
+ writeResponseVariable(ctx, varKey, {
493
+ ...ctx.variables?.[varKey],
494
+ _timeout: true,
495
+ });
496
+ throw new Error(`Polling timeout after ${maxAttempts} attempts`);
497
+ }
498
+ // Terminal value reached — write the final state with re-render
499
+ writeResponseVariable(ctx, varKey, ctx.variables?.[varKey] ?? {});
500
+ }
501
+ if (step.onSuccess) {
502
+ await runActionSteps(step.onSuccess, ctx);
503
+ }
504
+ }
505
+ catch (error) {
506
+ // AbortError = card disposed or timeout, check which
507
+ if (error instanceof DOMException && error.name === 'AbortError') {
508
+ // If card abortSignal is aborted → dispose, silently stop
509
+ if (ctx.abortSignal?.aborted) {
510
+ return;
511
+ }
512
+ // Otherwise it's a timeout — treat as error
513
+ const timeoutError = new Error(`Request timeout after ${requestTimeout}ms`);
514
+ writeResponseVariable(ctx, varKey, {
515
+ ok: false,
516
+ status: 0,
517
+ data: null,
518
+ _timeout: true,
519
+ message: timeoutError.message,
520
+ });
521
+ if (step.onFail) {
522
+ await runActionSteps(step.onFail, ctx);
523
+ }
524
+ else {
525
+ throw timeoutError;
526
+ }
527
+ return;
528
+ }
529
+ // Network errors (no response) — also write to variables
530
+ if (!ctx.variables?.[varKey]) {
531
+ writeResponseVariable(ctx, varKey, {
532
+ ok: false,
533
+ status: 0,
534
+ data: null,
535
+ message: error instanceof Error ? error.message : String(error),
536
+ });
537
+ }
538
+ if (step.onFail) {
539
+ await runActionSteps(step.onFail, ctx);
540
+ }
541
+ else {
542
+ throw error;
543
+ }
544
+ }
545
+ finally {
546
+ inflight.delete(varKey);
547
+ }
548
+ })();
549
+ inflight.set(varKey, task);
550
+ await task;
551
+ };
552
+ const handleToast = (step, ctx) => {
553
+ const { message, level, duration } = step.params;
554
+ if (ctx.showToast) {
555
+ ctx.showToast(message, level, duration);
556
+ }
557
+ else {
558
+ console.log(`[Toast] ${level ?? 'info'}: ${message}`);
559
+ }
560
+ };
561
+ const handleUrl = (step, ctx) => {
562
+ const { url, target } = step.params;
563
+ if (ctx.navigate) {
564
+ ctx.navigate(url, target);
565
+ }
566
+ else if (typeof window !== 'undefined') {
567
+ window.open(url, target ?? '_blank');
568
+ }
569
+ };
570
+ const handleSetVariable = (step, ctx) => {
571
+ const { key, value } = step.params;
572
+ if (ctx.setVariable) {
573
+ ctx.setVariable(key, value);
574
+ }
575
+ else {
576
+ console.warn('[ActionRunner] No setVariable handler registered');
577
+ }
578
+ };
579
+ const handleEmit = (step, ctx) => {
580
+ const { event, payload } = step.params;
581
+ if (ctx.emit) {
582
+ ctx.emit(event, payload);
583
+ }
584
+ else {
585
+ console.warn(`[ActionRunner] No emit handler; event="${event}"`);
586
+ }
587
+ };
588
+ const handleCopy = (step, ctx) => {
589
+ const { text } = step.params;
590
+ if (ctx.copyText) {
591
+ ctx.copyText(text);
592
+ }
593
+ else if (typeof navigator !== 'undefined' && navigator.clipboard) {
594
+ navigator.clipboard.writeText(text);
595
+ }
596
+ else {
597
+ console.warn('[ActionRunner] No clipboard API available');
598
+ }
599
+ };
600
+ // ─── Handler Map ─────────────────────────────────────────────────
601
+ const builtInHandlers = {
602
+ request: handleRequest,
603
+ toast: handleToast,
604
+ url: handleUrl,
605
+ setVariable: handleSetVariable,
606
+ emit: handleEmit,
607
+ copy: handleCopy,
608
+ };
609
+ const customHandlers = new Map();
610
+ /**
611
+ * Register a custom action step handler for a specific bot.
612
+ */
613
+ function registerActionHandler(botId, type, handler) {
614
+ let botMap = customHandlers.get(botId);
615
+ if (!botMap) {
616
+ botMap = new Map();
617
+ customHandlers.set(botId, botMap);
618
+ }
619
+ botMap.set(type, handler);
620
+ }
621
+ /**
622
+ * Execute a single ActionStep.
623
+ */
624
+ async function runActionStep(step, context = {}) {
625
+ const handler = customHandlers.get(context.botId ?? '')?.get(step.type) ?? builtInHandlers[step.type];
626
+ if (!handler) {
627
+ console.warn(`[ActionRunner] Unknown action type: ${step.type}`);
628
+ return;
629
+ }
630
+ // Resolve expression variables in params (e.g. '${order_id}' → 'ORDER_666')
631
+ const resolvedStep = context.variables
632
+ ? { ...step, params: resolveDeep(step.params, context.variables) }
633
+ : step;
634
+ await handler(resolvedStep, context);
635
+ }
636
+ /**
637
+ * Execute a chain of ActionSteps sequentially.
638
+ */
639
+ async function runActionSteps(steps, context = {}) {
640
+ for (const step of steps) {
641
+ await runActionStep(step, context);
642
+ }
643
+ }
644
+
645
+ var index = /*#__PURE__*/Object.freeze({
646
+ __proto__: null,
647
+ registerActionHandler: registerActionHandler,
648
+ runActionStep: runActionStep,
649
+ runActionSteps: runActionSteps
650
+ });
651
+
652
+ /**
653
+ * ActionRegistry — a simplified, business-friendly API for registering
654
+ * custom action handlers, scoped by botId.
655
+ *
656
+ * Usage:
657
+ * ```ts
658
+ * import { registry } from '@antglobal/copilot-cards-core';
659
+ *
660
+ * // Register a handler for a specific bot
661
+ * registry.register('10001', 'bizRequest', async (params, ctx) => {
662
+ * const res = await fetch(params.api, { method: params.method, body: JSON.stringify(params.data) });
663
+ * if (!res.ok) throw new Error('Business request failed');
664
+ * return res.json();
665
+ * });
666
+ * ```
667
+ *
668
+ * When a handler throws, the action chain is interrupted and `onFail` branch
669
+ * (if defined on the ActionStep) is executed instead.
670
+ */
671
+ // ─── ActionRegistry ─────────────────────────────────────────────
672
+ class ActionRegistry {
673
+ constructor() {
674
+ /** botId → Map<type, handler> */
675
+ this._handlers = new Map();
676
+ }
677
+ /**
678
+ * Register a custom action handler for a specific bot.
679
+ *
680
+ * The handler receives `step.params` directly. If it throws,
681
+ * the SDK will execute the step's `onFail` branch (if any)
682
+ * and stop the remaining action chain.
683
+ *
684
+ * @param botId The bot this handler belongs to
685
+ * @param type Action type name (e.g. 'bizRequest')
686
+ * @param handler The handler function
687
+ */
688
+ register(botId, type, handler) {
689
+ let botMap = this._handlers.get(botId);
690
+ if (!botMap) {
691
+ botMap = new Map();
692
+ this._handlers.set(botId, botMap);
693
+ }
694
+ botMap.set(type, handler);
695
+ // Bridge to core's low-level registerActionHandler (botId-scoped)
696
+ const wrappedHandler = async (step, context) => {
697
+ try {
698
+ await handler(step.params, context);
699
+ if (step.onSuccess) {
700
+ const { runActionSteps } = await Promise.resolve().then(function () { return index; });
701
+ await runActionSteps(step.onSuccess, context);
702
+ }
703
+ }
704
+ catch (error) {
705
+ if (step.onFail) {
706
+ const { runActionSteps } = await Promise.resolve().then(function () { return index; });
707
+ await runActionSteps(step.onFail, context);
708
+ }
709
+ else {
710
+ throw error;
711
+ }
712
+ }
713
+ };
714
+ registerActionHandler(botId, type, wrappedHandler);
715
+ }
716
+ /**
717
+ * Remove a previously registered handler for a specific bot.
718
+ */
719
+ unregister(botId, type) {
720
+ const botMap = this._handlers.get(botId);
721
+ if (botMap) {
722
+ botMap.delete(type);
723
+ if (botMap.size === 0)
724
+ this._handlers.delete(botId);
725
+ }
726
+ registerActionHandler(botId, type, () => {
727
+ console.warn(`[ActionRegistry] Handler "${type}" for bot "${botId}" has been unregistered`);
728
+ });
729
+ }
730
+ /**
731
+ * Check whether a handler is registered for the given bot and type.
732
+ */
733
+ has(botId, type) {
734
+ return this._handlers.get(botId)?.has(type) ?? false;
735
+ }
736
+ /**
737
+ * Get the list of all registered custom action types for a bot.
738
+ */
739
+ getRegisteredTypes(botId) {
740
+ return Array.from(this._handlers.get(botId)?.keys() ?? []);
741
+ }
742
+ }
743
+ // ─── Singleton ──────────────────────────────────────────────────
744
+ /** Global action registry singleton. */
745
+ const registry = new ActionRegistry();
746
+
747
+ /**
748
+ * Action Config Provider — abstracts where action chain configurations
749
+ * come from (local mock, remote API, etc.).
750
+ *
751
+ * Action chains are declarative ActionStep[] JSON stored per botId
752
+ * in the server database. The provider fetches them so the SDK can
753
+ * register and execute them on the frontend.
754
+ *
755
+ * Usage:
756
+ * ```ts
757
+ * const provider: ActionConfigProvider = new RemoteActionConfigProvider('https://api.example.com');
758
+ * const configs = await provider.fetchActions('10001');
759
+ * // configs = [{ name: 'confirmOrder', steps: [...] }]
760
+ * ```
761
+ */
762
+ // ─── Helpers ────────────────────────────────────────────────────
763
+ /**
764
+ * Resolve action references in event bindings.
765
+ *
766
+ * If an event value is a string (e.g. `"confirmOrder"`), look it up
767
+ * in the `actionsMap` and return the corresponding ActionStep[].
768
+ * If it's already an ActionStep[], return as-is.
769
+ */
770
+ function resolveActionRef(eventValue, actionsMap) {
771
+ if (eventValue == null)
772
+ return undefined;
773
+ if (typeof eventValue === 'string') {
774
+ const steps = actionsMap[eventValue];
775
+ if (!steps) {
776
+ console.warn(`[ActionProvider] Unknown action reference: "${eventValue}"`);
777
+ return undefined;
778
+ }
779
+ return steps;
780
+ }
781
+ return eventValue;
782
+ }
783
+
784
+ /**
785
+ * Legacy Schema Compatibility — converts old-format card JSON into
786
+ * the current CardSchema format so it can be rendered by the SDK.
787
+ *
788
+ * Old format:
789
+ * ```json
790
+ * {
791
+ * "cardType": "common",
792
+ * "cardContents": [{ "type": "text", "content": { "text": "hello" } }],
793
+ * "text": "card text",
794
+ * "extInfo": { "language": "zh-CN" }
795
+ * }
796
+ * ```
797
+ *
798
+ * Usage:
799
+ * ```ts
800
+ * import { convertLegacySchema } from '@antglobal/copilot-cards-core';
801
+ * const schema = convertLegacySchema(oldJson);
802
+ * renderCard(container, schema);
803
+ * ```
804
+ */
805
+ // ─── Detection ──────────────────────────────────────────────────
806
+ /**
807
+ * Detect whether an unknown input is a legacy card schema.
808
+ *
809
+ * Checks for the presence of legacy-specific fields (`cardContents`, `cardType`)
810
+ * and the absence of new-format fields (`rootID`, `elements`).
811
+ */
812
+ function isLegacySchema(input) {
813
+ if (input == null || typeof input !== 'object')
814
+ return false;
815
+ const obj = input;
816
+ return ('cardContents' in obj &&
817
+ 'cardType' in obj &&
818
+ !('rootID' in obj) &&
819
+ !('elements' in obj));
820
+ }
821
+ // ─── ID Generator ───────────────────────────────────────────────
822
+ let _idCounter = 0;
823
+ function uid(prefix = 'el') {
824
+ return `${prefix}_${++_idCounter}`;
825
+ }
826
+ /** Reset counter (useful for deterministic tests). */
827
+ function resetIdCounter() {
828
+ _idCounter = 0;
829
+ }
830
+ // ─── Type Mapping ───────────────────────────────────────────────
831
+ /**
832
+ * Map legacy component type names to new schema type names.
833
+ * Extensible — add more mappings as new component types are built.
834
+ */
835
+ const TYPE_MAP = {
836
+ text: 'Text',
837
+ button: 'Button',
838
+ image: 'Image',
839
+ form: 'Form',
840
+ timeline: 'Timeline',
841
+ group: 'ColumnSet',
842
+ custom: 'Custom',
843
+ };
844
+ function mapType(legacyType) {
845
+ return TYPE_MAP[legacyType] ?? legacyType;
846
+ }
847
+ // ─── Content → Props Converters ─────────────────────────────────
848
+ /**
849
+ * Convert a legacy `text` content item to ElementNode props.
850
+ *
851
+ * Legacy text content may look like:
852
+ * ```json
853
+ * { "text": "hello", "color": "red", "fontSize": 16, "bold": true, ... }
854
+ * ```
855
+ */
856
+ function convertTextContent(content) {
857
+ const { text, color, fontSize, fontWeight, bold, align, maxLines, style, ...rest } = content;
858
+ const props = { ...rest };
859
+ // content field
860
+ if (text != null) {
861
+ props.content = staticValue(String(text));
862
+ }
863
+ // Merge style
864
+ const mergedStyle = { ...style };
865
+ if (color)
866
+ mergedStyle.color = color;
867
+ if (fontSize)
868
+ mergedStyle.fontSize = fontSize;
869
+ if (fontWeight)
870
+ mergedStyle.fontWeight = fontWeight;
871
+ if (bold)
872
+ mergedStyle.fontWeight = 'bold';
873
+ if (align)
874
+ mergedStyle.textAlign = align;
875
+ if (Object.keys(mergedStyle).length > 0) {
876
+ props.style = mergedStyle;
877
+ }
878
+ if (maxLines)
879
+ props.maxLines = maxLines;
880
+ return props;
881
+ }
882
+ /**
883
+ * Convert a legacy `button` content item to ElementNode props.
884
+ *
885
+ * Legacy button content may look like:
886
+ * ```json
887
+ * { "label": "Submit", "url": "https://...", "style": { ... } }
888
+ * ```
889
+ */
890
+ function convertButtonContent(content) {
891
+ const { label, text, url, actionType, style, ...rest } = content;
892
+ const props = { ...rest };
893
+ // Button display text
894
+ const displayText = label ?? text;
895
+ if (displayText != null) {
896
+ props.content = staticValue(String(displayText));
897
+ }
898
+ if (style)
899
+ props.style = style;
900
+ // Convert URL / action to events
901
+ let events;
902
+ if (url) {
903
+ events = {
904
+ onClick: [{ type: 'url', params: { url } }],
905
+ };
906
+ }
907
+ return { props, events };
908
+ }
909
+ /**
910
+ * Convert a legacy `image` content item to ElementNode props.
911
+ *
912
+ * Legacy image content may look like:
913
+ * ```json
914
+ * { "src": "https://...", "alt": "desc", "width": 200, "height": 100 }
915
+ * ```
916
+ */
917
+ function convertImageContent(content) {
918
+ const { src, url, alt, width, height, style, ...rest } = content;
919
+ const props = { ...rest };
920
+ props.src = src ?? url;
921
+ if (alt)
922
+ props.alt = alt;
923
+ const mergedStyle = { ...style };
924
+ if (width)
925
+ mergedStyle.width = width;
926
+ if (height)
927
+ mergedStyle.height = height;
928
+ if (Object.keys(mergedStyle).length > 0) {
929
+ props.style = mergedStyle;
930
+ }
931
+ return props;
932
+ }
933
+ /**
934
+ * Convert a legacy `form` content item to ElementNode props.
935
+ * Passes through all content properties as props.
936
+ */
937
+ function convertFormContent(content) {
938
+ return { ...content };
939
+ }
940
+ /**
941
+ * Generic fallback converter — passes content through as props.
942
+ */
943
+ function convertGenericContent(content) {
944
+ return { ...content };
945
+ }
946
+ // ─── Main Converter ─────────────────────────────────────────────
947
+ /**
948
+ * Convert a legacy CardSchema to the current CardSchema format.
949
+ *
950
+ * @example
951
+ * ```ts
952
+ * const newSchema = convertLegacySchema(oldJson);
953
+ * renderCard(container, newSchema);
954
+ * ```
955
+ */
956
+ function convertLegacySchema(legacy) {
957
+ resetIdCounter();
958
+ const elements = {};
959
+ // Normalise cardContents to array
960
+ const contents = Array.isArray(legacy.cardContents)
961
+ ? legacy.cardContents
962
+ : [legacy.cardContents];
963
+ // Convert each content item, collecting top-level child IDs
964
+ const childIds = [];
965
+ for (const item of contents) {
966
+ const converted = convertContentItem(item, elements, legacy.tracking);
967
+ childIds.push(converted.id);
968
+ }
969
+ // Create root container element
970
+ const rootId = uid('root');
971
+ if (childIds.length === 1) {
972
+ // Single child — promote it as root directly
973
+ const onlyChild = elements[childIds[0]];
974
+ onlyChild.id = rootId;
975
+ delete elements[childIds[0]];
976
+ elements[rootId] = onlyChild;
977
+ }
978
+ else {
979
+ // Multiple children — wrap in a ColumnSet container
980
+ elements[rootId] = {
981
+ id: rootId,
982
+ type: 'ColumnSet',
983
+ props: {
984
+ slots: {
985
+ default: { children: childIds },
986
+ },
987
+ },
988
+ };
989
+ }
990
+ // Build variables from legacy metadata
991
+ const variables = {
992
+ _legacy: {
993
+ cardType: legacy.cardType,
994
+ cardName: legacy.cardName,
995
+ text: legacy.text,
996
+ description: legacy.description,
997
+ language: legacy.extInfo?.language,
998
+ extInfo: legacy.extInfo,
999
+ },
1000
+ };
1001
+ return {
1002
+ version: '1.0',
1003
+ rootID: rootId,
1004
+ elements,
1005
+ variables,
1006
+ };
1007
+ }
1008
+ // ─── Recursive Item Converter ───────────────────────────────────
1009
+ function convertContentItem(item, elements, tracking) {
1010
+ const id = uid(item.type);
1011
+ const type = mapType(item.type);
1012
+ const content = item.content ?? {};
1013
+ let props;
1014
+ let events;
1015
+ // Type-specific conversion
1016
+ switch (item.type) {
1017
+ case 'text':
1018
+ props = convertTextContent(content);
1019
+ break;
1020
+ case 'button': {
1021
+ const result = convertButtonContent(content);
1022
+ props = result.props;
1023
+ events = result.events;
1024
+ break;
1025
+ }
1026
+ case 'image':
1027
+ props = convertImageContent(content);
1028
+ break;
1029
+ case 'form':
1030
+ props = convertFormContent(content);
1031
+ break;
1032
+ case 'group': {
1033
+ // Group contains nested items
1034
+ const groupChildren = Array.isArray(content.items)
1035
+ ? content.items
1036
+ : content.children
1037
+ ? (Array.isArray(content.children) ? content.children : [content.children])
1038
+ : [];
1039
+ const groupChildIds = [];
1040
+ for (const child of groupChildren) {
1041
+ const childNode = convertContentItem(child, elements, tracking);
1042
+ groupChildIds.push(childNode.id);
1043
+ }
1044
+ props = {
1045
+ slots: {
1046
+ default: { children: groupChildIds },
1047
+ },
1048
+ };
1049
+ // Merge groupInfo
1050
+ if (item.groupInfo) {
1051
+ props.groupInfo = item.groupInfo;
1052
+ }
1053
+ break;
1054
+ }
1055
+ default:
1056
+ props = convertGenericContent(content);
1057
+ break;
1058
+ }
1059
+ // Apply tracking as lifecycle / events
1060
+ const lifecycle = convertTracking(tracking);
1061
+ const element = {
1062
+ id,
1063
+ type,
1064
+ props,
1065
+ ...(events ? { events } : {}),
1066
+ ...(lifecycle ? { lifecycle } : {}),
1067
+ };
1068
+ elements[id] = element;
1069
+ return element;
1070
+ }
1071
+ // ─── Tracking → Lifecycle/Events ────────────────────────────────
1072
+ function convertTracking(tracking) {
1073
+ if (!tracking)
1074
+ return undefined;
1075
+ const lifecycle = {};
1076
+ if (tracking.type === 'expo') {
1077
+ lifecycle.onExposed = [
1078
+ {
1079
+ type: 'emit',
1080
+ params: { event: 'tracking', payload: { spm: tracking.spm, type: 'expo' } },
1081
+ },
1082
+ ];
1083
+ }
1084
+ if (tracking.type === 'click') {
1085
+ // Click tracking is handled at the element level via events,
1086
+ // but we also attach an onMount hook to register the tracking context
1087
+ lifecycle.onMount = [
1088
+ {
1089
+ type: 'emit',
1090
+ params: { event: 'tracking:register', payload: { spm: tracking.spm, type: 'click' } },
1091
+ },
1092
+ ];
1093
+ }
1094
+ return Object.keys(lifecycle).length > 0 ? lifecycle : undefined;
1095
+ }
1096
+ // ─── Helpers ────────────────────────────────────────────────────
1097
+ function staticValue(value) {
1098
+ return { type: 'static', value };
1099
+ }
1100
+
1101
+ /**
1102
+ * Schema Parser — resolves a CardSchema into a renderable tree.
1103
+ *
1104
+ * A CardSchema contains a flat `elements` map plus a `rootID` entry point,
1105
+ * global `variables`, and optional global `actions`.
1106
+ */
1107
+ // ─── Normalize ──────────────────────────────────────────────────
1108
+ /**
1109
+ * Normalize any supported schema input into the current CardSchema.
1110
+ *
1111
+ * If the input is already a CardSchema, it is returned as-is.
1112
+ * If it is a legacy format, it is automatically converted.
1113
+ */
1114
+ function normalizeSchema(input) {
1115
+ if (isLegacySchema(input)) {
1116
+ return convertLegacySchema(input);
1117
+ }
1118
+ return input;
1119
+ }
1120
+ // ─── Parser ──────────────────────────────────────────────────────
1121
+ /**
1122
+ * Parse a CardSchema into a nested RenderTreeNode starting from `rootID`.
1123
+ *
1124
+ * Children are resolved through `props.slots` — each slot's `children` array
1125
+ * lists element IDs that become nested RenderTreeNodes.
1126
+ */
1127
+ function parseSchema(input) {
1128
+ const schema = normalizeSchema(input);
1129
+ const { elements, rootID } = schema;
1130
+ const visited = new Set();
1131
+ function buildNode(id) {
1132
+ if (visited.has(id)) {
1133
+ throw new Error(`[SchemaParser] Circular reference detected at "${id}"`);
1134
+ }
1135
+ const element = elements[id];
1136
+ if (!element) {
1137
+ throw new Error(`[SchemaParser] Missing element for id "${id}"`);
1138
+ }
1139
+ visited.add(id);
1140
+ // Collect children from all slots (children + groups + config.overlays)
1141
+ const children = [];
1142
+ if (element.props.slots) {
1143
+ for (const slot of Object.values(element.props.slots)) {
1144
+ if (slot.children) {
1145
+ for (const childId of slot.children) {
1146
+ children.push(buildNode(childId));
1147
+ }
1148
+ }
1149
+ if (slot.groups) {
1150
+ for (const group of slot.groups) {
1151
+ for (const childId of group) {
1152
+ children.push(buildNode(childId));
1153
+ }
1154
+ }
1155
+ }
1156
+ if (slot.config?.overlays) {
1157
+ for (const overlay of slot.config.overlays) {
1158
+ if (overlay.children) {
1159
+ for (const childId of overlay.children) {
1160
+ children.push(buildNode(childId));
1161
+ }
1162
+ }
1163
+ }
1164
+ }
1165
+ }
1166
+ }
1167
+ visited.delete(id); // allow same element across different branches
1168
+ return {
1169
+ id,
1170
+ type: element.type,
1171
+ props: element.props,
1172
+ children,
1173
+ lifecycle: element.lifecycle,
1174
+ events: element.events,
1175
+ directives: element.directives,
1176
+ };
1177
+ }
1178
+ return buildNode(rootID);
1179
+ }
1180
+ // ─── Validation ──────────────────────────────────────────────────
1181
+ /**
1182
+ * Validate a CardSchema and return any error messages.
1183
+ */
1184
+ function validateSchema(input) {
1185
+ const schema = normalizeSchema(input);
1186
+ const errors = [];
1187
+ if (!schema.version) {
1188
+ errors.push('Missing "version" field');
1189
+ }
1190
+ if (!schema.rootID) {
1191
+ errors.push('Missing "rootID" field');
1192
+ }
1193
+ else if (!schema.elements[schema.rootID]) {
1194
+ errors.push(`Root element "${schema.rootID}" not found in elements`);
1195
+ }
1196
+ const allIds = new Set(Object.keys(schema.elements));
1197
+ for (const [id, element] of Object.entries(schema.elements)) {
1198
+ if (!element.type) {
1199
+ errors.push(`Element "${id}" is missing a "type" field`);
1200
+ }
1201
+ // Validate slot children and groups references
1202
+ if (element.props.slots) {
1203
+ for (const [slotName, slot] of Object.entries(element.props.slots)) {
1204
+ if (slot.children) {
1205
+ for (const childId of slot.children) {
1206
+ if (!allIds.has(childId)) {
1207
+ errors.push(`Element "${id}" slot "${slotName}" references unknown child "${childId}"`);
1208
+ }
1209
+ }
1210
+ }
1211
+ if (slot.groups) {
1212
+ for (const group of slot.groups) {
1213
+ for (const childId of group) {
1214
+ if (!allIds.has(childId)) {
1215
+ errors.push(`Element "${id}" slot "${slotName}" group references unknown child "${childId}"`);
1216
+ }
1217
+ }
1218
+ }
1219
+ }
1220
+ }
1221
+ }
1222
+ }
1223
+ return errors;
1224
+ }
1225
+
1226
+ /**
1227
+ * Lifecycle Manager — manages mount / exposed / destroy hooks for card elements.
1228
+ *
1229
+ * Each element can declare lifecycle ActionStep chains (onMount, onExposed, onDestroy).
1230
+ * The manager tracks mounted/exposed state and runs the corresponding chains via
1231
+ * the ActionRunner.
1232
+ */
1233
+ // ─── Manager ─────────────────────────────────────────────────────
1234
+ class LifecycleManager {
1235
+ constructor() {
1236
+ this.lifecycles = new Map();
1237
+ this.mounted = new Set();
1238
+ this.exposed = new Set();
1239
+ }
1240
+ /**
1241
+ * Register lifecycle hooks for an element identified by `id`.
1242
+ */
1243
+ register(id, lifecycle) {
1244
+ if (lifecycle) {
1245
+ this.lifecycles.set(id, lifecycle);
1246
+ }
1247
+ return () => this.unregister(id);
1248
+ }
1249
+ /**
1250
+ * Remove lifecycle hooks for an element.
1251
+ */
1252
+ unregister(id) {
1253
+ this.lifecycles.delete(id);
1254
+ this.mounted.delete(id);
1255
+ this.exposed.delete(id);
1256
+ }
1257
+ /**
1258
+ * Trigger onMount for an element.
1259
+ */
1260
+ async mount(id, ctx = {}) {
1261
+ if (this.mounted.has(id))
1262
+ return;
1263
+ const lc = this.lifecycles.get(id);
1264
+ if (lc?.onMount) {
1265
+ await runActionSteps(lc.onMount, ctx);
1266
+ }
1267
+ this.mounted.add(id);
1268
+ }
1269
+ /**
1270
+ * Trigger onExposed for an element (entered viewport).
1271
+ */
1272
+ async markExposed(id, ctx = {}) {
1273
+ if (this.exposed.has(id))
1274
+ return;
1275
+ const lc = this.lifecycles.get(id);
1276
+ if (lc?.onExposed) {
1277
+ await runActionSteps(lc.onExposed, ctx);
1278
+ }
1279
+ this.exposed.add(id);
1280
+ }
1281
+ /**
1282
+ * Trigger onDestroy for an element.
1283
+ */
1284
+ async destroy(id, ctx = {}) {
1285
+ if (!this.mounted.has(id))
1286
+ return;
1287
+ const lc = this.lifecycles.get(id);
1288
+ if (lc?.onDestroy) {
1289
+ await runActionSteps(lc.onDestroy, ctx);
1290
+ }
1291
+ this.mounted.delete(id);
1292
+ this.exposed.delete(id);
1293
+ }
1294
+ /**
1295
+ * Check if an element is currently mounted.
1296
+ */
1297
+ isMounted(id) {
1298
+ return this.mounted.has(id);
1299
+ }
1300
+ /**
1301
+ * Destroy all elements and clear all hooks.
1302
+ */
1303
+ async dispose(ctx = {}) {
1304
+ const mountedIds = [...this.mounted];
1305
+ for (const id of mountedIds) {
1306
+ await this.destroy(id, ctx);
1307
+ }
1308
+ this.lifecycles.clear();
1309
+ this.mounted.clear();
1310
+ this.exposed.clear();
1311
+ }
1312
+ }
1313
+ /**
1314
+ * Create a new LifecycleManager instance.
1315
+ */
1316
+ function createLifecycleManager() {
1317
+ return new LifecycleManager();
1318
+ }
1319
+
1320
+ /**
1321
+ * A2UI v0.9 Envelope Adapter — converts Google A2UI protocol messages
1322
+ * into internal StreamingCommand objects.
1323
+ *
1324
+ * A2UI (https://a2ui.org) wraps each message as
1325
+ * `{ "version": "v0.9", "<messageType>": { ... } }`, while the internal
1326
+ * command stream uses a `type` discriminator field. The command names are
1327
+ * already aligned (createSurface / updateComponents / updateDataModel /
1328
+ * deleteSurface), so conversion is mostly an envelope unwrap plus a
1329
+ * component-shape mapping:
1330
+ *
1331
+ * - A2UI components are a flat array with a `component` discriminator and
1332
+ * top-level props (`{"id":"t1","component":"Text","content":...,"children":[...]}`)
1333
+ * - Internal elements are a map with `type` + nested `props`
1334
+ * (`{"t1":{"id":"t1","type":"Text","props":{content,slots:{default:{children}}}}}`)
1335
+ *
1336
+ * `appendContent` is accepted in envelope form as a non-standard extension
1337
+ * (A2UI has no token-append primitive; we keep ours for typewriter streaming).
1338
+ */
1339
+ // ─── Detection ───────────────────────────────────────────────────
1340
+ const ENVELOPE_KEYS = [
1341
+ 'createSurface',
1342
+ 'updateComponents',
1343
+ 'updateDataModel',
1344
+ 'deleteSurface',
1345
+ 'appendContent',
1346
+ ];
1347
+ /**
1348
+ * Check whether a parsed JSON object looks like an A2UI envelope
1349
+ * (no internal `type` discriminator, but exactly an A2UI action key).
1350
+ */
1351
+ function isA2UIEnvelope(msg) {
1352
+ if (!msg || typeof msg !== 'object')
1353
+ return false;
1354
+ if (typeof msg.type === 'string')
1355
+ return false; // internal command
1356
+ return ENVELOPE_KEYS.some((k) => msg[k] != null);
1357
+ }
1358
+ // ─── Conversion ──────────────────────────────────────────────────
1359
+ /**
1360
+ * Convert a flat A2UI component into an internal ElementNode.
1361
+ */
1362
+ function a2uiComponentToElement(comp) {
1363
+ const { id, component, children, slots, directives, events, lifecycle, ...props } = comp;
1364
+ const p = { ...props };
1365
+ if (children) {
1366
+ p.slots = { ...(p.slots ?? {}), default: { children } };
1367
+ }
1368
+ if (slots) {
1369
+ p.slots = { ...(p.slots ?? {}), ...slots };
1370
+ }
1371
+ const element = { id, type: component, props: p };
1372
+ if (directives)
1373
+ element.directives = directives;
1374
+ if (events)
1375
+ element.events = events;
1376
+ if (lifecycle)
1377
+ element.lifecycle = lifecycle;
1378
+ return element;
1379
+ }
1380
+ /**
1381
+ * Convert an A2UI v0.9 envelope message into an internal StreamingCommand.
1382
+ * Returns null for unrecognized messages.
1383
+ *
1384
+ * Notes:
1385
+ * - `catalogId` / `theme` / `sendDataModel` are accepted but currently ignored
1386
+ * (component catalog is built into the renderer).
1387
+ * - `createSurface.schema` is a convenience extension: a full internal schema
1388
+ * may be attached for template-style cards.
1389
+ * - A component with `id: "root"` sets the surface rootID (A2UI convention).
1390
+ */
1391
+ function a2uiToCommand(msg) {
1392
+ if (msg.createSurface) {
1393
+ const { surfaceId, schema } = msg.createSurface;
1394
+ return { type: 'createSurface', surfaceId, ...(schema ? { schema } : {}) };
1395
+ }
1396
+ if (msg.updateComponents) {
1397
+ const { surfaceId, components } = msg.updateComponents;
1398
+ const elements = {};
1399
+ let rootID;
1400
+ for (const comp of components ?? []) {
1401
+ if (!comp || typeof comp.id !== 'string' || typeof comp.component !== 'string')
1402
+ continue;
1403
+ elements[comp.id] = a2uiComponentToElement(comp);
1404
+ if (comp.id === 'root')
1405
+ rootID = 'root';
1406
+ }
1407
+ return { type: 'updateComponents', surfaceId, elements, ...(rootID ? { rootID } : {}) };
1408
+ }
1409
+ if (msg.updateDataModel) {
1410
+ const { surfaceId, path = '/', value = null } = msg.updateDataModel;
1411
+ return { type: 'updateDataModel', surfaceId, path, value };
1412
+ }
1413
+ if (msg.appendContent) {
1414
+ const { surfaceId, elementId, content } = msg.appendContent;
1415
+ return { type: 'appendContent', surfaceId, elementId, content };
1416
+ }
1417
+ if (msg.deleteSurface) {
1418
+ return { type: 'deleteSurface', surfaceId: msg.deleteSurface.surfaceId };
1419
+ }
1420
+ return null;
1421
+ }
1422
+
1423
+ /**
1424
+ * Streaming Message Parser — converts raw byte streams into typed command objects.
1425
+ *
1426
+ * Supports multiple transport formats:
1427
+ * - NDJSON (Newline-Delimited JSON): one JSON object per line
1428
+ * - SSE (Server-Sent Events): `data: {...}` format
1429
+ * - Chunked JSON: handles incomplete JSON that spans multiple chunks
1430
+ *
1431
+ * The parser maintains an internal buffer for handling partial messages
1432
+ * that arrive across chunk boundaries.
1433
+ */
1434
+ // ─── Parser ──────────────────────────────────────────────────────
1435
+ /**
1436
+ * Streaming message parser that converts raw text chunks from
1437
+ * SSE/WebSocket/fetch streaming into typed StreamingCommand objects.
1438
+ *
1439
+ * @example
1440
+ * ```ts
1441
+ * const parser = new StreamingParser();
1442
+ *
1443
+ * // Feed chunks as they arrive from the transport
1444
+ * socket.onmessage = (e) => {
1445
+ * const commands = parser.parse(e.data);
1446
+ * commands.forEach(cmd => engine.apply(cmd));
1447
+ * };
1448
+ *
1449
+ * // Flush remaining buffer when stream ends
1450
+ * socket.onclose = () => {
1451
+ * const remaining = parser.flush();
1452
+ * remaining.forEach(cmd => engine.apply(cmd));
1453
+ * };
1454
+ * ```
1455
+ */
1456
+ class StreamingParser {
1457
+ constructor(options = {}) {
1458
+ this.buffer = '';
1459
+ this.delimiter = options.delimiter ?? '\n';
1460
+ this.onParseError = options.onParseError;
1461
+ }
1462
+ /**
1463
+ * Parse a raw text chunk into an array of streaming commands.
1464
+ *
1465
+ * Incomplete lines are buffered internally and will be completed
1466
+ * when the next chunk arrives (or when flush() is called).
1467
+ *
1468
+ * @param chunk - Raw text chunk from the transport layer
1469
+ * @returns Array of successfully parsed commands (may be empty)
1470
+ */
1471
+ parse(chunk) {
1472
+ this.buffer += chunk;
1473
+ const commands = [];
1474
+ // Split by delimiter, keeping the last incomplete line in the buffer
1475
+ const lines = this.buffer.split(this.delimiter);
1476
+ this.buffer = lines.pop() ?? '';
1477
+ for (const line of lines) {
1478
+ const command = this.parseLine(line);
1479
+ if (command) {
1480
+ commands.push(command);
1481
+ }
1482
+ }
1483
+ return commands;
1484
+ }
1485
+ /**
1486
+ * Flush the internal buffer and attempt to parse any remaining content.
1487
+ *
1488
+ * Call this when the stream ends to ensure no messages are lost.
1489
+ *
1490
+ * @returns Array of commands parsed from the remaining buffer
1491
+ */
1492
+ flush() {
1493
+ const remaining = this.buffer.trim();
1494
+ this.buffer = '';
1495
+ if (!remaining)
1496
+ return [];
1497
+ const command = this.parseLine(remaining);
1498
+ return command ? [command] : [];
1499
+ }
1500
+ /**
1501
+ * Reset the parser state, clearing any buffered content.
1502
+ */
1503
+ reset() {
1504
+ this.buffer = '';
1505
+ }
1506
+ // ─── Private ────────────────────────────────────────────────────
1507
+ /**
1508
+ * Parse a single line into a StreamingCommand, handling SSE format.
1509
+ */
1510
+ parseLine(line) {
1511
+ const trimmed = line.trim();
1512
+ // Skip empty lines and SSE comments (lines starting with ':')
1513
+ if (!trimmed || trimmed.startsWith(':'))
1514
+ return null;
1515
+ // Handle SSE format: strip "data:" prefix
1516
+ let data = trimmed;
1517
+ if (data.startsWith('data:')) {
1518
+ data = data.slice(5).trim();
1519
+ }
1520
+ // Skip SSE control messages
1521
+ if (data === '[DONE]')
1522
+ return null;
1523
+ try {
1524
+ const parsed = JSON.parse(data);
1525
+ // Internal command format: has a `type` discriminator field
1526
+ if (parsed && typeof parsed.type === 'string') {
1527
+ return parsed;
1528
+ }
1529
+ // A2UI v0.9 envelope format: `{"version":"v0.9","createSurface":{...}}`
1530
+ if (isA2UIEnvelope(parsed)) {
1531
+ return a2uiToCommand(parsed);
1532
+ }
1533
+ return null;
1534
+ }
1535
+ catch (error) {
1536
+ this.onParseError?.(data, error);
1537
+ return null;
1538
+ }
1539
+ }
1540
+ }
1541
+
1542
+ /**
1543
+ * Streaming Engine — maintains card state and computes incremental changes.
1544
+ *
1545
+ * The engine acts as the central coordinator between the message parser
1546
+ * and the rendering layer. It maintains a mutable CardSchema state for
1547
+ * each surface and emits fine-grained change events that renderers can
1548
+ * use for efficient incremental updates.
1549
+ *
1550
+ * Key responsibilities:
1551
+ * - Manage multiple concurrent surfaces
1552
+ * - Apply streaming commands to internal state
1553
+ * - Compute parent-child relationships for component changes
1554
+ * - Emit typed events for the rendering layer
1555
+ */
1556
+ // ─── Engine ──────────────────────────────────────────────────────
1557
+ class StreamingEngine {
1558
+ constructor(listeners) {
1559
+ /** Active surfaces keyed by surfaceId */
1560
+ this.surfaces = new Map();
1561
+ this.listeners = listeners;
1562
+ }
1563
+ // ─── Public API ────────────────────────────────────────────────
1564
+ /**
1565
+ * Apply a single streaming command to the engine state.
1566
+ * This triggers the appropriate event listener after state mutation.
1567
+ */
1568
+ apply(command) {
1569
+ switch (command.type) {
1570
+ case 'createSurface':
1571
+ this.handleCreateSurface(command);
1572
+ break;
1573
+ case 'updateComponents':
1574
+ this.handleUpdateComponents(command);
1575
+ break;
1576
+ case 'updateDataModel':
1577
+ this.handleUpdateDataModel(command);
1578
+ break;
1579
+ case 'appendContent':
1580
+ this.handleAppendContent(command);
1581
+ break;
1582
+ case 'deleteSurface':
1583
+ this.handleDeleteSurface(command);
1584
+ break;
1585
+ }
1586
+ }
1587
+ /**
1588
+ * Apply multiple streaming commands in order.
1589
+ */
1590
+ applyBatch(commands) {
1591
+ for (const command of commands) {
1592
+ this.apply(command);
1593
+ }
1594
+ }
1595
+ /**
1596
+ * Get the current schema for a surface (read-only snapshot).
1597
+ */
1598
+ getSchema(surfaceId) {
1599
+ return this.surfaces.get(surfaceId);
1600
+ }
1601
+ /**
1602
+ * Get the current variables for a surface.
1603
+ */
1604
+ getVariables(surfaceId) {
1605
+ return this.surfaces.get(surfaceId)?.variables;
1606
+ }
1607
+ /**
1608
+ * Check if a surface exists.
1609
+ */
1610
+ hasSurface(surfaceId) {
1611
+ return this.surfaces.has(surfaceId);
1612
+ }
1613
+ /**
1614
+ * Dispose all surfaces and reset state.
1615
+ */
1616
+ dispose() {
1617
+ for (const surfaceId of this.surfaces.keys()) {
1618
+ this.listeners.onSurfaceDeleted(surfaceId);
1619
+ }
1620
+ this.surfaces.clear();
1621
+ }
1622
+ // ─── Command Handlers ──────────────────────────────────────────
1623
+ handleCreateSurface(cmd) {
1624
+ // (Re)creating a surface always resets its state. A2UI semantics: a
1625
+ // surfaceId is fixed once created and must be deleted before reuse, so a
1626
+ // repeated createSurface is treated as delete + create. Without a schema
1627
+ // an empty shell is stored — components then arrive via updateComponents.
1628
+ const schema = cmd.schema
1629
+ ? normalizeSchema(cmd.schema)
1630
+ : { version: '1.0', rootID: 'root', elements: {}, variables: {} };
1631
+ this.surfaces.set(cmd.surfaceId, schema);
1632
+ this.listeners.onSurfaceCreated(cmd.surfaceId, cmd.schema ?? null);
1633
+ }
1634
+ handleUpdateComponents(cmd) {
1635
+ let schema = this.surfaces.get(cmd.surfaceId);
1636
+ if (!schema) {
1637
+ // Implicit surface creation (v0.8 compatibility)
1638
+ schema = {
1639
+ version: '1.0',
1640
+ rootID: cmd.rootID ?? 'root',
1641
+ elements: {},
1642
+ variables: {},
1643
+ };
1644
+ this.surfaces.set(cmd.surfaceId, schema);
1645
+ }
1646
+ const changes = [];
1647
+ // Update rootID if provided
1648
+ if (cmd.rootID) {
1649
+ schema.rootID = cmd.rootID;
1650
+ }
1651
+ // Process element additions/updates.
1652
+ // Merge ALL elements first, THEN compute parent/index against the final
1653
+ // batch state — otherwise parentId depends on key order: a new child
1654
+ // processed before its parent's updated children array would resolve to
1655
+ // no parent, and incremental renderers would silently drop it.
1656
+ if (cmd.elements) {
1657
+ const isNewMap = new Map();
1658
+ for (const [id, element] of Object.entries(cmd.elements)) {
1659
+ isNewMap.set(id, !schema.elements[id]);
1660
+ schema.elements[id] = element;
1661
+ }
1662
+ for (const [id, element] of Object.entries(cmd.elements)) {
1663
+ const parentId = this.findParentId(schema, id);
1664
+ const index = parentId ? this.findChildIndex(schema, parentId, id) : undefined;
1665
+ changes.push({
1666
+ changeType: isNewMap.get(id) ? 'add' : 'update',
1667
+ elementId: id,
1668
+ element,
1669
+ parentId,
1670
+ index,
1671
+ });
1672
+ }
1673
+ }
1674
+ // Process element removals
1675
+ if (cmd.removeElements) {
1676
+ for (const id of cmd.removeElements) {
1677
+ if (schema.elements[id]) {
1678
+ changes.push({
1679
+ changeType: 'remove',
1680
+ elementId: id,
1681
+ parentId: this.findParentId(schema, id),
1682
+ });
1683
+ delete schema.elements[id];
1684
+ // Also remove from any parent's children lists
1685
+ this.removeFromParentSlots(schema, id);
1686
+ }
1687
+ }
1688
+ }
1689
+ if (changes.length > 0) {
1690
+ this.listeners.onComponentsUpdated(cmd.surfaceId, changes);
1691
+ }
1692
+ }
1693
+ handleUpdateDataModel(cmd) {
1694
+ const schema = this.surfaces.get(cmd.surfaceId);
1695
+ if (!schema)
1696
+ return;
1697
+ // Set value at path in variables
1698
+ setByPath(schema.variables, cmd.path, cmd.value);
1699
+ this.listeners.onDataModelUpdated(cmd.surfaceId, cmd.path, cmd.value);
1700
+ }
1701
+ handleAppendContent(cmd) {
1702
+ const schema = this.surfaces.get(cmd.surfaceId);
1703
+ if (!schema)
1704
+ return;
1705
+ // Update internal schema state
1706
+ const element = schema.elements[cmd.elementId];
1707
+ if (element) {
1708
+ const currentContent = typeof element.props.content === 'string'
1709
+ ? element.props.content
1710
+ : '';
1711
+ element.props.content = currentContent + cmd.content;
1712
+ }
1713
+ this.listeners.onContentAppended(cmd.surfaceId, cmd.elementId, cmd.content);
1714
+ }
1715
+ handleDeleteSurface(cmd) {
1716
+ this.surfaces.delete(cmd.surfaceId);
1717
+ this.listeners.onSurfaceDeleted(cmd.surfaceId);
1718
+ }
1719
+ // ─── Helpers ────────────────────────────────────────────────────
1720
+ /**
1721
+ * Find the parent element ID for a given element by scanning all slots.
1722
+ */
1723
+ findParentId(schema, elementId) {
1724
+ for (const [id, element] of Object.entries(schema.elements)) {
1725
+ if (id === elementId)
1726
+ continue;
1727
+ if (this.elementContainsChild(element, elementId)) {
1728
+ return id;
1729
+ }
1730
+ }
1731
+ return undefined;
1732
+ }
1733
+ /**
1734
+ * Find the index of a child element within its parent's slot children.
1735
+ */
1736
+ findChildIndex(schema, parentId, childId) {
1737
+ const parent = schema.elements[parentId];
1738
+ if (!parent?.props.slots)
1739
+ return undefined;
1740
+ for (const slot of Object.values(parent.props.slots)) {
1741
+ if (slot.children) {
1742
+ const idx = slot.children.indexOf(childId);
1743
+ if (idx >= 0)
1744
+ return idx;
1745
+ }
1746
+ if (slot.groups) {
1747
+ for (const group of slot.groups) {
1748
+ const idx = group.indexOf(childId);
1749
+ if (idx >= 0)
1750
+ return idx;
1751
+ }
1752
+ }
1753
+ }
1754
+ return undefined;
1755
+ }
1756
+ /**
1757
+ * Check if an element references the given child ID in any of its slots.
1758
+ */
1759
+ elementContainsChild(element, childId) {
1760
+ if (!element.props.slots)
1761
+ return false;
1762
+ for (const slot of Object.values(element.props.slots)) {
1763
+ if (slot.children?.includes(childId))
1764
+ return true;
1765
+ if (slot.groups?.some(group => group.includes(childId)))
1766
+ return true;
1767
+ if (slot.config?.overlays) {
1768
+ for (const overlay of slot.config.overlays) {
1769
+ if (overlay.children?.includes(childId))
1770
+ return true;
1771
+ }
1772
+ }
1773
+ }
1774
+ return false;
1775
+ }
1776
+ /**
1777
+ * Remove a child ID from all parent element slot references.
1778
+ */
1779
+ removeFromParentSlots(schema, childId) {
1780
+ for (const element of Object.values(schema.elements)) {
1781
+ if (!element.props.slots)
1782
+ continue;
1783
+ for (const slot of Object.values(element.props.slots)) {
1784
+ if (slot.children) {
1785
+ const idx = slot.children.indexOf(childId);
1786
+ if (idx >= 0)
1787
+ slot.children.splice(idx, 1);
1788
+ }
1789
+ if (slot.groups) {
1790
+ for (const group of slot.groups) {
1791
+ const idx = group.indexOf(childId);
1792
+ if (idx >= 0)
1793
+ group.splice(idx, 1);
1794
+ }
1795
+ }
1796
+ }
1797
+ }
1798
+ }
1799
+ }
1800
+ // ─── Utility Functions ────────────────────────────────────────────
1801
+ /**
1802
+ * Keys that must never be written through a data-model path. Walking or
1803
+ * assigning into `__proto__` / `constructor` / `prototype` lets a crafted
1804
+ * `updateDataModel` command reach `Object.prototype` and pollute every object
1805
+ * in the runtime. Streaming commands originate from the agent/LLM stream, so
1806
+ * this path is attacker-influenceable and must be guarded.
1807
+ */
1808
+ const UNSAFE_PATH_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
1809
+ /** Merge only own, non-dangerous keys of `src` into `target`. */
1810
+ function safeMerge(target, src) {
1811
+ for (const key of Object.keys(src)) {
1812
+ if (UNSAFE_PATH_KEYS.has(key))
1813
+ continue;
1814
+ target[key] = src[key];
1815
+ }
1816
+ }
1817
+ /**
1818
+ * Set a value at a JSON Pointer-like path in an object.
1819
+ *
1820
+ * Path format: '/key1/key2/key3' → obj.key1.key2.key3 = value
1821
+ * Root path '/' sets the entire object.
1822
+ *
1823
+ * Prototype-polluting segments (`__proto__` / `constructor` / `prototype`) are
1824
+ * rejected — the whole write is dropped rather than silently retargeted.
1825
+ *
1826
+ * @example
1827
+ * ```ts
1828
+ * const obj = { user: { name: 'Alice' } };
1829
+ * setByPath(obj, '/user/name', 'Bob');
1830
+ * // obj.user.name === 'Bob'
1831
+ * ```
1832
+ */
1833
+ function setByPath(obj, path, value) {
1834
+ // Remove leading slash and split
1835
+ const parts = path.replace(/^\//, '').split('/').filter(Boolean);
1836
+ // Reject any dangerous segment outright — never partially apply.
1837
+ if (parts.some((p) => UNSAFE_PATH_KEYS.has(p))) {
1838
+ console.warn(`[StreamingEngine] Rejected unsafe data-model path "${path}"`);
1839
+ return;
1840
+ }
1841
+ if (parts.length === 0) {
1842
+ // Root-level update: merge value into obj (own, safe keys only)
1843
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
1844
+ safeMerge(obj, value);
1845
+ }
1846
+ return;
1847
+ }
1848
+ let current = obj;
1849
+ for (let i = 0; i < parts.length - 1; i++) {
1850
+ const key = parts[i];
1851
+ if (current[key] === undefined || current[key] === null) {
1852
+ current[key] = {};
1853
+ }
1854
+ current = current[key];
1855
+ }
1856
+ current[parts[parts.length - 1]] = value;
1857
+ }
1858
+
1859
+ /**
1860
+ * Partial Schema Extractor — pulls completed pieces out of an INCOMPLETE
1861
+ * card-schema JSON text while it is still streaming in from an LLM.
1862
+ *
1863
+ * A card schema is `{"version","rootID","variables","elements":{id:{...},...}}`.
1864
+ * The `elements` map is flat, so every element value becomes parseable the
1865
+ * moment its own braces balance — long before the whole document closes.
1866
+ * This enables progressive rendering: blocks mount as they complete instead
1867
+ * of waiting out the entire (possibly multi-thousand-token) JSON.
1868
+ *
1869
+ * Design constraints:
1870
+ * - Pure & stateless: call it every frame with the ACCUMULATED text
1871
+ * (append-only); identical input → identical output.
1872
+ * - String-aware scanning: braces/quotes inside JSON string values
1873
+ * (including escapes) never confuse the balancer.
1874
+ * - Malformed single elements are skipped (the final full-document parse
1875
+ * in the caller is the correctness backstop).
1876
+ */
1877
+ const EMPTY = Object.freeze({ elements: {}, complete: false });
1878
+ // ─── Scanner primitives ──────────────────────────────────────────
1879
+ /**
1880
+ * Scan a balanced JSON value starting at `start` (must point at `{` or `[`).
1881
+ * Returns the index AFTER the closing bracket, or -1 if the text ends first.
1882
+ */
1883
+ function scanBalanced(text, start) {
1884
+ const open = text[start];
1885
+ const close = open === '{' ? '}' : ']';
1886
+ let depth = 0;
1887
+ let inStr = false;
1888
+ let esc = false;
1889
+ for (let i = start; i < text.length; i++) {
1890
+ const c = text[i];
1891
+ if (esc) {
1892
+ esc = false;
1893
+ continue;
1894
+ }
1895
+ if (c === '\\') {
1896
+ if (inStr)
1897
+ esc = true;
1898
+ continue;
1899
+ }
1900
+ if (c === '"') {
1901
+ inStr = !inStr;
1902
+ continue;
1903
+ }
1904
+ if (inStr)
1905
+ continue;
1906
+ if (c === open)
1907
+ depth++;
1908
+ else if (c === close) {
1909
+ depth--;
1910
+ if (depth === 0)
1911
+ return i + 1;
1912
+ }
1913
+ }
1914
+ return -1;
1915
+ }
1916
+ /** Scan a JSON string starting at `start` (must point at `"`). Returns index after closing quote, or -1. */
1917
+ function scanString(text, start) {
1918
+ let esc = false;
1919
+ for (let i = start + 1; i < text.length; i++) {
1920
+ const c = text[i];
1921
+ if (esc) {
1922
+ esc = false;
1923
+ continue;
1924
+ }
1925
+ if (c === '\\') {
1926
+ esc = true;
1927
+ continue;
1928
+ }
1929
+ if (c === '"')
1930
+ return i + 1;
1931
+ }
1932
+ return -1;
1933
+ }
1934
+ /** Skip whitespace from `i`. */
1935
+ function skipWs(text, i) {
1936
+ while (i < text.length && /\s/.test(text[i]))
1937
+ i++;
1938
+ return i;
1939
+ }
1940
+ /**
1941
+ * Find the top-level (depth-1) key `"elements"` outside of any string value.
1942
+ * Returns the index of its opening quote, or -1.
1943
+ */
1944
+ function findElementsKey(text) {
1945
+ let depth = 0;
1946
+ let i = 0;
1947
+ while (i < text.length) {
1948
+ const c = text[i];
1949
+ if (c === '"') {
1950
+ const end = scanString(text, i);
1951
+ if (end < 0)
1952
+ return -1; // unterminated string at stream edge
1953
+ // A depth-1 string followed by `:` is a top-level key
1954
+ if (depth === 1 && text.slice(i, end) === '"elements"') {
1955
+ const after = skipWs(text, end);
1956
+ if (text[after] === ':')
1957
+ return i;
1958
+ }
1959
+ i = end;
1960
+ continue;
1961
+ }
1962
+ if (c === '{' || c === '[')
1963
+ depth++;
1964
+ else if (c === '}' || c === ']')
1965
+ depth--;
1966
+ i++;
1967
+ }
1968
+ return -1;
1969
+ }
1970
+ // ─── Main API ────────────────────────────────────────────────────
1971
+ /**
1972
+ * Extract completed pieces from (possibly incomplete) schema JSON text.
1973
+ *
1974
+ * @param text - Accumulated schema JSON text, starting at the document `{`.
1975
+ * Surrounding whitespace is tolerated; surrounding prose is not
1976
+ * (strip markers like `<card>` before calling).
1977
+ */
1978
+ function extractPartialSchema(text) {
1979
+ const doc = text.trim();
1980
+ if (!doc.startsWith('{'))
1981
+ return EMPTY;
1982
+ // 1) Whole document already closed → authoritative full parse
1983
+ try {
1984
+ const full = JSON.parse(doc);
1985
+ if (full && typeof full === 'object') {
1986
+ return {
1987
+ version: full.version,
1988
+ rootID: full.rootID,
1989
+ variables: full.variables,
1990
+ elements: full.elements && typeof full.elements === 'object' ? full.elements : {},
1991
+ complete: true,
1992
+ };
1993
+ }
1994
+ }
1995
+ catch {
1996
+ // still streaming — fall through to partial extraction
1997
+ }
1998
+ // 2) Locate the top-level "elements" key
1999
+ const keyStart = findElementsKey(doc);
2000
+ if (keyStart < 0)
2001
+ return EMPTY; // header still streaming
2002
+ // 3) Header fields (version/rootID/variables precede elements per output
2003
+ // convention; they are scalars/small objects so they closed long ago).
2004
+ // Rebuild a tiny JSON doc from the prefix and parse it.
2005
+ let version;
2006
+ let rootID;
2007
+ let variables;
2008
+ const headRaw = doc.slice(0, keyStart).replace(/,\s*$/, '') + '}';
2009
+ try {
2010
+ const head = JSON.parse(headRaw);
2011
+ version = typeof head.version === 'string' ? head.version : undefined;
2012
+ rootID = typeof head.rootID === 'string' ? head.rootID : undefined;
2013
+ variables = head.variables && typeof head.variables === 'object' ? head.variables : undefined;
2014
+ }
2015
+ catch {
2016
+ // header irregular (e.g. elements emitted first) — elements still extractable
2017
+ }
2018
+ // 4) Walk the elements object body, harvesting balanced entries
2019
+ const elements = {};
2020
+ let i = skipWs(doc, keyStart + '"elements"'.length);
2021
+ if (doc[i] !== ':')
2022
+ return { version, rootID, variables, elements, complete: false };
2023
+ i = skipWs(doc, i + 1);
2024
+ if (doc[i] !== '{')
2025
+ return { version, rootID, variables, elements, complete: false };
2026
+ i++;
2027
+ for (;;) {
2028
+ i = skipWs(doc, i);
2029
+ if (doc[i] === ',') {
2030
+ i++;
2031
+ continue;
2032
+ }
2033
+ if (i >= doc.length || doc[i] === '}')
2034
+ break; // stream edge or map closed
2035
+ if (doc[i] !== '"')
2036
+ break; // malformed — stop harvesting
2037
+ const keyEnd = scanString(doc, i);
2038
+ if (keyEnd < 0)
2039
+ break; // key itself cut by stream edge
2040
+ let key;
2041
+ try {
2042
+ key = JSON.parse(doc.slice(i, keyEnd));
2043
+ }
2044
+ catch {
2045
+ break;
2046
+ }
2047
+ let j = skipWs(doc, keyEnd);
2048
+ if (doc[j] !== ':')
2049
+ break;
2050
+ j = skipWs(doc, j + 1);
2051
+ if (doc[j] !== '{')
2052
+ break; // element values must be objects
2053
+ const valueEnd = scanBalanced(doc, j);
2054
+ if (valueEnd < 0)
2055
+ break; // this element still streaming
2056
+ try {
2057
+ const el = JSON.parse(doc.slice(j, valueEnd));
2058
+ if (el && typeof el === 'object' && typeof el.type === 'string') {
2059
+ elements[key] = el;
2060
+ }
2061
+ // parse ok but shape wrong → skip silently (final full parse is backstop)
2062
+ }
2063
+ catch {
2064
+ // malformed single element → skip; keep harvesting the rest
2065
+ }
2066
+ i = valueEnd;
2067
+ }
2068
+ return { version, rootID, variables, elements, complete: false };
2069
+ }
2070
+
2071
+ export { ActionRegistry, LifecycleManager, StreamingEngine, StreamingParser, a2uiComponentToElement, a2uiToCommand, convertLegacySchema, createLifecycleManager, extractPartialSchema, getByPath$1 as getByPath, hasExpression, interpolate, isA2UIEnvelope, isLegacySchema, normalizeSchema, parseSchema, registerActionHandler, registry, resetIdCounter, resolveActionRef, resolveDeep, resolveExpression, resolveExpressionValue, runActionStep, runActionSteps, setByPath, validateSchema };
2072
+ //# sourceMappingURL=index.esm.js.map