@luckydraw/cumulus 0.28.7 → 0.28.9

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.
@@ -859,6 +859,32 @@
859
859
  '.cumulus-topbar-title { font-weight: 600; font-size: 15px; color: #e0e0e0; }',
860
860
  '.cumulus-topbar-right { display: flex; align-items: center; gap: 10px; }',
861
861
 
862
+ /* ── Update banner ── */
863
+ '.cumulus-update-banner {',
864
+ ' display: flex; align-items: center; justify-content: space-between;',
865
+ ' padding: 8px 14px; gap: 12px;',
866
+ ' background: linear-gradient(90deg, #1a3a5c, #1a2a4a);',
867
+ ' border-bottom: 1px solid #2a5a8a;',
868
+ ' color: #8ab4f8; font-size: 13px;',
869
+ ' flex-shrink: 0;',
870
+ '}',
871
+ '.cumulus-update-banner-text { flex: 1; }',
872
+ '.cumulus-update-banner-btn {',
873
+ ' padding: 4px 14px; border-radius: 4px; border: none;',
874
+ ' background: #2a6acf; color: #fff; font-size: 12px; font-weight: 600;',
875
+ ' cursor: pointer;',
876
+ '}',
877
+ '.cumulus-update-banner-btn:hover { background: #3a7ae0; }',
878
+ '.cumulus-update-banner-dismiss {',
879
+ ' background: none; border: none; color: #6a8ab0; cursor: pointer;',
880
+ ' font-size: 16px; padding: 0 4px; line-height: 1;',
881
+ '}',
882
+ '.cumulus-update-banner-dismiss:hover { color: #8ab4f8; }',
883
+ '.cumulus-update-banner-progress {',
884
+ ' height: 3px; background: #2a6acf; border-radius: 2px;',
885
+ ' transition: width 0.3s;',
886
+ '}',
887
+
862
888
  /* ── Panel header action buttons ── */
863
889
  '.cumulus-panel-actions { display: flex; align-items: center; gap: 4px; margin-left: auto; margin-right: 8px; }',
864
890
  '.cumulus-panel-action-btn {',
@@ -1189,6 +1215,101 @@
1189
1215
  ' .cumulus-standalone-empty-hint { display: none; }',
1190
1216
 
1191
1217
  '}',
