@olares/lares-core 0.15.17 → 0.15.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/i18n/chat-model-switch.js +8 -2
  2. package/i18n/voice.js +78 -2
  3. package/i18n/webhook.js +39 -0
  4. package/larepass/mux.js +1 -0
  5. package/larepass/rpc.js +4 -3
  6. package/larepass/runtime.js +4 -1
  7. package/larepass/settings.js +11 -8
  8. package/package.json +29 -2
  9. package/router/catalog-cache.js +1 -1
  10. package/router/catalog-coordinator.js +60 -0
  11. package/router/catalog-sync.js +17 -62
  12. package/router/catalog.d.ts +3 -1
  13. package/router/catalog.js +6 -13
  14. package/router/default-selection.d.ts +3 -0
  15. package/router/default-selection.js +22 -0
  16. package/router/model-authorization.js +53 -0
  17. package/router/model-state.js +35 -0
  18. package/router/models.js +2 -6
  19. package/router/session-model.js +5 -18
  20. package/router/stt.js +2 -1
  21. package/router/tts.js +13 -20
  22. package/voice/activity.js +10 -0
  23. package/voice/agent-session.js +4 -0
  24. package/voice/browser-capture.js +72 -6
  25. package/voice/browser-playback.js +16 -20
  26. package/voice/client.js +28 -14
  27. package/voice/composite-gateway.js +7 -3
  28. package/voice/config.js +6 -0
  29. package/voice/controller.js +19 -8
  30. package/voice/conversation.js +48 -15
  31. package/voice/device-input.js +14 -9
  32. package/voice/dialogue.js +4 -3
  33. package/voice/host-service.js +7 -2
  34. package/voice/interruption.js +44 -0
  35. package/voice/lares-policy.js +12 -0
  36. package/voice/options.js +2 -1
  37. package/voice/panel.js +61 -20
  38. package/voice/pcm-player.js +51 -0
  39. package/voice/pcm-worklet.js +71 -0
  40. package/voice/preferences.js +89 -0
  41. package/voice/presentation.js +13 -1
  42. package/voice/reply-stream.js +99 -0
  43. package/voice/router-audio.js +23 -6
  44. package/voice/script.js +27 -36
  45. package/voice/service.js +46 -33
  46. package/voice/shortcuts.js +54 -5
  47. package/voice/speech-segmenter.js +119 -0
  48. package/voice/speech.js +60 -13
  49. package/voice/spoken-text.js +196 -0
  50. package/voice/startup.js +9 -0
  51. package/voice/stream-client.js +165 -0
  52. package/voice/stream-gateway.js +146 -0
  53. package/voice/tts-preview.js +27 -0
  54. package/voice/tts-settings.js +79 -0
  55. package/voice/tts-voice.js +20 -0
  56. package/webhook/client.js +34 -0
  57. package/webhook/config.js +55 -0
  58. package/webhook/public.js +11 -0
  59. package/webhook/reply.js +12 -0
