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

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,195 @@ 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
+ let text = String(output ?? "").replace(/\r\n/g, "\n");
535
+ const afterAssistant = text.includes("<|assistant|>")
536
+ ? text.split("<|assistant|>").pop()
537
+ : "";
538
+ if (afterAssistant && afterAssistant.trim()) {
539
+ text = afterAssistant;
540
+ }
541
+ const escapedPrompt = OCR_PROMPT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
542
+ text = text
543
+ .replace(/<\|[^|>]+\|>/g, " ")
544
+ .replace(/<think>[\s\S]*?<\/think>/gi, " ")
545
+ .replace(/<think>|<\/think>/gi, " ")
546
+ .replace(new RegExp(escapedPrompt, "gi"), " ")
547
+ .replace(/(?:^|[\s/\\])nothink\b/gi, " ")
548
+ .replace(/\\n/g, "\n");
549
+ const lines = text
550
+ .split(/\r?\n/)
551
+ .map((line) => line.trim())
552
+ .filter((line) => line.length > 0)
553
+ .filter((line) => !line.startsWith("<frozen runpy>:"))
554
+ .filter(
555
+ (line) =>
556
+ !line.startsWith("Files:") &&
557
+ !line.startsWith("Prompt:") &&
558
+ !line.startsWith("Generation:") &&
559
+ !line.startsWith("Calling `python -m mlx_vlm.generate") &&
560
+ !line.startsWith("Peak memory:") &&
561
+ !line.startsWith("=======") &&
562
+ line !== "<think>" &&
563
+ line !== "</think>" &&
564
+ line !== "<think></think>" &&
565
+ line !== "No text generated for this prompt"
566
+ );
567
+ return lines.join("\n").trim();
568
+ }
569
+
570
+ async function runMlxVlmGenerate(modelPath, imagePath) {
571
+ const sharedArgs = [
572
+ "--model",
573
+ modelPath,
574
+ "--prompt",
575
+ OCR_PROMPT,
576
+ "--image",
577
+ imagePath,
578
+ "--temperature",
579
+ "0.0",
580
+ "--max-tokens",
581
+ String(OCR_MAX_TOKENS),
582
+ ];
583
+ try {
584
+ return await execFile(
585
+ "python3",
586
+ ["-m", "mlx_vlm.generate", ...sharedArgs],
587
+ {
588
+ timeout: OCR_TIMEOUT_MS,
589
+ maxBuffer: 10 * 1024 * 1024,
590
+ }
591
+ );
592
+ } catch (primaryError) {
593
+ const stderr =
594
+ primaryError && typeof primaryError === "object" && "stderr" in primaryError
595
+ ? String(primaryError.stderr ?? "").toLowerCase()
596
+ : "";
597
+ const message =
598
+ primaryError instanceof Error ? primaryError.message.toLowerCase() : "";
599
+ const missingLegacyModule =
600
+ stderr.includes("no module named mlx_vlm.generate") ||
601
+ message.includes("no module named mlx_vlm.generate");
602
+ if (!missingLegacyModule) {
603
+ throw primaryError;
604
+ }
605
+ return execFile(
606
+ "python3",
607
+ ["-m", "mlx_vlm", "generate", ...sharedArgs],
608
+ {
609
+ timeout: OCR_TIMEOUT_MS,
610
+ maxBuffer: 10 * 1024 * 1024,
611
+ }
612
+ );
613
+ }
614
+ }
615
+
616
+ async function runOcrJob(job) {
617
+ const { jobId, payload } = job;
618
+ const start = Date.now();
619
+ const runRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
620
+ method: "PATCH",
621
+ body: JSON.stringify({ jobStatus: "running" }),
622
+ });
623
+ await ensureWorkerOk(runRes, `mark running OCR jobId=${jobId}`);
624
+ try {
625
+ if (!payload || typeof payload !== "object") {
626
+ throw new Error("Invalid OCR payload.");
627
+ }
628
+ const attachment = payload.attachment;
629
+ const mimeType = typeof attachment?.mimeType === "string" ? attachment.mimeType : "";
630
+ const data = typeof attachment?.data === "string" ? attachment.data : "";
631
+ const name = typeof attachment?.name === "string" ? attachment.name : "";
632
+ if (!mimeType.startsWith("image/")) {
633
+ throw new Error("OCR job attachment must be an image.");
634
+ }
635
+ if (!data) {
636
+ throw new Error("OCR job attachment is empty.");
637
+ }
638
+ const bytes = Buffer.from(data, "base64");
639
+ if (!bytes.length) {
640
+ throw new Error("OCR job attachment data is not valid base64.");
641
+ }
642
+
643
+ const tempDir = await mkdtemp(join(tmpdir(), "gonext-ocr-worker-"));
644
+ let extractedText = "";
645
+ try {
646
+ const imagePath = join(
647
+ tempDir,
648
+ `input${resolveImageExtension(mimeType, name)}`
649
+ );
650
+ await writeFile(imagePath, bytes);
651
+ const modelPath = resolveOcrModelPath();
652
+ const { stdout } = await runMlxVlmGenerate(modelPath, imagePath);
653
+ extractedText = normalizeOcrOutput(stdout);
654
+ } finally {
655
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
656
+ }
657
+
658
+ if (!extractedText) {
659
+ throw new Error("OCR returned empty text for this image.");
660
+ }
661
+ const totalTimeSeconds = (Date.now() - start) / 1000;
662
+ const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
663
+ method: "PATCH",
664
+ body: JSON.stringify({
665
+ jobStatus: "completed",
666
+ resultText: extractedText,
667
+ tokenCount: Math.max(1, Math.ceil(extractedText.length / 4)),
668
+ totalTimeSeconds,
669
+ }),
670
+ });
671
+ await ensureWorkerOk(doneRes, `complete OCR jobId=${jobId}`);
672
+ console.log(
673
+ `[gonext-worker] completed OCR ${jobId} (${totalTimeSeconds.toFixed(1)}s)`
674
+ );
675
+ } catch (e) {
676
+ const message = e instanceof Error ? e.message : String(e);
677
+ const failRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
678
+ method: "PATCH",
679
+ body: JSON.stringify({
680
+ jobStatus: "failed",
681
+ errorMessage: message,
682
+ totalTimeSeconds: (Date.now() - start) / 1000,
683
+ }),
684
+ });
685
+ if (!failRes.ok) {
686
+ const snippet = (await failRes.text().catch(() => "")).trim().slice(0, 500);
687
+ console.error(
688
+ `[gonext-worker] OCR fail PATCH also failed ${failRes.status} jobId=${jobId}` +
689
+ (snippet ? ` response=${snippet}` : "")
690
+ );
691
+ }
692
+ console.error(`[gonext-worker] failed OCR ${jobId}:`, message);
693
+ }
694
+ }
695
+
493
696
  function normalizeBaseUrl(raw) {
494
697
  return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
495
698
  }
@@ -794,6 +997,16 @@ async function pollOnce() {
794
997
  );
795
998
  continue;
796
999
  }
1000
+ const isOcrByType = job.jobType === "ocr";
1001
+ const isOcrByPayload =
1002
+ job.payload &&
1003
+ typeof job.payload === "object" &&
1004
+ typeof job.payload.ocrId === "string" &&
1005
+ typeof job.payload.attachment?.data === "string";
1006
+ if (isOcrByType || isOcrByPayload) {
1007
+ await runOcrJob(job);
1008
+ return;
1009
+ }
797
1010
  await runChatJob(job);
798
1011
  return;
799
1012
  }
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.41",
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",