1218
+
1219
+ /* ── Voice Mode ── */
1220
+ '.cumulus-voice-overlay {',
1221
+ ' position: absolute; top: 0; left: 0; right: 0; bottom: 0;',
1222
+ ' z-index: 500;',
1223
+ ' display: flex; flex-direction: column;',
1224
+ ' align-items: center; justify-content: center;',
1225
+ ' transition: background 0.4s ease;',
1226
+ ' user-select: none;',
1227
+ '}',
1228
+ '.cumulus-voice-overlay.listening { background: #0a1a2e; }',
1229
+ '.cumulus-voice-overlay.processing { background: #0a2e1a; }',
1230
+ '.cumulus-voice-overlay.speaking { background: #1e1e1e; }',
1231
+ '.cumulus-voice-overlay.idle { background: #1a1a1a; }',
1232
+
1233
+ '.cumulus-voice-indicator {',
1234
+ ' width: 120px; height: 120px;',
1235
+ ' border-radius: 50%;',
1236
+ ' display: flex; align-items: center; justify-content: center;',
1237
+ ' transition: all 0.3s ease;',
1238
+ ' margin-bottom: 24px;',
1239
+ '}',
1240
+ '.cumulus-voice-overlay.listening .cumulus-voice-indicator {',
1241
+ ' background: rgba(0, 102, 204, 0.2);',
1242
+ ' border: 2px solid #0066cc;',
1243
+ ' animation: cumulus-voice-pulse 1.5s ease-in-out infinite;',
1244
+ '}',
1245
+ '.cumulus-voice-overlay.processing .cumulus-voice-indicator {',
1246
+ ' background: rgba(34, 197, 94, 0.15);',
1247
+ ' border: 2px solid #22c55e;',
1248
+ ' animation: cumulus-voice-spin 1s linear infinite;',
1249
+ '}',
1250
+ '.cumulus-voice-overlay.speaking .cumulus-voice-indicator {',
1251
+ ' background: rgba(255, 255, 255, 0.08);',
1252
+ ' border: 2px solid #888;',
1253
+ '}',
1254
+ '.cumulus-voice-overlay.idle .cumulus-voice-indicator {',
1255
+ ' background: rgba(255, 255, 255, 0.05);',
1256
+ ' border: 2px solid #555;',
1257
+ '}',
1258
+
1259
+ '@keyframes cumulus-voice-pulse {',
1260
+ ' 0%, 100% { transform: scale(1); opacity: 1; }',
1261
+ ' 50% { transform: scale(1.08); opacity: 0.7; }',
1262
+ '}',
1263
+ '@keyframes cumulus-voice-spin {',
1264
+ ' from { transform: rotate(0deg); }',
1265
+ ' to { transform: rotate(360deg); }',
1266
+ '}',
1267
+
1268
+ '.cumulus-voice-indicator-icon {',
1269
+ ' font-size: 36px; color: #e0e0e0;',
1270
+ '}',
1271
+ '.cumulus-voice-overlay.listening .cumulus-voice-indicator-icon::after { content: "\\1F3A4"; }',
1272
+ '.cumulus-voice-overlay.processing .cumulus-voice-indicator-icon::after { content: "\\2699"; }',
1273
+ '.cumulus-voice-overlay.speaking .cumulus-voice-indicator-icon::after { content: "\\1F50A"; }',
1274
+ '.cumulus-voice-overlay.idle .cumulus-voice-indicator-icon::after { content: "\\23F8"; }',
1275
+
1276
+ '.cumulus-voice-label {',
1277
+ ' font-size: 16px; color: #aaa;',
1278
+ ' margin-bottom: 32px;',
1279
+ ' min-height: 24px;',
1280
+ '}',
1281
+
1282
+ '.cumulus-voice-transcript {',
1283
+ ' font-size: 18px; color: #e0e0e0;',
1284
+ ' max-width: 80%; text-align: center;',
1285
+ ' min-height: 28px;',
1286
+ ' margin-bottom: 24px;',
1287
+ ' font-style: italic;',
1288
+ '}',
1289
+
1290
+ '.cumulus-voice-stop-btn {',
1291
+ ' background: rgba(239, 68, 68, 0.15);',
1292
+ ' border: 1px solid #ef4444;',
1293
+ ' border-radius: 2em;',
1294
+ ' color: #ef4444;',
1295
+ ' font-size: 16px;',
1296
+ ' padding: 12px 32px;',
1297
+ ' cursor: pointer;',
1298
+ ' transition: background 0.2s;',
1299
+ '}',
1300
+ '.cumulus-voice-stop-btn:hover { background: rgba(239, 68, 68, 0.25); }',
1301
+
1302
+ '.cumulus-mic-btn {',
1303
+ ' width: 2.7em; height: 2.7em; flex-shrink: 0;',
1304
+ ' background: #3d3d3d; border: 1px solid #4a4a4a;',
1305
+ ' border-radius: 0.55em; color: #aaa;',
1306
+ ' font-size: 14px; line-height: 1; cursor: pointer;',
1307
+ ' display: flex; align-items: center; justify-content: center;',
1308
+ ' padding: 0;',
1309
+ ' transition: border-color 0.2s, color 0.2s;',
1310
+ '}',
1311
+ '.cumulus-mic-btn:hover { border-color: #0066cc; color: #ddd; }',
1312
+ '.cumulus-mic-btn.active { border-color: #ef4444; color: #ef4444; background: rgba(239,68,68,0.15); }',
1192
1313
  ].join('\n');
1193
1314
 
1194
1315
  // ─── HTML Escaping ───────────────────────────────────────────────────────────
@@ -2246,6 +2367,7 @@
2246
2367
  var apiKey = opts.apiKey;
2247
2368
  var sessionId = opts.sessionId;
2248
2369
  var onMessage = opts.onMessage;
2370
+ var onBinary = opts.onBinary || null;
2249
2371
  var onStatus = opts.onStatus;
2250
2372
  var skipHistory = opts.skipHistory || false;
2251
2373
 
@@ -2262,6 +2384,7 @@
2262
2384
  onStatus('connecting');
2263
2385
  currentStatus = 'connecting';
2264
2386
  ws = new WebSocket(wsUrl);
2387
+ ws.binaryType = 'arraybuffer';
2265
2388
 
