benchgo-plugin 0.0.0-stage → 0.0.3
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/DEEPSEEK-LICENSE +21 -0
- package/LICENSE +21 -0
- package/NOTICE +17 -0
- package/PASEO-LICENSE +211 -0
- package/README.md +256 -2
- package/TOOL-CONFIG.md +33 -0
- package/VERIFICATION.md +297 -0
- package/assets/fonts/README.md +13 -0
- package/assets/fonts/manifest.json +437 -0
- package/assets/fonts/web/fangyuan-latin-238bc49130b1.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u30-0fe8d99641c3.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u32-202a573131a1.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u4e-cc2f8b20be49.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u4f-9189b5f2d321.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u50-e2560e003872.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u51-a9c5e7a8c7c4.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u52-0cfd00b473c8.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u53-18122a07f60a.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u54-8ca41aa9cff2.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u55-7bd55e14647e.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u56-26f093e8a810.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u57-36bde83a8378.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u58-d1df14217098.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u59-4e41ab22c51a.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u5a-746a367174e6.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u5b-3d1dd404d70c.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u5c-fe0a273f69e7.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u5d-af1f0a1c8d2d.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u5e-598c65420e17.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u5f-a92eaaa5265a.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u60-48ee3d26462d.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u61-7a57f35bc077.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u62-d2d0b7b36631.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u63-cade7c6635e6.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u64-ed34e40d7f65.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u65-e13e83e69c38.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u66-1dbcfa5da147.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u67-6b0a26b82da6.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u68-1b8217cfceed.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u69-673e63627790.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u6a-3c94008a4640.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u6b-44572c410fa0.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u6c-1b323b366e88.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u6d-cbc376d6fe3f.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u6e-7f7e211e2fee.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u6f-ff280ce052fa.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u70-cb727eaf73b6.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u71-0bb1747439fe.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u72-cccd39590d69.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u73-e67705133584.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u74-03a4e7ac9df5.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u75-a2d30b327e65.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u76-c08fe029c988.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u77-76f03be1f4cb.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u78-5b721deaf582.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u79-db7713c2eda4.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u7a-3e40d3726a09.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u7b-a8204d1c9dba.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u7c-89bcdae046de.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u7d-bcc45fa6351a.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u7e-49fab96d9269.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u7f-dc110e1ecbf3.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u80-c5ce9be0335e.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u81-b026091b3779.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u82-a7c6960e875d.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u83-4b7d1c2c50d4.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u84-4119128100c8.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u85-ca43dd4c5411.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u86-54fc0e3158fb.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u87-b64f5eb55720.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u88-00feb1251f08.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u89-db419435b316.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u8a-06c11d45fb87.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u8b-5c2f6fda436a.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u8c-2b46d2d1f4cc.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u8d-1a67d064000d.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u8e-eb87baf0091c.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u8f-96c1f0632da6.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u90-86752dba8cbe.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u91-0573b8548552.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u92-f85ecf735974.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u93-e7e0451735db.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u94-f3957ff98bcd.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u95-b31129d29013.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u96-ca7f779c2c13.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u97-100a25198b2c.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u98-59e4b784a94f.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u99-d35af6e30868.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u9a-1eb6f7cdfcfe.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u9b-8f428d81dcbb.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u9c-e10f7f8f0274.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u9e-2be65003b759.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u9f-02c3d5224fdb.woff2 +0 -0
- package/assets/fonts/web/fangyuan-ufb-3eaee54bc476.woff2 +0 -0
- package/assets/fonts/web/fangyuan-ufe-f64da8a3acce.woff2 +0 -0
- package/assets/fonts/web/fangyuan-uff-0a6dcfb689fd.woff2 +0 -0
- package/client/assets/iconsax/LICENSE +16 -0
- package/client/assets/iconsax/SOURCE.md +14 -0
- package/config/routergo-deepseek.example.yml +25 -0
- package/config.example.json +24 -0
- package/cordis.patch.yml +5 -0
- package/lib/LINK-THIRD-PARTY-LICENSES.txt +1197 -0
- package/lib/client.js +3577 -0
- package/lib/link-web/app.css +92 -0
- package/lib/link-web/app.js +599 -0
- package/lib/link-web/index.html +1 -0
- package/lib/link-web/workbench.js +118 -0
- package/lib/link.mjs +244 -0
- package/lib/relay/ip-certificate.mjs +559 -0
- package/lib/relay/relay.example.json +8 -0
- package/lib/relay/run-relay-server.cjs +4100 -0
- package/lib/server/bootstrap.mjs +147 -0
- package/lib/server/bootstrap.sh +39 -0
- package/lib/server/manifest.json +8 -0
- package/lib/server/server-seed.tar.gz +0 -0
- package/locale/en.json +6 -0
- package/locale/zh-CN.json +6 -0
- package/openapi.json +629 -0
- package/package.json +104 -4
- package/shared/plugin-install.mjs +198 -0
- package/shared/tool-quick-settings.mjs +70 -0
- package/shared/update-feed.mjs +191 -0
- package/shared/update-public-key.pem +3 -0
- package/src/cli.mjs +102 -0
- package/src/commands.mjs +17 -0
- package/src/delegation.mjs +47 -0
- package/src/fonts.mjs +17 -0
- package/src/harness-adapter.mjs +228 -0
- package/src/harness-commands.mjs +39 -0
- package/src/harness-selection.mjs +5 -0
- package/src/harness-usage.mjs +31 -0
- package/src/isolated-launch.mjs +22 -0
- package/src/line-process.mjs +114 -0
- package/src/link-plugin.mjs +21 -0
- package/src/mcp.mjs +21 -0
- package/src/model-providers.mjs +125 -0
- package/src/models.mjs +37 -0
- package/src/native-auth.mjs +32 -0
- package/src/picker-catalog.mjs +82 -0
- package/src/plugin.mjs +138 -0
- package/src/projects.mjs +96 -0
- package/src/provider-access.mjs +11 -0
- package/src/providers/acp.mjs +138 -0
- package/src/providers/claude.mjs +115 -0
- package/src/providers/codex.mjs +134 -0
- package/src/providers/opencode.mjs +138 -0
- package/src/providers/pi-permissions.ts +24 -0
- package/src/providers/pi.mjs +97 -0
- package/src/questions.mjs +34 -0
- package/src/recent-workspace.mjs +36 -0
- package/src/runtime.mjs +421 -0
- package/src/session-routes.mjs +89 -0
- package/src/store.mjs +98 -0
- package/src/tool-catalog.mjs +22 -0
- package/src/tool-config.mjs +94 -0
- package/src/tool-install.mjs +31 -0
- package/src/tool-profiles.mjs +114 -0
- package/src/tooling.mjs +90 -0
- package/src/updates.mjs +156 -0
- package/src/usage-import-parsers.mjs +37 -0
- package/src/usage-import.mjs +114 -0
- package/src/usage-timeline.mjs +36 -0
- package/src/usage.mjs +142 -0
- package/src/web.mjs +274 -0
- package/voice/README.md +49 -0
- package/voice/STREAMING.md +141 -0
- package/voice/VERIFICATION.md +36 -0
- package/voice/client/audio-worklet.js +35 -0
- package/voice/locale/en.json +1 -0
- package/voice/locale/zh-CN.json +1 -0
- package/voice/src/asr.mjs +44 -0
- package/voice/src/calls.mjs +37 -0
- package/voice/src/config.mjs +139 -0
- package/voice/src/media.mjs +70 -0
- package/voice/src/plugin.mjs +10 -0
- package/voice/src/realtime-protocols.mjs +30 -0
- package/voice/src/realtime-socket.mjs +86 -0
- package/voice/src/realtime.mjs +130 -0
- package/voice/src/tts.mjs +16 -0
- package/voice/src/web.mjs +171 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { VoiceError, validateRealtime } from './config.mjs';
|
|
2
|
+
import { LIMITS, readJson, readLimited, validateWav, transcribe, upstreamFailure } from './media.mjs';
|
|
3
|
+
import { streamTranscription } from './asr.mjs';
|
|
4
|
+
import { RealtimeASR } from './realtime.mjs';
|
|
5
|
+
import { speechRequest } from './tts.mjs';
|
|
6
|
+
import { CallLeases } from './calls.mjs';
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import { WebSocketServer } from 'ws';
|
|
9
|
+
import { createRealtimeSockets } from './realtime-socket.mjs';
|
|
10
|
+
|
|
11
|
+
const headers = { 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' };
|
|
12
|
+
export function assertSameOrigin(request) {
|
|
13
|
+
if (request.headers.get('sec-fetch-site') === 'cross-site') throw new VoiceError('Cross-site voice requests are not allowed', 403);
|
|
14
|
+
const origin = request.headers.get('origin');
|
|
15
|
+
if (!origin) return; // Non-browser clients still require the Connection owner credential.
|
|
16
|
+
let parsed; try { parsed = new URL(origin); } catch { throw new VoiceError('Invalid request origin', 403); }
|
|
17
|
+
const host = request.headers.get('host') || new URL(request.url).host;
|
|
18
|
+
if (!['https:', 'http:'].includes(parsed.protocol) || parsed.host !== host) throw new VoiceError('Cross-site voice requests are not allowed', 403);
|
|
19
|
+
}
|
|
20
|
+
function scope(request, signals) {
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const timeout = setTimeout(() => controller.abort(), LIMITS.requestTimeout); timeout.unref?.();
|
|
23
|
+
const abort = () => controller.abort();
|
|
24
|
+
for (const signal of [request.signal, ...signals]) { signal.addEventListener('abort', abort, { once: true }); if (signal.aborted) abort(); }
|
|
25
|
+
return { signal: controller.signal, abort, close() { clearTimeout(timeout); for (const signal of [request.signal, ...signals]) signal.removeEventListener('abort', abort); } };
|
|
26
|
+
}
|
|
27
|
+
export function createVoiceHandlers(store, { fetcher = fetch, shutdown = new AbortController().signal, providers, socket } = {}) {
|
|
28
|
+
const active = { asr: 0, tts: 0 }, realtime = new RealtimeASR({ socket });
|
|
29
|
+
const sockets = createRealtimeSockets(realtime);
|
|
30
|
+
const calls = new CallLeases(); let revision = 0, mediaGeneration = new AbortController();
|
|
31
|
+
const invalidate = () => { revision++; sockets.clear(); realtime.clear(); calls.clear(); mediaGeneration.abort(); mediaGeneration = new AbortController(); };
|
|
32
|
+
const unsubscribe = store.subscribe?.(invalidate);
|
|
33
|
+
const dispose = () => { unsubscribe?.(); sockets.dispose(); realtime.clear(); calls.clear(); mediaGeneration.abort(); };
|
|
34
|
+
shutdown.addEventListener('abort', dispose, { once: true });
|
|
35
|
+
const errorResponse = error => Response.json({ error: error instanceof VoiceError ? error.message : 'Voice service request failed; check provider configuration and connectivity', ...(error instanceof VoiceError && error.code ? { code: error.code } : {}) }, { status: error instanceof VoiceError ? error.status : error?.name === 'AbortError' ? 499 : 502, headers });
|
|
36
|
+
const protect = handler => async request => { try { assertSameOrigin(request); if (shutdown.aborted) throw new VoiceError('Voice service is stopping', 503); return await handler(request); } catch (error) { return errorResponse(error); } };
|
|
37
|
+
const acquire = kind => { if (active[kind] >= (kind === 'tts' ? 3 : 2)) throw new VoiceError('Too many active voice requests; wait for the current segment', 429); active[kind]++; let released = false; return () => { if (!released) { released = true; active[kind]--; } }; };
|
|
38
|
+
const provider = async kind => {
|
|
39
|
+
const value = structuredClone(store.value[kind]);
|
|
40
|
+
if (value.providerId) {
|
|
41
|
+
if (!providers) throw new VoiceError('模型供应商服务尚未就绪', 409);
|
|
42
|
+
try { const resolved = await providers.resolve(value.providerId); value.baseURL = resolved.baseURL; value.apiKey = resolved.apiKey; }
|
|
43
|
+
catch { throw new VoiceError('模型供应商不可用,请检查模型设置中的 API 地址和密钥', 409); }
|
|
44
|
+
}
|
|
45
|
+
if (!value.baseURL || !value.model) throw new VoiceError(`Configure the ${kind.toUpperCase()} model first`, 409);
|
|
46
|
+
if (kind === 'asr' && value.realtime) value.realtime = validateRealtime(value.realtime, value.apiKey);
|
|
47
|
+
return value;
|
|
48
|
+
};
|
|
49
|
+
const snapshot = async () => {
|
|
50
|
+
const config = store.snapshot();
|
|
51
|
+
for (const kind of ['asr', 'tts']) if (config[kind].providerId) {
|
|
52
|
+
try { const resolved = await providers?.resolve(config[kind].providerId); config[kind].baseURL = resolved?.baseURL || ''; config[kind].configured = !!(resolved && config[kind].model); }
|
|
53
|
+
catch { config[kind].configured = false; config[kind].baseURL = ''; }
|
|
54
|
+
}
|
|
55
|
+
return config;
|
|
56
|
+
};
|
|
57
|
+
const operationFor = (request, feature) => {
|
|
58
|
+
const callId = request.headers.get('x-voice-call-id');
|
|
59
|
+
if (!callId && !store.value[feature]) throw new VoiceError('Start a voice call before requesting call audio', 409);
|
|
60
|
+
const signals = [shutdown, mediaGeneration.signal];
|
|
61
|
+
if (callId) { if (!store.value.call) throw new VoiceError('Voice calls are disabled', 409); signals.push(calls.media(callId)); }
|
|
62
|
+
return scope(request, signals);
|
|
63
|
+
};
|
|
64
|
+
const reserve = (request, feature, kind) => { const operation = operationFor(request, feature); try { return { operation, release: acquire(kind) }; } catch (error) { operation.close(); throw error; } };
|
|
65
|
+
return {
|
|
66
|
+
dispose,
|
|
67
|
+
acceptRealtimeSocket: sockets.accept,
|
|
68
|
+
worklet: protect(async () => new Response(await readFile(new URL('../client/audio-worklet.js', import.meta.url)), { headers: { ...headers, 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'private, no-store' } })),
|
|
69
|
+
config: protect(async request => {
|
|
70
|
+
const input = await readJson(request);
|
|
71
|
+
if (input.action === 'providers') return Response.json({ providers: providers ? await providers.list() : [] }, { headers });
|
|
72
|
+
if (input.action === 'models') { if (!providers) throw new VoiceError('模型供应商服务尚未就绪', 409); return Response.json({ models: await providers.models(input.providerId, request.signal) }, { headers }); }
|
|
73
|
+
if (input.action === 'asr.start') {
|
|
74
|
+
const config = await provider('asr'); if (!['step-websocket', 'openai-websocket'].includes(config.protocol)) throw new VoiceError('Select a supported WebSocket transcription protocol first', 409);
|
|
75
|
+
const callId = request.headers.get('x-voice-call-id') || '';
|
|
76
|
+
if (callId ? !store.value.call : !store.value.dictation) throw new VoiceError('Voice input is disabled', 409);
|
|
77
|
+
const signals = [shutdown, mediaGeneration.signal]; if (callId) signals.push(calls.media(callId));
|
|
78
|
+
const release = acquire('asr'), opening = new AbortController(); signals.push(opening.signal);
|
|
79
|
+
const abort = () => opening.abort(); request.signal.addEventListener('abort', abort, { once: true }); if (request.signal.aborted) abort();
|
|
80
|
+
try { const id = await realtime.open(config, AbortSignal.any(signals), callId); realtime.get(id, callId).onClose = release; return Response.json({ id, ticket: sockets.issue(id, callId) }, { headers }); }
|
|
81
|
+
catch (error) { release(); throw error; } finally { request.signal.removeEventListener('abort', abort); }
|
|
82
|
+
}
|
|
83
|
+
if (input.action === 'asr.end') { realtime.get(input.id, request.headers.get('x-voice-call-id') || ''); realtime.close(input.id); return Response.json({ ok: true }, { headers }); }
|
|
84
|
+
if (input.action === 'config') return Response.json({ config: await snapshot() }, { headers });
|
|
85
|
+
if (input.action === 'configure') { await store.configure(input.config); if (!unsubscribe) invalidate(); return Response.json({ config: await snapshot(), revision }, { headers }); }
|
|
86
|
+
if (input.action === 'call.start') { if (!store.value.call) throw new VoiceError('Voice calls are disabled', 409); await provider('asr'); await provider('tts'); return Response.json({ call: calls.start(input), revision }, { headers }); }
|
|
87
|
+
if (input.action === 'call.heartbeat') return Response.json({ call: calls.heartbeat(input), revision }, { headers });
|
|
88
|
+
if (input.action === 'call.end') return Response.json(calls.end(input), { headers });
|
|
89
|
+
throw new VoiceError('Unknown Voice action');
|
|
90
|
+
}),
|
|
91
|
+
transcribe: protect(async request => {
|
|
92
|
+
if (!store.value.dictation && !store.value.call) throw new VoiceError('Voice input is disabled', 409);
|
|
93
|
+
if (!/^audio\/(wav|wave|x-wav)(;|$)/iu.test(request.headers.get('content-type') || '')) throw new VoiceError('Expected audio/wav', 415);
|
|
94
|
+
const config = await provider('asr'), { operation, release } = reserve(request, 'dictation', 'asr'); let handedOff = false;
|
|
95
|
+
try {
|
|
96
|
+
const bytes = await readLimited(request, LIMITS.audioBytes, operation.signal); validateWav(bytes);
|
|
97
|
+
if ((config.protocol || 'transcriptions') === 'transcriptions') { const result = await transcribe(fetcher, config, bytes, operation.signal); operation.signal.throwIfAborted(); return Response.json(result, { headers }); }
|
|
98
|
+
let finished = false; const close = () => { if (!finished) { finished = true; operation.close(); release(); } };
|
|
99
|
+
const stream = new ReadableStream({ start(controller) {
|
|
100
|
+
const emit = value => { operation.signal.throwIfAborted(); controller.enqueue(new TextEncoder().encode(JSON.stringify(value) + '\n')); };
|
|
101
|
+
void streamTranscription(fetcher, config, bytes, operation.signal, text => emit({ type: 'partial', text })).then(result => { emit({ type: 'final', text: result.text }); controller.close(); }).catch(error => { try { controller.enqueue(new TextEncoder().encode(JSON.stringify({ type: 'error', error: error instanceof VoiceError ? error.message : 'ASR stream failed' }) + '\n')); controller.close(); } catch {} }).finally(close);
|
|
102
|
+
}, cancel() { operation.abort(); close(); } });
|
|
103
|
+
handedOff = true; return new Response(stream, { headers: { ...headers, 'content-type': 'application/x-ndjson', 'x-accel-buffering': 'no' } });
|
|
104
|
+
} finally { if (!handedOff) { operation.close(); release(); } }
|
|
105
|
+
}),
|
|
106
|
+
speech: protect(async request => {
|
|
107
|
+
if (!store.value.readAloud && !store.value.call) throw new VoiceError('Voice playback is disabled', 409);
|
|
108
|
+
const input = await readJson(request);
|
|
109
|
+
if (typeof input.text !== 'string' || !input.text.trim() || input.text.length > LIMITS.speechCharacters) throw new VoiceError('Speech text must contain 1–4000 characters');
|
|
110
|
+
const config = await provider('tts'), { operation, release } = reserve(request, 'readAloud', 'tts'); let handedOff = false;
|
|
111
|
+
try {
|
|
112
|
+
const upstream = await fetcher(`${config.baseURL}/audio/speech`, {
|
|
113
|
+
method: 'POST', headers: { 'content-type': 'application/json', ...(config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {}) },
|
|
114
|
+
body: JSON.stringify(speechRequest(config, input.text)), signal: operation.signal, redirect: 'error',
|
|
115
|
+
});
|
|
116
|
+
if (operation.signal.aborted) { await upstream.body?.cancel(); operation.signal.throwIfAborted(); }
|
|
117
|
+
if (!upstream.ok) throw await upstreamFailure(upstream, 'TTS', operation.signal);
|
|
118
|
+
if (!upstream.body) throw new VoiceError('TTS service returned no audio', 502);
|
|
119
|
+
const type = upstream.headers.get('content-type')?.split(';')[0].trim().toLowerCase();
|
|
120
|
+
if (type && !['audio/pcm', 'audio/pcm16', 'audio/x-pcm', 'application/octet-stream'].includes(type)) { await upstream.body.cancel(); throw new VoiceError('TTS service must return raw PCM16LE, 24 kHz mono audio', 502); }
|
|
121
|
+
const contentType = upstream.headers.get('content-type') || '', rate = upstream.headers.get('x-voice-sample-rate') || /(?:rate|samplerate|sample_rate)\s*=\s*"?(\d+)/iu.exec(contentType)?.[1], channels = upstream.headers.get('x-voice-channels') || /channels\s*=\s*"?(\d+)/iu.exec(contentType)?.[1];
|
|
122
|
+
if (rate && Number(rate) !== 24000 || channels && Number(channels) !== 1) { await upstream.body.cancel(); throw new VoiceError('TTS returned a mismatched PCM sample rate or channel count', 502, 'tts_pcm_format'); }
|
|
123
|
+
if (Number(upstream.headers.get('content-length')) > LIMITS.speechBytes) { await upstream.body.cancel(); throw new VoiceError('TTS output exceeds audio limit', 502); }
|
|
124
|
+
const reader = upstream.body.getReader(); let bytes = 0, tail = null, finished = false, downstream;
|
|
125
|
+
const onAbort = () => { reader.cancel().catch(() => {}); try { downstream?.error(new Error('Voice audio stream cancelled')); } catch {} close(); };
|
|
126
|
+
const close = () => { if (!finished) { finished = true; operation.signal.removeEventListener('abort', onAbort); operation.close(); release(); } };
|
|
127
|
+
const body = new ReadableStream({
|
|
128
|
+
start(controller) { downstream = controller; operation.signal.addEventListener('abort', onAbort, { once: true }); if (operation.signal.aborted) onAbort(); },
|
|
129
|
+
async pull(controller) {
|
|
130
|
+
try {
|
|
131
|
+
operation.signal.throwIfAborted();
|
|
132
|
+
const { value, done } = await reader.read();
|
|
133
|
+
if (done) { if (tail !== null || !bytes) throw new Error('Incomplete PCM audio'); controller.close(); close(); return; }
|
|
134
|
+
bytes += value.byteLength; if (bytes > LIMITS.speechBytes) throw new Error('Audio limit');
|
|
135
|
+
let chunk = value;
|
|
136
|
+
if (tail !== null) { chunk = new Uint8Array(value.length + 1); chunk[0] = tail; chunk.set(value, 1); tail = null; }
|
|
137
|
+
if (chunk.length % 2) { tail = chunk[chunk.length - 1]; chunk = chunk.subarray(0, chunk.length - 1); }
|
|
138
|
+
if (chunk.length) controller.enqueue(chunk);
|
|
139
|
+
} catch { operation.abort(); await reader.cancel().catch(() => {}); controller.error(new Error('Voice audio stream ended')); close(); }
|
|
140
|
+
},
|
|
141
|
+
async cancel() { operation.abort(); try { await reader.cancel(); } finally { close(); } },
|
|
142
|
+
});
|
|
143
|
+
handedOff = true;
|
|
144
|
+
return new Response(body, { headers: { ...headers, 'content-type': 'audio/pcm;rate=24000;channels=1', 'x-voice-sample-rate': '24000', 'x-voice-channels': '1', 'x-voice-format': 'pcm_s16le', 'x-accel-buffering': 'no' } });
|
|
145
|
+
} finally { if (!handedOff) { operation.close(); release(); } }
|
|
146
|
+
}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Routes inherit the official Connection owner authentication before dispatch. */
|
|
151
|
+
export function mountWeb(ctx, store, options = {}) {
|
|
152
|
+
const shutdown = new AbortController();
|
|
153
|
+
const handlers = createVoiceHandlers(store, { ...options, shutdown: shutdown.signal });
|
|
154
|
+
ctx.effect(() => () => shutdown.abort());
|
|
155
|
+
for (const [path, handler] of [['/api/voice', handlers.config], ['/api/voice/transcribe', handlers.transcribe], ['/api/voice/speech', handlers.speech]]) {
|
|
156
|
+
ctx.effect(() => ctx.connection.fetch.register({ path, methods: ['POST'], requestBody: 'streaming', fetch: handler }));
|
|
157
|
+
}
|
|
158
|
+
ctx.effect(() => ctx.connection.fetch.register({ path: '/api/voice/worklet', methods: ['GET'], requestBody: 'buffered', fetch: handlers.worklet }));
|
|
159
|
+
store.acceptRealtimeSocket = handlers.acceptRealtimeSocket;
|
|
160
|
+
ctx.effect(() => () => { if (store.acceptRealtimeSocket === handlers.acceptRealtimeSocket) delete store.acceptRealtimeSocket; });
|
|
161
|
+
ctx.inject?.(['webServer'], child => {
|
|
162
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: 2048, perMessageDeflate: false });
|
|
163
|
+
child.effect(() => child.webServer.registerUpgrade({ path: '/api/voice/realtime', handler(req, socket, head) {
|
|
164
|
+
const rejection = child.connection.requestRejection(req);
|
|
165
|
+
if (rejection) { socket.end('HTTP/1.1 ' + rejection + ' Rejected\r\nConnection: close\r\n\r\n'); return; }
|
|
166
|
+
wss.handleUpgrade(req, socket, head, handlers.acceptRealtimeSocket);
|
|
167
|
+
} }));
|
|
168
|
+
child.effect(() => () => { for (const ws of wss.clients) ws.terminate(); wss.close(); });
|
|
169
|
+
});
|
|
170
|
+
return handlers;
|
|
171
|
+
}
|