@olares/lares-core 0.15.9 → 0.15.11

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/i18n/voice.js CHANGED
@@ -24,22 +24,67 @@ export const ZH = {
24
24
  "mic.idle": "语音输入",
25
25
  "mic.blocked": "当前无法输入",
26
26
  "lang.auto": "自动检测",
27
- "status.auto": "自动",
28
27
  "settings.title": "语音设置",
29
- "settings.intro": "点击麦克风开始说话,说完自动转成文字。语音播报开启后,只朗读这一轮的最终回答。",
30
28
  "settings.refresh": "刷新",
31
29
  "settings.refreshing": "刷新中…",
32
30
  "settings.router": "Router 控制台",
33
31
  "settings.loading": "正在读取语音设置…",
34
32
  "settings.loadFailed": "读取语音设置失败",
35
33
  "settings.refreshFailed": "刷新语音模型列表失败",
36
- "settings.status.ready": "识别就绪 · {model}",
37
- "settings.status.notReady": "还没有语音识别模型",
38
34
  "settings.stt.title": "语音识别",
39
35
  "settings.stt.hint": "把麦克风里的话转成文字",
40
36
  "settings.tts.title": "语音播报",
41
37
  "settings.tts.hint": "只朗读最终回答,思考和工具调用不播报",
42
38
  "settings.speak.label": "开启语音播报",
39
+ "settings.group.stt": "语音识别",
40
+ "settings.group.tts": "语音播报",
41
+ "settings.group.bluetooth": "输出设备",
42
+ "settings.stt.model": "识别模型",
43
+ "settings.tts.model": "播报模型",
44
+ "settings.speak.title": "自动朗读回答",
45
+ "settings.phase.running": "运行中",
46
+ "settings.phase.stopped": "已停止",
47
+ "settings.phase.busy": "处理中",
48
+ "settings.phase.failed": "异常",
49
+ "settings.phase.absent": "未安装",
50
+ "settings.mode.title": "播报方式",
51
+ "settings.mode.hint": "边写边读用显存分片,写完再读用时间分片。切换约 2–3 分钟,期间模型不可用",
52
+ "settings.mode.gpu": "显卡:{type}",
53
+ "settings.mode.turns": "写完再读",
54
+ "settings.mode.streaming": "边写边读",
55
+ "settings.mode.switching": "正在切换显卡模式…",
56
+ "settings.mode.resumingApp": "正在恢复 {app}…",
57
+ "settings.mode.partial": "显卡已切换,但这些应用没能恢复:{apps}",
58
+ "settings.mode.failed": "切换显卡模式失败:{error}",
59
+ "settings.mode.mismatch": "显卡当前是{type},和播报方式不一致",
60
+ "settings.mode.noGpu": "没有找到 NVIDIA 显卡",
61
+ "settings.mode.retry": "重试",
62
+ "settings.gpu.MemorySlice": "显存分片",
63
+ "settings.gpu.TimeSlice": "时间分片",
64
+ "settings.gpu.Exclusive": "独占",
65
+ "settings.apps.title": "显卡应用",
66
+ "settings.apps.hint": "时间分片下这些应用轮流使用整张显卡;同时运行太多,显存可能不够",
67
+ "settings.apps.required": "至少 {size} GiB 显存",
68
+ "settings.apps.start": "启动",
69
+ "settings.apps.stop": "停止",
70
+ "settings.apps.starting": "启动中…",
71
+ "settings.apps.stopping": "停止中…",
72
+ "settings.apps.empty": "没有需要显卡的应用",
73
+ "settings.slices.title": "显存分片",
74
+ "settings.slices.gpu": "显卡",
75
+ "settings.slices.used": "已分配 {used} / {total} GiB",
76
+ "settings.slices.hint": "选择哪些应用常驻显卡、各分多少显存。保存后只重启有改动的应用",
77
+ "settings.slices.hintPending": "切到边写边读时,按这里的配置分配显存",
78
+ "settings.slices.size": "{app}的显存",
79
+ "settings.slices.enable": "{app}参与显存分片",
80
+ "settings.slices.empty": "还没有应用绑定到这张显卡",
81
+ "settings.slices.over": "超出显卡显存 {over} GiB",
82
+ "settings.slices.invalid": "开启的应用显存不能低于它的最低要求",
83
+ "settings.slices.restart": "有改动的应用会重启",
84
+ "settings.slices.reset": "还原",
85
+ "settings.slices.save": "保存",
86
+ "settings.slices.apply": "保存并应用",
87
+ "settings.slices.saving": "保存中…",
43
88
  "settings.install": "安装",
44
89
  "settings.installing": "安装中…",
45
90
  "settings.installStarted": "已开始安装,模型下载完成后点刷新",
@@ -94,23 +139,67 @@ export const EN = {
94
139
  "mic.idle": "Voice input",
95
140
  "mic.blocked": "Input unavailable",
96
141
  "lang.auto": "Auto-detect",
97
- "status.auto": "auto",
98
142
  "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
143
  "settings.refresh": "Refresh",
102
144
  "settings.refreshing": "Refreshing…",
103
145
  "settings.router": "Router console",
104
146
  "settings.loading": "Loading voice settings…",
105
147
  "settings.loadFailed": "Failed to load voice settings",
106
148
  "settings.refreshFailed": "Failed to refresh voice models",
107
- "settings.status.ready": "Recognition ready · {model}",
108
- "settings.status.notReady": "No speech-recognition model yet",
109
149
  "settings.stt.title": "Speech recognition",
110
150
  "settings.stt.hint": "Turn the microphone into text",
111
151
  "settings.tts.title": "Speech playback",
112
152
  "settings.tts.hint": "Read only the final answer, not reasoning or tool calls",
113
153
  "settings.speak.label": "Speak the final answer",
154
+ "settings.group.stt": "Speech recognition",
155
+ "settings.group.tts": "Speech playback",
156
+ "settings.group.bluetooth": "Output device",
157
+ "settings.stt.model": "Recognition model",
158
+ "settings.tts.model": "Speech model",
159
+ "settings.speak.title": "Read answers aloud",
160
+ "settings.phase.running": "Running",
161
+ "settings.phase.stopped": "Stopped",
162
+ "settings.phase.busy": "Working",
163
+ "settings.phase.failed": "Failed",
164
+ "settings.phase.absent": "Not installed",
165
+ "settings.mode.title": "Reading mode",
166
+ "settings.mode.hint": "While writing uses memory slicing; when done uses time slicing. Switching takes 2–3 minutes; models are unavailable meanwhile",
167
+ "settings.mode.gpu": "GPU: {type}",
168
+ "settings.mode.turns": "When done",
169
+ "settings.mode.streaming": "While writing",
170
+ "settings.mode.switching": "Switching GPU mode…",
171
+ "settings.mode.resumingApp": "Resuming {app}…",
172
+ "settings.mode.partial": "GPU switched, but these apps did not resume: {apps}",
173
+ "settings.mode.failed": "GPU switch failed: {error}",
174
+ "settings.mode.mismatch": "The GPU is in {type}, which does not match the reading mode",
175
+ "settings.mode.noGpu": "No NVIDIA GPU found",
176
+ "settings.mode.retry": "Retry",
177
+ "settings.gpu.MemorySlice": "memory slicing",
178
+ "settings.gpu.TimeSlice": "time slicing",
179
+ "settings.gpu.Exclusive": "exclusive",
180
+ "settings.apps.title": "GPU apps",
181
+ "settings.apps.hint": "With time slicing these apps take turns on the whole GPU; running too many at once may run out of memory",
182
+ "settings.apps.required": "needs {size} GiB",
183
+ "settings.apps.start": "Start",
184
+ "settings.apps.stop": "Stop",
185
+ "settings.apps.starting": "Starting…",
186
+ "settings.apps.stopping": "Stopping…",
187
+ "settings.apps.empty": "No apps need the GPU",
188
+ "settings.slices.title": "Memory slices",
189
+ "settings.slices.gpu": "GPU",
190
+ "settings.slices.used": "{used} / {total} GiB allocated",
191
+ "settings.slices.hint": "Choose which apps stay on the GPU and how much memory each gets. Saving restarts only the apps that changed",
192
+ "settings.slices.hintPending": "Applied when switching to reading while writing",
193
+ "settings.slices.size": "Memory for {app}",
194
+ "settings.slices.enable": "{app} uses a memory slice",
195
+ "settings.slices.empty": "No apps are bound to this GPU yet",
196
+ "settings.slices.over": "{over} GiB over the GPU memory",
197
+ "settings.slices.invalid": "An enabled app cannot go below its minimum memory",
198
+ "settings.slices.restart": "Apps that changed will restart",
199
+ "settings.slices.reset": "Reset",
200
+ "settings.slices.save": "Save",
201
+ "settings.slices.apply": "Save and apply",
202
+ "settings.slices.saving": "Saving…",
114
203
  "settings.install": "Install",
115
204
  "settings.installing": "Installing…",
116
205
  "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,
@@ -872,9 +872,9 @@ export function createChatRuntime(client) {
872
872
  if (typeof client.reportLog !== "function") return Promise.resolve(false);
873
873
  return client.reportLog(scope, event, fields);
874
874
  },
875
- speak(text) {
875
+ speak(request) {
876
876
  if (typeof client.speak !== "function") return Promise.resolve(null);
877
- return client.speak(text);
877
+ return client.speak(request);
878
878
  },
879
879
  interruptSpeech() {
880
880
  if (typeof client.interruptSpeech !== "function") return Promise.resolve(false);
@@ -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
  }
package/log/report.js ADDED
@@ -0,0 +1,46 @@
1
+ /** Client diagnostics. One record shape, whether it is written here or posted in. */
2
+
3
+ export const REPORT_PATH = "/api/lares/log";
4
+
5
+ const SCOPE = /^[a-z][a-z0-9-]{0,31}$/;
6
+ const EVENT = /^[a-z][a-z0-9-]{0,39}$/;
7
+ const FIELD = /^[a-z][a-zA-Z0-9]{0,31}$/;
8
+
9
+ export function reportRecord(input = {}) {
10
+ const source = input && typeof input === "object" ? input : {};
11
+ const scope = typeof source.scope === "string" && SCOPE.test(source.scope) ? source.scope : "client";
12
+ const event = typeof source.event === "string" && EVENT.test(source.event) ? source.event : "event";
13
+ const record = { scope, event };
14
+ for (const [key, value] of Object.entries(source)) {
15
+ if (key === "scope" || key === "event" || !FIELD.test(key)) continue;
16
+ if (typeof value === "string") record[key] = value.slice(0, 180);
17
+ else if (typeof value === "number" && Number.isFinite(value)) record[key] = value;
18
+ else if (typeof value === "boolean") record[key] = value;
19
+ }
20
+ return record;
21
+ }
22
+
23
+ export function writeReport(input) {
24
+ const record = reportRecord(input);
25
+ console.log(`[lares:${record.scope}]`, JSON.stringify(record));
26
+ return record;
27
+ }
28
+
29
+ /**
30
+ * Fire-and-forget. `request` is the host port when the page has no cookie.
31
+ * A rejected port does not reject the caller.
32
+ */
33
+ export function postReport(input, options = {}) {
34
+ const body = reportRecord(input);
35
+ const url = options.url ?? REPORT_PATH;
36
+ const send = options.request;
37
+ const pending = typeof send === "function"
38
+ ? send(url, { method: "POST", headers: { "content-type": "application/json" }, body })
39
+ : fetch(url, {
40
+ method: "POST",
41
+ credentials: "include",
42
+ headers: { "content-type": "application/json" },
43
+ body: JSON.stringify(body),
44
+ });
45
+ return Promise.resolve(pending).then(() => true, () => false);
46
+ }
@@ -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.9",
3
+ "version": "0.15.11",
4
4
  "description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
5
5
  "type": "module",
6
6
  "repository": {
@@ -20,6 +20,7 @@
20
20
  "i18n",
21
21
  "icons",
22
22
  "larepass",
23
+ "log",
23
24
  "media",
24
25
  "mcp",
25
26
  "olares",
@@ -122,6 +123,7 @@
122
123
  "./voice/problem": "./voice/problem.js",
123
124
  "./voice/capture": "./voice/capture.js",
124
125
  "./voice/install": "./voice/install.js",
126
+ "./voice/gpu-mode": "./voice/gpu-mode.js",
125
127
  "./search/config": "./search/config.js",
126
128
  "./search/menu": "./search/menu.js",
127
129
  "./search/client": "./search/client.js",
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,6 +69,7 @@ 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
74
  signal: AbortSignal.timeout(request.timeoutMs ?? REQUEST_TIMEOUT_MS),
52
75
  });
@@ -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";
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);
@@ -99,12 +114,22 @@ export function createRouteHandler(options) {
99
114
  const suffix = new URL(req.url ?? "/", "http://x").pathname.slice(options.prefix.length) || "/";
100
115
  const path = suffix.replace(/\/+$/, "") || "/";
101
116
  const handlers = options.routes[path];
117
+ const cors = allowLarepassOrigin(req, res);
102
118
  if (!handlers) {
103
119
  sendJson(res, 404, {
104
120
  error: { code: "not_found", message: `no route ${path}` },
105
121
  });
106
122
  return;
107
123
  }
124
+ if (cors && method === "OPTIONS") {
125
+ res.writeHead(204, {
126
+ "access-control-allow-methods": Object.keys(handlers).join(", "),
127
+ "access-control-allow-headers": CORS_HEADERS,
128
+ "access-control-max-age": "600",
129
+ });
130
+ res.end();
131
+ return;
132
+ }
108
133
  const route = handlers[method];
109
134
  if (!route) {
110
135
  sendJson(res, 405, {
package/voice/client.js CHANGED
@@ -32,6 +32,7 @@ export async function loadVoiceSettings(options = {}) {
32
32
  function chartView(row) {
33
33
  const phase = row?.phase;
34
34
  return {
35
+ app: typeof row?.app === "string" ? row.app : "",
35
36
  phase: phase === "running" || phase === "stopped" || phase === "busy" || phase === "failed" ? phase : "absent",
36
37
  state: typeof row?.state === "string" ? row.state : "",
37
38
  };
@@ -62,11 +63,12 @@ export function wavBlob(base64) {
62
63
  return new Blob([bytes], { type: "audio/wav" });
63
64
  }
64
65
 
65
- export async function postSpeak(text) {
66
+ /** Hands over a streaming answer's text; with `part`, also returns that sentence's audio. */
67
+ export async function postSpeak({ key, text, done, part }) {
66
68
  const res = await fetch(`${API}/speak`, {
67
69
  method: "POST",
68
70
  headers: { "content-type": "application/json" },
69
- body: JSON.stringify({ text }),
71
+ body: JSON.stringify(Number.isInteger(part) ? { key, text, done, part } : { key, text, done }),
70
72
  });
71
73
  let body = null;
72
74
  try {
@@ -74,7 +76,11 @@ export async function postSpeak(text) {
74
76
  } catch {
75
77
  body = null;
76
78
  }
77
- if (!res.ok || typeof body?.audio !== "string") throw new Error(transcribeErrorCode(body));
79
+ if (res.ok && body && !Number.isInteger(part)) return { sentences: Number(body.sentences) || 0 };
80
+ if (!res.ok || typeof body?.audio !== "string") {
81
+ const code = transcribeErrorCode(body);
82
+ throw Object.assign(new Error(code), { code });
83
+ }
78
84
  return {
79
85
  bluetooth: body.bluetooth === "played" ? "played" : "skipped",
80
86
  blob: wavBlob(body.audio),
@@ -97,6 +103,47 @@ export async function saveVoiceSettings(patch) {
97
103
  return loadVoiceSettings({ force: true });
98
104
  }
99
105
 
106
+ /** Reading mode and the GPU slicing it needs; see core/voice/gpu-mode.js. */
107
+ export async function loadGpuMode() {
108
+ const res = await fetch(`${API}/gpu-mode`);
109
+ if (!res.ok) throw new Error(String(res.status));
110
+ return res.json();
111
+ }
112
+
113
+ export async function switchGpuMode(streaming) {
114
+ const res = await fetch(`${API}/gpu-mode`, {
115
+ method: "POST",
116
+ headers: { "content-type": "application/json" },
117
+ body: JSON.stringify({ streaming }),
118
+ });
119
+ if (!res.ok) throw new Error(String(res.status));
120
+ return res.json();
121
+ }
122
+
123
+ /** @param {{ app: string, enabled: boolean, memory: number }[]} apps memory in bytes */
124
+ export async function saveSlicePlan(apps) {
125
+ const res = await fetch(`${API}/gpu-mode/slices`, {
126
+ method: "POST",
127
+ headers: { "content-type": "application/json" },
128
+ body: JSON.stringify({ apps }),
129
+ });
130
+ const payload = await res.json().catch(() => null);
131
+ if (!res.ok) throw Object.assign(new Error(payload?.error?.message || String(res.status)), { code: payload?.error?.code });
132
+ return payload;
133
+ }
134
+
135
+ /** @param {string} app @param {"resume" | "stop"} op */
136
+ export async function postGpuApp(app, op) {
137
+ const res = await fetch(`${API}/gpu-mode/app`, {
138
+ method: "POST",
139
+ headers: { "content-type": "application/json" },
140
+ body: JSON.stringify({ app, op }),
141
+ });
142
+ const payload = await res.json().catch(() => null);
143
+ if (!res.ok) throw new Error(payload?.error?.message || String(res.status));
144
+ return payload;
145
+ }
146
+
100
147
  function transcribeErrorCode(payload) {
101
148
  const code = payload && typeof payload === "object" ? payload.error?.code : undefined;
102
149
  return typeof code === "string" ? code : "voice_failed";
package/voice/config.js CHANGED
@@ -9,6 +9,8 @@ const DEFAULTS = {
9
9
  ttsModel: "",
10
10
  language: "",
11
11
  speak: false,
12
+ // Read answers while they are written. Needs the GPU in MemorySlice, see gpu-mode.js.
13
+ streaming: false,
12
14
  };
13
15
 
14
16
  const WRITABLE = new Set(Object.keys(DEFAULTS));
@@ -25,9 +27,9 @@ export function validateConfigPatch(raw) {
25
27
  const patch = {};
26
28
  for (const [key, value] of Object.entries(raw)) {
27
29
  if (!WRITABLE.has(key)) invalid(`unknown voice setting: ${key}`);
28
- if (key === "speak") {
29
- if (typeof value !== "boolean") invalid("speak must be a boolean");
30
- patch.speak = value;
30
+ if (key === "speak" || key === "streaming") {
31
+ if (typeof value !== "boolean") invalid(`${key} must be a boolean`);
32
+ patch[key] = value;
31
33
  continue;
32
34
  }
33
35
  if (typeof value !== "string") invalid(`${key} must be a string`);
@@ -54,6 +56,7 @@ export function readConfig() {
54
56
  if (SAFE_MODEL.test(ttsModel)) merged.ttsModel = ttsModel;
55
57
  if (SUPPORTED_LANGUAGES.has(language)) merged.language = language;
56
58
  if (stored?.speak === true) merged.speak = true;
59
+ if (stored?.streaming === true) merged.streaming = true;
57
60
  return merged;
58
61
  }
59
62