2266
2389
  ws.onopen = function () {
2267
2390
  if (destroyed) {
@@ -2286,6 +2409,11 @@
2286
2409
  currentStatus = 'connected';
2287
2410
  onStatus('connected');
2288
2411
  }
2412
+ // Binary frames = voice audio PCM
2413
+ if (event.data instanceof ArrayBuffer) {
2414
+ if (onBinary) onBinary(event.data);
2415
+ return;
2416
+ }
2289
2417
  try {
2290
2418
  var data = JSON.parse(event.data);
2291
2419
  onMessage(data);
@@ -3478,6 +3606,124 @@
3478
3606
  // Periodic refresh every 30s
3479
3607
  if (refreshTimer) clearInterval(refreshTimer);
3480
3608
  refreshTimer = setInterval(requestThreadList, 30000);
3609
+ // Check for updates
3610
+ checkForUpdateBanner();
3611
+ }
3612
+
3613
+ // ── Update banner ──
3614
+ var updateBannerEl = null;
3615
+
3616
+ function checkForUpdateBanner() {
3617
+ // Fetch version info from the gateway
3618
+ var xhr = new XMLHttpRequest();
3619
+ xhr.open('GET', '/api/version');
3620
+ xhr.onload = function () {
3621
+ if (xhr.status !== 200) return;
3622
+ try {
3623
+ var info = JSON.parse(xhr.responseText);
3624
+ if (!info.updateAvailable) return;
3625
+ // Check if user dismissed this version in this session
3626
+ var dismissed = sessionStorage.getItem('cumulus-update-dismissed');
3627
+ if (dismissed === info.latest) return;
3628
+ showUpdateBanner(info);
3629
+ } catch (e) {
3630
+ // Ignore parse errors
3631
+ }
3632
+ };
3633
+ xhr.send();
3634
+ }
3635
+
3636
+ function showUpdateBanner(info) {
3637
+ // Remove existing banner if any
3638
+ if (updateBannerEl && updateBannerEl.parentNode) {
3639
+ updateBannerEl.parentNode.removeChild(updateBannerEl);
3640
+ }
3641
+
3642
+ updateBannerEl = document.createElement('div');
3643
+ updateBannerEl.className = 'cumulus-update-banner';
3644
+ updateBannerEl.setAttribute('data-testid', 'webchat-update-banner');
3645
+
3646
+ var textSpan = document.createElement('span');
3647
+ textSpan.className = 'cumulus-update-banner-text';
3648
+ textSpan.textContent = 'Update available: v' + info.current + ' \u2192 v' + info.latest;
3649
+
3650
+ var updateBtn = document.createElement('button');
3651
+ updateBtn.className = 'cumulus-update-banner-btn';
3652
+ updateBtn.setAttribute('data-testid', 'webchat-update-btn');
3653
+ updateBtn.textContent = 'Update';
3654
+ updateBtn.onclick = function () {
3655
+ performUpdateFromBanner(info);
3656
+ };
3657
+
3658
+ var dismissBtn = document.createElement('button');
3659
+ dismissBtn.className = 'cumulus-update-banner-dismiss';
3660
+ dismissBtn.setAttribute('data-testid', 'webchat-update-dismiss');
3661
+ dismissBtn.textContent = '\u00d7';
3662
+ dismissBtn.onclick = function () {
3663
+ sessionStorage.setItem('cumulus-update-dismissed', info.latest);
3664
+ if (updateBannerEl && updateBannerEl.parentNode) {
3665
+ updateBannerEl.parentNode.removeChild(updateBannerEl);
3666
+ }
3667
+ updateBannerEl = null;
3668
+ };
3669
+
3670
+ updateBannerEl.appendChild(textSpan);
3671
+ updateBannerEl.appendChild(updateBtn);
3672
+ updateBannerEl.appendChild(dismissBtn);
3673
+
3674
+ // Insert at the top of appLayout (before topbar)
3675
+ appLayout.insertBefore(updateBannerEl, appLayout.firstChild);
3676
+ }
3677
+
3678
+ function performUpdateFromBanner(info) {
3679
+ if (!updateBannerEl) return;
3680
+ // Replace banner content with progress
3681
+ updateBannerEl.innerHTML = '';
3682
+ var textSpan = document.createElement('span');
3683
+ textSpan.className = 'cumulus-update-banner-text';
3684
+ textSpan.textContent = 'Updating to v' + info.latest + '...';
3685
+ var progressBar = document.createElement('div');
3686
+ progressBar.style.cssText =
3687
+ 'flex:1; max-width:200px; height:3px; background:#1a2a4a; border-radius:2px; overflow:hidden;';
3688
+ var progressFill = document.createElement('div');
3689
+ progressFill.className = 'cumulus-update-banner-progress';
3690
+ progressFill.style.width = '30%';
3691
+ progressBar.appendChild(progressFill);
3692
+ updateBannerEl.appendChild(textSpan);
3693
+ updateBannerEl.appendChild(progressBar);
3694
+
3695
+ // POST /api/admin/update
3696
+ var xhr = new XMLHttpRequest();
3697
+ xhr.open('POST', '/api/admin/update');
3698
+ xhr.setRequestHeader('Content-Type', 'application/json');
3699
+ xhr.setRequestHeader('X-API-Key', activeApiKey);
3700
+ xhr.onload = function () {
3701
+ progressFill.style.width = '100%';
3702
+ if (xhr.status === 200) {
3703
+ textSpan.textContent =
3704
+ '\u2713 Updated to v' + info.latest + ' \u2014 Restarting gateway...';
3705
+ updateBannerEl.style.background = 'linear-gradient(90deg, #1a3a2a, #1a2a1a)';
3706
+ updateBannerEl.style.borderBottomColor = '#2a8a5a';
3707
+ textSpan.style.color = '#8af8b4';
3708
+ // Auto-dismiss after 5s (gateway will restart and WS will reconnect)
3709
+ setTimeout(function () {
3710
+ if (updateBannerEl && updateBannerEl.parentNode) {
3711
+ updateBannerEl.parentNode.removeChild(updateBannerEl);
3712
+ }
3713
+ updateBannerEl = null;
3714
+ }, 5000);
3715
+ } else {
3716
+ textSpan.textContent = '\u2717 Update failed. Check gateway logs.';
3717
+ updateBannerEl.style.background = 'linear-gradient(90deg, #3a1a1a, #2a1a1a)';
3718
+ textSpan.style.color = '#f88a8a';
3719
+ }
3720
+ };
3721
+ xhr.onerror = function () {
3722
+ textSpan.textContent = '\u2717 Update failed: connection error.';
3723
+ updateBannerEl.style.background = 'linear-gradient(90deg, #3a1a1a, #2a1a1a)';
3724
+ textSpan.style.color = '#f88a8a';
3725
+ };
3726
+ xhr.send(JSON.stringify({ version: info.latest }));
3481
3727
  }
3482
3728
 
3483
3729
  // ── Thread list ──
@@ -3812,6 +4058,416 @@
3812
4058
  }
