@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.
- package/README.md +40 -9
- package/package.json +1 -1
- package/src/chinese.ts +15 -0
- package/src/dictation-controller.ts +6 -2
- package/src/file-transcription.ts +9 -8
- package/src/onboarding.ts +76 -7
- package/src/openai-asr.ts +13 -47
- package/src/remote-asr.ts +212 -0
- package/src/runtime.ts +13 -11
- package/src/settings-menu.ts +66 -206
- package/src/settings.ts +79 -20
- package/src/transcription-service.ts +15 -13
- package/src/try-it.ts +15 -6
- package/src/ui-components.ts +8 -2
|
@@ -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 `
|
|
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
|
|
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
|
|
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
|
|
462
|
-
const { configured } = await ensureSettings(ctx);
|
|
461
|
+
const { configured, completedFirstRun } = await ensureSettings(ctx);
|
|
463
462
|
if (!configured) return;
|
|
464
|
-
if (
|
|
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
|
});
|
package/src/settings-menu.ts
CHANGED
|
@@ -2,11 +2,10 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
442
|
-
|
|
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
|
|
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
|
|
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 =
|
|
722
|
-
|
|
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,
|