@olares/lares-core 0.15.10 → 0.15.12

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 (45) hide show
  1. package/i18n/voice.js +246 -13
  2. package/larepass/host.js +6 -0
  3. package/larepass/mux.js +4 -3
  4. package/larepass/runtime.js +5 -3
  5. package/larepass/settings.js +3 -0
  6. package/larepass/transcript.js +4 -6
  7. package/olares/trusted-host.js +11 -1
  8. package/package.json +27 -2
  9. package/router/tts.js +25 -1
  10. package/tools/http.js +27 -1
  11. package/voice/agent-session.js +133 -0
  12. package/voice/agent.js +9 -1
  13. package/voice/browser-capture.js +274 -0
  14. package/voice/browser-playback.js +30 -0
  15. package/voice/capture.js +12 -2
  16. package/voice/client.js +71 -9
  17. package/voice/composite-gateway.js +156 -0
  18. package/voice/config.js +30 -17
  19. package/voice/controller.js +147 -0
  20. package/voice/conversation.js +131 -0
  21. package/voice/device-input.js +79 -0
  22. package/voice/dialogue.js +71 -0
  23. package/voice/duplex.js +1 -1
  24. package/voice/external.js +26 -0
  25. package/voice/gpu-mode.js +460 -0
  26. package/voice/gpu-problem.js +47 -0
  27. package/voice/host/bluetooth-ready.py +2 -0
  28. package/voice/host-input.js +38 -0
  29. package/voice/host-service.js +171 -0
  30. package/voice/input.js +2 -1
  31. package/voice/install.js +33 -12
  32. package/voice/languages.js +2 -6
  33. package/voice/lease.js +16 -0
  34. package/voice/options.js +23 -0
  35. package/voice/protocol-client.js +24 -0
  36. package/voice/realtime-audio.js +107 -0
  37. package/voice/realtime-conversation.js +122 -0
  38. package/voice/realtime-gateway.js +162 -0
  39. package/voice/realtime.js +63 -0
  40. package/voice/records.js +72 -0
  41. package/voice/router-audio.js +26 -0
  42. package/voice/script.js +85 -15
  43. package/voice/service.js +340 -57
  44. package/voice/session.js +11 -1
  45. package/voice/speech.js +228 -44
