@adecore/agent-contracts 0.17.0-beta.2 → 0.17.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/dist/chat.d.ts +39 -0
- package/dist/chat.js +9 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/protocol.d.ts +38 -0
- package/dist/protocol.js +3 -1
- package/dist/visual.d.ts +120 -0
- package/dist/visual.js +617 -0
- package/package.json +6 -1
- package/src/chat.ts +15 -1
- package/src/index.ts +1 -0
- package/src/protocol.ts +5 -0
- package/src/visual.ts +693 -0
package/dist/visual.js
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/*
|
|
3
|
+
* What a host accepts when an agent publishes a visual. The schemas below check shapes only, so a
|
|
4
|
+
* host that widens a limit never makes an older client refuse a whole attach.
|
|
5
|
+
*/
|
|
6
|
+
export const VISUAL_LIMITS = {
|
|
7
|
+
// In UTF-16 units, the measure a schema's `max` counts in.
|
|
8
|
+
title: 200,
|
|
9
|
+
// The height of a frame, in CSS pixels.
|
|
10
|
+
minHeight: 80,
|
|
11
|
+
maxHeight: 2000,
|
|
12
|
+
// The stored page with its bootstrap.
|
|
13
|
+
bytes: 16 * 1024 * 1024,
|
|
14
|
+
heights: 24
|
|
15
|
+
};
|
|
16
|
+
// The frame widths a host may measure a page at, ascending, from a phone to a wide chat.
|
|
17
|
+
export const VISUAL_MEASURE_WIDTHS = [320, 400, 480, 560, 640, 720, 800, 900, 1000, 1200];
|
|
18
|
+
// `[width, height]` of a page measured in a frame that wide, in CSS pixels.
|
|
19
|
+
export const VisualHeightSchema = z.tuple([z.number().positive(), z.number().nonnegative()]);
|
|
20
|
+
/*
|
|
21
|
+
* A page an agent published in a chat, shown in the thread at `at`. It lives beside the chat in the
|
|
22
|
+
* host's data folder and goes with the chat, a clear of it, or a person who removes it.
|
|
23
|
+
*/
|
|
24
|
+
export const ChatVisualSchema = z.object({
|
|
25
|
+
// Also the id of the stored page among the chat's attachments.
|
|
26
|
+
id: z.string().min(1),
|
|
27
|
+
title: z.string().min(1),
|
|
28
|
+
// On the clock of an item's `createdAt`, which is what places the visual among the items.
|
|
29
|
+
at: z.number(),
|
|
30
|
+
maxHeight: z.number().positive(),
|
|
31
|
+
// Ascending by width; absent while nobody measured the page.
|
|
32
|
+
heights: z.array(VisualHeightSchema).optional(),
|
|
33
|
+
// Bytes of the stored page.
|
|
34
|
+
size: z.number().int().nonnegative(),
|
|
35
|
+
// The turn that published it, when one was running.
|
|
36
|
+
turnId: z.string().min(1).optional()
|
|
37
|
+
});
|
|
38
|
+
export const ChatVisualsSchema = z.array(ChatVisualSchema);
|
|
39
|
+
function clampHeight(height) {
|
|
40
|
+
return Math.min(Math.max(Math.ceil(height), VISUAL_LIMITS.minHeight), VISUAL_LIMITS.maxHeight);
|
|
41
|
+
}
|
|
42
|
+
/*
|
|
43
|
+
* The height of a frame `width` CSS pixels wide, from the measured heights: the taller of the
|
|
44
|
+
* measurements at the nearest widths on either side, since a breakpoint between two measured widths
|
|
45
|
+
* can make the page as tall as either. Undefined while nobody measured the page.
|
|
46
|
+
*/
|
|
47
|
+
export function visualFrameHeight(visual, width) {
|
|
48
|
+
let below;
|
|
49
|
+
let above;
|
|
50
|
+
for (const measured of visual.heights ?? []) {
|
|
51
|
+
if (measured[0] <= width && (below === undefined || measured[0] > below[0])) {
|
|
52
|
+
below = measured;
|
|
53
|
+
}
|
|
54
|
+
if (measured[0] >= width && (above === undefined || measured[0] < above[0])) {
|
|
55
|
+
above = measured;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (below === undefined && above === undefined) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
return clampHeight(Math.min(Math.max(below?.[1] ?? 0, above?.[1] ?? 0), visual.maxHeight));
|
|
62
|
+
}
|
|
63
|
+
/*
|
|
64
|
+
* The messages between a visual's page and the app, JSON-RPC 2.0 over `postMessage`. The method
|
|
65
|
+
* names follow the MCP Apps extension, so the same host can later show an app a server ships.
|
|
66
|
+
*/
|
|
67
|
+
export const VISUAL_BRIDGE_METHODS = {
|
|
68
|
+
// From the sandbox host page to the app: it listens and waits for the page.
|
|
69
|
+
sandboxProxyReady: 'ui/notifications/sandbox-proxy-ready',
|
|
70
|
+
// From the app to the sandbox host page: the document to write.
|
|
71
|
+
sandboxResourceReady: 'ui/notifications/sandbox-resource-ready',
|
|
72
|
+
// From the app to the page: the theme it is drawn in.
|
|
73
|
+
hostContextChanged: 'ui/notifications/host-context-changed',
|
|
74
|
+
// From the page to the app: the size of its document.
|
|
75
|
+
sizeChanged: 'ui/notifications/size-changed',
|
|
76
|
+
// From the page to the app, as a request: a link to open outside the frame.
|
|
77
|
+
openLink: 'ui/open-link'
|
|
78
|
+
};
|
|
79
|
+
export function visualProxyReadyMessage() {
|
|
80
|
+
return { jsonrpc: '2.0', method: VISUAL_BRIDGE_METHODS.sandboxProxyReady };
|
|
81
|
+
}
|
|
82
|
+
export function visualResourceReadyMessage(html) {
|
|
83
|
+
return { jsonrpc: '2.0', method: VISUAL_BRIDGE_METHODS.sandboxResourceReady, params: { html } };
|
|
84
|
+
}
|
|
85
|
+
export function visualHostContextMessage(theme) {
|
|
86
|
+
return {
|
|
87
|
+
jsonrpc: '2.0',
|
|
88
|
+
method: VISUAL_BRIDGE_METHODS.hostContextChanged,
|
|
89
|
+
params: { theme: theme.appearance, styles: { variables: { ...theme.variables } } }
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function visualSizeChangedMessage(size) {
|
|
93
|
+
return { jsonrpc: '2.0', method: VISUAL_BRIDGE_METHODS.sizeChanged, params: { width: size.width, height: size.height } };
|
|
94
|
+
}
|
|
95
|
+
export function visualOpenLinkRequest(id, url) {
|
|
96
|
+
return { jsonrpc: '2.0', id, method: VISUAL_BRIDGE_METHODS.openLink, params: { url } };
|
|
97
|
+
}
|
|
98
|
+
// What the app answers an open-link request with, whether it opened the link or not.
|
|
99
|
+
export function visualOpenLinkResult(id) {
|
|
100
|
+
return { jsonrpc: '2.0', id, result: {} };
|
|
101
|
+
}
|
|
102
|
+
function isRecord(value) {
|
|
103
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
104
|
+
}
|
|
105
|
+
function sizeOf(value) {
|
|
106
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
107
|
+
}
|
|
108
|
+
function webUrl(value) {
|
|
109
|
+
if (typeof value !== 'string') {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const url = new globalThis.URL(value);
|
|
114
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : undefined;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/*
|
|
121
|
+
* A message of the bridge, or undefined for anything else that arrives on `message`. Fields it does
|
|
122
|
+
* not know are ignored, so either side may grow.
|
|
123
|
+
*/
|
|
124
|
+
export function parseVisualMessage(data) {
|
|
125
|
+
if (!isRecord(data) || data.jsonrpc !== '2.0' || typeof data.method !== 'string') {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
const params = isRecord(data.params) ? data.params : {};
|
|
129
|
+
switch (data.method) {
|
|
130
|
+
case VISUAL_BRIDGE_METHODS.sandboxProxyReady:
|
|
131
|
+
return { method: data.method };
|
|
132
|
+
case VISUAL_BRIDGE_METHODS.sandboxResourceReady:
|
|
133
|
+
return typeof params.html === 'string' ? { method: data.method, html: params.html } : undefined;
|
|
134
|
+
case VISUAL_BRIDGE_METHODS.hostContextChanged: {
|
|
135
|
+
if (params.theme !== 'light' && params.theme !== 'dark') {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
const styles = isRecord(params.styles) ? params.styles : {};
|
|
139
|
+
return { method: data.method, theme: { appearance: params.theme, variables: cleanVisualVariables(styles.variables) } };
|
|
140
|
+
}
|
|
141
|
+
case VISUAL_BRIDGE_METHODS.sizeChanged: {
|
|
142
|
+
const height = sizeOf(params.height);
|
|
143
|
+
const width = sizeOf(params.width);
|
|
144
|
+
if (height === undefined) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
return width === undefined ? { method: data.method, height } : { method: data.method, width, height };
|
|
148
|
+
}
|
|
149
|
+
case VISUAL_BRIDGE_METHODS.openLink: {
|
|
150
|
+
const url = webUrl(params.url);
|
|
151
|
+
const id = data.id;
|
|
152
|
+
if (url === undefined || !(typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id)))) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
return { method: data.method, id, url };
|
|
156
|
+
}
|
|
157
|
+
default:
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// The names models already know from widespread component themes, so a page an agent writes uses them unasked.
|
|
162
|
+
export const VISUAL_THEME_VARIABLES = [
|
|
163
|
+
'--background',
|
|
164
|
+
'--foreground',
|
|
165
|
+
'--muted',
|
|
166
|
+
'--muted-foreground',
|
|
167
|
+
'--card',
|
|
168
|
+
'--card-foreground',
|
|
169
|
+
'--popover',
|
|
170
|
+
'--popover-foreground',
|
|
171
|
+
'--secondary',
|
|
172
|
+
'--secondary-foreground',
|
|
173
|
+
'--border',
|
|
174
|
+
'--input',
|
|
175
|
+
'--ring',
|
|
176
|
+
'--primary',
|
|
177
|
+
'--primary-foreground',
|
|
178
|
+
'--accent',
|
|
179
|
+
'--accent-foreground',
|
|
180
|
+
'--destructive',
|
|
181
|
+
'--destructive-foreground',
|
|
182
|
+
'--warning',
|
|
183
|
+
'--warning-foreground',
|
|
184
|
+
'--success',
|
|
185
|
+
'--success-foreground',
|
|
186
|
+
'--info',
|
|
187
|
+
'--info-foreground',
|
|
188
|
+
'--code-background',
|
|
189
|
+
'--code-foreground',
|
|
190
|
+
'--chart-1',
|
|
191
|
+
'--chart-2',
|
|
192
|
+
'--chart-3',
|
|
193
|
+
'--chart-4',
|
|
194
|
+
'--chart-5',
|
|
195
|
+
'--chart-6',
|
|
196
|
+
'--radius',
|
|
197
|
+
'--font-sans',
|
|
198
|
+
'--font-mono'
|
|
199
|
+
];
|
|
200
|
+
const FONTS = {
|
|
201
|
+
'--radius': '8px',
|
|
202
|
+
'--font-sans': 'system-ui, sans-serif',
|
|
203
|
+
'--font-mono': 'ui-monospace, monospace'
|
|
204
|
+
};
|
|
205
|
+
const LIGHT = {
|
|
206
|
+
'--background': '#ffffff',
|
|
207
|
+
'--foreground': '#18181b',
|
|
208
|
+
'--muted': '#f4f4f5',
|
|
209
|
+
'--muted-foreground': '#71717a',
|
|
210
|
+
'--card': '#ffffff',
|
|
211
|
+
'--card-foreground': '#18181b',
|
|
212
|
+
'--popover': '#ffffff',
|
|
213
|
+
'--popover-foreground': '#18181b',
|
|
214
|
+
'--secondary': '#f4f4f5',
|
|
215
|
+
'--secondary-foreground': '#18181b',
|
|
216
|
+
'--border': 'rgba(24, 24, 27, 0.12)',
|
|
217
|
+
'--input': 'rgba(24, 24, 27, 0.16)',
|
|
218
|
+
'--ring': 'rgba(24, 24, 27, 0.4)',
|
|
219
|
+
'--primary': '#18181b',
|
|
220
|
+
'--primary-foreground': '#fafafa',
|
|
221
|
+
'--accent': '#f4f4f5',
|
|
222
|
+
'--accent-foreground': '#18181b',
|
|
223
|
+
'--destructive': '#dc2626',
|
|
224
|
+
'--destructive-foreground': '#ffffff',
|
|
225
|
+
'--warning': '#d97706',
|
|
226
|
+
'--warning-foreground': '#ffffff',
|
|
227
|
+
'--success': '#16a34a',
|
|
228
|
+
'--success-foreground': '#ffffff',
|
|
229
|
+
'--info': '#2563eb',
|
|
230
|
+
'--info-foreground': '#ffffff',
|
|
231
|
+
'--code-background': '#f4f4f5',
|
|
232
|
+
'--code-foreground': '#18181b',
|
|
233
|
+
'--chart-1': '#2563eb',
|
|
234
|
+
'--chart-2': '#ea580c',
|
|
235
|
+
'--chart-3': '#0d9488',
|
|
236
|
+
'--chart-4': '#9333ea',
|
|
237
|
+
'--chart-5': '#db2777',
|
|
238
|
+
'--chart-6': '#ca8a04',
|
|
239
|
+
...FONTS
|
|
240
|
+
};
|
|
241
|
+
const DARK = {
|
|
242
|
+
'--background': '#18181b',
|
|
243
|
+
'--foreground': '#fafafa',
|
|
244
|
+
'--muted': '#27272a',
|
|
245
|
+
'--muted-foreground': '#a1a1aa',
|
|
246
|
+
'--card': '#1f1f23',
|
|
247
|
+
'--card-foreground': '#fafafa',
|
|
248
|
+
'--popover': '#27272a',
|
|
249
|
+
'--popover-foreground': '#fafafa',
|
|
250
|
+
'--secondary': '#27272a',
|
|
251
|
+
'--secondary-foreground': '#fafafa',
|
|
252
|
+
'--border': 'rgba(250, 250, 250, 0.12)',
|
|
253
|
+
'--input': 'rgba(250, 250, 250, 0.16)',
|
|
254
|
+
'--ring': 'rgba(250, 250, 250, 0.4)',
|
|
255
|
+
'--primary': '#fafafa',
|
|
256
|
+
'--primary-foreground': '#18181b',
|
|
257
|
+
'--accent': '#27272a',
|
|
258
|
+
'--accent-foreground': '#fafafa',
|
|
259
|
+
'--destructive': '#ef4444',
|
|
260
|
+
'--destructive-foreground': '#ffffff',
|
|
261
|
+
'--warning': '#f59e0b',
|
|
262
|
+
'--warning-foreground': '#18181b',
|
|
263
|
+
'--success': '#22c55e',
|
|
264
|
+
'--success-foreground': '#18181b',
|
|
265
|
+
'--info': '#3b82f6',
|
|
266
|
+
'--info-foreground': '#ffffff',
|
|
267
|
+
'--code-background': '#27272a',
|
|
268
|
+
'--code-foreground': '#fafafa',
|
|
269
|
+
'--chart-1': '#60a5fa',
|
|
270
|
+
'--chart-2': '#fb923c',
|
|
271
|
+
'--chart-3': '#2dd4bf',
|
|
272
|
+
'--chart-4': '#c084fc',
|
|
273
|
+
'--chart-5': '#f472b6',
|
|
274
|
+
'--chart-6': '#facc15',
|
|
275
|
+
...FONTS
|
|
276
|
+
};
|
|
277
|
+
// What a page is drawn in until a host says otherwise: neutral grays, with no accent of any app.
|
|
278
|
+
export const VISUAL_LIGHT_THEME = { appearance: 'light', variables: LIGHT };
|
|
279
|
+
export const VISUAL_DARK_THEME = { appearance: 'dark', variables: DARK };
|
|
280
|
+
const VARIABLE_NAME = /^--[a-z0-9-]{1,64}$/;
|
|
281
|
+
// Whatever could end the declaration or the rule it is written in, or open a comment that swallows the rest.
|
|
282
|
+
const UNSAFE_VALUE = /[;{}<>\\\r\n]|\/\*|\*\//;
|
|
283
|
+
const MAX_VALUE_LENGTH = 512;
|
|
284
|
+
/* The variables of a theme a page may be given, from anything: the names it may hold and the values that cannot break out of their rule. */
|
|
285
|
+
export function cleanVisualVariables(input) {
|
|
286
|
+
const clean = {};
|
|
287
|
+
if (!isRecord(input)) {
|
|
288
|
+
return clean;
|
|
289
|
+
}
|
|
290
|
+
for (const [name, value] of Object.entries(input)) {
|
|
291
|
+
if (VARIABLE_NAME.test(name) && typeof value === 'string' && value.length <= MAX_VALUE_LENGTH && !UNSAFE_VALUE.test(value) && value.trim() !== '') {
|
|
292
|
+
clean[name] = value.trim();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return clean;
|
|
296
|
+
}
|
|
297
|
+
const FRAGMENT_KEY = 'visual-theme';
|
|
298
|
+
/* A theme in a URL fragment, which the page applies before its first paint and then takes off the address. */
|
|
299
|
+
export function visualThemeFragment(theme) {
|
|
300
|
+
return `#${FRAGMENT_KEY}=${encodeURIComponent(JSON.stringify({ appearance: theme.appearance, variables: theme.variables }))}`;
|
|
301
|
+
}
|
|
302
|
+
export const VISUAL_PAGE_RULES = `A visual is one self-contained HTML document of at most ${VISUAL_LIMITS.bytes / 1024 / 1024} MiB: styles in an inline <style>, code in an inline <script>, ` +
|
|
303
|
+
'and the data it shows inlined. Public http(s) URLs load as they are, so a chart library from a CDN or an image on the web works; ' +
|
|
304
|
+
'relative paths and local files do not. Prefer inline SVG or a canvas when the drawing needs no library. A link to an http(s) page opens in the browser.';
|
|
305
|
+
export const VISUAL_LAYOUT_GUIDE = "The page appears in the reply as a borderless frame on the thread's own background, as wide as the reply column: " +
|
|
306
|
+
'about 360px on a phone and up to about 1200px on a wide screen. Lay it out at a fluid width (percentages, flex, grid) ' +
|
|
307
|
+
'with no horizontal padding on the outermost element. Draw no outer card, border, shadow or banner title: the page is part of the reply. ' +
|
|
308
|
+
'The frame takes the height of the page, so give a chart a fixed height in pixels, never one relative to the viewport.';
|
|
309
|
+
export const VISUAL_THEME_GUIDE = `The page is themed through CSS custom properties on :root that follow the app's light and dark appearance live: ${VISUAL_THEME_VARIABLES.join(', ')}. ` +
|
|
310
|
+
'--background is exactly what lies behind the frame, so leave the page background to it; --chart-1 to --chart-6 are a categorical series for data. ' +
|
|
311
|
+
'Use them with var() in CSS or a style attribute instead of fixed colors. A script that reads them with getComputedStyle, for a canvas or a chart library, ' +
|
|
312
|
+
'reads them again when a message with the method ui/notifications/host-context-changed arrives.';
|
|
313
|
+
const BASE_CSS = [
|
|
314
|
+
'html{background:var(--background);color:var(--foreground);font-family:var(--font-sans);font-size:14px;line-height:1.5;-webkit-text-size-adjust:100%;text-size-adjust:100%;scrollbar-width:none}',
|
|
315
|
+
'html::-webkit-scrollbar{display:none}',
|
|
316
|
+
'body{margin:0}'
|
|
317
|
+
].join('\n');
|
|
318
|
+
function rootRule(theme) {
|
|
319
|
+
const declarations = Object.entries(theme.variables).map(([name, value]) => `${name}:${value};`);
|
|
320
|
+
return `:root{color-scheme:${theme.appearance};\n${declarations.join('\n')}\n}`;
|
|
321
|
+
}
|
|
322
|
+
/* A value inside a `<script>`, which no text in it can close. */
|
|
323
|
+
function inlineJson(value) {
|
|
324
|
+
return JSON.stringify(value).replaceAll('<', '\\u003c');
|
|
325
|
+
}
|
|
326
|
+
/*
|
|
327
|
+
* Runs before anything of the page. It is written into the stored file once, at publish, so it
|
|
328
|
+
* reads every message leniently and ignores what it does not know: a newer host still talks to it.
|
|
329
|
+
*/
|
|
330
|
+
function bootstrapScript() {
|
|
331
|
+
const constants = {
|
|
332
|
+
base: BASE_CSS,
|
|
333
|
+
defaults: { light: LIGHT, dark: DARK },
|
|
334
|
+
fragment: FRAGMENT_KEY,
|
|
335
|
+
name: VARIABLE_NAME.source,
|
|
336
|
+
unsafe: UNSAFE_VALUE.source,
|
|
337
|
+
maxValue: MAX_VALUE_LENGTH,
|
|
338
|
+
hostContext: VISUAL_BRIDGE_METHODS.hostContextChanged,
|
|
339
|
+
sizeChanged: VISUAL_BRIDGE_METHODS.sizeChanged,
|
|
340
|
+
openLink: VISUAL_BRIDGE_METHODS.openLink
|
|
341
|
+
};
|
|
342
|
+
return `(function () {
|
|
343
|
+
var script = document.currentScript;
|
|
344
|
+
var style = script && script.previousElementSibling;
|
|
345
|
+
if (!style) { return; }
|
|
346
|
+
var first = document.querySelector('style[data-visual-theme]');
|
|
347
|
+
if (first && first !== style) { style.remove(); script.remove(); return; }
|
|
348
|
+
var config = ${inlineJson(constants)};
|
|
349
|
+
var NAME = new RegExp(config.name);
|
|
350
|
+
var UNSAFE = new RegExp(config.unsafe);
|
|
351
|
+
var framed = window.parent !== window;
|
|
352
|
+
var current = { appearance: null, variables: {} };
|
|
353
|
+
function isObject(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); }
|
|
354
|
+
function clean(input) {
|
|
355
|
+
var output = {};
|
|
356
|
+
if (!isObject(input)) { return output; }
|
|
357
|
+
Object.keys(input).forEach(function (name) {
|
|
358
|
+
var value = input[name];
|
|
359
|
+
if (NAME.test(name) && typeof value === 'string' && value.length <= config.maxValue && !UNSAFE.test(value) && value.trim() !== '') { output[name] = value.trim(); }
|
|
360
|
+
});
|
|
361
|
+
return output;
|
|
362
|
+
}
|
|
363
|
+
function prefersLight() { return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: light)').matches; }
|
|
364
|
+
function apply(appearance, variables) {
|
|
365
|
+
if (appearance !== 'light' && appearance !== 'dark') { appearance = current.appearance || (prefersLight() ? 'light' : 'dark'); }
|
|
366
|
+
current = { appearance: appearance, variables: variables || current.variables };
|
|
367
|
+
var merged = Object.assign({}, config.defaults[appearance], current.variables);
|
|
368
|
+
var lines = Object.keys(merged).map(function (name) { return name + ':' + merged[name] + ';'; });
|
|
369
|
+
style.textContent = ':root{color-scheme:' + appearance + ';\\n' + lines.join('\\n') + '\\n}\\n' + config.base;
|
|
370
|
+
}
|
|
371
|
+
var hash = location.hash;
|
|
372
|
+
var match = new RegExp('(?:^#|&)' + config.fragment + '=([^&]*)').exec(hash);
|
|
373
|
+
if (match) {
|
|
374
|
+
try {
|
|
375
|
+
var theme = JSON.parse(decodeURIComponent(match[1]));
|
|
376
|
+
if (isObject(theme)) { apply(theme.appearance, clean(theme.variables)); }
|
|
377
|
+
} catch (e) {}
|
|
378
|
+
var rest = hash.slice(1).split('&').filter(function (part) { return part.indexOf(config.fragment + '=') !== 0; }).join('&');
|
|
379
|
+
try { history.replaceState(history.state, '', rest ? '#' + rest : location.href.split('#')[0]); } catch (e) {}
|
|
380
|
+
}
|
|
381
|
+
window.addEventListener('message', function (event) {
|
|
382
|
+
var data = event.data;
|
|
383
|
+
if (event.source !== window.parent || !isObject(data) || data.method !== config.hostContext) { return; }
|
|
384
|
+
var params = isObject(data.params) ? data.params : {};
|
|
385
|
+
var styles = isObject(params.styles) ? params.styles : {};
|
|
386
|
+
apply(params.theme, isObject(styles.variables) ? clean(styles.variables) : null);
|
|
387
|
+
});
|
|
388
|
+
var nextId = 1;
|
|
389
|
+
function hrefOf(node) {
|
|
390
|
+
if (!node || typeof node.tagName !== 'string' || node.tagName.toLowerCase() !== 'a' || typeof node.getAttribute !== 'function') { return null; }
|
|
391
|
+
var href = node.getAttribute('href');
|
|
392
|
+
return href === null ? node.getAttribute('xlink:href') : href;
|
|
393
|
+
}
|
|
394
|
+
function follow(event, anchor, href) {
|
|
395
|
+
var url;
|
|
396
|
+
try { url = new URL(href, document.baseURI); } catch (e) { return; }
|
|
397
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') { return; }
|
|
398
|
+
if (url.href.split('#')[0] === location.href.split('#')[0]) { return; }
|
|
399
|
+
if (framed) {
|
|
400
|
+
event.preventDefault();
|
|
401
|
+
window.parent.postMessage({ jsonrpc: '2.0', id: nextId++, method: config.openLink, params: { url: url.href } }, '*');
|
|
402
|
+
} else {
|
|
403
|
+
anchor.setAttribute('target', '_blank');
|
|
404
|
+
anchor.setAttribute('rel', 'noopener');
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
window.addEventListener('click', function (event) {
|
|
408
|
+
if (!event.isTrusted) { return; }
|
|
409
|
+
var path = typeof event.composedPath === 'function' ? event.composedPath() : [];
|
|
410
|
+
for (var i = 0; i < path.length; i++) {
|
|
411
|
+
var href = hrefOf(path[i]);
|
|
412
|
+
if (href !== null) { follow(event, path[i], href); return; }
|
|
413
|
+
}
|
|
414
|
+
}, true);
|
|
415
|
+
if (framed && typeof ResizeObserver === 'function') {
|
|
416
|
+
var root = document.documentElement;
|
|
417
|
+
var reported = '';
|
|
418
|
+
var scheduled = false;
|
|
419
|
+
var measure = function () {
|
|
420
|
+
scheduled = false;
|
|
421
|
+
var box = root.getBoundingClientRect();
|
|
422
|
+
var width = Math.ceil(box.width);
|
|
423
|
+
var height = Math.ceil(box.height);
|
|
424
|
+
if (width + 'x' + height === reported) { return; }
|
|
425
|
+
reported = width + 'x' + height;
|
|
426
|
+
window.parent.postMessage({ jsonrpc: '2.0', method: config.sizeChanged, params: { width: width, height: height } }, '*');
|
|
427
|
+
};
|
|
428
|
+
new ResizeObserver(function () {
|
|
429
|
+
if (!scheduled) { scheduled = true; requestAnimationFrame(measure); }
|
|
430
|
+
}).observe(root);
|
|
431
|
+
}
|
|
432
|
+
})();`;
|
|
433
|
+
}
|
|
434
|
+
function bootstrapMarkup(viewport) {
|
|
435
|
+
const theme = `${rootRule(VISUAL_DARK_THEME)}\n@media (prefers-color-scheme: light){\n${rootRule(VISUAL_LIGHT_THEME)}\n}\n${BASE_CSS}`;
|
|
436
|
+
return [
|
|
437
|
+
// Always first: the stored file is UTF-8 whatever the page declares, and the first charset wins.
|
|
438
|
+
'<meta charset="utf-8">',
|
|
439
|
+
viewport ? '<meta name="viewport" content="width=device-width, initial-scale=1">' : '',
|
|
440
|
+
`<style data-visual-theme>${theme}</style>`,
|
|
441
|
+
`<script>${bootstrapScript()}</script>`
|
|
442
|
+
].join('');
|
|
443
|
+
}
|
|
444
|
+
// Elements whose content the tokenizer reads as text, so markup in it is no markup.
|
|
445
|
+
const RAW_TEXT = new Set(['script', 'style', 'textarea', 'title', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript', 'plaintext']);
|
|
446
|
+
function commentEnd(html, from) {
|
|
447
|
+
if (html.startsWith('>', from)) {
|
|
448
|
+
return from + 1;
|
|
449
|
+
}
|
|
450
|
+
if (html.startsWith('->', from)) {
|
|
451
|
+
return from + 2;
|
|
452
|
+
}
|
|
453
|
+
const ends = [html.indexOf('-->', from), html.indexOf('--!>', from)].filter((index) => index >= 0);
|
|
454
|
+
if (ends.length === 0) {
|
|
455
|
+
return html.length;
|
|
456
|
+
}
|
|
457
|
+
const end = Math.min(...ends);
|
|
458
|
+
return end + (html.startsWith('-->', end) ? 3 : 4);
|
|
459
|
+
}
|
|
460
|
+
// Past the `>` of a tag, skipping a quoted attribute value, which may hold one.
|
|
461
|
+
function tagEnd(html, from) {
|
|
462
|
+
let at = from;
|
|
463
|
+
while (at < html.length) {
|
|
464
|
+
const char = html[at];
|
|
465
|
+
if (char === '>') {
|
|
466
|
+
return at + 1;
|
|
467
|
+
}
|
|
468
|
+
at++;
|
|
469
|
+
if (char !== '=') {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
while (at < html.length && /\s/.test(html[at])) {
|
|
473
|
+
at++;
|
|
474
|
+
}
|
|
475
|
+
const quote = html[at];
|
|
476
|
+
if (quote === '"' || quote === "'") {
|
|
477
|
+
const close = html.indexOf(quote, at + 1);
|
|
478
|
+
if (close < 0) {
|
|
479
|
+
return html.length;
|
|
480
|
+
}
|
|
481
|
+
at = close + 1;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return html.length;
|
|
485
|
+
}
|
|
486
|
+
function rawTextEnd(html, name, from) {
|
|
487
|
+
if (name === 'plaintext') {
|
|
488
|
+
return html.length;
|
|
489
|
+
}
|
|
490
|
+
const close = new RegExp(`</${name}[\\s/>]`, 'gi');
|
|
491
|
+
close.lastIndex = from;
|
|
492
|
+
const found = close.exec(html);
|
|
493
|
+
return found === null ? html.length : tagEnd(html, found.index + 2);
|
|
494
|
+
}
|
|
495
|
+
/*
|
|
496
|
+
* The document as the HTML tokenizer would cut it, coarsely: comments, a doctype, tags and the text
|
|
497
|
+
* between them. The content of a raw-text element is skipped, so markup inside a script never counts.
|
|
498
|
+
*/
|
|
499
|
+
function* tokens(html) {
|
|
500
|
+
let at = 0;
|
|
501
|
+
while (at < html.length) {
|
|
502
|
+
const open = html.indexOf('<', at);
|
|
503
|
+
if (open < 0) {
|
|
504
|
+
yield { kind: 'text', name: '', start: at, end: html.length };
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const next = html[open + 1] ?? '';
|
|
508
|
+
let token = null;
|
|
509
|
+
let resume = 0;
|
|
510
|
+
if (html.startsWith('<!--', open)) {
|
|
511
|
+
token = { kind: 'comment', name: '', start: open, end: commentEnd(html, open + 4) };
|
|
512
|
+
}
|
|
513
|
+
else if (next === '!' || next === '?') {
|
|
514
|
+
const close = html.indexOf('>', open);
|
|
515
|
+
const end = close < 0 ? html.length : close + 1;
|
|
516
|
+
token = { kind: /^<!doctype/i.test(html.slice(open, open + 9)) ? 'doctype' : 'comment', name: '', start: open, end };
|
|
517
|
+
}
|
|
518
|
+
else if (next === '/') {
|
|
519
|
+
const name = /^<\/([a-z][^\s/>]*)/i.exec(html.slice(open, open + 64))?.[1]?.toLowerCase() ?? '';
|
|
520
|
+
token = { kind: name === '' ? 'comment' : 'end', name, start: open, end: tagEnd(html, open + 2) };
|
|
521
|
+
}
|
|
522
|
+
else if (/[a-z]/i.test(next)) {
|
|
523
|
+
const name = /^<([a-z][^\s/>]*)/i.exec(html.slice(open, open + 64))[1].toLowerCase();
|
|
524
|
+
token = { kind: 'start', name, start: open, end: tagEnd(html, open + 1 + name.length) };
|
|
525
|
+
if (RAW_TEXT.has(name)) {
|
|
526
|
+
resume = rawTextEnd(html, name, token.end);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (token === null) {
|
|
530
|
+
// A `<` that opens nothing is text, which goes on to the next `<`.
|
|
531
|
+
const following = html.indexOf('<', open + 1);
|
|
532
|
+
const end = following < 0 ? html.length : following;
|
|
533
|
+
yield { kind: 'text', name: '', start: at, end };
|
|
534
|
+
at = end;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (open > at) {
|
|
538
|
+
yield { kind: 'text', name: '', start: at, end: open };
|
|
539
|
+
}
|
|
540
|
+
yield token;
|
|
541
|
+
at = Math.max(token.end, resume);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const BLANK = /^[\s]*$/;
|
|
545
|
+
/*
|
|
546
|
+
* Where the bootstrap goes: right after the head's start tag, or where the first thing of the page
|
|
547
|
+
* starts when it has no head there. Only comments, a doctype and the html tag may come before it,
|
|
548
|
+
* so the bootstrap is the first thing in the head and nothing inert (a comment, the text of a
|
|
549
|
+
* script, a template) is ever taken for the head.
|
|
550
|
+
*/
|
|
551
|
+
function bootstrapPlace(html) {
|
|
552
|
+
for (const token of tokens(html)) {
|
|
553
|
+
if (token.kind === 'comment' || token.kind === 'doctype' || (token.kind === 'text' && BLANK.test(html.slice(token.start, token.end)))) {
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (token.kind === 'start' && token.name === 'html') {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
if (token.kind === 'start' && token.name === 'head') {
|
|
560
|
+
return { at: token.end, head: true };
|
|
561
|
+
}
|
|
562
|
+
return { at: token.start, head: false };
|
|
563
|
+
}
|
|
564
|
+
return { at: html.length, head: false };
|
|
565
|
+
}
|
|
566
|
+
// Whether the page sets its own viewport, outside a template.
|
|
567
|
+
function hasViewport(html) {
|
|
568
|
+
let templates = 0;
|
|
569
|
+
for (const token of tokens(html)) {
|
|
570
|
+
if (token.name === 'template') {
|
|
571
|
+
templates = Math.max(0, templates + (token.kind === 'start' ? 1 : token.kind === 'end' ? -1 : 0));
|
|
572
|
+
}
|
|
573
|
+
else if (templates === 0 && token.kind === 'start' && token.name === 'meta') {
|
|
574
|
+
if (/\sname\s*=\s*["']?viewport(?=["'\s/>])/i.test(html.slice(token.start, token.end))) {
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
/*
|
|
582
|
+
* The page with the bootstrap at the very start of its head: a charset, a viewport when the page has
|
|
583
|
+
* none, the theme and its base stylesheet, and the script that applies a theme, reports the size and
|
|
584
|
+
* opens links outside the frame. Written into the stored page once, at publish.
|
|
585
|
+
*/
|
|
586
|
+
export function injectVisualBootstrap(html) {
|
|
587
|
+
const place = bootstrapPlace(html);
|
|
588
|
+
const bootstrap = bootstrapMarkup(!hasViewport(html));
|
|
589
|
+
return html.slice(0, place.at) + (place.head ? bootstrap : `<head>${bootstrap}</head>`) + html.slice(place.at);
|
|
590
|
+
}
|
|
591
|
+
function hostPageScript() {
|
|
592
|
+
const methods = { ready: VISUAL_BRIDGE_METHODS.sandboxProxyReady, resource: VISUAL_BRIDGE_METHODS.sandboxResourceReady };
|
|
593
|
+
return `(function () {
|
|
594
|
+
if (window.parent === window) { return; }
|
|
595
|
+
var methods = ${inlineJson(methods)};
|
|
596
|
+
var written = false;
|
|
597
|
+
window.addEventListener('message', function (event) {
|
|
598
|
+
var data = event.data;
|
|
599
|
+
if (written || event.source !== window.parent || typeof data !== 'object' || data === null || data.jsonrpc !== '2.0' || data.method !== methods.resource) { return; }
|
|
600
|
+
var params = data.params;
|
|
601
|
+
if (typeof params !== 'object' || params === null || typeof params.html !== 'string') { return; }
|
|
602
|
+
written = true;
|
|
603
|
+
document.open();
|
|
604
|
+
document.write(params.html);
|
|
605
|
+
document.close();
|
|
606
|
+
});
|
|
607
|
+
window.parent.postMessage({ jsonrpc: '2.0', method: methods.ready }, '*');
|
|
608
|
+
})();`;
|
|
609
|
+
}
|
|
610
|
+
/*
|
|
611
|
+
* The sandbox host page: the first document of every frame a visual is drawn in, which an app serves
|
|
612
|
+
* as UTF-8 HTML with its policy as a header, on another origin or on its own behind a `sandbox`
|
|
613
|
+
* policy. It says it listens, becomes the first page its parent sends, once, and ignores every other
|
|
614
|
+
* message. The page runs in this very document, so the policy this page was served with is the
|
|
615
|
+
* page's policy too.
|
|
616
|
+
*/
|
|
617
|
+
export const VISUAL_HOST_PAGE = `<!doctype html>\n<html><head><meta charset="utf-8"><script>${hostPageScript()}</script></head><body></body></html>\n`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agent-contracts",
|
|
3
|
-
"version": "0.17.0
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Schemas and port contracts for agent chats, provider accounts, models and usage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -64,6 +64,11 @@
|
|
|
64
64
|
"types": "./dist/usage.d.ts",
|
|
65
65
|
"default": "./dist/usage.js"
|
|
66
66
|
},
|
|
67
|
+
"./visual": {
|
|
68
|
+
"source": "./src/visual.ts",
|
|
69
|
+
"types": "./dist/visual.d.ts",
|
|
70
|
+
"default": "./dist/visual.js"
|
|
71
|
+
},
|
|
67
72
|
"./worktree": {
|
|
68
73
|
"source": "./src/worktree.ts",
|
|
69
74
|
"types": "./dist/worktree.d.ts",
|
package/src/chat.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { AgentKindSchema, AgentStatusSchema, SuggestedTitleSchema } from './agen
|
|
|
3
3
|
import { WorktreeSchema } from './worktree.ts';
|
|
4
4
|
import { ModelSelectionSchema, RuntimeModeSchema } from './model.ts';
|
|
5
5
|
import { ProviderAccountIdSchema } from './provider-accounts.ts';
|
|
6
|
+
import { ChatVisualsSchema } from './visual.ts';
|
|
6
7
|
|
|
7
8
|
// The client picks the id (its node id), like a terminal session.
|
|
8
9
|
export const ChatIdSchema = z.string().min(1);
|
|
@@ -762,6 +763,17 @@ export type ChatBookmarksResult = z.infer<typeof ChatBookmarksResultSchema>;
|
|
|
762
763
|
export const ChatBookmarksEventSchema = z.object({ chatId: ChatIdSchema, bookmarks: ChatBookmarksSchema });
|
|
763
764
|
export type ChatBookmarksEvent = z.infer<typeof ChatBookmarksEventSchema>;
|
|
764
765
|
|
|
766
|
+
// A person takes a visual out of the thread from its card; one that is already gone is no refusal.
|
|
767
|
+
export const ChatRemoveVisualPayloadSchema = ChatTargetPayloadSchema.extend({ visualId: z.string().min(1) });
|
|
768
|
+
export type ChatRemoveVisualPayload = z.infer<typeof ChatRemoveVisualPayloadSchema>;
|
|
769
|
+
|
|
770
|
+
export const ChatVisualsResultSchema = z.object({ visuals: ChatVisualsSchema });
|
|
771
|
+
export type ChatVisualsResult = z.infer<typeof ChatVisualsResultSchema>;
|
|
772
|
+
|
|
773
|
+
// The whole list after every change, to every client attached to the chat.
|
|
774
|
+
export const ChatVisualsEventSchema = z.object({ chatId: ChatIdSchema, visuals: ChatVisualsSchema });
|
|
775
|
+
export type ChatVisualsEvent = z.infer<typeof ChatVisualsEventSchema>;
|
|
776
|
+
|
|
765
777
|
export const ChatAttachResultSchema = z.object({
|
|
766
778
|
info: ChatInfoSchema,
|
|
767
779
|
items: z.array(ChatItemSchema),
|
|
@@ -771,7 +783,9 @@ export const ChatAttachResultSchema = z.object({
|
|
|
771
783
|
// Only when `since` was honored: what happened after it, in order; `items` is then empty.
|
|
772
784
|
events: z.array(ChatEventSchema).optional(),
|
|
773
785
|
// Absent from a daemon that keeps no bookmarks.
|
|
774
|
-
bookmarks: ChatBookmarksSchema.optional()
|
|
786
|
+
bookmarks: ChatBookmarksSchema.optional(),
|
|
787
|
+
// Absent from a host that keeps no visuals.
|
|
788
|
+
visuals: ChatVisualsSchema.optional()
|
|
775
789
|
});
|
|
776
790
|
export type ChatAttachResult = z.infer<typeof ChatAttachResultSchema>;
|
|
777
791
|
|