@tiens.nguyen/gonext-local-worker 1.0.42 → 1.0.44

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.
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
11
11
  import { execFile as execFileCallback } from "node:child_process";
12
+ import { createHash } from "node:crypto";
12
13
  import { homedir, platform, tmpdir } from "node:os";
13
14
  import { extname, join } from "node:path";
14
15
  import { promisify } from "node:util";
@@ -24,6 +25,12 @@ dotenv.config();
24
25
 
25
26
  const args = process.argv.slice(2);
26
27
 
28
+ function workerKeyFingerprint(secret) {
29
+ const v = String(secret ?? "").trim();
30
+ if (!v) return "";
31
+ return createHash("sha256").update(v, "utf8").digest("hex").slice(0, 12);
32
+ }
33
+
27
34
  function printHelp() {
28
35
  console.log(`
29
36
  gonext-local-worker
@@ -44,6 +51,7 @@ Env (optional):
44
51
  GONEXT_SIMULATE_TEXT default body for simulate-chat when no args
45
52
  GONEXT_MLX_LM_PYTHON Python executable for MLX LM native probe (default: python3)
46
53
  GONEXT_OCR_MODEL_PATH Local GLM-OCR-bf16 directory (default: ~/mlx-models/GLM-OCR-bf16)
54
+ GONEXT_OCR_DEBUG=1 Print raw OCR stdout/stderr snippets for troubleshooting
47
55
  `);
48
56
  }
49
57
 
@@ -96,16 +104,18 @@ if (args[0] === "set") {
96
104
  process.exit(0);
97
105
  }
98
106
 
99
- const apiBase = (process.env.GONEXT_API_BASE ?? "").replace(/\/+$/, "");
100
- const workerKey = process.env.GONEXT_WORKER_KEY ?? "";
107
+ const apiBase = String(process.env.GONEXT_API_BASE ?? "")
108
+ .trim()
109
+ .replace(/\/+$/, "");
110
+ const workerKey = String(process.env.GONEXT_WORKER_KEY ?? "").trim();
101
111
  const pollMs = 500;
102
112
  const localHealthConcurrency = 4;
103
113
 
104
114
  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.";
115
+ const OCR_PROMPT = "Text Recognition:";
107
116
  const OCR_MAX_TOKENS = 2048;
108
117
  const OCR_TIMEOUT_MS = 180_000;
118
+ const OCR_DEBUG = String(process.env.GONEXT_OCR_DEBUG ?? "").trim() === "1";
109
119
 
110
120
  async function workerFetch(path, init = {}) {
111
121
  const url = `${apiBase}${path.startsWith("/") ? path : `/${path}`}`;
@@ -125,13 +135,23 @@ async function ensureWorkerOk(res, context) {
125
135
  );
126
136
  }
127
137
 
