@tiens.nguyen/gonext-local-worker 1.0.98 → 1.0.100

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.
@@ -1309,6 +1309,171 @@ async function runOcrJob(job) {
1309
1309
  }
1310
1310
  }
1311
1311
 
1312
+ const TRANSCRIBE_TIMEOUT_MS =
1313
+ Number(process.env.GONEXT_TRANSCRIBE_TIMEOUT_MS ?? "300000") || 300000;
1314
+
1315
+ function resolveWorkerPython() {
1316
+ return (
1317
+ (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim() ||
1318
+ "python3"
1319
+ );
1320
+ }
1321
+
1322
+ /** Spawn a Python script, write `input` to stdin, capture stdout/stderr. */
1323
+ function runPythonCapture(python, args, input, timeoutMs) {
1324
+ return new Promise((resolve) => {
1325
+ const child = spawn(python, args, { stdio: ["pipe", "pipe", "pipe"] });
1326
+ let stdout = "";
1327
+ let stderr = "";
1328
+ let settled = false;
1329
+ const timer = setTimeout(() => {
1330
+ if (settled) return;
1331
+ settled = true;
1332
+ try {
1333
+ child.kill("SIGKILL");
1334
+ } catch {
1335
+ /* ignore */
1336
+ }
1337
+ resolve({ code: null, stdout, stderr, timedOut: true });
1338
+ }, timeoutMs);
1339
+ child.stdout.on("data", (d) => {
1340
+ stdout += d.toString();
1341
+ });
1342
+ child.stderr.on("data", (d) => {
1343
+ stderr += d.toString();
1344
+ });
1345
+ child.on("error", (e) => {
1346
+ if (settled) return;
1347
+ settled = true;
1348
+ clearTimeout(timer);
1349
+ resolve({ code: null, stdout, stderr: stderr + String(e), timedOut: false });
1350
+ });
1351
+ child.on("close", (code) => {
1352
+ if (settled) return;
1353
+ settled = true;
1354
+ clearTimeout(timer);
1355
+ resolve({ code, stdout, stderr, timedOut: false });
1356
+ });
1357
+ try {
1358
+ child.stdin.write(input);
1359
+ child.stdin.end();
1360
+ } catch {
1361
+ /* the error/close handler will settle */
1362
+ }
1363
+ });
1364
+ }
1365
+
1366
+ async function runTranscribeJob(job) {
1367
+ const { jobId, payload } = job;
1368
+ const start = Date.now();
1369
+ const runRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
1370
+ method: "PATCH",
1371
+ body: JSON.stringify({ jobStatus: "running" }),
1372
+ });
1373
+ await ensureWorkerOk(runRes, `mark running transcribe jobId=${jobId}`);
1374
+ const tempDir = await mkdtemp(join(tmpdir(), "gonext-transcribe-"));
1375
+ try {
1376
+ if (!payload || typeof payload !== "object") {
1377
+ throw new Error("Invalid transcribe payload.");
1378
+ }
1379
+ const audio = payload.audio;
1380
+ const mimeType = typeof audio?.mimeType === "string" ? audio.mimeType : "";
1381
+ const s3Object = audio && typeof audio === "object" ? audio.s3Object : undefined;
1382
+ const s3GetUrl =
1383
+ s3Object && typeof s3Object.getUrl === "string" ? s3Object.getUrl.trim() : "";
1384
+ const model =
1385
+ typeof payload.model === "string" && payload.model.trim()
1386
+ ? payload.model.trim()
1387
+ : "whisper-large-v3-turbo";
1388
+ const language =
1389
+ typeof payload.language === "string" ? payload.language.trim() : "";
1390
+ if (!mimeType.startsWith("audio/")) {
1391
+ throw new Error("Transcribe job attachment must be audio.");
1392
+ }
1393
+ if (!s3GetUrl) {
1394
+ throw new Error("Transcribe job is missing the audio download URL.");
1395
+ }
1396
+ const dlRes = await fetch(s3GetUrl, { method: "GET" });
1397
+ if (!dlRes.ok) {
1398
+ throw new Error(`Failed to download audio from S3 (${dlRes.status}).`);
1399
+ }
1400
+ const bytes = Buffer.from(await dlRes.arrayBuffer());
1401
+ if (!bytes.length) {
1402
+ throw new Error("Downloaded audio is empty.");
1403
+ }
1404
+ const audioPath = join(tempDir, "input.wav");
1405
+ await writeFile(audioPath, bytes);
1406
+
1407
+ const python = resolveWorkerPython();
1408
+ const scriptPath = join(WORKER_DIR, "gonext_transcribe.py");
1409
+ const { code, stdout, stderr, timedOut } = await runPythonCapture(
1410
+ python,
1411
+ [scriptPath],
1412
+ JSON.stringify({ audioPath, model, language: language || undefined }),
1413
+ TRANSCRIBE_TIMEOUT_MS
1414
+ );
1415
+ if (timedOut) {
1416
+ throw new Error("Transcription timed out.");
1417
+ }
1418
+ if (code !== 0) {
1419
+ const reason = String(stderr ?? "").split("\n")[0]?.trim() || "error";
1420
+ const hint =
1421
+ reason === "lib-missing"
1422
+ ? "Whisper library not installed. Run: pip install mlx-whisper"
1423
+ : reason === "model-missing"
1424
+ ? `Whisper model not downloaded. Run: hf download mlx-community/${model}`
1425
+ : reason === "audio-error"
1426
+ ? "Could not decode the recorded audio."
1427
+ : "Transcription failed.";
1428
+ throw new Error(`${hint} (${compactPreview(stderr)})`);
1429
+ }
1430
+ let text = "";
1431
+ try {
1432
+ const parsed = JSON.parse(String(stdout ?? "").trim() || "{}");
1433
+ text = typeof parsed.text === "string" ? parsed.text.trim() : "";
1434
+ } catch {
1435
+ throw new Error(`Transcription returned malformed output: ${compactPreview(stdout)}`);
1436
+ }
1437
+ if (!text) {
1438
+ throw new Error("Transcription returned empty text.");
1439
+ }
1440
+ const totalTimeSeconds = (Date.now() - start) / 1000;
1441
+ const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
1442
+ method: "PATCH",
1443
+ body: JSON.stringify({
1444
+ jobStatus: "completed",
1445
+ resultText: text,
1446
+ tokenCount: Math.max(1, Math.ceil(text.length / 4)),
1447
+ totalTimeSeconds,
1448
+ }),
1449
+ });
1450
+ await ensureWorkerOk(doneRes, `complete transcribe jobId=${jobId}`);
1451
+ console.log(
1452
+ `[gonext-worker] completed transcribe ${jobId} (${totalTimeSeconds.toFixed(1)}s) model=${model}`
1453
+ );
1454
+ } catch (e) {
1455
+ const message = e instanceof Error ? e.message : String(e);
1456
+ const failRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
1457
+ method: "PATCH",
1458
+ body: JSON.stringify({
1459
+ jobStatus: "failed",
1460
+ errorMessage: message,
1461
+ totalTimeSeconds: (Date.now() - start) / 1000,
1462
+ }),
1463
+ });
1464
+ if (!failRes.ok) {
1465
+ const snippet = (await failRes.text().catch(() => "")).trim().slice(0, 500);
1466
+ console.error(
1467
+ `[gonext-worker] transcribe fail PATCH also failed ${failRes.status} jobId=${jobId}` +
1468
+ (snippet ? ` response=${snippet}` : "")
1469
+ );
1470
+ }
1471
+ console.error(`[gonext-worker] failed transcribe ${jobId}:`, message);
1472
+ } finally {
1473
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
1474
+ }
1475
+ }
1476
+
1312
1477
  async function runAgentChatJob(job) {
1313
1478
  const { jobId, payload } = job;
1314
1479
  const start = Date.now();
@@ -1618,6 +1783,55 @@ async function checkMlxLmNativeImport() {
1618
1783
  };
1619
1784
  }