package/i18n/voice.js CHANGED
@@ -1,4 +1,55 @@
1
1
  export const ZH = {
2
+ "settings.backend": "语音后端",
3
+ "settings.backend.pipeline": "STT → Lares → TTS",
4
+ "settings.realtime.model": "Realtime 模型",
5
+ "settings.realtime.hint": "Realtime 模型完成实际验收后才可启用。普通语音模式始终可用。",
6
+ "error.voice_host_upgrade_required": "需要更新 Lares 主机音频助手",
7
+ "error.voice_device_lost": "音频设备已断开,请重新连接后恢复语音",
8
+
9
+ "call.mute": "关闭本次朗读",
10
+ "call.unmute": "开启本次朗读",
11
+ "call.history": "语音交流记录",
12
+ "call.clearHistory": "清除交流记录",
13
+ "settings.dictationCleanup": "整理听写文字(保留原意)",
14
+ "settings.preset": "语音工作方式",
15
+ "settings.chatModel": "交流模型",
16
+ "settings.preset.dictation": "只做听写",
17
+ "settings.preset.chat": "纯语音聊天",
18
+ "settings.preset.assistant": "语音助手委托 Lares",
19
+ "settings.preset.direct": "每轮直接提交 Lares",
20
+ "error.voice_idle_timeout": "语音因闲置已结束,点击重新开始",
21
+ "error.voice_recovery_expired": "语音恢复窗口已结束,点击重新开始",
22
+ "mic.cancel": "取消录音",
23
+ "mic.finish": "完成录音",
24
+ "call.pauseInput": "暂停收听",
25
+ "call.resumeInput": "恢复收听",
26
+ "call.inputPaused": "麦克风已暂停",
27
+ "call.halfDuplex": "播放中,麦克风已暂停",
28
+ "error.voice_draft_changed": "输入框暂时无法插入,请保留转写文字后重试",
29
+ "error.voice_background": "语音已暂停,点击恢复",
30
+ "settings.mobileMode": "移动端默认模式",
31
+ "settings.webMode": "网页默认模式",
32
+ "call.start": "语音对话",
33
+ "call.end": "结束语音",
34
+ "call.resume": "恢复语音",
35
+ "call.stopPlayback": "停止朗读",
36
+ "call.cancelTask": "取消任务",
37
+ "call.listening": "正在收听",
38
+ "call.working": "正在执行任务",
39
+ "call.speaking": "正在播放",
40
+ "call.waiting": "等待下一轮",
41
+ "settings.conversation": "语音对话",
42
+ "settings.mode": "对话方式",
43
+ "settings.mode.single": "语音输入",
44
+ "settings.mode.continuous": "连续对话",
45
+ "settings.device": "音频设备",
46
+ "settings.device.auto": "默认设备",
47
+ "settings.device.device": "浏览器麦克风与扬声器",
48
+ "settings.device.headset": "主机/蓝牙设备",
49
+ "error.voice_device_busy": "音频设备正在被其他语音连接使用",
50
+ "error.voice_disconnected": "连接已断开,点击恢复后继续收听",
51
+ "error.voice_realtime_unavailable": "Realtime 尚未验收,请选择普通语音模式",
52
+
2
53
  "error.voice_permission_denied": "麦克风权限被拒绝",
3
54
  "error.voice_audio_too_large": "录音过长",
4
55
  "error.voice_audio_unreadable": "音频无法识别,请重录",
@@ -9,7 +60,7 @@ export const ZH = {
9
60
  "error.voice_failed": "转写失败,请重试",
10
61
  "error.voice_unsupported": "当前浏览器不支持录音",
11
62
  "error.voice_bluetooth_not_ready": "蓝牙麦克风还没连上,请重试",
12
- "error.voice_bluetooth_unavailable": "这台机器的蓝牙还没就绪",
63
+ "error.voice_bluetooth_unavailable": "这台机器的蓝牙还没就绪。请确认机器有蓝牙并已打开,然后在 Olares 市场重启 Lares,重启时会重新安装蓝牙组件",
13
64
  "error.voice_bluetooth_scan_failed": "扫描附近设备失败",
14
65
  "error.voice_bluetooth_connect_failed": "连接失败,请确认设备仍在等待连接",
15
66
  "error.voice_bluetooth_invalid": "蓝牙地址无效",
@@ -18,28 +69,90 @@ export const ZH = {
18
69
  "error.voice_speak_failed": "语音播报失败,请重试",
19
70
  "error.voice_speak_timeout": "语音播报超时,请重试",
20
71
  "error.voice_speak_unready": "当前不能保存语音播报开关",
21
- "mic.preparing": "正在连接蓝牙麦克风…",
72
+ "error.voice_gpu_failed": "切换显卡模式失败({detail})。可以点重试;仍然失败,请到 Olares「设置 → GPU」查看显卡状态",
73
+ "error.voice_gpu_busy": "显卡正在切换,请等它完成后再操作",
74
+ "error.voice_gpu_no_gpu": "这台机器没有可用的 NVIDIA 显卡,不能边写边读,请使用「写完再读」",
75
+ "error.voice_gpu_unsupported": "{model} 不支持显存分片,不能边写边读,请使用「写完再读」",
76
+ "error.voice_gpu_plan_empty": "还没有应用分到显存,显卡模式没有改变。请先在下方「显存分片」里开启要用的应用并填好显存,保存后点重试",
77
+ "error.voice_gpu_plan_over": "这些应用至少需要 {need} GiB 显存,超过了显卡的 {total} GiB,显卡模式没有改变。请在下方「显存分片」里关掉一些应用或调小显存,保存后点重试",
78
+ "error.voice_gpu_plan_floor": "{app}的显存不能低于 {size} GiB",
79
+ "error.voice_gpu_unknown_app": "{app}不在这张显卡上,请刷新后重试",
80
+ "error.voice_gpu_no_slice": "{app}还没有分到显存。请先在「显存分片」里开启它并保存",
81
+ "error.voice_gpu_forbidden": "当前 Olares 账号没有管理显卡的权限,显卡模式没有改变。请用管理员账号安装 Lares,或请管理员到 Olares「设置 → GPU」里切换",
82
+ "error.voice_gpu_login": "Lares 访问 Olares 的登录凭据失效了。请在 Olares 市场重启 Lares 后再试",
83
+ "error.voice_gpu_blocked": "有应用停不下来,显卡模式没有改变({detail})。请先在 Olares 市场手动停止它,再点重试",
84
+ "error.voice_gpu_stop_failed": "{app}停不下来,显卡模式没有改变({detail})。请在 Olares 市场查看它的状态,再点重试",
85
+ "error.voice_gpu_memory": "{app}显存不够,没能启动。请在「显存分片」里调大它的显存,或关掉其他应用后重试",
86
+ "error.voice_gpu_resume_failed": "{app}没能启动({detail})。请在 Olares 市场查看它的状态,再点重试",
87
+ "mic.preparing": "正在准备麦克风…",
22
88
  "mic.stop": "点击结束录音",
23
89
  "mic.transcribing": "转写中…",
24
90
  "mic.idle": "语音输入",
25
91
  "mic.blocked": "当前无法输入",
26
92
  "lang.auto": "自动检测",
27
- "status.auto": "自动",
28
93
  "settings.title": "语音设置",
29
- "settings.intro": "点击麦克风开始说话,说完自动转成文字。语音播报开启后,只朗读这一轮的最终回答。",
30
94
  "settings.refresh": "刷新",
31
95
  "settings.refreshing": "刷新中…",
32
96
  "settings.router": "Router 控制台",
33
97
  "settings.loading": "正在读取语音设置…",
34
98
  "settings.loadFailed": "读取语音设置失败",
35
99
  "settings.refreshFailed": "刷新语音模型列表失败",
36
- "settings.status.ready": "识别就绪 · {model}",
37
- "settings.status.notReady": "还没有语音识别模型",
38
100
  "settings.stt.title": "语音识别",
39
101
  "settings.stt.hint": "把麦克风里的话转成文字",
40
102
  "settings.tts.title": "语音播报",
41
103
  "settings.tts.hint": "只朗读最终回答,思考和工具调用不播报",
42
104
  "settings.speak.label": "开启语音播报",
105
+ "settings.group.stt": "语音识别",
106
+ "settings.group.tts": "语音播报",
107
+ "settings.group.bluetooth": "输出设备",
108
+ "settings.stt.model": "识别模型",
109
+ "settings.tts.model": "播报模型",
110
+ "settings.speak.title": "自动朗读回答",
111
+ "settings.phase.running": "运行中",
112
+ "settings.phase.stopped": "已停止",
113
+ "settings.phase.busy": "处理中",
114
+ "settings.phase.failed": "异常",
115
+ "settings.phase.absent": "未安装",
116
+ "settings.mode.title": "播报方式",
117
+ "settings.mode.hint": "边写边读用显存分片,写完再读用时间分片。切换约 2–3 分钟,期间模型不可用",
118
+ "settings.mode.gpu": "显卡:{type}",
119
+ "settings.mode.turns": "写完再读",
120
+ "settings.mode.streaming": "边写边读",
121
+ "settings.mode.switching": "正在切换显卡模式…",
122
+ "settings.mode.resumingApp": "正在恢复 {app}…",
123
+ "settings.mode.restoring": "切换没成功,正在恢复原来的应用…",
124
+ "settings.mode.restored": "原来运行的应用已恢复。",
125
+ "settings.mode.notRestored": "这些应用没能恢复,请在 Olares 市场手动启动:{apps}",
126
+ "settings.mode.partial": "显卡已切换,但有应用没能启动。",
127
+ "settings.mode.mismatch": "显卡当前是{type},和播报方式不一致",
128
+ "settings.mode.retry": "重试",
129
+ "settings.list.separator": "、",
130
+ "settings.gpu.MemorySlice": "显存分片",
131
+ "settings.gpu.TimeSlice": "时间分片",
132
+ "settings.gpu.Exclusive": "独占",
133
+ "settings.apps.title": "显卡应用",
134
+ "settings.apps.hint": "时间分片下这些应用轮流使用整张显卡;同时运行太多,显存可能不够",
135
+ "settings.apps.required": "至少 {size} GiB 显存",
136
+ "settings.apps.start": "启动",
137
+ "settings.apps.stop": "停止",
138
+ "settings.apps.starting": "启动中…",
139
+ "settings.apps.stopping": "停止中…",
140
+ "settings.apps.empty": "没有需要显卡的应用",
141
+ "settings.slices.title": "显存分片",
142
+ "settings.slices.gpu": "显卡",
143
+ "settings.slices.used": "已分配 {used} / {total} GiB",
144
+ "settings.slices.hint": "选择哪些应用常驻显卡、各分多少显存。保存后只重启有改动的应用",
145
+ "settings.slices.hintPending": "切到边写边读时,按这里的配置分配显存",
146
+ "settings.slices.size": "{app}的显存",
147
+ "settings.slices.enable": "{app}参与显存分片",
148
+ "settings.slices.empty": "还没有应用绑定到这张显卡",
149
+ "settings.slices.over": "超出显卡显存 {over} GiB",
150
+ "settings.slices.invalid": "开启的应用显存不能低于它的最低要求",
151
+ "settings.slices.restart": "有改动的应用会重启",
152
+ "settings.slices.reset": "还原",
153
+ "settings.slices.save": "保存",
154
+ "settings.slices.apply": "保存并应用",
155
+ "settings.slices.saving": "保存中…",
43
156
  "settings.install": "安装",
44
157
  "settings.installing": "安装中…",
45
158
  "settings.installStarted": "已开始安装,模型下载完成后点刷新",
@@ -68,6 +181,57 @@ export const ZH = {
68
181
  };
69
182
 
70
183
  export const EN = {
184
+ "settings.backend": "Voice backend",
185
+ "settings.backend.pipeline": "STT → Lares → TTS",
186
+ "settings.realtime.model": "Realtime model",
187
+ "settings.realtime.hint": "Realtime is available only after model validation. Pipeline mode remains available.",
188
+ "error.voice_host_upgrade_required": "Update the Lares host audio helper",
189
+ "error.voice_device_lost": "Audio device disconnected. Reconnect it and resume voice.",
190
+
191
+ "call.mute": "Mute replies",
192
+ "call.unmute": "Enable spoken replies",
193
+ "call.history": "Voice history",
194
+ "call.clearHistory": "Clear voice history",
195
+ "settings.dictationCleanup": "Clean up dictation without rewriting",
196
+ "settings.preset": "Voice behavior",
197
+ "settings.chatModel": "Conversation model",
198
+ "settings.preset.dictation": "Dictation only",
199
+ "settings.preset.chat": "Voice chat",
200
+ "settings.preset.assistant": "Voice assistant delegates to Lares",
201
+ "settings.preset.direct": "Send every turn to Lares",
202
+ "error.voice_idle_timeout": "Voice ended after inactivity. Start again to continue.",
203
+ "error.voice_recovery_expired": "Voice recovery expired. Start again to continue.",
204
+ "mic.cancel": "Cancel recording",
205
+ "mic.finish": "Finish recording",
206
+ "call.pauseInput": "Pause microphone",
207
+ "call.resumeInput": "Resume microphone",
208
+ "call.inputPaused": "Microphone paused",
209
+ "call.halfDuplex": "Microphone paused during playback",
210
+ "error.voice_draft_changed": "The composer cannot accept text yet. Keep the transcript and retry.",
211
+ "error.voice_background": "Voice paused. Resume explicitly.",
212
+ "settings.mobileMode": "Mobile default mode",
213
+ "settings.webMode": "Web default mode",
214
+ "call.start": "Voice conversation",
215
+ "call.end": "End voice",
216
+ "call.resume": "Resume voice",
217
+ "call.stopPlayback": "Stop speaking",
218
+ "call.cancelTask": "Cancel task",
219
+ "call.listening": "Listening",
220
+ "call.working": "Working",
221
+ "call.speaking": "Speaking",
222
+ "call.waiting": "Waiting",
223
+ "settings.conversation": "Voice conversation",
224
+ "settings.mode": "Conversation mode",
225
+ "settings.mode.single": "Dictation",
226
+ "settings.mode.continuous": "Continuous conversation",
227
+ "settings.device": "Audio device",
228
+ "settings.device.auto": "Default device",
229
+ "settings.device.device": "Browser microphone and speaker",
230
+ "settings.device.headset": "Host / Bluetooth device",
231
+ "error.voice_device_busy": "Audio device is in use by another connection",
232
+ "error.voice_disconnected": "Connection lost. Resume to listen again",
233
+ "error.voice_realtime_unavailable": "Realtime has not been validated. Select pipeline mode",
234
+
71
235
  "error.voice_permission_denied": "Microphone access denied",
72
236
  "error.voice_audio_too_large": "Recording too long",
73
237
  "error.voice_audio_unreadable": "Audio unreadable, please re-record",
@@ -79,7 +243,8 @@ export const EN = {
79
243
  "error.voice_failed": "Transcription failed, please retry",
80
244
  "error.voice_unsupported": "This browser does not support recording",
81
245
  "error.voice_bluetooth_not_ready": "Bluetooth microphone is not ready, please retry",
82
- "error.voice_bluetooth_unavailable": "Bluetooth is not ready on this machine",
246
+ "error.voice_bluetooth_unavailable":
247
+ "Bluetooth is not ready on this machine. Make sure it has Bluetooth turned on, then restart Lares in the Olares Market; the restart reinstalls the Bluetooth pieces",
83
248
  "error.voice_bluetooth_scan_failed": "Failed to scan for nearby devices",
84
249
  "error.voice_bluetooth_connect_failed": "Connection failed. Confirm the device is still waiting to connect",
85
250
  "error.voice_bluetooth_invalid": "Invalid Bluetooth address",
@@ -88,29 +253,97 @@ export const EN = {
88
253
  "error.voice_speak_failed": "Couldn't speak the answer, please retry",
89
254
  "error.voice_speak_timeout": "Speech timed out, please retry",
90
255
  "error.voice_speak_unready": "Speech playback can't be saved right now",
91
- "mic.preparing": "Connecting the Bluetooth microphone…",
256
+ "error.voice_gpu_failed":
257
+ "Couldn't switch the GPU mode ({detail}). Retry; if it keeps failing, check the GPU in Olares Settings → GPU",
258
+ "error.voice_gpu_busy": "The GPU is switching. Wait for it to finish",
259
+ "error.voice_gpu_no_gpu": "This machine has no usable NVIDIA GPU, so reading while writing is unavailable. Use \"When done\"",
260
+ "error.voice_gpu_unsupported": "{model} does not support memory slicing, so reading while writing is unavailable. Use \"When done\"",
261
+ "error.voice_gpu_plan_empty":
262
+ "No app has a memory slice yet, so the GPU mode was left unchanged. Turn on the apps you need under Memory slices below, set their memory, save, then retry",
263
+ "error.voice_gpu_plan_over":
264
+ "These apps need at least {need} GiB, more than the GPU's {total} GiB, so the GPU mode was left unchanged. Turn some off or lower their memory under Memory slices below, save, then retry",
265
+ "error.voice_gpu_plan_floor": "{app} needs at least {size} GiB",
266
+ "error.voice_gpu_unknown_app": "{app} is not on this GPU. Refresh and try again",
267
+ "error.voice_gpu_no_slice": "{app} has no memory slice yet. Turn it on under Memory slices and save",
268
+ "error.voice_gpu_forbidden":
269
+ "This Olares account cannot manage the GPU, so the GPU mode was left unchanged. Install Lares from an admin account, or ask an admin to switch it in Olares Settings → GPU",
270
+ "error.voice_gpu_login": "Lares lost its Olares sign-in. Restart Lares in the Olares Market and try again",
271
+ "error.voice_gpu_blocked":
272
+ "An app would not stop, so the GPU mode was left unchanged ({detail}). Stop it in the Olares Market, then retry",
273
+ "error.voice_gpu_stop_failed":
274
+ "{app} would not stop, so the GPU mode was left unchanged ({detail}). Check it in the Olares Market, then retry",
275
+ "error.voice_gpu_memory":
276
+ "{app} ran out of GPU memory and did not start. Give it more memory under Memory slices, or turn other apps off, then retry",
277
+ "error.voice_gpu_resume_failed": "{app} did not start ({detail}). Check it in the Olares Market, then retry",
278
+ "mic.preparing": "Preparing microphone…",
92
279
  "mic.stop": "Click to stop recording",
93
280
  "mic.transcribing": "Transcribing…",
94
281
  "mic.idle": "Voice input",
95
282
  "mic.blocked": "Input unavailable",
96
283
  "lang.auto": "Auto-detect",
97
- "status.auto": "auto",
98
284
  "settings.title": "Voice settings",
99
- "settings.intro":
100
- "Click the microphone to start speaking; the take is transcribed into the composer. With playback on, only the turn's final answer is spoken.",
101
285
  "settings.refresh": "Refresh",
102
286
  "settings.refreshing": "Refreshing…",
103
287
  "settings.router": "Router console",
104
288
  "settings.loading": "Loading voice settings…",
105
289
  "settings.loadFailed": "Failed to load voice settings",
106
290
  "settings.refreshFailed": "Failed to refresh voice models",
107
- "settings.status.ready": "Recognition ready · {model}",
108
- "settings.status.notReady": "No speech-recognition model yet",
109
291
  "settings.stt.title": "Speech recognition",
110
292
  "settings.stt.hint": "Turn the microphone into text",
111
293
  "settings.tts.title": "Speech playback",
112
294
  "settings.tts.hint": "Read only the final answer, not reasoning or tool calls",
113
295
  "settings.speak.label": "Speak the final answer",
296
+ "settings.group.stt": "Speech recognition",
297
+ "settings.group.tts": "Speech playback",
298
+ "settings.group.bluetooth": "Output device",
299
+ "settings.stt.model": "Recognition model",
300
+ "settings.tts.model": "Speech model",
301
+ "settings.speak.title": "Read answers aloud",
302
+ "settings.phase.running": "Running",
303
+ "settings.phase.stopped": "Stopped",
304
+ "settings.phase.busy": "Working",
305
+ "settings.phase.failed": "Failed",
306
+ "settings.phase.absent": "Not installed",
307
+ "settings.mode.title": "Reading mode",
308
+ "settings.mode.hint": "While writing uses memory slicing; when done uses time slicing. Switching takes 2–3 minutes; models are unavailable meanwhile",
309
+ "settings.mode.gpu": "GPU: {type}",
310
+ "settings.mode.turns": "When done",
311
+ "settings.mode.streaming": "While writing",
312
+ "settings.mode.switching": "Switching GPU mode…",
313
+ "settings.mode.resumingApp": "Resuming {app}…",
314
+ "settings.mode.restoring": "The switch failed. Restoring the apps that were running…",
315
+ "settings.mode.restored": "The apps that were running are back.",
316
+ "settings.mode.notRestored": "These apps did not come back; start them in the Olares Market: {apps}",
317
+ "settings.mode.partial": "The GPU switched, but some apps did not start.",
318
+ "settings.mode.mismatch": "The GPU is in {type}, which does not match the reading mode",
319
+ "settings.mode.retry": "Retry",
320
+ "settings.list.separator": ", ",
321
+ "settings.gpu.MemorySlice": "memory slicing",
322
+ "settings.gpu.TimeSlice": "time slicing",
323
+ "settings.gpu.Exclusive": "exclusive",
324
+ "settings.apps.title": "GPU apps",
325
+ "settings.apps.hint": "With time slicing these apps take turns on the whole GPU; running too many at once may run out of memory",
326
+ "settings.apps.required": "needs {size} GiB",
327
+ "settings.apps.start": "Start",
328
+ "settings.apps.stop": "Stop",
329
+ "settings.apps.starting": "Starting…",
330
+ "settings.apps.stopping": "Stopping…",
331
+ "settings.apps.empty": "No apps need the GPU",
332
+ "settings.slices.title": "Memory slices",
333
+ "settings.slices.gpu": "GPU",
334
+ "settings.slices.used": "{used} / {total} GiB allocated",
335
+ "settings.slices.hint": "Choose which apps stay on the GPU and how much memory each gets. Saving restarts only the apps that changed",
336
+ "settings.slices.hintPending": "Applied when switching to reading while writing",
337
+ "settings.slices.size": "Memory for {app}",
338
+ "settings.slices.enable": "{app} uses a memory slice",
339
+ "settings.slices.empty": "No apps are bound to this GPU yet",
340
+ "settings.slices.over": "{over} GiB over the GPU memory",
341
+ "settings.slices.invalid": "An enabled app cannot go below its minimum memory",
342
+ "settings.slices.restart": "Apps that changed will restart",
343
+ "settings.slices.reset": "Reset",
344
+ "settings.slices.save": "Save",
345
+ "settings.slices.apply": "Save and apply",
346
+ "settings.slices.saving": "Saving…",
114
347
  "settings.install": "Install",
115
348
  "settings.installing": "Installing…",
116
349
  "settings.installStarted": "Install started. Refresh after the model finishes downloading",
package/larepass/host.js CHANGED
@@ -3,6 +3,12 @@ export const LARES_API_PREFIX = "/api/lares";
3
3
  export const MODELS_PATH = `${LARES_API_PREFIX}/models`;
4
4
  export const PC_TEST_PROXY = "/laresHost";
5
5
 
6
+ /**
7
+ * Packaged LarePass page origins (Capacitor Android `androidScheme: http`, iOS).
8
+ * Binary bodies leave those pages as a cross-origin webview `fetch`.
9
+ */
10
+ export const LAREPASS_PAGE_ORIGINS = ["http://localhost", "https://localhost", "capacitor://localhost"];
11
+
6
12
  /** Host plugin routes (`file-preview`, models, voice, …). Same path on PC and LarePass. */
7
13
  export function isLaresPluginPath(path) {
8
14
  const suffix = String(path ?? "").startsWith("/") ? path : `/${path}`;
package/larepass/mux.js CHANGED
@@ -77,11 +77,12 @@ export function classifyRemoteEvent(value) {
77
77
  return { kind: "pass", eventId: value.eventId };
78
78
  }
79
79
 
80
+ // A tool call starting is what turns the step being read aloud silent.
81
+ const LIVE_CHUNKS = new Set(["text-delta", "reasoning-delta", "tool-call-delta", "block-end"]);
82
+
80
83
  function liveChunkEvent(seq, turn, step, chunk) {
81
84
  if (!chunk || typeof chunk !== "object") return null;
82
- if (chunk.type !== "text-delta" && chunk.type !== "reasoning-delta" && chunk.type !== "block-end") {
83
- return null;
84
- }
85
+ if (!LIVE_CHUNKS.has(chunk.type)) return null;
85
86
  return {
86
87
  type: "assistant/chunk",
87
88
  seq,
@@ -47,7 +47,7 @@ function viewOf(state, cache, pendingQuestions, pendingApprovals, inbox) {
47
47
  messages: snap.messages,
48
48
  running: snap.running,
49
49
  runningSince: snap.runningSince,
50
- reply: snap.reply,
50
+ utterance: snap.utterance,
51
51
  error: state.error || snap.error,
52
52
  failed: state.failed,
53
53
  phase: state.phase,
@@ -862,6 +862,8 @@ export function createChatRuntime(client) {
862
862
  }
863
863
  return client.upload(id, file, options);
864
864
  },
865
+ voiceSocket(ticket) { return client.voiceSocket(ticket); },
866
+ voiceRequest(path, options) { return client.voiceRequest(path, options); },
865
867
  transcribe(blob, language, signal) {
866
868
  if (typeof client.transcribe !== "function") {
867
869
  return Promise.reject(new Error("voice_failed"));
@@ -872,9 +874,9 @@ export function createChatRuntime(client) {
872
874
  if (typeof client.reportLog !== "function") return Promise.resolve(false);
873
875
  return client.reportLog(scope, event, fields);
874
876
  },
875
- speak(text) {
877
+ speak(request) {
876
878
  if (typeof client.speak !== "function") return Promise.resolve(null);
877
- return client.speak(text);
879
+ return client.speak(request);
878
880
  },
879
881
  interruptSpeech() {
880
882
  if (typeof client.interruptSpeech !== "function") return Promise.resolve(false);
@@ -101,6 +101,9 @@ export function createHostSettings(request) {
101
101
  status: payload(status, "/api/lares/voice/status"),
102
102
  sttModels: modelIds(listed, "stt"),
103
103
  ttsModels: modelIds(listed, "tts"),
104
+ chatModels: modelIds(listed, "chat"),
105
+ realtimeModels: modelIds(listed, "realtime"),
106
+ realtimeReady: modelIds(listed, "realtimeReady"),
104
107
  };
105
108
  }, { force: Boolean(force) });
106
109
  },
@@ -1,6 +1,6 @@
1
1
  import { producedPathsFromView, turnMediaPathsFromEvent } from "../files/deliverables.js";
2
2
  import { durablePathFromToolCall, parseToolArguments } from "../files/published-tools.js";
3
- import { concludingReply } from "../voice/speech.js";
3
+ import { reduceUtterance } from "../voice/speech.js";
4
4
  import { contextForm, contextProvenance, isHumanUserSource } from "./context-provenance.js";
5
5
  import { toolRowModel } from "./tool-row.js";
6
6
  import { createTurnMetrics } from "./turn-metrics.js";
@@ -111,7 +111,7 @@ export function foldTranscript(entries) {
111
111
  let running = false;
112
112
  let runningSince = 0;
113
113
  let error = "";
114
- let reply = null;
114
+ let utterance = null;
115
115
  let streamCursor = { loc: "", kind: "", index: 0 };
116
116
  let activeTurn = 0;
117
117
  const ended = new Map();
@@ -238,6 +238,7 @@ export function foldTranscript(entries) {
238
238
  const data = event.data ?? {};
239
239
  const view = entryView(entry, event);
240
240
  metrics.observe(event);
241
+ utterance = reduceUtterance(utterance, event);
241
242
 
242
243
  if (event.type === "turn/start") {
243
244
  closeTurn();
@@ -248,7 +249,6 @@ export function foldTranscript(entries) {
248
249
  running = true;
249
250
  runningSince = Number(event.time) || 0;
250
251
  error = "";
251
- reply = null;
252
252
  continue;
253
253
  }
254
254
 
@@ -280,8 +280,6 @@ export function foldTranscript(entries) {
280
280
 
281
281
  if (event.type === "assistant/message") {
282
282
  applyAssistantMessage(data);
283
- const text = concludingReply(event);
284
- if (text) reply = { seq: event.seq, text };
285
283
  continue;
286
284
  }
287
285
 
@@ -376,6 +374,6 @@ export function foldTranscript(entries) {
376
374
  running,
377
375
  runningSince: running ? runningSince : 0,
378
376
  error,
379
- reply,
377
+ utterance,
380
378
  };
381
379
  }
@@ -2,9 +2,19 @@ export function loopbackAuthority(port) {
2
2
  return `127.0.0.1:${port}`;
3
3
  }
4
4
 
5
+ /** The browser's Origin, kept for CORS after the loopback rewrite replaces it. */
6
+ const pageOrigins = new WeakMap();
7
+
8
+ export function pageOrigin(headers) {
9
+ return pageOrigins.get(headers) ?? headers?.origin;
10
+ }
11
+
5
12
  export function applyLoopbackHeaders(headers, authority) {
6
13
  headers.host = authority;
7
- if (headers.origin !== undefined) headers.origin = `http://${authority}`;
14
+ if (headers.origin !== undefined) {
15
+ if (!pageOrigins.has(headers)) pageOrigins.set(headers, headers.origin);
16
+ headers.origin = `http://${authority}`;
17
+ }
8
18
  // Fetch Metadata outlives the origin rewrite. The browser decides this one and
9
19
  // we cannot argue with it: a packaged page calling the entrance is `cross-site`
10
20
  // however the host and origin now read, and dsh refuses that outright — the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-core",
3
- "version": "0.15.10",
3
+ "version": "0.15.12",
4
4
  "description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
5
5
  "type": "module",
6
6
  "repository": {
@@ -123,6 +123,8 @@
123
123
  "./voice/problem": "./voice/problem.js",
124
124
  "./voice/capture": "./voice/capture.js",
125
125
  "./voice/install": "./voice/install.js",
126
+ "./voice/gpu-mode": "./voice/gpu-mode.js",
127
+ "./voice/gpu-problem": "./voice/gpu-problem.js",
126
128
  "./search/config": "./search/config.js",
127
129
  "./search/menu": "./search/menu.js",
128
130
  "./search/client": "./search/client.js",
@@ -167,6 +169,29 @@
167
169
  "types": "./skills/state.d.ts",
168
170
  "default": "./skills/state.js"
169
171
  },
170
- "./skills/client": "./skills/client.js"
172
+ "./skills/client": "./skills/client.js",
173
+ "./voice/controller": "./voice/controller.js",
174
+ "./voice/lease": "./voice/lease.js",
175
+ "./voice/browser-capture": "./voice/browser-capture.js",
176
+ "./voice/agent-session": "./voice/agent-session.js",
177
+ "./voice/device-input": "./voice/device-input.js",
178
+ "./voice/host-input": "./voice/host-input.js",
179
+ "./voice/conversation": "./voice/conversation.js",
180
+ "./voice/browser-playback": "./voice/browser-playback.js",
181
+ "./voice/realtime": "./voice/realtime.js",
182
+ "./voice/realtime-gateway": "./voice/realtime-gateway.js",
183
+ "./voice/realtime-audio": "./voice/realtime-audio.js",
184
+ "./voice/realtime-conversation": "./voice/realtime-conversation.js",
185
+ "./voice/host-service": "./voice/host-service.js",
186
+ "./voice/options": "./voice/options.js",
187
+ "./voice/records": "./voice/records.js",
188
+ "./voice/dialogue": "./voice/dialogue.js",
189
+ "./voice/external": "./voice/external.js",
190
+ "./voice/composite-gateway": "./voice/composite-gateway.js",
191
+ "./voice/router-audio": "./voice/router-audio.js",
192
+ "./voice/protocol-client": "./voice/protocol-client.js"
193
+ },
194
+ "dependencies": {
195
+ "ws": "^8.22.0"
171
196
  }
172
197
  }
package/router/tts.js CHANGED
@@ -5,6 +5,28 @@ const REQUEST_TIMEOUT_MS = 180_000;
5
5
 
6
6
  const PCM_RATE = 24000;
7
7
 
8
+ /**
9
+ * Engines pick a speaker per request when none is named, so the readout voice
10
+ * changed between answers. First listed voice the model offers wins.
11
+ */
12
+ const PREFERRED_VOICES = ["serena", "vivian", "uncle_fu"];
13
+
14
+ export function pickTtsVoice(voices) {
15
+ const ids = voices.filter((id) => typeof id === "string" && id);
16
+ return PREFERRED_VOICES.find((id) => ids.includes(id)) ?? [...ids].sort()[0] ?? null;
17
+ }
18
+
19
+ /** @returns {Promise<string[]>} voice ids the model serves; empty when it lists none. */
20
+ export async function listTtsVoices(model) {
21
+ const res = await fetch(`${routerShimBaseUrl()}/audio/voices?model=${encodeURIComponent(model)}`, {
22
+ signal: AbortSignal.timeout(10_000),
23
+ });
24
+ if (!res.ok) return [];
25
+ const body = await res.json().catch(() => null);
26
+ const voices = Array.isArray(body?.voices) ? body.voices : [];
27
+ return voices.map((voice) => (typeof voice === "string" ? voice : voice?.id));
28
+ }
29
+
8
30
  /** Kokoro concatenates chunks with no gap. This is the silence we insert between sentences. */
9
31
  export function silencePcm(ms, sampleRate = PCM_RATE) {
10
32
  const samples = Math.max(0, Math.round((sampleRate * ms) / 1000));
@@ -47,8 +69,9 @@ export async function synthesize(request) {
47
69
  model: request.model,
48
70
  input: text,
49
71
  response_format: "wav",
72
+ ...(request.voice ? { voice: request.voice } : {}),
50
73
  }),
51
- signal: AbortSignal.timeout(request.timeoutMs ?? REQUEST_TIMEOUT_MS),
74
+ signal: request.signal ? AbortSignal.any([request.signal, AbortSignal.timeout(request.timeoutMs ?? REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(request.timeoutMs ?? REQUEST_TIMEOUT_MS),
52
75
  });
53
76
  } catch (err) {
54
77
  const timedOut = err?.name === "TimeoutError" || err?.name === "AbortError";
@@ -89,6 +112,7 @@ export async function synthesizeStream(request, onChunk) {
89
112
  input: text,
90
113
  response_format: "pcm",
91
114
  stream: true,
115
+ ...(request.voice ? { voice: request.voice } : {}),
92
116
  }),
93
117
  signal,
94
118
  });
package/tools/http.js CHANGED
@@ -1,3 +1,18 @@
1
+ import { LAREPASS_PAGE_ORIGINS } from "../larepass/host.js";
2
+ import { pageOrigin } from "../olares/trusted-host.js";
3
+
4
+ const CORS_HEADERS = "content-type, x-authorization, x-unauth-error, x-lares-session-id, x-lares-file-name, x-lares-upload-request-id, x-lares-voice-id, x-lares-voice-device";
5
+
6
+ /** @returns {boolean} true when the request came from a LarePass page. */
7
+ function allowLarepassOrigin(req, res) {
8
+ const origin = pageOrigin(req.headers);
9
+ if (!origin || !LAREPASS_PAGE_ORIGINS.includes(origin)) return false;
10
+ res.setHeader("access-control-allow-origin", origin);
11
+ res.setHeader("access-control-allow-credentials", "true");
12
+ res.setHeader("vary", "Origin");
13
+ return true;
14
+ }
15
+
1
16
  export class HttpError extends Error {
2
17
  constructor(code, status, message) {
3
18
  super(message);
@@ -70,6 +85,7 @@ function errorResponse(err, fallbackCode) {
70
85
  status: err.status >= 400 ? err.status : 500,
71
86
  code: err.code,
72
87
  message: err instanceof Error ? err.message : String(err),
88
+ params: err.params && typeof err.params === "object" ? err.params : undefined,
73
89
  };
74
90
  }
75
91
  return {
@@ -82,7 +98,7 @@ function errorResponse(err, fallbackCode) {
82
98
  export function sendError(res, err, fallbackCode) {
83
99
  const failure = errorResponse(err, fallbackCode);
84
100
  sendJson(res, failure.status, {
85
- error: { code: failure.code, message: failure.message },
101
+ error: { code: failure.code, message: failure.message, params: failure.params },
86
102
  });
87
103
  }
88
104
 
@@ -99,12 +115,22 @@ export function createRouteHandler(options) {
99
115
  const suffix = new URL(req.url ?? "/", "http://x").pathname.slice(options.prefix.length) || "/";
100
116
  const path = suffix.replace(/\/+$/, "") || "/";
101
117
  const handlers = options.routes[path];
118
+ const cors = allowLarepassOrigin(req, res);
102
119
  if (!handlers) {
103
120
  sendJson(res, 404, {
104
121
  error: { code: "not_found", message: `no route ${path}` },
105
122
  });
106
123
  return;
107
124
  }
125
+ if (cors && method === "OPTIONS") {
126
+ res.writeHead(204, {
127
+ "access-control-allow-methods": Object.keys(handlers).join(", "),
128
+ "access-control-allow-headers": CORS_HEADERS,
129
+ "access-control-max-age": "600",
130
+ });
131
+ res.end();
132
+ return;
133
+ }
108
134
  const route = handlers[method];
109
135
  if (!route) {
110
136
  sendJson(res, 405, {