clay-server 4.3.1-beta.4 → 4.4.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/lib/issues-filing-guidance.js +12 -0
  2. package/lib/issues-mcp-server.js +1 -1
  3. package/lib/project-logs-mcp-server.js +1 -1
  4. package/lib/public/app.js +13 -5
  5. package/lib/public/css/home-hub.css +1 -0
  6. package/lib/public/css/messages.css +1 -2
  7. package/lib/public/css/stt.css +106 -155
  8. package/lib/public/icons/speech/SOURCES.txt +5 -0
  9. package/lib/public/icons/speech/deepgram.svg +1 -0
  10. package/lib/public/icons/speech/elevenlabs.svg +1 -0
  11. package/lib/public/icons/speech/groq.svg +1 -0
  12. package/lib/public/icons/speech/soniox.png +0 -0
  13. package/lib/public/index.html +3 -2
  14. package/lib/public/modules/home-mate-chat.js +8 -0
  15. package/lib/public/modules/input.js +2 -0
  16. package/lib/public/modules/profile.js +0 -4
  17. package/lib/public/modules/stt-audio-worklet.js +32 -0
  18. package/lib/public/modules/stt-audio.js +68 -0
  19. package/lib/public/modules/stt-controller.js +202 -0
  20. package/lib/public/modules/stt-debug.js +7 -0
  21. package/lib/public/modules/stt-meter.js +55 -0
  22. package/lib/public/modules/stt-picker.js +123 -0
  23. package/lib/public/modules/stt-recording.js +130 -0
  24. package/lib/public/modules/stt-settings.js +50 -0
  25. package/lib/public/modules/stt-signal.js +57 -0
  26. package/lib/public/modules/stt.js +113 -252
  27. package/lib/public/modules/user-settings.js +15 -9
  28. package/lib/public/modules/worker-pane-lock.js +1 -0
  29. package/lib/server-speech.js +166 -0
  30. package/lib/server.js +8 -0
  31. package/lib/session-notes-mcp-server.js +4 -3
  32. package/lib/speech-catalog.js +25 -0
  33. package/lib/speech-openai-stream.js +102 -0
  34. package/lib/speech-store.js +71 -0
  35. package/lib/speech-stream.js +108 -0
  36. package/lib/speech-transcribe.js +63 -0
  37. package/package.json +1 -1
@@ -1,266 +1,127 @@
1
- // Speech-to-Text module using Web Speech API
2
- // Uses browser's built-in speech recognition (Chrome/Edge/Safari → Google servers)
3
-
1
+ // Compact shared voice controls for project, DM and Home composers.
4
2
  import { iconHtml, refreshIcons } from './icons.js';
5
- import { autoResize } from './input.js';
3
+ import { showToast } from './utils.js';
6
4
  import { store } from './store.js';
7
- import { VENDOR_NAMES } from './app-rendering.js';
8
-
9
- var ctx;
10
-
11
- // --- State ---
12
- var recording = false;
13
- var recognition = null;
14
- var selectedLang = null;
15
- var textBeforeSTT = '';
16
- var interimText = '';
17
-
18
- // DOM refs
19
- var sttBtn = null;
20
- var langPopover = null;
21
-
22
- // --- Language options ---
23
- // Web Speech API uses BCP-47 language tags
24
- var LANGUAGES = [
25
- { code: 'en-US', name: 'English' },
26
- { code: 'ko-KR', name: 'Korean' },
27
- { code: 'ja-JP', name: 'Japanese' },
28
- { code: 'zh-CN', name: 'Chinese' },
29
- { code: 'es-ES', name: 'Spanish' },
30
- { code: 'fr-FR', name: 'French' },
31
- { code: 'de-DE', name: 'German' },
32
- ];
5
+ import { initSpeechLifecycle, speechConstructor, setSpeechStatus, startSTT, stopSTT } from './stt-controller.js';
6
+ import { startRecording, finishRecording, retryRecording } from './stt-recording.js';
7
+ import { voiceModel, voiceProvider, loadSpeechSettings } from './stt-settings.js';
8
+ import { openSpeechPicker, initSpeechPicker } from './stt-picker.js';
9
+ import { createSpeechMeter } from './stt-meter.js';
10
+ export { stopSTT } from './stt-controller.js';
33
11
 