@@ -0,0 +1,165 @@
1
+ import { postReport } from "../log/report.js";
2
+ import { PCM_CAPTURE_WORKLET } from "./pcm-worklet.js";
3
+ import { createPcmResampler } from "./realtime-audio.js";
4
+ import { createPcmStreamPlayer } from "./pcm-player.js";
5
+ import { wavBlob } from "./client.js";
6
+ import { createBrowserPlayer } from "./browser-playback.js";
7
+
8
+ export function browserAudioSocket(ticket) {
9
+ const url = new URL("/api/lares/voice/stream", globalThis.location.href); url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
10
+ return new WebSocket(url, ["lares-voice", `lares-ticket.${ticket}`]);
11
+ }
12
+
13
+ export async function openAudioStream({ request, headers, operation, model, language, text, source, signal, socket = browserAudioSocket }) {
14
+ const response = await request("/api/lares/voice/stream/ticket", { method: "POST", headers, signal, body: { operation, model, language, text, source } });
15
+ if (!response?.ok) throw new Error(response?.body?.error?.code || "voice_stream_failed");
16
+ if (!response.body.supported) return null;
17
+ if (signal?.aborted) throw new Error("voice_cancelled");
18
+ const connection = socket(response.body.ticket); connection.binaryType = "arraybuffer";
19
+ const cancel = () => connection.close();
20
+ signal?.addEventListener("abort", cancel, { once: true });
21
+ connection.addEventListener("close", () => signal?.removeEventListener("abort", cancel), { once: true });
22
+ return connection;
23
+ }
24
+
25
+ /** Plays one confirmed reply. Receiving audio and rendering it run independently. */
26
+ function streamMetric(options, event) {
27
+ if (options.metric) { options.metric(event); return; }
28
+ void postReport({ scope: "voice", event: "metric", voiceId: options.headers?.["x-lares-voice-id"], utteranceKey: options.key, at: Date.now(), ...event }, {
29
+ request: (path, init) => options.request(path, { ...init, headers: { ...init.headers, ...options.headers } }),
30
+ });
31
+ }
32
+
33
+ export async function playAudioStream(options) {
34
+ const started = Date.now();
35
+ const connection = await openAudioStream({ ...options, operation: "tts" });
36
+ if (!connection) {
37
+ const response = await options.request("/api/lares/voice/speak", { method: "POST", headers: options.headers, signal: options.signal, body: { text: options.text } });
38
+ if (!response?.ok) throw new Error(response?.body?.error?.code || "voice_stream_failed");
39
+ if (response.body.sentences === 0) return true;
40
+ if (response.body.bluetooth === "played") return true;
41
+ if (options.source) throw new Error("voice_reply_unavailable");
42
+ const fallback = createBrowserPlayer();
43
+ options.controls?.({ canPause: true, pause: () => fallback.pause(), resume: () => fallback.resume() });
44
+ options.onStatus?.("playing");
45
+ const cancel = () => fallback.stop(); options.signal?.addEventListener("abort", cancel, { once: true });
46
+ try { if (!options.signal?.aborted) await fallback.play(wavBlob(response.body.audio)); } finally { options.signal?.removeEventListener("abort", cancel); fallback.stop(); }
47
+ return true;
48
+ }
49
+ const file = createBrowserPlayer();
50
+ const player = createPcmStreamPlayer({ onProgress: (frames) => { if (connection.readyState === 1) connection.send(JSON.stringify({ type: "progress", frames })); },
51
+ onMetric: (event) => { if (event.stage === "playback_started") options.onStatus?.("playing"); streamMetric(options, { ...event, durationMs: Date.now() - started }); } });
52
+ let chain = Promise.resolve(), complete = false, firstReceived = false, ready = false, paused = false, segmentIndex = -1;
53
+ options.controls?.({
54
+ get canPause() { return ready; },
55
+ pause() { if (!ready || paused) return; paused = true; player.pause(); file.pause(); if (connection.readyState === 1) connection.send(JSON.stringify({ type: "pause" })); },
56
+ resume() { if (!ready || !paused) return; paused = false; player.resume(); void file.resume()?.catch(() => connection.close()); if (connection.readyState === 1) connection.send(JSON.stringify({ type: "resume" })); },
57
+ });
58
+ try {
59
+ await new Promise((resolve, reject) => {
60
+ const fail = (error) => { reject(error); connection.close(); };
61
+ const cancelled = () => { player.stop(); file.stop(); fail(new Error("voice_cancelled")); };
62
+ options.signal?.addEventListener("abort", cancelled, { once: true });
63
+ connection.addEventListener("message", ({ data }) => {
64
+ if (typeof data !== "string" && !firstReceived) { firstReceived = true; streamMetric(options, { stage: "playback_first_received", durationMs: Date.now() - started }); }
65
+ chain = chain.then(async () => {
66
+ if (options.signal?.aborted) throw new Error("voice_cancelled");
67
+ if (typeof data !== "string") { player.write(new Uint8Array(data)); return; }
68
+ const event = JSON.parse(data);
69
+ if (event.type === "segment") {
70
+ if (event.index !== segmentIndex + 1) throw new Error("voice_reply_sequence");
71
+ segmentIndex = event.index;
72
+ }
73
+ else if (event.type === "segment.end") options.onSegment?.(event);
74
+ else if (event.type === "format") { await player.start(event); ready = true; if (paused) player.pause(); }
75
+ else if (event.type === "file") { ready = true; options.onStatus?.("playing"); await file.play(wavBlob(event.audio)); if (connection.readyState === 1) connection.send(JSON.stringify({ type: "file.done" })); }
76
+ else if (event.type === "error") throw new Error(event.code || "voice_stream_failed");
77
+ else if (event.type === "done") { await player.finish(); complete = true; resolve(); }
78
+ }).catch(fail);
79
+ });
80
+ connection.addEventListener("error", () => fail(new Error("voice_disconnected")), { once: true });
81
+ connection.addEventListener("close", () => { options.signal?.removeEventListener("abort", cancelled); if (!complete) reject(new Error("voice_disconnected")); }, { once: true });
82
+ if (options.signal?.aborted) cancelled();
83
+ });
84
+ return true;
85
+ } finally { player.stop(); file.stop(); connection.close(); }
86
+ }
87
+
88
+ /** A single final ASR result; partials never submit a task or replace the draft. */
89
+ export async function startTranscriptionStream(options) {
90
+ const connection = await openAudioStream({ ...options, operation: "stt" });
91
+ if (!connection) return null;
92
+ let final = null, finished = false, completed = false, failed = false, finishAt = 0, resolve, reject;
93
+ const result = new Promise((yes, no) => { resolve = yes; reject = no; });
94
+ void result.catch(() => {});
95
+ let readyResolve, readyReject;
96
+ const ready = new Promise((yes, no) => { readyResolve = yes; readyReject = no; });
97
+ const fail = (error) => {
98
+ if (completed || failed) return;
99
+ failed = true;
100
+ const code = /^voice_[a-z_]+$/.test(error.message) ? error.message : "voice_stream_failed";
101
+ if (code !== "voice_cancelled" && !options.signal?.aborted) void postReport({ scope: "voice", event: "stream-error", stage: "recognition", code }, {
102
+ request: (path, init) => options.request(path, { ...init, headers: { ...init.headers, ...options.headers } }),
103
+ });
104
+ readyReject(error); reject(error); connection.close();
105
+ };
106
+ connection.addEventListener("message", ({ data }) => {
107
+ try {
108
+ const event = JSON.parse(data);
109
+ if (event.type === "ready") readyResolve();
110
+ else if (event.type === "partial") options.onPartial?.(event.text);
111
+ else if (event.type === "final") final = event.text;
112
+ else if (event.type === "done" && finished && final !== null) { completed = true; streamMetric(options, { stage: "recognition_final_tail", durationMs: Date.now() - finishAt }); resolve(final); }
113
+ else if (event.type === "error") fail(new Error(event.code));
114
+ } catch { fail(new Error("voice_stream_failed")); }
115
+ });
116
+ connection.addEventListener("close", () => fail(new Error("voice_disconnected")), { once: true });
117
+ connection.addEventListener("error", () => fail(new Error("voice_disconnected")), { once: true });
118
+ if (options.signal?.aborted) fail(new Error("voice_cancelled"));
119
+ await ready;
120
+ return {
121
+ write(bytes) { if (finished || connection.readyState !== 1 || connection.bufferedAmount > 256 * 1024) throw new Error("voice_audio_backpressure"); connection.send(bytes); },
122
+ async finish() { finishAt = Date.now(); finished = true; connection.send(JSON.stringify({ type: "finish" })); try { return await result; } finally { connection.close(); } },
123
+ cancel() { fail(new Error("voice_cancelled")); },
124
+ };
125
+ }
126
+
127
+ export async function createStreamingCapture(stream, options) {
128
+ const port = await startTranscriptionStream(options);
129
+ if (!port) return null;
130
+ let context, source, node, failure, flushed;
131
+ const release = () => {
132
+ source?.disconnect(); source = null;
133
+ node?.disconnect(); if (node) node.port.onmessage = null; node = null;
134
+ const opened = context; context = null;
135
+ if (opened && opened.state !== "closed") void opened.close().catch(() => {});
136
+ };
137
+ try {
138
+ const Context = globalThis.AudioContext || globalThis.webkitAudioContext;
139
+ context = new Context();
140
+ const url = URL.createObjectURL(new Blob([PCM_CAPTURE_WORKLET], { type: "text/javascript" }));
141
+ try { await context.audioWorklet.addModule(url); await context.resume(); } finally { URL.revokeObjectURL(url); }
142
+ if (options.signal?.aborted) throw new Error("voice_cancelled");
143
+ const convert = createPcmResampler(context.sampleRate, 16000);
144
+ source = context.createMediaStreamSource(stream);
145
+ node = new AudioWorkletNode(context, "lares-pcm-capture", { numberOfOutputs: 1, outputChannelCount: [1] });
146
+ node.port.onmessage = ({ data }) => {
147
+ if (data.type === "flushed") { flushed?.(); return; }
148
+ try { port.write(convert(data.samples)); } catch (cause) { failure = cause; port.cancel(); }
149
+ };
150
+ source.connect(node); node.connect(context.destination);
151
+ return {
152
+ async finish() {
153
+ try {
154
+ await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("voice_timeout")), 2000); flushed = () => { clearTimeout(timer); resolve(); }; node.port.postMessage({ type: "flush" }); });
155
+ // No capture messages may follow the ASR commit, even while finalization waits.
156
+ release();
157
+ if (failure) throw failure;
158
+ return await port.finish();
159
+ } catch (cause) { port.cancel(); throw cause; }
160
+ finally { release(); }
161
+ },
162
+ cancel() { port.cancel(); release(); },
163
+ };
164
+ } catch (cause) { port.cancel(); release(); throw cause; }
165
+ }
@@ -0,0 +1,146 @@
1
+ import { ttsVoiceForModel } from "./tts-voice.js";
2
+ import { speechSegments } from "./script.js";
3
+ import { randomUUID } from "node:crypto";
4
+ import { WebSocket, WebSocketServer } from "ws";
5
+ import { synthesizeStream } from "../router/tts.js";
6
+ import { pickSttModelId, pickTtsModelId, VoiceError } from "../router/stt.js";
7
+ import { routerGatewayUrl, routerAuthHeaders } from "../router/gateway.js";
8
+
9
+ const fault = (code, status = 409) => new VoiceError(code, status, code);
10
+
11
+ /** Single-use HTTP grants bind raw audio sockets to the authenticated Host scope. */
12
+ export function createAudioStreamGateway({ config, models, replies, synthesize = synthesizeStream, env = process.env, metric = () => {} }) {
13
+ const tickets = new Map(), clients = new Set();
14
+ const server = new WebSocketServer({ noServer: true, maxPayload: 96 * 1024, perMessageDeflate: false,
15
+ handleProtocols: (values) => values.has("lares-voice") ? "lares-voice" : false });
16
+ return {
17
+ async ticket(scope, body) {
18
+ if (scope.device !== "device") return { supported: false };
19
+ if (!["tts", "stt"].includes(body.operation)) throw fault("voice_config_invalid", 400);
20
+ const tts = body.operation === "tts";
21
+ const snapshot = structuredClone(scope.voiceConfig || config());
22
+ if (tts && (scope.presentationSession || !scope.active)) {
23
+ const current = config();
24
+ snapshot.ttsModel = current.ttsModel; snapshot.voice = current.voice;
25
+ snapshot.ttsVoiceByModel = structuredClone(current.ttsVoiceByModel || {});
26
+ }
27
+ const rows = await models();
28
+ if (tts && !(scope.active ? scope.readAloud : snapshot.speak)) throw fault("voice_speak_disabled");
29
+ const selected = tts ? pickTtsModelId(rows, snapshot.ttsModel) : pickSttModelId(rows, body.model || snapshot.model);
30
+ if (!selected || tts && snapshot.ttsModel && selected !== snapshot.ttsModel) throw fault("voice_model_unavailable", 503);
31
+ if (!tts && !rows.find((row) => row.id === selected)?.supportsSttStream) return { supported: false };
32
+ if (tts && !body.source && (typeof body.text !== "string" || !body.text.trim() || body.text.length > 8192)) throw fault("voice_config_invalid", 400);
33
+ for (const [key, grant] of tickets) if (grant.expires < Date.now()) tickets.delete(key);
34
+ if (tickets.size >= 128 || clients.size >= 64) throw fault("voice_busy", 429);
35
+ const reply = tts && body.source ? await replies?.bind(scope, body.source) : null;
36
+ if (body.source && !reply) throw fault("voice_reply_unavailable");
37
+ const ticket = randomUUID();
38
+ tickets.set(ticket, { scope, model: selected, operation: body.operation, text: body.text, reply,
39
+ config: snapshot, language: body.language || snapshot.language, expires: Date.now() + 30000 });
40
+ return { supported: true, ticket };
41
+ },
42
+ upgrade(req, socket, head) {
43
+ const token = String(req.headers["sec-websocket-protocol"] || "").split(",").map((value) => value.trim()).find((value) => value.startsWith("lares-ticket."))?.slice(13);
44
+ const grant = tickets.get(token); tickets.delete(token);
45
+ if (!grant || grant.expires < Date.now()) { socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); return; }
46
+ server.handleUpgrade(req, socket, head, (client) => {
47
+ clients.add(client);
48
+ const abort = new AbortController();
49
+ let upstream = null, sentFrames = 0, playedFrames = 0, rate = 0, waiter = null, outputFormat = null;
50
+ let fileDone = null, pausedAt = 0, closing = false;
51
+ let received = 0, committed = false, outputStarted = false;
52
+ const started = Date.now();
53
+ const send = (value) => { if (client.readyState === 1) client.send(JSON.stringify(value)); };
54
+ const close = () => { if (closing) return; closing = true; abort.abort(); waiter?.(); fileDone?.(); clearTimeout(lifetime); upstream?.terminate(); clients.delete(client); client.close(); };
55
+ const error = (cause) => { send({ type: "error", code: cause.code || "voice_stream_failed" }); close(); };
56
+ const lifetime = setTimeout(() => error(fault("voice_timeout")), 180000);
57
+ client.on("close", close); client.on("error", close);
58
+ client.on("message", (raw, binary) => {
59
+ try {
60
+ if (grant.operation === "stt") {
61
+ if (!upstream || upstream.readyState !== 1 || committed) throw fault("voice_stream_state");
62
+ if (binary) {
63
+ received += raw.length;
64
+ if (raw.length % 2 || received > 16000 * 2 * 60 || upstream.bufferedAmount > 256 * 1024) throw fault("voice_audio_backpressure");
65
+ upstream.send(raw);
66
+ } else {
67
+ const frame = JSON.parse(String(raw));
68
+ if (frame.type !== "finish") throw fault("voice_stream_state");
69
+ committed = true; upstream.send(JSON.stringify({ type: "finish" }));
70
+ }
71
+ } else {
72
+ if (binary) throw fault("voice_stream_state");
73
+ const frame = JSON.parse(String(raw));
74
+ if (frame.type === "pause") { if (!pausedAt) pausedAt = Date.now(); waiter?.(); waiter = null; return; }
75
+ if (frame.type === "resume") { pausedAt = 0; waiter?.(); waiter = null; return; }
76
+ if (frame.type === "file.done" && fileDone) { fileDone(); fileDone = null; return; }
77
+ if (frame.type !== "progress" || !Number.isSafeInteger(frame.frames) || frame.frames < playedFrames || frame.frames > sentFrames) throw fault("voice_stream_state");
78
+ playedFrames = frame.frames; waiter?.(); waiter = null;
79
+ }
80
+ } catch (cause) { error(cause); }
81
+ });
82
+ if (grant.operation === "stt") {
83
+ upstream = new WebSocket(`${routerGatewayUrl(env).replace(/^http/, "ws")}/audio/stream?model=${encodeURIComponent(grant.model)}`, {
84
+ headers: routerAuthHeaders(env.LARES_ROUTER_API_KEY, env.OLARES_APP_ID, grant.scope.owner), handshakeTimeout: 15000, maxPayload: 96 * 1024 });
85
+ upstream.on("open", () => { upstream.send(JSON.stringify({ type: "start", sample_rate: 16000, language: grant.language || undefined })); send({ type: "ready" }); });
86
+ upstream.on("message", (raw, binary) => {
87
+ try {
88
+ if (binary) throw fault("voice_audio_format");
89
+ const value = JSON.parse(String(raw));
90
+ if (["partial", "final"].includes(value.type)) send({ type: value.type, text: String(value.text || "") });
91
+ else if (value.type === "error") error(fault("voice_transcribe_failed", 502));
92
+ else if (value.type === "closed") { if (!committed) throw fault("voice_stream_state"); send({ type: "done" }); }
93
+ } catch (cause) { error(cause); }
94
+ });
95
+ upstream.on("error", error);
96
+ upstream.on("close", () => { send({ type: "eof" }); close(); });
97
+ } else {
98
+ // A stop/new output owns the scope's existing duplex lease too.
99
+ const lease = grant.scope.duplex.beginSpeech(abort.signal);
100
+ const segments = grant.reply ? grant.reply(lease.signal) : speechSegments(grant.text, { done: true }).map((text, index) => ({ text, index }));
101
+ let carry = null;
102
+ const run = async () => { for await (const segment of segments) {
103
+ if (!lease.active()) throw fault("voice_cancelled");
104
+ const { text, index } = segment;
105
+ metric({ stage: "synthesis_segment", voiceId: grant.scope.id, index, chars: text.length, at: Date.now() });
106
+ send({ type: "segment", index, attemptId: segment.attemptId, startFrame: sentFrames });
107
+ const result = await synthesize({ text, model: grant.model, voice: ttsVoiceForModel(grant.config, grant.model), signal: lease.signal,
108
+ onFormat(format) { if (outputFormat && (format.channels !== outputFormat.channels || format.encoding !== outputFormat.encoding)) throw fault("voice_audio_format"); outputFormat = format; if (!rate) { rate = format.sampleRate; send({ type: "format", ...format }); } else if (rate !== format.sampleRate) throw fault("voice_audio_format"); },
109
+ }, async (chunk) => {
110
+ if (!lease.active()) throw fault("voice_cancelled");
111
+ if (!rate) throw fault("voice_audio_format", 502);
112
+ if (carry !== null) { chunk = Buffer.concat([Buffer.from([carry]), chunk]); carry = null; }
113
+ if (chunk.length % 2) { carry = chunk[chunk.length - 1]; chunk = chunk.subarray(0, -1); }
114
+ for (let offset = 0; offset < chunk.length; offset += 16384) {
115
+ while (pausedAt || sentFrames - playedFrames > rate * 2 || client.bufferedAmount > 128 * 1024) {
116
+ await new Promise((resolve, reject) => { const timer = setTimeout(() => { waiter = null; reject(fault(pausedAt ? "voice_interruption_timeout" : "voice_audio_backpressure")); }, pausedAt ? Math.max(1, 10000 - (Date.now() - pausedAt)) : 10000); waiter = () => { clearTimeout(timer); resolve(); }; });
117
+ if (!lease.active() || abort.signal.aborted) throw fault("voice_cancelled");
118
+ }
119
+ if (!lease.active() || client.readyState !== 1) throw fault("voice_cancelled");
120
+ const part = chunk.subarray(offset, offset + 16384);
121
+ sentFrames += part.length / 2;
122
+ client.send(part);
123
+ if (!outputStarted) { outputStarted = true; metric({ stage: "synthesis_first_audio", voiceId: grant.scope.id, at: Date.now(), durationMs: Date.now() - started }); }
124
+ }
125
+ });
126
+ send({ type: "segment.end", index, endFrame: sentFrames });
127
+ if (carry !== null) throw fault("voice_audio_format");
128
+ if (!result.streamed) {
129
+ if (rate) throw fault("voice_audio_format");
130
+ await new Promise((resolve, reject) => {
131
+ const timer = setTimeout(() => reject(fault("voice_timeout")), 120000);
132
+ fileDone = () => { clearTimeout(timer); resolve(); };
133
+ send({ type: "file", audio: result.audio.toString("base64") });
134
+ });
135
+ }
136
+ if (!lease.active()) throw fault("voice_cancelled");
137
+ } };
138
+ const wake = () => { waiter?.(); waiter = null; fileDone?.(); fileDone = null; close(); };
139
+ lease.signal.addEventListener("abort", wake, { once: true });
140
+ void run().then(() => { if (!abort.signal.aborted) send({ type: "done" }); }).catch(error).finally(() => { lease.signal.removeEventListener("abort", wake); lease.finish(); });
141
+ }
142
+ });
143
+ },
144
+ close() { tickets.clear(); for (const client of clients) client.terminate(); server.close(); },
145
+ };
146
+ }
@@ -0,0 +1,27 @@
1
+ /** Explicit audition is independent of automatic read-aloud and business sessions. */
2
+ export async function previewTts({ model, voice, text, signal }, { fetcher = fetch, createAudio = url => new Audio(url) } = {}) {
3
+ const response = await fetcher("/llm/v1/audio/speech", {
4
+ method: "POST", headers: { "content-type": "application/json" }, signal,
5
+ body: JSON.stringify({ model, input: text, response_format: "wav", ...(voice ? { voice } : {}) }),
6
+ });
7
+ if (!response.ok) throw new Error("tts_preview_failed");
8
+ const blob = await response.blob(); signal.throwIfAborted();
9
+ const url = URL.createObjectURL(blob);
10
+ try {
11
+ await new Promise((resolve, reject) => {
12
+ const audio = createAudio(url);
13
+ let done = false;
14
+ const finish = error => {
15
+ if (done) return; done = true;
16
+ audio.pause(); audio.removeAttribute?.("src"); audio.load?.();
17
+ signal.removeEventListener("abort", cancelled);
18
+ error ? reject(error) : resolve();
19
+ };
20
+ const cancelled = () => finish(signal.reason);
21
+ audio.addEventListener("ended", () => finish(), { once: true });
22
+ audio.addEventListener("error", () => finish(new Error("tts_preview_failed")), { once: true });
23
+ signal.addEventListener("abort", cancelled, { once: true });
24
+ if (signal.aborted) cancelled(); else Promise.resolve(audio.play()).catch(finish);
25
+ });
26
+ } finally { URL.revokeObjectURL(url); }
27
+ }
@@ -0,0 +1,79 @@
1
+ import { ttsVoiceForModel, fetchTtsVoices } from "./tts-voice.js";
2
+ import { voiceActivity } from "./activity.js";
3
+
4
+ export const TTS_PREVIEW_TEXT = "你好,这是 Lares 的语音试听。";
5
+
6
+ export function ttsSettingsModel(value) {
7
+ const config = value?.config || {};
8
+ const ids = value?.ttsModels || [];
9
+ return config.ttsModel ? ids.includes(config.ttsModel) ? config.ttsModel : null : value?.ttsDefault || ids[0] || null;
10
+ }
11
+
12
+ /** Framework-neutral settings lifecycle; model switches invalidate both audio and directory results. */
13
+ export function createTtsSettings({ preferences, list = fetchTtsVoices, preview, activity = voiceActivity }) {
14
+ const initialModel = ttsSettingsModel(preferences.getSnapshot().value);
15
+ let state = { model: initialModel, voices: [], selected: ttsVoiceForModel(preferences.getSnapshot().value?.config || {}, initialModel) || "", loading: Boolean(initialModel), error: null, saving: false, playing: false, previewError: null, blocked: activity.getSnapshot(), unavailable: false };
16
+ const listeners = new Set();
17
+ let generation = 0, listing = null, playing = null, disposed = false;
18
+ const emit = patch => { if (disposed) return; state = { ...state, ...patch }; for (const listener of listeners) listener(); };
19
+ const stop = () => { playing?.abort(); playing = null; emit({ playing: false }); };
20
+ const selection = () => ttsVoiceForModel(preferences.getSnapshot().value?.config || {}, state.model) || "";
21
+ const updateSelection = () => {
22
+ const selected = selection();
23
+ emit({ selected, unavailable: Boolean(selected && !state.loading && !state.error && !state.voices.some(row => row.id === selected)) });
24
+ };
25
+ const load = async () => {
26
+ const token = ++generation; listing?.abort(); stop();
27
+ const model = ttsSettingsModel(preferences.getSnapshot().value);
28
+ emit({ model, voices: [], loading: Boolean(model), error: null, unavailable: false, previewError: null });
29
+ updateSelection();
30
+ if (!model) return;
31
+ const abort = listing = new AbortController();
32
+ try {
33
+ const voices = await list(model, { signal: AbortSignal.any([abort.signal, AbortSignal.timeout(10000)]) });
34
+ if (token !== generation || disposed) return;
35
+ emit({ voices, loading: false }); updateSelection();
36
+ } catch (error) {
37
+ if (token === generation && !disposed && !abort.signal.aborted) emit({ loading: false, error: "tts_voices_failed" });
38
+ }
39
+ };
40
+ let wasLoading = preferences.getSnapshot().loading, catalog = JSON.stringify(preferences.getSnapshot().value?.ttsModels || []);
41
+ const off = preferences.subscribe(() => {
42
+ const view = preferences.getSnapshot(), refreshed = wasLoading && !view.loading;
43
+ wasLoading = view.loading;
44
+ const nextCatalog = JSON.stringify(view.value?.ttsModels || []), changed = catalog !== nextCatalog; catalog = nextCatalog;
45
+ const model = ttsSettingsModel(preferences.getSnapshot().value);
46
+ if (model !== state.model || model && (refreshed || changed)) { void load(); return; }
47
+ if (selection() !== state.selected) { stop(); updateSelection(); }
48
+ });
49
+ const offActivity = activity.subscribe(() => { const blocked = activity.getSnapshot(); if (blocked) stop(); emit({ blocked }); });
50
+ return {
51
+ getSnapshot: () => state,
52
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
53
+ load,
54
+ stop,
55
+ async selectModel(model) {
56
+ if (state.saving) return;
57
+ stop(); emit({ saving: true, previewError: null });
58
+ try { await preferences.patch({ ttsModel: model }); }
59
+ finally { emit({ saving: false }); }
60
+ },
61
+ async selectVoice(voice) {
62
+ if (state.loading || state.error || state.saving || !state.model || voice && !state.voices.some(row => row.id === voice)) return;
63
+ const model = state.model; stop(); emit({ saving: true, previewError: null });
64
+ try { await preferences.saveTtsVoice(model, voice); updateSelection(); }
65
+ finally { emit({ saving: false }); }
66
+ },
67
+ async audition() {
68
+ if (state.playing) { stop(); return; }
69
+ if (!state.model || state.loading || state.error || state.saving || state.unavailable || state.blocked) return;
70
+ const abort = playing = new AbortController();
71
+ const take = { model: state.model, voice: state.selected || undefined, text: TTS_PREVIEW_TEXT, signal: AbortSignal.any([abort.signal, AbortSignal.timeout(60000)]) };
72
+ emit({ playing: true, previewError: null });
73
+ try { await preview(take); }
74
+ catch { if (playing === abort && !abort.signal.aborted) emit({ previewError: "tts_preview_failed" }); }
75
+ finally { if (playing === abort) { playing = null; emit({ playing: false }); } }
76
+ },
77
+ dispose() { generation++; listing?.abort(); stop(); off(); offActivity(); disposed = true; listeners.clear(); },
78
+ };
79
+ }
@@ -0,0 +1,20 @@
1
+ /** A legacy voice belongs only to the model for which it was explicitly saved. */
2
+ export function ttsVoiceForModel(config, model) {
3
+ if (Object.hasOwn(config.ttsVoiceByModel || {}, model)) return config.ttsVoiceByModel[model] || undefined;
4
+ return config.ttsModel === model ? config.voice || undefined : undefined;
5
+ }
6
+
7
+ export async function fetchTtsVoices(model, { signal, fetcher = fetch } = {}) {
8
+ const res = await fetcher(`/llm/v1/audio/voices?model=${encodeURIComponent(model)}`, { signal: signal ?? AbortSignal.timeout(10000) });
9
+ const body = await res.json().catch(() => null);
10
+ if ((res.status === 404 && /no preset voices/i.test(String(body?.detail || body?.error?.message || ""))) || res.status === 405 || res.status === 501) return [];
11
+ if (!res.ok) throw new Error("tts_voices_failed");
12
+ if (!Array.isArray(body?.voices)) throw new Error("tts_voices_failed");
13
+ const seen = new Set();
14
+ return body.voices.flatMap(row => {
15
+ const id = typeof row === "string" ? row : row?.id;
16
+ if (typeof id !== "string" || !id || seen.has(id)) return [];
17
+ seen.add(id);
18
+ return [{ id, label: typeof row?.name === "string" && row.name.trim() ? row.name : id }];
19
+ });
20
+ }
@@ -0,0 +1,34 @@
1
+ import { createSnapshotStore } from "../tools/async.js";
2
+
3
+ export const API = "/api/lares/webhook";
4
+
5
+ const settings = createSnapshotStore();
6
+
7
+ async function readPayload(res, path) {
8
+ const payload = await res.json().catch(() => ({}));
9
+ if (!res.ok) {
10
+ throw new Error(payload?.error?.message ?? payload?.error?.code ?? `${path} → ${res.status}`);
11
+ }
12
+ return settings.remember(payload);
13
+ }
14
+
15
+ async function post(path, body) {
16
+ const res = await fetch(`${API}${path}`, {
17
+ method: "POST",
18
+ headers: { "content-type": "application/json" },
19
+ body: JSON.stringify(body),
20
+ });
21
+ return readPayload(res, path);
22
+ }
23
+
24
+ export function rememberedWebhookSettings() {
25
+ return settings.peek();
26
+ }
27
+
28
+ export function loadWebhookSettings(options = {}) {
29
+ return settings.load(async () => readPayload(await fetch(API), "/"), options);
30
+ }
31
+
32
+ export function saveWebhookSettings(config) {
33
+ return post("/config", config);
34
+ }
@@ -0,0 +1,55 @@
1
+ import { isAbsolute, join } from "node:path";
2
+ import { HttpError } from "../tools/http.js";
3
+ import { readJsonFile, writeJsonFile } from "../tools/json-file.js";
4
+
5
+ export const WEBHOOK_ROUTE = "/api/lares/webhook";
6
+ const MAX_PROMPT = 16_000;
7
+ const MAX_PATH = 1024;
8
+
9
+ export function webhookConfigPath(dataDir) {
10
+ return join(dataDir, "webhook.json");
11
+ }
12
+
13
+ export function defaultWebhookConfig() {
14
+ return { enabled: false, workspacePath: "" };
15
+ }
16
+
17
+ /** @param {unknown} value */
18
+ export function normalizeWebhookConfig(value) {
19
+ const source = value && typeof value === "object" ? value : {};
20
+ const workspacePath = typeof source.workspacePath === "string" ? source.workspacePath.trim() : "";
21
+ if (workspacePath.length > MAX_PATH) throw new HttpError("bad_request", 400, "workspacePath is too long");
22
+ if (workspacePath && !isAbsolute(workspacePath)) {
23
+ throw new HttpError("bad_request", 400, "workspacePath must be an absolute path");
24
+ }
25
+ return {
26
+ enabled: source.enabled === true,
27
+ workspacePath,
28
+ };
29
+ }
30
+
31
+ export function loadWebhookConfig(dataDir) {
32
+ return normalizeWebhookConfig(readJsonFile(webhookConfigPath(dataDir)) ?? {});
33
+ }
34
+
35
+ export function saveWebhookConfig(dataDir, value) {
36
+ const config = normalizeWebhookConfig(value);
37
+ writeJsonFile(webhookConfigPath(dataDir), config);
38
+ return config;
39
+ }
40
+
41
+ /** @param {Record<string, unknown>} body */
42
+ export function webhookPrompt(body) {
43
+ const prompt = typeof body?.prompt === "string" ? body.prompt.trim() : "";
44
+ if (!prompt) throw new HttpError("bad_request", 400, "prompt is required");
45
+ if (prompt.length > MAX_PROMPT) throw new HttpError("bad_request", 400, "prompt is too long");
46
+ return prompt;
47
+ }
48
+
49
+ export function webhookView(config) {
50
+ return {
51
+ ...config,
52
+ endpoint: WEBHOOK_ROUTE,
53
+ jobsEndpoint: `${WEBHOOK_ROUTE}/jobs`,
54
+ };
55
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Public call URLs. The host is the webhook entrance, so the path is empty.
3
+ * POST submits a prompt; GET with ?id= reads that session.
4
+ *
5
+ * @param {string} [origin]
6
+ */
7
+ export function webhookCallUrls(origin) {
8
+ const root = String(origin ?? "").replace(/\/$/, "");
9
+ const base = root ? `${root}/` : "/";
10
+ return { post: base, jobs: `${base}?id=` };
11
+ }
@@ -0,0 +1,12 @@
1
+ import { textFromBlocks } from "../larepass/transcript.js";
2
+
3
+ /** Latest non-empty assistant text. An empty trailing message does not erase the previous reply. */
4
+ export function lastAssistantText(messages) {
5
+ let text = "";
6
+ for (const message of Array.isArray(messages) ? messages : []) {
7
+ if (message?.role !== "assistant") continue;
8
+ const next = textFromBlocks(message.content);
9
+ if (next) text = next;
10
+ }
11
+ return text;
12
+ }