@tiens.nguyen/gonext-local-worker 1.0.6 → 1.0.8
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/README.md +3 -120
- package/gonext-local-worker.mjs +27 -407
- package/package.json +6 -1
- package/launchd/com.gonext.worker.plist.example +0 -34
package/README.md
CHANGED
|
@@ -1,120 +1,3 @@
|
|
|
1
|
-
# gonext-local-worker
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
1. Polls **`POST /api/worker/jobs/next`** on your GoNext cloud API (Lambda).
|
|
6
|
-
2. Runs the job against **`payload.baseURL`** (your LAN `http://127.0.0.1:11434/v1` etc.).
|
|
7
|
-
3. **`PATCH`**es **`running`** → **`completed`** / **`failed`** so DynamoDB and the web app update.
|
|
8
|
-
|
|
9
|
-
Set your Firebase **worker user id** in the worker env so it claims your own queued jobs.
|
|
10
|
-
|
|
11
|
-
## Install
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
npm install -g @tiens.nguyen/gonext-local-worker
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
Or from source:
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
cd tools/gonext-local-worker
|
|
21
|
-
npm install
|
|
22
|
-
npm link
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
Requires **Node.js 18+**.
|
|
26
|
-
|
|
27
|
-
## Configure
|
|
28
|
-
|
|
29
|
-
Create `~/.gonext/worker.env` (optional):
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
mkdir -p ~/.gonext
|
|
33
|
-
cat > ~/.gonext/worker.env << 'EOF'
|
|
34
|
-
GONEXT_API_BASE=https://YOUR_API.execute-api.ap-southeast-1.amazonaws.com
|
|
35
|
-
GONEXT_WORKER_USER_ID=paste-your-firebase-user-id
|
|
36
|
-
GONEXT_POLL_MS=1500
|
|
37
|
-
EOF
|
|
38
|
-
chmod 600 ~/.gonext/worker.env
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
Or export in the shell:
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
export GONEXT_API_BASE=https://....execute-api....amazonaws.com
|
|
45
|
-
export GONEXT_WORKER_USER_ID=...
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
## Run
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
gonext-local-worker
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
Options:
|
|
55
|
-
|
|
56
|
-
```bash
|
|
57
|
-
gonext-local-worker --poll-ms 2000
|
|
58
|
-
gonext-local-worker --api-base https://other-host.example
|
|
59
|
-
gonext-local-worker --mode webhook --webhook-port 5001
|
|
60
|
-
gonext-local-worker --mode both
|
|
61
|
-
gonext-local-worker --help
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
Leave this process **running** while you use async local models from the web app.
|
|
65
|
-
|
|
66
|
-
## Push-dispatch mode (new option)
|
|
67
|
-
|
|
68
|
-
You now have 2 ways to update job status:
|
|
69
|
-
|
|
70
|
-
1. **Polling mode** (existing): worker polls `/api/worker/jobs/next`.
|
|
71
|
-
2. **Webhook mode** (new): API push-dispatches job payloads to your local worker endpoint.
|
|
72
|
-
|
|
73
|
-
Run local webhook endpoint:
|
|
74
|
-
|
|
75
|
-
```bash
|
|
76
|
-
gonext-local-worker --mode webhook --webhook-port 5001
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
or keep both (recommended transition):
|
|
80
|
-
|
|
81
|
-
```bash
|
|
82
|
-
gonext-local-worker --mode both --webhook-port 5001
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
Expose webhook with ngrok:
|
|
86
|
-
|
|
87
|
-
```bash
|
|
88
|
-
ngrok http 5001
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
Then in web app Settings set **Worker webhook URL** to:
|
|
92
|
-
|
|
93
|
-
`https://<your-ngrok-domain>/api/dispatch-job`
|
|
94
|
-
|
|
95
|
-
Local worker API endpoints on port 5001:
|
|
96
|
-
|
|
97
|
-
- `POST /api/chat` -> proxies to `http://localhost:11434/api/chat`
|
|
98
|
-
- `POST /api/generate` -> proxies to `http://localhost:11434/api/generate`
|
|
99
|
-
- `GET /api/tags` -> proxies to `http://localhost:11434/api/tags`
|
|
100
|
-
- `POST /api/dispatch-job` -> cloud dispatch entrypoint
|
|
101
|
-
- `GET /api/health`
|
|
102
|
-
|
|
103
|
-
Legacy non-prefixed routes (`/chat`, `/generate`, `/tags`, `/dispatch-job`, `/health`) are also available for compatibility.
|
|
104
|
-
|
|
105
|
-
## Run at login (macOS LaunchAgent)
|
|
106
|
-
|
|
107
|
-
1. Copy `launchd/com.gonext.worker.plist.example` to `~/Library/LaunchAgents/com.gonext.worker.plist`.
|
|
108
|
-
2. Edit paths: set **full path** to `gonext-local-worker` (`which gonext-local-worker` after `npm link`).
|
|
109
|
-
3. `launchctl load ~/Library/LaunchAgents/com.gonext.worker.plist`
|
|
110
|
-
|
|
111
|
-
Unload:
|
|
112
|
-
|
|
113
|
-
```bash
|
|
114
|
-
launchctl unload ~/Library/LaunchAgents/com.gonext.worker.plist
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
## Troubleshooting
|
|
118
|
-
|
|
119
|
-
- **`400` / missing workerUserId** — Set `GONEXT_WORKER_USER_ID` correctly.
|
|
120
|
-
- **Job never claimed** — Ensure DynamoDB + worker routes work; Settings must include **Ollama base URL** so the queued payload points at a reachable host from this Mac (`127.0.0.1` is fine).
|
|
1
|
+
# @tiens.nguyen/gonext-local-worker
|
|
2
|
+
Run:
|
|
3
|
+
GONEXT_API_BASE=... GONEXT_WORKER_KEY=... npx -y --package @tiens.nguyen/gonext-local-worker gonext-local-worker
|
package/gonext-local-worker.mjs
CHANGED
|
@@ -1,115 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* GoNext
|
|
4
|
-
*
|
|
3
|
+
* Polls GoNext Cloud API for pending local-LLM chat jobs, runs them against the
|
|
4
|
+
* URLs embedded in the job payload (your LAN Ollama / OpenAI-compatible server),
|
|
5
|
+
* then PATCHes completion back to the API.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
* GONEXT_API_BASE
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Usage (from api/):
|
|
8
|
+
* export GONEXT_API_BASE=https://xxxx.execute-api....amazonaws.com
|
|
9
|
+
* export GONEXT_WORKER_KEY=<plaintext secret from Settings → Worker API key>
|
|
10
|
+
* node scripts/local-llm-worker.mjs
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
* ~/.gonext/worker.env
|
|
13
|
-
* ./.env (cwd)
|
|
12
|
+
* Requires Node 18+ (global fetch). Uses the OpenAI SDK from this package.
|
|
14
13
|
*/
|
|
15
|
-
import { homedir } from "node:os";
|
|
16
|
-
import os from "node:os";
|
|
17
|
-
import { join } from "node:path";
|
|
18
|
-
import dotenv from "dotenv";
|
|
19
|
-
import express from "express";
|
|
20
14
|
import OpenAI from "openai";
|
|
21
15
|
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
const apiBase = (process.env.GONEXT_API_BASE ?? "").replace(/\/+$/, "");
|
|
17
|
+
const workerKey = process.env.GONEXT_WORKER_KEY ?? "";
|
|
18
|
+
const pollMs = Number(process.env.GONEXT_POLL_MS ?? "1500") || 1500;
|
|
24
19
|
|
|
25
|
-
|
|
26
|
-
const out = {
|
|
27
|
-
help: false,
|
|
28
|
-
pollMs: undefined,
|
|
29
|
-
apiBase: undefined,
|
|
30
|
-
mode: undefined,
|
|
31
|
-
webhookPort: undefined,
|
|
32
|
-
};
|
|
33
|
-
for (let i = 2; i < argv.length; i++) {
|
|
34
|
-
const a = argv[i];
|
|
35
|
-
if (a === "--help" || a === "-h") out.help = true;
|
|
36
|
-
else if (a === "--poll-ms" && argv[i + 1])
|
|
37
|
-
out.pollMs = Number(argv[++i]);
|
|
38
|
-
else if (a === "--api-base" && argv[i + 1]) out.apiBase = argv[++i];
|
|
39
|
-
else if (a === "--mode" && argv[i + 1]) out.mode = argv[++i];
|
|
40
|
-
else if (a === "--webhook-port" && argv[i + 1])
|
|
41
|
-
out.webhookPort = Number(argv[++i]);
|
|
42
|
-
}
|
|
43
|
-
return out;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function printHelp() {
|
|
47
|
-
console.log(`
|
|
48
|
-
gonext-local-worker — bridge cloud GoNext jobs ↔ local LLM (Ollama / OpenAI-compatible)
|
|
49
|
-
|
|
50
|
-
Usage:
|
|
51
|
-
export GONEXT_API_BASE=https://....amazonaws.com
|
|
52
|
-
export GONEXT_WORKER_USER_ID=your-firebase-user-id
|
|
53
|
-
gonext-local-worker
|
|
54
|
-
|
|
55
|
-
Options:
|
|
56
|
-
--poll-ms <ms> Idle poll interval (default 1500 or GONEXT_POLL_MS)
|
|
57
|
-
--api-base <url> Override GONEXT_API_BASE
|
|
58
|
-
--mode <poll|webhook|both> Worker mode (default: poll)
|
|
59
|
-
--webhook-port <n> Local webhook port (default: 5001)
|
|
60
|
-
|
|
61
|
-
Config files (optional):
|
|
62
|
-
~/.gonext/worker.env
|
|
63
|
-
.env in current directory
|
|
64
|
-
|
|
65
|
-
Install:
|
|
66
|
-
npm install -g @tiens.nguyen/gonext-local-worker
|
|
67
|
-
|
|
68
|
-
Then keep this running while you use the web app with local models.
|
|
69
|
-
`);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const args = parseArgs(process.argv);
|
|
73
|
-
if (args.help) {
|
|
74
|
-
printHelp();
|
|
75
|
-
process.exit(0);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const apiBase = (
|
|
79
|
-
args.apiBase ??
|
|
80
|
-
process.env.GONEXT_API_BASE ??
|
|
81
|
-
""
|
|
82
|
-
).replace(/\/+$/, "");
|
|
83
|
-
const workerUserId = (process.env.GONEXT_WORKER_USER_ID ?? "").trim();
|
|
84
|
-
const pollMs =
|
|
85
|
-
(Number.isFinite(args.pollMs) && args.pollMs > 0
|
|
86
|
-
? args.pollMs
|
|
87
|
-
: Number(process.env.GONEXT_POLL_MS ?? "1500")) || 1500;
|
|
88
|
-
const modeRaw = String(args.mode ?? process.env.GONEXT_WORKER_MODE ?? "poll");
|
|
89
|
-
const mode = ["poll", "webhook", "both"].includes(modeRaw)
|
|
90
|
-
? modeRaw
|
|
91
|
-
: "poll";
|
|
92
|
-
const webhookPort =
|
|
93
|
-
(Number.isFinite(args.webhookPort) && args.webhookPort > 0
|
|
94
|
-
? args.webhookPort
|
|
95
|
-
: Number(process.env.GONEXT_WEBHOOK_PORT ?? "5001")) || 5001;
|
|
96
|
-
const ollamaBase = (
|
|
97
|
-
process.env.GONEXT_LOCAL_OLLAMA_BASE ?? "http://127.0.0.1:11434"
|
|
98
|
-
).replace(/\/+$/, "");
|
|
99
|
-
|
|
100
|
-
if (!apiBase || !workerUserId) {
|
|
20
|
+
if (!apiBase || !workerKey) {
|
|
101
21
|
console.error(
|
|
102
|
-
"
|
|
22
|
+
"Set GONEXT_API_BASE (HTTP API origin, no /api suffix) and GONEXT_WORKER_KEY."
|
|
103
23
|
);
|
|
104
24
|
process.exit(1);
|
|
105
25
|
}
|
|
106
26
|
|
|
107
|
-
const WORKER_VERSION = "1.0.0";
|
|
108
|
-
const WORKER_HOST = os.hostname();
|
|
109
|
-
function ts() {
|
|
110
|
-
return new Date().toISOString();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
27
|
function toOpenAIMessages(messages) {
|
|
114
28
|
return messages.map((m) => {
|
|
115
29
|
if (m.role === "user" && m.attachments?.length) {
|
|
@@ -132,54 +46,19 @@ async function workerFetch(path, init = {}) {
|
|
|
132
46
|
const url = `${apiBase}${path.startsWith("/") ? path : `/${path}`}`;
|
|
133
47
|
const headers = {
|
|
134
48
|
"Content-Type": "application/json",
|
|
135
|
-
"X-Worker-
|
|
49
|
+
"X-Worker-Key": workerKey,
|
|
136
50
|
...(init.headers ?? {}),
|
|
137
51
|
};
|
|
138
52
|
return fetch(url, { ...init, headers });
|
|
139
53
|
}
|
|
140
54
|
|
|
141
|
-
let shuttingDown = false;
|
|
142
|
-
let activeJobId = "";
|
|
143
|
-
let lastError = "";
|
|
144
|
-
let webhookServer = null;
|
|
145
|
-
|
|
146
|
-
async function postHeartbeat(payload) {
|
|
147
|
-
try {
|
|
148
|
-
await workerFetch("/api/worker/heartbeat", {
|
|
149
|
-
method: "POST",
|
|
150
|
-
body: JSON.stringify({
|
|
151
|
-
workerVersion: WORKER_VERSION,
|
|
152
|
-
host: WORKER_HOST,
|
|
153
|
-
...payload,
|
|
154
|
-
}),
|
|
155
|
-
});
|
|
156
|
-
} catch {
|
|
157
|
-
/* keep worker loop alive */
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
55
|
async function runChatJob(job) {
|
|
162
56
|
const { jobId, payload } = job;
|
|
163
57
|
const start = Date.now();
|
|
164
|
-
|
|
165
|
-
lastError = "";
|
|
166
|
-
await postHeartbeat({ state: "running", currentJobId: jobId });
|
|
167
|
-
const patchRunning = await workerFetch(`/api/worker/jobs/${jobId}`, {
|
|
58
|
+
await workerFetch(`/api/worker/jobs/${jobId}`, {
|
|
168
59
|
method: "PATCH",
|
|
169
60
|
body: JSON.stringify({ jobStatus: "running" }),
|
|
170
61
|
});
|
|
171
|
-
if (!patchRunning.ok) {
|
|
172
|
-
const t = await patchRunning.text().catch(() => "");
|
|
173
|
-
console.error(`[${ts()}] PATCH running failed ${patchRunning.status}`, t);
|
|
174
|
-
await postHeartbeat({
|
|
175
|
-
state: "error",
|
|
176
|
-
currentJobId: jobId,
|
|
177
|
-
lastError: `PATCH running failed ${patchRunning.status}`,
|
|
178
|
-
});
|
|
179
|
-
lastError = `PATCH running failed ${patchRunning.status}`;
|
|
180
|
-
activeJobId = "";
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
62
|
|
|
184
63
|
const client = new OpenAI({
|
|
185
64
|
baseURL: payload.baseURL,
|
|
@@ -199,19 +78,11 @@ async function runChatJob(job) {
|
|
|
199
78
|
body: JSON.stringify({
|
|
200
79
|
jobStatus: "completed",
|
|
201
80
|
resultText: text,
|
|
202
|
-
tokenCount:
|
|
81
|
+
tokenCount: 1,
|
|
203
82
|
totalTimeSeconds,
|
|
204
83
|
}),
|
|
205
84
|
});
|
|
206
|
-
console.log(
|
|
207
|
-
`[${ts()}] completed job ${jobId} (${totalTimeSeconds.toFixed(1)}s)`
|
|
208
|
-
);
|
|
209
|
-
await postHeartbeat({
|
|
210
|
-
state: "idle",
|
|
211
|
-
currentJobId: "",
|
|
212
|
-
lastJobCompletedAt: new Date().toISOString(),
|
|
213
|
-
});
|
|
214
|
-
activeJobId = "";
|
|
85
|
+
console.log(`[gonext-worker] completed ${jobId} (${totalTimeSeconds.toFixed(1)}s)`);
|
|
215
86
|
} catch (e) {
|
|
216
87
|
const message = e instanceof Error ? e.message : String(e);
|
|
217
88
|
await workerFetch(`/api/worker/jobs/${jobId}`, {
|
|
@@ -222,14 +93,7 @@ async function runChatJob(job) {
|
|
|
222
93
|
totalTimeSeconds: (Date.now() - start) / 1000,
|
|
223
94
|
}),
|
|
224
95
|
});
|
|
225
|
-
console.error(`[
|
|
226
|
-
await postHeartbeat({
|
|
227
|
-
state: "error",
|
|
228
|
-
currentJobId: jobId,
|
|
229
|
-
lastError: message,
|
|
230
|
-
});
|
|
231
|
-
lastError = message;
|
|
232
|
-
activeJobId = "";
|
|
96
|
+
console.error(`[gonext-worker] failed ${jobId}:`, message);
|
|
233
97
|
}
|
|
234
98
|
}
|
|
235
99
|
|
|
@@ -238,268 +102,24 @@ async function pollOnce() {
|
|
|
238
102
|
if (res.status === 204) return;
|
|
239
103
|
if (!res.ok) {
|
|
240
104
|
const t = await res.text().catch(() => "");
|
|
241
|
-
throw new Error(`
|
|
105
|
+
throw new Error(`next failed ${res.status}: ${t}`);
|
|
242
106
|
}
|
|
243
107
|
const job = await res.json();
|
|
244
|
-
if (job?.jobId
|
|
108
|
+
if (job?.jobId) {
|
|
245
109
|
await runChatJob(job);
|
|
246
110
|
}
|
|
247
111
|
}
|
|
248
112
|
|
|
249
|
-
function
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
function toOllamaChatMessages(messages) {
|
|
254
|
-
return (messages ?? []).map((m) => ({
|
|
255
|
-
role: m.role,
|
|
256
|
-
content: m.content,
|
|
257
|
-
...(Array.isArray(m.attachments) && m.attachments.length > 0
|
|
258
|
-
? { images: m.attachments.map((a) => a.data) }
|
|
259
|
-
: {}),
|
|
260
|
-
}));
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
async function runOllamaAndMaybeUpdateJob(params) {
|
|
264
|
-
const { endpoint, requestBody, jobId, extractResultText } = params;
|
|
265
|
-
const hasJob = typeof jobId === "string" && jobId.length > 0;
|
|
266
|
-
const start = Date.now();
|
|
267
|
-
if (hasJob) {
|
|
268
|
-
activeJobId = jobId;
|
|
269
|
-
lastError = "";
|
|
270
|
-
await postHeartbeat({ state: "running", currentJobId: jobId });
|
|
271
|
-
await workerFetch(`/api/worker/jobs/${jobId}`, {
|
|
272
|
-
method: "PATCH",
|
|
273
|
-
body: JSON.stringify({ jobStatus: "running" }),
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
try {
|
|
277
|
-
const res = await fetch(`${ollamaBase}${endpoint}`, {
|
|
278
|
-
method: "POST",
|
|
279
|
-
headers: { "Content-Type": "application/json" },
|
|
280
|
-
body: JSON.stringify({
|
|
281
|
-
stream: false,
|
|
282
|
-
...requestBody,
|
|
283
|
-
}),
|
|
284
|
-
});
|
|
285
|
-
const raw = await res.text();
|
|
286
|
-
if (!res.ok) {
|
|
287
|
-
throw new Error(`Ollama ${endpoint} failed ${res.status}: ${raw}`);
|
|
288
|
-
}
|
|
289
|
-
const parsed = raw ? JSON.parse(raw) : {};
|
|
290
|
-
if (hasJob) {
|
|
291
|
-
await workerFetch(`/api/worker/jobs/${jobId}`, {
|
|
292
|
-
method: "PATCH",
|
|
293
|
-
body: JSON.stringify({
|
|
294
|
-
jobStatus: "completed",
|
|
295
|
-
resultText: extractResultText(parsed),
|
|
296
|
-
tokenCount: 1,
|
|
297
|
-
totalTimeSeconds: (Date.now() - start) / 1000,
|
|
298
|
-
}),
|
|
299
|
-
});
|
|
300
|
-
await postHeartbeat({
|
|
301
|
-
state: "idle",
|
|
302
|
-
currentJobId: "",
|
|
303
|
-
lastJobCompletedAt: new Date().toISOString(),
|
|
304
|
-
});
|
|
305
|
-
activeJobId = "";
|
|
306
|
-
}
|
|
307
|
-
return parsed;
|
|
308
|
-
} catch (e) {
|
|
309
|
-
if (hasJob) {
|
|
310
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
311
|
-
await workerFetch(`/api/worker/jobs/${jobId}`, {
|
|
312
|
-
method: "PATCH",
|
|
313
|
-
body: JSON.stringify({
|
|
314
|
-
jobStatus: "failed",
|
|
315
|
-
errorMessage: message,
|
|
316
|
-
totalTimeSeconds: (Date.now() - start) / 1000,
|
|
317
|
-
}),
|
|
318
|
-
});
|
|
319
|
-
await postHeartbeat({
|
|
320
|
-
state: "error",
|
|
321
|
-
currentJobId: jobId,
|
|
322
|
-
lastError: message,
|
|
323
|
-
});
|
|
324
|
-
lastError = message;
|
|
325
|
-
activeJobId = "";
|
|
326
|
-
}
|
|
327
|
-
throw e;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function startWebhookServer() {
|
|
332
|
-
const app = express();
|
|
333
|
-
app.use(express.json({ limit: "10mb" }));
|
|
334
|
-
|
|
335
|
-
const healthHandler = (_req, res) => {
|
|
336
|
-
const state =
|
|
337
|
-
activeJobId.length > 0 ? "running" : lastError ? "error" : "idle";
|
|
338
|
-
res.json({
|
|
339
|
-
ok: true,
|
|
340
|
-
workerVersion: WORKER_VERSION,
|
|
341
|
-
host: WORKER_HOST,
|
|
342
|
-
mode,
|
|
343
|
-
state,
|
|
344
|
-
activeJobId: activeJobId || undefined,
|
|
345
|
-
lastError: lastError || undefined,
|
|
346
|
-
dispatchPath: "/dispatch-job",
|
|
347
|
-
chatPath: "/chat",
|
|
348
|
-
generatePath: "/generate",
|
|
349
|
-
tagsPath: "/tags",
|
|
350
|
-
ollamaBase,
|
|
351
|
-
});
|
|
352
|
-
};
|
|
353
|
-
app.get("/health", healthHandler);
|
|
354
|
-
app.get("/api/health", healthHandler);
|
|
355
|
-
|
|
356
|
-
const tagsHandler = async (_req, res) => {
|
|
357
|
-
try {
|
|
358
|
-
const r = await fetch(`${ollamaBase}/api/tags`, { method: "GET" });
|
|
359
|
-
const raw = await r.text();
|
|
360
|
-
if (!r.ok) {
|
|
361
|
-
res
|
|
362
|
-
.status(r.status)
|
|
363
|
-
.json({ error: `Ollama /api/tags failed ${r.status}`, raw });
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
res.type("application/json").send(raw || "{}");
|
|
367
|
-
} catch (e) {
|
|
368
|
-
res.status(500).json({ error: e instanceof Error ? e.message : "error" });
|
|
369
|
-
}
|
|
370
|
-
};
|
|
371
|
-
app.get("/tags", tagsHandler);
|
|
372
|
-
app.get("/api/tags", tagsHandler);
|
|
373
|
-
|
|
374
|
-
const dispatchJobHandler = async (req, res) => {
|
|
375
|
-
const body = req.body ?? {};
|
|
376
|
-
if (!body?.jobId || !body?.payload) {
|
|
377
|
-
res.status(400).json({ error: "Expected { jobId, payload }." });
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
if (activeJobId) {
|
|
381
|
-
res.status(409).json({ error: "Worker busy.", activeJobId });
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
const payload = body.payload;
|
|
385
|
-
const chatBody = {
|
|
386
|
-
model: payload.modelId,
|
|
387
|
-
messages: toOllamaChatMessages(payload.messages),
|
|
388
|
-
};
|
|
389
|
-
void runOllamaAndMaybeUpdateJob({
|
|
390
|
-
endpoint: "/api/chat",
|
|
391
|
-
requestBody: chatBody,
|
|
392
|
-
jobId: String(body.jobId),
|
|
393
|
-
extractResultText: (j) => j?.message?.content ?? "",
|
|
394
|
-
}).catch((e) => {
|
|
395
|
-
console.error("[dispatch-job] error:", e instanceof Error ? e.message : e);
|
|
396
|
-
});
|
|
397
|
-
res.status(202).json({ ok: true, accepted: true, jobId: body.jobId });
|
|
398
|
-
};
|
|
399
|
-
app.post("/dispatch-job", dispatchJobHandler);
|
|
400
|
-
app.post("/api/dispatch-job", dispatchJobHandler);
|
|
401
|
-
|
|
402
|
-
const chatHandler = async (req, res) => {
|
|
403
|
-
try {
|
|
404
|
-
const body = req.body ?? {};
|
|
405
|
-
const jobId = typeof body.jobId === "string" ? body.jobId : "";
|
|
406
|
-
const requestBody =
|
|
407
|
-
body.request && typeof body.request === "object" ? body.request : body;
|
|
408
|
-
const parsed = await runOllamaAndMaybeUpdateJob({
|
|
409
|
-
endpoint: "/api/chat",
|
|
410
|
-
requestBody,
|
|
411
|
-
jobId,
|
|
412
|
-
extractResultText: (j) => j?.message?.content ?? "",
|
|
413
|
-
});
|
|
414
|
-
res.json(parsed);
|
|
415
|
-
} catch (e) {
|
|
416
|
-
res.status(500).json({ error: e instanceof Error ? e.message : "error" });
|
|
417
|
-
}
|
|
418
|
-
};
|
|
419
|
-
app.post("/chat", chatHandler);
|
|
420
|
-
app.post("/api/chat", chatHandler);
|
|
421
|
-
|
|
422
|
-
const generateHandler = async (req, res) => {
|
|
113
|
+
async function main() {
|
|
114
|
+
console.log(`[gonext-worker] polling ${apiBase} every ${pollMs}ms`);
|
|
115
|
+
for (;;) {
|
|
423
116
|
try {
|
|
424
|
-
|
|
425
|
-
const jobId = typeof body.jobId === "string" ? body.jobId : "";
|
|
426
|
-
const requestBody =
|
|
427
|
-
body.request && typeof body.request === "object" ? body.request : body;
|
|
428
|
-
const parsed = await runOllamaAndMaybeUpdateJob({
|
|
429
|
-
endpoint: "/api/generate",
|
|
430
|
-
requestBody,
|
|
431
|
-
jobId,
|
|
432
|
-
extractResultText: (j) => j?.response ?? "",
|
|
433
|
-
});
|
|
434
|
-
res.json(parsed);
|
|
117
|
+
await pollOnce();
|
|
435
118
|
} catch (e) {
|
|
436
|
-
|
|
119
|
+
console.error("[gonext-worker] poll error:", e);
|
|
437
120
|
}
|
|
438
|
-
|
|
439
|
-
app.post("/generate", generateHandler);
|
|
440
|
-
app.post("/api/generate", generateHandler);
|
|
441
|
-
|
|
442
|
-
webhookServer = app.listen(webhookPort, "127.0.0.1", () => {
|
|
443
|
-
console.log(
|
|
444
|
-
`[${ts()}] local worker API on http://127.0.0.1:${webhookPort} (/chat, /generate, /tags, /dispatch-job, /health)`
|
|
445
|
-
);
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
async function main() {
|
|
450
|
-
console.log(`[${ts()}] gonext-local-worker`);
|
|
451
|
-
console.log(` API ${apiBase}`);
|
|
452
|
-
console.log(` mode ${mode}`);
|
|
453
|
-
if (mode === "poll" || mode === "both") {
|
|
454
|
-
console.log(` poll every ${pollMs}ms (idle)`);
|
|
455
|
-
}
|
|
456
|
-
if (mode === "webhook" || mode === "both") {
|
|
457
|
-
console.log(` hook http://127.0.0.1:${webhookPort}/dispatch-job`);
|
|
458
|
-
}
|
|
459
|
-
console.log(` stop Ctrl+C`);
|
|
460
|
-
await postHeartbeat({ state: "idle", currentJobId: "" });
|
|
461
|
-
|
|
462
|
-
const loop = async () => {
|
|
463
|
-
while (!shuttingDown) {
|
|
464
|
-
try {
|
|
465
|
-
await pollOnce();
|
|
466
|
-
await postHeartbeat({ state: "idle", currentJobId: "" });
|
|
467
|
-
} catch (e) {
|
|
468
|
-
console.error(`[${ts()}] poll error:`, e instanceof Error ? e.message : e);
|
|
469
|
-
await postHeartbeat({
|
|
470
|
-
state: "error",
|
|
471
|
-
currentJobId: "",
|
|
472
|
-
lastError: e instanceof Error ? e.message : String(e),
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
if (shuttingDown) break;
|
|
476
|
-
await sleep(pollMs);
|
|
477
|
-
}
|
|
478
|
-
};
|
|
479
|
-
|
|
480
|
-
const stop = () => {
|
|
481
|
-
if (shuttingDown) return;
|
|
482
|
-
shuttingDown = true;
|
|
483
|
-
console.log(`\n[${ts()}] shutting down…`);
|
|
484
|
-
if (webhookServer) {
|
|
485
|
-
webhookServer.close();
|
|
486
|
-
}
|
|
487
|
-
process.exit(0);
|
|
488
|
-
};
|
|
489
|
-
process.on("SIGINT", stop);
|
|
490
|
-
process.on("SIGTERM", stop);
|
|
491
|
-
|
|
492
|
-
if (mode === "webhook" || mode === "both") {
|
|
493
|
-
startWebhookServer();
|
|
494
|
-
}
|
|
495
|
-
if (mode === "poll" || mode === "both") {
|
|
496
|
-
await loop();
|
|
497
|
-
} else {
|
|
498
|
-
await new Promise(() => {});
|
|
121
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
499
122
|
}
|
|
500
123
|
}
|
|
501
124
|
|
|
502
|
-
main()
|
|
503
|
-
console.error(e);
|
|
504
|
-
process.exit(1);
|
|
505
|
-
});
|
|
125
|
+
main();
|
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.8",
|
|
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",
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
"bin": {
|
|
16
16
|
"gonext-local-worker": "./gonext-local-worker.mjs"
|
|
17
17
|
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"deploy:local": "npm install -g .",
|
|
20
|
+
"run": "gonext-local-worker",
|
|
21
|
+
"publish:org": "npm publish --access public"
|
|
22
|
+
},
|
|
18
23
|
"files": [
|
|
19
24
|
"gonext-local-worker.mjs",
|
|
20
25
|
"README.md",
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
-
<plist version="1.0">
|
|
4
|
-
<dict>
|
|
5
|
-
<key>Label</key>
|
|
6
|
-
<string>com.gonext.worker</string>
|
|
7
|
-
<key>ProgramArguments</key>
|
|
8
|
-
<array>
|
|
9
|
-
<!-- Replace with output of: which gonext-local-worker -->
|
|
10
|
-
<string>/usr/local/bin/gonext-local-worker</string>
|
|
11
|
-
</array>
|
|
12
|
-
<!-- Optional: uncomment to force Node path -->
|
|
13
|
-
<!--
|
|
14
|
-
<array>
|
|
15
|
-
<string>/usr/local/bin/node</string>
|
|
16
|
-
<string>/ABSOLUTE/PATH/TO/gonext/tools/gonext-local-worker/gonext-local-worker.mjs</string>
|
|
17
|
-
</array>
|
|
18
|
-
-->
|
|
19
|
-
<key>RunAtLoad</key>
|
|
20
|
-
<true/>
|
|
21
|
-
<key>KeepAlive</key>
|
|
22
|
-
<true/>
|
|
23
|
-
<key>StandardOutPath</key>
|
|
24
|
-
<string>/tmp/gonext-worker.log</string>
|
|
25
|
-
<key>StandardErrorPath</key>
|
|
26
|
-
<string>/tmp/gonext-worker.err</string>
|
|
27
|
-
<key>EnvironmentVariables</key>
|
|
28
|
-
<dict>
|
|
29
|
-
<!-- Or rely on ~/.gonext/worker.env (loaded by the worker) -->
|
|
30
|
-
<key>PATH</key>
|
|
31
|
-
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
|
|
32
|
-
</dict>
|
|
33
|
-
</dict>
|
|
34
|
-
</plist>
|