@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,667 @@
1
+ import { esc } from '../render-helpers.js';
2
+ import { requestMicStream } from '../audio/mic.js';
3
+ // ── main renderer ─────────────────────────────────────────────────────────────
4
+ export function renderConversation(root, config, runtime, deps) {
5
+ // Stable session id — minted once per mount, never re-minted.
6
+ const sessionId = crypto.randomUUID();
7
+ // Runtime state
8
+ let state = { phase: 'starting' };
9
+ let pollTimer = null;
10
+ let pollStarted = 0;
11
+ let statusPollTimer = null;
12
+ let mediaRecorder = null;
13
+ let recordChunks = [];
14
+ let recordTimerId = null;
15
+ let recordStartedAt = 0;
16
+ let recordButtonLocked = false; // double-start guard
17
+ let startEventPublished = false; // ensure startEvent fires at most once per mount
18
+ let doneNotified = false; // fire deps.onDone at most once per mount
19
+ let pendingUserTurnIdx = null; // a submitted answer not yet seen in the DB
20
+ // ── safe scroll helper ────────────────────────────────────────────────────────
21
+ // jsdom does not implement scrollIntoView; guard to avoid TypeError in tests.
22
+ function scrollToBottom() {
23
+ const anchor = root.querySelector('#scroll_anchor');
24
+ if (anchor && typeof anchor.scrollIntoView === 'function') {
25
+ anchor.scrollIntoView({ behavior: 'smooth', block: 'end' });
26
+ }
27
+ }
28
+ // ── teardown ──────────────────────────────────────────────────────────────────
29
+ function stopPolling() {
30
+ if (pollTimer) {
31
+ clearTimeout(pollTimer);
32
+ pollTimer = null;
33
+ }
34
+ }
35
+ function stopStatusPolling() {
36
+ if (statusPollTimer) {
37
+ clearTimeout(statusPollTimer);
38
+ statusPollTimer = null;
39
+ }
40
+ }
41
+ function stopRecordTimer() {
42
+ if (recordTimerId) {
43
+ clearInterval(recordTimerId);
44
+ recordTimerId = null;
45
+ }
46
+ }
47
+ function teardown() {
48
+ stopPolling();
49
+ stopStatusPolling();
50
+ stopRecordTimer();
51
+ if (mediaRecorder && mediaRecorder.state !== 'inactive') {
52
+ try {
53
+ mediaRecorder.stop();
54
+ }
55
+ catch { /* ignore */ }
56
+ }
57
+ mediaRecorder = null;
58
+ recordChunks = [];
59
+ recordButtonLocked = false;
60
+ }
61
+ if (deps?.signal) {
62
+ if (deps.signal.aborted) {
63
+ teardown();
64
+ return;
65
+ }
66
+ deps.signal.addEventListener('abort', () => teardown(), { once: true });
67
+ }
68
+ // ── turn helpers ──────────────────────────────────────────────────────────────
69
+ function parseTurns(result) {
70
+ const roleIdx = result.columns.findIndex((c) => c.toLowerCase() === config.roleColumn.toLowerCase());
71
+ const textIdx = result.columns.findIndex((c) => c.toLowerCase() === config.textColumn.toLowerCase());
72
+ const idxIdx = result.columns.findIndex((c) => c.toLowerCase() === config.turnIdxColumn.toLowerCase());
73
+ const raw = result.rows.map((row) => ({
74
+ role: String(row[roleIdx] ?? ''),
75
+ text: String(row[textIdx] ?? ''),
76
+ turnIdx: Number(row[idxIdx] ?? 0),
77
+ }));
78
+ // Collapse duplicate rows for the same (role, turnIdx) — a duplicated insert
79
+ // or a poll race must never render the same question twice. Map.set keeps the
80
+ // first insertion position (rows arrive ORDER BY turn_idx) while taking the
81
+ // latest text for that turn.
82
+ const byKey = new Map();
83
+ for (const t of raw)
84
+ byKey.set(`${t.role}#${t.turnIdx}`, t);
85
+ return [...byKey.values()];
86
+ }
87
+ // Two turn lists render identically? Used to skip no-op poll re-renders that
88
+ // would otherwise rebuild the transcript (and jump the scroll) every tick.
89
+ function sameTurns(a, b) {
90
+ if (a.length !== b.length)
91
+ return false;
92
+ for (let i = 0; i < a.length; i++) {
93
+ if (a[i].role !== b[i].role || a[i].text !== b[i].text || a[i].turnIdx !== b[i].turnIdx)
94
+ return false;
95
+ }
96
+ return true;
97
+ }
98
+ // Resolve what to display + whether we're waiting on the coach, accounting for
99
+ // an answer the user just submitted that may not be persisted yet. Without
100
+ // this, a poll that runs before the user-turn write lands sees the agent's
101
+ // question as the last turn → flips back to the record control (the "asked
102
+ // again" loop) and the user's answer bubble disappears. While the submitted
103
+ // turn is pending we keep it visible as "(processing…)" and stay waiting.
104
+ function resolveWaiting(turns) {
105
+ const userIdxs = turns.filter((t) => t.role === 'user').map((t) => t.turnIdx);
106
+ const maxUserIdx = userIdxs.length ? Math.max(...userIdxs) : -1;
107
+ if (pendingUserTurnIdx !== null && maxUserIdx >= pendingUserTurnIdx) {
108
+ pendingUserTurnIdx = null; // our submitted answer is now in the transcript
109
+ }
110
+ if (pendingUserTurnIdx !== null) {
111
+ return {
112
+ turns: [...turns, { role: 'user', text: '(processing…)', turnIdx: pendingUserTurnIdx }],
113
+ waitingForAgent: true,
114
+ };
115
+ }
116
+ const last = lastTurn(turns);
117
+ return { turns, waitingForAgent: last ? last.role === 'user' : true };
118
+ }
119
+ function nextUserTurnIdx(turns) {
120
+ const userTurns = turns.filter((t) => t.role === 'user');
121
+ if (!userTurns.length)
122
+ return 0;
123
+ return Math.max(...userTurns.map((t) => t.turnIdx)) + 1;
124
+ }
125
+ function lastTurn(turns) {
126
+ return turns.length ? turns[turns.length - 1] : undefined;
127
+ }
128
+ // ── transition + render ───────────────────────────────────────────────────────
129
+ function transition(next) {
130
+ if (next.phase !== 'conversing' && next.phase !== 'uploading') {
131
+ stopPolling();
132
+ }
133
+ if (next.phase !== 'awaiting-confirm' && next.phase !== 'done') {
134
+ stopStatusPolling();
135
+ }
136
+ // Stop the record elapsed timer whenever we leave the conversing phase.
137
+ // Recording only happens within 'conversing'; any other phase (uploading,
138
+ // awaiting-confirm, done, error, not-connected) must not keep ticking it.
139
+ if (next.phase !== 'conversing') {
140
+ stopRecordTimer();
141
+ }
142
+ state = next;
143
+ render();
144
+ // Terminal: tell the host shell so it can re-check the gate and unlock
145
+ // panels without a page reload. Once per mount.
146
+ if (next.phase === 'done' && !doneNotified) {
147
+ doneNotified = true;
148
+ deps?.onDone?.();
149
+ }
150
+ }
151
+ function render() {
152
+ switch (state.phase) {
153
+ case 'starting': return renderStarting();
154
+ case 'conversing': return renderConversing(state);
155
+ case 'uploading': return renderUploading(state);
156
+ case 'awaiting-confirm': return renderAwaitingConfirm(state);
157
+ case 'done': return renderDone();
158
+ case 'error': return renderError(state);
159
+ case 'not-connected': return renderNotConnected();
160
+ }
161
+ }
162
+ // ── error handler ─────────────────────────────────────────────────────────────
163
+ function handleError(err) {
164
+ teardown();
165
+ if (err && err.name === 'AgentNotConnectedError') {
166
+ transition({ phase: 'not-connected' });
167
+ }
168
+ else {
169
+ transition({ phase: 'error', message: err?.message ?? String(err) });
170
+ }
171
+ }
172
+ // ── polling: turns (no-status path) ──────────────────────────────────────────
173
+ //
174
+ // Only poll while waitingForAgent (i.e. the last seen turn is from the user and
175
+ // we are waiting for the coach to reply). Once the agent has replied and the
176
+ // Record button is visible we STOP polling; runUpload() re-arms polling after
177
+ // the user submits their answer. This prevents an infinite timer loop.
178
+ function schedulePoll(knownTurns) {
179
+ // Cancel any in-flight turns poll before scheduling a new one, so a tick
180
+ // and a user action can never spawn concurrent/duplicate timer chains.
181
+ stopPolling();
182
+ if (Date.now() - pollStarted > config.poll.timeoutMs) {
183
+ transition({ phase: 'error', message: 'Session timed out. Please try again.' });
184
+ return;
185
+ }
186
+ pollTimer = setTimeout(async () => {
187
+ try {
188
+ if (state.phase !== 'conversing')
189
+ return; // state changed under us — bail
190
+ const result = await runtime.query(config.turnsQuery, [sessionId]);
191
+ const parsed = parseTurns(result);
192
+ const { turns, waitingForAgent } = resolveWaiting(parsed);
193
+ // Skip the re-render (and scroll jump) when nothing visible changed.
194
+ const changed = !(sameTurns(state.turns, turns) && state.waitingForAgent === waitingForAgent);
195
+ if (changed)
196
+ transition({ phase: 'conversing', turns, waitingForAgent });
197
+ // Only keep polling while still waiting for the agent to reply.
198
+ // When the agent has replied (waitingForAgent=false) we stop and
199
+ // wait for the user to click Record — runUpload re-arms the poll.
200
+ if (state.phase === 'conversing' && waitingForAgent) {
201
+ schedulePoll(parsed);
202
+ }
203
+ }
204
+ catch (err) {
205
+ handleError(err);
206
+ }
207
+ }, config.poll.intervalMs);
208
+ }
209
+ // ── polling: session status (with-status path) ────────────────────────────────
210
+ //
211
+ // Used when config.statusQuery + confirmWhen are set. Polls both tables in
212
+ // parallel. Stops when it reaches awaiting-confirm or done. Skips scheduling
213
+ // while the user is actively recording their answer (mid-recording in the
214
+ // conversing phase) — runUpload re-arms it once the turn is submitted and we
215
+ // are waiting for the agent again. The backend can still drive the status to
216
+ // confirm/done after an agent reply, so we keep polling whenever the user is
217
+ // *not* recording.
218
+ function isRecording() {
219
+ return mediaRecorder !== null && mediaRecorder.state !== 'inactive';
220
+ }
221
+ function scheduleStatusPoll(currentTurns) {
222
+ // Cancel any in-flight status poll before scheduling a new one, so a tick
223
+ // and a user action can never spawn concurrent/duplicate timer chains.
224
+ stopStatusPolling();
225
+ if (!config.statusQuery)
226
+ return;
227
+ if (Date.now() - pollStarted > config.poll.timeoutMs) {
228
+ transition({ phase: 'error', message: 'Session timed out. Please try again.' });
229
+ return;
230
+ }
231
+ // Honour the comment: don't poll while the user is in their answer window
232
+ // (actively recording within the conversing phase). Re-armed by runUpload.
233
+ if (state.phase === 'conversing' && !state.waitingForAgent && isRecording()) {
234
+ return;
235
+ }
236
+ statusPollTimer = setTimeout(async () => {
237
+ try {
238
+ const currentPhase = state.phase;
239
+ if (currentPhase !== 'conversing' && currentPhase !== 'awaiting-confirm')
240
+ return;
241
+ const [turnsResult, statusRes] = await Promise.all([
242
+ runtime.query(config.turnsQuery, [sessionId]),
243
+ runtime.query(config.statusQuery, [sessionId]),
244
+ ]);
245
+ const parsed = parseTurns(turnsResult);
246
+ const { turns, waitingForAgent } = resolveWaiting(parsed);
247
+ // Terminal: doneWhen
248
+ if (config.doneWhen && matchesCondition(statusRes, config.doneWhen)) {
249
+ teardown();
250
+ transition({ phase: 'done' });
251
+ return;
252
+ }
253
+ // Transition: confirmWhen
254
+ if (config.confirmWhen && matchesCondition(statusRes, config.confirmWhen)) {
255
+ stopPolling();
256
+ if (state.phase !== 'awaiting-confirm') {
257
+ transition({ phase: 'awaiting-confirm', turns });
258
+ }
259
+ // Keep polling so we catch doneWhen after the user confirms
260
+ scheduleStatusPoll(parsed);
261
+ return;
262
+ }
263
+ // Still conversing — update transcript only when something changed
264
+ // (avoids the every-tick re-render/scroll jump) and keep polling.
265
+ if (state.phase === 'conversing') {
266
+ const changed = !(sameTurns(state.turns, turns) && state.waitingForAgent === waitingForAgent);
267
+ if (changed)
268
+ transition({ phase: 'conversing', turns, waitingForAgent });
269
+ scheduleStatusPoll(parsed);
270
+ }
271
+ else if (state.phase === 'awaiting-confirm') {
272
+ // awaiting-confirm: keep polling until doneWhen
273
+ scheduleStatusPoll(turns);
274
+ }
275
+ }
276
+ catch (err) {
277
+ handleError(err);
278
+ }
279
+ }, config.poll.intervalMs);
280
+ }
281
+ function matchesCondition(result, cond) {
282
+ const colIdx = result.columns.findIndex((c) => c.toLowerCase() === cond.column.toLowerCase());
283
+ if (colIdx === -1 || !result.rows.length)
284
+ return false;
285
+ return String(result.rows[0][colIdx] ?? '') === cond.equals;
286
+ }
287
+ // ── boot sequence ─────────────────────────────────────────────────────────────
288
+ async function boot() {
289
+ try {
290
+ // Fire startEvent at most once per mount (if configured). Re-running boot()
291
+ // from the Try-again / Connect buttons must NOT re-fire intake.start once
292
+ // it has been published successfully. The flag is set only after a
293
+ // successful publish, so a failed first attempt can still be retried.
294
+ if (config.startEvent && !startEventPublished) {
295
+ await runtime.publish(config.startEvent.type, { schema_version: 1, session_id: sessionId }, sessionId);
296
+ startEventPublished = true;
297
+ }
298
+ // Start polling
299
+ pollStarted = Date.now();
300
+ const initialResult = await runtime.query(config.turnsQuery, [sessionId]);
301
+ const initialTurns = parseTurns(initialResult);
302
+ const isWaitingForAgent = lastTurn(initialTurns)?.role === 'user' || initialTurns.length === 0;
303
+ transition({ phase: 'conversing', turns: initialTurns, waitingForAgent: isWaitingForAgent });
304
+ if (config.statusQuery && config.confirmWhen) {
305
+ // Always poll when status-aware — we need to detect confirm/done transitions
306
+ scheduleStatusPoll(initialTurns);
307
+ }
308
+ else if (isWaitingForAgent) {
309
+ // Only poll while waiting for the agent's first reply
310
+ schedulePoll(initialTurns);
311
+ }
312
+ // If agent already replied (isWaitingForAgent=false), stop: wait for user to record
313
+ }
314
+ catch (err) {
315
+ handleError(err);
316
+ }
317
+ }
318
+ // ── audio recording ───────────────────────────────────────────────────────────
319
+ async function startRecording(turns) {
320
+ if (recordButtonLocked)
321
+ return;
322
+ recordButtonLocked = true;
323
+ recordChunks = [];
324
+ let stream;
325
+ try {
326
+ stream = await requestMicStream();
327
+ }
328
+ catch (err) {
329
+ recordButtonLocked = false;
330
+ transition({ phase: 'error', message: `Microphone access denied: ${err.message}` });
331
+ return;
332
+ }
333
+ mediaRecorder = new MediaRecorder(stream);
334
+ mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0)
335
+ recordChunks.push(e.data); };
336
+ mediaRecorder.onstop = () => {
337
+ stream.getTracks().forEach((t) => t.stop());
338
+ stopRecordTimer();
339
+ const blob = new Blob(recordChunks, { type: 'audio/webm' });
340
+ recordChunks = [];
341
+ recordButtonLocked = false;
342
+ void runUpload(blob, turns);
343
+ };
344
+ mediaRecorder.start(500);
345
+ recordStartedAt = Date.now();
346
+ recordTimerId = setInterval(() => {
347
+ const elapsed = Math.floor((Date.now() - recordStartedAt) / 1000);
348
+ const elEl = root.querySelector('#rec_elapsed');
349
+ if (elEl)
350
+ elEl.textContent = formatElapsed(elapsed);
351
+ }, 1000);
352
+ // Re-render to show recording controls (turns stay visible)
353
+ renderConversing({ phase: 'conversing', turns, waitingForAgent: false }, true);
354
+ }
355
+ function stopRecording() {
356
+ mediaRecorder?.stop();
357
+ // onstop → runs runUpload
358
+ }
359
+ function formatElapsed(secs) {
360
+ const m = Math.floor(secs / 60);
361
+ const s = secs % 60;
362
+ return `${m}:${s.toString().padStart(2, '0')}`;
363
+ }
364
+ // ── upload + publish turn ─────────────────────────────────────────────────────
365
+ async function runUpload(blob, turns) {
366
+ const uploadFn = deps?.uploadAudio;
367
+ if (!uploadFn) {
368
+ transition({ phase: 'error', message: 'Audio upload not configured.' });
369
+ return;
370
+ }
371
+ const turnIdx = nextUserTurnIdx(turns);
372
+ transition({ phase: 'uploading', turns, progress: 'Uploading…' });
373
+ let urls;
374
+ try {
375
+ urls = await uploadFn(blob, {
376
+ segmentSeconds: config.audio.segmentSeconds,
377
+ onProgress: (done, total) => {
378
+ if (state.phase === 'uploading') {
379
+ transition({ phase: 'uploading', turns: state.turns, progress: `Uploading ${done}/${total}…` });
380
+ }
381
+ },
382
+ });
383
+ }
384
+ catch (err) {
385
+ handleError(err);
386
+ return;
387
+ }
388
+ try {
389
+ await runtime.publish(config.turnEvent.type, {
390
+ schema_version: 1,
391
+ session_id: sessionId,
392
+ [config.turnEvent.turnIdxField ?? 'turn_idx']: turnIdx,
393
+ [config.turnEvent.audioField]: urls,
394
+ }, sessionId);
395
+ }
396
+ catch (err) {
397
+ handleError(err);
398
+ return;
399
+ }
400
+ // Resume polling — waiting for agent's next turn. Mark this answer pending
401
+ // so a poll that runs before the user-turn write lands keeps showing it as
402
+ // processing (and stays in the waiting state) instead of re-prompting.
403
+ pendingUserTurnIdx = turnIdx;
404
+ pollStarted = Date.now();
405
+ const { turns: updatedTurns, waitingForAgent } = resolveWaiting(turns);
406
+ transition({ phase: 'conversing', turns: updatedTurns, waitingForAgent });
407
+ if (config.statusQuery && config.confirmWhen) {
408
+ scheduleStatusPoll(turns);
409
+ }
410
+ else {
411
+ schedulePoll(turns);
412
+ }
413
+ }
414
+ // ── render: starting ──────────────────────────────────────────────────────────
415
+ function renderStarting() {
416
+ root.innerHTML = `
417
+ <div class="card" style="display:flex;flex-direction:column;gap:.75rem">
418
+ <div style="display:flex;align-items:center;gap:.5rem;color:#6366f1">
419
+ <span class="spin" aria-hidden="true" style="display:inline-block;font-size:1.1rem">◌</span>
420
+ <span>Starting session…</span>
421
+ </div>
422
+ </div>`;
423
+ }
424
+ // ── render: conversing ────────────────────────────────────────────────────────
425
+ function renderConversing(s, isRecording = false) {
426
+ const isRecordingNow = isRecording || (mediaRecorder !== null && mediaRecorder.state !== 'inactive');
427
+ const elapsed = isRecordingNow ? Math.floor((Date.now() - recordStartedAt) / 1000) : 0;
428
+ const turnsHtml = s.turns.map((t) => {
429
+ const isAgent = t.role === 'agent' || t.role === 'coach' || t.role === 'assistant';
430
+ const bubbleStyle = isAgent
431
+ ? 'background:#f0f0fe;border-radius:1rem 1rem 1rem .25rem;padding:.6rem .85rem;max-width:85%;align-self:flex-start;'
432
+ : 'background:#6366f1;color:#fff;border-radius:1rem 1rem .25rem 1rem;padding:.6rem .85rem;max-width:85%;align-self:flex-end;';
433
+ const roleLabel = isAgent ? 'Coach' : 'You';
434
+ const isPending = t.text === '(processing…)';
435
+ const textHtml = isPending
436
+ ? `<em style="opacity:.7">sending…</em>`
437
+ : esc(t.text);
438
+ return `<div style="${bubbleStyle}" role="listitem">
439
+ <span class="sr-only">${esc(roleLabel)}: </span>${textHtml}
440
+ </div>`;
441
+ }).join('');
442
+ const thinkingHtml = s.waitingForAgent && !isRecordingNow
443
+ ? `<div style="align-self:flex-start;display:flex;align-items:center;gap:.4rem;color:#9ca3af;font-size:.88rem;padding:.2rem .4rem">
444
+ <span aria-hidden="true" style="animation:pulse 1.4s ease-in-out infinite">●</span>
445
+ <span aria-live="polite">Coach is thinking…</span>
446
+ </div>`
447
+ : '';
448
+ let controlHtml = '';
449
+ if (isRecordingNow) {
450
+ controlHtml = `
451
+ <div style="display:flex;flex-direction:column;gap:.5rem;padding:.75rem;background:#fff7ed;border:1px solid #fed7aa;border-radius:.75rem">
452
+ <div style="display:flex;align-items:center;gap:.6rem">
453
+ <span style="color:red;font-size:1rem;animation:pulse 1s ease-in-out infinite" aria-hidden="true">●</span>
454
+ <span id="rec_elapsed" style="font-size:1.1rem;font-weight:600;font-variant-numeric:tabular-nums">${formatElapsed(elapsed)}</span>
455
+ <span style="color:#b45309;font-size:.85rem">Recording…</span>
456
+ </div>
457
+ <button id="stop_btn" type="button" class="button"
458
+ style="width:100%;padding:.5rem;background:#dc2626;border:none;color:#fff;border-radius:.5rem;cursor:pointer;font-size:.95rem">
459
+ ■ Stop &amp; Send
460
+ </button>
461
+ </div>`;
462
+ }
463
+ else if (!s.waitingForAgent) {
464
+ // Last turn is from agent — show the answer control(s). Depending on
465
+ // config.audio.mode this is a file-upload picker, a Record button, or both.
466
+ // The Vault iframe currently denies microphone access, so file-upload is
467
+ // the path that works without a mic; recording stays for hosts that grant it.
468
+ const mode = config.audio.mode;
469
+ const allowUpload = mode === 'upload' || mode === 'both';
470
+ const allowRecord = mode === 'record' || mode === 'both';
471
+ const uploadHtml = allowUpload
472
+ ? `<div>
473
+ <label class="label" for="turn_file" style="display:block;margin-bottom:.25rem;font-size:.88rem;color:#374151">${esc('Upload audio answer')}</label>
474
+ <input id="turn_file" type="file" accept="audio/*"
475
+ style="display:block;width:100%;box-sizing:border-box" />
476
+ </div>`
477
+ : '';
478
+ const recordHtml = allowRecord
479
+ ? `<button id="record_btn" type="button" class="button"
480
+ style="width:100%;padding:.6rem;display:flex;align-items:center;justify-content:center;gap:.5rem;background:#6366f1;color:#fff;border:none;border-radius:.5rem;cursor:pointer;font-size:.95rem"
481
+ aria-label="Record your answer">
482
+ <span aria-hidden="true">🎙</span> ${allowUpload ? esc('Or record answer') : esc('Record answer')}
483
+ </button>`
484
+ : '';
485
+ controlHtml = `
486
+ <div style="padding:.5rem 0;display:flex;flex-direction:column;gap:.6rem">
487
+ ${uploadHtml}
488
+ ${recordHtml}
489
+ </div>`;
490
+ }
491
+ root.innerHTML = `
492
+ <div class="card" style="display:flex;flex-direction:column;height:100%;min-height:300px;gap:0">
493
+ <div id="transcript"
494
+ role="list"
495
+ aria-label="Conversation"
496
+ aria-live="polite"
497
+ style="flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:.5rem;padding:.5rem 0;margin-bottom:.5rem;max-height:420px">
498
+ ${turnsHtml}
499
+ ${thinkingHtml}
500
+ <div id="scroll_anchor"></div>
501
+ </div>
502
+ <div style="border-top:1px solid #f3f4f6;padding-top:.75rem">
503
+ ${controlHtml}
504
+ </div>
505
+ </div>`;
506
+ scrollToBottom();
507
+ // Wire stop button
508
+ root.querySelector('#stop_btn')?.addEventListener('click', () => {
509
+ stopRecording();
510
+ });
511
+ // Wire record button
512
+ root.querySelector('#record_btn')?.addEventListener('click', () => {
513
+ const btn = root.querySelector('#record_btn');
514
+ if (btn)
515
+ btn.disabled = true;
516
+ void startRecording(s.turns);
517
+ });
518
+ // Wire file-upload input. A pre-recorded clip goes through the SAME
519
+ // runUpload() helper as the record path (upload → publish turn → re-poll),
520
+ // so record and upload share all publish/poll logic. Disable both controls
521
+ // immediately so a second pick / a Record click can't race the upload.
522
+ root.querySelector('#turn_file')?.addEventListener('change', (e) => {
523
+ const file = e.target.files?.[0] ?? null;
524
+ if (!file)
525
+ return;
526
+ const fileInput = root.querySelector('#turn_file');
527
+ if (fileInput)
528
+ fileInput.disabled = true;
529
+ const recBtn = root.querySelector('#record_btn');
530
+ if (recBtn)
531
+ recBtn.disabled = true;
532
+ void runUpload(file, s.turns);
533
+ });
534
+ }
535
+ // ── render: uploading ─────────────────────────────────────────────────────────
536
+ function renderUploading(s) {
537
+ const turnsHtml = s.turns.map((t) => {
538
+ const isAgent = t.role === 'agent' || t.role === 'coach' || t.role === 'assistant';
539
+ const bubbleStyle = isAgent
540
+ ? 'background:#f0f0fe;border-radius:1rem 1rem 1rem .25rem;padding:.6rem .85rem;max-width:85%;align-self:flex-start;'
541
+ : 'background:#6366f1;color:#fff;border-radius:1rem 1rem .25rem 1rem;padding:.6rem .85rem;max-width:85%;align-self:flex-end;';
542
+ return `<div style="${bubbleStyle}" role="listitem">${esc(t.text)}</div>`;
543
+ }).join('');
544
+ root.innerHTML = `
545
+ <div class="card" style="display:flex;flex-direction:column;gap:0;height:100%;min-height:300px">
546
+ <div role="list" aria-label="Conversation"
547
+ style="flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:.5rem;padding:.5rem 0;margin-bottom:.5rem;max-height:420px">
548
+ ${turnsHtml}
549
+ <div id="scroll_anchor"></div>
550
+ </div>
551
+ <div style="border-top:1px solid #f3f4f6;padding-top:.75rem">
552
+ <div style="display:flex;flex-direction:column;gap:.4rem;padding:.6rem;background:#f8fafc;border-radius:.5rem">
553
+ <p aria-live="polite" style="margin:0;font-size:.9rem;color:#374151">${esc(s.progress)}</p>
554
+ <div style="height:4px;background:#e5e7eb;border-radius:2px">
555
+ <div style="height:100%;width:60%;background:#6366f1;border-radius:2px;animation:pulse 1.2s ease-in-out infinite"></div>
556
+ </div>
557
+ </div>
558
+ </div>
559
+ </div>`;
560
+ scrollToBottom();
561
+ }
562
+ // ── render: awaiting-confirm ──────────────────────────────────────────────────
563
+ function renderAwaitingConfirm(s) {
564
+ const turnsHtml = s.turns.map((t) => {
565
+ const isAgent = t.role === 'agent' || t.role === 'coach' || t.role === 'assistant';
566
+ const bubbleStyle = isAgent
567
+ ? 'background:#f0f0fe;border-radius:1rem 1rem 1rem .25rem;padding:.6rem .85rem;max-width:85%;align-self:flex-start;'
568
+ : 'background:#6366f1;color:#fff;border-radius:1rem 1rem .25rem 1rem;padding:.6rem .85rem;max-width:85%;align-self:flex-end;';
569
+ return `<div style="${bubbleStyle}" role="listitem">${esc(t.text)}</div>`;
570
+ }).join('');
571
+ root.innerHTML = `
572
+ <div class="card" style="display:flex;flex-direction:column;gap:0;height:100%;min-height:300px">
573
+ <div role="list" aria-label="Conversation"
574
+ style="flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:.5rem;padding:.5rem 0;margin-bottom:.5rem;max-height:420px">
575
+ ${turnsHtml}
576
+ <div id="scroll_anchor"></div>
577
+ </div>
578
+ <div style="border-top:1px solid #f3f4f6;padding-top:.75rem;display:flex;flex-direction:column;gap:.5rem">
579
+ <p style="margin:0;font-size:.9rem;color:#374151;text-align:center">
580
+ Your coach has built a draft profile based on your conversation.
581
+ </p>
582
+ <button id="confirm_btn" type="button" class="button"
583
+ style="width:100%;padding:.65rem;background:#059669;color:#fff;border:none;border-radius:.5rem;cursor:pointer;font-size:1rem;font-weight:600"
584
+ aria-label="Confirm your profile">
585
+ ✓ Confirm profile
586
+ </button>
587
+ </div>
588
+ </div>`;
589
+ scrollToBottom();
590
+ root.querySelector('#confirm_btn')?.addEventListener('click', async () => {
591
+ const btn = root.querySelector('#confirm_btn');
592
+ if (btn) {
593
+ btn.disabled = true;
594
+ btn.textContent = 'Confirming…';
595
+ }
596
+ try {
597
+ await runtime.publish(config.confirmEvent.type, { schema_version: 1, session_id: sessionId, edits: {} }, sessionId);
598
+ // Poll until doneWhen
599
+ if (config.doneWhen && config.statusQuery) {
600
+ scheduleStatusPoll(s.turns);
601
+ }
602
+ else {
603
+ transition({ phase: 'done' });
604
+ }
605
+ }
606
+ catch (err) {
607
+ handleError(err);
608
+ }
609
+ });
610
+ }
611
+ // ── render: done ──────────────────────────────────────────────────────────────
612
+ function renderDone() {
613
+ root.innerHTML = `
614
+ <div class="card" style="text-align:center;padding:2rem 1rem;display:flex;flex-direction:column;align-items:center;gap:1rem">
615
+ <div style="font-size:2.5rem" aria-hidden="true">✓</div>
616
+ <h2 style="margin:0;color:#059669;font-size:1.15rem">Profile confirmed!</h2>
617
+ <p class="muted" style="margin:0;max-width:280px">
618
+ Your coaching profile is ready. Check the Profile widget to review it anytime.
619
+ </p>
620
+ </div>`;
621
+ }
622
+ // ── render: error ─────────────────────────────────────────────────────────────
623
+ function renderError(s) {
624
+ root.innerHTML = `
625
+ <div class="card">
626
+ <p class="error" role="alert">${esc(s.message)}</p>
627
+ <button id="retry_btn" type="button" class="button" style="margin-top:.75rem">
628
+ Try again
629
+ </button>
630
+ </div>`;
631
+ root.querySelector('#retry_btn')?.addEventListener('click', () => {
632
+ teardown();
633
+ transition({ phase: 'starting' });
634
+ void boot();
635
+ });
636
+ }
637
+ // ── render: not-connected ─────────────────────────────────────────────────────
638
+ function renderNotConnected() {
639
+ root.innerHTML = `
640
+ <div class="card">
641
+ <h2 style="margin:0 0 .25rem">Connect your account</h2>
642
+ <p class="muted">${esc(config.connectPrompt ?? 'Connect your account to continue.')}</p>
643
+ <button id="connect_btn" type="button" class="button"
644
+ style="width:100%;padding:.6rem;font-size:1rem;margin-top:.75rem">
645
+ Connect
646
+ </button>
647
+ </div>`;
648
+ root.querySelector('#connect_btn')?.addEventListener('click', async () => {
649
+ const btn = root.querySelector('#connect_btn');
650
+ if (btn) {
651
+ btn.disabled = true;
652
+ btn.textContent = 'Connecting…';
653
+ }
654
+ try {
655
+ await runtime.connectAgent();
656
+ transition({ phase: 'starting' });
657
+ void boot();
658
+ }
659
+ catch (err) {
660
+ transition({ phase: 'error', message: err?.message ?? String(err) });
661
+ }
662
+ });
663
+ }
664
+ // ── initial boot ──────────────────────────────────────────────────────────────
665
+ render(); // show 'starting' immediately
666
+ void boot();
667
+ }
@@ -0,0 +1,2 @@
1
+ import type { DashboardConfig, WidgetRuntimeApi } from '../types.js';
2
+ export declare function renderDashboard(root: HTMLElement, config: DashboardConfig, runtime: WidgetRuntimeApi): Promise<void>;