@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.
- package/CHANGELOG.md +31 -0
- package/dist/gateway/adapters/webchat.d.ts.map +1 -1
- package/dist/gateway/adapters/webchat.js +81 -1
- package/dist/gateway/adapters/webchat.js.map +1 -1
- package/dist/gateway/daemon.d.ts +1 -1
- package/dist/gateway/daemon.d.ts.map +1 -1
- package/dist/gateway/daemon.js +140 -1
- package/dist/gateway/daemon.js.map +1 -1
- package/dist/gateway/server.d.ts.map +1 -1
- package/dist/gateway/server.js +33 -0
- package/dist/gateway/server.js.map +1 -1
- package/dist/gateway/static/widget.js +733 -1
- package/dist/gateway/voice-tts.d.ts +64 -0
- package/dist/gateway/voice-tts.d.ts.map +1 -0
- package/dist/gateway/voice-tts.js +191 -0
- package/dist/gateway/voice-tts.js.map +1 -0
- package/dist/lib/version-check.d.ts +41 -0
- package/dist/lib/version-check.d.ts.map +1 -0
- package/dist/lib/version-check.js +181 -0
- package/dist/lib/version-check.js.map +1 -0
- package/package.json +4 -2
|
@@ -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:
|
|
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
|
});
|