@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.
@@ -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
  }
@@ -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 stream = await client.chat.completions.create({
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: Math.max(1, 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.38",
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",