@flowingspring/dsh-voco 0.2.0
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.i18n.yaml +6 -0
- package/README.md +31 -0
- package/README.zh.md +31 -0
- package/cordis.patch.yml +43 -0
- package/lib/client.js +1469 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +6 -0
- package/lib/invariant.js +9 -0
- package/lib/plugins/llm-tool-call-compat.js +101 -0
- package/lib/plugins/voice-assistant.js +1248 -0
- package/lib/plugins/voice-local.js +593 -0
- package/lib/plugins/voice-web.js +267 -0
- package/lib/plugins/voice.js +401 -0
- package/lib/types/index.d.ts +4 -0
- package/lib/types/invariant.d.ts +7 -0
- package/package.json +119 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { VoiceCommandCallId, VoiceTaskId } from "./voice.js";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
5
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
6
|
+
//#region ../voice-web/src/loopback-hostname.ts
|
|
7
|
+
/**
|
|
8
|
+
* Browser-safe, zero-dependency loopback classification shared by the `/api`
|
|
9
|
+
* Host fence and the package's `ctx.connection` state. The predicate stays
|
|
10
|
+
* package-internal; client plugins consume the derived state through Cordis.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Whether a normalized URL hostname names the local loopback authority.
|
|
14
|
+
* @param hostname - WHATWG URL hostname (IPv6 literals retain brackets).
|
|
15
|
+
* @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8.
|
|
16
|
+
*/
|
|
17
|
+
function isLoopbackHostname(hostname) {
|
|
18
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
19
|
+
const parts = hostname.split(".");
|
|
20
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
21
|
+
}
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region ../voice-web/src/api-request-trust.ts
|
|
24
|
+
function header(headers, name) {
|
|
25
|
+
if (headers instanceof Headers) return headers.get(name) ?? void 0;
|
|
26
|
+
const value = headers[name];
|
|
27
|
+
return typeof value === "string" ? value : void 0;
|
|
28
|
+
}
|
|
29
|
+
/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
|
|
30
|
+
function parseAuthority(authority) {
|
|
31
|
+
try {
|
|
32
|
+
return new URL(`http://${authority}`);
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Assert one configured `trustedHosts` entry is a bare authority (`host` or
|
|
39
|
+
* `host:port`) in canonical form: it must survive WHATWG parsing unchanged
|
|
40
|
+
* (case aside). Anything parsing would silently rewrite is refused as a typo
|
|
41
|
+
* that must fail the load loudly instead of being ignored until requests 403
|
|
42
|
+
* or quietly changing the grant: URL parts beyond the authority
|
|
43
|
+
* (`harness.internal/path`, `user@harness.internal` — which would authorize
|
|
44
|
+
* the embedded hostname), stripped whitespace, a dangling colon or
|
|
45
|
+
* zero-padded port (which would broaden an intended exact-port grant to every
|
|
46
|
+
* port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding,
|
|
47
|
+
* unbracketed IPv6; IDN hosts are declared in punycode, the form the wire
|
|
48
|
+
* carries).
|
|
49
|
+
* @param entry - the configured value, verbatim.
|
|
50
|
+
*/
|
|
51
|
+
function assertTrustedAuthority(entry) {
|
|
52
|
+
const entryUrl = parseAuthority(entry);
|
|
53
|
+
if (entryUrl !== void 0 && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return;
|
|
54
|
+
throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Canonical form of a parsed authority: `hostname` when no port was written,
|
|
58
|
+
* else `hostname:port`. The port is judged from URL parses under both special
|
|
59
|
+
* schemes (their default ports differ, so `:80` and `:443` still count as
|
|
60
|
+
* explicit), never from the raw string, where WHATWG trimming would misread
|
|
61
|
+
* shapes like `host:port ` as port-less.
|
|
62
|
+
*/
|
|
63
|
+
function canonicalAuthority(entry, entryUrl) {
|
|
64
|
+
const port = entryUrl.port !== "" ? entryUrl.port : new URL(`https://${entry}`).port;
|
|
65
|
+
return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Whether the request authority matches a `trustedHosts` entry. An entry with
|
|
69
|
+
* an explicit port matches that exact authority; a port-less entry matches the
|
|
70
|
+
* hostname on any port (the shape the CLI derives for IP-literal LAN serving,
|
|
71
|
+
* where the bound port may be OS-assigned). Both sides compare through WHATWG
|
|
72
|
+
* normalization, so case and a redundant `:80` never decide trust.
|
|
73
|
+
*/
|
|
74
|
+
function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
75
|
+
return trustedHosts.some((entry) => {
|
|
76
|
+
const entryUrl = parseAuthority(entry);
|
|
77
|
+
if (entryUrl === void 0) return false;
|
|
78
|
+
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname ? entryUrl.hostname === hostUrl.hostname : entryUrl.host === hostUrl.host;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Decide whether one /api request may reach the RPC bridge.
|
|
83
|
+
* @param request - Node HTTP or Fetch request facts (headers).
|
|
84
|
+
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
|
|
85
|
+
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
|
|
86
|
+
*/
|
|
87
|
+
function isTrustedApiRequest(request, trustedHosts) {
|
|
88
|
+
const host = header(request.headers, "host");
|
|
89
|
+
if (host === void 0) return false;
|
|
90
|
+
const hostUrl = parseAuthority(host);
|
|
91
|
+
if (hostUrl === void 0) return false;
|
|
92
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
|
|
93
|
+
if (header(request.headers, "sec-fetch-site") === "cross-site") return false;
|
|
94
|
+
const origin = header(request.headers, "origin");
|
|
95
|
+
if (origin === void 0) return true;
|
|
96
|
+
try {
|
|
97
|
+
return new URL(origin).host === hostUrl.host;
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region ../voice-web/src/index.ts
|
|
104
|
+
/** Dedicated browser WebSocket carrier for realtime voice audio and events. @module @flowingspring/dsh-voice-web */
|
|
105
|
+
const name = "voice-web";
|
|
106
|
+
const inject = ["voice", "webServer"];
|
|
107
|
+
/** Dedicated browser voice upgrade pathname. */
|
|
108
|
+
const VOICE_PATH = "/voice";
|
|
109
|
+
const Config = z.object({
|
|
110
|
+
trustedHosts: z.array(String).default([]),
|
|
111
|
+
maxAudioFrameBytes: z.natural().min(1).default(65536)
|
|
112
|
+
});
|
|
113
|
+
/** Register the `/voice` upgrade route. @param ctx - web and voice context. @param config - trust and frame limits. */
|
|
114
|
+
function apply(ctx, config = {}) {
|
|
115
|
+
const trustedHosts = config.trustedHosts ?? [];
|
|
116
|
+
for (const authority of trustedHosts) assertTrustedAuthority(authority);
|
|
117
|
+
const maxAudioFrameBytes = config.maxAudioFrameBytes ?? 65536;
|
|
118
|
+
const server = new WebSocketServer({
|
|
119
|
+
noServer: true,
|
|
120
|
+
maxPayload: maxAudioFrameBytes
|
|
121
|
+
});
|
|
122
|
+
ctx.effect(() => ctx.webServer.registerUpgrade({
|
|
123
|
+
path: VOICE_PATH,
|
|
124
|
+
handler: (request, socket, head) => {
|
|
125
|
+
if (!isTrustedApiRequest(request, trustedHosts)) {
|
|
126
|
+
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 9\r\n\r\nforbidden");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
server.handleUpgrade(request, socket, head, (websocket) => {
|
|
130
|
+
/* v8 ignore next -- node:http always sets url on server upgrade requests */
|
|
131
|
+
attach(ctx, websocket, request.url ?? "/voice");
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}), "voice-web route");
|
|
135
|
+
ctx.effect(() => async () => {
|
|
136
|
+
for (const socket of server.clients) socket.terminate();
|
|
137
|
+
await new Promise((resolve, reject) => {
|
|
138
|
+
server.close((error) => {
|
|
139
|
+
if (error === void 0) resolve();
|
|
140
|
+
else reject(error);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}, "voice-web sockets");
|
|
144
|
+
}
|
|
145
|
+
async function attach(ctx, socket, rawUrl) {
|
|
146
|
+
const voice = ctx.voice;
|
|
147
|
+
let voiceId;
|
|
148
|
+
let unsubscribe;
|
|
149
|
+
const state = {
|
|
150
|
+
closed: false,
|
|
151
|
+
finalClose: false
|
|
152
|
+
};
|
|
153
|
+
const finalClose = (id) => {
|
|
154
|
+
voice.close(id).catch((error) => {
|
|
155
|
+
ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
socket.once("close", () => {
|
|
159
|
+
state.closed = true;
|
|
160
|
+
unsubscribe?.();
|
|
161
|
+
if (voiceId === void 0) return;
|
|
162
|
+
if (state.finalClose) finalClose(voiceId);
|
|
163
|
+
else voice.detach(voiceId);
|
|
164
|
+
});
|
|
165
|
+
socket.on("error", (error) => {
|
|
166
|
+
/* v8 ignore next -- other WebSocket errors are transport loss and retain the provider for reconnect */
|
|
167
|
+
if (error.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH") state.finalClose = true;
|
|
168
|
+
});
|
|
169
|
+
try {
|
|
170
|
+
const value = new URL(rawUrl, "http://voice.local").searchParams.get("sessionId");
|
|
171
|
+
if (value === null || value === "") {
|
|
172
|
+
state.finalClose = true;
|
|
173
|
+
socket.close(1008, "sessionId is required");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const session = await voice.open(SessionId(value));
|
|
177
|
+
voiceId = session.id;
|
|
178
|
+
if (state.closed) {
|
|
179
|
+
await voice.close(session.id);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
unsubscribe = voice.subscribe(session.id, (event) => {
|
|
183
|
+
sendEvent(socket, event);
|
|
184
|
+
if (event.type === "closed") {
|
|
185
|
+
state.finalClose = true;
|
|
186
|
+
socket.close(1011, "voice provider closed");
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
socket.send(JSON.stringify({
|
|
190
|
+
type: "ready",
|
|
191
|
+
voiceSessionId: session.id,
|
|
192
|
+
audio: session.audio,
|
|
193
|
+
interactionMode: session.interactionMode
|
|
194
|
+
}));
|
|
195
|
+
socket.on("message", (data, isBinary) => {
|
|
196
|
+
const frame = data;
|
|
197
|
+
if (isBinary) {
|
|
198
|
+
voice.appendAudio(session.id, frame);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
let control;
|
|
202
|
+
const text = frame.toString("utf8");
|
|
203
|
+
try {
|
|
204
|
+
control = JSON.parse(text);
|
|
205
|
+
} catch {
|
|
206
|
+
state.finalClose = true;
|
|
207
|
+
socket.close(1008, "invalid control frame");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (control === null || typeof control !== "object") {
|
|
211
|
+
state.finalClose = true;
|
|
212
|
+
socket.close(1008, "invalid control frame");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const type = control.type;
|
|
216
|
+
if (type === "audio.commit") voice.commitAudio(session.id);
|
|
217
|
+
else if (type === "response.interrupt") voice.interruptResponse(session.id);
|
|
218
|
+
else if (type === "playback.ended") voice.playbackEnded(session.id);
|
|
219
|
+
else if (type === "task.cancel") {
|
|
220
|
+
const taskId = control.taskId;
|
|
221
|
+
if (typeof taskId !== "string" || taskId === "") {
|
|
222
|
+
state.finalClose = true;
|
|
223
|
+
socket.close(1008, "taskId is required");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
voice.submitTaskCommand(session.id, {
|
|
227
|
+
id: VoiceCommandCallId(`browser:${randomUUID()}`),
|
|
228
|
+
command: {
|
|
229
|
+
type: "cancel_task",
|
|
230
|
+
taskId: VoiceTaskId(taskId)
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
} else if (type === "text.submit") {
|
|
234
|
+
const submitted = control.text;
|
|
235
|
+
if (typeof submitted !== "string" || submitted.trim() === "") {
|
|
236
|
+
state.finalClose = true;
|
|
237
|
+
socket.close(1008, "text is required");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
voice.submitText(session.id, submitted);
|
|
241
|
+
} else if (type === "session.close") {
|
|
242
|
+
state.finalClose = true;
|
|
243
|
+
unsubscribe?.();
|
|
244
|
+
finalClose(session.id);
|
|
245
|
+
socket.close(1e3, "voice session closed");
|
|
246
|
+
} else {
|
|
247
|
+
state.finalClose = true;
|
|
248
|
+
socket.close(1008, "unknown control frame");
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
} catch (error) {
|
|
252
|
+
state.finalClose = true;
|
|
253
|
+
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({
|
|
254
|
+
type: "error",
|
|
255
|
+
message: String(error)
|
|
256
|
+
}));
|
|
257
|
+
socket.close(1011, "voice setup failed");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function sendEvent(socket, event) {
|
|
261
|
+
/* v8 ignore next -- the close-race guard cannot be scheduled deterministically */
|
|
262
|
+
if (socket.readyState !== WebSocket.OPEN) return;
|
|
263
|
+
if (event.type === "output_audio.delta") socket.send(event.audio, { binary: true });
|
|
264
|
+
else socket.send(JSON.stringify(event));
|
|
265
|
+
}
|
|
266
|
+
//#endregion
|
|
267
|
+
export { Config, VOICE_PATH, apply, inject, name };
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
//#region ../voice/src/types.ts
|
|
5
|
+
/**
|
|
6
|
+
* Brand a raw id as a voice-session identity.
|
|
7
|
+
* @param value - raw identity.
|
|
8
|
+
* @returns branded identity.
|
|
9
|
+
*/
|
|
10
|
+
function VoiceSessionId(value) {
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Brand a raw id as a voice-task identity.
|
|
15
|
+
* @param value - raw identity.
|
|
16
|
+
* @returns branded identity.
|
|
17
|
+
*/
|
|
18
|
+
function VoiceTaskId(value) {
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Brand a raw id as a voice-utterance identity.
|
|
23
|
+
* @param value - provider or locally minted utterance identity.
|
|
24
|
+
* @returns branded identity.
|
|
25
|
+
*/
|
|
26
|
+
function VoiceUtteranceId(value) {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Brand a raw id as a voice-response identity.
|
|
31
|
+
* @param value - provider response identity.
|
|
32
|
+
* @returns branded identity.
|
|
33
|
+
*/
|
|
34
|
+
function VoiceResponseId(value) {
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Brand a raw id as a frontend command-call identity.
|
|
39
|
+
* @param value - provider call identity.
|
|
40
|
+
* @returns branded identity.
|
|
41
|
+
*/
|
|
42
|
+
function VoiceCommandCallId(value) {
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Brand a raw id as a backend voice-message identity.
|
|
47
|
+
* @param value - raw identity.
|
|
48
|
+
* @returns branded identity.
|
|
49
|
+
*/
|
|
50
|
+
function VoiceTaskMessageId(value) {
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region ../voice/src/index.ts
|
|
55
|
+
/** Provider-neutral realtime voice session capability seam. @module @flowingspring/dsh-voice */
|
|
56
|
+
/** Provider registry and connected-session coordinator. */
|
|
57
|
+
var VoiceRuntime = class extends Service {
|
|
58
|
+
static Config = z.object({
|
|
59
|
+
provider: z.string(),
|
|
60
|
+
maxCommandCalls: z.natural().min(1).default(256),
|
|
61
|
+
reconnectGraceMs: z.natural().min(1).default(6e4)
|
|
62
|
+
});
|
|
63
|
+
providers = /* @__PURE__ */ new Map();
|
|
64
|
+
sessions = /* @__PURE__ */ new Map();
|
|
65
|
+
memory = { source: void 0 };
|
|
66
|
+
configuredProvider;
|
|
67
|
+
maxCommandCalls;
|
|
68
|
+
reconnectGraceMs;
|
|
69
|
+
constructor(ctx, config = {}) {
|
|
70
|
+
super(ctx, "voice");
|
|
71
|
+
this.configuredProvider = config.provider;
|
|
72
|
+
this.maxCommandCalls = config.maxCommandCalls ?? 256;
|
|
73
|
+
this.reconnectGraceMs = config.reconnectGraceMs ?? 6e4;
|
|
74
|
+
ctx.effect(() => async () => {
|
|
75
|
+
const settled = await Promise.allSettled([...this.sessions.keys()].map((id) => this.close(id)));
|
|
76
|
+
const failures = [];
|
|
77
|
+
for (const result of settled) if (result.status === "rejected") failures.push(result.reason);
|
|
78
|
+
if (failures.length > 0) throw new AggregateError(failures, "failed to close voice provider sessions");
|
|
79
|
+
}, "voice provider sessions");
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Register one provider.
|
|
83
|
+
* @param provider - provider implementation.
|
|
84
|
+
* @returns disposer removing it.
|
|
85
|
+
*/
|
|
86
|
+
registerProvider(provider) {
|
|
87
|
+
if (this.providers.has(provider.id)) throw new Error(`voice provider "${provider.id}" is already registered`);
|
|
88
|
+
this.providers.set(provider.id, provider);
|
|
89
|
+
return () => {
|
|
90
|
+
this.providers.delete(provider.id);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Register the sole durable conversation-memory source.
|
|
95
|
+
* @param source - loader keyed by the durable source Agent session.
|
|
96
|
+
* @returns disposer removing it when still current.
|
|
97
|
+
*/
|
|
98
|
+
registerMemorySource(source) {
|
|
99
|
+
if (this.memory.source !== void 0) throw new Error("voice conversation memory source is already registered");
|
|
100
|
+
this.memory.source = source;
|
|
101
|
+
return () => {
|
|
102
|
+
if (this.memory.source === source) this.memory.source = void 0;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Open or reattach a voice transport for an Agent session.
|
|
107
|
+
* @param agentSessionId - durable Agent identity.
|
|
108
|
+
* @returns connected session metadata.
|
|
109
|
+
*/
|
|
110
|
+
async open(agentSessionId) {
|
|
111
|
+
const detached = [...this.sessions.values()].find((live) => !live.attached && live.info.agentSessionId === agentSessionId);
|
|
112
|
+
if (detached !== void 0) {
|
|
113
|
+
clearTimeout(detached.detachTimer);
|
|
114
|
+
delete detached.detachTimer;
|
|
115
|
+
detached.attached = true;
|
|
116
|
+
this.ctx.emit("voice/session-opened", detached.info);
|
|
117
|
+
return detached.info;
|
|
118
|
+
}
|
|
119
|
+
const id = VoiceSessionId(randomUUID());
|
|
120
|
+
const provider = this.resolveProvider();
|
|
121
|
+
const memory = await this.memory.source?.(agentSessionId);
|
|
122
|
+
const holder = {};
|
|
123
|
+
const connected = await provider.connect({
|
|
124
|
+
voiceSessionId: id,
|
|
125
|
+
agentSessionId,
|
|
126
|
+
...memory !== void 0 && memory.items.length > 0 ? { memory } : {},
|
|
127
|
+
emit: (event) => {
|
|
128
|
+
const live = holder.live;
|
|
129
|
+
if (live === void 0 || this.sessions.get(id) !== live) return;
|
|
130
|
+
this.acceptProviderEvent(live, event);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
const info = {
|
|
134
|
+
id,
|
|
135
|
+
agentSessionId,
|
|
136
|
+
audio: connected.audio,
|
|
137
|
+
interactionMode: connected.interactionMode
|
|
138
|
+
};
|
|
139
|
+
const live = {
|
|
140
|
+
info,
|
|
141
|
+
provider: connected,
|
|
142
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
143
|
+
commands: /* @__PURE__ */ new Map(),
|
|
144
|
+
attached: true
|
|
145
|
+
};
|
|
146
|
+
holder.live = live;
|
|
147
|
+
this.sessions.set(id, live);
|
|
148
|
+
this.ctx.emit("voice/session-opened", info);
|
|
149
|
+
return info;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Subscribe to one session's events; a failing listener is logged without starving later listeners.
|
|
153
|
+
* @param id - voice session.
|
|
154
|
+
* @param listener - event receiver.
|
|
155
|
+
* @returns disposer.
|
|
156
|
+
*/
|
|
157
|
+
subscribe(id, listener) {
|
|
158
|
+
const live = this.requireAttachedSession(id);
|
|
159
|
+
live.listeners.add(listener);
|
|
160
|
+
return () => {
|
|
161
|
+
live.listeners.delete(listener);
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Forward microphone PCM.
|
|
166
|
+
* @param id - voice session.
|
|
167
|
+
* @param audio - PCM bytes.
|
|
168
|
+
*/
|
|
169
|
+
appendAudio(id, audio) {
|
|
170
|
+
this.requireAttachedSession(id).provider.appendAudio(audio);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Commit current microphone input.
|
|
174
|
+
* @param id - voice session.
|
|
175
|
+
*/
|
|
176
|
+
commitAudio(id) {
|
|
177
|
+
this.requireAttachedSession(id).provider.commitAudio();
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Cancel current provider speech.
|
|
181
|
+
* @param id - voice session.
|
|
182
|
+
*/
|
|
183
|
+
interruptResponse(id) {
|
|
184
|
+
this.requireAttachedSession(id).provider.interruptResponse();
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Report local playback completion.
|
|
188
|
+
* @param id - voice session.
|
|
189
|
+
*/
|
|
190
|
+
playbackEnded(id) {
|
|
191
|
+
this.requireAttachedSession(id).provider.playbackEnded();
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Append model-independent task state.
|
|
195
|
+
* @param id - voice session.
|
|
196
|
+
* @param event - observation.
|
|
197
|
+
*/
|
|
198
|
+
appendTaskObservation(id, event) {
|
|
199
|
+
const live = this.requireAttachedSession(id);
|
|
200
|
+
live.provider.appendTaskObservation(event);
|
|
201
|
+
this.publish(live, {
|
|
202
|
+
type: "task.observation",
|
|
203
|
+
observation: event
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/** Queue one already-rewritten speech fragment for the attached provider. */
|
|
207
|
+
appendSpeechText(id, text) {
|
|
208
|
+
const provider = this.requireAttachedSession(id).provider;
|
|
209
|
+
if (provider.appendSpeechText === void 0) return false;
|
|
210
|
+
provider.appendSpeechText(text);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
/** Whether the attached provider supports independent speech fragments. */
|
|
214
|
+
supportsSpeechText(id) {
|
|
215
|
+
return this.requireAttachedSession(id).provider.appendSpeechText !== void 0;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Ask the provider to speak pending observations.
|
|
219
|
+
* @param id - voice session.
|
|
220
|
+
* @param policy - response policy.
|
|
221
|
+
*/
|
|
222
|
+
requestResponse(id, policy) {
|
|
223
|
+
this.requireAttachedSession(id).provider.requestResponse(policy);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Complete one admitted frontend command exactly once.
|
|
227
|
+
* @param id - voice session.
|
|
228
|
+
* @param callId - provider command call.
|
|
229
|
+
* @param result - typed bridge result.
|
|
230
|
+
*/
|
|
231
|
+
completeTaskCommand(id, callId, result) {
|
|
232
|
+
const live = this.requireSession(id);
|
|
233
|
+
const record = live.commands.get(callId);
|
|
234
|
+
if (record === void 0) throw new Error(`voice command call "${callId}" is not pending`);
|
|
235
|
+
if (record.result !== void 0) throw new Error(`voice command call "${callId}" is already completed`);
|
|
236
|
+
live.provider.completeTaskCommand(callId, result);
|
|
237
|
+
record.result = result;
|
|
238
|
+
}
|
|
239
|
+
/** Submit one validated command from a non-provider frontend control. */
|
|
240
|
+
submitTaskCommand(id, call) {
|
|
241
|
+
this.acceptTaskCommand(this.requireAttachedSession(id), call);
|
|
242
|
+
}
|
|
243
|
+
/** Route browser-typed text through the same transcript and task path as recognized speech. */
|
|
244
|
+
submitText(id, text) {
|
|
245
|
+
const value = text.trim();
|
|
246
|
+
if (value === "") throw new Error("voice text submission requires non-empty text");
|
|
247
|
+
const live = this.requireAttachedSession(id);
|
|
248
|
+
const utteranceId = VoiceUtteranceId(`typed:${randomUUID()}`);
|
|
249
|
+
live.provider.interruptResponse();
|
|
250
|
+
this.publish(live, {
|
|
251
|
+
type: "transcription.started",
|
|
252
|
+
utteranceId
|
|
253
|
+
});
|
|
254
|
+
this.publish(live, {
|
|
255
|
+
type: "transcription.completed",
|
|
256
|
+
utteranceId,
|
|
257
|
+
text: value
|
|
258
|
+
});
|
|
259
|
+
this.acceptTaskCommand(live, {
|
|
260
|
+
id: VoiceCommandCallId(`typed:${randomUUID()}`),
|
|
261
|
+
command: {
|
|
262
|
+
type: "route_transcription",
|
|
263
|
+
input: value
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Detach the browser while retaining the provider conversation for bounded reconnection.
|
|
269
|
+
* @param id - voice session.
|
|
270
|
+
*/
|
|
271
|
+
detach(id) {
|
|
272
|
+
const live = this.sessions.get(id);
|
|
273
|
+
if (live === void 0 || !live.attached) return;
|
|
274
|
+
live.attached = false;
|
|
275
|
+
live.listeners.clear();
|
|
276
|
+
this.ctx.emit("voice/session-detached", live.info);
|
|
277
|
+
try {
|
|
278
|
+
live.provider.interruptResponse();
|
|
279
|
+
} catch (error) {
|
|
280
|
+
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
|
|
281
|
+
}
|
|
282
|
+
live.detachTimer = setTimeout(() => {
|
|
283
|
+
delete live.detachTimer;
|
|
284
|
+
this.close(id).catch((error) => {
|
|
285
|
+
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
|
|
286
|
+
});
|
|
287
|
+
}, this.reconnectGraceMs);
|
|
288
|
+
live.detachTimer.unref();
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Final-close one provider conversation without disposing its Agent.
|
|
292
|
+
* @param id - voice session.
|
|
293
|
+
*/
|
|
294
|
+
async close(id) {
|
|
295
|
+
const live = this.sessions.get(id);
|
|
296
|
+
if (live === void 0) return;
|
|
297
|
+
this.sessions.delete(id);
|
|
298
|
+
if (live.detachTimer !== void 0) clearTimeout(live.detachTimer);
|
|
299
|
+
live.attached = false;
|
|
300
|
+
live.listeners.clear();
|
|
301
|
+
this.ctx.emit("voice/session-closed", live.info);
|
|
302
|
+
await live.provider.close();
|
|
303
|
+
}
|
|
304
|
+
publish(live, event) {
|
|
305
|
+
this.ctx.emit("voice/session-event", live.info, event);
|
|
306
|
+
for (const listener of live.listeners) try {
|
|
307
|
+
listener(event);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
acceptProviderEvent(live, event) {
|
|
313
|
+
if (event.type === "closed") {
|
|
314
|
+
this.publish(live, event);
|
|
315
|
+
this.close(live.info.id).catch((error) => {
|
|
316
|
+
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (!live.attached && event.type !== "task.command") return;
|
|
321
|
+
if (event.type !== "task.command") {
|
|
322
|
+
this.publish(live, event);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
this.acceptTaskCommand(live, event.call);
|
|
326
|
+
}
|
|
327
|
+
acceptTaskCommand(live, call) {
|
|
328
|
+
const fingerprint = commandFingerprint(call);
|
|
329
|
+
const existing = live.commands.get(call.id);
|
|
330
|
+
if (existing === void 0) {
|
|
331
|
+
if (live.commands.size >= this.maxCommandCalls) {
|
|
332
|
+
live.provider.completeTaskCommand(call.id, {
|
|
333
|
+
kind: "rejected",
|
|
334
|
+
code: "capacity_exceeded",
|
|
335
|
+
message: `voice session already tracks ${String(this.maxCommandCalls)} command calls`
|
|
336
|
+
});
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
live.commands.set(call.id, { fingerprint });
|
|
340
|
+
this.publish(live, {
|
|
341
|
+
type: "task.command",
|
|
342
|
+
call
|
|
343
|
+
});
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (existing.fingerprint !== fingerprint) {
|
|
347
|
+
this.publish(live, {
|
|
348
|
+
type: "error",
|
|
349
|
+
message: `voice command call "${call.id}" was reused with different arguments`
|
|
350
|
+
});
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (existing.result !== void 0) live.provider.completeTaskCommand(call.id, existing.result);
|
|
354
|
+
}
|
|
355
|
+
requireSession(id) {
|
|
356
|
+
const live = this.sessions.get(id);
|
|
357
|
+
if (live === void 0) throw new Error(`voice session "${id}" is not open`);
|
|
358
|
+
return live;
|
|
359
|
+
}
|
|
360
|
+
requireAttachedSession(id) {
|
|
361
|
+
const live = this.requireSession(id);
|
|
362
|
+
if (!live.attached) throw new Error(`voice session "${id}" is detached`);
|
|
363
|
+
return live;
|
|
364
|
+
}
|
|
365
|
+
resolveProvider() {
|
|
366
|
+
if (this.configuredProvider !== void 0) {
|
|
367
|
+
const provider = this.providers.get(this.configuredProvider);
|
|
368
|
+
if (provider === void 0) throw new Error(`configured voice provider "${this.configuredProvider}" is not registered`);
|
|
369
|
+
if (!provider.available()) throw new Error(`configured voice provider "${this.configuredProvider}" is unavailable`);
|
|
370
|
+
return provider;
|
|
371
|
+
}
|
|
372
|
+
const available = [...this.providers.values()].filter((provider) => provider.available());
|
|
373
|
+
if (available.length !== 1) throw new Error(available.length === 0 ? "no usable voice provider is registered" : "multiple usable voice providers are registered; configure one explicitly");
|
|
374
|
+
return available[0];
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
function commandFingerprint(call) {
|
|
378
|
+
const command = call.command;
|
|
379
|
+
switch (command.type) {
|
|
380
|
+
case "route_transcription": return JSON.stringify([command.type, command.input]);
|
|
381
|
+
case "realtime_delegation": return JSON.stringify([
|
|
382
|
+
command.type,
|
|
383
|
+
command.input,
|
|
384
|
+
command.transcriptDelta
|
|
385
|
+
]);
|
|
386
|
+
case "send_task_message": return JSON.stringify([
|
|
387
|
+
command.type,
|
|
388
|
+
command.taskId,
|
|
389
|
+
command.message
|
|
390
|
+
]);
|
|
391
|
+
case "cancel_task": return JSON.stringify([command.type, command.taskId]);
|
|
392
|
+
/* v8 ignore next -- closed-union exhaustiveness guard */
|
|
393
|
+
default: return assertNever(command);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/* v8 ignore next -- only reachable through the closed-union guard */
|
|
397
|
+
function assertNever(value) {
|
|
398
|
+
throw new Error(`unexpected task command: ${JSON.stringify(value)}`);
|
|
399
|
+
}
|
|
400
|
+
//#endregion
|
|
401
|
+
export { VoiceCommandCallId, VoiceResponseId, VoiceRuntime, VoiceRuntime as default, VoiceSessionId, VoiceTaskId, VoiceTaskMessageId, VoiceUtteranceId };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Package invariant companion. @module @flowingspring/dsh-voco/invariant */
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
3
|
+
export declare const name = "voco-invariant";
|
|
4
|
+
export declare const inject: string[];
|
|
5
|
+
/** Register invariant ownership. @param ctx - runtime context. @returns disposer. */
|
|
6
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
7
|
+
//# sourceMappingURL=invariant.d.ts.map
|