@tychenjiajun/pi-voice 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +134 -2
- package/catalog/recommendations.json +710 -0
- package/index.ts +1 -0
- package/package.json +68 -4
- package/src/async-limiter.ts +77 -0
- package/src/audio-constants.ts +1 -0
- package/src/audio-errors.ts +14 -0
- package/src/audio.ts +109 -0
- package/src/catalog.generated.ts +1305 -0
- package/src/catalog.ts +89 -0
- package/src/chinese.ts +52 -0
- package/src/deferred.ts +30 -0
- package/src/dictation-controller.ts +315 -0
- package/src/file-audio.ts +164 -0
- package/src/file-transcription.ts +212 -0
- package/src/index.ts +135 -0
- package/src/install-migration.ts +47 -0
- package/src/keybindings.ts +119 -0
- package/src/languages.ts +22 -0
- package/src/log.ts +171 -0
- package/src/microphone-picker.ts +95 -0
- package/src/model-activation.ts +74 -0
- package/src/model-cells.ts +218 -0
- package/src/model-picker.ts +1026 -0
- package/src/model-ratings-help.md +41 -0
- package/src/model-ratings-help.ts +146 -0
- package/src/model-selection-controller.ts +185 -0
- package/src/models.ts +263 -0
- package/src/onboarding.ts +319 -0
- package/src/openai-asr.ts +247 -0
- package/src/pcm-chunker.ts +46 -0
- package/src/recommendation-picker.ts +512 -0
- package/src/recommendations.ts +423 -0
- package/src/runtime.ts +524 -0
- package/src/settings-menu.ts +638 -0
- package/src/settings-path.ts +19 -0
- package/src/settings.ts +273 -0
- package/src/shortcut-core.ts +85 -0
- package/src/shortcuts.ts +167 -0
- package/src/startup-shortcut.ts +24 -0
- package/src/transcript-preview.ts +52 -0
- package/src/transcription-service.ts +570 -0
- package/src/transcription.ts +203 -0
- package/src/try-it.ts +318 -0
- package/src/ui-components.ts +432 -0
- package/src/visualizer.ts +271 -0
package/src/log.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pi Voice's diagnostic log (~/.pi/agent/pi-voice.log).
|
|
6
|
+
* On by default at info, debug level with PI_VOICE_DEBUG=1
|
|
7
|
+
* Does not log transcripts or audio.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type LogLevel = "error" | "warn" | "info" | "debug";
|
|
11
|
+
/** Who wrote the line: Pi Voice, or a native library through its log hook. */
|
|
12
|
+
export type LogSource = "voice" | "recorder" | "transcribe";
|
|
13
|
+
|
|
14
|
+
const RANK: Record<LogLevel, number> = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
15
|
+
/** Past this the file moves to `.1`, replacing the previous one. */
|
|
16
|
+
const MAX_BYTES = 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
type Sink = { path: string; level: LogLevel; opened: boolean; failed: boolean };
|
|
19
|
+
let sink: Sink | undefined;
|
|
20
|
+
|
|
21
|
+
export function initLog(options: { path: string; level: LogLevel }): void {
|
|
22
|
+
if (sink?.path === options.path) {
|
|
23
|
+
sink.level = options.level;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
sink = { path: options.path, level: options.level, opened: false, failed: false };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Stops logging and the event-loop watch. */
|
|
30
|
+
export function closeLog(): void {
|
|
31
|
+
sink = undefined;
|
|
32
|
+
stopWatch();
|
|
33
|
+
watchers = 0;
|
|
34
|
+
step = "idle";
|
|
35
|
+
keyPressedAt = undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The level being written, or undefined when the log is off. */
|
|
39
|
+
export function logLevel(): LogLevel | undefined {
|
|
40
|
+
return sink?.level;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function logEnabled(level: LogLevel): boolean {
|
|
44
|
+
return sink !== undefined && RANK[level] <= RANK[sink.level];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function append(sink: Sink, line: string): void {
|
|
48
|
+
if (!sink.opened) {
|
|
49
|
+
mkdirSync(dirname(sink.path), { recursive: true });
|
|
50
|
+
sink.opened = true;
|
|
51
|
+
const runtime = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`;
|
|
52
|
+
line = `${format("info", "voice", `pi-voice log opened (${runtime}, ${process.platform}-${process.arch}, level ${sink.level})`)}${line}`;
|
|
53
|
+
}
|
|
54
|
+
// Sized on every write: other Pi processes append to and rotate this file too.
|
|
55
|
+
try {
|
|
56
|
+
const size = statSync(sink.path).size;
|
|
57
|
+
if (size > 0 && size + Buffer.byteLength(line) > MAX_BYTES) renameSync(sink.path, `${sink.path}.1`);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// No file yet, or another process rotated it first.
|
|
60
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
61
|
+
}
|
|
62
|
+
appendFileSync(sink.path, line);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function format(level: LogLevel, source: LogSource, message: string, timeMs = Date.now()): string {
|
|
66
|
+
const time = new Date(timeMs).toISOString();
|
|
67
|
+
return `${time} ${process.pid} ${level.padEnd(5)} ${source.padEnd(10)} ${message}\n`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Writes one line. `timeMs` is when it happened, for native records that arrive late. */
|
|
71
|
+
export function writeLog(level: LogLevel, source: LogSource, message: string, timeMs?: number): void {
|
|
72
|
+
if (!sink || sink.failed || !logEnabled(level)) return;
|
|
73
|
+
try {
|
|
74
|
+
append(sink, format(level, source, message.trimEnd().replaceAll("\n", " ⏎ "), timeMs));
|
|
75
|
+
} catch {
|
|
76
|
+
sink.failed = true; // A read-only or full disk must not break dictation.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const log = {
|
|
81
|
+
error: (message: string) => writeLog("error", "voice", message),
|
|
82
|
+
warn: (message: string) => writeLog("warn", "voice", message),
|
|
83
|
+
info: (message: string) => writeLog("info", "voice", message),
|
|
84
|
+
debug: (message: string) => writeLog("debug", "voice", message),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** An error's message, with a `RecorderError`'s code when it has one. */
|
|
88
|
+
export function describeError(error: unknown): string {
|
|
89
|
+
if (!(error instanceof Error)) return String(error);
|
|
90
|
+
const { code } = error as Error & { code?: unknown };
|
|
91
|
+
// Node's own errors already lead with their code.
|
|
92
|
+
return typeof code === "string" && !error.message.startsWith(code) ? `${code}: ${error.message}` : error.message;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Steps: what Pi Voice is doing, so a blocked event loop can say where.
|
|
96
|
+
|
|
97
|
+
let step = "idle";
|
|
98
|
+
|
|
99
|
+
/** Marks what Pi Voice is doing now; logged at debug. */
|
|
100
|
+
export function logStep(name: string, detail?: string): void {
|
|
101
|
+
step = name;
|
|
102
|
+
if (timer) stepsSinceTick.push(name);
|
|
103
|
+
log.debug(detail ? `${name}: ${detail}` : name);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let keyPressedAt: number | undefined;
|
|
107
|
+
|
|
108
|
+
/** The dictation shortcut was pressed; the next `sinceKeyPress` measures from here. */
|
|
109
|
+
export function markKeyPress(): void {
|
|
110
|
+
keyPressedAt = performance.now();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Milliseconds since the last key press, once; undefined if none is pending. */
|
|
114
|
+
export function sinceKeyPress(): number | undefined {
|
|
115
|
+
if (keyPressedAt === undefined) return undefined;
|
|
116
|
+
const elapsed = performance.now() - keyPressedAt;
|
|
117
|
+
keyPressedAt = undefined;
|
|
118
|
+
return elapsed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The event-loop watch: a timer that notices when it ran late, while Pi Voice
|
|
122
|
+
// has work in flight. It can only report a block once the loop resumes, so it
|
|
123
|
+
// names every step entered since the last tick: the block is among them.
|
|
124
|
+
|
|
125
|
+
const TICK_MS = 50;
|
|
126
|
+
/** A frozen TUI starts to show at about this. */
|
|
127
|
+
const BLOCK_MS = 250;
|
|
128
|
+
|
|
129
|
+
let watchers = 0;
|
|
130
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
131
|
+
let lastTick = 0;
|
|
132
|
+
let stepsSinceTick = [step];
|
|
133
|
+
|
|
134
|
+
function tick(): void {
|
|
135
|
+
const now = performance.now();
|
|
136
|
+
const blocked = now - lastTick - TICK_MS;
|
|
137
|
+
if (blocked >= BLOCK_MS) {
|
|
138
|
+
const where = stepsSinceTick.length === 1
|
|
139
|
+
? `during "${step}"`
|
|
140
|
+
: `across "${stepsSinceTick.join('" → "')}"`;
|
|
141
|
+
const sleep = blocked >= 30_000 ? " (or the system slept)" : "";
|
|
142
|
+
log.warn(`event loop blocked for ${(blocked / 1000).toFixed(1)} s ${where}${sleep}`);
|
|
143
|
+
}
|
|
144
|
+
lastTick = now;
|
|
145
|
+
stepsSinceTick = [step];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function stopWatch(): void {
|
|
149
|
+
if (timer) clearInterval(timer);
|
|
150
|
+
timer = undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Watches the event loop until the returned release is called. Nested watches share one timer. */
|
|
154
|
+
export function watchEventLoop(): () => void {
|
|
155
|
+
if (!sink) return () => {};
|
|
156
|
+
if (watchers++ === 0) {
|
|
157
|
+
lastTick = performance.now();
|
|
158
|
+
stepsSinceTick = [step];
|
|
159
|
+
timer = setInterval(tick, TICK_MS);
|
|
160
|
+
timer.unref?.();
|
|
161
|
+
}
|
|
162
|
+
let released = false;
|
|
163
|
+
return () => {
|
|
164
|
+
if (released || watchers === 0) return;
|
|
165
|
+
released = true;
|
|
166
|
+
if (--watchers === 0) {
|
|
167
|
+
tick(); // A block that ended just now, before the timer could run.
|
|
168
|
+
stopWatch();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getAvailableMicrophones, type InputDevice, type Permission } from "./audio.js";
|
|
3
|
+
import type { MicrophoneSetting } from "./settings.js";
|
|
4
|
+
import { SingleSelectPicker, type SingleSelectChoice } from "./ui-components.js";
|
|
5
|
+
|
|
6
|
+
export function microphoneSummary(microphone: MicrophoneSetting): string {
|
|
7
|
+
if (microphone.type === "system-default") return "System default";
|
|
8
|
+
const duplicate = microphone.occurrence > 0 ? ` · device ${microphone.occurrence + 1}` : "";
|
|
9
|
+
return `${microphone.name}${duplicate}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function microphonesEqual(left: MicrophoneSetting, right: MicrophoneSetting): boolean {
|
|
13
|
+
return (
|
|
14
|
+
left.type === right.type &&
|
|
15
|
+
(left.type === "system-default" ||
|
|
16
|
+
(right.type === "device" &&
|
|
17
|
+
left.name === right.name &&
|
|
18
|
+
left.occurrence === right.occurrence))
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function microphoneChoices(
|
|
23
|
+
devices: readonly InputDevice[],
|
|
24
|
+
current: MicrophoneSetting,
|
|
25
|
+
): {
|
|
26
|
+
choices: SingleSelectChoice<string>[];
|
|
27
|
+
currentValue: string;
|
|
28
|
+
byValue: Map<string, MicrophoneSetting>;
|
|
29
|
+
} {
|
|
30
|
+
const totals = new Map<string, number>();
|
|
31
|
+
for (const { name } of devices) totals.set(name, (totals.get(name) ?? 0) + 1);
|
|
32
|
+
const byValue = new Map<string, MicrophoneSetting>();
|
|
33
|
+
byValue.set("system-default", { type: "system-default" });
|
|
34
|
+
const choices: SingleSelectChoice<string>[] = [
|
|
35
|
+
{
|
|
36
|
+
value: "system-default",
|
|
37
|
+
label: "System default",
|
|
38
|
+
description: "Follow the input device selected by the operating system",
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
let currentValue = "system-default";
|
|
42
|
+
for (const [index, { name, occurrence }] of devices.entries()) {
|
|
43
|
+
const microphone: MicrophoneSetting = { type: "device", name, occurrence };
|
|
44
|
+
const value = `device-${index}`;
|
|
45
|
+
const label = (totals.get(name) ?? 0) > 1 ? `${name} · device ${occurrence + 1}` : name;
|
|
46
|
+
choices.push({ value, label });
|
|
47
|
+
byValue.set(value, microphone);
|
|
48
|
+
if (microphonesEqual(current, microphone)) currentValue = value;
|
|
49
|
+
}
|
|
50
|
+
return { choices, currentValue, byValue };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Nothing where the platform has no permission to report (Linux). */
|
|
54
|
+
export function microphonePermissionSummary(permission: Permission): string | undefined {
|
|
55
|
+
if (permission === "granted") return "Microphone: ✓ Access granted";
|
|
56
|
+
if (permission === "denied") return "Microphone: ✗ Access denied";
|
|
57
|
+
if (permission === "not-determined") {
|
|
58
|
+
return "Microphone: ⚠ Not yet requested — first recording will prompt for access";
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function chooseMicrophone(
|
|
64
|
+
ctx: ExtensionContext,
|
|
65
|
+
current: MicrophoneSetting,
|
|
66
|
+
permission: Permission,
|
|
67
|
+
): Promise<MicrophoneSetting | undefined> {
|
|
68
|
+
let devices: InputDevice[] = [];
|
|
69
|
+
try {
|
|
70
|
+
devices = await getAvailableMicrophones();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
ctx.ui.notify(
|
|
73
|
+
`Could not list microphones: ${error instanceof Error ? error.message : String(error)}`,
|
|
74
|
+
"error",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const { choices, currentValue, byValue } = microphoneChoices(devices, current);
|
|
78
|
+
const selected = await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) =>
|
|
79
|
+
new SingleSelectPicker(
|
|
80
|
+
tui,
|
|
81
|
+
theme,
|
|
82
|
+
keybindings,
|
|
83
|
+
choices,
|
|
84
|
+
currentValue,
|
|
85
|
+
{
|
|
86
|
+
title: "Choose microphone input",
|
|
87
|
+
subtitle: microphonePermissionSummary(permission),
|
|
88
|
+
searchable: choices.length > 8,
|
|
89
|
+
cancelLabel: "back",
|
|
90
|
+
},
|
|
91
|
+
done,
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
return selected ? byValue.get(selected) : undefined;
|
|
95
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { CatalogModel } from "./catalog.js";
|
|
2
|
+
import { downloadCatalogModel, findCachedCatalogModel, type CachedCatalogModel } from "./models.js";
|
|
3
|
+
import { writeSettings, type TranscribeSettings } from "./settings.js";
|
|
4
|
+
|
|
5
|
+
export type CatalogModelActivation = (
|
|
6
|
+
model: CatalogModel,
|
|
7
|
+
options: {
|
|
8
|
+
cached: CachedCatalogModel | undefined;
|
|
9
|
+
signal: AbortSignal;
|
|
10
|
+
onProgress: (progress: { downloaded: number; total: number }) => void;
|
|
11
|
+
},
|
|
12
|
+
) => Promise<{ path: string }>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The download-then-save pipeline behind the model picker, shared by
|
|
16
|
+
* onboarding and the settings menu. Back-to-back selections can outrun their
|
|
17
|
+
* settings writes; commits run in selection order, so the file always ends on
|
|
18
|
+
* the user's last choice.
|
|
19
|
+
*/
|
|
20
|
+
export function createModelActivation(options: {
|
|
21
|
+
buildSettings: (model: CatalogModel, path: string) => TranscribeSettings;
|
|
22
|
+
onCommitted: (settings: TranscribeSettings) => void;
|
|
23
|
+
}): {
|
|
24
|
+
activate: CatalogModelActivation;
|
|
25
|
+
/** Resolves once every commit enqueued so far has landed (or failed). */
|
|
26
|
+
waitForCommits: () => Promise<void>;
|
|
27
|
+
} {
|
|
28
|
+
let commitQueue: Promise<void> = Promise.resolve();
|
|
29
|
+
|
|
30
|
+
const activate: CatalogModelActivation = async (
|
|
31
|
+
model,
|
|
32
|
+
{ cached, signal, onProgress },
|
|
33
|
+
) => {
|
|
34
|
+
let path: string;
|
|
35
|
+
if (cached) {
|
|
36
|
+
// The picker listed the cache when it opened; re-check so settings
|
|
37
|
+
// never point at a file that has since been evicted. Integrity is
|
|
38
|
+
// covered by the download-time hash and the size check here.
|
|
39
|
+
const stillCached = findCachedCatalogModel(model);
|
|
40
|
+
if (!stillCached) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"the downloaded file is missing; select the model again to re-download it",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
path = stillCached.path;
|
|
46
|
+
} else {
|
|
47
|
+
path = await downloadCatalogModel(model, { signal, onProgress });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const commit = commitQueue.then(async () => {
|
|
51
|
+
// Skip the write when this selection was superseded or its download
|
|
52
|
+
// was cancelled while the commit sat in the queue.
|
|
53
|
+
signal.throwIfAborted();
|
|
54
|
+
let settings: TranscribeSettings;
|
|
55
|
+
try {
|
|
56
|
+
settings = options.buildSettings(model, path);
|
|
57
|
+
await writeSettings(settings);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Could not save settings: ${error instanceof Error ? error.message : String(error)}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
options.onCommitted(settings);
|
|
64
|
+
});
|
|
65
|
+
commitQueue = commit.then(
|
|
66
|
+
() => undefined,
|
|
67
|
+
() => undefined,
|
|
68
|
+
);
|
|
69
|
+
await commit;
|
|
70
|
+
return { path };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return { activate, waitForCommits: () => commitQueue };
|
|
74
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
canonicalLanguage,
|
|
5
|
+
catalogModelSearchText,
|
|
6
|
+
displayLanguage,
|
|
7
|
+
formatBinarySize,
|
|
8
|
+
modelMatchesLanguage,
|
|
9
|
+
type CatalogModel,
|
|
10
|
+
} from "./catalog.js";
|
|
11
|
+
import {
|
|
12
|
+
languageAccuracyGrade,
|
|
13
|
+
modelWaitSeconds,
|
|
14
|
+
SPEED_METER_STEPS,
|
|
15
|
+
speedMeterLevel,
|
|
16
|
+
type AccuracyLetter,
|
|
17
|
+
type RecommendationRole,
|
|
18
|
+
} from "./recommendations.js";
|
|
19
|
+
import { LIST_PADDING, padToWidth, selectionMarker } from "./ui-components.js";
|
|
20
|
+
|
|
21
|
+
type UiTheme = ExtensionContext["ui"]["theme"];
|
|
22
|
+
|
|
23
|
+
// "A-" is the widest grade; language codes ("yue") can widen a column.
|
|
24
|
+
const MIN_GRADE_CELL_WIDTH = 2;
|
|
25
|
+
const MIN_MODEL_NAME_WIDTH = 12;
|
|
26
|
+
export const ROLE_LABELS: Record<RecommendationRole, string> = {
|
|
27
|
+
best: "Best",
|
|
28
|
+
fast: "Fast",
|
|
29
|
+
accurate: "Accurate",
|
|
30
|
+
};
|
|
31
|
+
export const MANUAL_LANGUAGE_TAG = "manual lang";
|
|
32
|
+
/** Replaces the size column for a model already in the cache. */
|
|
33
|
+
export const ON_DISK_LABEL = "on disk";
|
|
34
|
+
// "Best · Fast" is the widest role pairing that occurs.
|
|
35
|
+
export const TAG_WIDTH = 11;
|
|
36
|
+
|
|
37
|
+
/** Width of one grade cell for these language columns. */
|
|
38
|
+
export function gradeCellWidth(languages: readonly string[]): number {
|
|
39
|
+
return Math.max(MIN_GRADE_CELL_WIDTH, ...languages.map((language) => visibleWidth(language)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Width of the whole grade block: the cells and the spaces between them. */
|
|
43
|
+
export function gradeColumnsWidth(languages: readonly string[]): number {
|
|
44
|
+
return languages.length * gradeCellWidth(languages) + Math.max(0, languages.length - 1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Colour reinforces the letter rather than replacing it, one hue per grade so
|
|
48
|
+
// no single grade dominates: green A, blue B, gold C, red D and F. The blue
|
|
49
|
+
// and gold borrow the markdown link and heading tokens; the theme has no
|
|
50
|
+
// dedicated ones, and both shipped themes give them sensible values.
|
|
51
|
+
export function gradeStyle(theme: UiTheme, letter: AccuracyLetter, text: string): string {
|
|
52
|
+
switch (letter) {
|
|
53
|
+
case "A": return theme.fg("success", text);
|
|
54
|
+
case "B": return theme.fg("mdLink", text);
|
|
55
|
+
case "C": return theme.fg("mdHeading", text);
|
|
56
|
+
case "D":
|
|
57
|
+
case "F": return theme.fg("error", text);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// One cell per preferred language, in preference order, carrying the model's
|
|
62
|
+
// benchmark grade for it. A model that lacks the language shows a dash; one
|
|
63
|
+
// that claims it without a benchmark shows a question mark, since a claim on
|
|
64
|
+
// a model card is not a measurement.
|
|
65
|
+
export function gradeCells(
|
|
66
|
+
theme: UiTheme,
|
|
67
|
+
model: CatalogModel,
|
|
68
|
+
languages: readonly string[],
|
|
69
|
+
): string {
|
|
70
|
+
const cellWidth = gradeCellWidth(languages);
|
|
71
|
+
return languages
|
|
72
|
+
.map((language) => {
|
|
73
|
+
const grade = languageAccuracyGrade(model, language);
|
|
74
|
+
if (!grade) {
|
|
75
|
+
const mark = modelMatchesLanguage(model, language) ? "?" : "—";
|
|
76
|
+
return theme.fg("dim", padToWidth(mark, cellWidth));
|
|
77
|
+
}
|
|
78
|
+
return gradeStyle(theme, grade.letter, padToWidth(grade.label, cellWidth));
|
|
79
|
+
})
|
|
80
|
+
.join(" ");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The language codes over the grade cells, in the same widths. */
|
|
84
|
+
export function gradeHeader(languages: readonly string[]): string {
|
|
85
|
+
const cellWidth = gradeCellWidth(languages);
|
|
86
|
+
return languages.map((language) => padToWidth(language, cellWidth)).join(" ");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Benchmark processing speed, fuller the quicker (not end-of-speech latency).
|
|
90
|
+
// Unbenchmarked models leave the column empty rather than claim a speed.
|
|
91
|
+
export function speedCell(model: CatalogModel): string {
|
|
92
|
+
const wait = modelWaitSeconds(model);
|
|
93
|
+
if (wait === undefined) return " ".repeat(SPEED_METER_STEPS);
|
|
94
|
+
const level = speedMeterLevel(wait);
|
|
95
|
+
return "▰".repeat(level) + "▱".repeat(SPEED_METER_STEPS - level);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type ModelTableLayout = {
|
|
99
|
+
nameWidth: number;
|
|
100
|
+
/**
|
|
101
|
+
* A section heading that doubles as the column header row: the label sits
|
|
102
|
+
* over the name column, the column names over theirs.
|
|
103
|
+
*/
|
|
104
|
+
header: (label: string) => string;
|
|
105
|
+
sizeWidth?: number;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Shared column geometry for catalog and downloaded-model tables. */
|
|
109
|
+
export function modelTableLayout(
|
|
110
|
+
theme: UiTheme,
|
|
111
|
+
width: number,
|
|
112
|
+
maximumNameWidth: number,
|
|
113
|
+
languages: readonly string[],
|
|
114
|
+
sizeWidth?: number,
|
|
115
|
+
): ModelTableLayout {
|
|
116
|
+
const languagesWidth = gradeColumnsWidth(languages);
|
|
117
|
+
const sizeOverhead = sizeWidth === undefined ? 0 : 2 + sizeWidth;
|
|
118
|
+
const overhead =
|
|
119
|
+
LIST_PADDING * 2 +
|
|
120
|
+
4 +
|
|
121
|
+
2 +
|
|
122
|
+
SPEED_METER_STEPS +
|
|
123
|
+
2 +
|
|
124
|
+
languagesWidth +
|
|
125
|
+
sizeOverhead +
|
|
126
|
+
2 +
|
|
127
|
+
TAG_WIDTH;
|
|
128
|
+
const nameWidth = Math.min(
|
|
129
|
+
maximumNameWidth,
|
|
130
|
+
Math.max(MIN_MODEL_NAME_WIDTH, width - overhead),
|
|
131
|
+
);
|
|
132
|
+
// Labels outdent two columns from the names, like a heading; the rest of
|
|
133
|
+
// the name column is theirs, and a long one truncates on narrow terminals.
|
|
134
|
+
const labelWidth = 2 + nameWidth;
|
|
135
|
+
const size = sizeWidth === undefined
|
|
136
|
+
? ""
|
|
137
|
+
: ` ${theme.fg("dim", "Size".padStart(sizeWidth))}`;
|
|
138
|
+
return {
|
|
139
|
+
nameWidth,
|
|
140
|
+
sizeWidth,
|
|
141
|
+
header: (label) =>
|
|
142
|
+
` ${theme.fg("muted", padToWidth(label, labelWidth))} ` +
|
|
143
|
+
`${theme.fg("dim", padToWidth("Speed", SPEED_METER_STEPS))} ` +
|
|
144
|
+
theme.fg("dim", gradeHeader(languages)) +
|
|
145
|
+
size,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Shared model row; callers provide only the pane-specific final tag. */
|
|
150
|
+
export function modelTableRow(
|
|
151
|
+
theme: UiTheme,
|
|
152
|
+
model: CatalogModel,
|
|
153
|
+
languages: readonly string[],
|
|
154
|
+
layout: ModelTableLayout,
|
|
155
|
+
options: {
|
|
156
|
+
active: boolean;
|
|
157
|
+
current: boolean;
|
|
158
|
+
tag?: string;
|
|
159
|
+
/** Already in the cache: the size column says so instead of the size. */
|
|
160
|
+
downloaded?: boolean;
|
|
161
|
+
},
|
|
162
|
+
): string {
|
|
163
|
+
const prefix = options.active ? theme.fg("accent", "→ ") : " ";
|
|
164
|
+
const current = selectionMarker(theme, options.current);
|
|
165
|
+
const nameText = padToWidth(model.name, layout.nameWidth);
|
|
166
|
+
const name = options.active ? theme.fg("accent", nameText) : nameText;
|
|
167
|
+
const sizeText = options.downloaded ? ON_DISK_LABEL : formatBinarySize(model.size);
|
|
168
|
+
const size = layout.sizeWidth === undefined
|
|
169
|
+
? ""
|
|
170
|
+
: ` ${theme.fg("dim", sizeText.padStart(layout.sizeWidth))}`;
|
|
171
|
+
return (
|
|
172
|
+
`${prefix}${current} ${name} ${speedCell(model)} ` +
|
|
173
|
+
`${gradeCells(theme, model, languages)}${size} ${options.tag ?? ""}`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Shared description and capability lines below a model table. */
|
|
178
|
+
export function modelDetailText(
|
|
179
|
+
theme: UiTheme,
|
|
180
|
+
model: CatalogModel,
|
|
181
|
+
width: number,
|
|
182
|
+
padding: number,
|
|
183
|
+
feedback?: { type: "success" | "error" | "muted"; text: string },
|
|
184
|
+
showManualSelection = false,
|
|
185
|
+
): string {
|
|
186
|
+
const canonicalLanguages = [...new Set(model.languages.map(canonicalLanguage))];
|
|
187
|
+
const features = [
|
|
188
|
+
canonicalLanguages.length === 1
|
|
189
|
+
? `${displayLanguage(canonicalLanguages[0]!)} only`
|
|
190
|
+
: `${canonicalLanguages.length} languages`,
|
|
191
|
+
model.capabilities.languageDetection
|
|
192
|
+
? "auto language detection"
|
|
193
|
+
: showManualSelection
|
|
194
|
+
? "manual language selection"
|
|
195
|
+
: undefined,
|
|
196
|
+
].filter((value): value is string => Boolean(value));
|
|
197
|
+
const description = truncateToWidth(
|
|
198
|
+
model.description,
|
|
199
|
+
Math.max(24, width - padding * 2),
|
|
200
|
+
"…",
|
|
201
|
+
);
|
|
202
|
+
const feedbackText = feedback
|
|
203
|
+
? `\n${theme.fg(feedback.type, feedback.text)}`
|
|
204
|
+
: "";
|
|
205
|
+
return `${description}\n${theme.fg("dim", features.join(" · "))}${feedbackText}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The catalog's search matching, shared by every page that lists models.
|
|
210
|
+
* Each word of the query must appear literally in the model's id, name,
|
|
211
|
+
* family or size, so typing a model name finds that model and little else.
|
|
212
|
+
*/
|
|
213
|
+
export function matchesCatalogSearch(model: CatalogModel, query: string): boolean {
|
|
214
|
+
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
215
|
+
if (!tokens.length) return true;
|
|
216
|
+
const text = catalogModelSearchText(model);
|
|
217
|
+
return tokens.every((token) => text.includes(token));
|
|
218
|
+
}
|