@tiens.nguyen/gonext-local-worker 1.0.38 → 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.
- package/gonext-local-worker.mjs +219 -9
- 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
|
}
|
|
@@ -278,6 +292,25 @@ function toOpenAIMessages(messages) {
|
|
|
278
292
|
});
|
|
279
293
|
}
|
|
280
294
|
|
|
295
|
+
function parseCompletionTokens(usage) {
|
|
296
|
+
if (!usage || typeof usage !== "object") {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
if (typeof usage.completion_tokens === "number") {
|
|
300
|
+
return usage.completion_tokens;
|
|
301
|
+
}
|
|
302
|
+
if (typeof usage.output_tokens === "number") {
|
|
303
|
+
return usage.output_tokens;
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function shouldRetryWithoutUsage(err) {
|
|
309
|
+
const msg =
|
|
310
|
+
err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
|
311
|
+
return msg.includes("stream_options") || msg.includes("include_usage");
|
|
312
|
+
}
|
|
313
|
+
|
|
281
314
|
async function runChatJob(job) {
|
|
282
315
|
const { jobId, payload } = job;
|
|
283
316
|
if (!payload || !Array.isArray(payload.messages)) {
|
|
@@ -370,18 +403,34 @@ async function runChatJob(job) {
|
|
|
370
403
|
};
|
|
371
404
|
|
|
372
405
|
try {
|
|
373
|
-
const
|
|
406
|
+
const streamRequest = {
|
|
374
407
|
model: payload.modelId,
|
|
375
408
|
messages: toOpenAIMessages(payload.messages),
|
|
376
409
|
stream: true,
|
|
377
410
|
temperature: 0,
|
|
378
|
-
}
|
|
411
|
+
};
|
|
412
|
+
const stream = await client.chat.completions
|
|
413
|
+
.create({
|
|
414
|
+
...streamRequest,
|
|
415
|
+
stream_options: { include_usage: true },
|
|
416
|
+
})
|
|
417
|
+
.catch(async (e) => {
|
|
418
|
+
if (!shouldRetryWithoutUsage(e)) {
|
|
419
|
+
throw e;
|
|
420
|
+
}
|
|
421
|
+
return client.chat.completions.create(streamRequest);
|
|
422
|
+
});
|
|
379
423
|
|
|
380
424
|
let tokenCount = 0;
|
|
425
|
+
let completionTokensFromUsage = null;
|
|
381
426
|
let isStartThinking = false;
|
|
382
427
|
let isEndThinking = false;
|
|
383
428
|
|
|
384
429
|
for await (const chunk of stream) {
|
|
430
|
+
const usageTokens = parseCompletionTokens(chunk.usage);
|
|
431
|
+
if (usageTokens !== null) {
|
|
432
|
+
completionTokensFromUsage = usageTokens;
|
|
433
|
+
}
|
|
385
434
|
const delta = chunk.choices[0]?.delta;
|
|
386
435
|
const content = delta?.content ?? "";
|
|
387
436
|
const reasoningContent = delta?.reasoning_content;
|
|
@@ -419,7 +468,10 @@ async function runChatJob(job) {
|
|
|
419
468
|
body: JSON.stringify({
|
|
420
469
|
jobStatus: "completed",
|
|
421
470
|
resultText: fullText,
|
|
422
|
-
tokenCount:
|
|
471
|
+
tokenCount:
|
|
472
|
+
completionTokensFromUsage !== null
|
|
473
|
+
? completionTokensFromUsage
|
|
474
|
+
: Math.max(1, tokenCount),
|
|
423
475
|
totalTimeSeconds,
|
|
424
476
|
}),
|
|
425
477
|
});
|
|
@@ -452,6 +504,154 @@ async function runChatJob(job) {
|
|
|
452
504
|
}
|
|
453
505
|
}
|
|
454
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
|
+
|
|
455
655
|
function normalizeBaseUrl(raw) {
|
|
456
656
|
return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
|
|
457
657
|
}
|
|
@@ -756,6 +956,16 @@ async function pollOnce() {
|
|
|
756
956
|
);
|
|
757
957
|
continue;
|
|
758
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
|
+
}
|
|
759
969
|
await runChatJob(job);
|
|
760
970
|
return;
|
|
761
971
|
}
|
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.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",
|