aicodeman 1.16.3 → 1.16.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -9
- package/dist/claude-credentials.d.ts +50 -0
- package/dist/claude-credentials.d.ts.map +1 -0
- package/dist/claude-credentials.js +102 -0
- package/dist/claude-credentials.js.map +1 -0
- package/dist/config/voice.d.ts +43 -0
- package/dist/config/voice.d.ts.map +1 -0
- package/dist/config/voice.js +48 -0
- package/dist/config/voice.js.map +1 -0
- package/dist/web/ports/config-port.d.ts +2 -0
- package/dist/web/ports/config-port.d.ts.map +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.f9c6aadd.js.gz +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/constants.53dc0fa1.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/home-sessions.js +15 -4
- package/dist/web/public/home-sessions.js.br +0 -0
- package/dist/web/public/home-sessions.js.gz +0 -0
- package/dist/web/public/i18n.89097ff5.js +1 -0
- package/dist/web/public/i18n.89097ff5.js.br +0 -0
- package/dist/web/public/i18n.89097ff5.js.gz +0 -0
- package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
- package/dist/web/public/index.html +657 -402
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.0f524637.js.gz +0 -0
- package/dist/web/public/mobile-handlers.32fdd57f.js.gz +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.4f875962.css +1 -0
- package/dist/web/public/mobile.4f875962.css.br +0 -0
- package/dist/web/public/mobile.4f875962.css.gz +0 -0
- package/dist/web/public/notification-manager.5d229063.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/panels-ui.54b0d004.js.gz +0 -0
- package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
- package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
- package/dist/web/public/readmymind-ui.js +20 -4
- package/dist/web/public/readmymind-ui.js.br +0 -0
- package/dist/web/public/readmymind-ui.js.gz +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
- package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
- package/dist/web/public/{session-ui.f4fed187.js → session-ui.46a0993c.js} +5 -5
- package/dist/web/public/session-ui.46a0993c.js.br +0 -0
- package/dist/web/public/session-ui.46a0993c.js.gz +0 -0
- package/dist/web/public/settings-ui.63be4178.js +67 -0
- package/dist/web/public/settings-ui.63be4178.js.br +0 -0
- package/dist/web/public/settings-ui.63be4178.js.gz +0 -0
- package/dist/web/public/styles.9e04378d.css +1 -0
- package/dist/web/public/styles.9e04378d.css.br +0 -0
- package/dist/web/public/styles.9e04378d.css.gz +0 -0
- package/dist/web/public/subagent-windows.4cc8b005.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/terminal-ui.ae776405.js +4 -0
- package/dist/web/public/terminal-ui.ae776405.js.br +0 -0
- package/dist/web/public/terminal-ui.ae776405.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.6cec0fba.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/{voice-input.e5461859.js → voice-input.4ec670b0.js} +421 -13
- package/dist/web/public/voice-input.4ec670b0.js.br +0 -0
- package/dist/web/public/voice-input.4ec670b0.js.gz +0 -0
- package/dist/web/public/voice-pcm-worklet.js +57 -0
- package/dist/web/public/voice-pcm-worklet.js.br +0 -0
- package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/routes/index.d.ts +1 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +1 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/voice-routes.d.ts +45 -0
- package/dist/web/routes/voice-routes.d.ts.map +1 -0
- package/dist/web/routes/voice-routes.js +172 -0
- package/dist/web/routes/voice-routes.js.map +1 -0
- package/dist/web/schemas.d.ts +2 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +12 -0
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +1 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +11 -1
- package/dist/web/server.js.map +1 -1
- package/dist/web/voice-stream.d.ts +96 -0
- package/dist/web/voice-stream.d.ts.map +1 -0
- package/dist/web/voice-stream.js +268 -0
- package/dist/web/voice-stream.js.map +1 -0
- package/package.json +1 -1
- package/dist/web/public/i18n.1a8b3412.js +0 -1
- package/dist/web/public/i18n.1a8b3412.js.br +0 -0
- package/dist/web/public/i18n.1a8b3412.js.gz +0 -0
- package/dist/web/public/mobile.52b30b35.css +0 -1
- package/dist/web/public/mobile.52b30b35.css.br +0 -0
- package/dist/web/public/mobile.52b30b35.css.gz +0 -0
- package/dist/web/public/session-ui.f4fed187.js.br +0 -0
- package/dist/web/public/session-ui.f4fed187.js.gz +0 -0
- package/dist/web/public/settings-ui.c674f367.js +0 -67
- package/dist/web/public/settings-ui.c674f367.js.br +0 -0
- package/dist/web/public/settings-ui.c674f367.js.gz +0 -0
- package/dist/web/public/styles.2eb23e0f.css +0 -1
- package/dist/web/public/styles.2eb23e0f.css.br +0 -0
- package/dist/web/public/styles.2eb23e0f.css.gz +0 -0
- package/dist/web/public/terminal-ui.61c7829d.js +0 -4
- package/dist/web/public/terminal-ui.61c7829d.js.br +0 -0
- package/dist/web/public/terminal-ui.61c7829d.js.gz +0 -0
- package/dist/web/public/voice-input.e5461859.js.br +0 -0
- package/dist/web/public/voice-input.e5461859.js.gz +0 -0
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Voice input with
|
|
2
|
+
* @fileoverview Voice input with three providers: Claude (this server's Claude Code
|
|
3
|
+
* login), Deepgram Nova-3, and the Web Speech API.
|
|
3
4
|
*
|
|
4
|
-
* Defines
|
|
5
|
+
* Defines three singleton objects:
|
|
6
|
+
*
|
|
7
|
+
* - ClaudeVoiceProvider — Dictation through Codeman's own `/ws/voice/stream`, which
|
|
8
|
+
* relays to the speech-to-text service Claude Code's `/voice` mode uses. No API key:
|
|
9
|
+
* the server holds the OAuth token, the browser only sends PCM16 @16 kHz (AudioWorklet,
|
|
10
|
+
* since MediaRecorder cannot emit raw PCM) and receives text. See docs/claude-voice-plan.md.
|
|
5
11
|
*
|
|
6
12
|
* - DeepgramProvider — Direct browser-to-Deepgram WebSocket connection for speech-to-text.
|
|
7
13
|
* Captures audio via MediaRecorder, streams chunks every 250ms, handles KeepAlive pings,
|
|
@@ -14,6 +20,7 @@
|
|
|
14
20
|
* Includes a temporary green Send button that replaces the settings gear icon after voice input.
|
|
15
21
|
* Web Speech API has auto-retry (up to 2x) for premature onend and iOS Safari stability check.
|
|
16
22
|
*
|
|
23
|
+
* @globals {object} ClaudeVoiceProvider
|
|
17
24
|
* @globals {object} DeepgramProvider
|
|
18
25
|
* @globals {object} VoiceInput
|
|
19
26
|
*
|
|
@@ -22,9 +29,13 @@
|
|
|
22
29
|
* @loadorder 3 of 15 — loaded after mobile-handlers.js, before notification-manager.js
|
|
23
30
|
*/
|
|
24
31
|
|
|
25
|
-
// Codeman — Voice input with Deepgram Nova-3 and Web Speech API
|
|
32
|
+
// Codeman — Voice input with Claude, Deepgram Nova-3 and Web Speech API
|
|
26
33
|
// Loaded after mobile-handlers.js, before app.js
|
|
27
34
|
|
|
35
|
+
/** Dev vocabulary sent to the recognizer as a hint. Shared by every provider and the settings form. */
|
|
36
|
+
const DEFAULT_VOICE_KEYTERMS =
|
|
37
|
+
'refactor, endpoint, middleware, callback, async, regex, TypeScript, npm, API, deploy, config, linter, env, webhook, schema, CLI, JSON, CSS, DOM, SSE, backend, frontend, localhost, dependencies, repository, merge, rebase, diff, commit, com';
|
|
38
|
+
|
|
28
39
|
// ═══════════════════════════════════════════════════════════════
|
|
29
40
|
// Voice Input (Deepgram Nova-3 + Web Speech API fallback)
|
|
30
41
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -245,7 +256,282 @@ const DeepgramProvider = {
|
|
|
245
256
|
};
|
|
246
257
|
|
|
247
258
|
/**
|
|
248
|
-
*
|
|
259
|
+
* ClaudeVoiceProvider - Speech-to-text through this Codeman server's Claude Code
|
|
260
|
+
* login, i.e. the same service the CLI's own `/voice` mode uses. No API key.
|
|
261
|
+
*
|
|
262
|
+
* Audio goes browser -> Codeman -> Anthropic: the OAuth token never leaves the
|
|
263
|
+
* server, so the browser only ever sends PCM and receives text
|
|
264
|
+
* (docs/claude-voice-plan.md).
|
|
265
|
+
*
|
|
266
|
+
* ⚠️ The upstream endpoint is opened as linear16 / 16 kHz / mono, so capture MUST
|
|
267
|
+
* be raw PCM at that rate. MediaRecorder cannot emit raw PCM (container formats
|
|
268
|
+
* only), which is why this path uses an AudioWorklet rather than reusing
|
|
269
|
+
* DeepgramProvider's recorder. The AudioContext is constructed at 16000 Hz so the
|
|
270
|
+
* browser does the resampling.
|
|
271
|
+
*
|
|
272
|
+
* ⚠️ Transcript frames carry the WHOLE running transcript, not deltas. Callers
|
|
273
|
+
* must replace, never concatenate.
|
|
274
|
+
*/
|
|
275
|
+
const ClaudeVoiceProvider = {
|
|
276
|
+
_ws: null,
|
|
277
|
+
_stream: null,
|
|
278
|
+
_audioContext: null,
|
|
279
|
+
_workletNode: null,
|
|
280
|
+
_sourceNode: null,
|
|
281
|
+
_scriptNode: null,
|
|
282
|
+
_silenceTimeout: null,
|
|
283
|
+
_onResult: null,
|
|
284
|
+
_onError: null,
|
|
285
|
+
_onEnd: null,
|
|
286
|
+
_finalized: false,
|
|
287
|
+
|
|
288
|
+
/** How long without any transcript before the recording gives up on its own. */
|
|
289
|
+
SILENCE_MS: 6000,
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Start streaming.
|
|
293
|
+
* @param {object} opts - { language, keyterms[], onResult(text, isFinal), onError(msg), onEnd(), onStream(stream) }
|
|
294
|
+
*/
|
|
295
|
+
async start(opts) {
|
|
296
|
+
this._onResult = opts.onResult;
|
|
297
|
+
this._onError = opts.onError;
|
|
298
|
+
this._onEnd = opts.onEnd;
|
|
299
|
+
this._finalized = false;
|
|
300
|
+
|
|
301
|
+
if (!navigator.mediaDevices?.getUserMedia) {
|
|
302
|
+
this._onError?.('Microphone requires a secure context (HTTPS). Use --https flag or access via localhost.');
|
|
303
|
+
this._cleanup();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
this._stream = await navigator.mediaDevices.getUserMedia({
|
|
308
|
+
audio: { noiseSuppression: true, echoCancellation: true, autoGainControl: true }
|
|
309
|
+
});
|
|
310
|
+
} catch (err) {
|
|
311
|
+
const msg = err.name === 'NotAllowedError'
|
|
312
|
+
? 'Microphone access denied. Check browser settings.'
|
|
313
|
+
: 'Microphone error: ' + err.message;
|
|
314
|
+
this._onError?.(msg);
|
|
315
|
+
this._cleanup();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
opts.onStream?.(this._stream);
|
|
319
|
+
|
|
320
|
+
const params = new URLSearchParams();
|
|
321
|
+
if (opts.language) params.set('language', opts.language);
|
|
322
|
+
if (opts.keyterms?.length) params.set('keyterms', opts.keyterms.join(','));
|
|
323
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
324
|
+
try {
|
|
325
|
+
this._ws = new WebSocket(`${proto}//${location.host}/ws/voice/stream?${params}`);
|
|
326
|
+
} catch (err) {
|
|
327
|
+
this._onError?.('Failed to open voice stream: ' + err.message);
|
|
328
|
+
this._cleanup();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
this._ws.binaryType = 'arraybuffer';
|
|
332
|
+
|
|
333
|
+
this._ws.onopen = () => {
|
|
334
|
+
// Capture starts only once the socket is up: PCM buffered before that would
|
|
335
|
+
// be the oldest audio, and dropping it keeps the transcript aligned with what
|
|
336
|
+
// the user hears themselves saying.
|
|
337
|
+
this._startCapture().catch((err) => {
|
|
338
|
+
this._onError?.('Microphone capture failed: ' + err.message);
|
|
339
|
+
this.stop();
|
|
340
|
+
});
|
|
341
|
+
this._resetSilenceTimeout();
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
this._ws.onmessage = (event) => {
|
|
345
|
+
let msg;
|
|
346
|
+
try {
|
|
347
|
+
msg = JSON.parse(event.data);
|
|
348
|
+
} catch (_e) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (msg.t === 'transcript' && msg.text) {
|
|
352
|
+
this._resetSilenceTimeout();
|
|
353
|
+
this._onResult?.(msg.text, msg.final === true);
|
|
354
|
+
} else if (msg.t === 'error') {
|
|
355
|
+
this._onError?.(msg.message || 'Voice transcription failed');
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
this._ws.onerror = () => {
|
|
360
|
+
// onclose carries the actionable detail (close code); nothing useful here.
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
this._ws.onclose = (event) => {
|
|
364
|
+
if (event.code === 4004) {
|
|
365
|
+
this._onError?.(this._unavailableMessage(event.reason));
|
|
366
|
+
} else if (event.code === 4008) {
|
|
367
|
+
this._onError?.('Too many voice streams are already running on this server.');
|
|
368
|
+
} else if (event.code === 4003) {
|
|
369
|
+
this._onError?.('Voice stream refused (origin not allowed).');
|
|
370
|
+
} else if (event.code !== 1000 && !this._finalized) {
|
|
371
|
+
this._onError?.('Voice stream closed: ' + (event.reason || `code ${event.code}`));
|
|
372
|
+
}
|
|
373
|
+
this._stopCapture();
|
|
374
|
+
const onEnd = this._onEnd;
|
|
375
|
+
this._onEnd = null;
|
|
376
|
+
onEnd?.();
|
|
377
|
+
};
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
/** Map the server's close reason onto something a user can act on. */
|
|
381
|
+
_unavailableMessage(reason) {
|
|
382
|
+
if (reason === 'expired') return 'Claude login expired. Run a Claude session to refresh it, then try again.';
|
|
383
|
+
if (reason === 'disabled') return 'Claude voice is off. Enable it in Settings > Voice.';
|
|
384
|
+
return 'No Claude Code login found on the server. Sign in with `claude` there, or use Deepgram.';
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
/** Wire mic -> 16 kHz PCM16 frames -> WebSocket. */
|
|
388
|
+
async _startCapture() {
|
|
389
|
+
const Ctx = window.AudioContext || window.webkitAudioContext;
|
|
390
|
+
// Ask for 16 kHz directly so the browser resamples; Safari may hand back its
|
|
391
|
+
// own rate, which _pcmFromFloat32 then downsamples to match.
|
|
392
|
+
this._audioContext = new Ctx({ sampleRate: 16000 });
|
|
393
|
+
if (this._audioContext.state === 'suspended') await this._audioContext.resume();
|
|
394
|
+
this._sourceNode = this._audioContext.createMediaStreamSource(this._stream);
|
|
395
|
+
|
|
396
|
+
if (this._audioContext.audioWorklet) {
|
|
397
|
+
await this._audioContext.audioWorklet.addModule(this._workletUrl());
|
|
398
|
+
this._workletNode = new AudioWorkletNode(this._audioContext, 'pcm-frame-processor');
|
|
399
|
+
this._workletNode.port.onmessage = (event) => this._sendAudio(event.data);
|
|
400
|
+
this._sourceNode.connect(this._workletNode);
|
|
401
|
+
// A worklet with no destination is not pulled in some engines; a zero-gain
|
|
402
|
+
// sink keeps the graph running without echoing the mic to the speakers.
|
|
403
|
+
const sink = this._audioContext.createGain();
|
|
404
|
+
sink.gain.value = 0;
|
|
405
|
+
this._workletNode.connect(sink).connect(this._audioContext.destination);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Fallback for engines without AudioWorklet (older Safari): deprecated, but
|
|
410
|
+
// it is this or no dictation at all there.
|
|
411
|
+
this._scriptNode = this._audioContext.createScriptProcessor(4096, 1, 1);
|
|
412
|
+
this._scriptNode.onaudioprocess = (event) => {
|
|
413
|
+
this._sendAudio(this._pcmFromFloat32(event.inputBuffer.getChannelData(0), this._audioContext.sampleRate));
|
|
414
|
+
};
|
|
415
|
+
this._sourceNode.connect(this._scriptNode);
|
|
416
|
+
this._scriptNode.connect(this._audioContext.destination);
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Worklet URL carrying this page's cache-bust token.
|
|
421
|
+
*
|
|
422
|
+
* ⚠️ Static assets are served `immutable` for a year, and `cacheBustAssets`
|
|
423
|
+
* only rewrites `.js` refs in `<script>`/`<link>` tags — a URL built here in JS
|
|
424
|
+
* is invisible to it. So the token is borrowed from voice-input.js's own script
|
|
425
|
+
* tag, which the server DID rewrite. Consequence: **edit the worklet and this
|
|
426
|
+
* file together**, or the browser keeps serving the old worklet.
|
|
427
|
+
*/
|
|
428
|
+
_workletUrl() {
|
|
429
|
+
const src = document.querySelector('script[src*="voice-input.js"]')?.getAttribute('src') || '';
|
|
430
|
+
const q = src.indexOf('?');
|
|
431
|
+
return 'voice-pcm-worklet.js' + (q === -1 ? '' : src.slice(q));
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
/** Float32 [-1,1] at any rate -> Int16 PCM at 16 kHz (nearest-neighbour decimation). */
|
|
435
|
+
_pcmFromFloat32(input, sampleRate) {
|
|
436
|
+
const ratio = sampleRate / 16000;
|
|
437
|
+
const outLength = Math.floor(input.length / ratio);
|
|
438
|
+
const out = new Int16Array(outLength);
|
|
439
|
+
for (let i = 0; i < outLength; i++) {
|
|
440
|
+
const sample = Math.max(-1, Math.min(1, input[Math.floor(i * ratio)]));
|
|
441
|
+
out[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
|
442
|
+
}
|
|
443
|
+
return out.buffer;
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
_sendAudio(arrayBuffer) {
|
|
447
|
+
if (this._finalized) return;
|
|
448
|
+
if (this._ws?.readyState !== WebSocket.OPEN) return;
|
|
449
|
+
try {
|
|
450
|
+
this._ws.send(arrayBuffer);
|
|
451
|
+
} catch (_e) {
|
|
452
|
+
/* socket died mid-frame */
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
|
|
456
|
+
_resetSilenceTimeout() {
|
|
457
|
+
clearTimeout(this._silenceTimeout);
|
|
458
|
+
this._silenceTimeout = setTimeout(() => this.stop(), this.SILENCE_MS);
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Ask for the final transcript and let the server close the socket. Capture stops
|
|
463
|
+
* immediately, but the WebSocket stays open: the last (and usually best) transcript
|
|
464
|
+
* arrives AFTER the audio does, so closing here would throw away the utterance.
|
|
465
|
+
*/
|
|
466
|
+
stop() {
|
|
467
|
+
clearTimeout(this._silenceTimeout);
|
|
468
|
+
this._silenceTimeout = null;
|
|
469
|
+
if (this._finalized) return;
|
|
470
|
+
this._finalized = true;
|
|
471
|
+
this._stopCapture();
|
|
472
|
+
if (this._ws?.readyState === WebSocket.OPEN) {
|
|
473
|
+
try {
|
|
474
|
+
this._ws.send(JSON.stringify({ t: 'finalize' }));
|
|
475
|
+
} catch (_e) {
|
|
476
|
+
/* ignore */
|
|
477
|
+
}
|
|
478
|
+
} else {
|
|
479
|
+
const onEnd = this._onEnd;
|
|
480
|
+
this._onEnd = null;
|
|
481
|
+
onEnd?.();
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
|
|
485
|
+
/** Tear down the audio graph and release the mic. Idempotent. */
|
|
486
|
+
_stopCapture() {
|
|
487
|
+
if (this._workletNode) {
|
|
488
|
+
this._workletNode.port.onmessage = null;
|
|
489
|
+
try { this._workletNode.disconnect(); } catch (_e) { /* ignore */ }
|
|
490
|
+
this._workletNode = null;
|
|
491
|
+
}
|
|
492
|
+
if (this._scriptNode) {
|
|
493
|
+
this._scriptNode.onaudioprocess = null;
|
|
494
|
+
try { this._scriptNode.disconnect(); } catch (_e) { /* ignore */ }
|
|
495
|
+
this._scriptNode = null;
|
|
496
|
+
}
|
|
497
|
+
if (this._sourceNode) {
|
|
498
|
+
try { this._sourceNode.disconnect(); } catch (_e) { /* ignore */ }
|
|
499
|
+
this._sourceNode = null;
|
|
500
|
+
}
|
|
501
|
+
if (this._audioContext) {
|
|
502
|
+
try { this._audioContext.close(); } catch (_e) { /* ignore */ }
|
|
503
|
+
this._audioContext = null;
|
|
504
|
+
}
|
|
505
|
+
if (this._stream) {
|
|
506
|
+
this._stream.getTracks().forEach(t => t.stop());
|
|
507
|
+
this._stream = null;
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
|
|
511
|
+
/** Hard stop: drop the socket without waiting for a final transcript. */
|
|
512
|
+
_cleanup() {
|
|
513
|
+
this._finalized = true;
|
|
514
|
+
clearTimeout(this._silenceTimeout);
|
|
515
|
+
this._silenceTimeout = null;
|
|
516
|
+
this._stopCapture();
|
|
517
|
+
if (this._ws) {
|
|
518
|
+
this._ws.onclose = null;
|
|
519
|
+
this._ws.onmessage = null;
|
|
520
|
+
this._ws.onerror = null;
|
|
521
|
+
if (this._ws.readyState === WebSocket.OPEN) {
|
|
522
|
+
try { this._ws.close(1000); } catch (_e) { /* ignore */ }
|
|
523
|
+
}
|
|
524
|
+
this._ws = null;
|
|
525
|
+
}
|
|
526
|
+
this._onResult = null;
|
|
527
|
+
this._onError = null;
|
|
528
|
+
this._onEnd = null;
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* VoiceInput - Speech-to-text with Claude (this server's Claude Code login),
|
|
534
|
+
* Deepgram Nova-3, or the Web Speech API.
|
|
249
535
|
* Toggle mode: tap mic to start, tap again to stop. Auto-stops after silence.
|
|
250
536
|
* Shows interim transcription in a floating preview overlay.
|
|
251
537
|
* Inserts final text into the active session (user presses Enter to submit).
|
|
@@ -273,6 +559,29 @@ const VoiceInput = {
|
|
|
273
559
|
this._initRecognition();
|
|
274
560
|
// Always show buttons — if unsupported, toggle() shows a toast
|
|
275
561
|
this._showButtons();
|
|
562
|
+
// Probe the server's Claude voice availability in the background. `auto`
|
|
563
|
+
// resolution reads the cached answer, so the first mic press does not wait
|
|
564
|
+
// on a round trip; a miss just falls through to the next provider.
|
|
565
|
+
this.refreshClaudeStatus();
|
|
566
|
+
},
|
|
567
|
+
|
|
568
|
+
/** Last /api/voice/status answer, or null before the first probe resolves. */
|
|
569
|
+
_claudeStatus: null,
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Re-probe whether this server can transcribe with its Claude Code login.
|
|
573
|
+
* Called at init and whenever App Settings opens (the setting is server-side,
|
|
574
|
+
* so another device could have flipped it).
|
|
575
|
+
*/
|
|
576
|
+
async refreshClaudeStatus() {
|
|
577
|
+
try {
|
|
578
|
+
const res = await fetch('/api/voice/status');
|
|
579
|
+
const json = await res.json();
|
|
580
|
+
this._claudeStatus = json?.success ? json.data : { available: false, reason: 'disabled' };
|
|
581
|
+
} catch (_e) {
|
|
582
|
+
this._claudeStatus = { available: false, reason: 'disabled' };
|
|
583
|
+
}
|
|
584
|
+
return this._claudeStatus;
|
|
276
585
|
},
|
|
277
586
|
|
|
278
587
|
// --- Deepgram config (localStorage only, never sent to server) ---
|
|
@@ -294,11 +603,37 @@ const VoiceInput = {
|
|
|
294
603
|
return !!(cfg.apiKey && cfg.apiKey.trim());
|
|
295
604
|
},
|
|
296
605
|
|
|
606
|
+
_claudeAvailable() {
|
|
607
|
+
return this._claudeStatus?.available === true;
|
|
608
|
+
},
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Which provider a press of the mic would use.
|
|
612
|
+
*
|
|
613
|
+
* An explicit pick always wins, even when it cannot run — the resulting error
|
|
614
|
+
* ("Claude voice is off", "no Deepgram key") is more useful than silently
|
|
615
|
+
* transcribing somewhere the user did not choose. `auto` prefers Claude because
|
|
616
|
+
* it needs no key and no per-word billing, then the configured Deepgram key,
|
|
617
|
+
* then the browser's own engine.
|
|
618
|
+
*/
|
|
619
|
+
_resolveProvider() {
|
|
620
|
+
const pinned = this._getDeepgramConfig().provider;
|
|
621
|
+
if (pinned === 'claude' || pinned === 'deepgram' || pinned === 'webspeech') return pinned;
|
|
622
|
+
if (this._claudeAvailable()) return 'claude';
|
|
623
|
+
if (this._shouldUseDeepgram()) return 'deepgram';
|
|
624
|
+
return 'webspeech';
|
|
625
|
+
},
|
|
626
|
+
|
|
297
627
|
/** Get the active provider name for display */
|
|
298
628
|
getActiveProviderName() {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
629
|
+
switch (this._resolveProvider()) {
|
|
630
|
+
case 'claude':
|
|
631
|
+
return this._claudeAvailable() ? 'Claude (this server’s login)' : 'Claude (unavailable)';
|
|
632
|
+
case 'deepgram':
|
|
633
|
+
return this._shouldUseDeepgram() ? 'Deepgram Nova-3' : 'Deepgram (no API key)';
|
|
634
|
+
default:
|
|
635
|
+
return this.supported ? 'Web Speech API' : 'None';
|
|
636
|
+
}
|
|
302
637
|
},
|
|
303
638
|
|
|
304
639
|
/** Try to create a SpeechRecognition instance */
|
|
@@ -334,13 +669,81 @@ const VoiceInput = {
|
|
|
334
669
|
}
|
|
335
670
|
this._retryCount = 0;
|
|
336
671
|
|
|
337
|
-
|
|
672
|
+
const provider = this._resolveProvider();
|
|
673
|
+
if (provider === 'claude') {
|
|
674
|
+
this._startClaude();
|
|
675
|
+
} else if (provider === 'deepgram') {
|
|
338
676
|
this._startDeepgram();
|
|
339
677
|
} else {
|
|
340
678
|
this._startWebSpeech();
|
|
341
679
|
}
|
|
342
680
|
},
|
|
343
681
|
|
|
682
|
+
_startClaude() {
|
|
683
|
+
if (!this._claudeAvailable()) {
|
|
684
|
+
const reason = this._claudeStatus?.reason;
|
|
685
|
+
app.showToast(
|
|
686
|
+
reason === 'expired'
|
|
687
|
+
? 'Claude login expired on the server. Run a Claude session to refresh it.'
|
|
688
|
+
: reason === 'no-credentials'
|
|
689
|
+
? 'No Claude Code login found on the server. Sign in there with `claude`.'
|
|
690
|
+
: 'Claude voice is off. Enable it in Settings > Voice.',
|
|
691
|
+
'warning'
|
|
692
|
+
);
|
|
693
|
+
// Re-probe so a setting flipped on another device is picked up by the next press.
|
|
694
|
+
this.refreshClaudeStatus();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const cfg = this._getDeepgramConfig();
|
|
699
|
+
this.isRecording = true;
|
|
700
|
+
this._activeProvider = 'claude';
|
|
701
|
+
this._accumulatedFinal = '';
|
|
702
|
+
this._lastTranscript = '';
|
|
703
|
+
this._hasReceivedResult = false;
|
|
704
|
+
this._recordingStartedAt = Date.now();
|
|
705
|
+
this._updateButtons('recording');
|
|
706
|
+
this._showPreview('Listening...', 'claude');
|
|
707
|
+
this._startDurationTimer();
|
|
708
|
+
|
|
709
|
+
const keyterms = (cfg.keyterms || DEFAULT_VOICE_KEYTERMS)
|
|
710
|
+
.split(',').map(t => t.trim()).filter(Boolean);
|
|
711
|
+
|
|
712
|
+
ClaudeVoiceProvider.start({
|
|
713
|
+
// The upstream endpoint wants a bare language tag; the Deepgram picker's
|
|
714
|
+
// 'en-US' style narrows to its base, and 'multi' means auto-detect.
|
|
715
|
+
language: (cfg.language || 'en-US').split('-')[0],
|
|
716
|
+
keyterms,
|
|
717
|
+
onStream: (stream) => this._startLevelMeter(stream),
|
|
718
|
+
onResult: (text, isFinal) => {
|
|
719
|
+
if (!this.isRecording) return;
|
|
720
|
+
this._hasReceivedResult = true;
|
|
721
|
+
// Each frame is the WHOLE running transcript, so replace rather than append.
|
|
722
|
+
this._accumulatedFinal = text;
|
|
723
|
+
if (isFinal) {
|
|
724
|
+
this._hidePreview();
|
|
725
|
+
this._insertText(text);
|
|
726
|
+
this.stop();
|
|
727
|
+
} else {
|
|
728
|
+
this._showPreview(text, 'claude');
|
|
729
|
+
}
|
|
730
|
+
},
|
|
731
|
+
onError: (msg) => {
|
|
732
|
+
const wasRecording = this.isRecording;
|
|
733
|
+
this.stop();
|
|
734
|
+
if (wasRecording) app.showToast(msg, 'error');
|
|
735
|
+
},
|
|
736
|
+
onEnd: () => {
|
|
737
|
+
if (this.isRecording) {
|
|
738
|
+
if (this._accumulatedFinal) this._insertText(this._accumulatedFinal);
|
|
739
|
+
this.stop();
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
if (navigator.vibrate) navigator.vibrate(50);
|
|
745
|
+
},
|
|
746
|
+
|
|
344
747
|
_startDeepgram() {
|
|
345
748
|
const cfg = this._getDeepgramConfig();
|
|
346
749
|
this.isRecording = true;
|
|
@@ -353,7 +756,7 @@ const VoiceInput = {
|
|
|
353
756
|
this._showPreview('Listening...', 'deepgram');
|
|
354
757
|
this._startDurationTimer();
|
|
355
758
|
|
|
356
|
-
const keyterms = (cfg.keyterms ||
|
|
759
|
+
const keyterms = (cfg.keyterms || DEFAULT_VOICE_KEYTERMS)
|
|
357
760
|
.split(',').map(t => t.trim()).filter(Boolean);
|
|
358
761
|
|
|
359
762
|
DeepgramProvider.start({
|
|
@@ -452,7 +855,10 @@ const VoiceInput = {
|
|
|
452
855
|
this._updateButtons('idle');
|
|
453
856
|
this._hidePreview();
|
|
454
857
|
|
|
455
|
-
if (this._activeProvider === '
|
|
858
|
+
if (this._activeProvider === 'claude') {
|
|
859
|
+
// Finalize, don't hang up: the last transcript arrives after the audio does.
|
|
860
|
+
ClaudeVoiceProvider.stop();
|
|
861
|
+
} else if (this._activeProvider === 'deepgram') {
|
|
456
862
|
DeepgramProvider.stop();
|
|
457
863
|
} else if (this._activeProvider === 'webspeech') {
|
|
458
864
|
try {
|
|
@@ -803,11 +1209,12 @@ const VoiceInput = {
|
|
|
803
1209
|
timerEl.textContent = '0:00';
|
|
804
1210
|
indicator.appendChild(timerEl);
|
|
805
1211
|
this.previewEl.appendChild(indicator);
|
|
806
|
-
// Provider badge
|
|
807
|
-
|
|
1212
|
+
// Provider badge (Web Speech gets none — it is the fallback, not a choice)
|
|
1213
|
+
const badgeText = provider === 'deepgram' ? 'DG' : provider === 'claude' ? 'CLAUDE' : '';
|
|
1214
|
+
if (badgeText) {
|
|
808
1215
|
const badge = document.createElement('span');
|
|
809
1216
|
badge.className = 'voice-preview-badge';
|
|
810
|
-
badge.textContent =
|
|
1217
|
+
badge.textContent = badgeText;
|
|
811
1218
|
this.previewEl.appendChild(badge);
|
|
812
1219
|
this.previewEl.appendChild(document.createTextNode(' '));
|
|
813
1220
|
}
|
|
@@ -861,6 +1268,7 @@ const VoiceInput = {
|
|
|
861
1268
|
if (this.isRecording) this.stop();
|
|
862
1269
|
this._hideVoiceSendBtn();
|
|
863
1270
|
DeepgramProvider._cleanup();
|
|
1271
|
+
ClaudeVoiceProvider._cleanup();
|
|
864
1272
|
this.recognition = null;
|
|
865
1273
|
this._activeProvider = null;
|
|
866
1274
|
this._stopDurationTimer();
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview AudioWorklet that turns microphone audio into the PCM frames the
|
|
3
|
+
* Claude voice endpoint expects.
|
|
4
|
+
*
|
|
5
|
+
* The endpoint is opened as `encoding=linear16, sample_rate=16000, channels=1`,
|
|
6
|
+
* i.e. raw signed 16-bit little-endian mono. MediaRecorder cannot produce that
|
|
7
|
+
* (it only emits container formats — webm/opus, mp4), which is why the Deepgram
|
|
8
|
+
* path's capture code cannot be reused here: Deepgram sniffs the container,
|
|
9
|
+
* Anthropic's endpoint does not.
|
|
10
|
+
*
|
|
11
|
+
* Sample rate is handled by the AudioContext, constructed at 16000 Hz so the
|
|
12
|
+
* browser resamples the mic for us. This processor only converts Float32 [-1,1]
|
|
13
|
+
* to Int16 and batches, because a raw 128-sample render quantum is a ~4 ms
|
|
14
|
+
* WebSocket frame — 250 frames a second of pure overhead.
|
|
15
|
+
*
|
|
16
|
+
* Loaded via `audioWorklet.addModule()` from voice-input.js. Runs on the audio
|
|
17
|
+
* thread: no DOM, no globals from the page.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ Edit this file and voice-input.js together. Static assets are served
|
|
20
|
+
* `immutable` for a year and this one is fetched from JS, so it inherits its
|
|
21
|
+
* cache-bust token from voice-input.js's script tag (see `_workletUrl()`); a
|
|
22
|
+
* change here alone would keep serving the old copy to every returning browser.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** ~256 ms at 16 kHz. Big enough to keep frame overhead down, small enough that interim transcripts stay live. */
|
|
26
|
+
const FRAME_SAMPLES = 4096;
|
|
27
|
+
|
|
28
|
+
class PcmFrameProcessor extends AudioWorkletProcessor {
|
|
29
|
+
constructor() {
|
|
30
|
+
super();
|
|
31
|
+
this._buffer = new Int16Array(FRAME_SAMPLES);
|
|
32
|
+
this._offset = 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
process(inputs) {
|
|
36
|
+
const channel = inputs[0]?.[0];
|
|
37
|
+
// No input yet (mic still warming) — keep the processor alive.
|
|
38
|
+
if (!channel) return true;
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < channel.length; i++) {
|
|
41
|
+
// Clamp before scaling: values slightly outside [-1,1] are legal in Web Audio
|
|
42
|
+
// and would wrap around to the opposite sign as Int16, which sounds like a click.
|
|
43
|
+
const sample = Math.max(-1, Math.min(1, channel[i]));
|
|
44
|
+
this._buffer[this._offset++] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
|
45
|
+
|
|
46
|
+
if (this._offset === FRAME_SAMPLES) {
|
|
47
|
+
// Transfer a copy: the worklet keeps reusing its own buffer.
|
|
48
|
+
const frame = new Int16Array(this._buffer);
|
|
49
|
+
this.port.postMessage(frame.buffer, [frame.buffer]);
|
|
50
|
+
this._offset = 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
registerProcessor('pcm-frame-processor', PcmFrameProcessor);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -23,5 +23,6 @@ export { registerSearchRoutes } from './search-routes.js';
|
|
|
23
23
|
export { registerMeRoutes } from './me-routes.js';
|
|
24
24
|
export { registerAdminRoutes } from './admin-routes.js';
|
|
25
25
|
export { registerWsRoutes } from './ws-routes.js';
|
|
26
|
+
export { registerVoiceRoutes } from './voice-routes.js';
|
|
26
27
|
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
|
|
27
28
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/web/routes/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,6BAA6B,EAAE,MAAM,8BAA8B,CAAC;AAC7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/web/routes/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,6BAA6B,EAAE,MAAM,8BAA8B,CAAC;AAC7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,qBAAqB,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC"}
|
package/dist/web/routes/index.js
CHANGED
|
@@ -23,5 +23,6 @@ export { registerSearchRoutes } from './search-routes.js';
|
|
|
23
23
|
export { registerMeRoutes } from './me-routes.js';
|
|
24
24
|
export { registerAdminRoutes } from './admin-routes.js';
|
|
25
25
|
export { registerWsRoutes } from './ws-routes.js';
|
|
26
|
+
export { registerVoiceRoutes } from './voice-routes.js';
|
|
26
27
|
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
|
|
27
28
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/web/routes/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,6BAA6B,EAAE,MAAM,8BAA8B,CAAC;AAC7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/web/routes/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,6BAA6B,EAAE,MAAM,8BAA8B,CAAC;AAC7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,qBAAqB,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Claude voice dictation routes.
|
|
3
|
+
*
|
|
4
|
+
* - `GET /api/voice/status` — can this server transcribe? (settings gate + credential state)
|
|
5
|
+
* - `GET /ws/voice/stream` — one dictation: PCM16 audio up, transcripts down
|
|
6
|
+
*
|
|
7
|
+
* Design and the upstream protocol: `docs/claude-voice-plan.md`. The relay itself
|
|
8
|
+
* lives in `../voice-stream.ts`; this file is the auth, gating and lifetime shell
|
|
9
|
+
* around it.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ `/api/voice/status` reports STATE, never the token: `{ available, reason,
|
|
12
|
+
* subscriptionType?, expiresAt? }`. The Claude OAuth access token stays inside the
|
|
13
|
+
* server process — the browser sends audio and receives text, nothing else.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ The WebSocket carries the same upgrade guard as `/ws/sessions/:id/terminal`
|
|
16
|
+
* (allowed Host + same-site Origin, on top of the global auth hook that already ran
|
|
17
|
+
* on the handshake). Without it a cross-site page could open a dictation stream on
|
|
18
|
+
* the user's credentials and bill their subscription.
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ The feature is OFF unless `claudeVoiceEnabled` is set: turning it on spends the
|
|
21
|
+
* server owner's Claude subscription on transcription for anyone who can reach the
|
|
22
|
+
* UI, which is a decision for the operator rather than a default.
|
|
23
|
+
*/
|
|
24
|
+
import { FastifyInstance } from 'fastify';
|
|
25
|
+
import { type HostPolicy } from '../network-auth-policy.js';
|
|
26
|
+
import type { ConfigPort } from '../ports/index.js';
|
|
27
|
+
/** Why voice is unavailable, in a form the frontend can branch on. */
|
|
28
|
+
export type VoiceUnavailableReason = 'disabled' | 'no-credentials' | 'expired' | 'malformed';
|
|
29
|
+
export interface VoiceStatus {
|
|
30
|
+
available: boolean;
|
|
31
|
+
reason?: VoiceUnavailableReason;
|
|
32
|
+
/** Display-only ('max', 'pro'); present when the credential store reported one. */
|
|
33
|
+
subscriptionType?: string;
|
|
34
|
+
expiresAt?: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the server's dictation readiness. Split out and exported so the status
|
|
38
|
+
* endpoint and the WebSocket upgrade cannot drift apart: the socket must never
|
|
39
|
+
* accept a stream the status endpoint calls unavailable.
|
|
40
|
+
*/
|
|
41
|
+
export declare function resolveVoiceStatus(enabled: boolean): Promise<VoiceStatus>;
|
|
42
|
+
/** Test seam: the cap is process-wide state, so suites must be able to reset it. */
|
|
43
|
+
export declare function _resetVoiceStreamCountForTesting(): void;
|
|
44
|
+
export declare function registerVoiceRoutes(app: FastifyInstance, ctx: ConfigPort, getHostPolicy: () => HostPolicy): void;
|
|
45
|
+
//# sourceMappingURL=voice-routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voice-routes.d.ts","sourceRoot":"","sources":["../../../src/web/routes/voice-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAG1C,OAAO,EAAgD,KAAK,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAI1G,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAKpD,sEAAsE;AACtE,MAAM,MAAM,sBAAsB,GAAG,UAAU,GAAG,gBAAgB,GAAG,SAAS,GAAG,WAAW,CAAC;AAE7F,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE,sBAAsB,CAAC;IAChC,mFAAmF;IACnF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,WAAW,CAAC,CAa/E;AAKD,oFAAoF;AACpF,wBAAgB,gCAAgC,IAAI,IAAI,CAEvD;AAYD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,UAAU,GAAG,IAAI,CA2GhH"}
|