128
- if (args[0] === "ws-ping-test") {
129
- if (!apiBase || !workerKey) {
138
+ function assertWorkerRuntimeConfig(mode) {
139
+ if (!workerKey) {
130
140
  console.error(
131
- "ws-ping-test needs GONEXT_API_BASE and GONEXT_WORKER_KEY (run: gonext-local-worker set <key> --api-base <url>)"
141
+ `[gonext-worker] ${mode}: missing GONEXT_WORKER_KEY. Run: gonext-local-worker set <workerKey> --api-base <url>`
132
142
  );
133
143
  process.exit(1);
134
144
  }
145
+ if (!apiBase) {
146
+ console.error(
147
+ `[gonext-worker] ${mode}: missing GONEXT_API_BASE. Run: gonext-local-worker set <workerKey> --api-base <url>`
148
+ );
149
+ process.exit(1);
150
+ }
151
+ }
152
+
153
+ if (args[0] === "ws-ping-test") {
154
+ assertWorkerRuntimeConfig("ws-ping-test");
135
155
  const url = `${apiBase}/api/worker/ws-ping-test`;
136
156
  const res = await fetch(url, {
137
157
  method: "POST",
@@ -151,17 +171,21 @@ if (args[0] === "ws-ping-test") {
151
171
  }
152
172
  process.exit(1);
153
173
  }
154
- console.log("[gonext-worker] ws-ping-test OK:", text);
174
+ try {
175
+ const parsed = JSON.parse(text);
176
+ console.log(
177
+ `[gonext-worker] ws-ping-test OK user=${parsed.userId ?? "unknown"} host=${
178
+ parsed.workerHostId ?? "legacy"
179
+ } keyfp=${parsed.workerKeyFingerprint ?? workerKeyFingerprint(workerKey)}`
180
+ );
181
+ } catch {
182
+ console.log("[gonext-worker] ws-ping-test OK:", text);
183
+ }
155
184
  process.exit(0);
156
185
  }
157
186
 
158
187
  if (args[0] === "simulate-chat") {
159
- if (!apiBase || !workerKey) {
160
- console.error(
161
- "simulate-chat needs GONEXT_API_BASE and GONEXT_WORKER_KEY (run: gonext-local-worker set <key> --api-base <url>)"
162
- );
163
- process.exit(1);
164
- }
188
+ assertWorkerRuntimeConfig("simulate-chat");
165
189
  const reply =
166
190
  args.slice(1).join(" ").trim() ||
167
191
  String(process.env.GONEXT_SIMULATE_TEXT ?? "").trim() ||
@@ -263,15 +287,12 @@ if (args[0] === "simulate-chat") {
263
287
  process.exit(0);
264
288
  }
265
289
 
266
- if (!apiBase || !workerKey) {
267
- console.error(
268
- "Set GONEXT_API_BASE (HTTP API origin, no /api suffix) and GONEXT_WORKER_KEY."
269
- );
270
- process.exit(1);
271
- }
290
+ assertWorkerRuntimeConfig("worker");
272
291
 
273
292
  console.log(
274
- `[gonext-worker] API base ${apiBase} — streaming chunks POST ${apiBase}${CHUNK_PATH}`
293
+ `[gonext-worker] API base ${apiBase} — streaming chunks POST ${apiBase}${CHUNK_PATH} — keyfp=${workerKeyFingerprint(
294
+ workerKey
295
+ )}`
275
296
  );
276
297
 
277
298
  function toOpenAIMessages(messages) {
@@ -522,10 +543,18 @@ function resolveImageExtension(mimeType, fileName) {
522
543
  return ext || ".png";
523
544
  }
524
545
 
525
- function resolveOcrModelPath() {
526
- const raw = String(process.env.GONEXT_OCR_MODEL_PATH ?? "").trim();
527
- if (raw) {
528
- return raw.replace(/^~(?=\/)/, homedir());
546
+ function resolveOcrModelPath(modelFromJob) {
547
+ const fromJob = String(modelFromJob ?? "").trim();
548
+ if (fromJob) {
549
+ const expanded = fromJob.replace(/^~(?=\/)/, homedir());
550
+ if (expanded.startsWith("/")) {
551
+ return expanded;
552
+ }
553
+ return join(homedir(), "mlx-models", expanded);
554
+ }
555
+ const fromEnv = String(process.env.GONEXT_OCR_MODEL_PATH ?? "").trim();
556
+ if (fromEnv) {
557
+ return fromEnv.replace(/^~(?=\/)/, homedir());
529
558
  }
530
559
  return join(homedir(), "mlx-models", "GLM-OCR-bf16");
531
560
  }
@@ -581,7 +610,7 @@ async function runMlxVlmGenerate(modelPath, imagePath) {
581
610
  String(OCR_MAX_TOKENS),
582
611
  ];
583
612
  try {
584
- return await execFile(
613
+ const result = await execFile(
585
614
  "python3",
586
615
  ["-m", "mlx_vlm.generate", ...sharedArgs],
587
616
  {
@@ -589,6 +618,7 @@ async function runMlxVlmGenerate(modelPath, imagePath) {
589
618
  maxBuffer: 10 * 1024 * 1024,
590
619
  }
591
620
  );
621
+ return { ...result, invocation: "python3 -m mlx_vlm.generate" };
592
622
  } catch (primaryError) {
593
623
  const stderr =
594
624
  primaryError && typeof primaryError === "object" && "stderr" in primaryError
@@ -602,7 +632,7 @@ async function runMlxVlmGenerate(modelPath, imagePath) {
602
632
  if (!missingLegacyModule) {
603
633
  throw primaryError;
604
634
  }
605
- return execFile(
635
+ const result = await execFile(
606
636
  "python3",
607
637
  ["-m", "mlx_vlm", "generate", ...sharedArgs],
608
638
  {
@@ -610,7 +640,22 @@ async function runMlxVlmGenerate(modelPath, imagePath) {
610
640
  maxBuffer: 10 * 1024 * 1024,
611
641
  }
612
642
  );
643
+ return { ...result, invocation: "python3 -m mlx_vlm generate" };
644
+ }
645
+ }
646
+
647
+ function compactPreview(text, max = 800) {
648
+ const normalized = String(text ?? "")
649
+ .replace(/\r\n/g, "\n")
650
+ .replace(/\s+/g, " ")
651
+ .trim();
652
+ if (!normalized) {
653
+ return "(empty)";
654
+ }
655
+ if (normalized.length <= max) {
656
+ return normalized;
613
657
  }
658
+ return `${normalized.slice(0, max)} …[truncated ${normalized.length - max} chars]`;
614
659
  }
615
660
 
616
661
  async function runOcrJob(job) {
@@ -628,17 +673,41 @@ async function runOcrJob(job) {
628
673
  const attachment = payload.attachment;
629
674
  const mimeType = typeof attachment?.mimeType === "string" ? attachment.mimeType : "";
630
675
  const data = typeof attachment?.data === "string" ? attachment.data : "";
676
+ const s3Object =
677
+ attachment && typeof attachment === "object" ? attachment.s3Object : undefined;
678
+ const s3GetUrl =
679
+ s3Object && typeof s3Object.getUrl === "string" ? s3Object.getUrl.trim() : "";
680
+ const sourceLabel = s3GetUrl
681
+ ? `s3://${s3Object?.bucket ?? "unknown"}/${s3Object?.key ?? "unknown"}`
682
+ : "inline_base64";
683
+ const ocrModel =
684
+ typeof payload.model === "string" && payload.model.trim()
685
+ ? payload.model.trim()
686
+ : "GLM-OCR-bf16";
631
687
  const name = typeof attachment?.name === "string" ? attachment.name : "";
632
688
  if (!mimeType.startsWith("image/")) {
633
689
  throw new Error("OCR job attachment must be an image.");
634
690
  }
635
- if (!data) {
691
+ if (!data && !s3GetUrl) {
636
692
  throw new Error("OCR job attachment is empty.");
637
693
  }
638
- const bytes = Buffer.from(data, "base64");
694
+ let bytes;
695
+ if (s3GetUrl) {
696
+ const dlRes = await fetch(s3GetUrl, { method: "GET" });
697
+ if (!dlRes.ok) {
698
+ throw new Error(
699
+ `Failed to download OCR image from S3 (${dlRes.status}).`
700
+ );
701
+ }
702
+ const arr = await dlRes.arrayBuffer();
703
+ bytes = Buffer.from(arr);
704
+ } else {
705
+ bytes = Buffer.from(data, "base64");
706
+ }
639
707
  if (!bytes.length) {
640
- throw new Error("OCR job attachment data is not valid base64.");
708
+ throw new Error("OCR job attachment data is not valid.");
641
709
  }
710
+ console.log(`[gonext-worker] OCR source jobId=${jobId} ${sourceLabel}`);
642
711
 
643
712
  const tempDir = await mkdtemp(join(tmpdir(), "gonext-ocr-worker-"));
644
713
  let extractedText = "";
@@ -648,16 +717,27 @@ async function runOcrJob(job) {
648
717
  `input${resolveImageExtension(mimeType, name)}`
649
718
  );
650
719
  await writeFile(imagePath, bytes);
651
- const modelPath = resolveOcrModelPath();
652
- const { stdout } = await runMlxVlmGenerate(modelPath, imagePath);
720
+ const modelPath = resolveOcrModelPath(ocrModel);
721
+ const { stdout, stderr, invocation } = await runMlxVlmGenerate(
722
+ modelPath,
723
+ imagePath
724
+ );
725
+ if (OCR_DEBUG) {
726
+ console.log(
727
+ `[gonext-worker][ocr-debug] jobId=${jobId} invocation=${invocation} model=${modelPath} image=${imagePath} source=${
728
+ sourceLabel
729
+ } stdout=${compactPreview(stdout)} stderr=${compactPreview(stderr)}`
730
+ );
731
+ }
653
732
  extractedText = normalizeOcrOutput(stdout);
733
+ if (!extractedText) {
734
+ throw new Error(
735
+ `OCR returned empty text for this image. invocation=${invocation} stdout=${compactPreview(stdout)} stderr=${compactPreview(stderr)}`
736
+ );
737
+ }
654
738
  } finally {
655
739
  await rm(tempDir, { recursive: true, force: true }).catch(() => {});
656
740
  }
657
-
658
- if (!extractedText) {
659
- throw new Error("OCR returned empty text for this image.");
660
- }
661
741
  const totalTimeSeconds = (Date.now() - start) / 1000;
662
742
  const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
663
743
  method: "PATCH",
@@ -670,7 +750,7 @@ async function runOcrJob(job) {
670
750
  });
671
751
  await ensureWorkerOk(doneRes, `complete OCR jobId=${jobId}`);
672
752
  console.log(
673
- `[gonext-worker] completed OCR ${jobId} (${totalTimeSeconds.toFixed(1)}s)`
753
+ `[gonext-worker] completed OCR ${jobId} (${totalTimeSeconds.toFixed(1)}s) model=${ocrModel}`
674
754
  );
675
755
  } catch (e) {
676
756
  const message = e instanceof Error ? e.message : String(e);
@@ -742,20 +822,30 @@ async function checkOllamaTags(base) {
742
822
  }
743
823
  }
744
824
 
745
- async function checkOpenAiModels(base, apiKey) {
825
+ async function checkOpenAiModels(base, apiKey, source) {
746
826
  const endpoint = `${base}/models`;
747
827
  const headers = {};
748
828
  if (apiKey?.trim()) {
749
829
  headers.Authorization = `Bearer ${apiKey.trim()}`;
750
830
  }
831
+ const hostSourceId =
832
+ typeof source?.workerHostId === "string" ? source.workerHostId.trim() : "";
833
+ const hostSourceLabel =
834
+ typeof source?.hostName === "string" ? source.hostName.trim() : "";
751
835
  try {
752
836
  const res = await fetch(endpoint, { method: "GET", headers });
753
837
  if (!res.ok) return { online: false, endpoint, models: [] };
754
838
  const j = await res.json();
839
+ const modelSuffix = hostSourceId ? `@@host:${hostSourceId}` : "";
840
+ const displaySuffix = hostSourceLabel ? ` (${hostSourceLabel})` : "";
755
841
  const models = (j.data ?? [])
756
842
  .map((d) => d.id)
757
843
  .filter(Boolean)
758
- .map((id) => ({ id, name: id, value: `mlx:${id}` }));
844
+ .map((id) => ({
845
+ id: hostSourceId ? `${id}@@${hostSourceId}` : id,
846
+ name: `${id}${displaySuffix}`,
847
+ value: `mlx:${id}${modelSuffix}`,
848
+ }));
759
849
  return { online: true, endpoint, models };
760
850
  } catch {
761
851
  return { online: false, endpoint, models: [] };
@@ -848,13 +938,24 @@ async function runLocalHealthJob(job) {
848
938
  }
849
939
  }
850
940
  const mlxRoot = normalizeOpenAiV1Root(payload?.mlxOpenAiBaseUrl);
941
+ const targetWorkerHostId =
942
+ typeof payload?.targetWorkerHostId === "string"
943
+ ? payload.targetWorkerHostId.trim()
944
+ : "";
945
+ const targetWorkerHostName =
946
+ typeof payload?.targetWorkerHostName === "string"
947
+ ? payload.targetWorkerHostName.trim()
948
+ : "";
851
949
  let mlxHttp = null;
852
950
  let mlxNative = null;
853
951
 
854
952
  if (mlxRoot) {
855
953
  const mlxStart = Date.now();
856
954
  console.log(`[gonext-worker] local_health ${jobId} check mlx HTTP ${mlxRoot}`);
857
- mlxHttp = await checkOpenAiModels(mlxRoot, payload?.mlxApiKey ?? "");
955
+ mlxHttp = await checkOpenAiModels(mlxRoot, payload?.mlxApiKey ?? "", {
956
+ workerHostId: targetWorkerHostId,
957
+ hostName: targetWorkerHostName || sourceLabelFromBase(mlxRoot),
958
+ });
858
959
  console.log(
859
960
  `[gonext-worker] local_health ${jobId} mlx HTTP online=${mlxHttp.online} models=${mlxHttp.models.length} took=${((Date.now() - mlxStart) / 1000).toFixed(2)}s`
860
961
  );
@@ -881,6 +982,16 @@ async function runLocalHealthJob(job) {
881
982
  if (mlxRoot || mlxNative?.available) {
882
983
  const httpOk = Boolean(mlxHttp?.online && (mlxHttp?.models?.length ?? 0) > 0);
883
984
  const nativeOk = mlxNative?.available === true;
985
+ const hostName = targetWorkerHostName || sourceLabelFromBase(mlxRoot || "mlx");
986
+ const sources = [
987
+ {
988
+ workerHostId: targetWorkerHostId || undefined,
989
+ hostName,
990
+ endpoint: mlxHttp?.endpoint,
991
+ online: httpOk || nativeOk,
992
+ modelCount: httpOk ? mlxHttp.models.length : nativeOk ? 1 : 0,
993
+ },
994
+ ];
884
995
  mlx = {
885
996
  configured: httpOk || nativeOk,
886
997
  online: httpOk || nativeOk,
@@ -906,6 +1017,7 @@ async function runLocalHealthJob(job) {
906
1017
  }
907
1018
  : undefined,
908
1019
  native: mlxNative ?? undefined,
1020
+ sources,
909
1021
  };
910
1022
  }
911
1023
  const result = {
@@ -1014,7 +1126,8 @@ async function pollOnce() {
1014
1126
  job.payload &&
1015
1127
  typeof job.payload === "object" &&
1016
1128
  typeof job.payload.ocrId === "string" &&
1017
- typeof job.payload.attachment?.data === "string";
1129
+ (typeof job.payload.attachment?.data === "string" ||
1130
+ typeof job.payload.attachment?.s3Object?.getUrl === "string");
1018
1131
  if (isOcrByType || isOcrByPayload) {
1019
1132
  await runOcrJob(job);
1020
1133
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.42",
3
+ "version": "1.0.44",
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",