@tychenjiajun/pi-voice 0.3.0 → 0.5.0

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.
@@ -0,0 +1,212 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Container, Input, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { VoiceKeys } from "./keybindings.js";
4
+ import type { BackendSettings } from "./settings.js";
5
+ import {
6
+ LIST_PADDING,
7
+ panelBorder,
8
+ SingleSelectPicker,
9
+ type SingleSelectChoice,
10
+ } from "./ui-components.js";
11
+
12
+ export type BackendType = "transcribe-cpp" | "openai-asr";
13
+
14
+ /** Pick the transcription backend; shared by first-run setup and the settings menu. */
15
+ export async function chooseBackendType(
16
+ ctx: ExtensionContext,
17
+ current: BackendSettings,
18
+ ): Promise<BackendType | undefined> {
19
+ const choices: SingleSelectChoice<BackendType>[] = [
20
+ {
21
+ value: "transcribe-cpp",
22
+ label: "Local model",
23
+ description: "Transcribe fully on-device with the downloaded model; works offline",
24
+ },
25
+ {
26
+ value: "openai-asr",
27
+ label: "Remote ASR (OpenAI-compatible)",
28
+ description:
29
+ "Upload audio to any OpenAI-compatible chat-completions ASR endpoint; no local model is used",
30
+ },
31
+ ];
32
+ return ctx.ui.custom<BackendType | undefined>((tui, theme, keybindings, done) =>
33
+ new SingleSelectPicker(
34
+ tui,
35
+ theme,
36
+ keybindings,
37
+ choices,
38
+ current.type,
39
+ { title: "Transcription backend", cancelLabel: "back" },
40
+ done,
41
+ ),
42
+ );
43
+ }
44
+
45
+ function validateBaseUrl(value: string): string | undefined {
46
+ if (!value) return "Base URL is required";
47
+ try {
48
+ const url = new URL(value);
49
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
50
+ return "Base URL must start with http:// or https://";
51
+ }
52
+ } catch {
53
+ return "Base URL must be a valid URL";
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ function validateEnvName(value: string): string | undefined {
59
+ if (!value) return "Environment variable name is required";
60
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
61
+ return "Use a plain environment variable name, e.g. OPENAI_API_KEY";
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ function remoteBackendsEqual(
67
+ left: Extract<BackendSettings, { type: "openai-asr" }>,
68
+ right: Extract<BackendSettings, { type: "openai-asr" }>,
69
+ ): boolean {
70
+ return (
71
+ left.baseUrl === right.baseUrl &&
72
+ left.model === right.model &&
73
+ left.apiKeyEnv === right.apiKeyEnv
74
+ );
75
+ }
76
+
77
+ /** Warn when the named variable is unset, so remote calls fail until it is set. */
78
+ export function warnMissingApiKey(ctx: ExtensionContext, apiKeyEnv: string): void {
79
+ if (process.env[apiKeyEnv]) return;
80
+ ctx.ui.notify(
81
+ `${apiKeyEnv} is not set in Pi's environment; remote transcription will fail until it is set`,
82
+ "warning",
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Single-field text prompt built on pi-tui's Input. Empty input may be legal
88
+ * (the caller decides via validate); Escape backs out of the whole flow.
89
+ */
90
+ function promptText(
91
+ ctx: ExtensionContext,
92
+ options: {
93
+ title: string;
94
+ description?: string;
95
+ value: string;
96
+ placeholder?: string;
97
+ validate?: (value: string) => string | undefined;
98
+ },
99
+ ): Promise<string | undefined> {
100
+ return ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
101
+ const keys = new VoiceKeys(keybindings);
102
+ const input = new Input();
103
+ input.setValue(options.value);
104
+ let error: string | undefined;
105
+ const container = new Container();
106
+
107
+ const rebuild = (): void => {
108
+ container.clear();
109
+ container.addChild(panelBorder(theme));
110
+ container.addChild(new Spacer(1));
111
+ container.addChild(new Text(theme.fg("accent", theme.bold(options.title)), 1, 0));
112
+ if (options.description) {
113
+ container.addChild(new Text(theme.fg("muted", options.description), 1, 0));
114
+ }
115
+ container.addChild(new Spacer(1));
116
+ if (error) {
117
+ container.addChild(new Text(theme.fg("error", error), 1, 0));
118
+ container.addChild(new Spacer(1));
119
+ }
120
+ if (!input.getValue() && options.placeholder) {
121
+ container.addChild(new Text(theme.fg("dim", options.placeholder), 2, 0));
122
+ }
123
+ const inputBox = new Box(LIST_PADDING, 0);
124
+ inputBox.addChild(input);
125
+ container.addChild(inputBox);
126
+ container.addChild(new Spacer(1));
127
+ container.addChild(
128
+ new Text(
129
+ `${keys.hint("tui.input.submit", "save")} ${keys.hint("tui.select.cancel", "back")}`,
130
+ 1,
131
+ 0,
132
+ ),
133
+ );
134
+ container.addChild(new Spacer(1));
135
+ container.addChild(panelBorder(theme));
136
+ };
137
+ rebuild();
138
+
139
+ input.onSubmit = (value) => {
140
+ const problem = options.validate?.(value.trim());
141
+ if (problem) {
142
+ error = problem;
143
+ rebuild();
144
+ tui.requestRender();
145
+ return;
146
+ }
147
+ done(value.trim());
148
+ };
149
+ input.onEscape = () => done(undefined);
150
+
151
+ return {
152
+ get focused() {
153
+ return input.focused;
154
+ },
155
+ set focused(value: boolean) {
156
+ input.focused = value;
157
+ },
158
+ render(width: number): string[] {
159
+ return container
160
+ .render(width)
161
+ .map((line) => truncateToWidth(line, width, ""));
162
+ },
163
+ handleInput(data: string): void {
164
+ input.handleInput(data);
165
+ },
166
+ invalidate(): void {
167
+ container.invalidate();
168
+ },
169
+ };
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Collect base URL, model, and API key env var for an OpenAI-compatible ASR
175
+ * endpoint. Returns undefined if the user backs out of any field. `current`
176
+ * pre-fills the prompts when editing an existing backend.
177
+ */
178
+ export async function promptRemoteBackend(
179
+ ctx: ExtensionContext,
180
+ current?: Extract<BackendSettings, { type: "openai-asr" }>,
181
+ ): Promise<Extract<BackendSettings, { type: "openai-asr" }> | undefined> {
182
+ const baseUrl = await promptText(ctx, {
183
+ title: "ASR base URL",
184
+ description: "OpenAI-compatible endpoint root, without the /chat/completions path",
185
+ value: current?.baseUrl ?? "",
186
+ placeholder: "https://api.example.com/v1",
187
+ validate: validateBaseUrl,
188
+ });
189
+ if (baseUrl === undefined) return undefined;
190
+
191
+ const model = await promptText(ctx, {
192
+ title: "ASR model",
193
+ value: current?.model ?? "",
194
+ placeholder: "whisper-1",
195
+ validate: (value) => (value ? undefined : "Model name is required"),
196
+ });
197
+ if (model === undefined) return undefined;
198
+
199
+ const apiKeyEnv = await promptText(ctx, {
200
+ title: "API key environment variable",
201
+ description:
202
+ "The key itself is read from this variable at runtime; it is never stored in settings",
203
+ value: current?.apiKeyEnv ?? "",
204
+ placeholder: "OPENAI_API_KEY",
205
+ validate: validateEnvName,
206
+ });
207
+ if (apiKeyEnv === undefined) return undefined;
208
+
209
+ const backend = { type: "openai-asr" as const, baseUrl, model, apiKeyEnv };
210
+ if (current && remoteBackendsEqual(current, backend)) return undefined;
211
+ return backend;
212
+ }
package/src/runtime.ts CHANGED
@@ -48,7 +48,7 @@ function captureErrorMessage(error: unknown): string {
48
48
 
49
49
  function transcriptionErrorMessage(error: unknown): string {
50
50
  const message = error instanceof Error ? error.message : String(error);
51
- return `Local transcription failed: ${message}`;
51
+ return `Transcription failed: ${message}`;
52
52
  }
53
53
 
54
54
  export function createPiVoiceRuntime(
@@ -145,7 +145,7 @@ export function createPiVoiceRuntime(
145
145
  preferredLanguages: previous.preferredLanguages,
146
146
  transcriptionLanguage: previous.transcriptionLanguage,
147
147
  chineseOutput: previous.chineseOutput,
148
- currentModelId: previous.model.id,
148
+ currentModelId: previous.model?.id,
149
149
  microphone: previous.microphone,
150
150
  backend: previous.backend,
151
151
  polishEnabled: previous.polishEnabled,
@@ -165,12 +165,12 @@ export function createPiVoiceRuntime(
165
165
  ctx.ui.notify(settingsReadWarning, "warning");
166
166
  }
167
167
 
168
- if (settings && existsSync(settings.model.path)) {
168
+ if (settings && (!settings.model || existsSync(settings.model.path))) {
169
169
  return { configured: settings, completedFirstRun: false };
170
170
  }
171
171
 
172
172
  const previous = settings;
173
- if (settings) {
173
+ if (settings?.model) {
174
174
  ctx.ui.notify(
175
175
  `Configured model file is missing: ${settings.model.path}. Choose a model again; nothing will be downloaded without confirmation.`,
176
176
  "warning",
@@ -189,15 +189,15 @@ export function createPiVoiceRuntime(
189
189
  await loadSettingsOnce();
190
190
  if (settingsReadWarning && !settings) {
191
191
  throw new Error(
192
- `${settingsReadWarning} Ask the user to run /voice-settings in Pi's interactive TUI to configure a local model, then retry transcribe_file.`,
192
+ `${settingsReadWarning} Ask the user to run /voice-settings in Pi's interactive TUI to set up Pi Voice (a local model or a remote ASR endpoint), then retry transcribe_file.`,
193
193
  );
194
194
  }
195
195
  if (!settings) {
196
196
  throw new Error(
197
- "Pi Voice is not configured. Ask the user to run /voice-settings in Pi's interactive TUI once to choose and download a local model, then retry transcribe_file.",
197
+ "Pi Voice is not configured. Ask the user to run /voice-settings in Pi's interactive TUI once to set up Pi Voice (choose and download a local model, or configure a remote ASR endpoint), then retry transcribe_file.",
198
198
  );
199
199
  }
200
- if (!existsSync(settings.model.path)) {
200
+ if (settings.model && !existsSync(settings.model.path)) {
201
201
  throw new Error(
202
202
  `The configured transcription model is missing: ${settings.model.path}. Ask the user to run /voice-settings and choose a model again, then retry transcribe_file.`,
203
203
  );
@@ -408,7 +408,7 @@ export function createPiVoiceRuntime(
408
408
  const { clearTranscribeWidget, showTranscribeStatus } = await loadVisualizer();
409
409
  logStep("loading settings");
410
410
  await loadSettingsOnce();
411
- if (settings && existsSync(settings.model.path)) {
411
+ if (settings && (!settings.model || existsSync(settings.model.path))) {
412
412
  showTranscribeStatus(ctx, "Starting microphone…");
413
413
  } else {
414
414
  // Setup panes replace only the editor, so a status line set here or by
@@ -458,15 +458,17 @@ export function createPiVoiceRuntime(
458
458
  let reload = false;
459
459
  await runExclusive(ctx, async () => {
460
460
  await loadSettingsOnce();
461
- const hadConfiguration = Boolean(settings && existsSync(settings.model.path));
462
- const { configured } = await ensureSettings(ctx);
461
+ const { configured, completedFirstRun } = await ensureSettings(ctx);
463
462
  if (!configured) return;
464
- if (!hadConfiguration) {
463
+ if (completedFirstRun) {
465
464
  // First-run setup ends on its Ready message rather than falling
466
465
  // straight through into the regular settings menu.
467
466
  reload = configured.shortcut !== registeredShortcut;
468
467
  return;
469
468
  }
469
+ // A recovered missing model file also lands here: the user asked for
470
+ // settings, so continue into them instead of making them run the
471
+ // command a second time.
470
472
  const { showSettingsMenu } = await import("./settings-menu.js");
471
473
  reload = await showSettingsMenu(pi, ctx, configured, registeredShortcut);
472
474
  });
@@ -2,11 +2,10 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import { Box, Container, Input, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
5
+ import { truncateToWidth } from "@earendil-works/pi-tui";
6
6
  import { microphonePermission, type Permission } from "./audio.js";
7
7
  import { displayLanguage, getCatalogModel } from "./catalog.js";
8
8
  import { chineseOutputSummary, isChineseLanguage } from "./chinese.js";
9
- import { VoiceKeys } from "./keybindings.js";
10
9
  import {
11
10
  chooseLanguages,
12
11
  createTranscriptionLanguagePicker,
@@ -18,6 +17,7 @@ import {
18
17
  microphoneSummary,
19
18
  } from "./microphone-picker.js";
20
19
  import { runModelSelection } from "./onboarding.js";
20
+ import { chooseBackendType, promptRemoteBackend, warnMissingApiKey } from "./remote-asr.js";
21
21
  import {
22
22
  writeSettings,
23
23
  type BackendSettings,
@@ -30,9 +30,7 @@ import {
30
30
  import { displayShortcut } from "./shortcut-core.js";
31
31
  import { createShortcutPicker } from "./shortcuts.js";
32
32
  import {
33
- LIST_PADDING,
34
33
  padToWidth,
35
- panelBorder,
36
34
  SingleSelectPicker,
37
35
  type SingleSelectChoice,
38
36
  } from "./ui-components.js";
@@ -43,7 +41,7 @@ const SETTINGS_LABEL_WIDTH = 25;
43
41
  // Text padding (2), cursor gutter (2), label column, and its gap (2).
44
42
  const SETTINGS_ROW_OVERHEAD = SETTINGS_LABEL_WIDTH + 6;
45
43
 
46
- type SettingsAction =
44
+ export type SettingsAction =
47
45
  | "preferred-languages"
48
46
  | "model"
49
47
  | "backend"
@@ -53,7 +51,7 @@ type SettingsAction =
53
51
  | "microphone"
54
52
  | "shortcut";
55
53
 
56
- type SettingsHomeChoice = SingleSelectChoice<SettingsAction> & {
54
+ export type SettingsHomeChoice = SingleSelectChoice<SettingsAction> & {
57
55
  summary: string;
58
56
  /** Render the summary in the error color. */
59
57
  alert?: boolean;
@@ -98,11 +96,13 @@ function polishSummary(polishEnabled: boolean | undefined, model: PolishModelRef
98
96
  return model ? `${model.provider}/${model.id}` : "Pi's current model";
99
97
  }
100
98
 
101
- function settingsHomeChoices(
99
+ export function settingsHomeChoices(
102
100
  configured: TranscribeSettings,
103
101
  permission: Permission,
104
102
  ): SettingsHomeChoice[] {
105
- const model = getCatalogModel(configured.model.id)!;
103
+ // Local mode always carries a model; remote mode never does.
104
+ const model = configured.model ? getCatalogModel(configured.model.id) : undefined;
105
+ const remote = configured.backend.type === "openai-asr";
106
106
  const choices: SettingsHomeChoice[] = [
107
107
  {
108
108
  value: "preferred-languages",
@@ -110,18 +110,24 @@ function settingsHomeChoices(
110
110
  summary: preferredLanguagesSummary(configured.preferredLanguages),
111
111
  description: "Languages you speak, used to rank and recommend transcription models",
112
112
  },
113
- {
113
+ ];
114
+
115
+ if (model) {
116
+ choices.push({
114
117
  value: "model",
115
118
  label: "Model",
116
119
  summary: model.name,
117
120
  description: "Switch between downloaded models, or download a new one",
118
- },
121
+ });
122
+ }
123
+
124
+ choices.push(
119
125
  {
120
126
  value: "backend",
121
127
  label: "Transcription backend",
122
128
  summary: backendSummary(configured.backend),
123
129
  description:
124
- "Local model, or an OpenAI-compatible remote ASR endpoint (local model stays as fallback)",
130
+ "Local model or remote ASR endpoint; the two modes are exclusive",
125
131
  },
126
132
  {
127
133
  value: "post-processing",
@@ -130,17 +136,28 @@ function settingsHomeChoices(
130
136
  description:
131
137
  "Refine transcripts with Pi's current model after ASR, like context compaction",
132
138
  },
133
- {
139
+ );
140
+
141
+ if (model) {
142
+ choices.push({
134
143
  value: "transcription-language",
135
144
  label: "Transcription language",
136
145
  summary: transcriptionLanguageSummary(configured.transcriptionLanguage, model),
137
146
  description: "Language expected in recordings, or automatic detection when supported",
138
- },
139
- ];
147
+ });
148
+ }
140
149
 
150
+ // Chinese script conversion applies when the configured language is Chinese,
151
+ // when a preferred language is Chinese, or when a detecting model can pick
152
+ // Chinese on its own. Remote mode can always produce Chinese, so its row is
153
+ // never gated.
154
+ const detectsChinese =
155
+ model?.capabilities.languageDetection && model.languages.some(isChineseLanguage);
141
156
  if (
157
+ remote ||
142
158
  isChineseLanguage(configured.transcriptionLanguage) ||
143
- configured.preferredLanguages.some(isChineseLanguage)
159
+ configured.preferredLanguages.some(isChineseLanguage) ||
160
+ detectsChinese
144
161
  ) {
145
162
  choices.push({
146
163
  value: "chinese-output",
@@ -256,7 +273,8 @@ async function chooseTranscriptionLanguage(
256
273
  ctx: ExtensionContext,
257
274
  configured: TranscribeSettings,
258
275
  ): Promise<TranscriptionLanguage | undefined> {
259
- const model = getCatalogModel(configured.model.id)!;
276
+ const model = configured.model ? getCatalogModel(configured.model.id) : undefined;
277
+ if (!model) return undefined;
260
278
  return ctx.ui.custom<TranscriptionLanguage | undefined>(
261
279
  (tui, theme, keybindings, done) =>
262
280
  createTranscriptionLanguagePicker(
@@ -280,145 +298,6 @@ async function chooseShortcut(
280
298
  );
281
299
  }
282
300
 
283
- /**
284
- * Single-field text prompt built on pi-tui's Input. Empty input may be legal
285
- * (the caller decides via validate); Escape backs out of the whole flow.
286
- */
287
- function promptText(
288
- ctx: ExtensionContext,
289
- options: {
290
- title: string;
291
- description?: string;
292
- value: string;
293
- placeholder?: string;
294
- validate?: (value: string) => string | undefined;
295
- },
296
- ): Promise<string | undefined> {
297
- return ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
298
- const keys = new VoiceKeys(keybindings);
299
- const input = new Input();
300
- input.setValue(options.value);
301
- let error: string | undefined;
302
- const container = new Container();
303
-
304
- const rebuild = (): void => {
305
- container.clear();
306
- container.addChild(panelBorder(theme));
307
- container.addChild(new Spacer(1));
308
- container.addChild(new Text(theme.fg("accent", theme.bold(options.title)), 1, 0));
309
- if (options.description) {
310
- container.addChild(new Text(theme.fg("muted", options.description), 1, 0));
311
- }
312
- container.addChild(new Spacer(1));
313
- if (error) {
314
- container.addChild(new Text(theme.fg("error", error), 1, 0));
315
- container.addChild(new Spacer(1));
316
- }
317
- if (!input.getValue() && options.placeholder) {
318
- container.addChild(new Text(theme.fg("dim", options.placeholder), 2, 0));
319
- }
320
- const inputBox = new Box(LIST_PADDING, 0);
321
- inputBox.addChild(input);
322
- container.addChild(inputBox);
323
- container.addChild(new Spacer(1));
324
- container.addChild(
325
- new Text(
326
- `${keys.hint("tui.input.submit", "save")} ${keys.hint("tui.select.cancel", "back")}`,
327
- 1,
328
- 0,
329
- ),
330
- );
331
- container.addChild(new Spacer(1));
332
- container.addChild(panelBorder(theme));
333
- };
334
- rebuild();
335
-
336
- input.onSubmit = (value) => {
337
- const problem = options.validate?.(value.trim());
338
- if (problem) {
339
- error = problem;
340
- rebuild();
341
- tui.requestRender();
342
- return;
343
- }
344
- done(value.trim());
345
- };
346
- input.onEscape = () => done(undefined);
347
-
348
- return {
349
- get focused() {
350
- return input.focused;
351
- },
352
- set focused(value: boolean) {
353
- input.focused = value;
354
- },
355
- render(width: number): string[] {
356
- return container
357
- .render(width)
358
- .map((line) => truncateToWidth(line, width, ""));
359
- },
360
- handleInput(data: string): void {
361
- input.handleInput(data);
362
- },
363
- invalidate(): void {
364
- container.invalidate();
365
- },
366
- };
367
- });
368
- }
369
-
370
- async function chooseBackendType(
371
- ctx: ExtensionContext,
372
- current: BackendSettings,
373
- ): Promise<"transcribe-cpp" | "openai-asr" | undefined> {
374
- const choices: SingleSelectChoice<"transcribe-cpp" | "openai-asr">[] = [
375
- {
376
- value: "transcribe-cpp",
377
- label: "Local model",
378
- description: "Transcribe fully on-device with the downloaded model; works offline",
379
- },
380
- {
381
- value: "openai-asr",
382
- label: "Remote ASR (OpenAI-compatible)",
383
- description:
384
- "Upload audio to any OpenAI-compatible chat-completions ASR endpoint; falls back to the local model if the request fails",
385
- },
386
- ];
387
- return ctx.ui.custom<"transcribe-cpp" | "openai-asr" | undefined>(
388
- (tui, theme, keybindings, done) =>
389
- new SingleSelectPicker(
390
- tui,
391
- theme,
392
- keybindings,
393
- choices,
394
- current.type,
395
- { title: "Transcription backend", cancelLabel: "back" },
396
- done,
397
- ),
398
- );
399
- }
400
-
401
- function validateBaseUrl(value: string): string | undefined {
402
- if (!value) return "Base URL is required";
403
- try {
404
- const url = new URL(value);
405
- if (url.protocol !== "http:" && url.protocol !== "https:") {
406
- return "Base URL must start with http:// or https://";
407
- }
408
- } catch {
409
- return "Base URL must be a valid URL";
410
- }
411
- return undefined;
412
- }
413
-
414
- function validateEnvName(value: string): string | undefined {
415
- if (!value) return "Environment variable name is required";
416
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
417
- return "Use a plain environment variable name, e.g. OPENAI_API_KEY";
418
- }
419
- return undefined;
420
- }
421
-
422
301
  /** Backend row flow: pick the type, then collect remote settings step by step. */
423
302
  async function editBackend(
424
303
  ctx: ExtensionContext,
@@ -428,63 +307,40 @@ async function editBackend(
428
307
  if (!type) return false;
429
308
  if (type === "transcribe-cpp") {
430
309
  if (configured.backend.type === "transcribe-cpp") return false;
431
- return saveUpdatedSettings(
432
- ctx,
433
- configured,
434
- { ...configured, backend: { type: "transcribe-cpp" } },
435
- "Using the local model",
436
- );
310
+ // Remote-only settings carry no local model; pick one first. Every commit
311
+ // writes local-mode settings, so cancelling keeps the remote setup.
312
+ const updated = await runModelSelection(ctx, {
313
+ shortcut: configured.shortcut,
314
+ preferredLanguages: configured.preferredLanguages,
315
+ transcriptionLanguage: configured.transcriptionLanguage,
316
+ chineseOutput: configured.chineseOutput,
317
+ microphone: configured.microphone,
318
+ backend: { type: "transcribe-cpp" },
319
+ polishEnabled: configured.polishEnabled,
320
+ polishModel: configured.polishModel,
321
+ postActivation: "stay",
322
+ });
323
+ if (!updated) {
324
+ ctx.ui.notify("Local mode needs a local model; keeping remote ASR", "info");
325
+ return false;
326
+ }
327
+ Object.assign(configured, updated);
328
+ ctx.ui.notify("Using the local model", "info");
329
+ return true;
437
330
  }
438
331
 
439
332
  const current =
440
333
  configured.backend.type === "openai-asr" ? configured.backend : undefined;
441
- const baseUrl = await promptText(ctx, {
442
- title: "ASR base URL",
443
- description: "OpenAI-compatible endpoint root, without the /chat/completions path",
444
- value: current?.baseUrl ?? "",
445
- placeholder: "https://api.example.com/v1",
446
- validate: validateBaseUrl,
447
- });
448
- if (baseUrl === undefined) return false;
334
+ const backend = await promptRemoteBackend(ctx, current);
335
+ if (!backend) return false;
449
336
 
450
- const model = await promptText(ctx, {
451
- title: "ASR model",
452
- value: current?.model ?? "",
453
- placeholder: "whisper-1",
454
- validate: (value) => (value ? undefined : "Model name is required"),
455
- });
456
- if (model === undefined) return false;
457
-
458
- const apiKeyEnv = await promptText(ctx, {
459
- title: "API key environment variable",
460
- description: "The key itself is read from this variable at runtime; it is never stored in settings",
461
- value: current?.apiKeyEnv ?? "",
462
- placeholder: "OPENAI_API_KEY",
463
- validate: validateEnvName,
464
- });
465
- if (apiKeyEnv === undefined) return false;
466
-
467
- const backend: BackendSettings = { type: "openai-asr", baseUrl, model, apiKeyEnv };
468
- if (
469
- current &&
470
- current.baseUrl === baseUrl &&
471
- current.model === model &&
472
- current.apiKeyEnv === apiKeyEnv
473
- ) {
474
- return false;
475
- }
476
337
  const saved = await saveUpdatedSettings(
477
338
  ctx,
478
339
  configured,
479
- { ...configured, backend },
480
- `Remote ASR configured (${model})`,
340
+ { ...configured, backend, model: undefined },
341
+ `Remote ASR configured (${backend.model}); the local model is no longer used`,
481
342
  );
482
- if (saved && !process.env[apiKeyEnv]) {
483
- ctx.ui.notify(
484
- `${apiKeyEnv} is not set in Pi's environment; remote transcription will fall back to the local model until it is set`,
485
- "warning",
486
- );
487
- }
343
+ if (saved) warnMissingApiKey(ctx, backend.apiKeyEnv);
488
344
  return saved;
489
345
  }
490
346
 
@@ -682,7 +538,7 @@ export async function showSettingsMenu(
682
538
  preferredLanguages: configured.preferredLanguages,
683
539
  transcriptionLanguage: configured.transcriptionLanguage,
684
540
  chineseOutput: configured.chineseOutput,
685
- currentModelId: configured.model.id,
541
+ currentModelId: configured.model?.id,
686
542
  microphone: configured.microphone,
687
543
  backend: configured.backend,
688
544
  polishEnabled: configured.polishEnabled,
@@ -718,8 +574,12 @@ export async function showSettingsMenu(
718
574
  ) {
719
575
  continue;
720
576
  }
721
- const model = getCatalogModel(configured.model.id)!;
722
- const summary = transcriptionLanguageSummary(transcriptionLanguage, model);
577
+ const model = configured.model
578
+ ? getCatalogModel(configured.model.id)
579
+ : undefined;
580
+ const summary = model
581
+ ? transcriptionLanguageSummary(transcriptionLanguage, model)
582
+ : transcriptionLanguage;
723
583
  await saveUpdatedSettings(
724
584
  ctx,
725
585
  configured,