dictawhisper 0.0.13 → 0.0.14
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 +10 -2
- package/bin/dictawhisper.mjs +14 -0
- package/config.example.json +41 -0
- package/dist/ui/_app/immutable/assets/0.C6M3gHo0.css +1 -0
- package/dist/ui/_app/immutable/assets/2.C96vgj_4.css +1 -0
- package/dist/ui/_app/immutable/chunks/BSs9MmST.js +3 -0
- package/dist/ui/_app/immutable/chunks/Bjy-W4x2.js +81 -0
- package/dist/ui/_app/immutable/chunks/BslYtab-.js +1 -0
- package/dist/ui/_app/immutable/chunks/xihTtKlq.js +1 -0
- package/dist/ui/_app/immutable/entry/app.BJlRju4c.js +2 -0
- package/dist/ui/_app/immutable/entry/start.C5XBbXTT.js +1 -0
- package/dist/ui/_app/immutable/nodes/0.D23pkrHr.js +1 -0
- package/dist/ui/_app/immutable/nodes/1.C5T-U4uV.js +1 -0
- package/dist/ui/_app/immutable/nodes/2.59uAq_ko.js +5 -0
- package/dist/ui/_app/version.json +1 -0
- package/dist/ui/favicon.png +0 -0
- package/dist/ui/favicon.svg +3 -0
- package/dist/ui/index.html +38 -0
- package/dist/ui/logo.png +0 -0
- package/package.json +12 -3
- package/scripts/transcribe_faster_whisper.py +428 -0
- package/src/apiRoutes.ts +464 -0
- package/src/classes/Queue.ts +77 -0
- package/src/classes/Watcher.ts +117 -0
- package/src/cli.ts +132 -0
- package/src/config.ts +238 -0
- package/src/dev.ts +63 -0
- package/src/doctor.ts +12 -0
- package/src/lib/alignLib.ts +154 -0
- package/src/lib/audioLib.ts +202 -0
- package/src/lib/audioProbeLib.ts +112 -0
- package/src/lib/cleanupProvenanceLib.ts +80 -0
- package/src/lib/downloadLib.ts +8 -0
- package/src/lib/fileSettleLib.ts +213 -0
- package/src/lib/fsLib.ts +37 -0
- package/src/lib/healthLib.ts +377 -0
- package/src/lib/inboxUrl.ts +123 -0
- package/src/lib/journalEmbedLib.ts +127 -0
- package/src/lib/journalIndexLib.ts +923 -0
- package/src/lib/journalQueryLib.ts +269 -0
- package/src/lib/journalService.ts +287 -0
- package/src/lib/jsonLib.ts +71 -0
- package/src/lib/localberthGet.ts +14 -0
- package/src/lib/ollanetLib.ts +117 -0
- package/src/lib/ollanetReadyLib.ts +7 -0
- package/src/lib/organizationLib.ts +182 -0
- package/src/lib/pathAllowLib.ts +60 -0
- package/src/lib/queueLib.ts +30 -0
- package/src/lib/speechCleanupLib.ts +14 -0
- package/src/lib/tagConsolidateLib.ts +431 -0
- package/src/lib/tailscaleLib.ts +78 -0
- package/src/lib/transcriptionLib.ts +484 -0
- package/src/lib/whisperLib.ts +409 -0
- package/src/mcp.ts +151 -0
- package/src/prompts/cleanTranscription.ts +62 -0
- package/src/prompts/consolidateTags.ts +38 -0
- package/src/prompts/whisperInitial.ts +11 -0
- package/src/server.ts +220 -0
- package/src/socketEvents.ts +72 -0
- package/src/types/transcription.ts +56 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import net from 'net';
|
|
4
|
+
import { ollamaBaseUrl, ollamaTags, resolveTarget } from 'ollanet';
|
|
5
|
+
import { configPath, type DictaConfig } from '../config.ts';
|
|
6
|
+
import { apiListenHost, discoverTailscale, inboxUrls } from './tailscaleLib.ts';
|
|
7
|
+
import { getWhisperWorkerStatus, resolveWhisperModel } from './whisperLib.ts';
|
|
8
|
+
import { getJournalIndex, openJournalIndex } from './journalIndexLib.ts';
|
|
9
|
+
|
|
10
|
+
export type HealthLevel = 'ok' | 'warn' | 'fail';
|
|
11
|
+
|
|
12
|
+
export type HealthCheck = {
|
|
13
|
+
id: string;
|
|
14
|
+
level: HealthLevel;
|
|
15
|
+
message: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type HealthMode = 'doctor' | 'startup' | 'live';
|
|
19
|
+
|
|
20
|
+
export type HealthReport = {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
degraded: boolean;
|
|
23
|
+
node: string;
|
|
24
|
+
configPath: string;
|
|
25
|
+
whisper: {
|
|
26
|
+
model: string;
|
|
27
|
+
device: string;
|
|
28
|
+
computeType: string;
|
|
29
|
+
python: string;
|
|
30
|
+
worker: ReturnType<typeof getWhisperWorkerStatus>;
|
|
31
|
+
};
|
|
32
|
+
ollanet: {
|
|
33
|
+
machine: string;
|
|
34
|
+
model: string;
|
|
35
|
+
reachable: boolean;
|
|
36
|
+
};
|
|
37
|
+
http: { host: string; port: number };
|
|
38
|
+
checks: HealthCheck[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const LIVE_CACHE_MS = 30_000;
|
|
42
|
+
const PYTHON_PROBE_MS = 45_000;
|
|
43
|
+
const OLLANET_RESOLVE_MS = 12_000;
|
|
44
|
+
const OLLANET_TAGS_MS = 8_000;
|
|
45
|
+
|
|
46
|
+
let liveCache: { at: number; report: HealthReport } | null = null;
|
|
47
|
+
|
|
48
|
+
function add(checks: HealthCheck[], id: string, level: HealthLevel, message: string): void {
|
|
49
|
+
checks.push({ id, level, message });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function pythonOnPath(name: string): boolean {
|
|
53
|
+
return name === 'python' || name === 'python3' || name === 'py';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function spawnPython(
|
|
57
|
+
python: string,
|
|
58
|
+
args: string[],
|
|
59
|
+
timeout = PYTHON_PROBE_MS
|
|
60
|
+
): { status: number | null; stdout: string; stderr: string; error?: string } {
|
|
61
|
+
const result = spawnSync(python, args, {
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
timeout,
|
|
64
|
+
windowsHide: true,
|
|
65
|
+
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
66
|
+
});
|
|
67
|
+
if (result.error) {
|
|
68
|
+
return { status: result.status, stdout: result.stdout || '', stderr: result.stderr || '', error: result.error.message };
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
status: result.status,
|
|
72
|
+
stdout: (result.stdout || '').trim(),
|
|
73
|
+
stderr: (result.stderr || '').trim(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function probePort(host: string, port: number): Promise<'free' | 'in-use' | 'error'> {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const server = net.createServer();
|
|
80
|
+
server.unref();
|
|
81
|
+
server.once('error', (error: NodeJS.ErrnoException) => {
|
|
82
|
+
resolve(error.code === 'EADDRINUSE' ? 'in-use' : 'error');
|
|
83
|
+
});
|
|
84
|
+
server.listen(port, host, () => {
|
|
85
|
+
server.close(() => resolve('free'));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
91
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
92
|
+
return Promise.race([
|
|
93
|
+
promise,
|
|
94
|
+
new Promise<T>((_, reject) => {
|
|
95
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
96
|
+
}),
|
|
97
|
+
]).finally(() => {
|
|
98
|
+
if (timer) clearTimeout(timer);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function modelListed(tags: { name: string }[], model: string): boolean {
|
|
103
|
+
const want = model.trim().toLowerCase();
|
|
104
|
+
return tags.some((tag) => {
|
|
105
|
+
const name = tag.name.toLowerCase();
|
|
106
|
+
return name === want || name.startsWith(`${want}:`) || name.startsWith(`${want}-`);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function probeOllanet(
|
|
111
|
+
machine: string,
|
|
112
|
+
model: string
|
|
113
|
+
): Promise<{ reachable: boolean; message: string; level: HealthLevel }> {
|
|
114
|
+
try {
|
|
115
|
+
const host = await withTimeout(
|
|
116
|
+
resolveTarget(machine),
|
|
117
|
+
OLLANET_RESOLVE_MS,
|
|
118
|
+
`timed out resolving ${machine}`
|
|
119
|
+
);
|
|
120
|
+
const baseUrl = ollamaBaseUrl(host);
|
|
121
|
+
const tags = await ollamaTags(baseUrl, OLLANET_TAGS_MS);
|
|
122
|
+
if (model && !modelListed(tags, model)) {
|
|
123
|
+
return {
|
|
124
|
+
reachable: true,
|
|
125
|
+
level: 'warn',
|
|
126
|
+
message: `ollanet ${machine} is up; model ${model} not in /api/tags`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
reachable: true,
|
|
131
|
+
level: 'ok',
|
|
132
|
+
message: `ollanet ${machine} / ${model} (${tags.length} models)`,
|
|
133
|
+
};
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
136
|
+
return {
|
|
137
|
+
reachable: false,
|
|
138
|
+
level: 'warn',
|
|
139
|
+
message: `ollanet ${machine} unreachable (${detail}) — cleanup degraded; raw transcripts still work`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function collectHealth(
|
|
145
|
+
config: DictaConfig,
|
|
146
|
+
options: { mode: HealthMode } = { mode: 'startup' }
|
|
147
|
+
): Promise<HealthReport> {
|
|
148
|
+
const checks: HealthCheck[] = [];
|
|
149
|
+
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
150
|
+
if (!Number.isFinite(nodeMajor) || nodeMajor < 20) {
|
|
151
|
+
add(checks, 'node', 'fail', `node ${process.version} (need >= 20)`);
|
|
152
|
+
} else {
|
|
153
|
+
add(checks, 'node', 'ok', `node ${process.version}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
add(checks, 'config', 'ok', `config ${configPath}`);
|
|
157
|
+
|
|
158
|
+
if (!config.watch.roots.length) {
|
|
159
|
+
add(checks, 'watch-roots', 'ok', 'no extra watch roots (browser record/drop only)');
|
|
160
|
+
}
|
|
161
|
+
for (const root of config.watch.roots) {
|
|
162
|
+
if (fs.existsSync(root)) {
|
|
163
|
+
add(checks, 'watch-root', 'ok', `watch root ${root}`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (config.watch.createMissingRoots) {
|
|
167
|
+
fs.mkdirSync(root, { recursive: true });
|
|
168
|
+
add(checks, 'watch-root', 'ok', `watch root ${root} (created)`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
add(checks, 'watch-root', 'fail', `watch root missing ${root}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const drop = config.watch.browserDropFolder;
|
|
175
|
+
if (!fs.existsSync(drop)) {
|
|
176
|
+
fs.mkdirSync(drop, { recursive: true });
|
|
177
|
+
add(checks, 'browser-drop', 'ok', `browser drop ${drop} (created)`);
|
|
178
|
+
} else {
|
|
179
|
+
add(checks, 'browser-drop', 'ok', `browser drop ${drop}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const python = config.whisper.python;
|
|
183
|
+
const pythonExists = pythonOnPath(python) || fs.existsSync(python);
|
|
184
|
+
if (!pythonExists) {
|
|
185
|
+
add(checks, 'python', 'fail', `python not found: ${python}`);
|
|
186
|
+
} else {
|
|
187
|
+
const version = spawnPython(python, ['--version'], 15_000);
|
|
188
|
+
if (version.status !== 0) {
|
|
189
|
+
add(checks, 'python', 'fail', `python failed (${version.error || version.stderr || `exit ${version.status}`}): ${python}`);
|
|
190
|
+
} else {
|
|
191
|
+
const fw = spawnPython(python, [
|
|
192
|
+
'-c',
|
|
193
|
+
'import faster_whisper; print(getattr(faster_whisper, "__version__", "ok"))',
|
|
194
|
+
]);
|
|
195
|
+
if (fw.status !== 0) {
|
|
196
|
+
add(
|
|
197
|
+
checks,
|
|
198
|
+
'faster-whisper',
|
|
199
|
+
'fail',
|
|
200
|
+
`faster-whisper import failed (${fw.error || fw.stderr || `exit ${fw.status}`})`
|
|
201
|
+
);
|
|
202
|
+
} else {
|
|
203
|
+
add(checks, 'faster-whisper', 'ok', `python ${python} (faster-whisper ${fw.stdout || 'ok'})`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const cuda = spawnPython(python, ['-c', 'import ctranslate2; print(ctranslate2.get_cuda_device_count())']);
|
|
207
|
+
const device = (config.whisper.device || 'cuda').toLowerCase();
|
|
208
|
+
if (cuda.status !== 0) {
|
|
209
|
+
add(
|
|
210
|
+
checks,
|
|
211
|
+
'device',
|
|
212
|
+
'warn',
|
|
213
|
+
`could not probe CUDA (${cuda.error || cuda.stderr || `exit ${cuda.status}`}); device=${device}`
|
|
214
|
+
);
|
|
215
|
+
} else {
|
|
216
|
+
const count = Number(cuda.stdout);
|
|
217
|
+
if (device === 'cpu') {
|
|
218
|
+
add(checks, 'device', 'warn', 'whisper device=cpu (slow; raw mode still works)');
|
|
219
|
+
} else if (!Number.isFinite(count) || count <= 0) {
|
|
220
|
+
add(
|
|
221
|
+
checks,
|
|
222
|
+
'device',
|
|
223
|
+
'warn',
|
|
224
|
+
`whisper device=${device} but 0 CUDA devices — set whisper.device=cpu or fix the GPU driver`
|
|
225
|
+
);
|
|
226
|
+
} else {
|
|
227
|
+
add(checks, 'device', 'ok', `whisper device=${device} (${count} CUDA device${count === 1 ? '' : 's'})`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const ffmpeg = spawnSync('ffmpeg', ['-version'], { encoding: 'utf8', timeout: 15_000, windowsHide: true });
|
|
234
|
+
if (ffmpeg.status === 0) {
|
|
235
|
+
const version = (ffmpeg.stdout || '').match(/ffmpeg version (\S+)/i)?.[1];
|
|
236
|
+
add(checks, 'ffmpeg', 'ok', version ? `ffmpeg ${version}` : 'ffmpeg on PATH');
|
|
237
|
+
} else if (config.audio.preprocess) {
|
|
238
|
+
add(checks, 'ffmpeg', 'fail', 'ffmpeg not on PATH (denoise will fail)');
|
|
239
|
+
} else {
|
|
240
|
+
add(checks, 'ffmpeg', 'warn', 'ffmpeg not on PATH (preprocess is off)');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const machine = config.ollanet.machine.trim();
|
|
244
|
+
const model = config.ollanet.cleanModel.trim();
|
|
245
|
+
const configured =
|
|
246
|
+
machine.length > 0 && !machine.startsWith('YOUR-') && model.length > 0 && !model.startsWith('YOUR-');
|
|
247
|
+
let ollanetReachable = false;
|
|
248
|
+
if (!configured) {
|
|
249
|
+
add(checks, 'ollanet', 'warn', 'ollanet machine/model not configured — cleanup skipped; raw transcripts still work');
|
|
250
|
+
} else {
|
|
251
|
+
const ping = await probeOllanet(machine, model);
|
|
252
|
+
ollanetReachable = ping.reachable;
|
|
253
|
+
add(checks, 'ollanet', ping.level, ping.message);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const journalPath = config.journal.index;
|
|
257
|
+
if (!fs.existsSync(journalPath)) {
|
|
258
|
+
add(checks, 'journal-index', 'warn', `journal index missing (${journalPath}); run pnpm journal:index`);
|
|
259
|
+
} else {
|
|
260
|
+
let opened = getJournalIndex();
|
|
261
|
+
let closeAfter = false;
|
|
262
|
+
try {
|
|
263
|
+
if (!opened) {
|
|
264
|
+
opened = openJournalIndex(journalPath);
|
|
265
|
+
closeAfter = true;
|
|
266
|
+
}
|
|
267
|
+
const stats = opened.stats();
|
|
268
|
+
const vec = opened.hasVec()
|
|
269
|
+
? `, ${stats.embedded} embedded${stats.embedModel ? ` (${stats.embedModel})` : ''}`
|
|
270
|
+
: ', FTS only';
|
|
271
|
+
add(
|
|
272
|
+
checks,
|
|
273
|
+
'journal-index',
|
|
274
|
+
stats.notes > 0 ? 'ok' : 'warn',
|
|
275
|
+
stats.notes > 0
|
|
276
|
+
? `${stats.notes} notes in SQLite${vec}`
|
|
277
|
+
: `journal index empty (${journalPath})`,
|
|
278
|
+
);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
281
|
+
add(checks, 'journal-index', 'fail', `journal index unreadable (${detail})`);
|
|
282
|
+
} finally {
|
|
283
|
+
if (closeAfter) opened?.close();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (config.http.tailscale) {
|
|
288
|
+
const self = discoverTailscale();
|
|
289
|
+
if (self) {
|
|
290
|
+
const remote = inboxUrls(config, self).filter((url) => !/localhost|127\.0\.0\.1/.test(url));
|
|
291
|
+
add(checks, 'tailscale', 'ok', `inbox ${remote.join(' ')}; API stays ${apiListenHost(config)}:${config.http.port}`);
|
|
292
|
+
} else {
|
|
293
|
+
add(checks, 'tailscale', 'warn', 'http.tailscale is on but the tailscale CLI did not return an IP; inbox stays on 127.0.0.1');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const bindHost = apiListenHost(config);
|
|
298
|
+
if (options.mode === 'doctor') {
|
|
299
|
+
const bind = await probePort(bindHost === '0.0.0.0' ? '127.0.0.1' : bindHost, config.http.port);
|
|
300
|
+
if (bind === 'free') {
|
|
301
|
+
add(checks, 'port', 'ok', `port ${bindHost}:${config.http.port} free`);
|
|
302
|
+
} else if (bind === 'in-use') {
|
|
303
|
+
add(checks, 'port', 'warn', `port ${bindHost}:${config.http.port} in use (API may already be running)`);
|
|
304
|
+
} else {
|
|
305
|
+
add(checks, 'port', 'warn', `could not probe port ${bindHost}:${config.http.port}`);
|
|
306
|
+
}
|
|
307
|
+
} else if (options.mode === 'live') {
|
|
308
|
+
add(checks, 'port', 'ok', `listening on ${bindHost}:${config.http.port}`);
|
|
309
|
+
const worker = getWhisperWorkerStatus();
|
|
310
|
+
add(
|
|
311
|
+
checks,
|
|
312
|
+
'whisper-worker',
|
|
313
|
+
worker === 'ready' || worker === 'starting' ? 'ok' : 'warn',
|
|
314
|
+
`whisper worker ${worker}`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const failed = checks.some((check) => check.level === 'fail');
|
|
319
|
+
const degraded = checks.some((check) => check.level === 'warn');
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
ok: !failed,
|
|
323
|
+
degraded,
|
|
324
|
+
node: process.version,
|
|
325
|
+
configPath,
|
|
326
|
+
whisper: {
|
|
327
|
+
model: resolveWhisperModel(),
|
|
328
|
+
device: config.whisper.device,
|
|
329
|
+
computeType: config.whisper.computeType,
|
|
330
|
+
python,
|
|
331
|
+
worker: getWhisperWorkerStatus(),
|
|
332
|
+
},
|
|
333
|
+
ollanet: {
|
|
334
|
+
machine,
|
|
335
|
+
model,
|
|
336
|
+
reachable: ollanetReachable,
|
|
337
|
+
},
|
|
338
|
+
http: { host: config.http.host, port: config.http.port },
|
|
339
|
+
checks,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export async function getHealthReport(
|
|
344
|
+
config: DictaConfig,
|
|
345
|
+
options: { mode: HealthMode; fresh?: boolean } = { mode: 'live' }
|
|
346
|
+
): Promise<HealthReport> {
|
|
347
|
+
if (
|
|
348
|
+
options.mode === 'live' &&
|
|
349
|
+
!options.fresh &&
|
|
350
|
+
liveCache &&
|
|
351
|
+
Date.now() - liveCache.at < LIVE_CACHE_MS
|
|
352
|
+
) {
|
|
353
|
+
return {
|
|
354
|
+
...liveCache.report,
|
|
355
|
+
whisper: { ...liveCache.report.whisper, worker: getWhisperWorkerStatus() },
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const report = await collectHealth(config, { mode: options.mode });
|
|
359
|
+
if (options.mode === 'live') liveCache = { at: Date.now(), report };
|
|
360
|
+
return report;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function printHealthReport(report: HealthReport, prefix = 'health'): void {
|
|
364
|
+
for (const check of report.checks) {
|
|
365
|
+
const tag = check.level === 'ok' ? 'ok ' : check.level === 'warn' ? 'WARN' : 'FAIL';
|
|
366
|
+
console.log(`[${prefix}] ${tag} ${check.message}`);
|
|
367
|
+
}
|
|
368
|
+
if (!report.ok) {
|
|
369
|
+
console.error(`[${prefix}] fix the failing items above, then retry`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (report.degraded) {
|
|
373
|
+
console.log(`[${prefix}] ready (degraded — raw mode still works)`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
console.log(`[${prefix}] ready`);
|
|
377
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export type InboxSort = 'recent' | 'oldest' | 'relevance' | '';
|
|
2
|
+
export type InboxMode = 'lex' | 'hybrid' | '';
|
|
3
|
+
|
|
4
|
+
export type InboxUrlState = {
|
|
5
|
+
q: string;
|
|
6
|
+
tags: string[];
|
|
7
|
+
year: string;
|
|
8
|
+
month: string;
|
|
9
|
+
since: string;
|
|
10
|
+
until: string;
|
|
11
|
+
sort: InboxSort;
|
|
12
|
+
mode: InboxMode;
|
|
13
|
+
unreadable: boolean;
|
|
14
|
+
starred: boolean;
|
|
15
|
+
file: string;
|
|
16
|
+
cue: number | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const SORTS = new Set(['recent', 'oldest', 'relevance']);
|
|
20
|
+
const MODES = new Set(['lex', 'hybrid']);
|
|
21
|
+
|
|
22
|
+
export function emptyInboxUrl(): InboxUrlState {
|
|
23
|
+
return {
|
|
24
|
+
q: '',
|
|
25
|
+
tags: [],
|
|
26
|
+
year: '',
|
|
27
|
+
month: '',
|
|
28
|
+
since: '',
|
|
29
|
+
until: '',
|
|
30
|
+
sort: '',
|
|
31
|
+
mode: '',
|
|
32
|
+
unreadable: false,
|
|
33
|
+
starred: false,
|
|
34
|
+
file: '',
|
|
35
|
+
cue: null,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseInboxUrl(search: string | URLSearchParams): InboxUrlState {
|
|
40
|
+
const params =
|
|
41
|
+
typeof search === 'string' ? new URLSearchParams(search.startsWith('?') ? search.slice(1) : search) : search;
|
|
42
|
+
const sortRaw = params.get('sort') || '';
|
|
43
|
+
const modeRaw = params.get('mode') || '';
|
|
44
|
+
const year = (params.get('year') || '').trim();
|
|
45
|
+
const monthRaw = (params.get('month') || '').trim();
|
|
46
|
+
const month = /^\d{1,2}$/.test(monthRaw) ? monthRaw.padStart(2, '0') : '';
|
|
47
|
+
return {
|
|
48
|
+
q: params.get('q') || '',
|
|
49
|
+
tags: params.getAll('tag').map((tag) => tag.trim()).filter(Boolean),
|
|
50
|
+
year: /^\d{4}$/.test(year) ? year : '',
|
|
51
|
+
month: year && month && Number(month) >= 1 && Number(month) <= 12 ? month : '',
|
|
52
|
+
since: (params.get('since') || '').trim(),
|
|
53
|
+
until: (params.get('until') || '').trim(),
|
|
54
|
+
sort: SORTS.has(sortRaw) ? (sortRaw as InboxSort) : '',
|
|
55
|
+
mode: MODES.has(modeRaw) ? (modeRaw as InboxMode) : '',
|
|
56
|
+
unreadable: params.get('unreadable') === '1',
|
|
57
|
+
starred: params.get('starred') === '1',
|
|
58
|
+
file: params.get('file') || '',
|
|
59
|
+
cue: null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseCueHash(hash: string): number | null {
|
|
64
|
+
const match = String(hash || '').match(/^#?cue-(\d+)$/);
|
|
65
|
+
if (!match) return null;
|
|
66
|
+
const cue = Number(match[1]);
|
|
67
|
+
return Number.isInteger(cue) && cue >= 0 ? cue : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function buildInboxSearch(state: InboxUrlState): string {
|
|
71
|
+
const params = new URLSearchParams();
|
|
72
|
+
if (state.q.trim()) params.set('q', state.q.trim());
|
|
73
|
+
for (const tag of state.tags) {
|
|
74
|
+
if (tag.trim()) params.append('tag', tag.trim());
|
|
75
|
+
}
|
|
76
|
+
if (state.year) params.set('year', state.year);
|
|
77
|
+
if (state.year && state.month) params.set('month', state.month);
|
|
78
|
+
if (state.since) params.set('since', state.since);
|
|
79
|
+
if (state.until) params.set('until', state.until);
|
|
80
|
+
if (state.sort) params.set('sort', state.sort);
|
|
81
|
+
if (state.mode) params.set('mode', state.mode);
|
|
82
|
+
if (state.unreadable) params.set('unreadable', '1');
|
|
83
|
+
if (state.starred) params.set('starred', '1');
|
|
84
|
+
if (state.file) params.set('file', state.file);
|
|
85
|
+
return params.toString();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function inboxPath(state: InboxUrlState): string {
|
|
89
|
+
const qs = buildInboxSearch(state);
|
|
90
|
+
const path = qs ? `/?${qs}` : '/';
|
|
91
|
+
if (state.file && state.cue != null && Number.isInteger(state.cue) && state.cue >= 0) {
|
|
92
|
+
return `${path}#cue-${state.cue}`;
|
|
93
|
+
}
|
|
94
|
+
return path;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isFilenameQuery(query: string): boolean {
|
|
98
|
+
const raw = String(query || '').replace(/^filename:\s*/i, '').trim();
|
|
99
|
+
if (!raw) return false;
|
|
100
|
+
if (/^filename:/i.test(query)) return true;
|
|
101
|
+
return /\d{4}[-_.]\d{2}[-_.]\d{2}/.test(raw) || /\.(mp3|m4a|wav|webm|ogg|json)$/i.test(raw);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function filenameNeedle(query: string): string {
|
|
105
|
+
return String(query || '')
|
|
106
|
+
.replace(/^filename:\s*/i, '')
|
|
107
|
+
.trim()
|
|
108
|
+
.replace(/\.(mp3|m4a|wav|webm|ogg|json)$/i, '');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function tightenFilenameHits<T extends { basename?: string; jsonFile?: string }>(
|
|
112
|
+
query: string,
|
|
113
|
+
hits: T[],
|
|
114
|
+
): T[] {
|
|
115
|
+
if (!isFilenameQuery(query) || !hits.length) return hits;
|
|
116
|
+
const needle = filenameNeedle(query);
|
|
117
|
+
if (!needle) return hits;
|
|
118
|
+
const matched = hits.filter((hit) => {
|
|
119
|
+
const name = `${hit.basename || ''} ${hit.jsonFile || ''}`.replace(/\\/g, '/');
|
|
120
|
+
return name.includes(needle);
|
|
121
|
+
});
|
|
122
|
+
return matched.length ? matched : hits;
|
|
123
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { listTargets, ollamaBaseUrl, ollamaTags } from 'ollanet';
|
|
2
|
+
import { config } from '../config.ts';
|
|
3
|
+
|
|
4
|
+
const EMBED_NAME = /embed|nomic|mxbai|bge|e5|minilm|arctic/i;
|
|
5
|
+
|
|
6
|
+
export type EmbedClient = {
|
|
7
|
+
model: string;
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
host: string;
|
|
10
|
+
embed(text: string): Promise<number[]>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type EmbedCandidate = {
|
|
14
|
+
host: string;
|
|
15
|
+
baseUrl: string;
|
|
16
|
+
names: string[];
|
|
17
|
+
local: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function modelMatchesPreferred(name: string, preferred: string): boolean {
|
|
21
|
+
const want = preferred.trim();
|
|
22
|
+
if (!want) return false;
|
|
23
|
+
return name === want || name.startsWith(`${want}:`) || name.startsWith(`${want}-`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function pickEmbedName(names: string[], preferred = ''): string | null {
|
|
27
|
+
const want = preferred.trim();
|
|
28
|
+
if (want) {
|
|
29
|
+
const hit = names.find((name) => modelMatchesPreferred(name, want));
|
|
30
|
+
if (hit) return hit;
|
|
31
|
+
}
|
|
32
|
+
return names.find((name) => EMBED_NAME.test(name)) || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Prefer a local box, then any ollanet host that has an embed-class model. */
|
|
36
|
+
export function pickEmbedTarget(candidates: EmbedCandidate[], preferred = ''): EmbedCandidate & { model: string } | null {
|
|
37
|
+
const ranked = [...candidates].sort((a, b) => Number(b.local) - Number(a.local));
|
|
38
|
+
if (preferred.trim()) {
|
|
39
|
+
for (const row of ranked) {
|
|
40
|
+
const model = pickEmbedName(row.names, preferred);
|
|
41
|
+
if (model && modelMatchesPreferred(model, preferred)) return { ...row, model };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const row of ranked) {
|
|
45
|
+
const model = pickEmbedName(row.names, preferred);
|
|
46
|
+
if (model) return { ...row, model };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function createEmbedClient(preferredModel = config.journal.embedModel): Promise<EmbedClient | null> {
|
|
52
|
+
let targets: Awaited<ReturnType<typeof listTargets>> = [];
|
|
53
|
+
try {
|
|
54
|
+
targets = await listTargets();
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const ordered = [...targets].sort(
|
|
59
|
+
(a, b) => Number(b.isSelf || b.source === 'localhost') - Number(a.isSelf || a.source === 'localhost'),
|
|
60
|
+
);
|
|
61
|
+
const candidates: EmbedCandidate[] = [];
|
|
62
|
+
const want = preferredModel.trim();
|
|
63
|
+
for (const host of ordered) {
|
|
64
|
+
const baseUrl = ollamaBaseUrl(host);
|
|
65
|
+
try {
|
|
66
|
+
const tags = await ollamaTags(baseUrl, 8_000);
|
|
67
|
+
candidates.push({
|
|
68
|
+
host: host.hostname || host.dnsName || host.ip,
|
|
69
|
+
baseUrl,
|
|
70
|
+
names: tags.map((tag: { name: string }) => tag.name),
|
|
71
|
+
local: Boolean(host.isSelf || host.source === 'localhost'),
|
|
72
|
+
});
|
|
73
|
+
const pickedSoFar = pickEmbedTarget(candidates, preferredModel);
|
|
74
|
+
if (!pickedSoFar) continue;
|
|
75
|
+
if (want && !modelMatchesPreferred(pickedSoFar.model, want)) continue;
|
|
76
|
+
if (pickedSoFar.local || !want) {
|
|
77
|
+
return {
|
|
78
|
+
model: pickedSoFar.model,
|
|
79
|
+
baseUrl: pickedSoFar.baseUrl,
|
|
80
|
+
host: pickedSoFar.host,
|
|
81
|
+
async embed(text: string) {
|
|
82
|
+
return embedWithOllama(pickedSoFar.baseUrl, pickedSoFar.model, text);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// host offline or not serving Ollama
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const picked = pickEmbedTarget(candidates, preferredModel);
|
|
91
|
+
if (!picked) return null;
|
|
92
|
+
return {
|
|
93
|
+
model: picked.model,
|
|
94
|
+
baseUrl: picked.baseUrl,
|
|
95
|
+
host: picked.host,
|
|
96
|
+
async embed(text: string) {
|
|
97
|
+
return embedWithOllama(picked.baseUrl, picked.model, text);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function embedWithOllama(baseUrl: string, model: string, text: string): Promise<number[]> {
|
|
103
|
+
const clipped = text.slice(0, 8000);
|
|
104
|
+
const modern = await fetch(`${baseUrl.replace(/\/$/, '')}/api/embed`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { 'Content-Type': 'application/json' },
|
|
107
|
+
body: JSON.stringify({ model, input: clipped }),
|
|
108
|
+
});
|
|
109
|
+
if (modern.ok) {
|
|
110
|
+
const json = (await modern.json()) as { embeddings?: number[][]; embedding?: number[] };
|
|
111
|
+
const vector = json.embeddings?.[0] || json.embedding;
|
|
112
|
+
if (Array.isArray(vector) && vector.length) return vector;
|
|
113
|
+
}
|
|
114
|
+
const legacy = await fetch(`${baseUrl.replace(/\/$/, '')}/api/embeddings`, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
body: JSON.stringify({ model, prompt: clipped }),
|
|
118
|
+
});
|
|
119
|
+
if (!legacy.ok) {
|
|
120
|
+
throw new Error(`ollama embed ${legacy.status} ${await legacy.text().catch(() => '')}`);
|
|
121
|
+
}
|
|
122
|
+
const json = (await legacy.json()) as { embedding?: number[] };
|
|
123
|
+
if (!Array.isArray(json.embedding) || !json.embedding.length) {
|
|
124
|
+
throw new Error('ollama embed returned no vector');
|
|
125
|
+
}
|
|
126
|
+
return json.embedding;
|
|
127
|
+
}
|