@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.
- package/gonext-local-worker.mjs +232 -0
- package/gonext_agent_chat.py +18 -1
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -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 &&
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|