@tiens.nguyen/gonext-local-worker 1.0.6 → 1.0.7
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/package.json +6 -1
- package/README.md +0 -120
- package/gonext-local-worker.mjs +0 -505
- package/launchd/com.gonext.worker.plist.example +0 -34
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.7",
|
|
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",
|
package/README.md
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
# gonext-local-worker
|
|
2
|
-
|
|
3
|
-
Runs on **your Mac** next to **Ollama** or any **OpenAI-compatible** local server. It:
|
|
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).
|
package/gonext-local-worker.mjs
DELETED
|
@@ -1,505 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* GoNext local LLM worker — runs on the Mac where Ollama / MLX HTTP server lives.
|
|
4
|
-
* Polls your cloud API for jobs, calls the model locally, PATCHes status back.
|
|
5
|
-
*
|
|
6
|
-
* Env:
|
|
7
|
-
* GONEXT_API_BASE HTTPS origin only (no trailing slash), e.g. https://xxx.execute-api....amazonaws.com
|
|
8
|
-
* GONEXT_WORKER_USER_ID Firebase user id (owner of queued jobs)
|
|
9
|
-
* GONEXT_POLL_MS Poll interval when idle (default 1500)
|
|
10
|
-
*
|
|
11
|
-
* Optional env files (loaded in order; shell exports win if set before launch):
|
|
12
|
-
* ~/.gonext/worker.env
|
|
13
|
-
* ./.env (cwd)
|
|
14
|
-
*/
|
|
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
|
-
import OpenAI from "openai";
|
|
21
|
-
|
|
22
|
-
dotenv.config({ path: join(homedir(), ".gonext", "worker.env") });
|
|
23
|
-
dotenv.config();
|
|
24
|
-
|
|
25
|
-
function parseArgs(argv) {
|
|
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) {
|
|
101
|
-
console.error(
|
|
102
|
-
"Missing GONEXT_API_BASE or GONEXT_WORKER_USER_ID.\nSet them or put them in ~/.gonext/worker.env — run with --help."
|
|
103
|
-
);
|
|
104
|
-
process.exit(1);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const WORKER_VERSION = "1.0.0";
|
|
108
|
-
const WORKER_HOST = os.hostname();
|
|
109
|
-
function ts() {
|
|
110
|
-
return new Date().toISOString();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function toOpenAIMessages(messages) {
|
|
114
|
-
return messages.map((m) => {
|
|
115
|
-
if (m.role === "user" && m.attachments?.length) {
|
|
116
|
-
return {
|
|
117
|
-
role: m.role,
|
|
118
|
-
content: [
|
|
119
|
-
{ type: "text", text: m.content },
|
|
120
|
-
...m.attachments.map((a) => ({
|
|
121
|
-
type: "image_url",
|
|
122
|
-
image_url: { url: `data:${a.mimeType};base64,${a.data}` },
|
|
123
|
-
})),
|
|
124
|
-
],
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
return { role: m.role, content: m.content };
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async function workerFetch(path, init = {}) {
|
|
132
|
-
const url = `${apiBase}${path.startsWith("/") ? path : `/${path}`}`;
|
|
133
|
-
const headers = {
|
|
134
|
-
"Content-Type": "application/json",
|
|
135
|
-
"X-Worker-User-Id": workerUserId,
|
|
136
|
-
...(init.headers ?? {}),
|
|
137
|
-
};
|
|
138
|
-
return fetch(url, { ...init, headers });
|
|
139
|
-
}
|
|
140
|
-
|
|
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
|
-
async function runChatJob(job) {
|
|
162
|
-
const { jobId, payload } = job;
|
|
163
|
-
const start = Date.now();
|
|
164
|
-
activeJobId = jobId;
|
|
165
|
-
lastError = "";
|
|
166
|
-
await postHeartbeat({ state: "running", currentJobId: jobId });
|
|
167
|
-
const patchRunning = await workerFetch(`/api/worker/jobs/${jobId}`, {
|
|
168
|
-
method: "PATCH",
|
|
169
|
-
body: JSON.stringify({ jobStatus: "running" }),
|
|
170
|
-
});
|
|
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
|
-
|
|
184
|
-
const client = new OpenAI({
|
|
185
|
-
baseURL: payload.baseURL,
|
|
186
|
-
apiKey: payload.apiKey || "ollama",
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
try {
|
|
190
|
-
const completion = await client.chat.completions.create({
|
|
191
|
-
model: payload.modelId,
|
|
192
|
-
messages: toOpenAIMessages(payload.messages),
|
|
193
|
-
temperature: 0,
|
|
194
|
-
});
|
|
195
|
-
const text = completion.choices[0]?.message?.content ?? "";
|
|
196
|
-
const totalTimeSeconds = (Date.now() - start) / 1000;
|
|
197
|
-
await workerFetch(`/api/worker/jobs/${jobId}`, {
|
|
198
|
-
method: "PATCH",
|
|
199
|
-
body: JSON.stringify({
|
|
200
|
-
jobStatus: "completed",
|
|
201
|
-
resultText: text,
|
|
202
|
-
tokenCount: Math.max(1, completion.usage?.total_tokens ?? 1),
|
|
203
|
-
totalTimeSeconds,
|
|
204
|
-
}),
|
|
205
|
-
});
|
|
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 = "";
|
|
215
|
-
} catch (e) {
|
|
216
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
217
|
-
await workerFetch(`/api/worker/jobs/${jobId}`, {
|
|
218
|
-
method: "PATCH",
|
|
219
|
-
body: JSON.stringify({
|
|
220
|
-
jobStatus: "failed",
|
|
221
|
-
errorMessage: message,
|
|
222
|
-
totalTimeSeconds: (Date.now() - start) / 1000,
|
|
223
|
-
}),
|
|
224
|
-
});
|
|
225
|
-
console.error(`[${ts()}] failed job ${jobId}:`, message);
|
|
226
|
-
await postHeartbeat({
|
|
227
|
-
state: "error",
|
|
228
|
-
currentJobId: jobId,
|
|
229
|
-
lastError: message,
|
|
230
|
-
});
|
|
231
|
-
lastError = message;
|
|
232
|
-
activeJobId = "";
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
async function pollOnce() {
|
|
237
|
-
const res = await workerFetch("/api/worker/jobs/next", { method: "POST" });
|
|
238
|
-
if (res.status === 204) return;
|
|
239
|
-
if (!res.ok) {
|
|
240
|
-
const t = await res.text().catch(() => "");
|
|
241
|
-
throw new Error(`POST /api/worker/jobs/next → ${res.status}: ${t}`);
|
|
242
|
-
}
|
|
243
|
-
const job = await res.json();
|
|
244
|
-
if (job?.jobId && job.payload) {
|
|
245
|
-
await runChatJob(job);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function sleep(ms) {
|
|
250
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
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) => {
|
|
423
|
-
try {
|
|
424
|
-
const body = req.body ?? {};
|
|
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);
|
|
435
|
-
} catch (e) {
|
|
436
|
-
res.status(500).json({ error: e instanceof Error ? e.message : "error" });
|
|
437
|
-
}
|
|
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(() => {});
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
main().catch((e) => {
|
|
503
|
-
console.error(e);
|
|
504
|
-
process.exit(1);
|
|
505
|
-
});
|
|
@@ -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>
|