@olares/lares-core 0.15.20 → 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.
@@ -221,7 +221,7 @@ export function transcriptBlocks(items, { running = false } = {}) {
221
221
  if (showHeader && !folded && memberSet.has(entry)) {
222
222
  blocks.push({
223
223
  kind: "fold",
224
- key: `fold-${group.turn ?? `g${groupIndex}`}-${entry.index}`,
224
+ key: `fold-${group.turn ?? `g${groupIndex}`}-${entry.row?.key || entry.row?.id || entry.index}`,
225
225
  phase,
226
226
  foldable,
227
227
  reason,
@@ -232,7 +232,12 @@ export function transcriptBlocks(items, { running = false } = {}) {
232
232
  folded = true;
233
233
  }
234
234
  if (memberSet.has(entry) && showHeader) continue;
235
- blocks.push({ kind: "row", key: `row-${entry.index}`, index: entry.index, row: entry.row });
235
+ blocks.push({
236
+ kind: "row",
237
+ key: entry.row?.key || entry.row?.id || `row-${entry.index}`,
238
+ index: entry.index,
239
+ row: entry.row,
240
+ });
236
241
  }
237
242
  });
238
243
  return blocks;
@@ -152,6 +152,9 @@ export function foldTranscript(entries) {
152
152
  };
153
153
 
154
154
  const putBlock = (turn, step, index, row) => {
155
+ // The block is one row for its whole life. Keying it by the event being
156
+ // folded makes the key change on every chunk, and the list remounts.
157
+ row.key = `block:${blockKey(turn, step, index)}:${row.type}`;
155
158
  const stored = upsert(items, blocks, blockKey(turn, step, index), tag(row));
156
159
  // The turn's readings belong under its closing reply, the row whose action
157
160
  // bar ends the turn on screen.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-core",
3
- "version": "0.15.20",
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 {