@maintainer-pro/ai-server 0.1.0 → 0.1.2
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 -2
- package/package.json +3 -2
- package/src/server.mjs +584 -25
- package/src/ws.mjs +243 -0
package/README.md
CHANGED
|
@@ -23,7 +23,8 @@ Requires **Node.js 22+**, [`@maintainer-pro/ai-ui`](https://www.npmjs.com/packag
|
|
|
23
23
|
| Path | Purpose |
|
|
24
24
|
|------|---------|
|
|
25
25
|
| `GET` / `POST /api/chat` | Chat (agent + Maintainer Pro store) |
|
|
26
|
-
| `
|
|
26
|
+
| `WS /api/ws` (also `/ws`) | Live AI events (`subscribe`, `chat.run`, `message.created`) |
|
|
27
|
+
| `GET /embed-config.js` | `window.__MAINTAINER_PRO__` — `aiServerUrl`, `apiUrl`, `aiServerWsUrl`, client keys |
|
|
27
28
|
| `GET /ai-ui.iife.js` | Embed widget from `node_modules/@maintainer-pro/ai-ui` (unless a file exists in `AI_SERVER_UI`) |
|
|
28
29
|
| other paths | Static files from `AI_SERVER_UI` |
|
|
29
30
|
|
|
@@ -41,6 +42,7 @@ A copied `ai-ui.iife.js` in the UI folder is served first and will not update wh
|
|
|
41
42
|
| `MAINTAINER_PRO_URL` | Admin base URL |
|
|
42
43
|
| `MAINTAINER_PRO_API_KEY` | Server API key |
|
|
43
44
|
| `MAINTAINER_PRO_CLIENT_API_KEY` | Client key (served at `/embed-config.js`) |
|
|
45
|
+
| `AI_SERVER_URL` | Public chat origin (e.g. `http://localhost:3100`). Included in `/embed-config.js` as `aiServerUrl` / `apiUrl`. If unset, derived from the request Host. |
|
|
44
46
|
| `AI_CLI_PROVIDER` | `auto` \| `claude` \| `cursor` \| `antigravity` |
|
|
45
47
|
| `AI_SERVER_PRODUCT_DESCRIPTION` | Optional product text for the agent prompt |
|
|
46
48
|
|
|
@@ -54,4 +56,3 @@ npx --yes @maintainer-pro/ai-server --port 3100 --ui . --workspace .
|
|
|
54
56
|
|---------|------|
|
|
55
57
|
| [`@maintainer-pro/ai-cli`](https://www.npmjs.com/package/@maintainer-pro/ai-cli) | Talks to coding agents |
|
|
56
58
|
| [`@maintainer-pro/ai-ui`](https://www.npmjs.com/package/@maintainer-pro/ai-ui) | Embed widget (`/ai-ui.iife.js`) |
|
|
57
|
-
| [`@maintainer-pro/setup`](https://www.npmjs.com/package/@maintainer-pro/setup) | Download the sandbox setup guide |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maintainer-pro/ai-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Node HTTP server for Maintainer Pro chat. Uses @maintainer-pro/ai-cli to talk to coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"maintainer-pro",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"access": "public"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@maintainer-pro/ai-cli": "^0.1.
|
|
29
|
+
"@maintainer-pro/ai-cli": "^0.1.5",
|
|
30
|
+
"ws": "^8.21.3"
|
|
30
31
|
},
|
|
31
32
|
"engines": {
|
|
32
33
|
"node": ">=22"
|
package/src/server.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Config from .env (or --env-file). CLI flags override env.
|
|
7
7
|
*/
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
8
9
|
import fs from "node:fs";
|
|
9
10
|
import http from "node:http";
|
|
10
11
|
import path from "node:path";
|
|
@@ -12,14 +13,12 @@ import { fileURLToPath } from "node:url";
|
|
|
12
13
|
import {
|
|
13
14
|
createChatHandler,
|
|
14
15
|
createDefaultSystemPrompt,
|
|
16
|
+
createLogger,
|
|
15
17
|
createMaintainerProStoreFromEnv,
|
|
18
|
+
parseIgnorePathsEnv,
|
|
19
|
+
resolveIgnorePaths,
|
|
16
20
|
} from "@maintainer-pro/ai-cli";
|
|
17
|
-
|
|
18
|
-
const log = (msg) => console.log(`[ai-server] ${msg}`);
|
|
19
|
-
const fail = (msg) => {
|
|
20
|
-
console.error(`[ai-server] ${msg}`);
|
|
21
|
-
process.exit(1);
|
|
22
|
-
};
|
|
21
|
+
import { attachChatWebSocket } from "./ws.mjs";
|
|
23
22
|
|
|
24
23
|
function parseArgs(argv) {
|
|
25
24
|
/** @type {Record<string, string | boolean>} */
|
|
@@ -59,7 +58,8 @@ function loadEnvFile(file) {
|
|
|
59
58
|
}
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
|
|
61
|
+
const args = parseArgs(process.argv.slice(2));
|
|
62
|
+
if (args.help) {
|
|
63
63
|
console.log(`Maintainer Pro AI server
|
|
64
64
|
|
|
65
65
|
Usage:
|
|
@@ -73,9 +73,32 @@ Flags override .env. Preferred config is env (from the admin setup guide).
|
|
|
73
73
|
--workspace <dir> Agent edit root (AI_CLI_WORKSPACE)
|
|
74
74
|
--env-file <path> Env file (default: ./.env)
|
|
75
75
|
--cors-origin <url> CORS origin (CORS_ORIGIN)
|
|
76
|
+
|
|
77
|
+
Logging (env):
|
|
78
|
+
LOG_LEVEL=info|debug|warn|error|silent
|
|
79
|
+
AI_LOG_LEVEL=… alias for LOG_LEVEL
|
|
80
|
+
NODE_ENV=development defaults to debug logs (auto if unset)
|
|
81
|
+
LOG_PRETTY=0 disable pretty TTY output
|
|
76
82
|
`);
|
|
83
|
+
process.exit(0);
|
|
77
84
|
}
|
|
78
85
|
|
|
86
|
+
const envFile = path.resolve(
|
|
87
|
+
typeof args.envFile === "string" ? args.envFile : path.join(process.cwd(), ".env")
|
|
88
|
+
);
|
|
89
|
+
loadEnvFile(envFile);
|
|
90
|
+
|
|
91
|
+
// Local sidecar defaults to development (debug logs) unless explicitly set.
|
|
92
|
+
if (!process.env.NODE_ENV?.trim()) {
|
|
93
|
+
process.env.NODE_ENV = "development";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const logger = createLogger("ai-server");
|
|
97
|
+
const fail = (msg) => {
|
|
98
|
+
logger.fatal(msg);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
};
|
|
101
|
+
|
|
79
102
|
function contentType(file) {
|
|
80
103
|
if (file.endsWith(".html")) return "text/html; charset=utf-8";
|
|
81
104
|
if (file.endsWith(".js")) return "text/javascript; charset=utf-8";
|
|
@@ -138,17 +161,12 @@ async function send(res, response) {
|
|
|
138
161
|
res.end(Buffer.from(await response.arrayBuffer()));
|
|
139
162
|
}
|
|
140
163
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
164
|
+
function toHttpWsUrl(httpUrl) {
|
|
165
|
+
const u = new URL(httpUrl);
|
|
166
|
+
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
|
|
167
|
+
return u.toString().replace(/\/$/, "");
|
|
145
168
|
}
|
|
146
169
|
|
|
147
|
-
const envFile = path.resolve(
|
|
148
|
-
typeof args.envFile === "string" ? args.envFile : path.join(process.cwd(), ".env")
|
|
149
|
-
);
|
|
150
|
-
loadEnvFile(envFile);
|
|
151
|
-
|
|
152
170
|
const port = Number(args.port || process.env.PORT || 3000);
|
|
153
171
|
const workspaceDir = path.resolve(
|
|
154
172
|
args.workspace || process.env.AI_CLI_WORKSPACE || process.cwd()
|
|
@@ -162,7 +180,22 @@ const corsOrigin =
|
|
|
162
180
|
|
|
163
181
|
process.env.AI_CLI_WORKSPACE = workspaceDir;
|
|
164
182
|
|
|
165
|
-
const
|
|
183
|
+
const runningTurns = new Set();
|
|
184
|
+
/** One active AI turn per conversation — prevents raced double promotes. */
|
|
185
|
+
const runningByConversation = new Map();
|
|
186
|
+
/** Turns that arrived while the conversation was busy — run after unlock. */
|
|
187
|
+
const deferredTurns = new Map();
|
|
188
|
+
/** @type {{ publish: (conversationId: string, payload: Record<string, unknown>) => void; close: () => void }} */
|
|
189
|
+
let hub = { publish() {}, close() {} };
|
|
190
|
+
/** @type {(event: { conversationId: string; message: { id?: string; content?: string; senderType?: string | null; senderName?: string | null } }) => Promise<void>} */
|
|
191
|
+
let runWorkingTurn = async () => {};
|
|
192
|
+
|
|
193
|
+
const db = createMaintainerProStoreFromEnv({
|
|
194
|
+
logger,
|
|
195
|
+
onWorkingTurn: (event) => {
|
|
196
|
+
void runWorkingTurn(event);
|
|
197
|
+
},
|
|
198
|
+
});
|
|
166
199
|
if (!db) {
|
|
167
200
|
fail(
|
|
168
201
|
`Set MAINTAINER_PRO_URL and MAINTAINER_PRO_API_KEY (env file: ${envFile}).`
|
|
@@ -173,14 +206,255 @@ const productDescription =
|
|
|
173
206
|
process.env.AI_SERVER_PRODUCT_DESCRIPTION?.trim() ||
|
|
174
207
|
"This product uses Maintainer Pro. When the user asks for a change, edit source files in the workspace using the coding agent CLI. Do not invent JSON tool calls.";
|
|
175
208
|
|
|
209
|
+
const ignorePaths = resolveIgnorePaths(
|
|
210
|
+
parseIgnorePathsEnv(process.env.AI_CLI_IGNORE_PATHS)
|
|
211
|
+
);
|
|
212
|
+
|
|
176
213
|
const handlers = createChatHandler({
|
|
177
|
-
systemPrompt: createDefaultSystemPrompt({ productDescription }),
|
|
214
|
+
systemPrompt: createDefaultSystemPrompt({ productDescription, ignorePaths }),
|
|
178
215
|
workspaceDir,
|
|
179
216
|
db,
|
|
217
|
+
logger,
|
|
180
218
|
});
|
|
181
219
|
|
|
220
|
+
async function historyForTurn(conversationId, userMessage, userMessageId) {
|
|
221
|
+
const history = db.listMessages ? await db.listMessages(conversationId) : [];
|
|
222
|
+
let rows = history.filter(
|
|
223
|
+
(row) =>
|
|
224
|
+
(row.role === "user" || row.role === "assistant") &&
|
|
225
|
+
row.provider !== "working" &&
|
|
226
|
+
Boolean(row.content?.trim())
|
|
227
|
+
);
|
|
228
|
+
// Exclude later queued user turns so the model answers the current working
|
|
229
|
+
// message, not messages still waiting behind it.
|
|
230
|
+
if (userMessageId) {
|
|
231
|
+
const end = rows.findIndex((row) => row.id === userMessageId);
|
|
232
|
+
if (end >= 0) {
|
|
233
|
+
rows = rows.slice(0, end + 1);
|
|
234
|
+
} else {
|
|
235
|
+
rows = rows.filter(
|
|
236
|
+
(row) =>
|
|
237
|
+
row.queueStatus !== "queued" ||
|
|
238
|
+
row.content === userMessage
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
rows = rows.filter((row) => row.queueStatus !== "queued");
|
|
243
|
+
}
|
|
244
|
+
const messages = rows.map((row) => ({ role: row.role, content: row.content }));
|
|
245
|
+
if (
|
|
246
|
+
userMessage &&
|
|
247
|
+
!messages.some(
|
|
248
|
+
(row) => row.role === "user" && row.content === userMessage
|
|
249
|
+
)
|
|
250
|
+
) {
|
|
251
|
+
messages.push({ role: "user", content: userMessage });
|
|
252
|
+
}
|
|
253
|
+
return messages;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function runChatPost(body) {
|
|
257
|
+
logger.debug(
|
|
258
|
+
{
|
|
259
|
+
conversationId: body.conversationId,
|
|
260
|
+
skipPersistUser: body.skipPersistUser,
|
|
261
|
+
userMessageId: body.userMessageId,
|
|
262
|
+
messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
|
|
263
|
+
},
|
|
264
|
+
"runChatPost"
|
|
265
|
+
);
|
|
266
|
+
const response = await handlers.POST(
|
|
267
|
+
new Request("http://127.0.0.1/api/chat", {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers: { "content-type": "application/json" },
|
|
270
|
+
body: JSON.stringify(body),
|
|
271
|
+
})
|
|
272
|
+
);
|
|
273
|
+
const text = await response.text();
|
|
274
|
+
let data = {};
|
|
275
|
+
try {
|
|
276
|
+
data = text ? JSON.parse(text) : {};
|
|
277
|
+
} catch {
|
|
278
|
+
data = { error: text || "invalid chat response" };
|
|
279
|
+
}
|
|
280
|
+
logger.debug(
|
|
281
|
+
{ conversationId: body.conversationId, ok: response.ok, status: response.status },
|
|
282
|
+
"runChatPost done"
|
|
283
|
+
);
|
|
284
|
+
return { ok: response.ok, status: response.status, data };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function publishChatResult(conversationId, result, fallbackMessageId, parentMessageId) {
|
|
288
|
+
if (!conversationId) return;
|
|
289
|
+
if (!result.ok) {
|
|
290
|
+
logger.debug({ conversationId, status: result.status }, "publish chat.run.failed");
|
|
291
|
+
hub.publish(conversationId, {
|
|
292
|
+
type: "chat.run.failed",
|
|
293
|
+
conversationId,
|
|
294
|
+
error: result.data?.error ?? `chat failed (${result.status})`,
|
|
295
|
+
userMessageId: parentMessageId,
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const reply = typeof result.data?.text === "string" ? result.data.text : "";
|
|
300
|
+
if (!reply) {
|
|
301
|
+
logger.debug({ conversationId }, "skip publish (empty reply)");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const messageId =
|
|
305
|
+
typeof result.data?.messageId === "string"
|
|
306
|
+
? result.data.messageId
|
|
307
|
+
: fallbackMessageId;
|
|
308
|
+
logger.debug(
|
|
309
|
+
{ conversationId, messageId, provider: result.data?.provider, chars: reply.length },
|
|
310
|
+
"publish message.created"
|
|
311
|
+
);
|
|
312
|
+
hub.publish(conversationId, {
|
|
313
|
+
type: "message.created",
|
|
314
|
+
conversationId,
|
|
315
|
+
message: {
|
|
316
|
+
id: messageId,
|
|
317
|
+
role: "assistant",
|
|
318
|
+
content: reply,
|
|
319
|
+
senderType: "ai",
|
|
320
|
+
senderName: "AI",
|
|
321
|
+
provider: result.data?.provider ?? null,
|
|
322
|
+
parentMessageId: parentMessageId ?? null,
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
runWorkingTurn = async (event) => {
|
|
328
|
+
const userMessageId = event.message.id;
|
|
329
|
+
const content = event.message.content ?? "";
|
|
330
|
+
const conversationId = event.conversationId;
|
|
331
|
+
if (!userMessageId || !content.trim()) {
|
|
332
|
+
logger.debug({ event }, "skip working turn (empty)");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (runningTurns.has(userMessageId)) {
|
|
336
|
+
logger.debug({ userMessageId }, "skip working turn (already running)");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const busy = runningByConversation.get(conversationId);
|
|
340
|
+
if (busy && busy !== userMessageId) {
|
|
341
|
+
deferredTurns.set(conversationId, event);
|
|
342
|
+
logger.info(
|
|
343
|
+
{ conversationId, busy, userMessageId },
|
|
344
|
+
"defer working turn (conversation busy)"
|
|
345
|
+
);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
runningTurns.add(userMessageId);
|
|
349
|
+
runningByConversation.set(conversationId, userMessageId);
|
|
350
|
+
let nextQueued = null;
|
|
351
|
+
try {
|
|
352
|
+
const messages = await historyForTurn(conversationId, content, userMessageId);
|
|
353
|
+
if (!messages.length) {
|
|
354
|
+
messages.push({ role: "user", content });
|
|
355
|
+
}
|
|
356
|
+
logger.info(
|
|
357
|
+
{
|
|
358
|
+
conversationId,
|
|
359
|
+
userMessageId,
|
|
360
|
+
historyLength: messages.length,
|
|
361
|
+
},
|
|
362
|
+
"ai turn"
|
|
363
|
+
);
|
|
364
|
+
hub.publish(conversationId, {
|
|
365
|
+
type: "chat.turn",
|
|
366
|
+
conversationId,
|
|
367
|
+
userMessageId,
|
|
368
|
+
});
|
|
369
|
+
const assistantMessageId = randomUUID();
|
|
370
|
+
const result = await runChatPost({
|
|
371
|
+
conversationId,
|
|
372
|
+
messages,
|
|
373
|
+
userMessage: content,
|
|
374
|
+
skipPersistUser: true,
|
|
375
|
+
userMessageId,
|
|
376
|
+
assistantMessageId,
|
|
377
|
+
senderType: event.message.senderType === "client" ? "client" : undefined,
|
|
378
|
+
senderName: event.message.senderName ?? undefined,
|
|
379
|
+
});
|
|
380
|
+
publishChatResult(conversationId, result, assistantMessageId, userMessageId);
|
|
381
|
+
if (!result.ok) {
|
|
382
|
+
logger.warn({ status: result.status }, "ai turn failed");
|
|
383
|
+
} else if (
|
|
384
|
+
result.data?.nextQueued &&
|
|
385
|
+
typeof result.data.nextQueued.id === "string"
|
|
386
|
+
) {
|
|
387
|
+
nextQueued = result.data.nextQueued;
|
|
388
|
+
}
|
|
389
|
+
} catch (err) {
|
|
390
|
+
logger.error({ err }, "ai turn failed");
|
|
391
|
+
hub.publish(conversationId, {
|
|
392
|
+
type: "chat.run.failed",
|
|
393
|
+
conversationId,
|
|
394
|
+
error: err instanceof Error ? err.message : String(err),
|
|
395
|
+
userMessageId,
|
|
396
|
+
});
|
|
397
|
+
} finally {
|
|
398
|
+
runningTurns.delete(userMessageId);
|
|
399
|
+
if (runningByConversation.get(conversationId) === userMessageId) {
|
|
400
|
+
runningByConversation.delete(conversationId);
|
|
401
|
+
}
|
|
402
|
+
// Prefer explicit next from complete-turn; else a deferred becameWorking event.
|
|
403
|
+
const deferred = deferredTurns.get(conversationId);
|
|
404
|
+
if (deferred) deferredTurns.delete(conversationId);
|
|
405
|
+
const followUp =
|
|
406
|
+
nextQueued?.id && nextQueued.content?.trim()
|
|
407
|
+
? {
|
|
408
|
+
conversationId,
|
|
409
|
+
message: {
|
|
410
|
+
id: nextQueued.id,
|
|
411
|
+
content: nextQueued.content,
|
|
412
|
+
senderType: "client",
|
|
413
|
+
},
|
|
414
|
+
}
|
|
415
|
+
: deferred && deferred.message?.id !== userMessageId
|
|
416
|
+
? deferred
|
|
417
|
+
: null;
|
|
418
|
+
if (followUp) {
|
|
419
|
+
logger.info(
|
|
420
|
+
{
|
|
421
|
+
conversationId,
|
|
422
|
+
from: userMessageId,
|
|
423
|
+
next: followUp.message.id,
|
|
424
|
+
via: nextQueued?.id ? "nextQueued" : "deferred",
|
|
425
|
+
},
|
|
426
|
+
"chain next queued turn"
|
|
427
|
+
);
|
|
428
|
+
queueMicrotask(() => {
|
|
429
|
+
void runWorkingTurn(followUp);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
function resolveAiServerUrl(req) {
|
|
436
|
+
const fromEnv = (
|
|
437
|
+
process.env.AI_SERVER_URL ||
|
|
438
|
+
process.env.AI_SERVER_BASE_URL ||
|
|
439
|
+
""
|
|
440
|
+
)
|
|
441
|
+
.trim()
|
|
442
|
+
.replace(/\/$/, "");
|
|
443
|
+
if (fromEnv) return fromEnv;
|
|
444
|
+
const host = req.headers.host?.trim();
|
|
445
|
+
if (host) {
|
|
446
|
+
const xfProto = String(req.headers["x-forwarded-proto"] ?? "")
|
|
447
|
+
.split(",")[0]
|
|
448
|
+
?.trim();
|
|
449
|
+
const proto = xfProto || "http";
|
|
450
|
+
return `${proto}://${host}`;
|
|
451
|
+
}
|
|
452
|
+
return `http://localhost:${port}`;
|
|
453
|
+
}
|
|
454
|
+
|
|
182
455
|
const server = http.createServer((req, res) => {
|
|
183
456
|
const pathname = (req.url ?? "/").split("?")[0];
|
|
457
|
+
logger.debug({ method: req.method, pathname }, "http");
|
|
184
458
|
res.setHeader("Access-Control-Allow-Origin", corsOrigin);
|
|
185
459
|
res.setHeader("Access-Control-Allow-Headers", "content-type, authorization");
|
|
186
460
|
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
|
@@ -190,24 +464,61 @@ const server = http.createServer((req, res) => {
|
|
|
190
464
|
return;
|
|
191
465
|
}
|
|
192
466
|
if (pathname === "/embed-config.js") {
|
|
193
|
-
const
|
|
467
|
+
const aiServerUrl = resolveAiServerUrl(req);
|
|
468
|
+
const payload = {
|
|
469
|
+
aiServerUrl,
|
|
470
|
+
apiUrl: `${aiServerUrl}/api/chat`,
|
|
471
|
+
aiServerWsUrl: `${toHttpWsUrl(aiServerUrl)}/api/ws`,
|
|
472
|
+
debug: true,
|
|
473
|
+
logLevel: "debug",
|
|
194
474
|
maintainerProUrl: process.env.MAINTAINER_PRO_URL ?? "",
|
|
195
475
|
maintainerProApiKey:
|
|
196
476
|
process.env.MAINTAINER_PRO_CLIENT_API_KEY ??
|
|
197
477
|
process.env.NEXT_PUBLIC_MAINTAINER_PRO_CLIENT_API_KEY ??
|
|
198
478
|
"",
|
|
199
479
|
};
|
|
480
|
+
logger.debug({ aiServerUrl }, "embed-config");
|
|
200
481
|
res.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
|
201
482
|
res.end(`window.__MAINTAINER_PRO__=${JSON.stringify(payload)};`);
|
|
202
483
|
return;
|
|
203
484
|
}
|
|
204
485
|
if (pathname === "/api/chat") {
|
|
486
|
+
logger.info({ method: req.method }, "http /api/chat");
|
|
205
487
|
const handler = req.method === "GET" ? handlers.GET : handlers.POST;
|
|
206
488
|
void toFetchRequest(req)
|
|
207
|
-
.then((request) =>
|
|
489
|
+
.then(async (request) => {
|
|
490
|
+
const response = await handler(request);
|
|
491
|
+
if (req.method === "POST") {
|
|
492
|
+
const clone = response.clone();
|
|
493
|
+
const text = await clone.text();
|
|
494
|
+
let data = {};
|
|
495
|
+
try {
|
|
496
|
+
data = text ? JSON.parse(text) : {};
|
|
497
|
+
} catch {
|
|
498
|
+
data = {};
|
|
499
|
+
}
|
|
500
|
+
const conversationId =
|
|
501
|
+
typeof data.conversationId === "string" ? data.conversationId : "";
|
|
502
|
+
const parentMessageId =
|
|
503
|
+
typeof data.parentMessageId === "string"
|
|
504
|
+
? data.parentMessageId
|
|
505
|
+
: undefined;
|
|
506
|
+
publishChatResult(
|
|
507
|
+
conversationId,
|
|
508
|
+
{
|
|
509
|
+
ok: response.ok,
|
|
510
|
+
status: response.status,
|
|
511
|
+
data,
|
|
512
|
+
},
|
|
513
|
+
undefined,
|
|
514
|
+
parentMessageId
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
return response;
|
|
518
|
+
})
|
|
208
519
|
.then((response) => send(res, response))
|
|
209
520
|
.catch((err) => {
|
|
210
|
-
|
|
521
|
+
logger.error({ err }, "chat handler failed");
|
|
211
522
|
res.statusCode = 500;
|
|
212
523
|
res.end("Chat handler failed");
|
|
213
524
|
});
|
|
@@ -223,8 +534,256 @@ const server = http.createServer((req, res) => {
|
|
|
223
534
|
fs.createReadStream(file).pipe(res);
|
|
224
535
|
});
|
|
225
536
|
|
|
226
|
-
server
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
537
|
+
hub = attachChatWebSocket(server, {
|
|
538
|
+
logger,
|
|
539
|
+
onChatRun: async (msg) => {
|
|
540
|
+
const conversationId =
|
|
541
|
+
typeof msg.conversationId === "string" ? msg.conversationId : "";
|
|
542
|
+
const userMessage =
|
|
543
|
+
typeof msg.userMessage === "string"
|
|
544
|
+
? msg.userMessage
|
|
545
|
+
: typeof msg.content === "string"
|
|
546
|
+
? msg.content
|
|
547
|
+
: "";
|
|
548
|
+
const userMessageId =
|
|
549
|
+
typeof msg.userMessageId === "string" ? msg.userMessageId : undefined;
|
|
550
|
+
logger.info(
|
|
551
|
+
{
|
|
552
|
+
conversationId,
|
|
553
|
+
userMessageId,
|
|
554
|
+
chars: userMessage.length,
|
|
555
|
+
hasMessages: Array.isArray(msg.messages),
|
|
556
|
+
messageCount: Array.isArray(msg.messages) ? msg.messages.length : 0,
|
|
557
|
+
skipPersistUser: msg.skipPersistUser,
|
|
558
|
+
},
|
|
559
|
+
"ws chat.run"
|
|
560
|
+
);
|
|
561
|
+
if (userMessageId && runningTurns.has(userMessageId)) {
|
|
562
|
+
logger.debug({ userMessageId }, "ws chat.run duplicate");
|
|
563
|
+
return { conversationId, accepted: true, duplicate: true };
|
|
564
|
+
}
|
|
565
|
+
if (conversationId) {
|
|
566
|
+
const busy = runningByConversation.get(conversationId);
|
|
567
|
+
if (busy && busy !== userMessageId) {
|
|
568
|
+
if (userMessageId && userMessage.trim()) {
|
|
569
|
+
deferredTurns.set(conversationId, {
|
|
570
|
+
conversationId,
|
|
571
|
+
message: {
|
|
572
|
+
id: userMessageId,
|
|
573
|
+
content: userMessage,
|
|
574
|
+
senderType:
|
|
575
|
+
typeof msg.senderType === "string" ? msg.senderType : "client",
|
|
576
|
+
senderName:
|
|
577
|
+
typeof msg.senderName === "string" ? msg.senderName : null,
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
logger.info(
|
|
581
|
+
{ conversationId, busy, userMessageId },
|
|
582
|
+
"ws chat.run deferred (conversation busy)"
|
|
583
|
+
);
|
|
584
|
+
return { conversationId, accepted: true, deferred: true };
|
|
585
|
+
}
|
|
586
|
+
logger.warn(
|
|
587
|
+
{ conversationId, busy, userMessageId },
|
|
588
|
+
"ws chat.run skipped (conversation busy)"
|
|
589
|
+
);
|
|
590
|
+
return { conversationId, accepted: false, busy: true };
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
let messages = Array.isArray(msg.messages)
|
|
594
|
+
? msg.messages.filter(
|
|
595
|
+
(row) =>
|
|
596
|
+
row &&
|
|
597
|
+
(row.role === "user" || row.role === "assistant") &&
|
|
598
|
+
typeof row.content === "string"
|
|
599
|
+
)
|
|
600
|
+
: [];
|
|
601
|
+
if (!messages.length && conversationId) {
|
|
602
|
+
messages = await historyForTurn(conversationId, userMessage, userMessageId);
|
|
603
|
+
logger.info(
|
|
604
|
+
{ conversationId, history: messages.length },
|
|
605
|
+
"ws chat.run loaded history"
|
|
606
|
+
);
|
|
607
|
+
} else if (
|
|
608
|
+
userMessage &&
|
|
609
|
+
!messages.some((row) => row.role === "user" && row.content === userMessage)
|
|
610
|
+
) {
|
|
611
|
+
messages.push({ role: "user", content: userMessage });
|
|
612
|
+
}
|
|
613
|
+
const assistantMessageId =
|
|
614
|
+
typeof msg.assistantMessageId === "string" && msg.assistantMessageId
|
|
615
|
+
? msg.assistantMessageId
|
|
616
|
+
: randomUUID();
|
|
617
|
+
if (userMessageId) runningTurns.add(userMessageId);
|
|
618
|
+
if (conversationId && userMessageId) {
|
|
619
|
+
runningByConversation.set(conversationId, userMessageId);
|
|
620
|
+
}
|
|
621
|
+
let nextQueued = null;
|
|
622
|
+
try {
|
|
623
|
+
hub.publish(conversationId, {
|
|
624
|
+
type: "chat.turn",
|
|
625
|
+
conversationId,
|
|
626
|
+
userMessageId,
|
|
627
|
+
});
|
|
628
|
+
const result = await runChatPost({
|
|
629
|
+
conversationId,
|
|
630
|
+
messages,
|
|
631
|
+
userMessage,
|
|
632
|
+
skipPersistUser: msg.skipPersistUser !== false,
|
|
633
|
+
userMessageId,
|
|
634
|
+
assistantMessageId,
|
|
635
|
+
senderType: typeof msg.senderType === "string" ? msg.senderType : undefined,
|
|
636
|
+
senderName: typeof msg.senderName === "string" ? msg.senderName : undefined,
|
|
637
|
+
});
|
|
638
|
+
publishChatResult(conversationId, result, assistantMessageId, userMessageId);
|
|
639
|
+
if (!result.ok) {
|
|
640
|
+
throw new Error(result.data?.error ?? `chat failed (${result.status})`);
|
|
641
|
+
}
|
|
642
|
+
if (
|
|
643
|
+
result.data?.nextQueued &&
|
|
644
|
+
typeof result.data.nextQueued.id === "string"
|
|
645
|
+
) {
|
|
646
|
+
nextQueued = result.data.nextQueued;
|
|
647
|
+
}
|
|
648
|
+
return { conversationId, ...result.data };
|
|
649
|
+
} finally {
|
|
650
|
+
if (userMessageId) runningTurns.delete(userMessageId);
|
|
651
|
+
if (
|
|
652
|
+
conversationId &&
|
|
653
|
+
userMessageId &&
|
|
654
|
+
runningByConversation.get(conversationId) === userMessageId
|
|
655
|
+
) {
|
|
656
|
+
runningByConversation.delete(conversationId);
|
|
657
|
+
}
|
|
658
|
+
const deferred = deferredTurns.get(conversationId);
|
|
659
|
+
if (deferred) deferredTurns.delete(conversationId);
|
|
660
|
+
const followUp =
|
|
661
|
+
nextQueued?.id && String(nextQueued.content ?? "").trim()
|
|
662
|
+
? {
|
|
663
|
+
conversationId,
|
|
664
|
+
message: {
|
|
665
|
+
id: nextQueued.id,
|
|
666
|
+
content: nextQueued.content,
|
|
667
|
+
senderType: "client",
|
|
668
|
+
},
|
|
669
|
+
}
|
|
670
|
+
: deferred && deferred.message?.id !== userMessageId
|
|
671
|
+
? deferred
|
|
672
|
+
: null;
|
|
673
|
+
if (followUp) {
|
|
674
|
+
logger.info(
|
|
675
|
+
{
|
|
676
|
+
conversationId,
|
|
677
|
+
from: userMessageId,
|
|
678
|
+
next: followUp.message.id,
|
|
679
|
+
via: nextQueued?.id ? "nextQueued" : "deferred",
|
|
680
|
+
},
|
|
681
|
+
"chain next queued turn"
|
|
682
|
+
);
|
|
683
|
+
queueMicrotask(() => {
|
|
684
|
+
void runWorkingTurn(followUp);
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
},
|
|
230
689
|
});
|
|
690
|
+
|
|
691
|
+
async function start() {
|
|
692
|
+
if (db.ready) {
|
|
693
|
+
try {
|
|
694
|
+
const info = await db.ready;
|
|
695
|
+
logger.info(
|
|
696
|
+
{
|
|
697
|
+
conversations: info.conversations,
|
|
698
|
+
messages: info.messages,
|
|
699
|
+
},
|
|
700
|
+
"cache ready"
|
|
701
|
+
);
|
|
702
|
+
} catch (err) {
|
|
703
|
+
logger.warn({ err }, "cache hydrate failed");
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const recoverMs = Math.max(
|
|
708
|
+
5_000,
|
|
709
|
+
Number(process.env.AI_QUEUE_RECOVER_MS || 20_000) || 20_000
|
|
710
|
+
);
|
|
711
|
+
let recoverInFlight = false;
|
|
712
|
+
const sweepStuckQueues = async (reason) => {
|
|
713
|
+
if (recoverInFlight || !db.recoverQueue || !db.listConversations) return;
|
|
714
|
+
recoverInFlight = true;
|
|
715
|
+
try {
|
|
716
|
+
const convs = await db.listConversations();
|
|
717
|
+
for (const conv of convs) {
|
|
718
|
+
if (runningByConversation.has(conv.id)) continue;
|
|
719
|
+
const msgs = conv.messages ?? [];
|
|
720
|
+
const hasWorking = msgs.some(
|
|
721
|
+
(m) =>
|
|
722
|
+
m.role === "user" &&
|
|
723
|
+
m.queueStatus === "working" &&
|
|
724
|
+
m.senderType !== "developer"
|
|
725
|
+
);
|
|
726
|
+
const hasQueued = msgs.some(
|
|
727
|
+
(m) =>
|
|
728
|
+
m.role === "user" &&
|
|
729
|
+
m.queueStatus === "queued" &&
|
|
730
|
+
m.senderType !== "developer"
|
|
731
|
+
);
|
|
732
|
+
if (!hasWorking && !hasQueued) continue;
|
|
733
|
+
const result = await db.recoverQueue(conv.id);
|
|
734
|
+
if (result && result.action && result.action !== "noop") {
|
|
735
|
+
logger.info(
|
|
736
|
+
{
|
|
737
|
+
reason,
|
|
738
|
+
conversationId: conv.id,
|
|
739
|
+
action: result.action,
|
|
740
|
+
workingId: result.workingId,
|
|
741
|
+
promotedId: result.promotedId,
|
|
742
|
+
},
|
|
743
|
+
"queue recover"
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
} catch (err) {
|
|
748
|
+
logger.warn({ err, reason }, "queue recover sweep failed");
|
|
749
|
+
} finally {
|
|
750
|
+
recoverInFlight = false;
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
void sweepStuckQueues("startup");
|
|
755
|
+
const recoverTimer = setInterval(() => {
|
|
756
|
+
void sweepStuckQueues("interval");
|
|
757
|
+
}, recoverMs);
|
|
758
|
+
if (typeof recoverTimer.unref === "function") recoverTimer.unref();
|
|
759
|
+
|
|
760
|
+
server.listen(port, () => {
|
|
761
|
+
const base =
|
|
762
|
+
(process.env.AI_SERVER_URL || process.env.AI_SERVER_BASE_URL || "")
|
|
763
|
+
.trim()
|
|
764
|
+
.replace(/\/$/, "") || `http://localhost:${port}`;
|
|
765
|
+
logger.info(`chat → ${base}/api/chat`);
|
|
766
|
+
logger.info(`ws → ${toHttpWsUrl(base)}/api/ws`);
|
|
767
|
+
logger.info(`ui → ${base}/`);
|
|
768
|
+
logger.info(`embed-config → ${base}/embed-config.js`);
|
|
769
|
+
logger.info(`workspace ${workspaceDir}`);
|
|
770
|
+
logger.info(
|
|
771
|
+
{
|
|
772
|
+
logLevel: logger.level,
|
|
773
|
+
nodeEnv: process.env.NODE_ENV,
|
|
774
|
+
queueRecoverMs: recoverMs,
|
|
775
|
+
},
|
|
776
|
+
"logger ready"
|
|
777
|
+
);
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
void start();
|
|
782
|
+
|
|
783
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
784
|
+
process.on(signal, () => {
|
|
785
|
+
db.stop?.();
|
|
786
|
+
hub.close();
|
|
787
|
+
process.exit(0);
|
|
788
|
+
});
|
|
789
|
+
}
|
package/src/ws.mjs
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { WebSocketServer } from "ws";
|
|
2
|
+
|
|
3
|
+
function rawToText(raw) {
|
|
4
|
+
if (typeof raw === "string") return raw;
|
|
5
|
+
if (Buffer.isBuffer(raw)) return raw.toString("utf8");
|
|
6
|
+
if (ArrayBuffer.isView(raw)) {
|
|
7
|
+
return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString(
|
|
8
|
+
"utf8"
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
return String(raw ?? "");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseJson(raw) {
|
|
15
|
+
try {
|
|
16
|
+
const data = JSON.parse(rawToText(raw).trim());
|
|
17
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return null;
|
|
18
|
+
return data;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sendJson(socket, payload, debug) {
|
|
25
|
+
if (socket.readyState !== socket.OPEN) {
|
|
26
|
+
debug?.(
|
|
27
|
+
{ type: payload?.type, readyState: socket.readyState },
|
|
28
|
+
"ws outbound skipped (not open)"
|
|
29
|
+
);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
socket.send(JSON.stringify(payload));
|
|
34
|
+
} catch (err) {
|
|
35
|
+
debug?.(
|
|
36
|
+
{ type: payload?.type, err: err instanceof Error ? err.message : String(err) },
|
|
37
|
+
"ws outbound failed"
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Accepts browser / bridge WebSockets on /api/ws (and /ws).
|
|
44
|
+
* Clients subscribe to a conversation and receive AI turn events.
|
|
45
|
+
*/
|
|
46
|
+
export function attachChatWebSocket(server, opts) {
|
|
47
|
+
const logger = opts.logger;
|
|
48
|
+
const log =
|
|
49
|
+
opts.log ??
|
|
50
|
+
((msg) => {
|
|
51
|
+
if (logger) logger.info(msg);
|
|
52
|
+
else console.log(`[ai-server] ${msg}`);
|
|
53
|
+
});
|
|
54
|
+
const debug = (obj, msg) => {
|
|
55
|
+
if (logger?.debug) logger.debug(obj, msg);
|
|
56
|
+
};
|
|
57
|
+
const info = (obj, msg) => {
|
|
58
|
+
if (logger?.info) logger.info(obj, msg);
|
|
59
|
+
else debug(obj, msg);
|
|
60
|
+
};
|
|
61
|
+
const onChatRun = opts.onChatRun;
|
|
62
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
63
|
+
/** @type {Map<string, Set<import("ws").WebSocket>>} */
|
|
64
|
+
const channels = new Map();
|
|
65
|
+
/** @type {WeakMap<import("ws").WebSocket, Set<string>>} */
|
|
66
|
+
const socketChannels = new WeakMap();
|
|
67
|
+
|
|
68
|
+
const subscribe = (socket, conversationId) => {
|
|
69
|
+
const id = String(conversationId ?? "").trim();
|
|
70
|
+
if (!id) return;
|
|
71
|
+
let set = channels.get(id);
|
|
72
|
+
if (!set) {
|
|
73
|
+
set = new Set();
|
|
74
|
+
channels.set(id, set);
|
|
75
|
+
}
|
|
76
|
+
set.add(socket);
|
|
77
|
+
const owned = socketChannels.get(socket) ?? new Set();
|
|
78
|
+
owned.add(id);
|
|
79
|
+
socketChannels.set(socket, owned);
|
|
80
|
+
debug({ conversationId: id, subscribers: set.size }, "ws subscribe");
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const unsubscribeAll = (socket) => {
|
|
84
|
+
const owned = socketChannels.get(socket);
|
|
85
|
+
if (!owned) return;
|
|
86
|
+
for (const id of owned) {
|
|
87
|
+
const set = channels.get(id);
|
|
88
|
+
if (!set) continue;
|
|
89
|
+
set.delete(socket);
|
|
90
|
+
if (set.size === 0) channels.delete(id);
|
|
91
|
+
}
|
|
92
|
+
socketChannels.delete(socket);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const publish = (conversationId, payload) => {
|
|
96
|
+
const id = String(conversationId ?? "").trim();
|
|
97
|
+
if (!id) return;
|
|
98
|
+
const set = channels.get(id);
|
|
99
|
+
if (!set?.size) {
|
|
100
|
+
debug({ conversationId: id, type: payload?.type }, "ws publish (no subscribers)");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
debug(
|
|
104
|
+
{ conversationId: id, type: payload?.type, subscribers: set.size },
|
|
105
|
+
"ws publish"
|
|
106
|
+
);
|
|
107
|
+
for (const socket of [...set]) {
|
|
108
|
+
sendJson(socket, payload, debug);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
wss.on("connection", (socket) => {
|
|
113
|
+
debug({ clients: wss.clients.size }, "ws connection open");
|
|
114
|
+
sendJson(socket, { type: "hello", role: "ai_server" }, debug);
|
|
115
|
+
socket.on("message", (raw) => {
|
|
116
|
+
void (async () => {
|
|
117
|
+
const text = rawToText(raw);
|
|
118
|
+
const msg = parseJson(raw);
|
|
119
|
+
if (!msg) {
|
|
120
|
+
log(`ws inbound unparsed (${text.slice(0, 200)})`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const type = typeof msg.type === "string" ? msg.type : "";
|
|
124
|
+
if (type && type !== "ping") {
|
|
125
|
+
info(
|
|
126
|
+
{
|
|
127
|
+
type,
|
|
128
|
+
conversationId: msg.conversationId,
|
|
129
|
+
userMessageId: msg.userMessageId,
|
|
130
|
+
requestId: msg.requestId,
|
|
131
|
+
bytes: text.length,
|
|
132
|
+
},
|
|
133
|
+
"ws inbound"
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (type === "ping") {
|
|
137
|
+
sendJson(socket, { type: "pong" }, debug);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (type === "subscribe") {
|
|
141
|
+
const ids = [];
|
|
142
|
+
if (typeof msg.conversationId === "string") {
|
|
143
|
+
ids.push(msg.conversationId);
|
|
144
|
+
}
|
|
145
|
+
if (Array.isArray(msg.channels)) {
|
|
146
|
+
for (const channel of msg.channels) {
|
|
147
|
+
if (typeof channel !== "string") continue;
|
|
148
|
+
ids.push(
|
|
149
|
+
channel.startsWith("conversation:")
|
|
150
|
+
? channel.slice("conversation:".length)
|
|
151
|
+
: channel
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
debug({ channels: ids }, "ws subscribe request");
|
|
156
|
+
for (const id of ids.slice(0, 50)) subscribe(socket, id);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (type === "chat.run" && onChatRun) {
|
|
160
|
+
const requestId =
|
|
161
|
+
typeof msg.requestId === "string" ? msg.requestId : undefined;
|
|
162
|
+
debug(
|
|
163
|
+
{
|
|
164
|
+
requestId,
|
|
165
|
+
conversationId: msg.conversationId,
|
|
166
|
+
userMessageId: msg.userMessageId,
|
|
167
|
+
},
|
|
168
|
+
"ws chat.run"
|
|
169
|
+
);
|
|
170
|
+
try {
|
|
171
|
+
const result = await onChatRun(msg);
|
|
172
|
+
info(
|
|
173
|
+
{
|
|
174
|
+
requestId,
|
|
175
|
+
conversationId: msg.conversationId,
|
|
176
|
+
duplicate: result?.duplicate === true,
|
|
177
|
+
ok: result?.ok !== false,
|
|
178
|
+
},
|
|
179
|
+
"ws chat.run done"
|
|
180
|
+
);
|
|
181
|
+
sendJson(
|
|
182
|
+
socket,
|
|
183
|
+
{
|
|
184
|
+
type: "chat.accepted",
|
|
185
|
+
requestId,
|
|
186
|
+
ok: true,
|
|
187
|
+
...result,
|
|
188
|
+
},
|
|
189
|
+
debug
|
|
190
|
+
);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
log(
|
|
193
|
+
`ws chat.run failed: ${err instanceof Error ? err.message : err}`
|
|
194
|
+
);
|
|
195
|
+
sendJson(
|
|
196
|
+
socket,
|
|
197
|
+
{
|
|
198
|
+
type: "chat.error",
|
|
199
|
+
requestId,
|
|
200
|
+
error: err instanceof Error ? err.message : "chat.run failed",
|
|
201
|
+
},
|
|
202
|
+
debug
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
info({ type: type || "(missing)" }, "ws inbound ignored (unknown type)");
|
|
208
|
+
})().catch((err) => {
|
|
209
|
+
log(`ws handler failed: ${err instanceof Error ? err.message : err}`);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
socket.on("close", () => {
|
|
213
|
+
debug({}, "ws connection close");
|
|
214
|
+
unsubscribeAll(socket);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
server.on("upgrade", (req, socket, head) => {
|
|
219
|
+
const pathname = (req.url ?? "/").split("?")[0];
|
|
220
|
+
if (pathname !== "/api/ws" && pathname !== "/ws") {
|
|
221
|
+
socket.destroy();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
debug({ pathname }, "ws upgrade");
|
|
225
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
226
|
+
wss.emit("connection", ws, req);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
publish,
|
|
232
|
+
close() {
|
|
233
|
+
for (const socket of wss.clients) {
|
|
234
|
+
try {
|
|
235
|
+
socket.close();
|
|
236
|
+
} catch {
|
|
237
|
+
/* ignore */
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
wss.close();
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|