@tiens.nguyen/gonext-local-worker 1.0.39 → 1.0.40

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.
@@ -7,10 +7,10 @@
7
7
  * - `gonext-local-worker simulate-chat [text]` — claim next chat job, push fake reply like the real worker (needs GONEXT_* env)
8
8
  * - `gonext-local-worker` — starts polling loop (claims jobs and runs models)
9
9
  */
10
- import { mkdir, readFile, writeFile } from "node:fs/promises";
10
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
11
11
  import { execFile as execFileCallback } from "node:child_process";
12
- import { homedir, platform } from "node:os";
13
- import { join } from "node:path";
12
+ import { homedir, platform, tmpdir } from "node:os";
13
+ import { extname, join } from "node:path";
14
14
  import { promisify } from "node:util";
15
15
  import dotenv from "dotenv";
16
16
  import OpenAI from "openai";
@@ -43,6 +43,7 @@ Examples:
43
43
  Env (optional):
44
44
  GONEXT_SIMULATE_TEXT default body for simulate-chat when no args
45
45
  GONEXT_MLX_LM_PYTHON Python executable for MLX LM native probe (default: python3)
46
+ GONEXT_OCR_MODEL_PATH Local GLM-OCR-bf16 directory (default: ~/mlx-models/GLM-OCR-bf16)
46
47
  `);
47
48
  }
48
49
 
@@ -101,6 +102,10 @@ const pollMs = 500;
101
102
  const localHealthConcurrency = 4;
102
103
 
103
104
  const CHUNK_PATH = "/api/worker/job-chunk";
105
+ const OCR_PROMPT =
106
+ "Extract all readable text from this image. Return plain text only and preserve line breaks.";
107
+ const OCR_MAX_TOKENS = 2048;
108
+ const OCR_TIMEOUT_MS = 180_000;
104
109
 
105
110
  async function workerFetch(path, init = {}) {
106
111
  const url = `${apiBase}${path.startsWith("/") ? path : `/${path}`}`;
@@ -181,18 +186,27 @@ if (args[0] === "simulate-chat") {
181
186
  }
182
187
  const isLocalHealth =
183
188
  job.jobType === "local_health" || job.modelKey === "local_health";
184
- if (isLocalHealth) {
189
+ const isOcrJob =
190
+ job.jobType === "ocr" ||
191
+ (job.payload &&
192
+ typeof job.payload === "object" &&
193
+ typeof job.payload.ocrId === "string");
194
+ if (isLocalHealth || isOcrJob) {
185
195
  await workerFetch(`/api/worker/jobs/${jobId}`, {
186
196
  method: "PATCH",
187
197
  body: JSON.stringify({
188
198
  jobStatus: "failed",
189
199
  errorMessage:
190
- "simulate-chat: claimed a local_health job. Mark failed so you can retry. Queue a chat message (not Settings refresh) and stop the normal worker before simulate-chat.",
200
+ isLocalHealth
201
+ ? "simulate-chat: claimed a local_health job. Mark failed so you can retry. Queue a chat message (not Settings refresh) and stop the normal worker before simulate-chat."
202
+ : "simulate-chat: claimed an OCR job. Mark failed so you can retry. Queue a chat message and stop the normal worker before simulate-chat.",
191
203
  totalTimeSeconds: 0,
192
204
  }),
193
205
  });
194
206
  console.error(
195
- "[gonext-worker] simulate-chat: claimed local_health instead of chat. Job marked failed. Retry with only a pending chat job."
207
+ isLocalHealth
208
+ ? "[gonext-worker] simulate-chat: claimed local_health instead of chat. Job marked failed. Retry with only a pending chat job."
209
+ : "[gonext-worker] simulate-chat: claimed OCR instead of chat. Job marked failed. Retry with only a pending chat job."
196
210
  );
197
211
  process.exit(1);
198
212
  }
@@ -490,6 +504,154 @@ async function runChatJob(job) {
490
504
  }
491
505
  }
492
506
 
507
+ function resolveImageExtension(mimeType, fileName) {
508
+ const byMime = {
509
+ "image/png": ".png",
510
+ "image/jpeg": ".jpg",
511
+ "image/jpg": ".jpg",
512
+ "image/webp": ".webp",
513
+ "image/gif": ".gif",
514
+ "image/bmp": ".bmp",
515
+ "image/tiff": ".tiff",
516
+ "image/heic": ".heic",
517
+ "image/heif": ".heif",
518
+ };
519
+ const byMimeExt = byMime[String(mimeType ?? "").toLowerCase()];
520
+ if (byMimeExt) return byMimeExt;
521
+ const ext = fileName ? extname(String(fileName)).toLowerCase() : "";
522
+ return ext || ".png";
523
+ }
524
+
525
+ function resolveOcrModelPath() {
526
+ const raw = String(process.env.GONEXT_OCR_MODEL_PATH ?? "").trim();
527
+ if (raw) {
528
+ return raw.replace(/^~(?=\/)/, homedir());
529
+ }
530
+ return join(homedir(), "mlx-models", "GLM-OCR-bf16");
531
+ }
532
+
533
+ function normalizeOcrOutput(output) {
534
+ const lines = String(output ?? "")
535
+ .split(/\r?\n/)
536
+ .map((line) => line.trim())
537
+ .filter((line) => line.length > 0)
538
+ .filter((line) => !line.startsWith("<frozen runpy>:"))
539
+ .filter(
540
+ (line) =>
541
+ !line.startsWith("Files:") &&
542
+ !line.startsWith("Prompt:") &&
543
+ !line.startsWith("Generation:") &&
544
+ !line.startsWith("Peak memory:") &&
545
+ !line.startsWith("=======") &&
546
+ line !== "<think>" &&
547
+ line !== "</think>" &&
548
+ line !== "<think></think>" &&
549
+ line !== "No text generated for this prompt"
550
+ );
551
+ return lines.join("\n").trim();
552
+ }
553
+
554
+ async function runOcrJob(job) {
555
+ const { jobId, payload } = job;
556
+ const start = Date.now();
557
+ const runRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
558
+ method: "PATCH",
559
+ body: JSON.stringify({ jobStatus: "running" }),
560
+ });
561
+ await ensureWorkerOk(runRes, `mark running OCR jobId=${jobId}`);
562
+ try {
563
+ if (!payload || typeof payload !== "object") {
564
+ throw new Error("Invalid OCR payload.");
565
+ }
566
+ const attachment = payload.attachment;
567
+ const mimeType = typeof attachment?.mimeType === "string" ? attachment.mimeType : "";
568
+ const data = typeof attachment?.data === "string" ? attachment.data : "";
569
+ const name = typeof attachment?.name === "string" ? attachment.name : "";
570
+ if (!mimeType.startsWith("image/")) {
571
+ throw new Error("OCR job attachment must be an image.");
572
+ }
573
+ if (!data) {
574
+ throw new Error("OCR job attachment is empty.");
575
+ }
576
+ const bytes = Buffer.from(data, "base64");
577
+ if (!bytes.length) {
578
+ throw new Error("OCR job attachment data is not valid base64.");
579
+ }
580
+
581
+ const tempDir = await mkdtemp(join(tmpdir(), "gonext-ocr-worker-"));
582
+ let extractedText = "";
583
+ try {
584
+ const imagePath = join(
585
+ tempDir,
586
+ `input${resolveImageExtension(mimeType, name)}`
587
+ );
588
+ await writeFile(imagePath, bytes);
589
+ const modelPath = resolveOcrModelPath();
590
+ const { stdout } = await execFile(
591
+ "python3",
592
+ [
593
+ "-m",
594
+ "mlx_vlm",
595
+ "generate",
596
+ "--model",
597
+ modelPath,
598
+ "--prompt",
599
+ OCR_PROMPT,
600
+ "--image",
601
+ imagePath,
602
+ "--temperature",
603
+ "0.0",
604
+ "--max-tokens",
605
+ String(OCR_MAX_TOKENS),
606
+ ],
607
+ {
608
+ timeout: OCR_TIMEOUT_MS,
609
+ maxBuffer: 10 * 1024 * 1024,
610
+ }
611
+ );
612
+ extractedText = normalizeOcrOutput(stdout);
613
+ } finally {
614
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
615
+ }
616
+
617
+ if (!extractedText) {
618
+ throw new Error("OCR returned empty text for this image.");
619
+ }
620
+ const totalTimeSeconds = (Date.now() - start) / 1000;
621
+ const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
622
+ method: "PATCH",
623
+ body: JSON.stringify({
624
+ jobStatus: "completed",
625
+ resultText: extractedText,
626
+ tokenCount: Math.max(1, Math.ceil(extractedText.length / 4)),
627
+ totalTimeSeconds,
628
+ }),
629
+ });
630
+ await ensureWorkerOk(doneRes, `complete OCR jobId=${jobId}`);
631
+ console.log(
632
+ `[gonext-worker] completed OCR ${jobId} (${totalTimeSeconds.toFixed(1)}s)`
633
+ );
634
+ } catch (e) {
635
+ const message = e instanceof Error ? e.message : String(e);
636
+ const failRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
637
+ method: "PATCH",
638
+ body: JSON.stringify({
639
+ jobStatus: "failed",
640
+ errorMessage: message,
641
+ totalTimeSeconds: (Date.now() - start) / 1000,
642
+ }),
643
+ });
644
+ if (!failRes.ok) {
645
+ const snippet = (await failRes.text().catch(() => "")).trim().slice(0, 500);
646
+ console.error(
647
+ `[gonext-worker] OCR fail PATCH also failed ${failRes.status} jobId=${jobId}` +
648
+ (snippet ? ` response=${snippet}` : "")
649
+ );
650
+ }
651
+ console.error(`[gonext-worker] failed OCR ${jobId}:`, message);
652
+ }
653
+ }
654
+
493
655
  function normalizeBaseUrl(raw) {
494
656
  return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
495
657
  }
@@ -794,6 +956,16 @@ async function pollOnce() {
794
956
  );
795
957
  continue;
796
958
  }
959
+ const isOcrByType = job.jobType === "ocr";
960
+ const isOcrByPayload =
961
+ job.payload &&
962
+ typeof job.payload === "object" &&
963
+ typeof job.payload.ocrId === "string" &&
964
+ typeof job.payload.attachment?.data === "string";
965
+ if (isOcrByType || isOcrByPayload) {
966
+ await runOcrJob(job);
967
+ return;
968
+ }
797
969
  await runChatJob(job);
798
970
  return;
799
971
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.39",
3
+ "version": "1.0.40",
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",