@cef-ai/widget-runtime 0.2.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.
Files changed (56) hide show
  1. package/README.md +34 -0
  2. package/dist/audio/capture-upload.d.ts +116 -0
  3. package/dist/audio/capture-upload.js +347 -0
  4. package/dist/audio/clip-registry.d.ts +20 -0
  5. package/dist/audio/clip-registry.js +47 -0
  6. package/dist/audio/ddc-delegation.d.ts +18 -0
  7. package/dist/audio/ddc-delegation.js +64 -0
  8. package/dist/audio/mic.d.ts +1 -0
  9. package/dist/audio/mic.js +23 -0
  10. package/dist/audio/segment.d.ts +3 -0
  11. package/dist/audio/segment.js +24 -0
  12. package/dist/audio/wav.d.ts +2 -0
  13. package/dist/audio/wav.js +27 -0
  14. package/dist/bridge-wallet.d.ts +46 -0
  15. package/dist/bridge-wallet.js +80 -0
  16. package/dist/bridge.d.ts +6 -0
  17. package/dist/bridge.js +15 -0
  18. package/dist/build-tools.d.ts +4 -0
  19. package/dist/build-tools.js +12 -0
  20. package/dist/entry.d.ts +11 -0
  21. package/dist/entry.js +159 -0
  22. package/dist/host-bridge.d.ts +19 -0
  23. package/dist/host-bridge.js +214 -0
  24. package/dist/index.d.ts +25 -0
  25. package/dist/index.js +21 -0
  26. package/dist/kinds/composite.d.ts +7 -0
  27. package/dist/kinds/composite.js +206 -0
  28. package/dist/kinds/conversation.d.ts +7 -0
  29. package/dist/kinds/conversation.js +667 -0
  30. package/dist/kinds/dashboard.d.ts +2 -0
  31. package/dist/kinds/dashboard.js +53 -0
  32. package/dist/kinds/list.d.ts +2 -0
  33. package/dist/kinds/list.js +22 -0
  34. package/dist/kinds/record.d.ts +2 -0
  35. package/dist/kinds/record.js +14 -0
  36. package/dist/kinds/submit.d.ts +6 -0
  37. package/dist/kinds/submit.js +481 -0
  38. package/dist/manifest.d.ts +32 -0
  39. package/dist/manifest.js +47 -0
  40. package/dist/mount.d.ts +19 -0
  41. package/dist/mount.js +48 -0
  42. package/dist/render-helpers.d.ts +9 -0
  43. package/dist/render-helpers.js +42 -0
  44. package/dist/runtime.d.ts +12 -0
  45. package/dist/runtime.js +36 -0
  46. package/dist/standalone-wallet.d.ts +21 -0
  47. package/dist/standalone-wallet.js +102 -0
  48. package/dist/types.d.ts +245 -0
  49. package/dist/types.js +7 -0
  50. package/dist/validate-config.d.ts +2 -0
  51. package/dist/validate-config.js +74 -0
  52. package/dist/vault-client.d.ts +45 -0
  53. package/dist/vault-client.js +122 -0
  54. package/dist/widget-runtime.js +348 -0
  55. package/dist/widget.html +33 -0
  56. package/package.json +43 -0
