@olares/lares-core 0.15.23 → 0.15.24

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.23",
3
+ "version": "0.15.24",
4
4
  "description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
5
5
  "type": "module",
6
6
  "repository": {
@@ -157,14 +157,15 @@ export function createVoiceController({ input, agent, output, voiceId = mintRpcI
157
157
  change({ connection: code ? "paused" : "closed", error: code });
158
158
  await Promise.allSettled([cancelInput(), stopPlayback()]);
159
159
  },
160
- async resume({ shouldResume = () => true } = {}) {
160
+ async resume({ shouldResume = () => true, resumeListening = !manualTurns } = {}) {
161
161
  const resumedEpoch = epoch;
162
162
  if (state.connection !== "paused") return;
163
163
  const running = state.sessionId ? await agent.running(state.sessionId) : false;
164
164
  if (resumedEpoch !== epoch || !shouldResume()) return;
165
165
  clearTimers();
166
166
  change({ connection: "open", agent: running ? "running" : "idle", error: null });
167
- touch(); if (!manualTurns) void listen();
167
+ touch();
168
+ if (resumeListening) void listen();
168
169
  },
169
170
  };
170
171
  return controller;
package/voice/panel.js CHANGED
@@ -55,16 +55,28 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
55
55
  if (processing === abort) { processing = null; renderDraft(); }
56
56
  }
57
57
  };
58
- const prepare = async (token) => {
58
+ const readoutWanted = async () => {
59
+ if (readAloudPreference === true) return true;
60
+ if (readAloudPreference === false) return false;
61
+ return (await loadConfig())?.speak === true;
62
+ };
63
+ const prepare = async (token, { readout = false } = {}) => {
64
+ const armed = () => token === intent && (readout || state.visible);
65
+ if (readout && !await readoutWanted()) return;
59
66
  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);
67
+ await call.resume({ shouldResume: armed, ...(readout ? { resumeListening: false } : {}) });
68
+ if (armed() && readout) await panel.setReadAloud(true);
69
+ else if (armed() && state.mode === "continuous" && typeof readAloudPreference === "boolean") await panel.setReadAloud(readAloudPreference);
70
+ return;
71
+ }
72
+ if (call?.controller.getSnapshot().connection === "open") {
73
+ if (readout && readAloudPreference === true) await panel.setReadAloud(true);
62
74
  return;
63
75
  }
64
- if (call?.controller.getSnapshot().connection === "open") return;
65
76
  await end(); config = state.mode === "single" && config ? config : structuredClone(await loadConfig());
66
- if (token !== intent || !state.visible) return;
77
+ if (!armed()) return;
67
78
  if (state.mode === "continuous" && typeof readAloudPreference === "boolean") config = { ...config, speak: readAloudPreference };
79
+ if (readout && config.speak !== true) return;
68
80
  const initialReadAloud = state.mode === "continuous" && config.speak === true;
69
81
  const target = state.sessionId, epoch = ++generation;
70
82
  const conversation = await create({ mode: state.mode, sessionId: target, config, preparation: inputPreparation,
@@ -73,7 +85,7 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
73
85
  const raw = [state.raw, text].filter(Boolean).join(" "); emit({ raw, draft: raw, edited: false }); save();
74
86
  }, onDevice(device) { if (epoch === generation) emit({ device }); },
75
87
  });
76
- if (token !== intent || !state.visible) { await conversation.stop(); return; }
88
+ if (!armed()) { await conversation.stop(); return; }
77
89
  call = conversation; appliedReadAloud = initialReadAloud;
78
90
  unsubscribe = call.controller.subscribe(() => {
79
91
  if (epoch !== generation) return;
@@ -82,11 +94,12 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
82
94
  emit({ snapshot, ...(snapshot.inputLevel == null ? {} : { levels: [...state.levels.slice(-19), snapshot.inputLevel] }),
83
95
  ...(snapshot.capture === "recording" && snapshot.inputReady && !state.startedAt ? { startedAt: Date.now(), elapsedMs: 0 } : snapshot.capture !== "recording" && state.startedAt ? { startedAt: null, elapsedMs: Date.now() - state.startedAt } : {}) });
84
96
  });
