@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,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 & 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
|
+
}
|