agentic-ui-kit 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -2
- package/dist/core.js +96 -1
- package/dist/index.js +1482 -304
- package/dist/styles.css +1 -1
- package/dist/tailwind.css +33 -3
- package/dist/types/core/src/index.d.ts +8 -1
- package/dist/types/core/src/partial-json.d.ts +8 -0
- package/dist/types/core/src/types.d.ts +5 -0
- package/dist/types/core/src/ui-markup.d.ts +92 -0
- package/dist/types/core/src/ui-tree.d.ts +110 -0
- package/dist/types/core/src/visualize.d.ts +60 -0
- package/dist/types/react/src/components/chat-notice.d.ts +11 -0
- package/dist/types/react/src/components/composer.d.ts +7 -1
- package/dist/types/react/src/components/markdown.d.ts +19 -1
- package/dist/types/react/src/components/message-list.d.ts +10 -2
- package/dist/types/react/src/components/messages.d.ts +25 -4
- package/dist/types/react/src/components/tools.d.ts +20 -7
- package/dist/types/react/src/components/ui-tree.d.ts +87 -0
- package/dist/types/react/src/components/widget-frame.d.ts +97 -0
- package/dist/types/react/src/index.d.ts +23 -4
- package/dist/types/react/src/primitives/button.d.ts +10 -1
- package/dist/types/react/src/primitives/chat-theme.d.ts +13 -1
- package/dist/types/react/src/primitives/icons.d.ts +95 -0
- package/dist/types/react/src/primitives/labels.d.ts +193 -0
- package/dist/types/react/src/primitives/menu.d.ts +3 -1
- package/dist/types/react/src/primitives/segmented-control.d.ts +22 -0
- package/dist/types/react/src/views/chat-transcript.d.ts +27 -0
- package/dist/types/react/src/views/chat-view.d.ts +20 -3
- package/dist/ui-markup-DtMYer7I.js +1082 -0
- package/package.json +1 -1
- package/dist/sse-BA_ebMcH.js +0 -408
|
@@ -0,0 +1,1082 @@
|
|
|
1
|
+
// ../core/src/partial-json.ts
|
|
2
|
+
const tryParse = (text) => {
|
|
3
|
+
try {
|
|
4
|
+
return JSON.parse(text);
|
|
5
|
+
} catch {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Best-effort value of JSON that is still streaming, so a tool call's fields can show while the model
|
|
11
|
+
* writes them: `{"title":"Stand-up","day":"Tu` gives `{ title: "Stand-up", day: "Tu" }`. Open strings are
|
|
12
|
+
* cut where the text ends, open objects and arrays are closed, and a key without its value yet is left
|
|
13
|
+
* out. Returns undefined when nothing usable has arrived. One pass over the text.
|
|
14
|
+
*/
|
|
15
|
+
function parsePartialJson(text) {
|
|
16
|
+
const whole = tryParse(text);
|
|
17
|
+
if (whole !== void 0) return whole;
|
|
18
|
+
const stack = [];
|
|
19
|
+
const closers = () => stack.map((f) => f.close).reverse().join("");
|
|
20
|
+
let safe = 0;
|
|
21
|
+
let safeClose = "";
|
|
22
|
+
const valueDone = (end) => {
|
|
23
|
+
const top = stack.at(-1);
|
|
24
|
+
if (top) top.expect = "comma";
|
|
25
|
+
safe = end;
|
|
26
|
+
safeClose = closers();
|
|
27
|
+
};
|
|
28
|
+
let i = 0;
|
|
29
|
+
const n = text.length;
|
|
30
|
+
while (i < n) {
|
|
31
|
+
const ch = text[i];
|
|
32
|
+
if (ch === " " || ch === "\n" || ch === "\r" || ch === " ") {
|
|
33
|
+
i++;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const top = stack.at(-1);
|
|
37
|
+
if (ch === "\"") {
|
|
38
|
+
let j = i + 1;
|
|
39
|
+
let escaped = false;
|
|
40
|
+
for (; j < n; j++) {
|
|
41
|
+
const c = text[j];
|
|
42
|
+
if (escaped) escaped = false;
|
|
43
|
+
else if (c === "\\") escaped = true;
|
|
44
|
+
else if (c === "\"") break;
|
|
45
|
+
}
|
|
46
|
+
const isKey = top?.close === "}" && top.expect === "key";
|
|
47
|
+
if (j >= n) {
|
|
48
|
+
if (isKey) break;
|
|
49
|
+
const body = text.slice(i, n).replace(/\\(u[0-9a-fA-F]{0,3})?$/, "");
|
|
50
|
+
return tryParse(text.slice(0, i) + body + "\"" + closers()) ?? tryParse(text.slice(0, safe) + safeClose);
|
|
51
|
+
}
|
|
52
|
+
i = j + 1;
|
|
53
|
+
if (isKey) top.expect = "colon";
|
|
54
|
+
else valueDone(i);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (ch === ":") {
|
|
58
|
+
if (top) top.expect = "value";
|
|
59
|
+
i++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (ch === ",") {
|
|
63
|
+
if (top) top.expect = top.close === "}" ? "key" : "value";
|
|
64
|
+
i++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (ch === "{" || ch === "[") {
|
|
68
|
+
stack.push({
|
|
69
|
+
close: ch === "{" ? "}" : "]",
|
|
70
|
+
expect: ch === "{" ? "key" : "value"
|
|
71
|
+
});
|
|
72
|
+
i++;
|
|
73
|
+
safe = i;
|
|
74
|
+
safeClose = closers();
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (ch === "}" || ch === "]") {
|
|
78
|
+
stack.pop();
|
|
79
|
+
i++;
|
|
80
|
+
valueDone(i);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
let j = i;
|
|
84
|
+
while (j < n && !" \n\r ,]}:".includes(text[j])) j++;
|
|
85
|
+
if (j >= n) {
|
|
86
|
+
if (/^-?\d+(\.\d+)?$/.test(text.slice(i, j))) return tryParse(text.slice(0, j) + closers()) ?? tryParse(text.slice(0, safe) + safeClose);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
i = j;
|
|
90
|
+
valueDone(i);
|
|
91
|
+
}
|
|
92
|
+
return safe === 0 ? void 0 : tryParse(text.slice(0, safe) + safeClose);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* JSON as far as it is valid: a stray bracket midway keeps what came before it, instead of losing it all.
|
|
96
|
+
* `error` says where it broke, for feedback to the model.
|
|
97
|
+
*/
|
|
98
|
+
function salvageJson(text) {
|
|
99
|
+
try {
|
|
100
|
+
return { value: JSON.parse(text) };
|
|
101
|
+
} catch (error) {
|
|
102
|
+
const m = /position (\d+)|line 1 column (\d+)/.exec(String(error));
|
|
103
|
+
const at = m ? Number(m[1] ?? Number(m[2]) - 1) : text.length;
|
|
104
|
+
const near = text.slice(Math.max(0, at - 30), at + 10);
|
|
105
|
+
return {
|
|
106
|
+
value: parsePartialJson(text.slice(0, at)),
|
|
107
|
+
error: `invalid JSON at character ${at}, near ${JSON.stringify(near)}`
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// ../core/src/reduce.ts
|
|
112
|
+
/**
|
|
113
|
+
* Applies one stream event to the message it builds. Pure and immutable: returns the same object when the
|
|
114
|
+
* event changes nothing, so React can skip renders.
|
|
115
|
+
*
|
|
116
|
+
* Events with a seq at or below `message.lastSeq` are dropped, so replaying from an earlier seq after a
|
|
117
|
+
* reconnect is safe. Events other than run.start need the message to exist; without it they are dropped.
|
|
118
|
+
*/
|
|
119
|
+
function applyEvent(message, event) {
|
|
120
|
+
if (event.type === "run.start") {
|
|
121
|
+
if (message?.runId === event.runId && (message.lastSeq ?? -1) >= event.seq) return message;
|
|
122
|
+
if (message?.id === event.messageId) return {
|
|
123
|
+
...message,
|
|
124
|
+
status: "streaming",
|
|
125
|
+
runId: event.runId,
|
|
126
|
+
lastSeq: event.seq
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
id: event.messageId,
|
|
130
|
+
parentId: event.parentId,
|
|
131
|
+
role: "assistant",
|
|
132
|
+
status: "streaming",
|
|
133
|
+
parts: [],
|
|
134
|
+
createdAt: event.createdAt,
|
|
135
|
+
runId: event.runId,
|
|
136
|
+
lastSeq: event.seq,
|
|
137
|
+
...event.model !== void 0 && { model: event.model },
|
|
138
|
+
...event.effort !== void 0 && { effort: event.effort }
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (!message || message.runId !== event.runId) return message;
|
|
142
|
+
if ((message.lastSeq ?? -1) >= event.seq) return message;
|
|
143
|
+
const seen = {
|
|
144
|
+
...message,
|
|
145
|
+
lastSeq: event.seq
|
|
146
|
+
};
|
|
147
|
+
switch (event.type) {
|
|
148
|
+
case "part.start":
|
|
149
|
+
if (message.parts.some((p) => p.id === event.part.id)) return seen;
|
|
150
|
+
return {
|
|
151
|
+
...seen,
|
|
152
|
+
parts: [...message.parts, event.part]
|
|
153
|
+
};
|
|
154
|
+
case "part.delta": return updatePart(seen, event.partId, (part) => appendDelta(part, event.text, event.inputJson));
|
|
155
|
+
case "part.update": return updatePart(seen, event.partId, (part) => applyFields(part, event));
|
|
156
|
+
case "part.end": return updatePart(seen, event.partId, (part) => closePart(applyFields(part, event)));
|
|
157
|
+
case "run.usage": return {
|
|
158
|
+
...seen,
|
|
159
|
+
usage: event.usage
|
|
160
|
+
};
|
|
161
|
+
case "run.end": return {
|
|
162
|
+
...seen,
|
|
163
|
+
status: event.status,
|
|
164
|
+
parts: message.parts.map((p) => closeOpenPart(p, event.status)),
|
|
165
|
+
...event.usage !== void 0 && { usage: event.usage },
|
|
166
|
+
...event.error !== void 0 && { error: event.error }
|
|
167
|
+
};
|
|
168
|
+
case "title": return seen;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Builds a message from a full event list, e.g. a stored run or a test fixture. */
|
|
172
|
+
function reduceEvents(events, initial) {
|
|
173
|
+
let message = initial;
|
|
174
|
+
for (const event of events) message = applyEvent(message, event);
|
|
175
|
+
return message;
|
|
176
|
+
}
|
|
177
|
+
function updatePart(message, partId, update) {
|
|
178
|
+
const index = message.parts.findIndex((p) => p.id === partId);
|
|
179
|
+
if (index === -1) return message;
|
|
180
|
+
const parts = message.parts.slice();
|
|
181
|
+
parts[index] = update(parts[index]);
|
|
182
|
+
return {
|
|
183
|
+
...message,
|
|
184
|
+
parts
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function appendDelta(part, text = "", inputJson = "") {
|
|
188
|
+
switch (part.type) {
|
|
189
|
+
case "text":
|
|
190
|
+
case "thinking": return text ? {
|
|
191
|
+
...part,
|
|
192
|
+
text: part.text + text
|
|
193
|
+
} : part;
|
|
194
|
+
case "custom": return text ? {
|
|
195
|
+
...part,
|
|
196
|
+
text: (part.text ?? "") + text
|
|
197
|
+
} : part;
|
|
198
|
+
case "widget": return text ? {
|
|
199
|
+
...part,
|
|
200
|
+
html: (part.html ?? "") + text
|
|
201
|
+
} : part;
|
|
202
|
+
case "tool_call":
|
|
203
|
+
if (!inputJson) return part;
|
|
204
|
+
return {
|
|
205
|
+
...part,
|
|
206
|
+
inputJson: part.inputJson + inputJson,
|
|
207
|
+
partialInput: parsePartialJson(part.inputJson + inputJson)
|
|
208
|
+
};
|
|
209
|
+
case "attachment": return part;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** Copies the fields from `fields` that are valid for the part's type. */
|
|
213
|
+
function applyFields(part, fields) {
|
|
214
|
+
const pick = (...keys) => {
|
|
215
|
+
const out = {};
|
|
216
|
+
for (const key of keys) if (fields[key] !== void 0) out[key] = fields[key];
|
|
217
|
+
return out;
|
|
218
|
+
};
|
|
219
|
+
const status = pick("status");
|
|
220
|
+
switch (part.type) {
|
|
221
|
+
case "text": return {
|
|
222
|
+
...part,
|
|
223
|
+
...status
|
|
224
|
+
};
|
|
225
|
+
case "thinking": return {
|
|
226
|
+
...part,
|
|
227
|
+
...status,
|
|
228
|
+
...pick("title", "durationMs")
|
|
229
|
+
};
|
|
230
|
+
case "tool_call": {
|
|
231
|
+
const next = {
|
|
232
|
+
...part,
|
|
233
|
+
...status,
|
|
234
|
+
...pick("title", "subtitle", "durationMs", "input", "result", "data", "error")
|
|
235
|
+
};
|
|
236
|
+
return next.status === "input" || next.status === "streaming" ? next : withParsedInput(next);
|
|
237
|
+
}
|
|
238
|
+
case "attachment": return {
|
|
239
|
+
...part,
|
|
240
|
+
...status,
|
|
241
|
+
...pick("url")
|
|
242
|
+
};
|
|
243
|
+
case "widget": return {
|
|
244
|
+
...part,
|
|
245
|
+
...status,
|
|
246
|
+
...pick("title", "resourceUri")
|
|
247
|
+
};
|
|
248
|
+
case "custom": return {
|
|
249
|
+
...part,
|
|
250
|
+
...status,
|
|
251
|
+
...pick("data")
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function closePart(part) {
|
|
256
|
+
return withParsedInput(part.status === "error" ? part : {
|
|
257
|
+
...part,
|
|
258
|
+
status: "done"
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/** Parses a tool call's streamed input if it is complete JSON and not parsed yet. */
|
|
262
|
+
function withParsedInput(part) {
|
|
263
|
+
if (part.type !== "tool_call") return part;
|
|
264
|
+
const { partialInput: _, ...rest } = part;
|
|
265
|
+
if (part.input !== void 0 || !part.inputJson) return "partialInput" in part ? rest : part;
|
|
266
|
+
const input = parseJson(part.inputJson);
|
|
267
|
+
if (input !== void 0) return {
|
|
268
|
+
...rest,
|
|
269
|
+
input
|
|
270
|
+
};
|
|
271
|
+
return part.status === "error" ? rest : {
|
|
272
|
+
...rest,
|
|
273
|
+
status: "error",
|
|
274
|
+
error: part.error ?? "The tool input was cut off or is not valid JSON"
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/** At the end of a run, parts still open are closed. A tool call cut off by a stop or an error is an error. */
|
|
278
|
+
function closeOpenPart(part, runStatus) {
|
|
279
|
+
if (part.status === "done" || part.status === "error") return part;
|
|
280
|
+
if (part.type === "tool_call" && part.status === "awaiting" && runStatus === "done") return withParsedInput(part);
|
|
281
|
+
if (part.type === "tool_call" && runStatus !== "done") {
|
|
282
|
+
const error = part.error ?? (runStatus === "stopped" ? "Stopped" : "Failed");
|
|
283
|
+
return withParsedInput({
|
|
284
|
+
...part,
|
|
285
|
+
status: "error",
|
|
286
|
+
error
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return closePart(part);
|
|
290
|
+
}
|
|
291
|
+
function parseJson(text) {
|
|
292
|
+
try {
|
|
293
|
+
return JSON.parse(text);
|
|
294
|
+
} catch {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/** Rough token count of text, about four characters per token. For live counters, not billing. */
|
|
299
|
+
function estimateTokens(text) {
|
|
300
|
+
return Math.ceil(text.length / 4);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Tokens the model spent inside an activity group: its reasoning and the tool inputs it wrote. Tool
|
|
304
|
+
* results are not model output, so they do not count. An estimate; per-step counts are not reported.
|
|
305
|
+
*/
|
|
306
|
+
function activityTokens(parts) {
|
|
307
|
+
let chars = 0;
|
|
308
|
+
for (const part of parts) if (part.type === "thinking") chars += part.text.length;
|
|
309
|
+
else if (part.type === "tool_call") chars += part.inputJson.length;
|
|
310
|
+
return Math.ceil(chars / 4);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Output tokens of a message so far: the backend's count when it has sent one, else an estimate from
|
|
314
|
+
* everything the model has written (text, thinking, tool input, widget html).
|
|
315
|
+
*/
|
|
316
|
+
function outputTokens(message) {
|
|
317
|
+
if (message.usage?.outputTokens !== void 0) return message.usage.outputTokens;
|
|
318
|
+
let chars = 0;
|
|
319
|
+
for (const part of message.parts) if (part.type === "text" || part.type === "thinking") chars += part.text.length;
|
|
320
|
+
else if (part.type === "tool_call") chars += part.inputJson.length;
|
|
321
|
+
else if (part.type === "widget") chars += part.html?.length ?? 0;
|
|
322
|
+
else if (part.type === "custom") chars += part.text?.length ?? 0;
|
|
323
|
+
return Math.ceil(chars / 4);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The effort to send with a model: the chosen one if the model offers it, else the model's default, else
|
|
327
|
+
* none. Models without effort levels never get one.
|
|
328
|
+
*/
|
|
329
|
+
function resolveEffort(model, effort) {
|
|
330
|
+
const levels = model?.efforts;
|
|
331
|
+
if (!levels?.length) return void 0;
|
|
332
|
+
if (effort && levels.some((e) => e.id === effort)) return effort;
|
|
333
|
+
return model?.defaultEffort ?? levels[0].id;
|
|
334
|
+
}
|
|
335
|
+
/** Tool calls the client has to act on: run them (client tools) or ask the user (approvals). */
|
|
336
|
+
function awaitingToolCalls(message) {
|
|
337
|
+
return message.parts.filter((p) => p.type === "tool_call" && p.status === "awaiting");
|
|
338
|
+
}
|
|
339
|
+
/** Sets fields on one tool call locally, e.g. a client tool's result or the user's approval. */
|
|
340
|
+
function settleToolCall(message, partId, fields) {
|
|
341
|
+
return {
|
|
342
|
+
...message,
|
|
343
|
+
parts: message.parts.map((p) => p.id === partId && p.type === "tool_call" ? {
|
|
344
|
+
...p,
|
|
345
|
+
...fields
|
|
346
|
+
} : p)
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
// ../core/src/blocks.ts
|
|
350
|
+
function isActivityPart(part) {
|
|
351
|
+
return part.type === "thinking" || part.type === "tool_call";
|
|
352
|
+
}
|
|
353
|
+
function toBlocks(parts, standalone) {
|
|
354
|
+
const blocks = [];
|
|
355
|
+
for (const part of parts) {
|
|
356
|
+
if (part.type === "tool_call" && standalone?.(part)) {
|
|
357
|
+
blocks.push({
|
|
358
|
+
kind: "tool",
|
|
359
|
+
id: part.id,
|
|
360
|
+
part
|
|
361
|
+
});
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (!isActivityPart(part)) {
|
|
365
|
+
blocks.push({
|
|
366
|
+
kind: "part",
|
|
367
|
+
id: part.id,
|
|
368
|
+
part
|
|
369
|
+
});
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const last = blocks.at(-1);
|
|
373
|
+
if (last?.kind === "activity") last.parts.push(part);
|
|
374
|
+
else blocks.push({
|
|
375
|
+
kind: "activity",
|
|
376
|
+
id: part.id,
|
|
377
|
+
parts: [part]
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return blocks;
|
|
381
|
+
}
|
|
382
|
+
// ../core/src/sse.ts
|
|
383
|
+
/**
|
|
384
|
+
* Reads a server-sent event stream of `StreamEvent`s. Each SSE message carries one JSON event in `data`;
|
|
385
|
+
* comments and messages without data (keep-alives) are skipped.
|
|
386
|
+
*/
|
|
387
|
+
async function* readEventStream(body) {
|
|
388
|
+
const reader = body.getReader();
|
|
389
|
+
const decoder = new TextDecoder();
|
|
390
|
+
let buffer = "";
|
|
391
|
+
try {
|
|
392
|
+
for (;;) {
|
|
393
|
+
const { value, done } = await reader.read();
|
|
394
|
+
if (done) break;
|
|
395
|
+
buffer += decoder.decode(value, { stream: true });
|
|
396
|
+
let end;
|
|
397
|
+
while ((end = messageEnd(buffer)) !== -1) {
|
|
398
|
+
const event = parseMessage(buffer.slice(0, end));
|
|
399
|
+
buffer = buffer.slice(end).replace(BLANK_LINE_START, "");
|
|
400
|
+
if (event) yield event;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const event = parseMessage(buffer + decoder.decode());
|
|
404
|
+
if (event) yield event;
|
|
405
|
+
} finally {
|
|
406
|
+
reader.releaseLock();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/** Formats one event as an SSE message, for servers written in TS. */
|
|
410
|
+
function formatEvent(event) {
|
|
411
|
+
return `id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
412
|
+
}
|
|
413
|
+
const BLANK_LINE = /\r\n\r\n|\n\n|\r\r/;
|
|
414
|
+
const BLANK_LINE_START = /^(\r\n\r\n|\n\n|\r\r)/;
|
|
415
|
+
function messageEnd(buffer) {
|
|
416
|
+
const match = BLANK_LINE.exec(buffer);
|
|
417
|
+
return match ? match.index : -1;
|
|
418
|
+
}
|
|
419
|
+
function parseMessage(text) {
|
|
420
|
+
const data = [];
|
|
421
|
+
for (const line of text.split(/\r\n|\r|\n/)) if (line.startsWith("data:")) data.push(line.slice(line.startsWith("data: ") ? 6 : 5));
|
|
422
|
+
if (data.length === 0) return void 0;
|
|
423
|
+
return JSON.parse(data.join("\n"));
|
|
424
|
+
}
|
|
425
|
+
// ../core/src/ui-tree.ts
|
|
426
|
+
/**
|
|
427
|
+
* Host-registered components: instead of HTML, the model writes a small tree of components the host
|
|
428
|
+
* registered (trusted React, themed, able to read the app's own data). The kit validates each node against
|
|
429
|
+
* its component's schema; bad nodes render nothing. Idea from OpenUI (github.com/thesysdev/openui).
|
|
430
|
+
*/
|
|
431
|
+
/** Schema for a prop that holds an action, e.g. `{ onPress: uiActionSchema }`. */
|
|
432
|
+
const uiActionSchema = { anyOf: [
|
|
433
|
+
{
|
|
434
|
+
type: "object",
|
|
435
|
+
properties: {
|
|
436
|
+
action: { const: "set" },
|
|
437
|
+
key: { type: "string" },
|
|
438
|
+
value: {}
|
|
439
|
+
},
|
|
440
|
+
required: [
|
|
441
|
+
"action",
|
|
442
|
+
"key",
|
|
443
|
+
"value"
|
|
444
|
+
]
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
type: "object",
|
|
448
|
+
properties: {
|
|
449
|
+
action: { const: "send" },
|
|
450
|
+
text: {
|
|
451
|
+
type: "string",
|
|
452
|
+
minLength: 1
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
required: ["action", "text"]
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
type: "object",
|
|
459
|
+
properties: {
|
|
460
|
+
action: { const: "open" },
|
|
461
|
+
url: {
|
|
462
|
+
type: "string",
|
|
463
|
+
pattern: "^https?://"
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
required: ["action", "url"]
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
type: "object",
|
|
470
|
+
properties: {
|
|
471
|
+
action: { const: "copy" },
|
|
472
|
+
text: { type: "string" }
|
|
473
|
+
},
|
|
474
|
+
required: ["action", "text"]
|
|
475
|
+
}
|
|
476
|
+
] };
|
|
477
|
+
const renderUiToolName = "render_ui";
|
|
478
|
+
const typeOk = (type, value) => {
|
|
479
|
+
switch (type) {
|
|
480
|
+
case "string": return typeof value === "string";
|
|
481
|
+
case "number": return typeof value === "number" && Number.isFinite(value);
|
|
482
|
+
case "integer": return Number.isInteger(value);
|
|
483
|
+
case "boolean": return typeof value === "boolean";
|
|
484
|
+
case "null": return value === null;
|
|
485
|
+
case "array": return Array.isArray(value);
|
|
486
|
+
case "object": return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
487
|
+
default: return true;
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
const show = (v) => {
|
|
491
|
+
const text = JSON.stringify(v);
|
|
492
|
+
return text === void 0 ? String(v) : text.length > 40 ? text.slice(0, 37) + "..." : text;
|
|
493
|
+
};
|
|
494
|
+
const kind = (v) => v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
|
|
495
|
+
/**
|
|
496
|
+
* Why `value` doesn't fit `schema`, one line per problem, each starting with its path ("props.items[2].label").
|
|
497
|
+
* Empty when it fits. Covers the common JSON Schema keywords (type, enum, const, properties, required,
|
|
498
|
+
* additionalProperties, items, anyOf/oneOf/allOf, lengths, ranges, pattern); others are ignored.
|
|
499
|
+
*/
|
|
500
|
+
function schemaErrors(schema, value, path = "value") {
|
|
501
|
+
if (schema === void 0 || schema === true) return [];
|
|
502
|
+
if (schema === false) return [`${path}: not allowed`];
|
|
503
|
+
const s = schema;
|
|
504
|
+
if (s.type !== void 0 && ![s.type].flat().some((t) => typeOk(t, value))) return [`${path}: expected ${[s.type].flat().join(" or ")}, got ${kind(value)} ${show(value)}`];
|
|
505
|
+
if (s.const !== void 0 && JSON.stringify(s.const) !== JSON.stringify(value)) return [`${path}: must be ${show(s.const)}`];
|
|
506
|
+
if (Array.isArray(s.enum) && !s.enum.some((e) => JSON.stringify(e) === JSON.stringify(value))) return [`${path}: must be one of ${s.enum.map(show).join(", ")}, got ${show(value)}`];
|
|
507
|
+
const anyOf = s.anyOf ?? s.oneOf;
|
|
508
|
+
if (Array.isArray(anyOf)) {
|
|
509
|
+
const each = anyOf.map((sub) => schemaErrors(sub, value, path));
|
|
510
|
+
if (!each.some((e) => e.length === 0)) return each.reduce((a, b) => b.length < a.length ? b : a);
|
|
511
|
+
}
|
|
512
|
+
const out = [];
|
|
513
|
+
if (Array.isArray(s.allOf)) for (const sub of s.allOf) out.push(...schemaErrors(sub, value, path));
|
|
514
|
+
if (typeof value === "string") {
|
|
515
|
+
if (typeof s.minLength === "number" && value.length < s.minLength) out.push(`${path}: at least ${s.minLength} characters`);
|
|
516
|
+
if (typeof s.maxLength === "number" && value.length > s.maxLength) out.push(`${path}: at most ${s.maxLength} characters`);
|
|
517
|
+
if (typeof s.pattern === "string" && !new RegExp(s.pattern).test(value)) out.push(`${path}: must match ${s.pattern}, got ${show(value)}`);
|
|
518
|
+
}
|
|
519
|
+
if (typeof value === "number") {
|
|
520
|
+
if (typeof s.minimum === "number" && value < s.minimum) out.push(`${path}: at least ${s.minimum}, got ${value}`);
|
|
521
|
+
if (typeof s.maximum === "number" && value > s.maximum) out.push(`${path}: at most ${s.maximum}, got ${value}`);
|
|
522
|
+
}
|
|
523
|
+
if (Array.isArray(value)) {
|
|
524
|
+
if (typeof s.minItems === "number" && value.length < s.minItems) out.push(`${path}: at least ${s.minItems} items, got ${value.length}`);
|
|
525
|
+
if (typeof s.maxItems === "number" && value.length > s.maxItems) out.push(`${path}: at most ${s.maxItems} items, got ${value.length}`);
|
|
526
|
+
if (s.items !== void 0) value.forEach((v, i) => out.push(...schemaErrors(s.items, v, `${path}[${i}]`)));
|
|
527
|
+
}
|
|
528
|
+
if (typeOk("object", value)) {
|
|
529
|
+
const obj = value;
|
|
530
|
+
const props = s.properties ?? {};
|
|
531
|
+
if (Array.isArray(s.required)) {
|
|
532
|
+
for (const k of s.required) if (obj[k] === void 0) out.push(`${path}.${k}: required`);
|
|
533
|
+
}
|
|
534
|
+
for (const [k, v] of Object.entries(obj)) if (k in props) out.push(...schemaErrors(props[k], v, `${path}.${k}`));
|
|
535
|
+
else if (s.additionalProperties === false) out.push(`${path}.${k}: unknown prop (allowed: ${Object.keys(props).join(", ") || "none"})`);
|
|
536
|
+
else if (s.additionalProperties !== void 0) out.push(...schemaErrors(s.additionalProperties, v, `${path}.${k}`));
|
|
537
|
+
}
|
|
538
|
+
return out;
|
|
539
|
+
}
|
|
540
|
+
/** Whether `value` fits `schema`; see `schemaErrors` for why not. */
|
|
541
|
+
function matchesSchema(schema, value) {
|
|
542
|
+
return schemaErrors(schema, value).length === 0;
|
|
543
|
+
}
|
|
544
|
+
/** A prop that reads the widget's state: `{ "$state": "tab" }`. */
|
|
545
|
+
function isStateRef(value) {
|
|
546
|
+
return typeof value === "object" && value !== null && typeof value.$state === "string";
|
|
547
|
+
}
|
|
548
|
+
/** The render tool for these components, with a description generated from them. */
|
|
549
|
+
function renderUiTool(components) {
|
|
550
|
+
const list = components.map((c) => {
|
|
551
|
+
const props = c.props ? JSON.stringify(c.props) : "{}";
|
|
552
|
+
return `- ${c.name}${c.children ? " (takes children)" : ""}: ${c.description}\n props: ${props}`;
|
|
553
|
+
}).join("\n");
|
|
554
|
+
return {
|
|
555
|
+
name: renderUiToolName,
|
|
556
|
+
description: `Show an interactive visual built from the app's components, inline in your reply.
|
|
557
|
+
Write a tree: { "root": { "component": "<name>", "props": { ... }, "children": [ ...nodes ] }, "state": { ... } }.
|
|
558
|
+
"root" is a JSON object, never a string. Only these components exist; a node with an unknown component or
|
|
559
|
+
props that don't fit is not shown. One visual per answer: don't also draw the same thing another way.
|
|
560
|
+
${list}
|
|
561
|
+
|
|
562
|
+
Actions: a prop the schema marks as an action takes one of
|
|
563
|
+
{ "action": "set", "key": "<state key>", "value": <any> } (change the widget's state; prefer this),
|
|
564
|
+
{ "action": "send", "text": "..." } (send a message as the user, for what needs you),
|
|
565
|
+
{ "action": "open", "url": "https://..." }, { "action": "copy", "text": "..." }.
|
|
566
|
+
State: "state" holds starting values; any prop can read one with { "$state": "<key>" }.
|
|
567
|
+
Set apart what you added or changed from what was already there.`,
|
|
568
|
+
inputSchema: {
|
|
569
|
+
type: "object",
|
|
570
|
+
properties: {
|
|
571
|
+
root: {
|
|
572
|
+
type: "object",
|
|
573
|
+
description: "The top node.",
|
|
574
|
+
properties: { component: { enum: components.map((c) => c.name) } },
|
|
575
|
+
required: ["component"]
|
|
576
|
+
},
|
|
577
|
+
state: {
|
|
578
|
+
type: "object",
|
|
579
|
+
description: "Starting values of the widget's state."
|
|
580
|
+
}
|
|
581
|
+
},
|
|
582
|
+
required: ["root"]
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
function checkUiTree(input, components, options = {}) {
|
|
587
|
+
const { toolName = renderUiToolName, where } = typeof options === "string" ? { toolName: options } : options;
|
|
588
|
+
const errors = [];
|
|
589
|
+
const byName = new Map(components.map((c) => [c.name, c]));
|
|
590
|
+
const lower = new Map(components.map((c) => [c.name.toLowerCase(), c.name]));
|
|
591
|
+
let total = 0;
|
|
592
|
+
let bad = 0;
|
|
593
|
+
const fail = (line) => errors.push(line);
|
|
594
|
+
const unwrap = (key, value) => {
|
|
595
|
+
if (typeof value !== "string") return value;
|
|
596
|
+
const { value: parsed, error } = salvageJson(value);
|
|
597
|
+
fail(`${key}: must be a JSON object like {"component":"Stack","children":[...]}, not a string: don't put it in quotes or escape it${error ? ` (the string also has ${error})` : ""}`);
|
|
598
|
+
return parsed;
|
|
599
|
+
};
|
|
600
|
+
const i = typeof input === "string" ? unwrap("input", input) : input;
|
|
601
|
+
if (typeof i !== "object" || i === null) return {
|
|
602
|
+
ok: false,
|
|
603
|
+
errors: ["input: expected an object"],
|
|
604
|
+
feedback: `Not shown: the input must be an object { "root": { "component": ..., "props": {...}, "children": [...] }, "state": {...} }. Call ${toolName} again.`
|
|
605
|
+
};
|
|
606
|
+
const { root, state: rawState } = i;
|
|
607
|
+
const state = unwrap("state", rawState);
|
|
608
|
+
if (state !== void 0 && (typeof state !== "object" || state === null || Array.isArray(state))) fail("state: expected an object of starting values");
|
|
609
|
+
const values = typeof state === "object" && state !== null ? state : {};
|
|
610
|
+
const walk = (node, path) => {
|
|
611
|
+
if (Array.isArray(node) && path === "root") return node.forEach((n, k) => walk(n, `root[${k}]`));
|
|
612
|
+
total++;
|
|
613
|
+
const at = where ? where(node, path) : path;
|
|
614
|
+
const before = errors.length;
|
|
615
|
+
if (typeof node !== "object" || node === null || Array.isArray(node)) {
|
|
616
|
+
fail(`${at}: expected a node { "component": ..., "props": {...} }, got ${kind(node)}`);
|
|
617
|
+
bad++;
|
|
618
|
+
} else {
|
|
619
|
+
const { component, props = {}, children, ...rest } = node;
|
|
620
|
+
const extra = Object.keys(rest);
|
|
621
|
+
if (typeof props === "object" && props !== null && "children" in props) fail(`${at}.props.children: children go in the node's "children" array, next to "props", not inside props`);
|
|
622
|
+
if (extra.length) fail(`${at}: unknown key ${extra.map((k) => `"${k}"`).join(", ")} (a node has only component, props, children)`);
|
|
623
|
+
const spec = typeof component === "string" ? byName.get(component) : void 0;
|
|
624
|
+
if (typeof component !== "string") fail(`${at}.component: required, one of ${components.map((c) => c.name).join(", ")}`);
|
|
625
|
+
else if (!spec) {
|
|
626
|
+
const near = lower.get(component.toLowerCase());
|
|
627
|
+
fail(`${at}.component: unknown "${component}"${near ? `; did you mean "${near}"?` : `; use one of ${components.map((c) => c.name).join(", ")}`}`);
|
|
628
|
+
}
|
|
629
|
+
if (typeof props !== "object" || props === null || Array.isArray(props)) fail(`${at}.props: expected an object`);
|
|
630
|
+
else if (spec) {
|
|
631
|
+
const unset = /* @__PURE__ */ new Set();
|
|
632
|
+
const resolve = (v, at) => {
|
|
633
|
+
if (isStateRef(v)) {
|
|
634
|
+
if (!(v.$state in values)) unset.add(at);
|
|
635
|
+
return values[v.$state];
|
|
636
|
+
}
|
|
637
|
+
if (Array.isArray(v)) return v.map((x, n) => resolve(x, `${at}[${n}]`));
|
|
638
|
+
if (typeof v === "object" && v !== null) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, resolve(x, `${at}.${k}`)]).filter(([, x]) => x !== void 0));
|
|
639
|
+
return v;
|
|
640
|
+
};
|
|
641
|
+
const resolved = resolve(props, `${at}.props`);
|
|
642
|
+
for (const line of schemaErrors(spec.props ?? { type: "object" }, resolved, `${at}.props`)) {
|
|
643
|
+
if (line.startsWith(`${at}.props.children:`)) continue;
|
|
644
|
+
if (![...unset].some((u) => line.startsWith(u + ":") || line.startsWith(u + "."))) fail(line);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (errors.length > before) bad++;
|
|
648
|
+
if (children !== void 0) {
|
|
649
|
+
if (spec && !spec.children) fail(`${at}.children: ${spec.name} takes no children (they are not shown)`);
|
|
650
|
+
else if (!Array.isArray(children)) fail(`${at}.children: expected an array of nodes`);
|
|
651
|
+
else children.forEach((child, n) => walk(child, `${path}.children[${n}]`));
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
if (root === void 0) fail("root: required");
|
|
656
|
+
else walk(unwrap("root", root), "root");
|
|
657
|
+
const ok = errors.length === 0;
|
|
658
|
+
return {
|
|
659
|
+
ok,
|
|
660
|
+
errors,
|
|
661
|
+
feedback: ok ? "Shown to the user." : `${bad === 0 ? "Not shown as written" : `Only part of it is shown: ${bad} of ${total} nodes were left out`}. Fix these and call ${toolName} again, without remarking on the fix to the user:\n` + errors.slice(0, 20).map((e) => `- ${e}`).join("\n") + (errors.length > 20 ? `\n- ...and ${errors.length - 20} more` : "")
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
// ../core/src/ui-markup.ts
|
|
665
|
+
/**
|
|
666
|
+
* UI as JSX in the reply: the model writes a ```ui fenced block in its normal text, e.g.
|
|
667
|
+
*
|
|
668
|
+
* <State tab="stills" />
|
|
669
|
+
* <Card title="Pixel 9 Pro"><Metric label="Zoom" value="5x" /></Card>
|
|
670
|
+
*
|
|
671
|
+
* Every model has seen a lot of JSX; it streams element by element and needs no JSON escaping. The parser is
|
|
672
|
+
* lenient where the intent is clear (JS-style objects, `{$state.k}`, `{"$state":"k"}`, text children) and
|
|
673
|
+
* tolerates a block cut short while it streams.
|
|
674
|
+
*/
|
|
675
|
+
/** The fence language the kit draws as UI. */
|
|
676
|
+
const uiMarkupLanguage = "ui";
|
|
677
|
+
/** Reads a value written inside `{…}`: JSON, or a JS literal (unquoted keys, single quotes, trailing commas). */
|
|
678
|
+
function readUiValue(source) {
|
|
679
|
+
const text = source.trim();
|
|
680
|
+
const ref = /^\$?state\.([\w-]+)$/.exec(text);
|
|
681
|
+
if (ref) return { value: { $state: ref[1] } };
|
|
682
|
+
try {
|
|
683
|
+
return { value: JSON.parse(text) };
|
|
684
|
+
} catch {}
|
|
685
|
+
if (/^"[^"]+"\s*:/.test(text)) try {
|
|
686
|
+
return { value: JSON.parse(`{${text}}`) };
|
|
687
|
+
} catch {}
|
|
688
|
+
let i = 0;
|
|
689
|
+
const fail = (what) => {
|
|
690
|
+
throw new Error(`${what} at "${text.slice(Math.max(0, i - 10), i + 10)}"`);
|
|
691
|
+
};
|
|
692
|
+
const space = () => {
|
|
693
|
+
while (i < text.length && /\s/.test(text[i])) i++;
|
|
694
|
+
};
|
|
695
|
+
const value = () => {
|
|
696
|
+
space();
|
|
697
|
+
const c = text[i];
|
|
698
|
+
if (c === "{") {
|
|
699
|
+
i++;
|
|
700
|
+
const out = {};
|
|
701
|
+
for (;;) {
|
|
702
|
+
space();
|
|
703
|
+
if (text[i] === "}") return i++, out;
|
|
704
|
+
const key = text[i] === "\"" || text[i] === "'" ? str() : word();
|
|
705
|
+
space();
|
|
706
|
+
if (text[i++] !== ":") fail("expected :");
|
|
707
|
+
out[key] = value();
|
|
708
|
+
space();
|
|
709
|
+
if (text[i] === ",") i++;
|
|
710
|
+
else if (text[i] !== "}") fail("expected , or }");
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (c === "[") {
|
|
714
|
+
i++;
|
|
715
|
+
const out = [];
|
|
716
|
+
for (;;) {
|
|
717
|
+
space();
|
|
718
|
+
if (text[i] === "]") return i++, out;
|
|
719
|
+
out.push(value());
|
|
720
|
+
space();
|
|
721
|
+
if (text[i] === ",") i++;
|
|
722
|
+
else if (text[i] !== "]") fail("expected , or ]");
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (c === "\"" || c === "'" || c === "`") return str();
|
|
726
|
+
const w = word();
|
|
727
|
+
if (w === "true") return true;
|
|
728
|
+
if (w === "false") return false;
|
|
729
|
+
if (w === "null" || w === "undefined") return null;
|
|
730
|
+
const ref = /^\$?state\.([\w-]+)$/.exec(w);
|
|
731
|
+
if (ref) return { $state: ref[1] };
|
|
732
|
+
if (/^-?\d/.test(w) && !Number.isNaN(Number(w))) return Number(w);
|
|
733
|
+
return fail(w ? `unknown name ${w} (JavaScript expressions are not run; write the value out)` : "expected a value");
|
|
734
|
+
};
|
|
735
|
+
const word = () => {
|
|
736
|
+
const m = /^[\w$.+-]+/.exec(text.slice(i));
|
|
737
|
+
if (!m) return "";
|
|
738
|
+
i += m[0].length;
|
|
739
|
+
return m[0];
|
|
740
|
+
};
|
|
741
|
+
const str = () => {
|
|
742
|
+
const q = text[i++];
|
|
743
|
+
let out = "";
|
|
744
|
+
while (i < text.length && text[i] !== q) {
|
|
745
|
+
if (text[i] === "\\") {
|
|
746
|
+
const e = text[++i];
|
|
747
|
+
out += e === "n" ? "\n" : e === "t" ? " " : e;
|
|
748
|
+
} else out += text[i];
|
|
749
|
+
i++;
|
|
750
|
+
}
|
|
751
|
+
if (text[i++] !== q) fail("unclosed string");
|
|
752
|
+
return out;
|
|
753
|
+
};
|
|
754
|
+
try {
|
|
755
|
+
const v = value();
|
|
756
|
+
space();
|
|
757
|
+
if (i < text.length) fail("unexpected text");
|
|
758
|
+
return { value: v };
|
|
759
|
+
} catch (error) {
|
|
760
|
+
return { error: error.message };
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
/** Parses UI markup (the inside of a ```ui block). Safe on any prefix of it. */
|
|
764
|
+
function parseUiMarkup(source) {
|
|
765
|
+
const lines = /* @__PURE__ */ new WeakMap();
|
|
766
|
+
const errors = [];
|
|
767
|
+
const state = {};
|
|
768
|
+
const top = [];
|
|
769
|
+
const stack = [];
|
|
770
|
+
let i = 0;
|
|
771
|
+
const lineAt = (at) => source.slice(0, at).split("\n").length;
|
|
772
|
+
const add = (node) => stack.length ? (stack.at(-1).node.children ??= []).push(node) : top.push(node);
|
|
773
|
+
const braceEnd = (from) => {
|
|
774
|
+
let depth = 0;
|
|
775
|
+
for (let j = from; j < source.length; j++) {
|
|
776
|
+
const c = source[j];
|
|
777
|
+
if (c === "\"" || c === "'" || c === "`") {
|
|
778
|
+
for (j++; j < source.length && source[j] !== c; j++) if (source[j] === "\\") j++;
|
|
779
|
+
} else if (c === "{") depth++;
|
|
780
|
+
else if (c === "}" && --depth === 0) return j;
|
|
781
|
+
}
|
|
782
|
+
return -1;
|
|
783
|
+
};
|
|
784
|
+
const flushText = (end) => {
|
|
785
|
+
const open = stack.at(-1);
|
|
786
|
+
const text = source.slice(i, end);
|
|
787
|
+
if (!text.trim()) return;
|
|
788
|
+
if (open) open.text.push(text);
|
|
789
|
+
else errors.push(`line ${lineAt(i)}: text outside any component (put it in a component, e.g. <Text text="…" />)`);
|
|
790
|
+
};
|
|
791
|
+
const close = (open) => {
|
|
792
|
+
const text = open.text.join("").replace(/\s+/g, " ").trim();
|
|
793
|
+
if (text && !open.node.children?.length) {
|
|
794
|
+
const props = open.node.props ??= {};
|
|
795
|
+
if (!("text" in props)) props.text = text;
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
while (i < source.length) {
|
|
799
|
+
const lt = source.indexOf("<", i);
|
|
800
|
+
const brace = source.indexOf("{", i);
|
|
801
|
+
if (brace >= 0 && (lt < 0 || brace < lt) && stack.length) {
|
|
802
|
+
const end = braceEnd(brace);
|
|
803
|
+
flushText(brace);
|
|
804
|
+
const inner = source.slice(brace + 1, end < 0 ? void 0 : end).trim();
|
|
805
|
+
if (end >= 0 && !/^\/\*[\s\S]*\*\/$/.test(inner)) {
|
|
806
|
+
const { value, error } = readUiValue(inner);
|
|
807
|
+
if (typeof value === "string") stack.at(-1).text.push(value);
|
|
808
|
+
else errors.push(`line ${lineAt(brace)}: {${inner.slice(0, 40)}} as a child is not supported${error ? ` (${error})` : ""}; write the elements out`);
|
|
809
|
+
}
|
|
810
|
+
if (end < 0) break;
|
|
811
|
+
i = end + 1;
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
if (lt < 0) {
|
|
815
|
+
flushText(source.length);
|
|
816
|
+
i = source.length;
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
819
|
+
flushText(lt);
|
|
820
|
+
i = lt;
|
|
821
|
+
if (source.startsWith("<!--", i)) {
|
|
822
|
+
const end = source.indexOf("-->", i);
|
|
823
|
+
if (end < 0) break;
|
|
824
|
+
i = end + 3;
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
const closing = /^<\/\s*([A-Za-z][\w.]*)?\s*>/.exec(source.slice(i));
|
|
828
|
+
if (closing) {
|
|
829
|
+
const name = closing[1];
|
|
830
|
+
const at = name ? stack.map((o) => o.node.component).lastIndexOf(name) : stack.length - 1;
|
|
831
|
+
if (at < 0) errors.push(`line ${lineAt(i)}: </${name}> closes nothing`);
|
|
832
|
+
else for (const open of stack.splice(at).reverse()) {
|
|
833
|
+
if (open !== stack[at] && open.node.component !== name && name) errors.push(`line ${lineAt(i)}: <${open.node.component}> was not closed before </${name}>`);
|
|
834
|
+
close(open);
|
|
835
|
+
}
|
|
836
|
+
i += closing[0].length;
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
if (/^<\/[^>]*$/.test(source.slice(i))) break;
|
|
840
|
+
const name = /^<([A-Za-z][\w.]*)/.exec(source.slice(i));
|
|
841
|
+
if (!name) {
|
|
842
|
+
const open = stack.at(-1);
|
|
843
|
+
if (open) open.text.push("<");
|
|
844
|
+
i++;
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
const start = i;
|
|
848
|
+
let j = i + name[0].length;
|
|
849
|
+
const props = {};
|
|
850
|
+
let done;
|
|
851
|
+
let cut = false;
|
|
852
|
+
while (j < source.length) {
|
|
853
|
+
const ws = /^\s*/.exec(source.slice(j))[0].length;
|
|
854
|
+
j += ws;
|
|
855
|
+
if (source.startsWith("/>", j)) {
|
|
856
|
+
done = "self";
|
|
857
|
+
j += 2;
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
if (source[j] === "/" && j === source.length - 1) break;
|
|
861
|
+
if (source[j] === ">") {
|
|
862
|
+
done = "open";
|
|
863
|
+
j++;
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
const attr = /^([A-Za-z_$][\w$:-]*)/.exec(source.slice(j));
|
|
867
|
+
if (!attr) {
|
|
868
|
+
if (j < source.length) errors.push(`line ${lineAt(j)}: <${name[1]}>: unexpected "${source[j]}"`);
|
|
869
|
+
j++;
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
j += attr[0].length;
|
|
873
|
+
const key = attr[1];
|
|
874
|
+
const eq = /^\s*=\s*/.exec(source.slice(j));
|
|
875
|
+
if (!eq) {
|
|
876
|
+
props[key] = true;
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
j += eq[0].length;
|
|
880
|
+
const q = source[j];
|
|
881
|
+
if (q === "\"" || q === "'") {
|
|
882
|
+
const end = source.indexOf(q, j + 1);
|
|
883
|
+
if (end < 0) {
|
|
884
|
+
cut = true;
|
|
885
|
+
break;
|
|
886
|
+
}
|
|
887
|
+
props[key] = source.slice(j + 1, end);
|
|
888
|
+
j = end + 1;
|
|
889
|
+
} else if (q === "{") {
|
|
890
|
+
const end = braceEnd(j);
|
|
891
|
+
if (end < 0) {
|
|
892
|
+
cut = true;
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
const { value, error } = readUiValue(source.slice(j + 1, end));
|
|
896
|
+
if (error) errors.push(`line ${lineAt(j)}: <${name[1]}> ${key}: can't read {${source.slice(j + 1, end).slice(0, 60)}}: ${error}`);
|
|
897
|
+
else props[key] = value;
|
|
898
|
+
j = end + 1;
|
|
899
|
+
} else if (q === void 0) {
|
|
900
|
+
cut = true;
|
|
901
|
+
break;
|
|
902
|
+
} else {
|
|
903
|
+
const bare = /^[^\s/>]+/.exec(source.slice(j))[0];
|
|
904
|
+
props[key] = bare;
|
|
905
|
+
j += bare.length;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (cut || !done) break;
|
|
909
|
+
i = j;
|
|
910
|
+
if (name[1] === "State") {
|
|
911
|
+
if (stack.length) errors.push(`line ${lineAt(start)}: <State> goes at the top, outside other components`);
|
|
912
|
+
Object.assign(state, props);
|
|
913
|
+
if (done === "open") stack.push({
|
|
914
|
+
node: { component: "State" },
|
|
915
|
+
text: []
|
|
916
|
+
});
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
const node = {
|
|
920
|
+
component: name[1],
|
|
921
|
+
...Object.keys(props).length && { props }
|
|
922
|
+
};
|
|
923
|
+
lines.set(node, lineAt(start));
|
|
924
|
+
add(node);
|
|
925
|
+
if (done === "open") stack.push({
|
|
926
|
+
node,
|
|
927
|
+
text: []
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
const complete = stack.filter((o) => o.node.component !== "State").length === 0;
|
|
931
|
+
for (const open of stack.reverse()) close(open);
|
|
932
|
+
return {
|
|
933
|
+
nodes: top,
|
|
934
|
+
state,
|
|
935
|
+
errors,
|
|
936
|
+
complete,
|
|
937
|
+
lineOf: (node) => lines.get(node)
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
/** Finds the ```ui blocks in reply text (the last may still be open while it streams). */
|
|
941
|
+
function findUiBlocks(text, language = "ui") {
|
|
942
|
+
const blocks = [];
|
|
943
|
+
const open = new RegExp("^([ \\t]*)(`{3,}|~{3,})[ \\t]*" + language.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "[ \\t]*$", "gm");
|
|
944
|
+
for (let m; m = open.exec(text);) {
|
|
945
|
+
const start = m.index + m[0].length + 1;
|
|
946
|
+
const fence = m[2];
|
|
947
|
+
const close = new RegExp("^[ \\t]*" + fence[0] + "{" + fence.length + ",}[ \\t]*$", "m").exec(text.slice(start));
|
|
948
|
+
const end = close ? start + close.index : text.length;
|
|
949
|
+
blocks.push({
|
|
950
|
+
source: text.slice(start, end),
|
|
951
|
+
offset: start,
|
|
952
|
+
closed: !!close
|
|
953
|
+
});
|
|
954
|
+
if (!close) break;
|
|
955
|
+
open.lastIndex = end + close[0].length;
|
|
956
|
+
}
|
|
957
|
+
return blocks;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Checks the ```ui blocks in a finished reply against the host's components, so the harness can tell the
|
|
961
|
+
* model what to fix (in its own words: line numbers, "did you mean", the props a component takes).
|
|
962
|
+
*/
|
|
963
|
+
function checkUiMarkup(text, components, language = "ui") {
|
|
964
|
+
const blocks = findUiBlocks(text, language);
|
|
965
|
+
const errors = [];
|
|
966
|
+
blocks.forEach((block, n) => {
|
|
967
|
+
const where = blocks.length > 1 ? `block ${n + 1}, ` : "";
|
|
968
|
+
if (!block.closed) errors.push(`${where}the block is not closed with \`\`\``);
|
|
969
|
+
errors.push(...markupErrors(block.source, components, where, block.closed));
|
|
970
|
+
});
|
|
971
|
+
const ok = errors.length === 0;
|
|
972
|
+
return {
|
|
973
|
+
ok,
|
|
974
|
+
errors,
|
|
975
|
+
feedback: ok ? "" : `The \`\`\`${language} block in your last reply did not fully show. Problems:\n` + list(errors) + `\nWhen you next write UI, avoid these. If the user still needs it, write the block again, fixed.`
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
function markupErrors(source, components, where = "", closed = true) {
|
|
979
|
+
const errors = [];
|
|
980
|
+
const parsed = parseUiMarkup(source);
|
|
981
|
+
for (const e of parsed.errors) errors.push(where + e);
|
|
982
|
+
if (closed && !parsed.complete) errors.push(`${where}some elements are not closed`);
|
|
983
|
+
if (!parsed.nodes.length) errors.push(`${where}no components`);
|
|
984
|
+
const check = checkUiTree({
|
|
985
|
+
root: parsed.nodes,
|
|
986
|
+
state: parsed.state
|
|
987
|
+
}, components, { where: (node, path) => {
|
|
988
|
+
const line = isNode(node) ? parsed.lineOf(node) : void 0;
|
|
989
|
+
const name = isNode(node) ? `<${node.component}>` : path;
|
|
990
|
+
return `${where}${line ? `line ${line} ` : ""}${name}`;
|
|
991
|
+
} });
|
|
992
|
+
errors.push(...check.errors);
|
|
993
|
+
return errors;
|
|
994
|
+
}
|
|
995
|
+
const list = (errors) => errors.slice(0, 20).map((e) => `- ${e}`).join("\n") + (errors.length > 20 ? `\n- ...and ${errors.length - 20} more` : "");
|
|
996
|
+
const isNode = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && typeof v.component === "string";
|
|
997
|
+
function hint(schema, inner = false) {
|
|
998
|
+
const s = schema ?? {};
|
|
999
|
+
if (Array.isArray(s.enum)) return s.enum.map((e) => JSON.stringify(e)).join("|");
|
|
1000
|
+
if (Array.isArray(s.anyOf)) return inner ? "action" : "{action}";
|
|
1001
|
+
const type = Array.isArray(s.type) ? s.type[0] : s.type;
|
|
1002
|
+
const wrap = (v) => inner ? v : `{${v}}`;
|
|
1003
|
+
if (type === "string") return inner ? "text" : "\"…\"";
|
|
1004
|
+
if (type === "number" || type === "integer") return wrap("number");
|
|
1005
|
+
if (type === "boolean") return wrap("true|false");
|
|
1006
|
+
if (type === "array") return wrap(`[${hint(s.items, true)}, …]`);
|
|
1007
|
+
if (type === "object" && s.properties) {
|
|
1008
|
+
const req = new Set(s.required ?? []);
|
|
1009
|
+
return wrap(`{${Object.keys(s.properties).map((k) => k + (req.has(k) ? "" : "?")).join(", ")}}`);
|
|
1010
|
+
}
|
|
1011
|
+
return wrap("value");
|
|
1012
|
+
}
|
|
1013
|
+
function markupRules(components) {
|
|
1014
|
+
const line = (c) => {
|
|
1015
|
+
const props = c.props?.properties ?? {};
|
|
1016
|
+
const req = new Set(c.props?.required ?? []);
|
|
1017
|
+
const attrs = Object.entries(props).map(([k, v]) => `${k}${req.has(k) ? "" : "?"}=${hint(v)}`);
|
|
1018
|
+
return `- ${`<${c.name}${attrs.length ? " " + attrs.join(" ") : ""}${c.children ? `>…</${c.name}>` : " />"}`}\n ${c.description}`;
|
|
1019
|
+
};
|
|
1020
|
+
return `Only these components exist:
|
|
1021
|
+
${components.map(line).join("\n")}
|
|
1022
|
+
|
|
1023
|
+
How to write it
|
|
1024
|
+
- Plain JSX: strings as attr="text"; numbers, lists and objects in braces, e.g. items={[{"label": "Tent"}]}.
|
|
1025
|
+
- Components marked >…< take other components inside them; the rest close with />.
|
|
1026
|
+
- Actions (for onPress and similar): {"action":"send","text":"…"} sends that text to you as the user;
|
|
1027
|
+
{"action":"set","key":"k","value":…} changes local state; {"action":"open","url":"https://…"};
|
|
1028
|
+
{"action":"copy","text":"…"}.
|
|
1029
|
+
- Local state: start values on the first line, <State tab="overview" />; read one with attr={$state.tab}.
|
|
1030
|
+
- No JavaScript: no .map(), no variables, no functions. Write every element out.`;
|
|
1031
|
+
}
|
|
1032
|
+
/** The suggested UI tool's name. */
|
|
1033
|
+
const showUiToolName = "show_ui";
|
|
1034
|
+
/**
|
|
1035
|
+
* A suggested tool for drawing UI from your components: the model passes JSX as a string, which streams
|
|
1036
|
+
* into the chat element by element (draw it with `uiMarkupToolView`), and the harness answers the call with
|
|
1037
|
+
* `checkUiMarkupCode(...).feedback`. Built only from the components, so it is byte-stable across turns.
|
|
1038
|
+
*/
|
|
1039
|
+
function showUiTool(components, name = showUiToolName) {
|
|
1040
|
+
return {
|
|
1041
|
+
name,
|
|
1042
|
+
description: "Show interactive UI inline in your reply, built from the app's components: options to compare, a plan, a checklist, numbers at a glance. Write JSX in `markup`. Keep explaining in text around it; one call per answer unless the user asks for more.\n\n" + markupRules(components),
|
|
1043
|
+
inputSchema: {
|
|
1044
|
+
type: "object",
|
|
1045
|
+
properties: { markup: {
|
|
1046
|
+
type: "string",
|
|
1047
|
+
description: "JSX of the components above, e.g. <Card title=\"Plan\"><Metric label=\"Total\" value=\"€184\" /></Card>"
|
|
1048
|
+
} },
|
|
1049
|
+
required: ["markup"]
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Checks the markup of one UI tool call. Its `feedback` is the tool result to return: "Shown to the user." or
|
|
1055
|
+
* what to fix, by line and component.
|
|
1056
|
+
*/
|
|
1057
|
+
function checkUiMarkupCode(markup, components, toolName = showUiToolName) {
|
|
1058
|
+
if (typeof markup !== "string" || !markup.trim()) return {
|
|
1059
|
+
ok: false,
|
|
1060
|
+
errors: ["markup: required, JSX of the components"],
|
|
1061
|
+
feedback: `Not shown: markup is empty. Call ${toolName} again with JSX in markup.`
|
|
1062
|
+
};
|
|
1063
|
+
const errors = markupErrors(markup, components);
|
|
1064
|
+
const ok = errors.length === 0;
|
|
1065
|
+
return {
|
|
1066
|
+
ok,
|
|
1067
|
+
errors,
|
|
1068
|
+
feedback: ok ? "Shown to the user." : `Not all of it is shown. Fix these and call ${toolName} again, without remarking on the fix to the user:\n` + list(errors)
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* A suggested system-prompt section that teaches the model to write ```ui blocks in its reply text, if you'd
|
|
1073
|
+
* rather not use a tool. It only depends on the components, so it stays byte-stable across turns.
|
|
1074
|
+
*/
|
|
1075
|
+
function uiMarkupGuide(components, language = "ui") {
|
|
1076
|
+
return `## Interactive UI in your reply
|
|
1077
|
+
When a visual helps more than text (options to compare, a plan, a checklist, numbers at a glance), write a
|
|
1078
|
+
\`\`\`${language} fenced block in your reply. It holds JSX of the app's components.
|
|
1079
|
+
${markupRules(components)}
|
|
1080
|
+
- Keep explaining in text around the block. One block per answer, unless the user asks for more.`;
|
|
1081
|
+
}
|
|
1082
|
+
export { salvageJson as A, awaitingToolCalls as C, resolveEffort as D, reduceEvents as E, settleToolCall as O, applyEvent as S, outputTokens as T, formatEvent as _, readUiValue as a, toBlocks as b, uiMarkupGuide as c, isStateRef as d, matchesSchema as f, uiActionSchema as g, schemaErrors as h, parseUiMarkup as i, parsePartialJson as k, uiMarkupLanguage as l, renderUiToolName as m, checkUiMarkupCode as n, showUiTool as o, renderUiTool as p, findUiBlocks as r, showUiToolName as s, checkUiMarkup as t, checkUiTree as u, readEventStream as v, estimateTokens as w, activityTokens as x, isActivityPart as y };
|