85
- await call.start({ sessionId: target, continuous: state.mode === "continuous", manual: state.mode === "continuous" && manualTurns, shouldStart: () => token === intent && state.visible });
86
- if (token !== intent || !state.visible) await call.controller.cancelRecording();
97
+ await call.start({ sessionId: target, continuous: state.mode === "continuous", manual: state.mode === "continuous" && (manualTurns || readout), shouldStart: armed });
98
+ if (!armed()) await call.controller.cancelRecording();
87
99
  else {
88
100
  readAloudReady = true;
89
- if (state.mode === "continuous" && typeof readAloudPreference === "boolean") await panel.setReadAloud(readAloudPreference);
101
+ if (readout) await panel.setReadAloud(true);
102
+ else if (state.mode === "continuous" && typeof readAloudPreference === "boolean") await panel.setReadAloud(readAloudPreference);
90
103
  }
91
104
  };
92
105
  const pause = async () => {
@@ -165,6 +178,12 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
165
178
  close() { intent++; cancelPreparation(); cancelProcessing(); emit({ visible: false, holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
166
179
  pause() { intent++; cancelPreparation(); cancelProcessing(); emit({ holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
167
180
  start() { armOutput(); const token = ++intent; return enqueue(() => prepare(token)); },
181
+ openReadout() {
182
+ if (state.mode !== "continuous") return Promise.resolve();
183
+ armOutput();
184
+ const token = ++intent;
185
+ return enqueue(() => prepare(token, { readout: true }));
186
+ },
168
187
  finish() {
169
188
  if (state.busy) return Promise.resolve();
170
189
  const token = intent;
@@ -7,25 +7,26 @@ export function voicePanelPresentation(view) {
7
7
  const preparing = view.busy || capture === "recording" && view.snapshot?.inputReady === false;
8
8
  const recording = capture === "recording" && !preparing;
9
9
  const reported = view.error || view.snapshot?.error;
10
+ // Recovery and idle endings are not failures. Hold and send open readout again.
10
11
  // 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;
12
+ const error = QUIET_ENDINGS.has(reported) ? null : reported === "voice_disconnected" && view.snapshot?.outputMuted ? "voice_background" : reported;
12
13
  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";
13
14
  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];
14
15
  return { phase, status, preview, recording, pending: processing || transcribing || preparing, error };
15
16
  }
16
17
 
17
- const RESTART_ERRORS = new Set(["voice_recovery_expired", "voice_idle_timeout"]);
18
+ const QUIET_ENDINGS = new Set(["voice_recovery_expired", "voice_idle_timeout"]);
18
19
 
19
20
  /**
20
21
  * A continuous call that has failed is one composer banner.
21
- * The session can still be resumed while it is paused; once the recovery
22
- * window has closed, the only way back is a new call.
22
+ * A paused session can still be resumed. Recovery and idle endings are quiet:
23
+ * hold-to-speak and send open readout again, without a banner.
23
24
  */
24
25
  export function voiceCallFailure(view) {
25
26
  if (!view?.visible || view.mode !== "continuous") return null;
26
27
  const ui = voicePanelPresentation(view);
27
28
  if (!ui.error) return null;
28
- const recoverable = view.snapshot?.connection === "paused" && !RESTART_ERRORS.has(ui.error);
29
+ const recoverable = view.snapshot?.connection === "paused" && !QUIET_ENDINGS.has(ui.error);
29
30
  return { code: ui.error, action: recoverable ? "resume" : "restart" };
30
31
  }
31
32
 
@@ -45,7 +46,8 @@ export function voiceDictationPresentation(view, now = Date.now()) {
45
46
  }
46
47
 
47
48
  export function voiceIndicatorPresentation(view, settingsError = null) {
48
- const error = view.error || view.snapshot?.error || view.readAloudError || settingsError;
49
+ const callError = view.error || view.snapshot?.error;
50
+ const error = (QUIET_ENDINGS.has(callError) ? null : callError) || view.readAloudError || settingsError;
49
51
  const continuous = view.mode === "continuous";
50
52
  const active = view.visible && (view.snapshot?.connection === "open" || view.busy && !view.snapshot);
51
53
  return {