3813
4059
 
3814
4060
  // ── Thread panel builder ──
4061
+ // ── Voice Mode ────────────────────────────────────────────────────────────
4062
+ var SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition;
4063
+
4064
+ function createVoiceMode(panel, threadName, sendFn) {
4065
+ var state = {
4066
+ active: false,
4067
+ phase: 'idle',
4068
+ recognition: null,
4069
+ synth: window.speechSynthesis,
4070
+ speaking: false,
4071
+ wakeLock: null,
4072
+ watchdog: null,
4073
+ transcript: '',
4074
+ // Server-side TTS (Piper)
4075
+ serverTTS: false,
4076
+ serverSampleRate: 22050,
4077
+ audioCtx: null,
4078
+ audioQueue: [], // queue of Float32Array PCM chunks
4079
+ audioPlaying: false, // currently playing audio
4080
+ audioDone: false, // server signaled all audio sent
4081
+ };
4082
+
4083
+ // ── Overlay DOM ──
4084
+ var overlay = document.createElement('div');
4085
+ overlay.className = 'cumulus-voice-overlay idle';
4086
+ overlay.style.display = 'none';
4087
+
4088
+ var indicator = document.createElement('div');
4089
+ indicator.className = 'cumulus-voice-indicator';
4090
+ var indicatorIcon = document.createElement('span');
4091
+ indicatorIcon.className = 'cumulus-voice-indicator-icon';
4092
+ indicator.appendChild(indicatorIcon);
4093
+ overlay.appendChild(indicator);
4094
+
4095
+ var transcriptEl = document.createElement('div');
4096
+ transcriptEl.className = 'cumulus-voice-transcript';
4097
+ overlay.appendChild(transcriptEl);
4098
+
4099
+ var label = document.createElement('div');
4100
+ label.className = 'cumulus-voice-label';
4101
+ overlay.appendChild(label);
4102
+
4103
+ var stopBtn = document.createElement('button');
4104
+ stopBtn.className = 'cumulus-voice-stop-btn';
4105
+ stopBtn.setAttribute('data-testid', 'webchat-voice-stop');
4106
+ stopBtn.textContent = 'Exit Voice Mode';
4107
+ stopBtn.addEventListener('click', function () {
4108
+ deactivate();
4109
+ });
4110
+ overlay.appendChild(stopBtn);
4111
+
4112
+ panel.appendChild(overlay);
4113
+
4114
+ function setPhase(p) {
4115
+ state.phase = p;
4116
+ overlay.className = 'cumulus-voice-overlay ' + p;
4117
+ if (p === 'listening') label.textContent = 'Listening\u2026';
4118
+ else if (p === 'processing') label.textContent = 'Thinking\u2026';
4119
+ else if (p === 'speaking') label.textContent = '';
4120
+ else label.textContent = 'Tap mic to speak';
4121
+ }
4122
+
4123
+ // ── Wake Lock ──
4124
+ function requestWakeLock() {
4125
+ if (navigator.wakeLock) {
4126
+ navigator.wakeLock
4127
+ .request('screen')
4128
+ .then(function (lock) {
4129
+ state.wakeLock = lock;
4130
+ })
4131
+ .catch(function () {});
4132
+ }
4133
+ }
4134
+ function releaseWakeLock() {
4135
+ if (state.wakeLock) {
4136
+ state.wakeLock.release().catch(function () {});
4137
+ state.wakeLock = null;
4138
+ }
4139
+ }
4140
+
4141
+ // ── Speech Recognition ──
4142
+ function startRecognition() {
4143
+ if (!SpeechRecognitionApi) {
4144
+ label.textContent = 'Speech recognition not supported in this browser';
4145
+ return;
4146
+ }
4147
+ if (state.recognition) {
4148
+ try {
4149
+ state.recognition.abort();
4150
+ } catch (e) {}
4151
+ }
4152
+
4153
+ var rec = new SpeechRecognitionApi();
4154
+ rec.continuous = false;
4155
+ rec.interimResults = true;
4156
+ rec.lang = 'en-US';
4157
+ state.recognition = rec;
4158
+ state.transcript = '';
4159
+ transcriptEl.textContent = '';
4160
+
4161
+ rec.onresult = function (event) {
4162
+ var interim = '';
4163
+ var final_ = '';
4164
+ for (var i = event.resultIndex; i < event.results.length; i++) {
4165
+ var t = event.results[i][0].transcript;
4166
+ if (event.results[i].isFinal) {
4167
+ final_ += t;
4168
+ } else {
4169
+ interim += t;
4170
+ }
4171
+ }
4172
+ if (final_) {
4173
+ state.transcript += final_;
4174
+ transcriptEl.textContent = state.transcript;
4175
+ } else {
4176
+ transcriptEl.textContent = state.transcript + interim;
4177
+ }
4178
+
4179
+ // Barge-in: if user speaks while TTS is playing, stop it
4180
+ if (state.speaking) {
4181
+ stopSpeaking();
4182
+ }
4183
+ };
4184
+
4185
+ rec.onend = function () {
4186
+ clearWatchdog();
4187
+ if (!state.active) return;
4188
+
4189
+ // If we got a transcript, send it
4190
+ if (state.transcript.trim()) {
4191
+ var msg = state.transcript.trim();
4192
+ transcriptEl.textContent = msg;
4193
+ setPhase('processing');
4194
+ sendFn(msg);
4195
+ } else {
4196
+ // No speech detected — restart listening
4197
+ setPhase('idle');
4198
+ setTimeout(function () {
4199
+ if (state.active) startRecognition();
4200
+ }, 300);
4201
+ }
4202
+ };
4203
+
4204
+ rec.onerror = function (event) {
4205
+ clearWatchdog();
4206
+ if (event.error === 'no-speech' || event.error === 'aborted') {
4207
+ // Normal — just restart
4208
+ if (state.active) {
4209
+ setPhase('idle');
4210
+ setTimeout(function () {
4211
+ if (state.active) startRecognition();
4212
+ }, 500);
4213
+ }
4214
+ } else {
4215
+ label.textContent = 'Mic error: ' + event.error;
4216
+ setTimeout(function () {
4217
+ if (state.active) startRecognition();
4218
+ }, 1000);
4219
+ }
4220
+ };
4221
+
4222
+ setPhase('listening');
4223
+ rec.start();
4224
+
4225
+ // Watchdog: SpeechRecognition dies after ~60s on iOS
4226
+ startWatchdog();
4227
+ }
4228
+
4229
+ function startWatchdog() {
4230
+ clearWatchdog();
4231
+ state.watchdog = setTimeout(function () {
4232
+ if (state.active && state.phase === 'listening') {
4233
+ try {
4234
+ state.recognition.abort();
4235
+ } catch (e) {}
4236
+ startRecognition();
4237
+ }
4238
+ }, 55000); // restart before 60s iOS limit
4239
+ }
4240
+
4241
+ function clearWatchdog() {
4242
+ if (state.watchdog) {
4243
+ clearTimeout(state.watchdog);
4244
+ state.watchdog = null;
4245
+ }
4246
+ }
4247
+
4248
+ // ── Speech Synthesis (Phase 1 — browser TTS) ──
4249
+ function speak(text) {
4250
+ if (!state.synth || !state.active) return;
4251
+
4252
+ // Strip markdown and blex fences
4253
+ var clean = text
4254
+ .replace(/~~~blex:[\s\S]*?~~~/g, '')
4255
+ .replace(/```[\s\S]*?```/g, '')
4256
+ .replace(/[*_~`#]/g, '')
4257
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
4258
+ .replace(/https?:\/\/\S+/g, '')
4259
+ .trim();
4260
+
4261
+ if (!clean) {
4262
+ onSpeakDone();
4263
+ return;
4264
+ }
4265
+
4266
+ state.speaking = true;
4267
+ setPhase('speaking');
4268
+
4269
+ var utterance = new SpeechSynthesisUtterance(clean);
4270
+ utterance.rate = 1.0;
4271
+ utterance.pitch = 1.0;
4272
+
4273
+ // Try to pick a decent voice
4274
+ var voices = state.synth.getVoices();
4275
+ var preferred =
4276
+ voices.find(function (v) {
4277
+ return v.name.indexOf('Samantha') >= 0;
4278
+ }) ||
4279
+ voices.find(function (v) {
4280
+ return v.lang.startsWith('en') && v.localService;
4281
+ }) ||
4282
+ voices[0];
4283
+ if (preferred) utterance.voice = preferred;
4284
+
4285
+ utterance.onend = function () {
4286
+ onSpeakDone();
4287
+ };
4288
+ utterance.onerror = function () {
4289
+ onSpeakDone();
4290
+ };
4291
+
4292
+ state.synth.speak(utterance);
4293
+ }
4294
+
4295
+ function stopSpeaking() {
4296
+ if (state.serverTTS) {
4297
+ stopServerAudio();
4298
+ }
4299
+ if (state.synth) state.synth.cancel();
4300
+ state.speaking = false;
4301
+ }
4302
+
4303
+ function onSpeakDone() {
4304
+ state.speaking = false;
4305
+ if (state.active) {
4306
+ setPhase('idle');
4307
+ setTimeout(function () {
4308
+ if (state.active) startRecognition();
4309
+ }, 300);
4310
+ }
4311
+ }
4312
+
4313
+ // ── Server-side TTS (Web Audio API playback) ──
4314
+ function ensureAudioCtx() {
4315
+ if (!state.audioCtx) {
4316
+ state.audioCtx = new (window.AudioContext || window.webkitAudioContext)({
4317
+ sampleRate: state.serverSampleRate,
4318
+ });
4319
+ }
4320
+ // Resume if suspended (iOS requires user gesture)
4321
+ if (state.audioCtx.state === 'suspended') {
4322
+ state.audioCtx.resume();
4323
+ }
4324
+ return state.audioCtx;
4325
+ }
4326
+
4327
+ function queuePCMAudio(arrayBuffer) {
4328
+ if (!state.active || state.phase === 'listening') return;
4329
+
4330
+ // Strip 4-byte "VPCM" header
4331
+ var pcmData = new Int16Array(arrayBuffer, 4);
4332
+ // Convert Int16 to Float32 for Web Audio API
4333
+ var float32 = new Float32Array(pcmData.length);
4334
+ for (var i = 0; i < pcmData.length; i++) {
4335
+ float32[i] = pcmData[i] / 32768.0;
4336
+ }
4337
+ state.audioQueue.push(float32);
4338
+
4339
+ if (!state.audioPlaying) {
4340
+ state.speaking = true;
4341
+ setPhase('speaking');
4342
+ playNextChunk();
4343
+ }
4344
+ }
4345
+
4346
+ function playNextChunk() {
4347
+ if (!state.active || state.audioQueue.length === 0) {
4348
+ state.audioPlaying = false;
4349
+ if (state.audioDone || state.audioQueue.length === 0) {
4350
+ onServerSpeakDone();
4351
+ }
4352
+ return;
4353
+ }
4354
+
4355
+ state.audioPlaying = true;
4356
+ var ctx = ensureAudioCtx();
4357
+ var samples = state.audioQueue.shift();
4358
+ var buffer = ctx.createBuffer(1, samples.length, state.serverSampleRate);
4359
+ buffer.getChannelData(0).set(samples);
4360
+
4361
+ var source = ctx.createBufferSource();
4362
+ source.buffer = buffer;
4363
+ source.connect(ctx.destination);
4364
+ source.onended = function () {
4365
+ playNextChunk();
4366
+ };
4367
+ source.start();
4368
+ state._currentSource = source;
4369
+ }
4370
+
4371
+ function stopServerAudio() {
4372
+ state.audioQueue.length = 0;
4373
+ state.audioPlaying = false;
4374
+ state.audioDone = false;
4375
+ if (state._currentSource) {
4376
+ try {
4377
+ state._currentSource.stop();
4378
+ } catch (e) {}
4379
+ state._currentSource = null;
4380
+ }
4381
+ }
4382
+
4383
+ function onServerSpeakDone() {
4384
+ state.speaking = false;
4385
+ state.audioDone = false;
4386
+ if (state.active) {
4387
+ setPhase('idle');
4388
+ setTimeout(function () {
4389
+ if (state.active) startRecognition();
4390
+ }, 300);
4391
+ }
4392
+ }
4393
+
4394
+ // ── Public API ──
4395
+ function activate() {
4396
+ if (!SpeechRecognitionApi) {
4397
+ alert('Speech recognition is not supported in this browser.');
4398
+ return;
4399
+ }
4400
+ state.active = true;
4401
+ overlay.style.display = 'flex';
4402
+ requestWakeLock();
4403
+
4404
+ // Unlock audio on iOS (requires user gesture)
4405
+ if (state.synth) {
4406
+ var unlockUtterance = new SpeechSynthesisUtterance('');
4407
+ unlockUtterance.volume = 0;
4408
+ state.synth.speak(unlockUtterance);
4409
+ }
4410
+
4411
+ setPhase('idle');
4412
+ startRecognition();
4413
+ }
4414
+
4415
+ function deactivate() {
4416
+ state.active = false;
4417
+ overlay.style.display = 'none';
4418
+ stopSpeaking();
4419
+ clearWatchdog();
4420
+ releaseWakeLock();
4421
+ if (state.recognition) {
4422
+ try {
4423
+ state.recognition.abort();
4424
+ } catch (e) {}
4425
+ state.recognition = null;
4426
+ }
4427
+ // Reset server TTS state
4428
+ state.serverTTS = false;
4429
+ if (state.audioCtx) {
4430
+ state.audioCtx.close().catch(function () {});
4431
+ state.audioCtx = null;
4432
+ }
4433
+ setPhase('idle');
4434
+ }
4435
+
4436
+ // Called by the message handler when assistant response arrives
4437
+ function onAssistantMessage(content) {
4438
+ if (!state.active) return;
4439
+ // Don't speak system or agent messages
4440
+ if (typeof content === 'string' && content.startsWith('[System]')) return;
4441
+ // If server TTS (Piper) is active, audio comes via binary WebSocket frames — skip browser TTS
4442
+ if (state.serverTTS) return;
4443
+ speak(content);
4444
+ }
4445
+
4446
+ return {
4447
+ activate: activate,
4448
+ deactivate: deactivate,
4449
+ onAssistantMessage: onAssistantMessage,
4450
+ isActive: function () {
4451
+ return state.active;
4452
+ },
4453
+ // Server TTS methods
4454
+ setServerTTS: function (sampleRate) {
4455
+ state.serverTTS = true;
4456
+ state.serverSampleRate = sampleRate || 22050;
4457
+ state.audioDone = false;
4458
+ },
4459
+ queuePCMAudio: queuePCMAudio,
4460
+ onVoiceAudioDone: function () {
4461
+ state.audioDone = true;
4462
+ // If not currently playing, trigger done immediately
4463
+ if (!state.audioPlaying && state.audioQueue.length === 0) {
4464
+ onServerSpeakDone();
4465
+ }
4466
+ },
4467
+ overlay: overlay,
4468
+ };
4469
+ }
4470
+
3815
4471
  function buildThreadPanel(threadName) {
3816
4472
  var state = getThreadState(threadName);
3817
4473
 
@@ -4078,9 +4734,49 @@
4078
4734
  inputRow.appendChild(attachBtn);
4079
4735
  inputRow.appendChild(inputEl);
4080
4736
  inputRow.appendChild(sendBtn);
4737
+
4738
+ // Mic button for voice mode
4739
+ var micBtn = document.createElement('button');
4740
+ micBtn.className = 'cumulus-mic-btn';
4741
+ micBtn.setAttribute('data-testid', 'webchat-mic-btn');
4742
+ micBtn.setAttribute('title', 'Voice mode');
4743
+ micBtn.innerHTML =
4744
+ '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>';
4745
+ if (!SpeechRecognitionApi) micBtn.style.display = 'none';
4746
+ inputRow.appendChild(micBtn);
4747
+
4081
4748
  inputArea.appendChild(inputRow);
4082
4749
  panel.appendChild(inputArea);
4083
4750
 
4751
+ // ── Voice mode setup ──
4752
+ var voiceMode = createVoiceMode(panel, threadName, function (spokenText) {
4753
+ // Send the spoken text with voiceMode flag — server injects voice prompt into system prompt
4754
+ if (!connection) return;
4755
+ state.messages.push({ role: 'user', content: spokenText });
4756
+ state.streaming = true;
4757
+ state.streamBuffer = '';
4758
+ renderPanelMessages();
4759
+ connection.send({
4760
+ type: 'message',
4761
+ threadName: threadName,
4762
+ message: spokenText,
4763
+ voiceMode: true,
4764
+ });
4765
+ });
4766
+
4767
+ // Store voice mode on thread state so the message handler can trigger TTS
4768
+ state._voiceMode = voiceMode;
4769
+
4770
+ micBtn.addEventListener('click', function () {
4771
+ if (voiceMode.isActive()) {
4772
+ voiceMode.deactivate();
4773
+ micBtn.classList.remove('active');
4774
+ } else {
4775
+ voiceMode.activate();
4776
+ micBtn.classList.add('active');
4777
+ }
4778
+ });
4779
+
4084
4780
  // ── Panel-local render functions ──
4085
4781
  function scrollToBottom() {
4086
4782
  requestAnimationFrame(function () {
@@ -5212,6 +5908,26 @@
5212
5908
  // Reserved for future verbose display
5213
5909
  break;
5214
5910
 
5911
+ case 'voice_info':
5912
+ // Server has Piper TTS available — switch to server-side audio
5913
+ if (data.threadName) {
5914
+ var state = getThreadState(data.threadName);
5915
+ if (state._voiceMode && state._voiceMode.isActive()) {
5916
+ state._voiceMode.setServerTTS(data.sampleRate);
5917
+ }
5918
+ }
5919
+ break;
5920
+
5921
+ case 'voice_audio_done':
5922
+ // Server finished sending all audio for this response
5923
+ if (data.threadName) {
5924
+ var state = getThreadState(data.threadName);
5925
+ if (state._voiceMode && state._voiceMode.isActive()) {
5926
+ state._voiceMode.onVoiceAudioDone();
5927
+ }
5928
+ }
5929
+ break;
5930
+
5215
5931
  case 'done':
5216
5932
  if (data.threadName) {
5217
5933
  clearStreamingTimeout(data.threadName);
@@ -5228,13 +5944,18 @@
5228
5944
  break;
5229
5945
  }
5230
5946
  state.streaming = false;
5947
+ var responseContent = data.response || state.streamBuffer;
5231
5948
  state.messages.push({
5232
5949
  role: 'assistant',
5233
- content: data.response || state.streamBuffer,
5950
+ content: responseContent,
5234
5951
  });
5235
5952
  state.streamBuffer = '';
5236
5953
  updateThreadActivity(data.threadName);
5237
5954
  refreshThreadPanel(data.threadName);
5955
+ // Voice mode: speak the assistant response
5956
+ if (state._voiceMode && state._voiceMode.isActive()) {
5957
+ state._voiceMode.onAssistantMessage(responseContent);
5958
+ }
5238
5959
  }
5239
5960
  break;
5240
5961
 
@@ -5461,6 +6182,17 @@
5461
6182
  apiKey: activeApiKey,
5462
6183
  sessionId: sessionId,
5463
6184
  onMessage: handleServerMessage,
6185
+ onBinary: function (arrayBuffer) {
6186
+ // Route binary PCM audio to the active voice mode session
6187
+ // Find which thread has active voice mode
6188
+ for (var tn in threadStates) {
6189
+ var ts = threadStates[tn];
6190
+ if (ts._voiceMode && ts._voiceMode.isActive()) {
6191
+ ts._voiceMode.queuePCMAudio(arrayBuffer);
6192
+ break;
6193
+ }
6194
+ }
6195
+ },
5464
6196
  onStatus: updateStatus,
5465
6197
  skipHistory: true, // standalone manages history per-thread
5466
6198
  });