@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.
- package/gonext-local-worker.mjs +219 -6
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|