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