34
- // --- Check browser support ---
35
- function getSpeechRecognition() {
36
- return window.SpeechRecognition || window.webkitSpeechRecognition || null;
12
+ export function setSTTLang(code) {
13
+ if (typeof code !== 'string' || !code) return;
14
+ if (store.get('speechLang') !== code) stopSTT();
15
+ store.set({ speechLang: code });
37
16
  }
38
-
39
- // --- Init ---
40
- export function initSTT(_ctx) {
41
- ctx = _ctx;
42
-
43
- sttBtn = document.getElementById('stt-btn');
44
- if (!sttBtn) return;
45
-
46
- if (!getSpeechRecognition()) {
47
- sttBtn.style.display = 'none';
48
- console.warn('[STT] Web Speech API not supported in this browser');
49
- return;
50
- }
51
-
52
- sttBtn.addEventListener('click', function(e) {
53
- e.stopPropagation();
54
-
55
- if (recording) {
56
- stopRecording();
17
+ export function getSTTLang() { return store.get('speechLang') || 'en-US'; }
18
+ export function isSTTRecording() { return !!store.get('speechActive'); }
19
+ export function isSTTInitializing() { var active = store.get('speechActive'); return !!active && !active.started; }
20
+
21
+ function bindComposer(id, inputId, wrapperId) {
22
+ var button = document.getElementById(id);
23
+ var input = document.getElementById(inputId);
24
+ var wrapper = document.getElementById(wrapperId);
25
+ if (!button || !input || !wrapper || button.dataset.speechBound) return;
26
+ var composer = document.getElementById(id === 'stt-btn' ? 'input-row' : 'home-mate-chat-composer') || wrapper;
27
+ var toolbar = document.getElementById(id === 'stt-btn' ? 'input-bottom' : 'home-mate-chat-composer') || composer;
28
+ button.dataset.speechBound = 'true'; button.classList.add('stt-button');
29
+ var group = document.createElement('span'); group.className = 'stt-controls';
30
+ button.before(group); group.appendChild(button);
31
+ var picker = document.createElement('button'); picker.type = 'button'; picker.id = id + '-picker';
32
+ picker.className = 'stt-picker-toggle'; picker.innerHTML = iconHtml('chevron-down');
33
+ picker.setAttribute('aria-label', 'Choose voice model'); picker.setAttribute('aria-haspopup', 'dialog'); picker.setAttribute('aria-expanded', 'false');
34
+ picker.title = 'Choose voice model'; group.appendChild(picker);
35
+ picker.addEventListener('click', function () { openSpeechPicker(picker); });
36
+ var row = document.createElement('div'); row.className = 'stt-progress'; row.hidden = true;
37
+ var status = document.createElement('span'); status.id = id + '-status'; status.className = 'stt-status'; status.setAttribute('role', 'status');
38
+ var timer = document.createElement('span'); timer.className = 'stt-timer'; timer.setAttribute('aria-hidden', 'true');
39
+ var cancel = document.createElement('button'); cancel.type = 'button'; cancel.innerHTML = iconHtml('x');
40
+ cancel.className = 'stt-cancel'; cancel.title = 'Cancel voice input'; cancel.setAttribute('aria-label', 'Cancel voice input');
41
+ cancel.addEventListener('click', function () { stopSTT(); store.set({ speechRetry: null }); });
42
+ var meter = createSpeechMeter(id);
43
+ var retry = document.createElement('button'); retry.type = 'button'; retry.textContent = 'Retry';
44
+ retry.addEventListener('click', retryRecording);
45
+ var account = document.createElement('a'); account.textContent = 'Provider account'; account.target = '_blank'; account.rel = 'noopener noreferrer';
46
+ var dismiss = document.createElement('button'); dismiss.type = 'button'; dismiss.textContent = 'Dismiss';
47
+ dismiss.addEventListener('click', function () { setSpeechStatus(id, ''); });
48
+ row.append(cancel, meter.element, timer, status, retry, account, dismiss); composer.prepend(row);
49
+ button.setAttribute('aria-describedby', status.id);
50
+ button.addEventListener('click', function () {
51
+ var active = store.get('speechActive');
52
+ if (active && active.id === id) {
53
+ if (active.model && active.phase === 'recording') finishRecording(); else stopSTT();
57
54
  return;
58
55
  }
59
-
60
- if (!selectedLang) {
61
- showLangPopover();
62
- } else {
63
- startRecording();
64
- }
56
+ var model = voiceModel(); var settings = store.get('speechSettings');
57
+ if (model.provider !== 'browser' && (!settings || settings.configured.indexOf(model.provider) < 0)) { openSpeechPicker(picker); return; }
58
+ if (model.provider === 'browser') startSTT(id, input); else startRecording(id, input, model);
65
59
  });
66
-
67
- // Right-click to change language
68
- sttBtn.addEventListener('contextmenu', function(e) {
69
- e.preventDefault();
70
- e.stopPropagation();
71
- if (recording) stopRecording();
72
- showLangPopover();
60
+ input.addEventListener('beforeinput', function () {
61
+ var active = store.get('speechActive');
62
+ if (active && active.id === id) stopSTT('Voice input stopped so you can edit your draft.');
63
+ store.set({ speechRetry: null });
73
64
  });
74
- }
75
-
76
- // --- Language popover ---
77
- function showLangPopover() {
78
- if (langPopover) {
79
- hideLangPopover();
80
- return;
81
- }
82
-
83
- langPopover = document.createElement('div');
84
- langPopover.className = 'stt-lang-popover';
85
-
86
- var html = '<div class="stt-lang-title">Voice Input Language</div>';
87
- for (var i = 0; i < LANGUAGES.length; i++) {
88
- var l = LANGUAGES[i];
89
- var activeClass = (selectedLang === l.code) ? ' stt-lang-active' : '';
90
- html += '<button class="stt-lang-option' + activeClass + '" data-lang="' + l.code + '">' +
91
- '<span class="stt-lang-name">' + l.name + '</span>' +
92
- '</button>';
93
- }
94
- langPopover.innerHTML = html;
95
-
96
- langPopover.querySelectorAll('.stt-lang-option').forEach(function(btn) {
97
- btn.addEventListener('click', function() {
98
- onLangSelected(btn.dataset.lang);
99
- });
65
+ input.addEventListener('input', function () {
66
+ var active = store.get('speechActive');
67
+ if (active && active.id === id && !active.writing) stopSTT();
100
68
  });
101
-
102
- var wrapper = document.getElementById('input-wrapper');
103
- wrapper.appendChild(langPopover);
104
-
105
- setTimeout(function() {
106
- document.addEventListener('click', closeLangOnOutside);
107
- }, 0);
108
- }
109
-
110
- function closeLangOnOutside(e) {
111
- if (langPopover && !langPopover.contains(e.target) && e.target !== sttBtn && !sttBtn.contains(e.target)) {
112
- hideLangPopover();
113
- }
114
- }
115
-
116
- function hideLangPopover() {
117
- if (langPopover) {
118
- langPopover.remove();
119
- langPopover = null;
120
- }
121
- document.removeEventListener('click', closeLangOnOutside);
122
- }
123
-
124
- function onLangSelected(code) {
125
- selectedLang = code;
126
- hideLangPopover();
127
- startRecording();
128
- }
129
-
130
- // --- Recording ---
131
- function startRecording() {
132
- if (recording) return;
133
-
134
- var SpeechRecognition = getSpeechRecognition();
135
- if (!SpeechRecognition) return;
136
-
137
- recognition = new SpeechRecognition();
138
- recognition.lang = selectedLang || 'en-US';
139
- recognition.continuous = true;
140
- recognition.interimResults = true;
141
-
142
- textBeforeSTT = ctx.inputEl.value;
143
- interimText = '';
144
-
145
- recognition.onresult = function(e) {
146
- var final = '';
147
- var interim = '';
148
-
149
- for (var i = 0; i < e.results.length; i++) {
150
- var result = e.results[i];
151
- if (result.isFinal) {
152
- final += result[0].transcript;
153
- } else {
154
- interim += result[0].transcript;
155
- }
156
- }
157
-
158
- var text = textBeforeSTT;
159
- if (final) {
160
- if (text && text.length > 0 && text[text.length - 1] !== ' ' && text[text.length - 1] !== '\n') {
161
- text += ' ';
162
- }
163
- text += final;
164
- }
165
- if (interim) {
166
- if (text && text.length > 0 && text[text.length - 1] !== ' ' && text[text.length - 1] !== '\n') {
167
- text += ' ';
168
- }
169
- text += interim;
170
- }
171
-
172
- ctx.inputEl.value = text;
173
- autoResize();
174
- if (ctx.scrollToBottom) ctx.scrollToBottom();
175
- };
176
-
177
- recognition.onerror = function(e) {
178
- console.error('[STT] Recognition error:', e.error);
179
- if (e.error === 'not-allowed') {
180
- if (ctx.addSystemMessage) {
181
- ctx.addSystemMessage('Microphone access denied.\n\nTo fix: click the lock icon in the address bar → Site settings → Microphone → Allow, then reload.', true);
182
- }
183
- stopRecording();
184
- } else if (e.error === 'no-speech') {
185
- // Silence — just keep listening
186
- } else if (e.error === 'network') {
187
- if (ctx.addSystemMessage) {
188
- ctx.addSystemMessage('Speech recognition unavailable.\n\nWeb Speech API sends audio to Google servers for recognition. Some Chromium forks (Arc, Brave) block this connection.\n\nSupported: Chrome, Edge, Safari 14.1+, Samsung Internet\nNot supported: Arc, Brave, Firefox', true);
189
- }
190
- stopRecording();
191
- }
192
- };
193
-
194
- recognition.onend = function() {
195
- // Auto-restart if still recording (browser may stop after silence)
196
- if (recording) {
197
- // Save confirmed text so far
198
- textBeforeSTT = ctx.inputEl.value;
199
- try {
200
- recognition.start();
201
- } catch (e) {
202
- // Already started or other error
203
- stopRecording();
69
+ function render() {
70
+ var active = store.get('speechActive'); var own = !!active && active.id === id;
71
+ var model = voiceModel(); var provider = voiceProvider(model.provider);
72
+ var pending = own && active.model && active.phase !== 'recording';
73
+ composer.classList.toggle('stt-recording', own);
74
+ row.classList.toggle('stt-progress-active', own);
75
+ row.classList.toggle('stt-progress-pending', !!pending);
76
+ var parent = own ? toolbar : composer;
77
+ if (row.parentElement !== parent || row !== parent.firstElementChild) parent.prepend(row);
78
+ meter.update(own ? active : null);
79
+ picker.hidden = own;
80
+ button.disabled = input.disabled || input.readOnly || !store.get('speechSettings');
81
+ picker.disabled = input.disabled || input.readOnly || own;
82
+ button.classList.toggle('stt-active', own);
83
+ button.setAttribute('aria-pressed', String(own));
84
+ var action = own ? (pending ? 'Cancel voice input' : (model.mode === 'recorded' ? 'Stop and transcribe' : 'Stop voice input')) : 'Start voice input';
85
+ button.setAttribute('aria-label', action + ' · ' + (provider ? provider.name + ' · ' : '') + model.name);
86
+ button.title = action + ' · ' + model.name;
87
+ var signature = [own, pending, provider && provider.id].join(':');
88
+ if (button.dataset.speechIcon !== signature) {
89
+ button.dataset.speechIcon = signature;
90
+ button.innerHTML = iconHtml(own ? (pending ? 'x' : 'square') : 'mic');
91
+ if (provider && !own) {
92
+ var badge = document.createElement('img'); badge.className = 'stt-vendor-badge'; badge.src = provider.icon; badge.alt = ''; button.appendChild(badge);
204
93
  }
94
+ refreshIcons();
205
95
  }
206
- };
207
-
208
- try {
209
- recognition.start();
210
- recording = true;
211
- sttBtn.classList.add('stt-active');
212
- sttBtn.innerHTML =
213
- '<span class="stt-wave">' +
214
- '<span class="stt-wave-bar"></span>' +
215
- '<span class="stt-wave-bar"></span>' +
216
- '<span class="stt-wave-bar"></span>' +
217
- '<span class="stt-wave-bar"></span>' +
218
- '<span class="stt-wave-bar"></span>' +
219
- '</span>' +
220
- '<span class="stt-stop-label">Stop</span>';
221
- ctx.inputEl.setAttribute('placeholder', 'Listening...');
222
- } catch (err) {
223
- console.error('[STT] Failed to start:', err);
224
- if (ctx.addSystemMessage) {
225
- ctx.addSystemMessage('Failed to start voice input: ' + err.message, true);
226
- }
96
+ var message = (store.get('speechStatuses') || {})[id] || '';
97
+ var failure = (store.get('speechErrors') || {})[id];
98
+ row.classList.toggle('stt-progress-error', !!failure);
99
+ status.setAttribute('role', failure ? 'alert' : 'status');
100
+ dismiss.hidden = !failure;
101
+ account.hidden = !failure || !provider;
102
+ if (provider) account.href = provider.url;
103
+ if (model.provider === 'browser' && !speechConstructor()) message = 'Browser voice input is unavailable. Choose a connected speech provider or type your message.';
104
+ if (!window.isSecureContext) message = 'Voice input requires a secure HTTPS connection.';
105
+ status.textContent = message;
106
+ timer.textContent = own && active.startTime ? Math.floor((Date.now() - active.startTime) / 60000) + ':' + String(Math.floor((Date.now() - active.startTime) / 1000) % 60).padStart(2, '0') : '';
107
+ cancel.hidden = !own;
108
+ var retryState = store.get('speechRetry'); retry.hidden = !retryState || retryState.id !== id;
109
+ row.hidden = !own && !failure && retry.hidden;
227
110
  }
111
+ store.subscribe(render);
112
+ store.subscribe(function (state, previous) {
113
+ var failure = (state.speechErrors || {})[id];
114
+ if (failure && failure !== (previous.speechErrors || {})[id]) showToast(failure.message, 'warn');
115
+ });
116
+ var observer = new MutationObserver(render);
117
+ observer.observe(input, { attributes: true, attributeFilter: ['disabled', 'readonly'] });
118
+ render();
228
119
  }
229
-
230
- function stopRecording() {
231
- if (!recording) return;
232
- recording = false;
233
-
234
- if (recognition) {
235
- try { recognition.stop(); } catch (e) { /* ignore */ }
236
- recognition = null;
237
- }
238
-
239
- sttBtn.classList.remove('stt-active');
240
- sttBtn.innerHTML = iconHtml('mic');
241
- refreshIcons();
242
- if (document.body.classList.contains("mate-dm-active") && document.body.dataset.mateName) {
243
- ctx.inputEl.setAttribute('placeholder', 'Message ' + document.body.dataset.mateName + '...');
244
- } else {
245
- var _v = store.get('currentVendor') || "claude";
246
- ctx.inputEl.setAttribute('placeholder', 'Message ' + (VENDOR_NAMES[_v] || VENDOR_NAMES.claude) + '...');
247
- }
248
- }
249
-
250
- // --- External lang setter (used by profile module) ---
251
- export function setSTTLang(code) {
252
- selectedLang = code;
253
- }
254
-
255
- export function getSTTLang() {
256
- return selectedLang;
257
- }
258
-
259
- // --- Exports ---
260
- export function isSTTRecording() {
261
- return recording;
262
- }
263
-
264
- export function isSTTInitializing() {
265
- return false;
120
+ export function initSTT() {
121
+ initSpeechLifecycle(); initSpeechPicker();
122
+ bindComposer('stt-btn', 'input', 'input-wrapper');
123
+ bindComposer('home-stt-btn', 'home-mate-chat-input', 'home-mate-chat-composer-frame');
124
+ loadSpeechSettings();
125
+ window.addEventListener('focus', loadSpeechSettings);
126
+ try { window.top.document.addEventListener('clay:speech-settings-saved', loadSpeechSettings); } catch (error) { /* Cross-origin host. */ }
266
127
  }
@@ -6,7 +6,7 @@ import { showToast } from './utils.js';
6
6
  import { getChatLayout, mountThemePicker, setChatLayout } from './theme.js';
7
7
  import { applyTerminalFont } from './terminal-prefs.js';
8
8
  import { showEmailSetupModal, getEmailAccountListCache } from './context-sources.js';
9
- import { setSTTLang } from './stt.js';
9
+ import { saveSpeechLanguage, speechLanguages } from './stt-settings.js';
10
10
  import { userAvatarUrl } from './avatar.js';
11
11
  import { store } from './store.js';
12
12
  import { getWs } from './ws-ref.js';
@@ -27,6 +27,11 @@ var currentProfile = null;
27
27
 
28
28
 
29
29
  export function initUserSettings(appCtx) {
30
+ window.addEventListener('clay:speech-language-saved', function (event) {
31
+ var select = document.getElementById('us-lang-select');
32
+ if (select) select.value = event.detail;
33
+ });
34
+
30
35
  ctx = appCtx;
31
36
  settingsEl = document.getElementById('user-settings');
32
37
  openBtn = document.getElementById('user-settings-btn');
@@ -153,8 +158,15 @@ export function initUserSettings(appCtx) {
153
158
 
154
159
  var langSelect = document.getElementById('us-lang-select');
155
160
  if (langSelect) {
161
+ langSelect.replaceChildren();
162
+ speechLanguages.forEach(function (language) {
163
+ var option = document.createElement('option'); option.value = language[0]; option.textContent = language[1]; langSelect.appendChild(option);
164
+ });
156
165
  langSelect.addEventListener('change', function () {
157
- saveProfileChange({ lang: this.value });
166
+ var select = this; select.disabled = true;
167
+ saveSpeechLanguage(select.value).catch(function () {
168
+ select.value = store.get('speechLang') || 'en-US'; showToast('Could not save voice language');
169
+ }).finally(function () { select.disabled = false; });
158
170
  });
159
171
  }
160
172
 
@@ -422,10 +434,7 @@ function populateAccount() {
422
434
  pinFormTitle.textContent = pinEnabled ? 'Change PIN' : 'Set PIN';
423
435
  }
424
436
  if (langSelect) {
425
- langSelect.value = data.lang || 'en-US';
426
- }
427
- if (data.lang) {
428
- setSTTLang(data.lang);
437
+ langSelect.value = store.get('speechLang') || 'en-US';
429
438
  }
430
439
  if (avatarImg && avatarFallback) {
431
440
  var avatarUrl = getCurrentUserAvatarSrc(data);
@@ -500,9 +509,6 @@ function saveProfileChange(updates) {
500
509
  }
501
510
  }
502
511
  currentProfile = nextProfile;
503
- if (updates.lang) {
504
- setSTTLang(updates.lang);
505
- }
506
512
  payload.name = nextProfile.name;
507
513
  payload.lang = nextProfile.lang;
508
514
  payload.avatarColor = nextProfile.avatarColor;
@@ -49,6 +49,7 @@ var LOCKED_CONTROL_IDS = [
49
49
  "input-more-btn",
50
50
  "shell-command-btn",
51
51
  "stt-btn",
52
+ "stt-btn-picker",
52
53
  "schedule-btn",
53
54
  "ask-mate-btn",
54
55
  "context-sources-add",
@@ -0,0 +1,166 @@
1
+ var WebSocketServer = require('ws').WebSocketServer;
2
+ var catalog = require('./speech-catalog');
3
+ var attachSpeechStore = require('./speech-store').attachSpeechStore;
4
+ var transcribe = require('./speech-transcribe').transcribe;
5
+ var openSpeechStream = require('./speech-stream').openSpeechStream;
6
+ var MAX_AUDIO = 10 * 1024 * 1024;
7
+
8
+ function attachSpeech(ctx) {
9
+ var storage = attachSpeechStore(ctx.CONFIG_DIR);
10
+ var sockets = new WebSocketServer({ noServer: true, maxPayload: 65536 });
11
+ var busy = new Map();
12
+ var destroyed = false;
13
+ function owner(req) {
14
+ if (!ctx.isRequestAuthed(req)) return null;
15
+ if (!ctx.users.isMultiUser()) return 'default';
16
+ var user = ctx.getMultiUserFromReq(req);
17
+ return user && user.id;
18
+ }
19
+ function sameOrigin(req) {
20
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false;
21
+ if (!req.headers.origin) return true;
22
+ try { return new URL(req.headers.origin).host === req.headers.host; } catch (error) { return false; }
23
+ }
24
+ function json(res, status, body) {
25
+ if (res.destroyed || res.writableEnded) return;
26
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body));
27
+ }
28
+ function collect(req, limit, signal) {
29
+ return new Promise(function (resolve, reject) {
30
+ var chunks = []; var size = 0;
31
+ function abort() { chunks = []; reject(new Error('Request cancelled')); }
32
+ if (signal) {
33
+ if (signal.aborted) { abort(); return; }
34
+ signal.addEventListener('abort', abort, { once: true });
35
+ req.once('end', function () { signal.removeEventListener('abort', abort); });
36
+ }
37
+ req.on('data', function (chunk) { size += chunk.length; if (size <= limit) chunks.push(chunk); else { chunks = []; reject(new Error('Request is too large')); } });
38
+ req.on('end', function () { if (size <= limit) resolve(Buffer.concat(chunks)); });
39
+ req.on('error', reject); req.on('aborted', function () { reject(new Error('Request cancelled')); });
40
+ });
41
+ }
42
+ function language(value) {
43
+ if (typeof value !== 'string' || !/^[a-z]{2,3}(?:-[a-zA-Z]{2,8})?$/.test(value)) throw new Error('Choose a valid speech language');
44
+ return value;
45
+ }
46
+ function selection(id, mode, userId) {
47
+ var model = catalog.findModel(id);
48
+ if (!model || model.mode !== mode || model.provider === 'browser') throw new Error('Invalid transcription model');
49
+ var key = storage.key(userId, model.provider);
50
+ if (!key) throw new Error('Connect this provider in Voice input settings first');
51
+ return { model: model, key: key };
52
+ }
53
+ function handleRequest(req, res, fullUrl) {
54
+ if (fullUrl !== '/api/speech/settings' && fullUrl !== '/api/speech/transcribe') return false;
55
+ var userId = owner(req);
56
+ if (!userId) { json(res, 401, { error: 'Sign in to use voice input' }); return true; }
57
+ if (!sameOrigin(req)) { json(res, 403, { error: 'Invalid request origin' }); return true; }
58
+ if (fullUrl === '/api/speech/settings') {
59
+ if (req.method === 'GET') {
60
+ try { json(res, 200, storage.view(userId)); } catch (error) { json(res, 500, { error: 'Voice settings could not be loaded' }); }
61
+ } else if (req.method === 'PUT') {
62
+ collect(req, 8192).then(function (bytes) {
63
+ if (owner(req) !== userId) { json(res, 401, { error: 'Please sign in again' }); return; }
64
+ var data = JSON.parse(bytes.toString());
65
+ if (!data || typeof data !== 'object') throw new Error('Invalid settings');
66
+ var value = storage.update(userId, data);
67
+ if (busy.has(userId)) busy.get(userId)();
68
+ json(res, 200, value);
69
+ }).catch(function () { json(res, 400, { error: 'Could not save voice settings. Check the model and API key.' }); });
70
+ } else json(res, 405, { error: 'Method not allowed' });
71
+ return true;
72
+ }
73
+ if (req.method !== 'POST') { json(res, 405, { error: 'Method not allowed' }); return true; }
74
+ if (busy.has(userId)) { json(res, 409, { error: 'Another voice request is already active' }); return true; }
75
+ var controller = new AbortController();
76
+ function cancel() { controller.abort(); }
77
+ busy.set(userId, cancel);
78
+ var timer = setTimeout(cancel, 120000);
79
+ res.on('close', cancel);
80
+ (async function () {
81
+ try {
82
+ var selected = selection(req.headers['x-speech-model'], 'recorded', userId);
83
+ var lang = language(req.headers['x-speech-language']);
84
+ var bytes = await collect(req, MAX_AUDIO, controller.signal);
85
+ if (owner(req) !== userId) throw new Error('Please sign in again');
86
+ if (bytes.length < 44 || bytes.toString('ascii', 0, 4) !== 'RIFF' || bytes.toString('ascii', 8, 12) !== 'WAVE') throw new Error('Invalid voice recording');
87
+ var text = await transcribe(selected.model, selected.key, bytes, lang, controller.signal);
88
+ if (typeof text !== 'string') throw new Error('No transcript returned');
89
+ json(res, 200, { text: text });
90
+ } catch (error) {
91
+ json(res, error.status || 400, { error: controller.signal.aborted ? 'Transcription cancelled or timed out. Please try again.' :
92
+ (error.status ? error.message : 'Could not transcribe this recording. Check your model, key and connection.') });
93
+ } finally { clearTimeout(timer); res.removeListener('close', cancel); if (busy.get(userId) === cancel) busy.delete(userId); }
94
+ })();
95
+ return true;
96
+ }
97
+ function handleUpgrade(req, socket, head) {
98
+ if (req.url.split('?')[0] !== '/api/speech/live') return false;
99
+ var userId = owner(req);
100
+ if (!userId || !sameOrigin(req) || busy.has(userId)) {
101
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); socket.destroy(); return true;
102
+ }
103
+ sockets.handleUpgrade(req, socket, head, function (ws) {
104
+ var upstream; var ready = false; var finishing = false; var total = 0; var ended = false;
105
+ function send(message) { if (ws.readyState === 1) ws.send(JSON.stringify(message)); }
106
+ function diagnostic(stage, values) { send(Object.assign({ type: 'diagnostic', stage: stage }, values || {})); }
107
+ diagnostic('clay-websocket-accepted');
108
+ function close() {
109
+ if (ended) return; ended = true;
110
+ clearTimeout(timer); clearInterval(authTimer);
111
+ if (upstream) upstream.abort();
112
+ if (busy.get(userId) === cancel) busy.delete(userId);
113
+ ws.close();
114
+ }
115
+ function cancel() { send({ type: 'error', error: 'Voice input cancelled. Your draft has been preserved.' }); close(); }
116
+ function fail(error) { diagnostic('clay-session-failed'); send({ type: 'error', error: error.message }); close(); }
117
+ busy.set(userId, cancel);
118
+ var timer = setTimeout(function () { fail(new Error('Voice input timed out')); }, 15000);
119
+ var authTimer = setInterval(function () { if (owner(req) !== userId) cancel(); }, 5000);
120
+ ws.on('message', function (raw, binary) {
121
+ if (ended) return;
122
+ try {
123
+ if (binary) {
124
+ if (!ready || finishing) throw new Error('Microphone session is not ready');
125
+ total += raw.length;
126
+ if (total === raw.length) diagnostic('clay-first-audio', { bytes: raw.length });
127
+ if (total > MAX_AUDIO || raw.length % 2) throw new Error('Recording limit reached');
128
+ upstream.audio(raw); return;
129
+ }
130
+ var message = JSON.parse(raw.toString());
131
+ if (message.type === 'start' && !upstream) {
132
+ diagnostic('clay-selecting-provider');
133
+ var selected = selection(message.model, 'live', userId);
134
+ var lang = language(message.language);
135
+ diagnostic('clay-provider-connecting');
136
+ upstream = openSpeechStream(selected.model, selected.key, lang, {
137
+ diagnostic: diagnostic,
138
+ ready: function () {
139
+ diagnostic('provider-ready');
140
+ ready = true; clearTimeout(timer);
141
+ timer = setTimeout(function () { fail(new Error('Recording limit reached. Stop within five minutes.')); }, 310000);
142
+ send({ type: 'ready' });
143
+ },
144
+ text: function (text) { send({ type: 'text', text: text }); },
145
+ done: function (text) { send({ type: 'done', text: text }); close(); },
146
+ error: fail
147
+ });
148
+ } else if (message.type === 'finish' && ready && !finishing) { finishing = true; upstream.finish(); }
149
+ else if (message.type === 'cancel') close();
150
+ else throw new Error('Invalid voice message');
151
+ } catch (error) { fail(new Error('Could not start or continue voice input. Check your key and connection.')); }
152
+ });
153
+ ws.on('close', close); ws.on('error', close);
154
+ });
155
+ return true;
156
+ }
157
+ function destroy() {
158
+ if (destroyed) return;
159
+ destroyed = true;
160
+ busy.forEach(function (cancel) { cancel(); });
161
+ sockets.clients.forEach(function (socket) { socket.terminate(); });
162
+ sockets.close();
163
+ }
164
+ return { handleRequest: handleRequest, handleUpgrade: handleUpgrade, destroy: destroy };
165
+ }
166
+ module.exports = { attachSpeech: attachSpeech };
package/lib/server.js CHANGED
@@ -23,6 +23,7 @@ var serverCursorSharing = require("./server-cursor-sharing");
23
23
  var serverTools = require("./server-tools");
24
24
  var serverAdmin = require("./server-admin");
25
25
  var serverSettings = require("./server-settings");
26
+ var serverSpeech = require("./server-speech");
26
27
  var serverPalette = require("./server-palette");
27
28
  var workspaceQueryModule = require("./workspace-query-service");
28
29
  var { attachWorkspaceQueryService } = workspaceQueryModule;
@@ -439,6 +440,9 @@ function createServer(opts) {
439
440
  getMultiUserFromReq: getMultiUserFromReq,
440
441
  });
441
442
 
443
+ var speech = serverSpeech.attachSpeech({ users: users, getMultiUserFromReq: getMultiUserFromReq,
444
+ isRequestAuthed: isRequestAuthed, CONFIG_DIR: CONFIG_DIR });
445
+
442
446
  var settings = serverSettings.attachSettings({
443
447
  users: users,
444
448
  mates: mates,
@@ -775,6 +779,7 @@ function createServer(opts) {
775
779
  return;
776
780
  }
777
781
 
782
+ if (speech.handleRequest(req, res, fullUrl)) return;
778
783
  if (settings.handleRequest(req, res, fullUrl)) return;
779
784
 
780
785
  // --- Admin API endpoints (multi-user mode only) ---
@@ -1104,7 +1109,9 @@ function createServer(opts) {
1104
1109
  },
1105
1110
  });
1106
1111
 
1112
+ server.once("close", function () { speech.destroy(); });
1107
1113
  server.on("upgrade", function (req, socket, head) {
1114
+ if (speech.handleUpgrade(req, socket, head)) return;
1108
1115
  var wsRequest = parseWsRequestUrl(req.url);
1109
1116
  var wsPathname = wsRequest.path;
1110
1117
  // Origin validation (CSRF prevention)
@@ -1904,6 +1911,7 @@ function createServer(opts) {
1904
1911
  });
1905
1912
 
1906
1913
  function destroyAll() {
1914
+ speech.destroy();
1907
1915
  var shutdowns = [];
1908
1916
  shutdowns.push(durableSchedulerLifecycle.shutdown());
1909
1917
  projects.forEach(function (ctx, slug) {