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