@olares/lares-core 0.15.21 → 0.15.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-core",
3
- "version": "0.15.21",
3
+ "version": "0.15.22",
4
4
  "description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
5
5
  "type": "module",
6
6
  "repository": {
@@ -3,6 +3,18 @@ import { createDeviceInput } from "./device-input.js";
3
3
  import { createHostInput } from "./host-input.js";
4
4
  import { mintRpcId } from "../larepass/rpc.js";
5
5
 
6
+ /** Bound a request without requiring AbortSignal.any / timeout, which some mobile webviews lack. */
7
+ function outputSignal(abort) {
8
+ const signals = [abort.signal];
9
+ if (typeof AbortSignal?.timeout === "function") {
10
+ try { signals.push(AbortSignal.timeout(10_000)); } catch { /* the caller signal still cancels the request */ }
11
+ }
12
+ if (signals.length > 1 && typeof AbortSignal.any === "function") {
13
+ try { return AbortSignal.any(signals); } catch { /* same */ }
14
+ }
15
+ return abort.signal;
16
+ }
17
+
6
18
  export function createVoiceConversation({ request, transcribe, playback, onReply = () => {}, onPhase, mode = () => "queue", device = "device", metric = () => {}, readAloud = true, preset, backend, presentationSession = false, endpointSilenceMs, idleTimeoutMs, recoveryTimeoutMs, onText = () => {}, onTranscript = null, input: inputPort, captureConfig, streamInput, preparation }) {
7
19
  const voiceId = mintRpcId();
8
20
  const headers = { "x-lares-voice-id": voiceId, "x-lares-voice-device": device };
@@ -154,7 +166,7 @@ export function createVoiceConversation({ request, transcribe, playback, onReply
154
166
  if (error) throw error;
155
167
  const abort = outputAbort = new AbortController();
156
168
  try {
157
- await call("/session/output", { readAloud: next }, AbortSignal.any([abort.signal, AbortSignal.timeout(10_000)]));
169
+ await call("/session/output", { readAloud: next }, outputSignal(abort));
158
170
  if (!disposed && revision === outputRevision) confirmedReadAloud = next;
159
171
  } catch (error) {
160
172
  if (!disposed && revision === outputRevision) {
package/voice/panel.js CHANGED
@@ -56,7 +56,11 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
56
56
  }
57
57
  };
58
58
  const prepare = async (token) => {
59
- if (call?.controller.getSnapshot().connection === "paused") { await call.resume({ shouldResume: () => token === intent && state.visible }); return; }
59
+ if (call?.controller.getSnapshot().connection === "paused") {
60
+ await call.resume({ shouldResume: () => token === intent && state.visible });
61
+ if (token === intent && state.visible && state.mode === "continuous" && typeof readAloudPreference === "boolean") await panel.setReadAloud(readAloudPreference);
62
+ return;
63
+ }
60
64
  if (call?.controller.getSnapshot().connection === "open") return;
61
65
  await end(); config = state.mode === "single" && config ? config : structuredClone(await loadConfig());
62
66
  if (token !== intent || !state.visible) return;
@@ -87,7 +91,11 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
87
91
  };
88
92
  const pause = async () => {
89
93
  try { if (state.mode === "single") { await finishCapture(); renderDraft(); } else await call?.controller.cancelRecording(); }
90
- finally { if (call?.controller.getSnapshot().connection === "open") await call.pause(); }
94
+ finally {
95
+ if (call?.controller.getSnapshot().connection === "open") await call.pause();
96
+ // The banner belongs to the open call. Leaving it must not keep a mute/sync notice on the chat.
97
+ if (!state.visible) { outputRevision++; emit({ readAloudError: null }); }
98
+ }
91
99
  };
92
100
  const insertDraft = async () => {
93
101
  if (!state.draft || state.stage !== "preview") return;
@@ -108,6 +116,10 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
108
116
  if (!readAloudPending && appliedReadAloud === next && !state.readAloudError) return Promise.resolve(true);
109
117
  const revision = ++outputRevision;
110
118
  const current = () => target === call && epoch === generation && revision === outputRevision;
119
+ // Phone playback is local. Asking for silence already stopped it, so a missed
120
+ // server ack is not a readout failure. A headset can keep playing until the host agrees.
121
+ const localSilence = next !== true && state.device !== "headset";
122
+ if (localSilence) emit({ readAloudError: null });
111
123
  const pending = { target, value: next, promise: null };
112
124
  pending.promise = Promise.resolve().then(() => {
113
125
  if (target !== call || epoch !== generation) return;
@@ -116,7 +128,9 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
116
128
  if (current()) { appliedReadAloud = next; emit({ readAloudError: null }); }
117
129
  return true;
118
130
  }, error => {
119
- if (current()) emit({ readAloudError: error.code || error.message || "voice_failed" });
131
+ if (!current()) return false;
132
+ if (localSilence) { appliedReadAloud = false; emit({ readAloudError: null }); return true; }
133
+ emit({ readAloudError: error.code || error.message || "voice_failed" });
120
134
  return false;
121
135
  }).finally(() => { if (readAloudPending === pending) readAloudPending = null; });
122
136
  readAloudPending = pending;
@@ -6,7 +6,9 @@ export function voicePanelPresentation(view) {
6
6
  const preview = view.mode === "single" && ["preview", "processing"].includes(view.stage);
7
7
  const preparing = view.busy || capture === "recording" && view.snapshot?.inputReady === false;
8
8
  const recording = capture === "recording" && !preparing;
9
- const error = view.error || view.snapshot?.error;
9
+ const reported = view.error || view.snapshot?.error;
10
+ // A muted call is not a listen session. "Resume to listen again" is the readout copy.
11
+ const error = reported === "voice_disconnected" && view.snapshot?.outputMuted ? "voice_background" : reported;
10
12
  const phase = view.cancelArmed ? "cancel" : error ? "error" : processing ? "processing" : transcribing ? "transcribing" : preparing ? "preparing" : recording ? "recording" : preview ? "preview" : view.snapshot && view.snapshot.playback !== "idle" ? "speaking" : view.snapshot?.agent === "running" ? "working" : "waiting";
11
13
  const status = error ? `error.${error}` : ({ cancel: "gesture.cancelRelease", processing: "dictation.processing", transcribing: "mic.transcribing", preparing: "mic.preparing", recording: "call.listening", preview: "dictation.preview", speaking: "dictation.speaking", working: "call.working", waiting: view.mode === "continuous" ? "gesture.waiting" : "dictation.preview" })[phase];
12
14
  return { phase, status, preview, recording, pending: processing || transcribing || preparing, error };
@@ -65,8 +65,9 @@ export async function playAudioStream(options) {
65
65
  else if (event.type === "done") { await player.finish(); complete = true; resolve(); }
66
66
  }).catch(fail);
67
67
  });
68
- connection.addEventListener("error", () => fail(new Error("voice_disconnected")), { once: true });
69
- connection.addEventListener("close", () => { options.signal?.removeEventListener("abort", cancelled); if (!complete) reject(new Error("voice_disconnected")); }, { once: true });
68
+ const dropped = () => new Error(options.signal?.aborted ? "voice_cancelled" : "voice_disconnected");
69
+ connection.addEventListener("error", () => fail(dropped()), { once: true });
70
+ connection.addEventListener("close", () => { options.signal?.removeEventListener("abort", cancelled); if (!complete) reject(dropped()); }, { once: true });
70
71
  if (options.signal?.aborted) cancelled();
71
72
  });
72
73
  return true;
@@ -101,8 +102,9 @@ export async function startTranscriptionStream(options) {
101
102
  else if (event.type === "error") fail(new Error(event.code));
102
103
  } catch { fail(new Error("voice_stream_failed")); }
103
104
  });
104
- connection.addEventListener("close", () => fail(new Error("voice_disconnected")), { once: true });
105
- connection.addEventListener("error", () => fail(new Error("voice_disconnected")), { once: true });
105
+ const dropped = () => fail(new Error(options.signal?.aborted ? "voice_cancelled" : "voice_disconnected"));
106
+ connection.addEventListener("close", dropped, { once: true });
107
+ connection.addEventListener("error", dropped, { once: true });
106
108
  if (options.signal?.aborted) fail(new Error("voice_cancelled"));
107
109
  await ready;
108
110
  return {