@openparachute/vault 0.7.5-rc.6 → 0.7.5
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/package.json +1 -1
- package/src/attachment-tickets.test.ts +66 -0
- package/src/attachment-tickets.ts +21 -2
- package/src/auto-transcribe.test.ts +112 -1
- package/src/auto-transcribe.ts +81 -3
- package/src/mirror-routes.ts +13 -6
- package/src/routes.ts +28 -3
- package/src/routing.ts +36 -0
- package/src/server.ts +14 -1
- package/src/transcription-routes.test.ts +195 -0
- package/src/transcription-routes.ts +327 -0
- package/src/vault.test.ts +24 -2
- package/web/ui/dist/assets/index-g3_KwmRE.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-cdAcASvO.js +0 -61
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transcription setup snapshot.
|
|
3
|
+
*
|
|
4
|
+
* The behaviour worth pinning is that "configured" and "working" are DIFFERENT
|
|
5
|
+
* — a box can have the right env vars and still transcribe nothing, which is
|
|
6
|
+
* how audio silently went nowhere for weeks (vault#643). So every test here is
|
|
7
|
+
* about the snapshot telling the truth about which piece is missing, rather
|
|
8
|
+
* than collapsing to a single boolean an operator can't act on.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { buildTranscriptionSnapshot, handleTranscriptionGet } from "./transcription-routes.ts";
|
|
16
|
+
|
|
17
|
+
const ORIG = { ...process.env };
|
|
18
|
+
let home: string;
|
|
19
|
+
/** Fake filesystem: only paths explicitly installed by a test exist. */
|
|
20
|
+
let present: Set<string>;
|
|
21
|
+
/** Resolution driven entirely by `present`, never by the real machine. */
|
|
22
|
+
function deps(active = false) {
|
|
23
|
+
return {
|
|
24
|
+
active,
|
|
25
|
+
existsImpl: (p: string) => present.has(p),
|
|
26
|
+
resolveBinaryImpl: (engine: "parakeet" | "whisper") => {
|
|
27
|
+
const name = engine === "whisper" ? "whisper-cli" : "parakeet-cli";
|
|
28
|
+
const p = join(home, "transcription", "bin", name);
|
|
29
|
+
return present.has(p) ? p : undefined;
|
|
30
|
+
},
|
|
31
|
+
resolveFfmpegImpl: () => {
|
|
32
|
+
const p = join(home, "ff", "ffmpeg");
|
|
33
|
+
return present.has(p) ? p : undefined;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
home = mkdtempSync(join(tmpdir(), "tr-routes-"));
|
|
40
|
+
present = new Set();
|
|
41
|
+
process.env.PARACHUTE_HOME = home;
|
|
42
|
+
// Isolate from the developer's real machine: an empty PATH plus no override
|
|
43
|
+
// means nothing resolves unless a test puts it there.
|
|
44
|
+
process.env.PATH = "";
|
|
45
|
+
delete process.env.WHISPER_CPP_BIN_DIR;
|
|
46
|
+
delete process.env.TRANSCRIPTION_PROVIDER;
|
|
47
|
+
delete process.env.TRANSCRIPTION_MODEL;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
rmSync(home, { recursive: true, force: true });
|
|
52
|
+
for (const k of ["PARACHUTE_HOME", "PATH", "WHISPER_CPP_BIN_DIR", "TRANSCRIPTION_PROVIDER", "TRANSCRIPTION_MODEL"]) {
|
|
53
|
+
if (ORIG[k] === undefined) delete process.env[k];
|
|
54
|
+
else process.env[k] = ORIG[k];
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** Put a fake binary on the resolution ladder. */
|
|
59
|
+
function installBinary(name: string) {
|
|
60
|
+
present.add(join(home, "transcription", "bin", name));
|
|
61
|
+
}
|
|
62
|
+
/** Put a model file where the resolver looks. */
|
|
63
|
+
function installModel(filename: string) {
|
|
64
|
+
present.add(join(home, "transcription", "models", filename));
|
|
65
|
+
}
|
|
66
|
+
/** ffmpeg, via the explicit bin-dir override. */
|
|
67
|
+
function installFfmpeg() {
|
|
68
|
+
present.add(join(home, "ff", "ffmpeg"));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("snapshot — the default (stale) provider", () => {
|
|
72
|
+
test("scribe-http with no backend reports NOT ready and says why", () => {
|
|
73
|
+
// The fresh-install state: nothing configured, so the provider resolves to
|
|
74
|
+
// scribe-http and there is no scribe. This is the case that used to be
|
|
75
|
+
// invisible.
|
|
76
|
+
const s = buildTranscriptionSnapshot(deps(false));
|
|
77
|
+
expect(s.provider).toBe("scribe-http");
|
|
78
|
+
expect(s.ready).toBe(false);
|
|
79
|
+
expect(s.reason).toMatch(/no reachable backend/);
|
|
80
|
+
expect(s.reason).toMatch(/whisper-cpp/);
|
|
81
|
+
expect(s.fix_command).toBeTruthy();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("snapshot — whisper-cpp readiness names the MISSING piece", () => {
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
process.env.TRANSCRIPTION_PROVIDER = "whisper-cpp";
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("nothing installed → names binary, model AND ffmpeg", () => {
|
|
91
|
+
const s = buildTranscriptionSnapshot(deps(false));
|
|
92
|
+
expect(s.ready).toBe(false);
|
|
93
|
+
expect(s.reason).toMatch(/parakeet-cli/);
|
|
94
|
+
expect(s.reason).toMatch(/model file/);
|
|
95
|
+
expect(s.reason).toMatch(/ffmpeg/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("binary + model but NO ffmpeg → ffmpeg-specific fix, not a generic install", () => {
|
|
99
|
+
// Different problem, different command. Telling someone to re-run
|
|
100
|
+
// `transcription install` when the real gap is a system package wastes a
|
|
101
|
+
// 400 MB download and doesn't fix it.
|
|
102
|
+
installBinary("parakeet-cli");
|
|
103
|
+
installModel("ggml-parakeet-tdt-0.6b-v3-q4_k.bin");
|
|
104
|
+
const s = buildTranscriptionSnapshot(deps(false));
|
|
105
|
+
expect(s.ready).toBe(false);
|
|
106
|
+
expect(s.reason).toMatch(/ffmpeg/);
|
|
107
|
+
expect(s.fix_command).toMatch(/brew install ffmpeg|apt install ffmpeg/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("everything present → ready, no reason, no fix", () => {
|
|
111
|
+
installBinary("parakeet-cli");
|
|
112
|
+
installModel("ggml-parakeet-tdt-0.6b-v3-q4_k.bin");
|
|
113
|
+
installFfmpeg();
|
|
114
|
+
const s = buildTranscriptionSnapshot(deps(false));
|
|
115
|
+
expect(s.ready).toBe(true);
|
|
116
|
+
expect(s.reason).toBeNull();
|
|
117
|
+
expect(s.fix_command).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a whisper model asks for whisper-cli, not parakeet-cli", () => {
|
|
121
|
+
process.env.TRANSCRIPTION_MODEL = "whisper-base.en";
|
|
122
|
+
const s = buildTranscriptionSnapshot(deps(false));
|
|
123
|
+
expect(s.binary.name).toBe("whisper-cli");
|
|
124
|
+
expect(s.reason).toMatch(/whisper-cli/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("an unknown model id is reported, not silently defaulted", () => {
|
|
128
|
+
process.env.TRANSCRIPTION_MODEL = "whisper-enormous";
|
|
129
|
+
const s = buildTranscriptionSnapshot(deps(false));
|
|
130
|
+
expect(s.model).toBeNull();
|
|
131
|
+
expect(s.ready).toBe(false);
|
|
132
|
+
expect(s.reason).toMatch(/isn't in the catalog/);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("snapshot — ready vs active", () => {
|
|
137
|
+
beforeEach(() => {
|
|
138
|
+
process.env.TRANSCRIPTION_PROVIDER = "whisper-cpp";
|
|
139
|
+
installBinary("parakeet-cli");
|
|
140
|
+
installModel("ggml-parakeet-tdt-0.6b-v3-q4_k.bin");
|
|
141
|
+
installFfmpeg();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("ready but not active → restart_required", () => {
|
|
145
|
+
// The operator just set a preference; the running worker hasn't picked it
|
|
146
|
+
// up. Conflating the two would report success on a box still doing nothing.
|
|
147
|
+
const s = buildTranscriptionSnapshot(deps(false));
|
|
148
|
+
expect(s.ready).toBe(true);
|
|
149
|
+
expect(s.active).toBe(false);
|
|
150
|
+
expect(s.restart_required).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("ready and active → nothing to do", () => {
|
|
154
|
+
const s = buildTranscriptionSnapshot(deps(true));
|
|
155
|
+
expect(s.restart_required).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("snapshot — actionability", () => {
|
|
160
|
+
test("reports the directories searched, so 'not found' is debuggable", () => {
|
|
161
|
+
// On macOS the likeliest failure is a binary that IS installed but
|
|
162
|
+
// invisible to a launchd-supervised vault (no login-shell PATH). A boolean
|
|
163
|
+
// can't express that; a list of probed directories can.
|
|
164
|
+
// Intentionally the real ladder — this asserts what we SHOW the operator.
|
|
165
|
+
const s = buildTranscriptionSnapshot(false);
|
|
166
|
+
expect(s.binary.searched.length).toBeGreaterThan(0);
|
|
167
|
+
expect(s.binary.searched.some((d) => d.includes("homebrew") || d.includes("/usr/local"))).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("offers the whole catalog with real sizes and per-model install state", () => {
|
|
171
|
+
installModel("ggml-tiny.en.bin");
|
|
172
|
+
const s = buildTranscriptionSnapshot(deps(false));
|
|
173
|
+
expect(s.available_models.length).toBeGreaterThan(3);
|
|
174
|
+
const tiny = s.available_models.find((m) => m.id === "whisper-tiny.en");
|
|
175
|
+
expect(tiny?.installed).toBe(true);
|
|
176
|
+
expect(tiny?.size_mb).toBe(74);
|
|
177
|
+
// A model we didn't write is correctly reported as absent.
|
|
178
|
+
expect(s.available_models.find((m) => m.id === "whisper-small.en")?.installed).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("catalog is smallest-first, so the picker reads as a ladder", () => {
|
|
182
|
+
const sizes = buildTranscriptionSnapshot(deps(false)).available_models.map((m) => m.size_mb);
|
|
183
|
+
expect([...sizes].sort((a, b) => a - b)).toEqual(sizes);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("GET handler", () => {
|
|
188
|
+
test("200 + no-store (a polled status must never be cached stale)", async () => {
|
|
189
|
+
const res = handleTranscriptionGet(false);
|
|
190
|
+
expect(res.status).toBe(200);
|
|
191
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
192
|
+
const body = (await res.json()) as { ready: boolean };
|
|
193
|
+
expect(typeof body.ready).toBe("boolean");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET|PUT /vault/<name>/.parachute/transcription` — the transcription setup
|
|
3
|
+
* surface for the admin SPA.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* Configuring local transcription meant reading source: which provider is
|
|
8
|
+
* resolved, whether a binary is anywhere findable, whether the model is on
|
|
9
|
+
* disk, and — the part nobody could see — whether the running worker actually
|
|
10
|
+
* picked any of it up. All of it lived in env vars and a boot log line the
|
|
11
|
+
* operator had usually scrolled past. A box could sit for weeks accepting audio
|
|
12
|
+
* and transcribing nothing with no way to tell from the UI (vault#643).
|
|
13
|
+
*
|
|
14
|
+
* So this endpoint answers, in one shot, the three questions an operator
|
|
15
|
+
* actually has:
|
|
16
|
+
*
|
|
17
|
+
* 1. **Is transcription working right now?** (`ready` + `active`)
|
|
18
|
+
* 2. **If not, exactly what's missing?** (`reason`, `binary`, `model`)
|
|
19
|
+
* 3. **What do I run to fix it?** (`fix_command`)
|
|
20
|
+
*
|
|
21
|
+
* Deliberately reports paths and the directories searched, not just booleans.
|
|
22
|
+
* "Not installed" is unactionable when it could mean two different things with
|
|
23
|
+
* two different fixes — and on macOS the likeliest cause is that the binary IS
|
|
24
|
+
* installed but a launchd-supervised vault can't see it (no login-shell PATH),
|
|
25
|
+
* which a boolean can never express.
|
|
26
|
+
*
|
|
27
|
+
* ## What PUT does, and deliberately doesn't
|
|
28
|
+
*
|
|
29
|
+
* PUT writes the *preference* — provider + model — to the vault's `.env`. It
|
|
30
|
+
* does NOT download anything or run a package manager. Installing needs to
|
|
31
|
+
* fetch hundreds of megabytes and shell `brew`/`tar`, which is a CLI job with a
|
|
32
|
+
* progress bar, not a web request that a browser tab can abandon halfway. The
|
|
33
|
+
* UI's job is to make the state legible and hand over the exact command; the
|
|
34
|
+
* CLI's job is to do the work. `restart_required` is honest about the gap
|
|
35
|
+
* between a persisted preference and the running worker, exactly like the
|
|
36
|
+
* embeddings toggle it mirrors.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { existsSync } from "node:fs";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
import { readEnvFile, setEnvVar } from "./config.ts";
|
|
42
|
+
import { getTranscriptionWorker } from "./transcription-registry.ts";
|
|
43
|
+
import {
|
|
44
|
+
DEFAULT_MODEL_ID,
|
|
45
|
+
findModel,
|
|
46
|
+
TRANSCRIPTION_MODELS,
|
|
47
|
+
type TranscriptionModel,
|
|
48
|
+
} from "./transcription/models.ts";
|
|
49
|
+
import {
|
|
50
|
+
binaryNameFor,
|
|
51
|
+
candidateBinDirs,
|
|
52
|
+
managedModelDir,
|
|
53
|
+
resolveCliBinary,
|
|
54
|
+
resolveFfmpeg,
|
|
55
|
+
} from "./transcription/resolve-binary.ts";
|
|
56
|
+
import {
|
|
57
|
+
resolveTranscriptionModelId,
|
|
58
|
+
resolveTranscriptionProviderName,
|
|
59
|
+
TRANSCRIPTION_PROVIDERS,
|
|
60
|
+
} from "./transcription/select.ts";
|
|
61
|
+
|
|
62
|
+
/** One entry in the model picker. */
|
|
63
|
+
export interface TranscriptionModelOption {
|
|
64
|
+
id: string;
|
|
65
|
+
label: string;
|
|
66
|
+
engine: "parakeet" | "whisper";
|
|
67
|
+
size_mb: number;
|
|
68
|
+
min_ram_mb: number;
|
|
69
|
+
note: string;
|
|
70
|
+
/** Whether this specific model's file is already downloaded. */
|
|
71
|
+
installed: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TranscriptionSnapshot {
|
|
75
|
+
/** Resolved provider name (`whisper-cpp`, `scribe-http`, …). */
|
|
76
|
+
provider: string;
|
|
77
|
+
/** Every provider the build knows about, for the picker. */
|
|
78
|
+
available_providers: readonly string[];
|
|
79
|
+
/** Configured model id (only meaningful for `whisper-cpp`). */
|
|
80
|
+
model_id: string;
|
|
81
|
+
/** The resolved model, or `null` when `model_id` names nothing known. */
|
|
82
|
+
model: TranscriptionModelOption | null;
|
|
83
|
+
/** Catalog for the picker, smallest first. */
|
|
84
|
+
available_models: TranscriptionModelOption[];
|
|
85
|
+
/** The CLI this model needs, and whether we can find it. */
|
|
86
|
+
binary: {
|
|
87
|
+
name: string;
|
|
88
|
+
path: string | null;
|
|
89
|
+
/** Directories probed, in order — so a "not found" is debuggable. */
|
|
90
|
+
searched: string[];
|
|
91
|
+
};
|
|
92
|
+
/** ffmpeg, required for transcoding regardless of provider. */
|
|
93
|
+
ffmpeg: { path: string | null };
|
|
94
|
+
/**
|
|
95
|
+
* True when everything needed to transcribe is present. Distinct from
|
|
96
|
+
* `active`: flipping a preference changes `ready` immediately, but the
|
|
97
|
+
* running worker only picks it up on restart.
|
|
98
|
+
*/
|
|
99
|
+
ready: boolean;
|
|
100
|
+
/** Whether the running process has a transcription worker live. */
|
|
101
|
+
active: boolean;
|
|
102
|
+
/** `true` when `ready !== active` — restart to apply. */
|
|
103
|
+
restart_required: boolean;
|
|
104
|
+
/** Human-readable reason when `ready` is false; `null` when ready. */
|
|
105
|
+
reason: string | null;
|
|
106
|
+
/** The exact command that fixes a not-ready state; `null` when ready. */
|
|
107
|
+
fix_command: string | null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function toOption(
|
|
111
|
+
m: TranscriptionModel,
|
|
112
|
+
modelDir: string,
|
|
113
|
+
exists: (p: string) => boolean = existsSync,
|
|
114
|
+
): TranscriptionModelOption {
|
|
115
|
+
return {
|
|
116
|
+
id: m.id,
|
|
117
|
+
label: m.label,
|
|
118
|
+
engine: m.engine,
|
|
119
|
+
size_mb: m.sizeMb,
|
|
120
|
+
min_ram_mb: m.minRamMb,
|
|
121
|
+
note: m.note,
|
|
122
|
+
installed: exists(join(modelDir, m.filename)),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Injection seams. All optional; production passes nothing.
|
|
128
|
+
*
|
|
129
|
+
* These exist because binary resolution deliberately probes Homebrew's
|
|
130
|
+
* prefixes whether or not they're on PATH (the launchd case — see
|
|
131
|
+
* `resolve-binary.ts`). That's correct in production and makes the snapshot
|
|
132
|
+
* NON-HERMETIC in tests: a developer with `brew install whisper-cpp` would see
|
|
133
|
+
* "installed" no matter what the test set up, so a test asserting the
|
|
134
|
+
* not-installed path would pass for the wrong reason on one machine and fail on
|
|
135
|
+
* another. Injecting resolution is what makes those assertions mean something.
|
|
136
|
+
*/
|
|
137
|
+
export interface SnapshotDeps {
|
|
138
|
+
/** Whether a worker is live. Production reads the shared registry. */
|
|
139
|
+
active?: boolean;
|
|
140
|
+
resolveBinaryImpl?: (engine: "parakeet" | "whisper") => string | undefined;
|
|
141
|
+
resolveFfmpegImpl?: () => string | undefined;
|
|
142
|
+
existsImpl?: (p: string) => boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build the snapshot. `deps.active` is how the server tells us whether a worker
|
|
147
|
+
* is actually running — this module can't know that on its own, and guessing
|
|
148
|
+
* would reintroduce the exact "looks configured, transcribes nothing" gap the
|
|
149
|
+
* endpoint exists to close.
|
|
150
|
+
*
|
|
151
|
+
* Accepts a bare boolean for back-compat with the `handleTranscription*`
|
|
152
|
+
* callers, which only ever passed `active`.
|
|
153
|
+
*/
|
|
154
|
+
export function buildTranscriptionSnapshot(
|
|
155
|
+
depsOrActive?: SnapshotDeps | boolean,
|
|
156
|
+
): TranscriptionSnapshot {
|
|
157
|
+
const deps: SnapshotDeps =
|
|
158
|
+
typeof depsOrActive === "boolean" ? { active: depsOrActive } : (depsOrActive ?? {});
|
|
159
|
+
const activeOverride = deps.active;
|
|
160
|
+
const exists = deps.existsImpl ?? existsSync;
|
|
161
|
+
const resolveBin = deps.resolveBinaryImpl ?? resolveCliBinary;
|
|
162
|
+
const resolveFf = deps.resolveFfmpegImpl ?? resolveFfmpeg;
|
|
163
|
+
|
|
164
|
+
const provider = resolveTranscriptionProviderName();
|
|
165
|
+
const modelId = resolveTranscriptionModelId();
|
|
166
|
+
const model = findModel(modelId);
|
|
167
|
+
const modelDir = managedModelDir();
|
|
168
|
+
|
|
169
|
+
const engine = model?.engine ?? "parakeet";
|
|
170
|
+
const binPath = resolveBin(engine) ?? null;
|
|
171
|
+
const ffmpegPath = resolveFf() ?? null;
|
|
172
|
+
const modelInstalled = model ? exists(join(modelDir, model.filename)) : false;
|
|
173
|
+
|
|
174
|
+
// Readiness is provider-specific. `whisper-cpp` needs binary + model +
|
|
175
|
+
// ffmpeg; the remote provider needs a URL, which the worker resolves itself.
|
|
176
|
+
let ready: boolean;
|
|
177
|
+
let reason: string | null = null;
|
|
178
|
+
let fix: string | null = null;
|
|
179
|
+
|
|
180
|
+
if (provider === "whisper-cpp") {
|
|
181
|
+
const missing: string[] = [];
|
|
182
|
+
if (!model) missing.push(`the model id "${modelId}" isn't in the catalog`);
|
|
183
|
+
if (!binPath) missing.push(`the ${binaryNameFor(engine)} binary`);
|
|
184
|
+
if (model && !modelInstalled) missing.push(`the model file (${model.label}, ${model.sizeMb} MB)`);
|
|
185
|
+
if (!ffmpegPath) missing.push("ffmpeg (needed to transcode audio to 16 kHz mono WAV)");
|
|
186
|
+
ready = missing.length === 0;
|
|
187
|
+
if (!ready) {
|
|
188
|
+
reason = `Not ready — missing ${missing.join("; ")}.`;
|
|
189
|
+
fix = !ffmpegPath && binPath && modelInstalled
|
|
190
|
+
? "brew install ffmpeg # or: sudo apt install ffmpeg"
|
|
191
|
+
: "parachute-vault transcription install";
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
// Any other provider: we can't introspect it from here, so defer to
|
|
195
|
+
// whether the server actually started a worker. Saying "ready" about a
|
|
196
|
+
// provider we haven't checked is how the silent-no-op bug happened.
|
|
197
|
+
ready = activeOverride ?? getTranscriptionWorker() !== null;
|
|
198
|
+
if (!ready) {
|
|
199
|
+
reason =
|
|
200
|
+
`Provider "${provider}" has no reachable backend. Local transcription needs ` +
|
|
201
|
+
`TRANSCRIPTION_PROVIDER=whisper-cpp.`;
|
|
202
|
+
fix = "parachute-vault transcription install";
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Read the SHARED registry the server populates at boot, mirroring how the
|
|
207
|
+
// embeddings snapshot reads its provider state. Defaulting to `false` here
|
|
208
|
+
// would report "not active" on a perfectly working box.
|
|
209
|
+
const active = activeOverride ?? getTranscriptionWorker() !== null;
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
provider,
|
|
213
|
+
available_providers: TRANSCRIPTION_PROVIDERS,
|
|
214
|
+
model_id: modelId,
|
|
215
|
+
model: model ? toOption(model, modelDir, exists) : null,
|
|
216
|
+
available_models: TRANSCRIPTION_MODELS.map((m) => toOption(m, modelDir, exists)),
|
|
217
|
+
binary: {
|
|
218
|
+
name: binaryNameFor(engine),
|
|
219
|
+
path: binPath,
|
|
220
|
+
// Only the first few — the full PATH is noise, and the ones that matter
|
|
221
|
+
// (our managed dir, then Homebrew) lead.
|
|
222
|
+
searched: candidateBinDirs().slice(0, 5),
|
|
223
|
+
},
|
|
224
|
+
ffmpeg: { path: ffmpegPath },
|
|
225
|
+
ready,
|
|
226
|
+
active,
|
|
227
|
+
restart_required: ready !== active,
|
|
228
|
+
reason,
|
|
229
|
+
fix_command: fix,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** `GET` — the snapshot. Admin-gated upstream in routing.ts. */
|
|
234
|
+
export function handleTranscriptionGet(activeOverride?: boolean): Response {
|
|
235
|
+
return Response.json(buildTranscriptionSnapshot(activeOverride), {
|
|
236
|
+
headers: { "Access-Control-Allow-Origin": "*", "cache-control": "no-store" },
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* `PUT` — persist a provider and/or model preference to the vault `.env`.
|
|
242
|
+
*
|
|
243
|
+
* Writes only; never downloads. See the module docstring for why installing
|
|
244
|
+
* stays a CLI job. Returns the fresh snapshot so the UI can render the new
|
|
245
|
+
* state — including `restart_required`, which will now be true.
|
|
246
|
+
*/
|
|
247
|
+
export async function handleTranscriptionPut(
|
|
248
|
+
req: Request,
|
|
249
|
+
activeOverride?: boolean,
|
|
250
|
+
): Promise<Response> {
|
|
251
|
+
let body: { provider?: unknown; model_id?: unknown };
|
|
252
|
+
try {
|
|
253
|
+
body = (await req.json()) as { provider?: unknown; model_id?: unknown };
|
|
254
|
+
} catch (err) {
|
|
255
|
+
return Response.json(
|
|
256
|
+
{
|
|
257
|
+
error: "Invalid JSON body",
|
|
258
|
+
error_type: "invalid_json",
|
|
259
|
+
message: (err as Error).message ?? String(err),
|
|
260
|
+
},
|
|
261
|
+
{ status: 400 },
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (body.provider !== undefined) {
|
|
266
|
+
if (
|
|
267
|
+
typeof body.provider !== "string" ||
|
|
268
|
+
!(TRANSCRIPTION_PROVIDERS as readonly string[]).includes(body.provider)
|
|
269
|
+
) {
|
|
270
|
+
return Response.json(
|
|
271
|
+
{
|
|
272
|
+
error: "provider invalid",
|
|
273
|
+
error_type: "validation",
|
|
274
|
+
field: "provider",
|
|
275
|
+
message: `provider must be one of: ${TRANSCRIPTION_PROVIDERS.join(", ")}.`,
|
|
276
|
+
},
|
|
277
|
+
{ status: 400 },
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (body.model_id !== undefined) {
|
|
283
|
+
if (typeof body.model_id !== "string" || !findModel(body.model_id)) {
|
|
284
|
+
return Response.json(
|
|
285
|
+
{
|
|
286
|
+
error: "model_id invalid",
|
|
287
|
+
error_type: "validation",
|
|
288
|
+
field: "model_id",
|
|
289
|
+
message:
|
|
290
|
+
`model_id must be a known model. Valid ids: ` +
|
|
291
|
+
`${TRANSCRIPTION_MODELS.map((m) => m.id).join(", ")}.`,
|
|
292
|
+
},
|
|
293
|
+
{ status: 400 },
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (body.provider === undefined && body.model_id === undefined) {
|
|
299
|
+
return Response.json(
|
|
300
|
+
{
|
|
301
|
+
error: "nothing to set",
|
|
302
|
+
error_type: "validation",
|
|
303
|
+
message: "Provide `provider` and/or `model_id`.",
|
|
304
|
+
},
|
|
305
|
+
{ status: 400 },
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Touch the env file only for keys actually supplied, so setting a model
|
|
310
|
+
// doesn't silently pin a provider the operator didn't choose.
|
|
311
|
+
if (typeof body.provider === "string") setEnvVar("TRANSCRIPTION_PROVIDER", body.provider);
|
|
312
|
+
if (typeof body.model_id === "string") setEnvVar("TRANSCRIPTION_MODEL", body.model_id);
|
|
313
|
+
|
|
314
|
+
// `setEnvVar` writes the file; the running process's `process.env` is what
|
|
315
|
+
// `resolve*` reads, so mirror the write in-process or the snapshot we return
|
|
316
|
+
// would describe the OLD preference and look like the write failed.
|
|
317
|
+
const env = readEnvFile();
|
|
318
|
+
if (env.TRANSCRIPTION_PROVIDER) process.env.TRANSCRIPTION_PROVIDER = env.TRANSCRIPTION_PROVIDER;
|
|
319
|
+
if (env.TRANSCRIPTION_MODEL) process.env.TRANSCRIPTION_MODEL = env.TRANSCRIPTION_MODEL;
|
|
320
|
+
|
|
321
|
+
return Response.json(buildTranscriptionSnapshot(activeOverride), {
|
|
322
|
+
headers: { "Access-Control-Allow-Origin": "*", "cache-control": "no-store" },
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Re-exported for the SPA's typing convenience. */
|
|
327
|
+
export { DEFAULT_MODEL_ID };
|
package/src/vault.test.ts
CHANGED
|
@@ -2877,7 +2877,12 @@ describe("HTTP /notes", async () => {
|
|
|
2877
2877
|
expect((note!.metadata as any)?.transcribe_stub).toBe(true);
|
|
2878
2878
|
});
|
|
2879
2879
|
|
|
2880
|
-
|
|
2880
|
+
// vault#643: audio with no explicit `transcribe` flag takes the AUTO path.
|
|
2881
|
+
// In this suite no provider is reachable, so the attachment is no longer
|
|
2882
|
+
// left looking like an ordinary upload — it records WHY nothing happened.
|
|
2883
|
+
// It is still not `pending` (nothing was enqueued) and the note is still
|
|
2884
|
+
// untouched (no stub, since the caller never asked for one).
|
|
2885
|
+
test("audio with no flag + no provider records the failure; note untouched", async () => {
|
|
2881
2886
|
await store.createNote("note body", { id: "v2" });
|
|
2882
2887
|
const res = await handleNotes(
|
|
2883
2888
|
mkReq("POST", "/notes/v2/attachments", {
|
|
@@ -2889,12 +2894,29 @@ describe("HTTP /notes", async () => {
|
|
|
2889
2894
|
);
|
|
2890
2895
|
expect(res.status).toBe(201);
|
|
2891
2896
|
const att = await res.json() as any;
|
|
2892
|
-
expect(att.metadata?.transcribe_status).
|
|
2897
|
+
expect(att.metadata?.transcribe_status).toBe("failed");
|
|
2898
|
+
expect(att.metadata?.transcribe_error).toMatch(/no transcription provider configured/i);
|
|
2893
2899
|
|
|
2894
2900
|
const note = await store.getNote("v2");
|
|
2895
2901
|
expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
|
|
2896
2902
|
});
|
|
2897
2903
|
|
|
2904
|
+
test("NON-audio with no flag still leaves metadata completely empty", async () => {
|
|
2905
|
+
await store.createNote("note body", { id: "v2b" });
|
|
2906
|
+
const res = await handleNotes(
|
|
2907
|
+
mkReq("POST", "/notes/v2b/attachments", {
|
|
2908
|
+
path: "docs/spec.pdf",
|
|
2909
|
+
mimeType: "application/pdf",
|
|
2910
|
+
}),
|
|
2911
|
+
store,
|
|
2912
|
+
"/v2b/attachments",
|
|
2913
|
+
);
|
|
2914
|
+
expect(res.status).toBe(201);
|
|
2915
|
+
const att = await res.json() as any;
|
|
2916
|
+
expect(att.metadata?.transcribe_status).toBeUndefined();
|
|
2917
|
+
expect(att.metadata?.transcribe_error).toBeUndefined();
|
|
2918
|
+
});
|
|
2919
|
+
|
|
2898
2920
|
test("transcribe: true preserves other note metadata", async () => {
|
|
2899
2921
|
await store.createNote("body", { id: "v3", metadata: { summary: "keep me" } });
|
|
2900
2922
|
await handleNotes(
|