@@ -0,0 +1,53 @@
1
+ import { esc, colIndex, formatValue } from '../render-helpers.js';
2
+ async function renderPanelBody(panel, runtime) {
3
+ const { columns, rows } = await runtime.query(panel.query);
4
+ switch (panel.render) {
5
+ case 'metric': {
6
+ const i = panel.value ? colIndex(columns, panel.value) : 0;
7
+ if (i === -1)
8
+ return `<p class="error">Panel '${esc(panel.title)}': column '${esc(panel.value ?? '')}' not in result.</p>`;
9
+ const v = rows.length ? rows[0][i] : 0;
10
+ return `<div class="metric">${esc(formatValue(v, 'number'))}</div>`;
11
+ }
12
+ case 'bar': {
13
+ const li = colIndex(columns, panel.label ?? columns[0]);
14
+ const vi = colIndex(columns, panel.value ?? columns[1]);
15
+ if (li === -1 || vi === -1)
16
+ return `<p class="error">Panel '${esc(panel.title)}': label/value column not in result.</p>`;
17
+ const max = Math.max(1, ...rows.map((r) => Number(r[vi]) || 0));
18
+ return rows.map((r) => {
19
+ const pct = Math.round(((Number(r[vi]) || 0) / max) * 100);
20
+ return `<div class="bar-row"><span class="bar-label">${esc(r[li])}</span><span class="bar" style="width:${pct}%"></span><span class="bar-val">${esc(r[vi])}</span></div>`;
21
+ }).join('');
22
+ }
23
+ case 'list':
24
+ return rows.map((r) => {
25
+ const t = panel.item ? r[colIndex(columns, panel.item.title)] : r[0];
26
+ const s = panel.item?.subtitle ? r[colIndex(columns, panel.item.subtitle)] : null;
27
+ return `<div class="list-item"><div class="li-title">${esc(t)}</div>${s != null ? `<div class="li-sub">${esc(s)}</div>` : ''}</div>`;
28
+ }).join('');
29
+ case 'table': {
30
+ const cols = panel.columns ?? columns;
31
+ const idx = cols.map((c) => colIndex(columns, c));
32
+ const head = cols.map((c) => `<th>${esc(c)}</th>`).join('');
33
+ const body = rows.map((r) => `<tr>${idx.map((i) => `<td>${esc(r[i])}</td>`).join('')}</tr>`).join('');
34
+ return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
35
+ }
36
+ default: return '';
37
+ }
38
+ }
39
+ export async function renderDashboard(root, config, runtime) {
40
+ const bodies = await Promise.all(config.panels.map(async (panel) => {
41
+ try {
42
+ return { panel, html: await renderPanelBody(panel, runtime) };
43
+ }
44
+ catch (err) {
45
+ // Not-connected must bubble up so mountKind renders the unified Connect CTA
46
+ // (consistent with record/list). Only other errors render inline per-panel.
47
+ if (err && err.name === 'AgentNotConnectedError')
48
+ throw err;
49
+ return { panel, html: `<p class="error">${esc(err instanceof Error ? err.message : String(err))}</p>` };
50
+ }
51
+ }));
52
+ root.innerHTML = bodies.map(({ panel, html }) => `<section class="panel"><h2 class="panel-title">${esc(panel.title)}</h2>${html}</section>`).join('');
53
+ }
@@ -0,0 +1,2 @@
1
+ import type { ListConfig, WidgetRuntimeApi } from '../types.js';
2
+ export declare function renderList(root: HTMLElement, config: ListConfig, runtime: WidgetRuntimeApi): Promise<void>;
@@ -0,0 +1,22 @@
1
+ import { esc, colIndex, formatValue, parseColumnSpec } from '../render-helpers.js';
2
+ function cell(columns, row, spec) {
3
+ if (!spec)
4
+ return '';
5
+ const { column, format } = parseColumnSpec(spec);
6
+ const i = colIndex(columns, column);
7
+ return formatValue(i === -1 ? null : row[i], format);
8
+ }
9
+ export async function renderList(root, config, runtime) {
10
+ const { columns, rows } = await runtime.query(config.query);
11
+ const limited = config.limit ? rows.slice(0, config.limit) : rows;
12
+ if (!limited.length) {
13
+ root.innerHTML = `<p class="muted">${esc(config.empty ?? 'Nothing here yet.')}</p>`;
14
+ return;
15
+ }
16
+ root.innerHTML = limited.map((row) => `
17
+ <div class="list-item">
18
+ <div class="li-title">${esc(cell(columns, row, config.item.title))}</div>
19
+ ${config.item.subtitle ? `<div class="li-sub">${esc(cell(columns, row, config.item.subtitle))}</div>` : ''}
20
+ ${config.item.meta ? `<div class="li-meta">${esc(cell(columns, row, config.item.meta))}</div>` : ''}
21
+ </div>`).join('');
22
+ }
@@ -0,0 +1,2 @@
1
+ import type { RecordConfig, WidgetRuntimeApi } from '../types.js';
2
+ export declare function renderRecord(root: HTMLElement, config: RecordConfig, runtime: WidgetRuntimeApi): Promise<void>;
@@ -0,0 +1,14 @@
1
+ import { esc, colIndex, formatValue } from '../render-helpers.js';
2
+ export async function renderRecord(root, config, runtime) {
3
+ const { columns, rows } = await runtime.query(config.query);
4
+ if (!rows.length) {
5
+ root.innerHTML = `<p class="muted">${esc(config.empty ?? 'Nothing here yet.')}</p>`;
6
+ return;
7
+ }
8
+ const row = rows[0];
9
+ root.innerHTML = config.fields.map((f) => {
10
+ const i = colIndex(columns, f.column);
11
+ const value = i === -1 ? null : row[i];
12
+ return `<section class="card"><div class="label">${esc(f.label)}</div><p>${esc(formatValue(value, f.format))}</p></section>`;
13
+ }).join('');
14
+ }
@@ -0,0 +1,6 @@
1
+ import type { SubmitConfig, WidgetRuntimeApi } from '../types.js';
2
+ import type { UploadAudioFn } from '../mount.js';
3
+ export declare function renderSubmit(root: HTMLElement, config: SubmitConfig, runtime: WidgetRuntimeApi, deps?: {
4
+ uploadAudio?: UploadAudioFn;
5
+ signal?: AbortSignal;
6
+ }): void;
@@ -0,0 +1,481 @@
1
+ import { esc } from '../render-helpers.js';
2
+ import { requestMicStream } from '../audio/mic.js';
3
+ // ── dot-path helper ────────────────────────────────────────────────────────────
4
+ function dotGet(obj, path) {
5
+ return path.split('.').reduce((cur, key) => {
6
+ if (cur == null || typeof cur !== 'object')
7
+ return undefined;
8
+ return cur[key];
9
+ }, obj);
10
+ }
11
+ // ── main renderer ─────────────────────────────────────────────────────────────
12
+ export function renderSubmit(root, config, runtime, deps) {
13
+ // Runtime state
14
+ let state = { phase: 'idle' };
15
+ let audioBlob = null;
16
+ let mediaRecorder = null;
17
+ let recordChunks = [];
18
+ let pollTimer = null;
19
+ let pollStarted = 0;
20
+ // ── teardown helpers ─────────────────────────────────────────────────────────
21
+ function stopPolling() {
22
+ if (pollTimer) {
23
+ clearTimeout(pollTimer);
24
+ pollTimer = null;
25
+ }
26
+ }
27
+ function stopRecordingTimer(s) {
28
+ clearInterval(s.timerId);
29
+ }
30
+ function abortTeardown() {
31
+ stopPolling();
32
+ if (state.phase === 'recording')
33
+ stopRecordingTimer(state);
34
+ if (mediaRecorder && mediaRecorder.state !== 'inactive') {
35
+ try {
36
+ mediaRecorder.stop();
37
+ }
38
+ catch { /* ignore */ }
39
+ }
40
+ }
41
+ if (deps?.signal) {
42
+ if (deps.signal.aborted) {
43
+ abortTeardown();
44
+ return;
45
+ }
46
+ deps.signal.addEventListener('abort', () => abortTeardown(), { once: true });
47
+ }
48
+ // ── form value collector ─────────────────────────────────────────────────────
49
+ function collectFormValues() {
50
+ const vals = {};
51
+ for (const field of config.form ?? []) {
52
+ const el = root.querySelector(`[name="${field.name}"]`);
53
+ if (!el)
54
+ continue;
55
+ const raw = el.value.trim();
56
+ if (field.type === 'number') {
57
+ const n = parseFloat(raw);
58
+ if (!isNaN(n))
59
+ vals[field.name] = n;
60
+ }
61
+ else if (raw) {
62
+ vals[field.name] = raw;
63
+ }
64
+ }
65
+ return vals;
66
+ }
67
+ function isFormValid() {
68
+ for (const field of config.form ?? []) {
69
+ if (!field.required)
70
+ continue;
71
+ const el = root.querySelector(`[name="${field.name}"]`);
72
+ if (!el || !el.value.trim())
73
+ return false;
74
+ }
75
+ return true;
76
+ }
77
+ // ── transition: start upload+submit pipeline ─────────────────────────────────
78
+ async function runSubmit(blob) {
79
+ const uploadFn = deps?.uploadAudio;
80
+ if (!uploadFn) {
81
+ transition({ phase: 'error', message: 'Audio upload not configured.' });
82
+ return;
83
+ }
84
+ // Collect form values NOW (before any re-render destroys the inputs).
85
+ const formValues = collectFormValues();
86
+ // UPLOAD
87
+ transition({ phase: 'uploading', progress: 'Uploading…' });
88
+ let uploadWarn;
89
+ let urls;
90
+ try {
91
+ urls = await uploadFn(blob, {
92
+ segmentSeconds: config.audio.segmentSeconds,
93
+ softCapSeconds: config.audio.softCapSeconds,
94
+ onProgress: (done, total) => {
95
+ if (state.phase === 'uploading') {
96
+ transition({ phase: 'uploading', progress: `Uploading ${done}/${total}…`, warn: uploadWarn });
97
+ }
98
+ },
99
+ onWarn: (m) => {
100
+ uploadWarn = m;
101
+ if (state.phase === 'uploading') {
102
+ transition({ phase: 'uploading', progress: state.progress, warn: m });
103
+ }
104
+ },
105
+ });
106
+ }
107
+ catch (err) {
108
+ handleError(err);
109
+ return;
110
+ }
111
+ // PUBLISH
112
+ const sessionId = crypto.randomUUID();
113
+ transition({ phase: 'analyzing', sessionId });
114
+ try {
115
+ const payload = { schema_version: 1, session_id: sessionId, [config.event.audioField]: urls };
116
+ if (Object.keys(formValues).length) {
117
+ if (config.event.formEnvelope)
118
+ payload[config.event.formEnvelope] = formValues;
119
+ else
120
+ Object.assign(payload, formValues);
121
+ }
122
+ await runtime.publish(config.event.type, payload, sessionId);
123
+ }
124
+ catch (err) {
125
+ handleError(err);
126
+ return;
127
+ }
128
+ // POLL
129
+ pollStarted = Date.now();
130
+ schedulePoll(sessionId);
131
+ }
132
+ function schedulePoll(sessionId) {
133
+ const { intervalMs, timeoutMs, doneWhen } = config.result.poll;
134
+ // Timeout is checked at schedule time (not inside the timer), so the
135
+ // worst-case overshoot past timeoutMs is ~one intervalMs.
136
+ if (Date.now() - pollStarted > timeoutMs) {
137
+ transition({ phase: 'error', message: 'Analysis timed out. Please try again.' });
138
+ return;
139
+ }
140
+ pollTimer = setTimeout(async () => {
141
+ try {
142
+ const result = await runtime.query(config.result.query, [sessionId]);
143
+ const colIdx = result.columns.findIndex((c) => c.toLowerCase() === doneWhen.column.toLowerCase());
144
+ if (colIdx !== -1 && result.rows.length > 0) {
145
+ const val = String(result.rows[0][colIdx] ?? '');
146
+ if (val === doneWhen.equals) {
147
+ transition({ phase: 'done', row: result.rows[0], columns: result.columns });
148
+ return;
149
+ }
150
+ }
151
+ if (state.phase === 'analyzing')
152
+ schedulePoll(sessionId);
153
+ }
154
+ catch (err) {
155
+ handleError(err);
156
+ }
157
+ }, intervalMs);
158
+ }
159
+ // ── error handler ─────────────────────────────────────────────────────────────
160
+ function handleError(err) {
161
+ stopPolling();
162
+ if (err && err.name === 'AgentNotConnectedError') {
163
+ transition({ phase: 'not-connected' });
164
+ }
165
+ else {
166
+ transition({ phase: 'error', message: err?.message ?? String(err) });
167
+ }
168
+ }
169
+ // ── transition + render ───────────────────────────────────────────────────────
170
+ function transition(next) {
171
+ // Clean up old state
172
+ if (state.phase === 'recording')
173
+ stopRecordingTimer(state);
174
+ if (next.phase !== 'analyzing')
175
+ stopPolling();
176
+ state = next;
177
+ render();
178
+ }
179
+ function render() {
180
+ switch (state.phase) {
181
+ case 'idle': return renderIdle();
182
+ case 'recording': return renderRecording(state);
183
+ case 'uploading': return renderUploading(state);
184
+ case 'analyzing': return renderAnalyzing();
185
+ case 'done': return renderDone(state);
186
+ case 'error': return renderError(state);
187
+ case 'not-connected': return renderNotConnected();
188
+ }
189
+ }
190
+ // ── idle ──────────────────────────────────────────────────────────────────────
191
+ function renderIdle() {
192
+ const titleHtml = config.title
193
+ ? `<h2 style="margin:0 0 .25rem">${esc(config.title)}</h2>`
194
+ : '';
195
+ const introHtml = config.intro
196
+ ? `<p class="muted" style="margin:0 0 1rem">${esc(config.intro)}</p>`
197
+ : '';
198
+ const formHtml = (config.form ?? []).map((f) => {
199
+ const req = f.required ? ' <span aria-hidden="true" style="color:red">*</span>' : '';
200
+ const reqAttr = f.required ? ' required' : '';
201
+ if (f.type === 'select' && f.options?.length) {
202
+ const opts = f.options.map((o) => `<option value="${esc(o)}">${esc(o)}</option>`).join('');
203
+ return `<div style="margin-bottom:.75rem">
204
+ <label class="label" for="f_${esc(f.name)}">${esc(f.label)}${req}</label>
205
+ <select id="f_${esc(f.name)}" name="${esc(f.name)}"${reqAttr} style="display:block;width:100%;padding:.35rem .5rem;margin-top:.25rem">
206
+ <option value="">— choose —</option>${opts}
207
+ </select>
208
+ </div>`;
209
+ }
210
+ const inputType = f.type === 'number' ? 'number' : 'text';
211
+ return `<div style="margin-bottom:.75rem">
212
+ <label class="label" for="f_${esc(f.name)}">${esc(f.label)}${req}</label>
213
+ <input id="f_${esc(f.name)}" name="${esc(f.name)}" type="${inputType}"${reqAttr}
214
+ style="display:block;width:100%;padding:.35rem .5rem;margin-top:.25rem;box-sizing:border-box" />
215
+ </div>`;
216
+ }).join('');
217
+ const mode = config.audio.mode;
218
+ const uploadSection = (mode === 'upload' || mode === 'both')
219
+ ? `<div style="margin-bottom:.5rem">
220
+ <label class="label" for="audio_file">
221
+ ${mode === 'both' ? 'Upload a file' : 'Audio file'}
222
+ ${config.audio.required ? '<span aria-hidden="true" style="color:red"> *</span>' : ''}
223
+ </label>
224
+ <input id="audio_file" type="file" accept="audio/*"
225
+ style="display:block;margin-top:.25rem" />
226
+ </div>`
227
+ : '';
228
+ const recordSection = (mode === 'record' || mode === 'both')
229
+ ? `<div style="margin-bottom:.5rem">
230
+ ${mode === 'both' ? '<div class="label" style="margin-bottom:.25rem">Or record now</div>' : ''}
231
+ <button id="record_btn" type="button" class="button" aria-label="Start recording">
232
+ ● Record
233
+ </button>
234
+ </div>`
235
+ : '';
236
+ const audioPreview = audioBlob
237
+ ? `<p style="color:green;margin:.5rem 0 0" role="status">✓ Audio ready (${Math.round(audioBlob.size / 1024)} KB)</p>`
238
+ : '';
239
+ const canSubmit = audioBlob !== null || (config.audio.required === false);
240
+ root.innerHTML = `
241
+ <div class="card">
242
+ ${titleHtml}${introHtml}
243
+ <form id="submit_form" novalidate>
244
+ ${formHtml}
245
+ <fieldset style="border:1px solid #ccc;border-radius:6px;padding:.75rem;margin-bottom:1rem">
246
+ <legend class="label">Audio recording</legend>
247
+ ${uploadSection}
248
+ ${recordSection}
249
+ ${audioPreview}
250
+ </fieldset>
251
+ <div aria-live="polite" id="status_region"></div>
252
+ <button id="analyze_btn" type="button" class="button"
253
+ ${canSubmit ? '' : 'disabled'}
254
+ style="width:100%;padding:.6rem;font-size:1rem;margin-top:.25rem">
255
+ ${esc(config.submitLabel ?? 'Submit')}
256
+ </button>
257
+ </form>
258
+ </div>`;
259
+ // Wire file input
260
+ if (mode === 'upload' || mode === 'both') {
261
+ root.querySelector('#audio_file')?.addEventListener('change', (e) => {
262
+ const file = e.target.files?.[0] ?? null;
263
+ audioBlob = file;
264
+ updateAnalyzeBtn();
265
+ const preview = root.querySelector('#status_region');
266
+ if (preview) {
267
+ preview.textContent = file ? `✓ File selected: ${file.name}` : '';
268
+ }
269
+ });
270
+ }
271
+ // Wire record button
272
+ if (mode === 'record' || mode === 'both') {
273
+ root.querySelector('#record_btn')?.addEventListener('click', () => {
274
+ const b = root.querySelector('#record_btn');
275
+ if (b instanceof HTMLButtonElement)
276
+ b.disabled = true;
277
+ void startRecording();
278
+ });
279
+ }
280
+ // Wire form field changes for required-field validation
281
+ for (const f of config.form ?? []) {
282
+ if (f.required) {
283
+ const el = root.querySelector(`[name="${f.name}"]`);
284
+ el?.addEventListener('input', () => updateAnalyzeBtn());
285
+ el?.addEventListener('change', () => updateAnalyzeBtn());
286
+ }
287
+ }
288
+ // Wire submit via the button's click handler (NOT the form's submit event):
289
+ // the Vault iframe sandbox lacks allow-forms, so a native form submission is
290
+ // blocked by the browser even with preventDefault. A type="button" click
291
+ // works in any sandbox.
292
+ root.querySelector('#analyze_btn')?.addEventListener('click', () => {
293
+ if (!audioBlob)
294
+ return;
295
+ if (!isFormValid()) {
296
+ const region = root.querySelector('#status_region');
297
+ if (region)
298
+ region.textContent = 'Please fill in all required fields.';
299
+ return;
300
+ }
301
+ void runSubmit(audioBlob);
302
+ });
303
+ }
304
+ function updateAnalyzeBtn() {
305
+ const btn = root.querySelector('#analyze_btn');
306
+ if (!btn)
307
+ return;
308
+ btn.disabled = !(audioBlob !== null || config.audio.required === false) || !isFormValid();
309
+ }
310
+ // ── recording ────────────────────────────────────────────────────────────────
311
+ async function startRecording() {
312
+ recordChunks = [];
313
+ let stream;
314
+ try {
315
+ stream = await requestMicStream();
316
+ }
317
+ catch (err) {
318
+ transition({ phase: 'error', message: `Microphone access denied: ${err.message}` });
319
+ return;
320
+ }
321
+ mediaRecorder = new MediaRecorder(stream);
322
+ mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0)
323
+ recordChunks.push(e.data); };
324
+ mediaRecorder.onstop = () => {
325
+ stream.getTracks().forEach((t) => t.stop());
326
+ audioBlob = new Blob(recordChunks, { type: 'audio/webm' });
327
+ transition({ phase: 'idle' });
328
+ };
329
+ mediaRecorder.start(500);
330
+ const startedAt = Date.now();
331
+ const timerId = setInterval(() => {
332
+ const elapsed = Math.floor((Date.now() - startedAt) / 1000);
333
+ const elEl = root.querySelector('#elapsed');
334
+ if (elEl)
335
+ elEl.textContent = `${elapsed}s`;
336
+ // Soft cap warning
337
+ if (config.audio.softCapSeconds && elapsed === config.audio.softCapSeconds) {
338
+ const warn = root.querySelector('#rec_warn');
339
+ if (warn) {
340
+ warn.textContent = `Recording is getting long (${Math.round(elapsed / 60)} min). Stop when ready.`;
341
+ }
342
+ }
343
+ }, 1000);
344
+ transition({ phase: 'recording', startedAt, timerId });
345
+ }
346
+ function renderRecording(s) {
347
+ const elapsed = Math.floor((Date.now() - s.startedAt) / 1000);
348
+ root.innerHTML = `
349
+ <div class="card">
350
+ <h2 style="margin:0 0 .5rem">${esc(config.title ?? 'Recording…')}</h2>
351
+ <p class="muted">Recording in progress.</p>
352
+ <div style="display:flex;align-items:center;gap:.75rem;margin-bottom:.75rem">
353
+ <span style="color:red;font-size:1.25rem" aria-hidden="true">●</span>
354
+ <span id="elapsed" style="font-size:1.5rem;font-weight:600">${elapsed}s</span>
355
+ </div>
356
+ <p id="rec_warn" aria-live="polite" style="color:#b45309;min-height:1.25rem"></p>
357
+ <button id="stop_btn" type="button" class="button"
358
+ style="width:100%;padding:.6rem;font-size:1rem">
359
+ ■ Stop recording
360
+ </button>
361
+ </div>`;
362
+ root.querySelector('#stop_btn')?.addEventListener('click', () => {
363
+ mediaRecorder?.stop();
364
+ // onstop → transitions to idle with audioBlob set
365
+ });
366
+ }
367
+ // ── uploading ────────────────────────────────────────────────────────────────
368
+ function renderUploading(s) {
369
+ root.innerHTML = `
370
+ <div class="card">
371
+ <h2 style="margin:0 0 .5rem">${esc(config.title ?? 'Uploading…')}</h2>
372
+ <p aria-live="polite" id="upload_status">${esc(s.progress)}</p>
373
+ ${s.warn ? `<p aria-live="polite" style="color:#b45309">${esc(s.warn)}</p>` : ''}
374
+ <div style="margin-top:.5rem;height:4px;background:#e5e7eb;border-radius:2px">
375
+ <div style="height:100%;width:60%;background:#6366f1;border-radius:2px;animation:pulse 1.2s ease-in-out infinite"></div>
376
+ </div>
377
+ <p class="muted" style="margin-top:.75rem">Please wait — do not close this window.</p>
378
+ </div>`;
379
+ }
380
+ // ── analyzing ────────────────────────────────────────────────────────────────
381
+ function renderAnalyzing() {
382
+ root.innerHTML = `
383
+ <div class="card" style="text-align:center">
384
+ <div style="font-size:2rem;margin-bottom:.75rem" aria-hidden="true">🔍</div>
385
+ <h2 style="margin:0 0 .25rem">${esc(config.processingLabel ?? config.title ?? 'Processing…')}</h2>
386
+ <p class="muted">This usually takes 30–60 seconds.</p>
387
+ <p aria-live="polite" style="margin-top:.5rem;color:#6366f1">Processing…</p>
388
+ </div>`;
389
+ }
390
+ // ── done ──────────────────────────────────────────────────────────────────────
391
+ function renderDone(s) {
392
+ if (config.result.render === 'summary' && config.result.summary?.length) {
393
+ const reportColIdx = s.columns.findIndex((c) => c.toLowerCase() === 'report_json');
394
+ let parsed = null;
395
+ if (reportColIdx !== -1 && s.row[reportColIdx]) {
396
+ try {
397
+ parsed = JSON.parse(String(s.row[reportColIdx]));
398
+ }
399
+ catch { /* ignore */ }
400
+ }
401
+ const summaryHtml = config.result.summary.map((item) => {
402
+ const value = parsed ? dotGet(parsed, item.path) : null;
403
+ const displayVal = value != null ? String(value) : '—';
404
+ return `<div style="display:flex;justify-content:space-between;align-items:center;padding:.5rem 0;border-bottom:1px solid #f3f4f6">
405
+ <span class="label" style="margin:0">${esc(item.label)}</span>
406
+ <span style="font-weight:600;color:#111">${esc(displayVal)}</span>
407
+ </div>`;
408
+ }).join('');
409
+ root.innerHTML = `
410
+ <div class="card">
411
+ <h2 style="margin:0 0 .25rem;color:#059669">✓ Analysis complete</h2>
412
+ <p class="muted" style="margin:0 0 1rem">Here's what we found.</p>
413
+ ${summaryHtml}
414
+ <p class="muted" style="margin-top:.75rem;font-size:.85rem">Full report in your Date report widget.</p>
415
+ <button id="again_btn" type="button" class="button"
416
+ style="margin-top:1rem;width:100%;padding:.5rem">
417
+ Analyze another date
418
+ </button>
419
+ </div>`;
420
+ }
421
+ else {
422
+ root.innerHTML = `
423
+ <div class="card" style="text-align:center">
424
+ <div style="font-size:2rem;margin-bottom:.5rem" aria-hidden="true">✓</div>
425
+ <h2 style="margin:0 0 .25rem;color:#059669">Date submitted successfully!</h2>
426
+ <p class="muted">Your date has been sent for analysis. Check back in your Date report widget.</p>
427
+ <button id="again_btn" type="button" class="button"
428
+ style="margin-top:1rem;padding:.5rem 1.5rem">
429
+ Analyze another date
430
+ </button>
431
+ </div>`;
432
+ }
433
+ root.querySelector('#again_btn')?.addEventListener('click', () => {
434
+ audioBlob = null;
435
+ transition({ phase: 'idle' });
436
+ });
437
+ }
438
+ // ── error ─────────────────────────────────────────────────────────────────────
439
+ function renderError(s) {
440
+ root.innerHTML = `
441
+ <div class="card">
442
+ <p class="error">${esc(s.message)}</p>
443
+ <button id="retry_btn" type="button" class="button" style="margin-top:.75rem">
444
+ Try again
445
+ </button>
446
+ </div>`;
447
+ root.querySelector('#retry_btn')?.addEventListener('click', () => {
448
+ audioBlob = null;
449
+ transition({ phase: 'idle' });
450
+ });
451
+ }
452
+ // ── not-connected ─────────────────────────────────────────────────────────────
453
+ function renderNotConnected() {
454
+ root.innerHTML = `
455
+ <div class="card">
456
+ <h2 style="margin:0 0 .25rem">Connect your account</h2>
457
+ <p class="muted">${esc(config.connectPrompt ?? 'Connect your account to continue.')}</p>
458
+ <button id="connect_btn" type="button" class="button"
459
+ style="width:100%;padding:.6rem;font-size:1rem;margin-top:.75rem">
460
+ Connect
461
+ </button>
462
+ </div>`;
463
+ root.querySelector('#connect_btn')?.addEventListener('click', async () => {
464
+ const btn = root.querySelector('#connect_btn');
465
+ if (btn) {
466
+ btn.disabled = true;
467
+ btn.textContent = 'Connecting…';
468
+ }
469
+ try {
470
+ await runtime.connectAgent();
471
+ audioBlob = null;
472
+ transition({ phase: 'idle' });
473
+ }
474
+ catch (err) {
475
+ transition({ phase: 'error', message: err?.message ?? String(err) });
476
+ }
477
+ });
478
+ }
479
+ // ── initial render ────────────────────────────────────────────────────────────
480
+ render();
481
+ }
@@ -0,0 +1,32 @@
1
+ import type { WidgetManifest, WidgetKind, WidgetConfig } from './types.js';
2
+ export interface BuildManifestInput {
3
+ widgetId: string;
4
+ name: string;
5
+ agentId: string;
6
+ scope?: string;
7
+ cubbyAlias: string;
8
+ wallet: {
9
+ appId: string;
10
+ env: string;
11
+ };
12
+ endpoints: WidgetManifest['endpoints'];
13
+ /** Single-query shorthand — kept for backwards compat. Either query or queries may be supplied. */
14
+ query?: {
15
+ sql: string;
16
+ label?: string;
17
+ timeoutMs?: number;
18
+ };
19
+ /** Multi-query list — takes precedence over query when provided. */
20
+ queries?: Array<{
21
+ id: string;
22
+ label?: string;
23
+ sql: string;
24
+ timeoutMs?: number;
25
+ }>;
26
+ events?: WidgetManifest['events'];
27
+ /** Config-driven kind. When provided, validateConfig is called to fail fast. */
28
+ kind?: WidgetKind;
29
+ /** Per-kind render config. */
30
+ config?: WidgetConfig;
31
+ }
32
+ export declare function buildWidgetManifest(input: BuildManifestInput): WidgetManifest;
@@ -0,0 +1,47 @@
1
+ import { validateConfig } from './validate-config.js';
2
+ export function buildWidgetManifest(input) {
3
+ if (!input.agentId)
4
+ throw new Error('buildWidgetManifest: agentId is required.');
5
+ // Build queries[]: explicit array takes priority; otherwise fall back to single query shorthand.
6
+ let resolvedQueries;
7
+ if (input.queries && input.queries.length > 0) {
8
+ resolvedQueries = input.queries.map((q) => ({
9
+ id: q.id,
10
+ label: q.label ?? q.id,
11
+ sql: q.sql,
12
+ timeoutMs: q.timeoutMs ?? 60000,
13
+ }));
14
+ }
15
+ else if (input.query) {
16
+ resolvedQueries = [{
17
+ id: input.widgetId,
18
+ label: input.query.label ?? input.name,
19
+ sql: input.query.sql,
20
+ timeoutMs: input.query.timeoutMs ?? 60000,
21
+ }];
22
+ }
23
+ else {
24
+ resolvedQueries = [];
25
+ }
26
+ // Validate kind/config pair if provided.
27
+ if (input.kind && input.kind !== 'custom' && input.config) {
28
+ validateConfig(input.kind, input.config);
29
+ }
30
+ if (resolvedQueries.length === 0 && input.kind && input.kind !== 'custom') {
31
+ throw new Error(`buildWidgetManifest: kind '${input.kind}' widget requires at least one query (via 'query' or 'queries[]').`);
32
+ }
33
+ return {
34
+ schemaVersion: 1,
35
+ widgetId: input.widgetId,
36
+ name: input.name,
37
+ agentId: input.agentId,
38
+ scope: input.scope ?? 'default',
39
+ cubbyAlias: input.cubbyAlias,
40
+ wallet: input.wallet,
41
+ endpoints: input.endpoints,
42
+ queries: resolvedQueries,
43
+ ...(input.events ? { events: input.events } : {}),
44
+ ...(input.kind ? { kind: input.kind } : {}),
45
+ ...(input.config ? { config: input.config } : {}),
46
+ };
47
+ }