@khanglvm/relay 0.10.2 → 0.11.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 +66 -0
- package/docs/AGENT.md +66 -1
- package/package.json +6 -2
- package/skills/relay/SKILL.md +14 -0
- package/src/cli.js +97 -2
- package/src/mcp-ui/board.js +854 -0
- package/src/mcp-ui/index.html +36 -0
- package/src/mcp.js +451 -0
- package/src/spec.js +65 -3
- package/src/ui/app.js +33 -0
- package/src/ui/blocks.css +25 -0
- package/src/ui/blocks.js +63 -0
- package/src/ui/style.css +11 -0
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
// board.js — the relay board as an MCP App (SEP-1865 "io.modelcontextprotocol/ui").
|
|
2
|
+
//
|
|
3
|
+
// This is the same board relay opens in a browser, but rendered INSIDE the
|
|
4
|
+
// host app (Claude desktop/mobile, Codex, …) as a sandboxed inline iframe.
|
|
5
|
+
// There is no local HTTP server here: every exchange with the host travels over
|
|
6
|
+
// JSON-RPC on window.postMessage — the spec arrives as the tool result, the
|
|
7
|
+
// user's answers go back via `ui/update-model-context`, and vendored libraries
|
|
8
|
+
// (Chart.js / Mermaid / Viz.js) are pulled through the host's `resources/read`.
|
|
9
|
+
//
|
|
10
|
+
// Rich blocks are rendered by the SAME window.RelayBlocks as the browser board
|
|
11
|
+
// (markdown, code, diff, table, chart, mermaid, graphviz, image, html), so the
|
|
12
|
+
// two surfaces stay in lockstep. Annotations / autosave / heartbeat are
|
|
13
|
+
// browser-server concepts and intentionally absent; per-question notes and the
|
|
14
|
+
// overall comment carry structured feedback back to the agent.
|
|
15
|
+
(() => {
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const BOOT = (() => {
|
|
19
|
+
try { return JSON.parse(document.getElementById('boot').textContent); } catch { return {}; }
|
|
20
|
+
})();
|
|
21
|
+
const PROTOCOL = '2025-06-18';
|
|
22
|
+
|
|
23
|
+
// ---------- tiny DOM helper (mirrors app.js / blocks.js) ----------
|
|
24
|
+
function el(tag, attrs = {}, ...children) {
|
|
25
|
+
const n = document.createElement(tag);
|
|
26
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
27
|
+
if (v === undefined || v === null) continue;
|
|
28
|
+
if (k === 'class') n.className = v;
|
|
29
|
+
else if (k.startsWith('on')) n.addEventListener(k.slice(2), v);
|
|
30
|
+
else n.setAttribute(k, v);
|
|
31
|
+
}
|
|
32
|
+
for (const c of children.flat()) {
|
|
33
|
+
if (c !== null && c !== undefined) n.append(c.nodeType ? c : document.createTextNode(c));
|
|
34
|
+
}
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ======================================================================
|
|
39
|
+
// MCP Apps postMessage / JSON-RPC bridge
|
|
40
|
+
// ======================================================================
|
|
41
|
+
const pending = new Map();
|
|
42
|
+
let rpcSeq = 0;
|
|
43
|
+
const notifyHandlers = Object.create(null);
|
|
44
|
+
function onNotify(method, fn) {
|
|
45
|
+
(notifyHandlers[method] || (notifyHandlers[method] = [])).push(fn);
|
|
46
|
+
}
|
|
47
|
+
function post(msg) {
|
|
48
|
+
try {
|
|
49
|
+
(window.parent && window.parent !== window ? window.parent : window).postMessage(msg, '*');
|
|
50
|
+
} catch {
|
|
51
|
+
// host frame gone — nothing we can do
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function request(method, params) {
|
|
55
|
+
const id = 'rly-' + (++rpcSeq);
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
pending.set(id, { resolve, reject });
|
|
58
|
+
post({ jsonrpc: '2.0', id, method, params: params || {} });
|
|
59
|
+
// Don't hang forever if a host ignores a method — resolve-less reject.
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
if (pending.has(id)) {
|
|
62
|
+
pending.delete(id);
|
|
63
|
+
reject(new Error('timeout: ' + method));
|
|
64
|
+
}
|
|
65
|
+
}, 15000);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function notify(method, params) {
|
|
69
|
+
post({ jsonrpc: '2.0', method, params: params || {} });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
window.addEventListener('message', (e) => {
|
|
73
|
+
const msg = e.data;
|
|
74
|
+
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return;
|
|
75
|
+
// A response to one of our requests.
|
|
76
|
+
if (msg.id !== undefined && msg.id !== null && (('result' in msg) || ('error' in msg))) {
|
|
77
|
+
const p = pending.get(msg.id);
|
|
78
|
+
if (!p) return;
|
|
79
|
+
pending.delete(msg.id);
|
|
80
|
+
if (msg.error) p.reject(Object.assign(new Error(msg.error.message || 'rpc error'), { rpc: msg.error }));
|
|
81
|
+
else p.resolve(msg.result);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// A notification or request FROM the host.
|
|
85
|
+
if (typeof msg.method === 'string') {
|
|
86
|
+
const hs = notifyHandlers[msg.method] || [];
|
|
87
|
+
for (const h of hs) {
|
|
88
|
+
try { h(msg.params || {}, msg); } catch { /* handler errors never break the bridge */ }
|
|
89
|
+
}
|
|
90
|
+
// Host requests we must acknowledge.
|
|
91
|
+
if (msg.id !== undefined && msg.id !== null) {
|
|
92
|
+
if (msg.method === 'ui/resource-teardown') post({ jsonrpc: '2.0', id: msg.id, result: {} });
|
|
93
|
+
else post({ jsonrpc: '2.0', id: msg.id, result: {} });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ======================================================================
|
|
99
|
+
// theme + size
|
|
100
|
+
// ======================================================================
|
|
101
|
+
let host = {};
|
|
102
|
+
function effectiveTheme() {
|
|
103
|
+
if (host.theme === 'dark' || host.theme === 'light') return host.theme;
|
|
104
|
+
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
105
|
+
}
|
|
106
|
+
function applyTheme() {
|
|
107
|
+
const t = host.theme;
|
|
108
|
+
if (t === 'dark' || t === 'light') {
|
|
109
|
+
document.documentElement.dataset.theme = t;
|
|
110
|
+
// Pin color-scheme so the host's light-dark() style variables resolve to
|
|
111
|
+
// the side the host actually picked (not whatever the OS prefers).
|
|
112
|
+
document.documentElement.style.colorScheme = t;
|
|
113
|
+
} else {
|
|
114
|
+
delete document.documentElement.dataset.theme;
|
|
115
|
+
document.documentElement.style.colorScheme = 'light dark';
|
|
116
|
+
}
|
|
117
|
+
if (window.RelayBlocks && typeof RelayBlocks.onThemeChange === 'function') {
|
|
118
|
+
try { RelayBlocks.onThemeChange(effectiveTheme()); } catch { /* not rendered yet */ }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Full color-blend: map the host's standardized style variables (SEP-1865
|
|
123
|
+
// theming) onto relay's own custom properties so the board adopts the app's
|
|
124
|
+
// surfaces, text, borders, primary-action color and fonts — reading as part of
|
|
125
|
+
// Claude/Codex rather than a foreign page. Every mapping is conditional: a
|
|
126
|
+
// token the host omits keeps relay's own default, so it degrades gracefully on
|
|
127
|
+
// leaner hosts (where the warm terracotta identity simply stays).
|
|
128
|
+
const HOST_VAR_MAP = {
|
|
129
|
+
'--bg': '--color-background-primary',
|
|
130
|
+
'--card': '--color-background-secondary',
|
|
131
|
+
'--bg-sunken': '--color-background-tertiary',
|
|
132
|
+
'--fg': '--color-text-primary',
|
|
133
|
+
'--fg-2': '--color-text-secondary',
|
|
134
|
+
'--muted': '--color-text-tertiary',
|
|
135
|
+
'--border': '--color-border-primary',
|
|
136
|
+
'--border-strong': '--color-border-secondary',
|
|
137
|
+
'--accent': '--color-background-inverse',
|
|
138
|
+
'--accent-hover': '--color-background-inverse',
|
|
139
|
+
'--accent-fg': '--color-text-inverse',
|
|
140
|
+
'--accent-soft': '--color-background-tertiary',
|
|
141
|
+
'--danger': '--color-text-danger',
|
|
142
|
+
'--ok': '--color-text-success',
|
|
143
|
+
'--sans': '--font-sans',
|
|
144
|
+
'--mono': '--font-mono',
|
|
145
|
+
};
|
|
146
|
+
let hostFontStyleEl = null;
|
|
147
|
+
function adoptHostStyles() {
|
|
148
|
+
const styles = host && host.styles;
|
|
149
|
+
if (!styles || typeof styles !== 'object') return;
|
|
150
|
+
const v = styles.variables && typeof styles.variables === 'object' ? styles.variables : {};
|
|
151
|
+
const root = document.documentElement.style;
|
|
152
|
+
for (const [ours, theirs] of Object.entries(HOST_VAR_MAP)) {
|
|
153
|
+
if (typeof v[theirs] === 'string' && v[theirs]) root.setProperty(ours, v[theirs]);
|
|
154
|
+
}
|
|
155
|
+
const fonts = styles.css && typeof styles.css.fonts === 'string' ? styles.css.fonts : '';
|
|
156
|
+
if (fonts) {
|
|
157
|
+
if (!hostFontStyleEl) { hostFontStyleEl = document.createElement('style'); document.head.appendChild(hostFontStyleEl); }
|
|
158
|
+
hostFontStyleEl.textContent = fonts;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let sizeTimer = null;
|
|
163
|
+
function measureHeight() {
|
|
164
|
+
return Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
|
|
165
|
+
}
|
|
166
|
+
function sendSize() {
|
|
167
|
+
notify('ui/notifications/size-changed', { width: document.documentElement.scrollWidth, height: measureHeight() });
|
|
168
|
+
}
|
|
169
|
+
function reportSize() {
|
|
170
|
+
if (sizeTimer) return;
|
|
171
|
+
sizeTimer = setTimeout(() => { sizeTimer = null; sendSize(); }, 60);
|
|
172
|
+
}
|
|
173
|
+
// Immediate, un-debounced report — used when the board shrinks (e.g. after
|
|
174
|
+
// submit) so the host collapses the iframe right away instead of waiting.
|
|
175
|
+
function reportSizeNow() {
|
|
176
|
+
if (sizeTimer) { clearTimeout(sizeTimer); sizeTimer = null; }
|
|
177
|
+
sendSize();
|
|
178
|
+
}
|
|
179
|
+
window.addEventListener('resize', reportSize);
|
|
180
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
181
|
+
try { new ResizeObserver(reportSize).observe(document.documentElement); } catch { /* older host */ }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ======================================================================
|
|
185
|
+
// client-side spec normalization (resilience)
|
|
186
|
+
// ----------------------------------------------------------------------
|
|
187
|
+
// The host normally hands us the SERVER-normalized spec via the tool result
|
|
188
|
+
// (structuredContent.spec) — options as {value,label}, blocks with ids, etc.
|
|
189
|
+
// If a host only forwards the raw tool input, this brings it close enough to
|
|
190
|
+
// render: it assigns ids and coerces option/column shapes. File-backed blocks
|
|
191
|
+
// (codeFile/htmlFile/local images) can't be resolved client-side and are left
|
|
192
|
+
// to the server path.
|
|
193
|
+
function clientNormalize(raw) {
|
|
194
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
195
|
+
const spec = {
|
|
196
|
+
title: String(raw.title || 'Relay'),
|
|
197
|
+
intro: typeof raw.intro === 'string' ? raw.intro : '',
|
|
198
|
+
blocks: normBlocks(raw.blocks, ''),
|
|
199
|
+
allowPartial: raw.allowPartial !== false,
|
|
200
|
+
note: raw.note !== false,
|
|
201
|
+
autoClose: raw.autoClose !== false,
|
|
202
|
+
questions: [],
|
|
203
|
+
submitLabel: typeof raw.submitLabel === 'string' ? raw.submitLabel : '',
|
|
204
|
+
};
|
|
205
|
+
const qs = Array.isArray(raw.questions) ? raw.questions : [];
|
|
206
|
+
qs.forEach((rq, i) => {
|
|
207
|
+
if (!rq || typeof rq !== 'object') return;
|
|
208
|
+
const id = String(rq.id || 'q' + (i + 1));
|
|
209
|
+
const type = String(rq.type || 'text');
|
|
210
|
+
const q = {
|
|
211
|
+
id, type,
|
|
212
|
+
label: String(rq.label || rq.question || rq.text || ''),
|
|
213
|
+
description: typeof rq.description === 'string' ? rq.description : '',
|
|
214
|
+
required: rq.required === true,
|
|
215
|
+
note: rq.note === undefined ? type === 'single' : rq.note === true,
|
|
216
|
+
placeholder: typeof rq.placeholder === 'string' ? rq.placeholder : '',
|
|
217
|
+
blocks: normBlocks(rq.blocks, id + '-'),
|
|
218
|
+
};
|
|
219
|
+
if (type === 'single' || type === 'multi') {
|
|
220
|
+
const opts = Array.isArray(rq.options) ? rq.options : [];
|
|
221
|
+
q.options = opts.map((o, j) => {
|
|
222
|
+
if (typeof o === 'string' || typeof o === 'number') return { value: String(o), label: String(o) };
|
|
223
|
+
if (o && typeof o === 'object') {
|
|
224
|
+
const value = String(o.value != null ? o.value : o.label != null ? o.label : '');
|
|
225
|
+
const out = { value, label: String(o.label != null ? o.label : value) };
|
|
226
|
+
if (o.description) out.description = String(o.description);
|
|
227
|
+
const ob = normBlocks(o.blocks, id + '-o' + (j + 1) + '-');
|
|
228
|
+
if (ob.length) out.blocks = ob;
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
return { value: String(o), label: String(o) };
|
|
232
|
+
});
|
|
233
|
+
q.other = rq.other === true;
|
|
234
|
+
}
|
|
235
|
+
if (type === 'scale') {
|
|
236
|
+
q.min = Number.isFinite(rq.min) ? rq.min : 1;
|
|
237
|
+
q.max = Number.isFinite(rq.max) ? rq.max : Math.max(5, q.min + 1);
|
|
238
|
+
q.minLabel = typeof rq.minLabel === 'string' ? rq.minLabel : '';
|
|
239
|
+
q.maxLabel = typeof rq.maxLabel === 'string' ? rq.maxLabel : '';
|
|
240
|
+
}
|
|
241
|
+
if (type === 'color' && Array.isArray(rq.presets)) {
|
|
242
|
+
q.presets = rq.presets.map((c) => String(c)).filter(Boolean);
|
|
243
|
+
}
|
|
244
|
+
if (rq.default !== undefined) q.default = rq.default;
|
|
245
|
+
spec.questions.push(q);
|
|
246
|
+
});
|
|
247
|
+
if (!spec.submitLabel) spec.submitLabel = spec.questions.length ? 'Submit' : 'Acknowledge';
|
|
248
|
+
return spec;
|
|
249
|
+
}
|
|
250
|
+
function normBlocks(blocks, prefix) {
|
|
251
|
+
if (!Array.isArray(blocks)) return [];
|
|
252
|
+
let n = 0;
|
|
253
|
+
const out = [];
|
|
254
|
+
for (const b of blocks) {
|
|
255
|
+
if (!b || typeof b !== 'object' || !b.type) continue;
|
|
256
|
+
out.push(b.id ? b : { ...b, id: prefix + 'b' + (++n) });
|
|
257
|
+
if (b.id) n++;
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ======================================================================
|
|
263
|
+
// vendored libraries pulled through the host bridge (resources/read)
|
|
264
|
+
// ----------------------------------------------------------------------
|
|
265
|
+
// blocks.js lazy-loads Chart.js / Mermaid / Viz.js via <script src="/vendor/…">
|
|
266
|
+
// and short-circuits when the global already exists. There's no server here,
|
|
267
|
+
// so we fetch the vendor source over the bridge and define the global up
|
|
268
|
+
// front; blocks.js then never reaches for the (absent) /vendor route.
|
|
269
|
+
const NEED = { chart: ['chart.umd.js', 'Chart'], mermaid: ['mermaid.min.js', 'mermaid'], graphviz: ['viz-standalone.js', 'Viz'] };
|
|
270
|
+
function blockTypesIn(spec) {
|
|
271
|
+
const types = new Set();
|
|
272
|
+
const scan = (blocks) => {
|
|
273
|
+
for (const b of Array.isArray(blocks) ? blocks : []) {
|
|
274
|
+
if (b && b.type) types.add(b.type);
|
|
275
|
+
if (b && Array.isArray(b.blocks)) scan(b.blocks);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
scan(spec.blocks);
|
|
279
|
+
for (const q of spec.questions || []) {
|
|
280
|
+
scan(q.blocks);
|
|
281
|
+
for (const o of Array.isArray(q.options) ? q.options : []) if (o) scan(o.blocks);
|
|
282
|
+
}
|
|
283
|
+
return types;
|
|
284
|
+
}
|
|
285
|
+
async function ensureVendor(file, globalName) {
|
|
286
|
+
if (window[globalName]) return true;
|
|
287
|
+
try {
|
|
288
|
+
const res = await request('resources/read', { uri: 'ui://relay/vendor/' + file });
|
|
289
|
+
const c = res && Array.isArray(res.contents) ? res.contents[0] : null;
|
|
290
|
+
const code = c && (c.text || (c.blob ? atob(c.blob) : ''));
|
|
291
|
+
if (!code) return false;
|
|
292
|
+
const s = document.createElement('script');
|
|
293
|
+
s.textContent = code;
|
|
294
|
+
document.head.appendChild(s);
|
|
295
|
+
return Boolean(window[globalName]);
|
|
296
|
+
} catch {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async function preloadVendors(spec) {
|
|
301
|
+
const types = blockTypesIn(spec);
|
|
302
|
+
const jobs = [];
|
|
303
|
+
if (types.has('chart')) jobs.push(ensureVendor(...NEED.chart));
|
|
304
|
+
if (types.has('mermaid')) jobs.push(ensureVendor(...NEED.mermaid));
|
|
305
|
+
if (types.has('graphviz')) jobs.push(ensureVendor(...NEED.graphviz));
|
|
306
|
+
if (jobs.length) await Promise.allSettled(jobs);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// html blocks: the browser board serves each in its own iframe from
|
|
310
|
+
// /html/b/<id>. Here we wrap the body into a self-contained document and hand
|
|
311
|
+
// blocks.js a blob: URL for the iframe src (same sandbox, no server needed).
|
|
312
|
+
const htmlBodies = new Map();
|
|
313
|
+
function indexHtmlBlocks(spec) {
|
|
314
|
+
const add = (blocks) => {
|
|
315
|
+
for (const b of Array.isArray(blocks) ? blocks : []) {
|
|
316
|
+
if (b && b.type === 'html' && typeof b.html === 'string') htmlBodies.set(b.id, b.html);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
add(spec.blocks);
|
|
320
|
+
for (const q of spec.questions || []) {
|
|
321
|
+
add(q.blocks);
|
|
322
|
+
for (const o of Array.isArray(q.options) ? q.options : []) if (o) add(o.blocks);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function htmlBlobSrc(blockId) {
|
|
326
|
+
const body = htmlBodies.get(blockId) || '';
|
|
327
|
+
const dark = effectiveTheme() === 'dark';
|
|
328
|
+
let doc;
|
|
329
|
+
if (/<html[\s>]/i.test(body)) {
|
|
330
|
+
doc = body;
|
|
331
|
+
} else {
|
|
332
|
+
const bg = dark ? '#282624' : '#ffffff';
|
|
333
|
+
const fg = dark ? '#edeae4' : '#1c1b19';
|
|
334
|
+
doc =
|
|
335
|
+
'<!doctype html><html><head><meta charset="utf-8">' +
|
|
336
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">' +
|
|
337
|
+
'<style>:root{color-scheme:' + (dark ? 'dark' : 'light') + '}' +
|
|
338
|
+
'body{margin:12px;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;' +
|
|
339
|
+
'background:' + bg + ';color:' + fg + '}</style></head><body>' + body + '</body></html>';
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
return URL.createObjectURL(new Blob([doc], { type: 'text/html' }));
|
|
343
|
+
} catch {
|
|
344
|
+
return 'data:text/html;charset=utf-8,' + encodeURIComponent(doc);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function blockCtx(questionId) {
|
|
349
|
+
return {
|
|
350
|
+
theme: effectiveTheme,
|
|
351
|
+
htmlSrc: (blockId) => htmlBlobSrc(blockId),
|
|
352
|
+
questionId: questionId == null ? null : questionId,
|
|
353
|
+
annotate: null,
|
|
354
|
+
edits: state.blockEdits,
|
|
355
|
+
onBlockEdit: (blockId, codeOrNull) => {
|
|
356
|
+
if (codeOrNull === null || codeOrNull === undefined) delete state.blockEdits[blockId];
|
|
357
|
+
else state.blockEdits[blockId] = codeOrNull;
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
function renderBlocks(container, blocks, questionId) {
|
|
362
|
+
if (!Array.isArray(blocks) || !blocks.length) return;
|
|
363
|
+
const target = el('div', { class: 'blocks' });
|
|
364
|
+
container.append(target);
|
|
365
|
+
if (!window.RelayBlocks) {
|
|
366
|
+
target.append(el('div', { class: 'blk' }, el('div', { class: 'blk-error' }, 'block failed to render')));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
Promise.resolve()
|
|
370
|
+
.then(() => window.RelayBlocks.render(target, blocks, blockCtx(questionId)))
|
|
371
|
+
.then(reportSize)
|
|
372
|
+
.catch(() => {
|
|
373
|
+
target.append(el('div', { class: 'blk' }, el('div', { class: 'blk-error' }, 'block failed to render')));
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ======================================================================
|
|
378
|
+
// board state + answer controls (ported from the browser board)
|
|
379
|
+
// ======================================================================
|
|
380
|
+
let spec = null;
|
|
381
|
+
let QS = [];
|
|
382
|
+
const state = { answers: {}, other: {}, notes: {}, comment: '', blockEdits: {} };
|
|
383
|
+
let submitted = false;
|
|
384
|
+
const cards = {};
|
|
385
|
+
const app = document.getElementById('app');
|
|
386
|
+
|
|
387
|
+
// ---------- display mode (host-driven) ----------
|
|
388
|
+
// GUI hosts (Claude, Codex) render their OWN full-screen control, so relay
|
|
389
|
+
// adds no redundant button. We still declare fullscreen support in
|
|
390
|
+
// ui/initialize and react to the host's mode change: in fullscreen the iframe
|
|
391
|
+
// fills the window, so we center the content to a readable column.
|
|
392
|
+
let displayMode = 'inline';
|
|
393
|
+
function applyDisplayMode() {
|
|
394
|
+
document.documentElement.classList.toggle('mcp-fullscreen', displayMode === 'fullscreen');
|
|
395
|
+
reportSize();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function seedDefaults() {
|
|
399
|
+
for (const q of QS) if (q.default !== undefined) state.answers[q.id] = q.default;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function getValue(q) {
|
|
403
|
+
const v = state.answers[q.id];
|
|
404
|
+
const oth = state.other[q.id];
|
|
405
|
+
switch (q.type) {
|
|
406
|
+
case 'single': {
|
|
407
|
+
if (oth && oth.on) { const t = (oth.text || '').trim(); return t || undefined; }
|
|
408
|
+
return typeof v === 'string' && v ? v : undefined;
|
|
409
|
+
}
|
|
410
|
+
case 'multi': {
|
|
411
|
+
const arr = Array.isArray(v) ? [...v] : [];
|
|
412
|
+
if (oth && oth.on) { const t = (oth.text || '').trim(); if (t) arr.push(t); }
|
|
413
|
+
return arr.length ? arr : undefined;
|
|
414
|
+
}
|
|
415
|
+
case 'yesno':
|
|
416
|
+
return v === 'yes' || v === 'no' ? v : undefined;
|
|
417
|
+
case 'scale':
|
|
418
|
+
return typeof v === 'number' ? v : undefined;
|
|
419
|
+
default: {
|
|
420
|
+
const t = typeof v === 'string' ? v.trim() : '';
|
|
421
|
+
return t || undefined;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function payload() {
|
|
427
|
+
const answers = {};
|
|
428
|
+
const notes = {};
|
|
429
|
+
for (const q of QS) {
|
|
430
|
+
const v = getValue(q);
|
|
431
|
+
if (v !== undefined) answers[q.id] = v;
|
|
432
|
+
const n = typeof state.notes[q.id] === 'string' ? state.notes[q.id].trim() : '';
|
|
433
|
+
if (n) notes[q.id] = n;
|
|
434
|
+
}
|
|
435
|
+
const skipped = QS.filter((q) => !(q.id in answers)).map((q) => q.id);
|
|
436
|
+
const blockEdits = Object.keys(state.blockEdits).length ? state.blockEdits : null;
|
|
437
|
+
return { answers, skipped, comment: (state.comment || '').trim(), notes, blockEdits };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function clearErr(qid) { if (cards[qid]) cards[qid].classList.remove('error'); }
|
|
441
|
+
|
|
442
|
+
function syncOptSel(group) {
|
|
443
|
+
for (const lab of group.querySelectorAll('label.opt')) {
|
|
444
|
+
const input = lab.querySelector('input');
|
|
445
|
+
lab.classList.toggle('sel', input.checked);
|
|
446
|
+
const wrap = lab.closest('.optwrap');
|
|
447
|
+
if (wrap) wrap.classList.toggle('sel', input.checked);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function withOptionBlocks(labelEl, o, questionId) {
|
|
451
|
+
if (!Array.isArray(o.blocks) || !o.blocks.length) return labelEl;
|
|
452
|
+
const wrap = el('div', { class: 'optwrap' + (labelEl.classList.contains('sel') ? ' sel' : '') }, labelEl);
|
|
453
|
+
renderBlocks(wrap, o.blocks, questionId);
|
|
454
|
+
return wrap;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function controlSingle(q) {
|
|
458
|
+
const group = el('div');
|
|
459
|
+
const entries = [];
|
|
460
|
+
let otherRadio = null;
|
|
461
|
+
const otherOn = () => Boolean(state.other[q.id] && state.other[q.id].on);
|
|
462
|
+
const syncSingle = () => {
|
|
463
|
+
for (const { input, value } of entries) input.checked = state.answers[q.id] === value && !otherOn();
|
|
464
|
+
if (otherRadio) otherRadio.checked = otherOn();
|
|
465
|
+
syncOptSel(group);
|
|
466
|
+
clearErr(q.id);
|
|
467
|
+
};
|
|
468
|
+
for (const o of q.options) {
|
|
469
|
+
const input = el('input', { type: 'radio', name: q.id });
|
|
470
|
+
entries.push({ input, value: o.value });
|
|
471
|
+
input.checked = state.answers[q.id] === o.value && !otherOn();
|
|
472
|
+
input.addEventListener('click', (e) => {
|
|
473
|
+
e.preventDefault();
|
|
474
|
+
if (state.answers[q.id] === o.value && !otherOn()) delete state.answers[q.id];
|
|
475
|
+
else { state.answers[q.id] = o.value; if (state.other[q.id]) state.other[q.id].on = false; }
|
|
476
|
+
setTimeout(syncSingle, 0);
|
|
477
|
+
});
|
|
478
|
+
input.addEventListener('change', () => {
|
|
479
|
+
if (!input.checked) return;
|
|
480
|
+
state.answers[q.id] = o.value;
|
|
481
|
+
if (state.other[q.id]) state.other[q.id].on = false;
|
|
482
|
+
syncSingle();
|
|
483
|
+
});
|
|
484
|
+
group.append(withOptionBlocks(
|
|
485
|
+
el('label', { class: 'opt' + (input.checked ? ' sel' : '') }, input,
|
|
486
|
+
el('div', {}, el('div', { class: 'ol' }, o.label), o.description ? el('div', { class: 'od' }, o.description) : null)),
|
|
487
|
+
o, q.id));
|
|
488
|
+
}
|
|
489
|
+
if (q.other) {
|
|
490
|
+
otherRadio = el('input', { type: 'radio', name: q.id });
|
|
491
|
+
const text = el('input', { type: 'text', placeholder: 'your own answer…' });
|
|
492
|
+
text.value = (state.other[q.id] && state.other[q.id].text) || '';
|
|
493
|
+
otherRadio.checked = otherOn();
|
|
494
|
+
const ensureOther = () => state.other[q.id] || (state.other[q.id] = { on: false, text: text.value });
|
|
495
|
+
otherRadio.addEventListener('click', (e) => { e.preventDefault(); const oth = ensureOther(); oth.on = !oth.on; if (oth.on) delete state.answers[q.id]; setTimeout(syncSingle, 0); });
|
|
496
|
+
otherRadio.addEventListener('change', () => { if (!otherRadio.checked) return; const oth = ensureOther(); oth.on = true; delete state.answers[q.id]; syncSingle(); });
|
|
497
|
+
text.addEventListener('input', () => { const oth = ensureOther(); oth.text = text.value; if (!oth.on) { oth.on = true; delete state.answers[q.id]; } syncSingle(); });
|
|
498
|
+
group.append(el('label', { class: 'opt' + (otherRadio.checked ? ' sel' : '') }, otherRadio,
|
|
499
|
+
el('div', { style: 'flex:1' }, el('div', { class: 'ol' }, 'Other'), el('div', { class: 'otherbox' }, text))));
|
|
500
|
+
}
|
|
501
|
+
return group;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function controlMulti(q) {
|
|
505
|
+
const group = el('div');
|
|
506
|
+
const selected = new Set(Array.isArray(state.answers[q.id]) ? state.answers[q.id] : []);
|
|
507
|
+
const readChecked = () => {
|
|
508
|
+
state.answers[q.id] = [...group.querySelectorAll('input[data-val]')].filter((i) => i.checked).map((i) => i.dataset.val);
|
|
509
|
+
syncOptSel(group);
|
|
510
|
+
clearErr(q.id);
|
|
511
|
+
};
|
|
512
|
+
for (const o of q.options) {
|
|
513
|
+
const input = el('input', { type: 'checkbox', 'data-val': o.value });
|
|
514
|
+
input.checked = selected.has(o.value);
|
|
515
|
+
input.addEventListener('change', readChecked);
|
|
516
|
+
group.append(withOptionBlocks(
|
|
517
|
+
el('label', { class: 'opt' + (input.checked ? ' sel' : '') }, input,
|
|
518
|
+
el('div', {}, el('div', { class: 'ol' }, o.label), o.description ? el('div', { class: 'od' }, o.description) : null)),
|
|
519
|
+
o, q.id));
|
|
520
|
+
}
|
|
521
|
+
if (q.other) {
|
|
522
|
+
const oth = state.other[q.id];
|
|
523
|
+
const box = el('input', { type: 'checkbox' });
|
|
524
|
+
const text = el('input', { type: 'text', placeholder: 'your own answer…' });
|
|
525
|
+
box.checked = Boolean(oth && oth.on);
|
|
526
|
+
text.value = (oth && oth.text) || '';
|
|
527
|
+
const sync = () => { state.other[q.id] = { on: box.checked, text: text.value }; syncOptSel(group); clearErr(q.id); };
|
|
528
|
+
box.addEventListener('change', sync);
|
|
529
|
+
text.addEventListener('input', () => { if (!box.checked) box.checked = true; sync(); });
|
|
530
|
+
group.append(el('label', { class: 'opt' + (box.checked ? ' sel' : '') }, box,
|
|
531
|
+
el('div', { style: 'flex:1' }, el('div', { class: 'ol' }, 'Other'), el('div', { class: 'otherbox' }, text))));
|
|
532
|
+
}
|
|
533
|
+
return group;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function segButtons(q, values, labels) {
|
|
537
|
+
const seg = el('div', { class: q.type === 'scale' ? 'scale' : 'seg' });
|
|
538
|
+
const buttons = [];
|
|
539
|
+
values.forEach((v, i) => {
|
|
540
|
+
const b = el('button', { type: 'button' }, labels[i]);
|
|
541
|
+
if (state.answers[q.id] === v) b.classList.add('sel');
|
|
542
|
+
b.addEventListener('click', () => {
|
|
543
|
+
if (state.answers[q.id] === v) delete state.answers[q.id];
|
|
544
|
+
else state.answers[q.id] = v;
|
|
545
|
+
for (const x of buttons) x.classList.toggle('sel', state.answers[q.id] === values[buttons.indexOf(x)]);
|
|
546
|
+
clearErr(q.id);
|
|
547
|
+
});
|
|
548
|
+
buttons.push(b);
|
|
549
|
+
seg.append(b);
|
|
550
|
+
});
|
|
551
|
+
return seg;
|
|
552
|
+
}
|
|
553
|
+
function controlScale(q) {
|
|
554
|
+
const values = [];
|
|
555
|
+
for (let i = q.min; i <= q.max; i++) values.push(i);
|
|
556
|
+
const seg = segButtons(q, values, values.map(String));
|
|
557
|
+
const row = el('div', { class: 'scale' });
|
|
558
|
+
if (q.minLabel) row.append(el('span', { class: 'slabel' }, q.minLabel));
|
|
559
|
+
row.append(...seg.children);
|
|
560
|
+
if (q.maxLabel) row.append(el('span', { class: 'slabel' }, q.maxLabel));
|
|
561
|
+
return row;
|
|
562
|
+
}
|
|
563
|
+
function controlText(q, multiline) {
|
|
564
|
+
const input = multiline
|
|
565
|
+
? el('textarea', { placeholder: q.placeholder || '' })
|
|
566
|
+
: el('input', { type: 'text', placeholder: q.placeholder || '' });
|
|
567
|
+
input.value = typeof state.answers[q.id] === 'string' ? state.answers[q.id] : '';
|
|
568
|
+
input.addEventListener('input', () => { state.answers[q.id] = input.value; clearErr(q.id); });
|
|
569
|
+
return input;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function toHex6(c) {
|
|
573
|
+
const s = String(c || '').trim();
|
|
574
|
+
let m = /^#?([0-9a-fA-F]{6})$/.exec(s);
|
|
575
|
+
if (m) return '#' + m[1].toLowerCase();
|
|
576
|
+
m = /^#?([0-9a-fA-F]{3})$/.exec(s);
|
|
577
|
+
if (m) return '#' + m[1].split('').map((x) => x + x).join('').toLowerCase();
|
|
578
|
+
return '#000000';
|
|
579
|
+
}
|
|
580
|
+
function controlColor(q) {
|
|
581
|
+
const wrap = el('div', { class: 'colorpick' });
|
|
582
|
+
const init = (typeof state.answers[q.id] === 'string' && state.answers[q.id]) || (typeof q.default === 'string' ? q.default : '');
|
|
583
|
+
const swatch = el('input', { type: 'color', class: 'colorswatch' });
|
|
584
|
+
const hex = el('input', { type: 'text', class: 'colorhex', placeholder: q.placeholder || '#rrggbb', spellcheck: 'false', autocapitalize: 'off' });
|
|
585
|
+
swatch.value = toHex6(init || '#888888');
|
|
586
|
+
if (init) { hex.value = init; state.answers[q.id] = init; }
|
|
587
|
+
const set = (val) => { state.answers[q.id] = val; clearErr(q.id); };
|
|
588
|
+
swatch.addEventListener('input', () => { hex.value = swatch.value; set(swatch.value); });
|
|
589
|
+
hex.addEventListener('input', () => { const v = hex.value.trim(); if (/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v)) swatch.value = toHex6(v); set(v); });
|
|
590
|
+
wrap.append(el('div', { class: 'colorrow' }, swatch, hex));
|
|
591
|
+
if (Array.isArray(q.presets) && q.presets.length) {
|
|
592
|
+
const presets = el('div', { class: 'colorpresets' });
|
|
593
|
+
for (const c of q.presets) {
|
|
594
|
+
const b = el('button', { type: 'button', class: 'colorpreset', style: 'background:' + c, title: c });
|
|
595
|
+
b.addEventListener('click', () => { swatch.value = toHex6(c); hex.value = c; set(c); });
|
|
596
|
+
presets.append(b);
|
|
597
|
+
}
|
|
598
|
+
wrap.append(presets);
|
|
599
|
+
}
|
|
600
|
+
return wrap;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ======================================================================
|
|
604
|
+
// render
|
|
605
|
+
// ======================================================================
|
|
606
|
+
function render() {
|
|
607
|
+
app.replaceChildren();
|
|
608
|
+
app.append(el('header', { class: 'qb-header' }, el('h1', {}, spec.title)));
|
|
609
|
+
if (spec.intro) {
|
|
610
|
+
const md = window.RelayBlocks && RelayBlocks.renderMarkdown;
|
|
611
|
+
app.append(md
|
|
612
|
+
? el('div', { class: 'intro blk-markdown' }, RelayBlocks.renderMarkdown(spec.intro))
|
|
613
|
+
: el('p', { class: 'intro' }, spec.intro));
|
|
614
|
+
}
|
|
615
|
+
renderBlocks(app, spec.blocks || [], null);
|
|
616
|
+
|
|
617
|
+
QS.forEach((q, idx) => {
|
|
618
|
+
const required = q.required || !spec.allowPartial;
|
|
619
|
+
const card = el('div', { class: 'card' },
|
|
620
|
+
el('div', { class: 'qnum' }, 'Q' + (idx + 1)),
|
|
621
|
+
el('p', { class: 'qlabel' }, q.label, required ? el('span', { class: 'req' }, ' *') : null),
|
|
622
|
+
q.description ? el('p', { class: 'qdesc' }, q.description) : null);
|
|
623
|
+
renderBlocks(card, q.blocks || [], q.id);
|
|
624
|
+
const control = el('div', { class: 'control' });
|
|
625
|
+
if (q.type === 'single') control.append(controlSingle(q));
|
|
626
|
+
else if (q.type === 'multi') control.append(controlMulti(q));
|
|
627
|
+
else if (q.type === 'yesno') control.append(segButtons(q, ['yes', 'no'], ['Yes', 'No']));
|
|
628
|
+
else if (q.type === 'scale') control.append(controlScale(q));
|
|
629
|
+
else if (q.type === 'color') control.append(controlColor(q));
|
|
630
|
+
else control.append(controlText(q, q.type === 'textarea'));
|
|
631
|
+
card.append(control);
|
|
632
|
+
if (q.note) {
|
|
633
|
+
const noteInput = el('textarea', { class: 'qnote', rows: 2, placeholder: 'optional note about this answer…' });
|
|
634
|
+
noteInput.value = typeof state.notes[q.id] === 'string' ? state.notes[q.id] : '';
|
|
635
|
+
noteInput.addEventListener('input', () => { state.notes[q.id] = noteInput.value; });
|
|
636
|
+
card.append(el('div', { class: 'qnotewrap' }, noteInput));
|
|
637
|
+
}
|
|
638
|
+
card.append(el('p', { class: 'errmsg' }, 'This question is required.'));
|
|
639
|
+
cards[q.id] = card;
|
|
640
|
+
app.append(card);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
if (spec.note) {
|
|
644
|
+
const note = el('textarea', { placeholder: 'optional note back to the agent…' });
|
|
645
|
+
note.value = state.comment || '';
|
|
646
|
+
note.addEventListener('input', () => { state.comment = note.value; });
|
|
647
|
+
app.append(el('div', { class: 'card' },
|
|
648
|
+
el('p', { class: 'qlabel' }, 'Anything else?'),
|
|
649
|
+
el('p', { class: 'qdesc' }, 'Free-text note returned to the agent along with your answers.'),
|
|
650
|
+
el('div', { class: 'control' }, note)));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (composing) {
|
|
654
|
+
// still streaming in — show a live "composing" note, no submit yet
|
|
655
|
+
app.append(el('div', { class: 'submitbar' }, el('span', { class: 'mcp-composing' }, 'Composing this board…')));
|
|
656
|
+
reportSize();
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
const submitBtn = el('button', { class: 'submit', type: 'button' }, spec.submitLabel);
|
|
660
|
+
const saveEl = el('span', { class: 'savestate' }, '');
|
|
661
|
+
const hint = el('span', { class: 'hint' }, QS.length && spec.allowPartial ? 'Unanswered questions are returned as skipped.' : '');
|
|
662
|
+
submitBtn.addEventListener('click', () => onSubmit(submitBtn, saveEl));
|
|
663
|
+
app.append(el('div', { class: 'submitbar' }, submitBtn, hint, saveEl));
|
|
664
|
+
reportSize();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function validate() {
|
|
668
|
+
let firstBad = null;
|
|
669
|
+
for (const q of QS) {
|
|
670
|
+
const required = q.required || !spec.allowPartial;
|
|
671
|
+
const bad = required && getValue(q) === undefined;
|
|
672
|
+
cards[q.id].classList.toggle('error', bad);
|
|
673
|
+
if (bad && !firstBad) firstBad = cards[q.id];
|
|
674
|
+
}
|
|
675
|
+
if (firstBad) firstBad.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
676
|
+
return !firstBad;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// A human-readable transcript of the submission — so the agent reads the
|
|
680
|
+
// answers even on a host that doesn't surface structuredContent.
|
|
681
|
+
function summarize(data) {
|
|
682
|
+
const lines = [];
|
|
683
|
+
lines.push('The user submitted the relay board "' + spec.title + '".');
|
|
684
|
+
if (QS.length) {
|
|
685
|
+
lines.push('', 'Answers:');
|
|
686
|
+
for (const q of QS) {
|
|
687
|
+
const v = data.answers[q.id];
|
|
688
|
+
let shown;
|
|
689
|
+
if (v === undefined) shown = '(skipped)';
|
|
690
|
+
else if (Array.isArray(v)) shown = v.join(', ');
|
|
691
|
+
else shown = String(v);
|
|
692
|
+
lines.push('- ' + q.label + ' [' + q.id + ']: ' + shown);
|
|
693
|
+
if (data.notes[q.id]) lines.push(' note: ' + data.notes[q.id]);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (data.comment) lines.push('', 'Comment: ' + data.comment);
|
|
697
|
+
if (data.blockEdits) lines.push('', 'Edited diagrams: ' + Object.keys(data.blockEdits).join(', '));
|
|
698
|
+
return lines.join('\n');
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async function onSubmit(submitBtn, saveEl) {
|
|
702
|
+
if (submitted) return;
|
|
703
|
+
if (!validate()) return;
|
|
704
|
+
submitBtn.disabled = true;
|
|
705
|
+
submitBtn.textContent = 'Submitting…';
|
|
706
|
+
const data = payload();
|
|
707
|
+
const structured = {
|
|
708
|
+
boardId: BOOT.boardId || null,
|
|
709
|
+
status: QS.length ? 'submitted' : 'acknowledged',
|
|
710
|
+
answers: data.answers,
|
|
711
|
+
skipped: data.skipped,
|
|
712
|
+
notes: data.notes,
|
|
713
|
+
comment: data.comment,
|
|
714
|
+
blockEdits: data.blockEdits,
|
|
715
|
+
};
|
|
716
|
+
const text = summarize(data);
|
|
717
|
+
let delivered = false;
|
|
718
|
+
try {
|
|
719
|
+
await request('ui/update-model-context', { content: [{ type: 'text', text }], structuredContent: structured });
|
|
720
|
+
delivered = true;
|
|
721
|
+
} catch {
|
|
722
|
+
// Fallback for hosts without update-model-context: post a chat message.
|
|
723
|
+
try { await request('ui/message', { role: 'user', content: { type: 'text', text } }); delivered = true; } catch { /* give up gracefully */ }
|
|
724
|
+
}
|
|
725
|
+
submitted = true;
|
|
726
|
+
showDone(delivered);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function showDone(delivered) {
|
|
730
|
+
// Collapse: leave fullscreen, drop the whole form for a one-line confirmation
|
|
731
|
+
// so the host shrinks the iframe to a small footprint in the transcript.
|
|
732
|
+
document.documentElement.classList.remove('mcp-fullscreen');
|
|
733
|
+
app.replaceChildren(el('div', { class: 'mcp-done' },
|
|
734
|
+
el('span', { class: 'mark' }, '✓'),
|
|
735
|
+
el('span', { class: 'lead' }, QS.length ? 'Submitted' : 'Acknowledged'),
|
|
736
|
+
el('span', { class: 'sub' }, delivered
|
|
737
|
+
? '· sent back to the agent'
|
|
738
|
+
: '· tell the agent you’ve responded')));
|
|
739
|
+
// Report the small height immediately, then again next frame / after layout
|
|
740
|
+
// settles — beats hosts that only grow on debounced size events.
|
|
741
|
+
reportSizeNow();
|
|
742
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reportSizeNow);
|
|
743
|
+
setTimeout(reportSizeNow, 150);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ======================================================================
|
|
747
|
+
// boot
|
|
748
|
+
// ======================================================================
|
|
749
|
+
function setStatus(text) {
|
|
750
|
+
const s = document.getElementById('mcp-status');
|
|
751
|
+
if (s) s.textContent = text;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
let booted = false;
|
|
755
|
+
async function boot(rawOrNormalized, alreadyNormalized) {
|
|
756
|
+
if (booted || submitted) return;
|
|
757
|
+
const next = alreadyNormalized ? rawOrNormalized : clientNormalize(rawOrNormalized);
|
|
758
|
+
if (!next || (!Array.isArray(next.questions)) ) { return; }
|
|
759
|
+
if (!next.questions.length && !(Array.isArray(next.blocks) && next.blocks.length)) return;
|
|
760
|
+
booted = true;
|
|
761
|
+
composing = false;
|
|
762
|
+
if (previewTimer) { clearTimeout(previewTimer); previewTimer = null; }
|
|
763
|
+
spec = next;
|
|
764
|
+
QS = spec.questions || [];
|
|
765
|
+
seedDefaults();
|
|
766
|
+
indexHtmlBlocks(spec);
|
|
767
|
+
setStatus('Preparing…');
|
|
768
|
+
try { await preloadVendors(spec); } catch { /* render anyway; blocks degrade individually */ }
|
|
769
|
+
render();
|
|
770
|
+
applyDisplayMode();
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Progressive preview: the host MAY stream the tool call's JSON as the agent
|
|
774
|
+
// writes it (ui/notifications/tool-input-partial — unclosed JSON auto-closed
|
|
775
|
+
// into a valid object). We render whatever blocks/questions are already valid
|
|
776
|
+
// so the board appears incrementally instead of only after the whole spec is
|
|
777
|
+
// generated. It's a read-only preview — submit is withheld until the final
|
|
778
|
+
// input/result arrives. Degrades to nothing on hosts that don't stream.
|
|
779
|
+
let composing = false;
|
|
780
|
+
let previewTimer = null;
|
|
781
|
+
let vendorsKicked = false;
|
|
782
|
+
function renderPreview(rawArgs) {
|
|
783
|
+
if (booted || submitted) return;
|
|
784
|
+
const next = clientNormalize(rawArgs);
|
|
785
|
+
if (!next) return;
|
|
786
|
+
const hasContent = (next.questions && next.questions.length) || (Array.isArray(next.blocks) && next.blocks.length);
|
|
787
|
+
if (!hasContent) return;
|
|
788
|
+
spec = next;
|
|
789
|
+
QS = spec.questions || [];
|
|
790
|
+
composing = true;
|
|
791
|
+
indexHtmlBlocks(spec);
|
|
792
|
+
if (!vendorsKicked) { vendorsKicked = true; preloadVendors(spec).catch(() => {}); }
|
|
793
|
+
if (previewTimer) return; // coalesce a burst of partials into one paint
|
|
794
|
+
previewTimer = setTimeout(() => { previewTimer = null; if (!booted && !submitted) render(); }, 120);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// The spec can arrive as the tool RESULT (preferred — server-normalized) or,
|
|
798
|
+
// on a leaner host, as the tool INPUT (raw). Prefer the result: when raw input
|
|
799
|
+
// lands first, hold briefly for a result before falling back to the input.
|
|
800
|
+
let rawInputTimer = null;
|
|
801
|
+
onNotify('ui/notifications/tool-result', (p) => {
|
|
802
|
+
if (rawInputTimer) { clearTimeout(rawInputTimer); rawInputTimer = null; }
|
|
803
|
+
const sc = p && p.structuredContent;
|
|
804
|
+
if (sc && sc.spec) return boot(sc.spec, true);
|
|
805
|
+
// Some hosts may echo the spec at the top level of structuredContent.
|
|
806
|
+
if (sc && Array.isArray(sc.questions)) return boot(sc, true);
|
|
807
|
+
});
|
|
808
|
+
onNotify('ui/notifications/tool-input', (p) => {
|
|
809
|
+
const args = p && p.arguments;
|
|
810
|
+
if (!args || typeof args !== 'object' || booted || rawInputTimer) return;
|
|
811
|
+
rawInputTimer = setTimeout(() => { rawInputTimer = null; boot(args, false); }, 250);
|
|
812
|
+
});
|
|
813
|
+
onNotify('ui/notifications/tool-input-partial', (p) => {
|
|
814
|
+
const args = p && p.arguments;
|
|
815
|
+
if (args && typeof args === 'object') renderPreview(args);
|
|
816
|
+
});
|
|
817
|
+
onNotify('ui/notifications/host-context-changed', (p) => {
|
|
818
|
+
if (p && typeof p === 'object') {
|
|
819
|
+
if ('theme' in p) host.theme = p.theme;
|
|
820
|
+
if ('styles' in p) host.styles = p.styles;
|
|
821
|
+
if (p.displayMode === 'inline' || p.displayMode === 'fullscreen' || p.displayMode === 'pip') {
|
|
822
|
+
displayMode = p.displayMode;
|
|
823
|
+
applyDisplayMode();
|
|
824
|
+
}
|
|
825
|
+
adoptHostStyles();
|
|
826
|
+
applyTheme();
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
// Kick off the handshake. Proceed even if the host doesn't answer init.
|
|
831
|
+
// SEP-1865 lifecycle: ui/initialize → (host reply) → ui/notifications/initialized.
|
|
832
|
+
// The host MUST NOT send tool-input / tool-result (i.e. the board spec) until
|
|
833
|
+
// it receives `initialized`, so this notification is mandatory — without it the
|
|
834
|
+
// board never gets its spec and never renders.
|
|
835
|
+
(async () => {
|
|
836
|
+
try {
|
|
837
|
+
const res = await request('ui/initialize', {
|
|
838
|
+
protocolVersion: PROTOCOL,
|
|
839
|
+
capabilities: {},
|
|
840
|
+
clientInfo: { name: 'relay', version: String(BOOT.version || '0') },
|
|
841
|
+
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
|
|
842
|
+
});
|
|
843
|
+
host = (res && res.hostContext) || {};
|
|
844
|
+
} catch {
|
|
845
|
+
host = {};
|
|
846
|
+
}
|
|
847
|
+
if (host.displayMode === 'fullscreen' || host.displayMode === 'pip') displayMode = host.displayMode;
|
|
848
|
+
notify('ui/notifications/initialized', {});
|
|
849
|
+
adoptHostStyles();
|
|
850
|
+
applyTheme();
|
|
851
|
+
setStatus('Waiting for the board…');
|
|
852
|
+
reportSize();
|
|
853
|
+
})();
|
|
854
|
+
})();
|