@olares/lares-core 0.15.17 → 0.15.18
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/brand/identity.js +25 -2
- package/i18n/chat-model-switch.js +8 -2
- package/i18n/voice.js +78 -2
- package/i18n/webhook.js +39 -0
- package/larepass/mux.js +1 -0
- package/larepass/rpc.js +4 -3
- package/larepass/runtime.js +4 -1
- package/larepass/settings.js +11 -8
- package/package.json +29 -2
- package/router/catalog-cache.js +1 -1
- package/router/catalog-coordinator.js +60 -0
- package/router/catalog-sync.js +17 -62
- package/router/catalog.d.ts +3 -1
- package/router/catalog.js +6 -13
- package/router/default-selection.d.ts +3 -0
- package/router/default-selection.js +22 -0
- package/router/model-authorization.js +53 -0
- package/router/model-state.js +35 -0
- package/router/models.js +2 -6
- package/router/session-model.js +5 -18
- package/router/stt.js +2 -1
- package/router/tts.js +13 -20
- package/voice/activity.js +10 -0
- package/voice/agent-session.js +4 -0
- package/voice/browser-capture.js +72 -6
- package/voice/browser-playback.js +16 -20
- package/voice/client.js +28 -14
- package/voice/composite-gateway.js +7 -3
- package/voice/config.js +6 -0
- package/voice/controller.js +19 -8
- package/voice/conversation.js +48 -15
- package/voice/device-input.js +14 -9
- package/voice/dialogue.js +4 -3
- package/voice/host-service.js +7 -2
- package/voice/interruption.js +44 -0
- package/voice/lares-policy.js +12 -0
- package/voice/options.js +2 -1
- package/voice/panel.js +61 -20
- package/voice/pcm-player.js +51 -0
- package/voice/pcm-worklet.js +71 -0
- package/voice/preferences.js +89 -0
- package/voice/presentation.js +13 -1
- package/voice/reply-stream.js +97 -0
- package/voice/router-audio.js +23 -6
- package/voice/script.js +27 -36
- package/voice/service.js +46 -33
- package/voice/shortcuts.js +54 -5
- package/voice/speech-segmenter.js +119 -0
- package/voice/speech.js +60 -13
- package/voice/spoken-text.js +196 -0
- package/voice/startup.js +9 -0
- package/voice/stream-client.js +165 -0
- package/voice/stream-gateway.js +146 -0
- package/voice/tts-preview.js +27 -0
- package/voice/tts-settings.js +79 -0
- package/voice/tts-voice.js +20 -0
- package/webhook/client.js +34 -0
- package/webhook/config.js +55 -0
- package/webhook/public.js +11 -0
- package/webhook/reply.js +12 -0
package/voice/shortcuts.js
CHANGED
|
@@ -5,17 +5,66 @@ export function shortcutFromEvent(event) {
|
|
|
5
5
|
if (!key || ["Control", "Meta", "Alt", "Shift"].includes(key)) return "";
|
|
6
6
|
return [event.ctrlKey && "Ctrl", event.metaKey && "Meta", event.altKey && "Alt", event.shiftKey && "Shift", key].filter(Boolean).join("+");
|
|
7
7
|
}
|
|
8
|
+
export function voiceShortcutProblem(key, action, value) {
|
|
9
|
+
if (typeof key !== "string" || key && (!/^(?:Ctrl\+)?(?:Meta\+)?(?:Alt\+)?(?:Shift\+)?(?:[a-z0-9]|F(?:[1-9]|1[0-2]))$/.test(key) || !/^(Ctrl|Meta|Alt)\+/.test(key))) return "invalid";
|
|
10
|
+
if (RESERVED.has(key)) return "reserved";
|
|
11
|
+
if (key && ACTIONS.some(id => id !== action && value[id] === key)) return "duplicate";
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
8
14
|
export function validateVoiceShortcuts(value) {
|
|
9
15
|
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join() !== [...ACTIONS].sort().join()) throw new Error("voice_shortcut_invalid");
|
|
10
|
-
const
|
|
16
|
+
const result = {};
|
|
11
17
|
for (const action of ACTIONS) {
|
|
12
|
-
const
|
|
13
|
-
if (
|
|
14
|
-
|
|
15
|
-
result[action] = key;
|
|
18
|
+
const reason = voiceShortcutProblem(value[action], action, value);
|
|
19
|
+
if (reason) throw Object.assign(new Error("voice_shortcut_invalid"), { reason });
|
|
20
|
+
result[action] = value[action];
|
|
16
21
|
}
|
|
17
22
|
return result;
|
|
18
23
|
}
|
|
24
|
+
export function formatVoiceShortcut(value, mac = false) {
|
|
25
|
+
if (!value) return "";
|
|
26
|
+
return value.split("+").map(key => mac ? ({ Meta: "⌘", Alt: "⌥", Ctrl: "⌃", Shift: "⇧" })[key] || key.toUpperCase() : key.length === 1 ? key.toUpperCase() : key).join(mac ? "" : "+");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One recorder per settings page; only release of the chosen main key can commit it. */
|
|
30
|
+
export function createVoiceShortcutRecorder({ current, save }) {
|
|
31
|
+
let state = { action: null, candidate: "", errors: {}, pending: {}, failed: {} }, code = null;
|
|
32
|
+
const listeners = new Set();
|
|
33
|
+
const emit = patch => { state = { ...state, ...patch }; for (const listener of listeners) listener(); };
|
|
34
|
+
const commit = async (action, key) => {
|
|
35
|
+
if (state.pending[action]) return;
|
|
36
|
+
emit({ action: null, candidate: "", pending: { ...state.pending, [action]: true }, errors: { ...state.errors, [action]: null } });
|
|
37
|
+
try { await save(action, key); emit({ failed: { ...state.failed, [action]: null } }); }
|
|
38
|
+
catch (error) { emit({ errors: { ...state.errors, [action]: error.reason || "save" }, failed: { ...state.failed, [action]: key } }); }
|
|
39
|
+
finally { emit({ pending: { ...state.pending, [action]: false } }); }
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
getSnapshot: () => state,
|
|
43
|
+
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
44
|
+
begin(action) { if (!state.pending[action]) { code = null; emit({ action, candidate: "", errors: { ...state.errors, [action]: null } }); } },
|
|
45
|
+
cancel() { code = null; emit({ action: null, candidate: "" }); },
|
|
46
|
+
keydown(event) {
|
|
47
|
+
if (!state.action) return false;
|
|
48
|
+
if (event.key === "Tab") { this.cancel(); return false; }
|
|
49
|
+
if (event.key === "Escape") { this.cancel(); return true; }
|
|
50
|
+
if (event.repeat || event.isComposing) return true;
|
|
51
|
+
const key = shortcutFromEvent(event);
|
|
52
|
+
if (!key) return true;
|
|
53
|
+
const error = voiceShortcutProblem(key, state.action, current());
|
|
54
|
+
code = error ? null : event.code || event.key;
|
|
55
|
+
emit({ candidate: error ? "" : key, errors: { ...state.errors, [state.action]: error } });
|
|
56
|
+
return true;
|
|
57
|
+
},
|
|
58
|
+
keyup(event) {
|
|
59
|
+
if (!state.action) return false;
|
|
60
|
+
if (event.isComposing || event.repeat) return true;
|
|
61
|
+
if (code && (event.code || event.key) === code && state.candidate) { const action = state.action, key = state.candidate; code = null; void commit(action, key); }
|
|
62
|
+
return true;
|
|
63
|
+
},
|
|
64
|
+
clear: action => commit(action, ""),
|
|
65
|
+
retry: action => state.failed[action] != null ? commit(action, state.failed[action]) : Promise.resolve(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
19
68
|
export function matchVoiceShortcut(event, shortcuts) {
|
|
20
69
|
if (event.repeat || event.isComposing || event.defaultPrevented) return null;
|
|
21
70
|
const key = shortcutFromEvent(event);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { segment } from "sentencex";
|
|
2
|
+
import { spokenBlocks } from "./spoken-text.js";
|
|
3
|
+
|
|
4
|
+
const LIMIT = 80, TARGET = 40;
|
|
5
|
+
export const LIST_WAIT_MS = 250;
|
|
6
|
+
const pairs = new Map([["(", ")"], ["(", ")"], ["[", "]"], ["【", "】"], ["{", "}"], ["“", "”"], ["‘", "’"], ["「", "」"], ["『", "』"]]);
|
|
7
|
+
const weight = text => [...text].reduce((n, c) => n + (/\p{Script=Han}|[\u3000-\u303f\uff00-\uffef]/u.test(c) ? 1 : 1 / 3), 0);
|
|
8
|
+
|
|
9
|
+
function boundaries(text) {
|
|
10
|
+
const stack = [], positions = new Set();
|
|
11
|
+
for (let i = 0; i < text.length; i++) {
|
|
12
|
+
const c = text[i];
|
|
13
|
+
if (c === "'" && !(/[\p{L}\p{N}]/u.test(text[i - 1] || "") && /[\p{L}\p{N}]/u.test(text[i + 1] || ""))) {
|
|
14
|
+
if (stack.at(-1) === "'") stack.pop(); else stack.push("'");
|
|
15
|
+
} else if (c === '"') { if (stack.at(-1) === '"') stack.pop(); else stack.push('"'); }
|
|
16
|
+
else if (pairs.has(c)) stack.push(pairs.get(c));
|
|
17
|
+
else if (stack.at(-1) === c) stack.pop();
|
|
18
|
+
if (!stack.length) positions.add(i + 1);
|
|
19
|
+
}
|
|
20
|
+
return { positions, balanced: !stack.length };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sentences(text, closed) {
|
|
24
|
+
const { positions, balanced } = boundaries(text);
|
|
25
|
+
const ends = new Set();
|
|
26
|
+
let from = 0;
|
|
27
|
+
const parts = segment("en", text);
|
|
28
|
+
for (let i = 0; i < parts.length; i++) {
|
|
29
|
+
const at = text.indexOf(parts[i], from); if (at < 0) continue;
|
|
30
|
+
from = at + parts[i].length;
|
|
31
|
+
if (i < parts.length - 1 || closed) ends.add(from);
|
|
32
|
+
}
|
|
33
|
+
for (let i = 0; i < text.length; i++) {
|
|
34
|
+
if (/[。!?]/.test(text[i])) {
|
|
35
|
+
let end = i + 1;
|
|
36
|
+
while (/[”’」』))\]]/.test(text[end] || "") && end < text.length) end++;
|
|
37
|
+
ends.add(end);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (closed) ends.add(text.length);
|
|
41
|
+
const out = []; let consumed = 0;
|
|
42
|
+
for (const end of [...ends].sort((a, b) => a - b)) {
|
|
43
|
+
if (!positions.has(end) && !(closed && end === text.length)) continue;
|
|
44
|
+
const sentence = text.slice(consumed, end).trim();
|
|
45
|
+
if (sentence.includes("\uFFFC")) break;
|
|
46
|
+
if (sentence) out.push(sentence);
|
|
47
|
+
consumed = end;
|
|
48
|
+
}
|
|
49
|
+
return { out, consumed, balanced };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clauses(text) {
|
|
53
|
+
if (weight(text) <= LIMIT) return [text];
|
|
54
|
+
const { positions } = boundaries(text);
|
|
55
|
+
const out = []; let start = 0, lastSafe = 0;
|
|
56
|
+
for (let i = 0; i < text.length; i++) {
|
|
57
|
+
if (positions.has(i + 1) && /^(?:[,;:]|[,;:](?=\s))/.test(text.slice(i, i + 2))) lastSafe = i + 1;
|
|
58
|
+
if (lastSafe > start && weight(text.slice(start, i + 1)) >= LIMIT) {
|
|
59
|
+
out.push(text.slice(start, lastSafe).trim()); start = lastSafe;
|
|
60
|
+
} else if (lastSafe === i + 1 && weight(text.slice(start, i + 1)) >= TARGET) {
|
|
61
|
+
out.push(text.slice(start, i + 1).trim()); start = i + 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (text.slice(start).trim()) out.push(text.slice(start).trim());
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A turn owns this state. Deadlines are serviced by Host orchestration, not UI timers. */
|
|
69
|
+
export function createNaturalSegmenter({ now = Date.now } = {}) {
|
|
70
|
+
let source = "", seen = new Map(), pending = [], group = null, deadline = null, ended = false;
|
|
71
|
+
const listText = items => items.map(item => /[。!?!?;;]$/.test(item) ? item : `${item};`).join("");
|
|
72
|
+
const emitList = out => {
|
|
73
|
+
if (pending.length) out.push(listText(pending));
|
|
74
|
+
pending = []; group = null; deadline = null;
|
|
75
|
+
};
|
|
76
|
+
const update = (text, { done = false } = {}) => {
|
|
77
|
+
if (ended) { if (text !== source) throw new Error("voice_reply_rewritten"); return []; }
|
|
78
|
+
source = text;
|
|
79
|
+
const out = [];
|
|
80
|
+
if (deadline !== null && now() >= deadline) emitList(out);
|
|
81
|
+
const blocks = spokenBlocks(source, { done });
|
|
82
|
+
const present = new Set(blocks.map(block => block.id));
|
|
83
|
+
for (const id of seen.keys()) if (!present.has(id)) throw new Error("voice_reply_rewritten");
|
|
84
|
+
for (const block of blocks) {
|
|
85
|
+
const previous = seen.get(block.id) || "";
|
|
86
|
+
if (!block.text.startsWith(previous)) throw new Error("voice_reply_rewritten");
|
|
87
|
+
const text = block.text.slice(previous.length);
|
|
88
|
+
if (!text) continue;
|
|
89
|
+
if (block.kind === "list") {
|
|
90
|
+
if (pending.length && group !== block.group) emitList(out);
|
|
91
|
+
if (!block.closed || !done && !boundaries(text).balanced || text.includes("\uFFFC")) break;
|
|
92
|
+
if (weight(listText([text])) > LIMIT) {
|
|
93
|
+
emitList(out); out.push(...sentences(text, true).out.flatMap(clauses));
|
|
94
|
+
seen.set(block.id, block.text); continue;
|
|
95
|
+
}
|
|
96
|
+
if (pending.length && weight(listText([...pending, text])) > LIMIT) emitList(out);
|
|
97
|
+
if (!pending.length) { group = block.group; deadline = now() + LIST_WAIT_MS; }
|
|
98
|
+
pending.push(text); seen.set(block.id, block.text);
|
|
99
|
+
if (pending.length >= 3 || weight(listText(pending)) >= LIMIT) emitList(out);
|
|
100
|
+
} else {
|
|
101
|
+
emitList(out);
|
|
102
|
+
const ready = sentences(text, block.closed && (done || boundaries(text).balanced));
|
|
103
|
+
for (const sentence of ready.out) out.push(...clauses(sentence));
|
|
104
|
+
if (ready.consumed) seen.set(block.id, (previous + text.slice(0, ready.consumed)).trimEnd());
|
|
105
|
+
if (ready.consumed < text.length) break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (done) { emitList(out); ended = true; }
|
|
109
|
+
return out;
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
update,
|
|
113
|
+
push(chunk, options) { return update(source + chunk, options); },
|
|
114
|
+
finish() { return update(source, { done: true }); },
|
|
115
|
+
flush() { return update(source); },
|
|
116
|
+
get deadline() { return deadline; },
|
|
117
|
+
reset() { source = ""; seen = new Map(); pending = []; group = null; deadline = null; ended = false; },
|
|
118
|
+
};
|
|
119
|
+
}
|
package/voice/speech.js
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
let readout = null;
|
|
2
|
+
const readoutListeners = new Set();
|
|
3
|
+
export const readoutState = {
|
|
4
|
+
getSnapshot: () => readout,
|
|
5
|
+
subscribe(listener) { readoutListeners.add(listener); return () => readoutListeners.delete(listener); },
|
|
6
|
+
publish(value) {
|
|
7
|
+
readout = value ? { ...(readout?.key === value.key ? readout : {}), ...value } : null;
|
|
8
|
+
for (const listener of readoutListeners) listener();
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
1
12
|
/**
|
|
2
13
|
* The utterance a turn may speak: the text of the step being written, read
|
|
3
14
|
* while it streams. Only a step that ends without a tool call answers the
|
|
@@ -67,15 +78,22 @@ function withBlock(state, index, text) {
|
|
|
67
78
|
export function reduceUtterance(state, event) {
|
|
68
79
|
const type = event?.type;
|
|
69
80
|
if (type === "turn/start") return null;
|
|
70
|
-
if (type === "turn/end")
|
|
81
|
+
if (type === "turn/end") {
|
|
82
|
+
if (state && state.turn !== turnOf(event)) return state;
|
|
83
|
+
const failed = ["error", "cancelled"].includes(event.data?.reason?.kind);
|
|
84
|
+
return state ? { ...state, done: true, silent: state.silent || failed } : state;
|
|
85
|
+
}
|
|
71
86
|
const turn = turnOf(event);
|
|
72
87
|
if (turn === null) return state;
|
|
73
88
|
const step = stepOf(event);
|
|
74
|
-
|
|
89
|
+
if (state && (turn < state.turn || turn === state.turn && step < state.step)) return state;
|
|
90
|
+
const same = state?.turn === turn && state.step === step && (!event.data?.attemptId || state.attemptId === event.data.attemptId);
|
|
75
91
|
if (isChunkEvent(event)) {
|
|
76
92
|
if (same && state.done) return state;
|
|
77
93
|
const chunk = event.data?.chunk;
|
|
78
|
-
|
|
94
|
+
let current = same ? state : { ...stepUtterance(turn, step), attemptId: event.data?.attemptId };
|
|
95
|
+
if (Number.isInteger(event.data?.chunkIndex) && event.data.chunkIndex <= (current.chunkIndex ?? -1)) return current;
|
|
96
|
+
if (Number.isInteger(event.data?.chunkIndex)) current = { ...current, chunkIndex: event.data.chunkIndex };
|
|
79
97
|
if (opensToolCall(chunk)) return current.silent ? current : { ...current, silent: true };
|
|
80
98
|
const index = Number.isInteger(chunk?.index) ? chunk.index : 0;
|
|
81
99
|
if (chunk?.type === "text-delta" && typeof chunk.text === "string") {
|
|
@@ -91,6 +109,8 @@ export function reduceUtterance(state, event) {
|
|
|
91
109
|
return {
|
|
92
110
|
...stepUtterance(turn, step),
|
|
93
111
|
text: assistantText(event),
|
|
112
|
+
messageId: event.data?.message?.id ?? event.data?.messageId,
|
|
113
|
+
attemptId: same ? state.attemptId : undefined,
|
|
94
114
|
done: true,
|
|
95
115
|
silent: Boolean(event.data?.interrupted) || content.some((block) => block?.type === "tool-call"),
|
|
96
116
|
};
|
|
@@ -165,24 +185,33 @@ const BOUNDARY = /[.!?;。!?;…\n]/;
|
|
|
165
185
|
* fail?: (err: unknown) => void,
|
|
166
186
|
* }} io
|
|
167
187
|
*/
|
|
168
|
-
export function createSentencePlayer({ send, play, halt, silence, fail = () => {}, onState = () => {} }) {
|
|
188
|
+
export function createSentencePlayer({ send, play, halt, silence, fail = () => {}, onState = () => {}, onOutput = readoutState.publish, stream }) {
|
|
169
189
|
let gen = 0;
|
|
170
190
|
let current = null;
|
|
171
191
|
// An utterance is read once: finishing it, failing, or being stopped all end it for good.
|
|
172
192
|
let lastKey = null;
|
|
173
193
|
let silencedKey = null;
|
|
194
|
+
const consumed = new Set();
|
|
195
|
+
const mutedTurns = new Set();
|
|
196
|
+
let lastTurn = null;
|
|
197
|
+
const turnKey = (value) => Number.isFinite(value.turn) ? `${value.sessionId ?? ""}:${value.turn}` : null;
|
|
198
|
+
const mark = (set, key) => { if (key != null) set.add(key); };
|
|
174
199
|
|
|
200
|
+
const emitOutput = value => {
|
|
201
|
+
if (value || readoutState.getSnapshot()?.key === lastKey || onOutput !== readoutState.publish) onOutput(value);
|
|
202
|
+
};
|
|
175
203
|
const failed = (run, err) => {
|
|
176
204
|
if (run.gen !== gen) return;
|
|
205
|
+
run.abort?.abort();
|
|
177
206
|
gen += 1;
|
|
178
207
|
run.wake?.();
|
|
179
208
|
current = null;
|
|
180
|
-
onState(false);
|
|
181
|
-
if (err?.code
|
|
209
|
+
onState(false); emitOutput(null);
|
|
210
|
+
if (!["voice_cancelled", "voice_output_owned"].includes(err?.code || err?.message)) { emitOutput({ ...run.identity, status: "error", error: err?.code || err?.message || "voice_playback_failed", stop: () => emitOutput(null) }); fail(err); }
|
|
182
211
|
};
|
|
183
212
|
|
|
184
213
|
const handOver = (run) => {
|
|
185
|
-
if (run.sending || run.gen !== gen) return;
|
|
214
|
+
if (stream || run.sending || run.gen !== gen) return;
|
|
186
215
|
const due = (run.done && !run.sentDone) || BOUNDARY.test(run.text.slice(run.sentLength));
|
|
187
216
|
if (!due) return;
|
|
188
217
|
const { text, done } = run;
|
|
@@ -207,6 +236,11 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
|
|
|
207
236
|
};
|
|
208
237
|
|
|
209
238
|
const read = async (run) => {
|
|
239
|
+
if (stream) {
|
|
240
|
+
await stream({ key: run.key, text: run.text, source: run.source, signal: run.abort.signal, controls: (value) => { if (run.gen === gen) run.controls = value; }, onStatus: (status) => { if (run.gen === gen) { if (status === "playing") onState(true); emitOutput({ key: run.key, status }); } } });
|
|
241
|
+
if (run.gen === gen) { current = null; onState(false); emitOutput(null); }
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
210
244
|
let next = null;
|
|
211
245
|
for (let part = 0; run.gen === gen;) {
|
|
212
246
|
if (part >= run.sent) {
|
|
@@ -220,15 +254,16 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
|
|
|
220
254
|
await play(sentence);
|
|
221
255
|
part += 1;
|
|
222
256
|
}
|
|
223
|
-
if (run.gen === gen) { current = null; onState(false); }
|
|
257
|
+
if (run.gen === gen) { current = null; onState(false); emitOutput(null); }
|
|
224
258
|
};
|
|
225
259
|
|
|
226
260
|
const stop = () => {
|
|
227
261
|
gen += 1;
|
|
262
|
+
current?.abort?.abort();
|
|
228
263
|
current?.wake?.();
|
|
229
264
|
if (current) halt();
|
|
230
265
|
current = null;
|
|
231
|
-
onState(false);
|
|
266
|
+
onState(false); emitOutput(null);
|
|
232
267
|
};
|
|
233
268
|
|
|
234
269
|
return {
|
|
@@ -243,14 +278,20 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
|
|
|
243
278
|
return;
|
|
244
279
|
}
|
|
245
280
|
if (!utterance.text) return;
|
|
281
|
+
const identity = turnKey(utterance);
|
|
282
|
+
if (identity && mutedTurns.has(identity)) return;
|
|
283
|
+
// A later tool call can invalidate uncommitted assistant text.
|
|
284
|
+
if (stream && !utterance.done && !utterance.source) return;
|
|
246
285
|
if (current?.key !== utterance.key) {
|
|
247
|
-
if (utterance.key
|
|
248
|
-
if (current) stop();
|
|
286
|
+
if (consumed.has(utterance.key)) return;
|
|
287
|
+
if (current) { mark(mutedTurns, lastTurn); stop(); }
|
|
249
288
|
gen += 1;
|
|
250
|
-
lastKey = utterance.key;
|
|
289
|
+
lastKey = utterance.key; lastTurn = identity; mark(consumed, lastKey);
|
|
251
290
|
current = {
|
|
252
291
|
gen,
|
|
292
|
+
abort: new AbortController(),
|
|
253
293
|
key: utterance.key,
|
|
294
|
+
source: utterance.source,
|
|
254
295
|
text: utterance.text,
|
|
255
296
|
done: utterance.done,
|
|
256
297
|
sent: 0,
|
|
@@ -259,8 +300,9 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
|
|
|
259
300
|
sending: null,
|
|
260
301
|
wake: null,
|
|
261
302
|
};
|
|
303
|
+
current.identity = utterance;
|
|
262
304
|
const run = current;
|
|
263
|
-
onState(true);
|
|
305
|
+
onState(true); emitOutput({ ...utterance, status: "preparing", stop: () => { mark(mutedTurns, lastTurn); stop(); silence(); } });
|
|
264
306
|
handOver(run);
|
|
265
307
|
read(run).catch((err) => failed(run, err));
|
|
266
308
|
return;
|
|
@@ -269,7 +311,12 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
|
|
|
269
311
|
current.done = utterance.done;
|
|
270
312
|
handOver(current);
|
|
271
313
|
},
|
|
314
|
+
get key() { return current?.key; },
|
|
315
|
+
get canPause() { return current?.controls?.canPause === true; },
|
|
316
|
+
pause() { if (!current?.controls?.canPause) return false; current.controls.pause(); emitOutput({ key: current.key, status: "confirming" }); return true; },
|
|
317
|
+
resume() { if (!current?.controls?.canPause) return false; current.controls.resume(); emitOutput({ key: current.key, status: "playing" }); return true; },
|
|
272
318
|
stop() {
|
|
319
|
+
mark(mutedTurns, lastTurn);
|
|
273
320
|
stop();
|
|
274
321
|
silence();
|
|
275
322
|
},
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import emojiRegex from "emoji-regex";
|
|
2
|
+
import MarkdownIt from "markdown-it";
|
|
3
|
+
|
|
4
|
+
const markdown = new MarkdownIt({ html: false, linkify: false, typographer: false });
|
|
5
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
6
|
+
|
|
7
|
+
markdown.inline.ruler.before("backticks", "speech_open_code", (state, silent) => {
|
|
8
|
+
if (!silent && state.env.streaming && state.src[state.pos] === "`") {
|
|
9
|
+
const marks = state.src.slice(state.pos).match(/^`+/)[0];
|
|
10
|
+
const rest = state.src.slice(state.pos + marks.length);
|
|
11
|
+
if (![...rest.matchAll(/`+/g)].some((match) => match[0].length === marks.length)) {
|
|
12
|
+
state.env.pending = Math.min(state.env.pending, state.pos);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Retain parser positions only for this invocation; no state survives a turn.
|
|
19
|
+
markdown.inline.ruler.before("strikethrough", "speech_delimiters", (state, silent) => {
|
|
20
|
+
if (silent || !state.env.streaming || !/[*_~]/.test(state.src[state.pos])) return false;
|
|
21
|
+
const positions = state.env.states.get(state) ?? new Map();
|
|
22
|
+
state.env.states.set(state, positions);
|
|
23
|
+
const scanned = state.scanDelims(state.pos, state.src[state.pos] !== "_");
|
|
24
|
+
const first = state.tokens.length + (state.pending ? 1 : 0);
|
|
25
|
+
for (let index = 0; index < scanned.length; index++) positions.set(first + index, state.pos + index);
|
|
26
|
+
return false;
|
|
27
|
+
});
|
|
28
|
+
markdown.inline.ruler2.before("fragments_join", "speech_unclosed_pairs", (state) => {
|
|
29
|
+
if (!state.env.streaming) return;
|
|
30
|
+
const positions = state.env.states.get(state);
|
|
31
|
+
for (const delimiter of state.delimiters) {
|
|
32
|
+
if (delimiter.open && delimiter.end < 0 && state.tokens[delimiter.token]?.type === "text") {
|
|
33
|
+
state.env.pending = Math.min(state.env.pending, positions?.get(delimiter.token) ?? Infinity);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
markdown.inline.ruler.push("speech_incomplete", (state, silent) => {
|
|
38
|
+
if (!silent && state.env.streaming && /[\[!`<&\\]/.test(state.src[state.pos])) {
|
|
39
|
+
state.env.pending = Math.min(state.env.pending, state.pos);
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function tokensText(tokens) {
|
|
45
|
+
return tokens.map((token) => {
|
|
46
|
+
if (token.type === "image") return tokensText(token.children ?? []);
|
|
47
|
+
if (token.type === "softbreak" || token.type === "hardbreak") return "\n";
|
|
48
|
+
return token.type === "text" || token.type === "code_inline" ? token.content : "";
|
|
49
|
+
}).join("");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function inlineText(source, streaming, references) {
|
|
53
|
+
const env = { streaming, references, pending: Infinity, states: new Map() };
|
|
54
|
+
let tokens = markdown.parseInline(source, env)[0].children;
|
|
55
|
+
if (Number.isFinite(env.pending)) tokens = markdown.parseInline(source.slice(0, env.pending), { references })[0].children;
|
|
56
|
+
const last = tokens.findLast((token) => token.type === "image" || token.content?.trim());
|
|
57
|
+
return { text: tokensText(tokens), pending: Number.isFinite(env.pending) || last?.type === "image" && !tokensText(last.children ?? []).trim() };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const RULE_LINE = /^\s*(?:\|?\s*:?-{3,}:?\s*)+\|?\s*$|^\s*([-*_])(?:\s*\1){2,}\s*$/;
|
|
61
|
+
const BLOCK_MARK = /^\s*(?:>(?:[ \t]+|$))*(?:#{1,6}\s+|[-*+]\s+(?:\[[ xX]\]\s+)?|\d{1,9}[.)]\s+)?/;
|
|
62
|
+
|
|
63
|
+
function blockLine(line, table = false) {
|
|
64
|
+
if (RULE_LINE.test(line)) return "";
|
|
65
|
+
let content = line.replace(BLOCK_MARK, "");
|
|
66
|
+
if (/^\s*(?:>\s*)*#{1,6}\s+/.test(line)) content = content.replace(/[ \t]+#+[ \t]*$/, "");
|
|
67
|
+
if (!table && !/^\s*\|.*\|\s*$/.test(content)) return content;
|
|
68
|
+
// Only table framing is removed; inline code and escaped pipes are content.
|
|
69
|
+
return content.trim().replace(/^\||(?<!\\)\|$/g, "").replace(/\\.|(`+)[\s\S]*?\1|\|/g, (match) => match === "|" ? " " : match);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function pipeAt(line) {
|
|
73
|
+
return [...line.matchAll(/\\.|(`+)[\s\S]*?\1|\|/g)].find((match) => match[0] === "|")?.index;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function blockText(source, done) {
|
|
77
|
+
const rows = new Set(), visible = new Set(), env = {};
|
|
78
|
+
for (const token of markdown.parse(source, env)) {
|
|
79
|
+
if (token.type === "tr_open" && token.map) rows.add(token.map[0]);
|
|
80
|
+
if (["inline", "code_block", "tr_open"].includes(token.type) && token.map) {
|
|
81
|
+
for (let line = token.map[0]; line < token.map[1]; line++) visible.add(line);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!done) {
|
|
85
|
+
const complete = {};
|
|
86
|
+
markdown.parse(source.slice(0, source.lastIndexOf("\n") + 1), complete);
|
|
87
|
+
env.references = complete.references;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
text: source.split("\n").map((line, index) => visible.has(index) ? blockLine(line, rows.has(index)) : "").join("\n"),
|
|
91
|
+
references: env.references,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function withoutEmoji(text) {
|
|
96
|
+
return text.replace(emojiRegex(), (match) => {
|
|
97
|
+
if (/^[0-9#*]\uFE0F?\u20E3$/.test(match)) return match[0];
|
|
98
|
+
if (/^[©®™]$/.test(match)) return match;
|
|
99
|
+
return " ";
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The assistant's spoken copy; never apply this to stored messages or raw TTS API input. */
|
|
104
|
+
function prepareSource(text, done, structural = false) {
|
|
105
|
+
let pending = false;
|
|
106
|
+
const skip = (match, offset, source) => {
|
|
107
|
+
if (offset + match.length >= source.trimEnd().length) pending = true;
|
|
108
|
+
return structural ? "\n\n---\n\n" : " ";
|
|
109
|
+
};
|
|
110
|
+
let source = String(text ?? "")
|
|
111
|
+
.replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, skip)
|
|
112
|
+
.replace(/^[\s\S]*?<\/think>/i, skip)
|
|
113
|
+
.replace(/<think\b[\s\S]*$/i, () => { pending = true; return ""; })
|
|
114
|
+
.replace(/(`{3,}|~{3,})[\s\S]*?\1/g, (match, _marks, offset, source) => skip(match, offset, source));
|
|
115
|
+
const openFence = /`{3,}|~{3,}/.exec(source);
|
|
116
|
+
if (openFence) { pending = true; source = source.slice(0, openFence.index); }
|
|
117
|
+
if (!done) {
|
|
118
|
+
const lines = source.split("\n");
|
|
119
|
+
let candidate = lines.length - 1;
|
|
120
|
+
// An unfinished separator can still turn the preceding line into a table.
|
|
121
|
+
if (candidate > 0 && /^[\s|:-]*$/.test(lines[candidate]) && !RULE_LINE.test(lines[candidate - 1]) && pipeAt(lines[candidate - 1]) !== undefined) candidate--;
|
|
122
|
+
const pipe = pipeAt(lines[candidate]);
|
|
123
|
+
if (pipe !== undefined) {
|
|
124
|
+
source = [...lines.slice(0, candidate), lines[candidate].slice(0, pipe)].join("\n");
|
|
125
|
+
pending = true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { source, pending };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function spokenLines(text, { done = true } = {}) {
|
|
132
|
+
let { source, pending } = prepareSource(text, done);
|
|
133
|
+
const blocks = blockText(source, done);
|
|
134
|
+
const inline = inlineText(blocks.text, !done, blocks.references);
|
|
135
|
+
// An opaque unfinished tail keeps earlier completed sentences eligible. It
|
|
136
|
+
// is always the final, withheld sentence and never reaches synthesis.
|
|
137
|
+
let plain = inline.text;
|
|
138
|
+
if (!done) {
|
|
139
|
+
const lineStart = plain.lastIndexOf("\n") + 1;
|
|
140
|
+
const end = plain.slice(lineStart).trimEnd();
|
|
141
|
+
const tail = [...graphemes.segment(end)].at(-1);
|
|
142
|
+
if (tail && (emojiRegex().test(tail.segment) || /[\uD800-\uDBFF]$/.test(tail.segment))) {
|
|
143
|
+
plain = plain.slice(0, lineStart) + end.slice(0, tail.index);
|
|
144
|
+
pending = true;
|
|
145
|
+
}
|
|
146
|
+
if (pending || inline.pending) plain += "\uFFFC";
|
|
147
|
+
}
|
|
148
|
+
return withoutEmoji(plain).split("\n").map((line) => line.replace(/\s+/g, " ").trim());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
/** Structural units for speech aggregation; rendered/stored Markdown is untouched. */
|
|
153
|
+
export function spokenBlocks(text, { done = true } = {}) {
|
|
154
|
+
const { source, pending } = prepareSource(text, done, true);
|
|
155
|
+
const env = {}, tokens = markdown.parse(source, env);
|
|
156
|
+
const references = blockText(source, done).references;
|
|
157
|
+
const last = tokens.findLastIndex(token => ["inline", "code_block"].includes(token.type));
|
|
158
|
+
const lists = [], items = [], result = [];
|
|
159
|
+
let heading = false, barrier = 0, rowLine = 0;
|
|
160
|
+
const paragraphs = new Set(), tableRows = new Set();
|
|
161
|
+
for (const [index, token] of tokens.entries()) {
|
|
162
|
+
if (token.type === "tr_open") { rowLine = token.map[0]; }
|
|
163
|
+
if (token.type === "hr" || token.type === "fence") barrier++;
|
|
164
|
+
if (token.type.endsWith("list_open")) lists.push(`${token.map[0]}:${token.level}`);
|
|
165
|
+
else if (token.type.endsWith("list_close")) lists.pop();
|
|
166
|
+
else if (token.type === "list_item_open") items.push(token.map[0]);
|
|
167
|
+
else if (token.type === "list_item_close") items.pop();
|
|
168
|
+
else if (token.type === "heading_open") heading = true;
|
|
169
|
+
else if (token.type === "heading_close") heading = false;
|
|
170
|
+
else if (["inline", "code_block"].includes(token.type)) {
|
|
171
|
+
if (!token.map && tableRows.has(rowLine)) continue;
|
|
172
|
+
if (!token.map) tableRows.add(rowLine);
|
|
173
|
+
const map = token.map || [rowLine, rowLine + 1];
|
|
174
|
+
const id = token.map ? `${map[0]}:${token.type}` : `${map[0]}:table`;
|
|
175
|
+
const nextBlock = tokens.slice(index + 1).some(next => next.map && next.map[0] >= map[1]);
|
|
176
|
+
const closed = done || nextBlock || (token.map ? index < last || map[1] < source.split("\n").length - 1 : map[1] <= source.split("\n").length - 1);
|
|
177
|
+
const content = token.type === "inline" && token.map
|
|
178
|
+
? source.split("\n").slice(map[0], map[1]).map(line => blockLine(line)).join("\n")
|
|
179
|
+
: token.map ? token.content : blockLine(source.split("\n")[rowLine], true);
|
|
180
|
+
const inline = inlineText(content, !done, references);
|
|
181
|
+
let plain = inline.text, opaque = inline.pending || index === last && pending && map[1] >= source.split("\n").length;
|
|
182
|
+
if (!done && !closed) {
|
|
183
|
+
const tail = [...graphemes.segment(plain.trimEnd())].at(-1);
|
|
184
|
+
if (tail && (emojiRegex().test(tail.segment) || /[\uD800-\uDBFF]$/.test(tail.segment))) { plain = plain.slice(0, tail.index); opaque = true; }
|
|
185
|
+
}
|
|
186
|
+
if (!done && opaque) plain += "\uFFFC";
|
|
187
|
+
plain = withoutEmoji(plain).replace(/\s+/g, " ").trim();
|
|
188
|
+
const continuation = lists.length && paragraphs.has(items.at(-1));
|
|
189
|
+
if (lists.length) paragraphs.add(items.at(-1));
|
|
190
|
+
if (plain) result.push({ id, text: plain, closed,
|
|
191
|
+
kind: lists.length && !continuation ? "list" : heading ? "heading" : "paragraph",
|
|
192
|
+
group: `${lists.at(-1)}:${barrier}`, item: items.at(-1), depth: lists.length });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|
package/voice/startup.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Startup timing contains only a stage and duration, never content or credentials. */
|
|
2
|
+
export function recordVoiceStartup(stage, durationMs) {
|
|
3
|
+
const fields = { stage, durationMs: Math.max(0, durationMs) };
|
|
4
|
+
try {
|
|
5
|
+
const clock = globalThis.performance;
|
|
6
|
+
clock?.measure?.(`lares.voice.startup.${stage}`, { start: Math.max(0, clock.now() - fields.durationMs), duration: fields.durationMs, detail: fields });
|
|
7
|
+
globalThis.console?.debug?.("[lares.voice.startup]", fields);
|
|
8
|
+
} catch { /* Observability cannot block recording. */ }
|
|
9
|
+
}
|