clay-server 4.3.1-beta.5 → 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.
- package/lib/public/app.js +13 -5
- package/lib/public/css/home-hub.css +1 -0
- package/lib/public/css/messages.css +1 -2
- package/lib/public/css/stt.css +106 -155
- package/lib/public/icons/speech/SOURCES.txt +5 -0
- package/lib/public/icons/speech/deepgram.svg +1 -0
- package/lib/public/icons/speech/elevenlabs.svg +1 -0
- package/lib/public/icons/speech/groq.svg +1 -0
- package/lib/public/icons/speech/soniox.png +0 -0
- package/lib/public/index.html +3 -2
- package/lib/public/modules/home-mate-chat.js +8 -0
- package/lib/public/modules/input.js +2 -0
- package/lib/public/modules/profile.js +0 -4
- package/lib/public/modules/stt-audio-worklet.js +32 -0
- package/lib/public/modules/stt-audio.js +68 -0
- package/lib/public/modules/stt-controller.js +202 -0
- package/lib/public/modules/stt-debug.js +7 -0
- package/lib/public/modules/stt-meter.js +55 -0
- package/lib/public/modules/stt-picker.js +123 -0
- package/lib/public/modules/stt-recording.js +130 -0
- package/lib/public/modules/stt-settings.js +50 -0
- package/lib/public/modules/stt-signal.js +57 -0
- package/lib/public/modules/stt.js +113 -252
- package/lib/public/modules/user-settings.js +15 -9
- package/lib/public/modules/worker-pane-lock.js +1 -0
- package/lib/server-speech.js +166 -0
- package/lib/server.js +8 -0
- package/lib/speech-catalog.js +25 -0
- package/lib/speech-openai-stream.js +102 -0
- package/lib/speech-store.js +71 -0
- package/lib/speech-stream.js +108 -0
- package/lib/speech-transcribe.js +63 -0
- package/package.json +1 -1
|
@@ -1,266 +1,127 @@
|
|
|
1
|
-
//
|
|
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 {
|
|
3
|
+
import { showToast } from './utils.js';
|
|
6
4
|
import { store } from './store.js';
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
export function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (!
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 (!
|
|
61
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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;
|
|
@@ -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) {
|