@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.
- package/gonext-local-worker.mjs +154 -41
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -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 ?? "")
|
|
100
|
-
|
|
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
|
-
|
|
129
|
-
if (!
|
|
138
|
+
function assertWorkerRuntimeConfig(mode) {
|
|
139
|
+
if (!workerKey) {
|
|
130
140
|
console.error(
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
527
|
-
if (
|
|
528
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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) => ({
|
|
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.
|
|
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",
|