@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.
- package/README.md +34 -0
- package/dist/audio/capture-upload.d.ts +116 -0
- package/dist/audio/capture-upload.js +347 -0
- package/dist/audio/clip-registry.d.ts +20 -0
- package/dist/audio/clip-registry.js +47 -0
- package/dist/audio/ddc-delegation.d.ts +18 -0
- package/dist/audio/ddc-delegation.js +64 -0
- package/dist/audio/mic.d.ts +1 -0
- package/dist/audio/mic.js +23 -0
- package/dist/audio/segment.d.ts +3 -0
- package/dist/audio/segment.js +24 -0
- package/dist/audio/wav.d.ts +2 -0
- package/dist/audio/wav.js +27 -0
- package/dist/bridge-wallet.d.ts +46 -0
- package/dist/bridge-wallet.js +80 -0
- package/dist/bridge.d.ts +6 -0
- package/dist/bridge.js +15 -0
- package/dist/build-tools.d.ts +4 -0
- package/dist/build-tools.js +12 -0
- package/dist/entry.d.ts +11 -0
- package/dist/entry.js +159 -0
- package/dist/host-bridge.d.ts +19 -0
- package/dist/host-bridge.js +214 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +21 -0
- package/dist/kinds/composite.d.ts +7 -0
- package/dist/kinds/composite.js +206 -0
- package/dist/kinds/conversation.d.ts +7 -0
- package/dist/kinds/conversation.js +667 -0
- package/dist/kinds/dashboard.d.ts +2 -0
- package/dist/kinds/dashboard.js +53 -0
- package/dist/kinds/list.d.ts +2 -0
- package/dist/kinds/list.js +22 -0
- package/dist/kinds/record.d.ts +2 -0
- package/dist/kinds/record.js +14 -0
- package/dist/kinds/submit.d.ts +6 -0
- package/dist/kinds/submit.js +481 -0
- package/dist/manifest.d.ts +32 -0
- package/dist/manifest.js +47 -0
- package/dist/mount.d.ts +19 -0
- package/dist/mount.js +48 -0
- package/dist/render-helpers.d.ts +9 -0
- package/dist/render-helpers.js +42 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +36 -0
- package/dist/standalone-wallet.d.ts +21 -0
- package/dist/standalone-wallet.js +102 -0
- package/dist/types.d.ts +245 -0
- package/dist/types.js +7 -0
- package/dist/validate-config.d.ts +2 -0
- package/dist/validate-config.js +74 -0
- package/dist/vault-client.d.ts +45 -0
- package/dist/vault-client.js +122 -0
- package/dist/widget-runtime.js +348 -0
- package/dist/widget.html +33 -0
- 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,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,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;
|
package/dist/manifest.js
ADDED
|
@@ -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
|
+
}
|