1620
1785
 
1786
+ // Probe whether voice transcription is ready (Audio support, task #21):
1787
+ // mlx-whisper importable AND the requested model present in the HF hub cache
1788
+ // (~/.cache/huggingface/hub, honoring HF_HOME/HF_HUB_CACHE). Returns
1789
+ // { available, model, reason } where reason is "ok" | "lib-missing" | "model-missing".
1790
+ async function checkWhisperAvailability(model) {
1791
+ const preferred =
1792
+ (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim() ||
1793
+ "python3";
1794
+ const repoName = `models--mlx-community--${model}`;
1795
+ const code = [
1796
+ "import os, sys, json, glob",
1797
+ "model = sys.argv[1] if len(sys.argv) > 1 else 'whisper-large-v3-turbo'",
1798
+ "try:",
1799
+ " import mlx_whisper # noqa: F401",
1800
+ "except Exception:",
1801
+ " print(json.dumps({'available': False, 'reason': 'lib-missing'})); sys.exit(0)",
1802
+ "home = os.environ.get('HF_HOME') or os.path.join(os.path.expanduser('~'), '.cache', 'huggingface')",
1803
+ "hub = os.environ.get('HF_HUB_CACHE') or os.path.join(home, 'hub')",
1804
+ `repo = os.path.join(hub, ${JSON.stringify(repoName)})`,
1805
+ "snaps = glob.glob(os.path.join(repo, 'snapshots', '*'))",
1806
+ "ok = False",
1807
+ "for s in snaps:",
1808
+ " if glob.glob(os.path.join(s, '*.safetensors')) or glob.glob(os.path.join(s, '*.npz')) or os.path.exists(os.path.join(s, 'weights.npz')):",
1809
+ " ok = True; break",
1810
+ "print(json.dumps({'available': ok, 'reason': 'ok' if ok else 'model-missing', 'cache': hub}))",
1811
+ ].join("\n");
1812
+ const candidates = [preferred];
1813
+ if (preferred === "python3") candidates.push("python");
1814
+ for (const exe of [...new Set(candidates)]) {
1815
+ try {
1816
+ const { stdout } = await execFile(exe, ["-c", code, model], {
1817
+ timeout: 15000,
1818
+ maxBuffer: 65536,
1819
+ windowsHide: true,
1820
+ });
1821
+ const parsed = JSON.parse(String(stdout ?? "").trim() || "{}");
1822
+ return {
1823
+ available: parsed.available === true,
1824
+ model,
1825
+ reason:
1826
+ typeof parsed.reason === "string" ? parsed.reason : "model-missing",
1827
+ };
1828
+ } catch {
1829
+ /* try next candidate */
1830
+ }
1831
+ }
1832
+ return { available: false, model, reason: "lib-missing" };
1833
+ }
1834
+
1621
1835
  async function runLocalHealthJob(job) {
1622
1836
  const { jobId, payload } = job;
1623
1837
  const start = Date.now();
@@ -1746,6 +1960,19 @@ async function runLocalHealthJob(job) {
1746
1960
  modelPathStatus.push(check);
1747
1961
  }
1748
1962
 
1963
+ const whisperModel =
1964
+ typeof payload?.voiceModel === "string" && payload.voiceModel.trim()
1965
+ ? payload.voiceModel.trim()
1966
+ : "whisper-large-v3-turbo";
1967
+ let whisper = null;
1968
+ if (platform() === "darwin") {
1969
+ const tW = Date.now();
1970
+ whisper = await checkWhisperAvailability(whisperModel);
1971
+ console.log(
1972
+ `[gonext-worker] local_health ${jobId} whisper available=${whisper.available} reason=${whisper.reason} took=${((Date.now() - tW) / 1000).toFixed(2)}s`
1973
+ );
1974
+ }
1975
+
1749
1976
  let mlx = null;
1750
1977
  if (mlxRoot || mlxNative?.available) {
1751
1978
  const httpOk = Boolean(mlxHttp?.online && (mlxHttp?.models?.length ?? 0) > 0);
@@ -1800,6 +2027,7 @@ async function runLocalHealthJob(job) {
1800
2027
  }
1801
2028
  : undefined,
1802
2029
  mlx,
2030
+ whisper: whisper ?? undefined,
1803
2031
  setup:
1804
2032
  modelPathStatus.length > 0
1805
2033
  ? {
@@ -1905,6 +2133,10 @@ async function pollOnce() {
1905
2133
  await runAgentChatJob(job);
1906
2134
  return;
1907
2135
  }
2136
+ if (job.jobType === "transcribe") {
2137
+ await runTranscribeJob(job);
2138
+ return;
2139
+ }
1908
2140
  const isOcrByType = job.jobType === "ocr";
1909
2141
  const isOcrByPayload =
1910
2142
  job.payload &&
@@ -368,6 +368,16 @@ _PDF_INSTALL_HINT = (
368
368
  "then restart the worker. (See the Instructions page in the web app.)"
369
369
  )
370
370
 
371
+ # WeasyPrint IS installed, but its system libraries (pango/cairo/gobject) couldn't be
372
+ # dlopen'd — almost always because the worker process predates the build that injects
373
+ # the Homebrew library path. A worker restart fixes it.
374
+ _PDF_LIBS_HINT = (
375
+ "PDF engine is installed but its system libraries could not be loaded. "
376
+ "Please RESTART the local worker — it automatically points WeasyPrint at the "
377
+ "Homebrew libraries (/opt/homebrew/lib on Apple Silicon, /usr/local/lib on Intel). "
378
+ "If it still fails, confirm `brew install pango libffi` succeeded."
379
+ )
380
+
371
381
  # Font stack with color-emoji fallbacks last, so WeasyPrint resolves emoji/flag
372
382
  # codepoints (🏆 🇿🇦) to a color font per-glyph while text uses a clean sans-serif.
373
383
  _PDF_FONT_STACK = (
@@ -385,9 +395,16 @@ def _render_pdf_bytes(markdown_text: str, title: str = "") -> bytes:
385
395
  """
386
396
  try:
387
397
  import markdown as _md
398
+ except Exception: # noqa: BLE001 — markdown pip pkg missing
399
+ raise RuntimeError(_PDF_INSTALL_HINT)
400
+ try:
388
401
  from weasyprint import HTML as _HTML # pulls pango/cairo via cffi at import
389
- except Exception: # noqa: BLE001 — missing python pkg OR system libs (cffi OSError)
402
+ except ModuleNotFoundError:
403
+ # WeasyPrint itself isn't pip-installed.
390
404
  raise RuntimeError(_PDF_INSTALL_HINT)
405
+ except Exception as e: # noqa: BLE001 — installed but cffi can't dlopen the system libs
406
+ _log(f"weasyprint lib load failed: {type(e).__name__}: {str(e)[:200]}")
407
+ raise RuntimeError(_PDF_LIBS_HINT)
391
408
 
392
409
  body_html = _md.markdown(
393
410
  markdown_text or "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.98",
3
+ "version": "1.0.100",
4
4
  "description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
5
5
  "type": "module",
6
6
  "license": "MIT",