@maintainer-pro/ai-server 0.1.4 → 0.1.6
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 +2 -2
- package/bin/cli.js +7 -2
- package/package.json +7 -3
- package/src/server.mjs +718 -522
package/src/server.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
1
|
/**
|
|
3
2
|
* Maintainer Pro chat HTTP server.
|
|
4
3
|
* Talks to coding agents through @maintainer-pro/ai-cli.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
5
|
+
* Use `startAiServer(options)` from the bridge (one instance per project).
|
|
6
|
+
* The CLI (`bin/cli.js`) still runs a single standalone listener.
|
|
7
7
|
*/
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
9
|
import fs from "node:fs";
|
|
@@ -15,9 +15,10 @@ import {
|
|
|
15
15
|
createChatHandler,
|
|
16
16
|
createDefaultSystemPrompt,
|
|
17
17
|
createLogger,
|
|
18
|
-
|
|
18
|
+
createMaintainerProStore,
|
|
19
19
|
parseIgnorePathsEnv,
|
|
20
20
|
resolveIgnorePaths,
|
|
21
|
+
ensureProjectDataDir,
|
|
21
22
|
} from "@maintainer-pro/ai-cli";
|
|
22
23
|
import { attachChatWebSocket } from "./ws.mjs";
|
|
23
24
|
|
|
@@ -39,8 +40,10 @@ function parseArgs(argv) {
|
|
|
39
40
|
return out;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
function
|
|
43
|
-
|
|
43
|
+
export function parseEnvFile(file) {
|
|
44
|
+
/** @type {Record<string, string>} */
|
|
45
|
+
const out = {};
|
|
46
|
+
if (!file || !fs.existsSync(file)) return out;
|
|
44
47
|
const text = fs.readFileSync(file, "utf8");
|
|
45
48
|
for (const raw of text.split(/\r?\n/)) {
|
|
46
49
|
const line = raw.trim();
|
|
@@ -55,51 +58,23 @@ function loadEnvFile(file) {
|
|
|
55
58
|
) {
|
|
56
59
|
value = value.slice(1, -1);
|
|
57
60
|
}
|
|
58
|
-
|
|
61
|
+
out[key] = value;
|
|
59
62
|
}
|
|
63
|
+
return out;
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
npx @maintainer-pro/ai-server@latest
|
|
68
|
-
npx @maintainer-pro/ai-server@latest --port 3000 --ui . --workspace .
|
|
69
|
-
|
|
70
|
-
Flags override .env. Preferred config is env (from the admin setup guide).
|
|
71
|
-
|
|
72
|
-
--port <n> Listen port (PORT)
|
|
73
|
-
--ui <dir> Static UI folder (AI_SERVER_UI)
|
|
74
|
-
--workspace <dir> Agent edit root (AI_CLI_WORKSPACE)
|
|
75
|
-
--env-file <path> Env file (default: ./.env)
|
|
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
|
|
83
|
-
`);
|
|
84
|
-
process.exit(0);
|
|
66
|
+
function loadEnvFile(file) {
|
|
67
|
+
const parsed = parseEnvFile(file);
|
|
68
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
69
|
+
if (process.env[key] === undefined) process.env[key] = value;
|
|
70
|
+
}
|
|
85
71
|
}
|
|
86
72
|
|
|
87
|
-
|
|
88
|
-
|
|
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";
|
|
73
|
+
function pickEnv(env, key, fallback = "") {
|
|
74
|
+
const raw = env?.[key] ?? process.env[key] ?? fallback;
|
|
75
|
+
return String(raw ?? "").trim();
|
|
95
76
|
}
|
|
96
77
|
|
|
97
|
-
const logger = createLogger("ai-server");
|
|
98
|
-
const fail = (msg) => {
|
|
99
|
-
logger.fatal(msg);
|
|
100
|
-
process.exit(1);
|
|
101
|
-
};
|
|
102
|
-
|
|
103
78
|
function contentType(file) {
|
|
104
79
|
if (file.endsWith(".html")) return "text/html; charset=utf-8";
|
|
105
80
|
if (file.endsWith(".js")) return "text/javascript; charset=utf-8";
|
|
@@ -110,19 +85,6 @@ function contentType(file) {
|
|
|
110
85
|
return "application/octet-stream";
|
|
111
86
|
}
|
|
112
87
|
|
|
113
|
-
function resolveUiFile(uiDir, pathname) {
|
|
114
|
-
const rel = decodeURIComponent(pathname === "/" ? "/index.html" : pathname);
|
|
115
|
-
const file = path.normalize(path.join(uiDir, rel));
|
|
116
|
-
const root = path.resolve(uiDir);
|
|
117
|
-
if (!file.startsWith(root)) return null;
|
|
118
|
-
if (fs.existsSync(file) && fs.statSync(file).isFile()) return file;
|
|
119
|
-
if (pathname === "/ai-ui.iife.js") {
|
|
120
|
-
const bundled = findIife();
|
|
121
|
-
if (bundled) return bundled;
|
|
122
|
-
}
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
88
|
function tryResolveIife(from) {
|
|
127
89
|
try {
|
|
128
90
|
return createRequire(from).resolve("@maintainer-pro/ai-ui");
|
|
@@ -131,7 +93,7 @@ function tryResolveIife(from) {
|
|
|
131
93
|
}
|
|
132
94
|
}
|
|
133
95
|
|
|
134
|
-
function findIife() {
|
|
96
|
+
export function findIife() {
|
|
135
97
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
136
98
|
const cwdPkg = path.join(process.cwd(), "package.json");
|
|
137
99
|
const candidates = [
|
|
@@ -144,6 +106,19 @@ function findIife() {
|
|
|
144
106
|
return candidates.find((file) => file && fs.existsSync(file)) ?? null;
|
|
145
107
|
}
|
|
146
108
|
|
|
109
|
+
function resolveUiFile(uiDir, pathname) {
|
|
110
|
+
const rel = decodeURIComponent(pathname === "/" ? "/index.html" : pathname);
|
|
111
|
+
const file = path.normalize(path.join(uiDir, rel));
|
|
112
|
+
const root = path.resolve(uiDir);
|
|
113
|
+
if (!file.startsWith(root)) return null;
|
|
114
|
+
if (fs.existsSync(file) && fs.statSync(file).isFile()) return file;
|
|
115
|
+
if (pathname === "/ai-ui.iife.js") {
|
|
116
|
+
const bundled = findIife();
|
|
117
|
+
if (bundled) return bundled;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
147
122
|
async function toFetchRequest(req) {
|
|
148
123
|
const host = req.headers.host ?? "localhost";
|
|
149
124
|
const url = `http://${host}${req.url ?? "/"}`;
|
|
@@ -179,503 +154,495 @@ function toHttpWsUrl(httpUrl) {
|
|
|
179
154
|
return u.toString().replace(/\/$/, "");
|
|
180
155
|
}
|
|
181
156
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
args.ui || process.env.AI_SERVER_UI || process.cwd()
|
|
188
|
-
);
|
|
189
|
-
const corsOrigin =
|
|
190
|
-
(typeof args.corsOrigin === "string" ? args.corsOrigin : process.env.CORS_ORIGIN) ||
|
|
191
|
-
"*";
|
|
192
|
-
|
|
193
|
-
process.env.AI_CLI_WORKSPACE = workspaceDir;
|
|
194
|
-
|
|
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
|
-
});
|
|
211
|
-
if (!db) {
|
|
212
|
-
fail(
|
|
213
|
-
`Set MAINTAINER_PRO_URL and MAINTAINER_PRO_API_KEY (env file: ${envFile}).`
|
|
214
|
-
);
|
|
157
|
+
function parseCorsOrigins(value) {
|
|
158
|
+
return String(value || "*")
|
|
159
|
+
.split(/[\s,]+/)
|
|
160
|
+
.map((item) => item.trim())
|
|
161
|
+
.filter(Boolean);
|
|
215
162
|
}
|
|
216
163
|
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
164
|
+
const HELP = `Maintainer Pro AI server
|
|
165
|
+
|
|
166
|
+
Usage:
|
|
167
|
+
npx --yes --prefer-online @maintainer-pro/ai-server@latest
|
|
168
|
+
npx --yes --prefer-online @maintainer-pro/ai-server@latest --port 3000 --ui . --workspace .
|
|
169
|
+
|
|
170
|
+
Flags override .env. Preferred config is env (from the admin setup guide).
|
|
171
|
+
|
|
172
|
+
--port <n> Listen port (PORT)
|
|
173
|
+
--ui <dir> Static UI folder (AI_SERVER_UI)
|
|
174
|
+
--workspace <dir> Agent edit root (AI_CLI_WORKSPACE)
|
|
175
|
+
--env-file <path> Env file (default: ./.env)
|
|
176
|
+
--cors-origin <url> CORS origin (CORS_ORIGIN)
|
|
177
|
+
|
|
178
|
+
Logging (env):
|
|
179
|
+
LOG_LEVEL=info|debug|warn|error|silent
|
|
180
|
+
AI_LOG_LEVEL=… alias for LOG_LEVEL
|
|
181
|
+
NODE_ENV=development defaults to debug logs (auto if unset)
|
|
182
|
+
LOG_PRETTY=0 disable pretty TTY output
|
|
183
|
+
`;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* One chat server for one project folder. Safe to start several in one process.
|
|
187
|
+
*
|
|
188
|
+
* @param {object} [options]
|
|
189
|
+
* @param {number} [options.port]
|
|
190
|
+
* @param {string} [options.workspaceDir]
|
|
191
|
+
* @param {string} [options.uiDir]
|
|
192
|
+
* @param {string|string[]} [options.corsOrigins]
|
|
193
|
+
* @param {Record<string, string>} [options.env]
|
|
194
|
+
* @param {import("@maintainer-pro/ai-cli").Logger} [options.logger]
|
|
195
|
+
* @param {boolean} [options.listen]
|
|
196
|
+
* @param {string} [options.dataDir]
|
|
197
|
+
* @param {string} [options.sandboxId]
|
|
198
|
+
* @param {string} [options.label]
|
|
199
|
+
*/
|
|
200
|
+
export async function startAiServer(options = {}) {
|
|
201
|
+
const env = options.env && typeof options.env === "object" ? options.env : {};
|
|
202
|
+
const port = Number(options.port || pickEnv(env, "PORT", "3100") || 3100);
|
|
203
|
+
const workspaceDir = path.resolve(
|
|
204
|
+
options.workspaceDir || pickEnv(env, "AI_CLI_WORKSPACE") || process.cwd()
|
|
239
205
|
);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
206
|
+
const sandboxId =
|
|
207
|
+
options.sandboxId || pickEnv(env, "MAINTAINER_PRO_SANDBOX_ID") || "";
|
|
208
|
+
const dataDir = ensureProjectDataDir({
|
|
209
|
+
workspaceDir,
|
|
210
|
+
sandboxId,
|
|
211
|
+
dataDir:
|
|
212
|
+
options.dataDir || pickEnv(env, "MAINTAINER_PRO_DATA_DIR") || undefined,
|
|
213
|
+
});
|
|
214
|
+
const uiDir = path.resolve(
|
|
215
|
+
options.uiDir || pickEnv(env, "AI_SERVER_UI") || workspaceDir
|
|
216
|
+
);
|
|
217
|
+
const corsOrigins = Array.isArray(options.corsOrigins)
|
|
218
|
+
? options.corsOrigins.filter(Boolean)
|
|
219
|
+
: parseCorsOrigins(
|
|
220
|
+
options.corsOrigins ||
|
|
221
|
+
pickEnv(env, "CORS_ORIGIN") ||
|
|
222
|
+
pickEnv(env, "CORS_ORIGINS") ||
|
|
223
|
+
"*"
|
|
251
224
|
);
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
)
|
|
262
|
-
) {
|
|
263
|
-
messages.push({ role: "user", content: userMessage });
|
|
225
|
+
const logger =
|
|
226
|
+
options.logger || createLogger(options.label || "ai-server");
|
|
227
|
+
const shouldListen = options.listen !== false;
|
|
228
|
+
|
|
229
|
+
const maintainerProUrl = pickEnv(env, "MAINTAINER_PRO_URL");
|
|
230
|
+
const maintainerProApiKey = pickEnv(env, "MAINTAINER_PRO_API_KEY");
|
|
231
|
+
if (!maintainerProUrl || !maintainerProApiKey) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
"Set MAINTAINER_PRO_URL and MAINTAINER_PRO_API_KEY for this project (.env)."
|
|
234
|
+
);
|
|
264
235
|
}
|
|
265
|
-
return messages;
|
|
266
|
-
}
|
|
267
236
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
237
|
+
const runningTurns = new Set();
|
|
238
|
+
const runningByConversation = new Map();
|
|
239
|
+
const deferredTurns = new Map();
|
|
240
|
+
/** @type {{ publish: (conversationId: string, payload: Record<string, unknown>) => void; close: () => void }} */
|
|
241
|
+
let hub = { publish() {}, close() {} };
|
|
242
|
+
/** @type {(event: { conversationId: string; message: { id?: string; content?: string; senderType?: string | null; senderName?: string | null } }) => Promise<void>} */
|
|
243
|
+
let runWorkingTurn = async () => {};
|
|
244
|
+
|
|
245
|
+
const db = createMaintainerProStore({
|
|
246
|
+
baseUrl: maintainerProUrl,
|
|
247
|
+
apiKey: maintainerProApiKey,
|
|
248
|
+
logger,
|
|
249
|
+
tempDir: path.join(dataDir, "uploads"),
|
|
250
|
+
onWorkingTurn: (event) => {
|
|
251
|
+
void runWorkingTurn(event);
|
|
275
252
|
},
|
|
276
|
-
|
|
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
|
-
}
|
|
253
|
+
});
|
|
298
254
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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"
|
|
255
|
+
const productDescription =
|
|
256
|
+
pickEnv(env, "AI_SERVER_PRODUCT_DESCRIPTION") ||
|
|
257
|
+
"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.";
|
|
258
|
+
|
|
259
|
+
const ignorePaths = resolveIgnorePaths(
|
|
260
|
+
parseIgnorePathsEnv(pickEnv(env, "AI_CLI_IGNORE_PATHS"))
|
|
323
261
|
);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
senderName: "AI",
|
|
333
|
-
provider: result.data?.provider ?? null,
|
|
334
|
-
parentMessageId: parentMessageId ?? null,
|
|
335
|
-
},
|
|
262
|
+
|
|
263
|
+
const handlers = createChatHandler({
|
|
264
|
+
systemPrompt: createDefaultSystemPrompt({ productDescription, ignorePaths }),
|
|
265
|
+
workspaceDir,
|
|
266
|
+
dataDir,
|
|
267
|
+
sandboxId: sandboxId || undefined,
|
|
268
|
+
db,
|
|
269
|
+
logger,
|
|
336
270
|
});
|
|
337
|
-
}
|
|
338
271
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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;
|
|
272
|
+
function applyCors(req, res) {
|
|
273
|
+
const requestOrigin = String(req.headers.origin || "").trim();
|
|
274
|
+
if (corsOrigins.includes("*")) {
|
|
275
|
+
res.setHeader("Access-Control-Allow-Origin", requestOrigin || "*");
|
|
276
|
+
return;
|
|
400
277
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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);
|
|
278
|
+
if (requestOrigin && corsOrigins.includes(requestOrigin)) {
|
|
279
|
+
res.setHeader("Access-Control-Allow-Origin", requestOrigin);
|
|
280
|
+
res.setHeader("Vary", "Origin");
|
|
281
|
+
return;
|
|
413
282
|
}
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
});
|
|
283
|
+
if (corsOrigins[0]) {
|
|
284
|
+
res.setHeader("Access-Control-Allow-Origin", corsOrigins[0]);
|
|
443
285
|
}
|
|
444
286
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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}`;
|
|
287
|
+
|
|
288
|
+
function headerMap(headers) {
|
|
289
|
+
/** @type {Record<string, string>} */
|
|
290
|
+
const out = {};
|
|
291
|
+
if (!headers || typeof headers !== "object") return out;
|
|
292
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
293
|
+
if (value == null) continue;
|
|
294
|
+
out[String(key).toLowerCase()] = Array.isArray(value)
|
|
295
|
+
? value.join(", ")
|
|
296
|
+
: String(value);
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
463
299
|
}
|
|
464
|
-
return `http://localhost:${port}`;
|
|
465
|
-
}
|
|
466
300
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
301
|
+
function resolvePublicUrl(reqOrHeaders) {
|
|
302
|
+
const headers = headerMap(
|
|
303
|
+
reqOrHeaders?.headers && typeof reqOrHeaders.headers === "object"
|
|
304
|
+
? reqOrHeaders.headers
|
|
305
|
+
: reqOrHeaders
|
|
306
|
+
);
|
|
307
|
+
const fromEnv = (
|
|
308
|
+
pickEnv(env, "AI_SERVER_URL") ||
|
|
309
|
+
pickEnv(env, "AI_SERVER_BASE_URL")
|
|
310
|
+
).replace(/\/$/, "");
|
|
311
|
+
if (fromEnv) return fromEnv;
|
|
312
|
+
const host = headers.host?.trim();
|
|
313
|
+
if (host) {
|
|
314
|
+
const xfProto = String(headers["x-forwarded-proto"] ?? "")
|
|
315
|
+
.split(",")[0]
|
|
316
|
+
?.trim();
|
|
317
|
+
const proto = xfProto || "http";
|
|
318
|
+
return `${proto}://${host}`;
|
|
319
|
+
}
|
|
320
|
+
return `http://localhost:${port}`;
|
|
477
321
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
debug: true,
|
|
485
|
-
logLevel: "debug",
|
|
486
|
-
maintainerProUrl: process.env.MAINTAINER_PRO_URL ?? "",
|
|
487
|
-
maintainerProApiKey:
|
|
488
|
-
process.env.MAINTAINER_PRO_CLIENT_API_KEY ??
|
|
489
|
-
process.env.NEXT_PUBLIC_MAINTAINER_PRO_CLIENT_API_KEY ??
|
|
490
|
-
"",
|
|
322
|
+
|
|
323
|
+
function corsHeadersFor(headers) {
|
|
324
|
+
/** @type {Record<string, string>} */
|
|
325
|
+
const out = {
|
|
326
|
+
"access-control-allow-headers": "content-type, authorization",
|
|
327
|
+
"access-control-allow-methods": "GET,POST,OPTIONS",
|
|
491
328
|
};
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
329
|
+
const requestOrigin = String(headerMap(headers).origin || "").trim();
|
|
330
|
+
if (corsOrigins.includes("*")) {
|
|
331
|
+
out["access-control-allow-origin"] = requestOrigin || "*";
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
if (requestOrigin && corsOrigins.includes(requestOrigin)) {
|
|
335
|
+
out["access-control-allow-origin"] = requestOrigin;
|
|
336
|
+
out.vary = "Origin";
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
if (corsOrigins[0]) out["access-control-allow-origin"] = corsOrigins[0];
|
|
340
|
+
return out;
|
|
496
341
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Handle an HTTP request in-process (no loopback to this server).
|
|
345
|
+
* @param {{ method?: string, url?: string, headers?: Record<string, unknown>, body?: Buffer | Uint8Array | string }} input
|
|
346
|
+
*/
|
|
347
|
+
async function handleHttp(input = {}) {
|
|
348
|
+
const method = String(input.method || "GET").toUpperCase();
|
|
349
|
+
const url = String(input.url || "/");
|
|
350
|
+
const headers = headerMap(input.headers);
|
|
351
|
+
const body = Buffer.isBuffer(input.body)
|
|
352
|
+
? input.body
|
|
353
|
+
: typeof input.body === "string" && input.body
|
|
354
|
+
? Buffer.from(input.body)
|
|
355
|
+
: input.body instanceof Uint8Array
|
|
356
|
+
? Buffer.from(input.body)
|
|
357
|
+
: Buffer.alloc(0);
|
|
358
|
+
const pathname = url.split("?")[0] || "/";
|
|
359
|
+
const outHeaders = corsHeadersFor(headers);
|
|
360
|
+
logger.debug({ method, pathname }, "http");
|
|
361
|
+
|
|
362
|
+
if (method === "OPTIONS") {
|
|
363
|
+
return { status: 204, headers: outHeaders, body: Buffer.alloc(0) };
|
|
364
|
+
}
|
|
365
|
+
if (pathname === "/health") {
|
|
366
|
+
outHeaders["content-type"] = "application/json; charset=utf-8";
|
|
367
|
+
return {
|
|
368
|
+
status: 200,
|
|
369
|
+
headers: outHeaders,
|
|
370
|
+
body: Buffer.from(JSON.stringify({ ok: true, workspace: workspaceDir })),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
if (pathname === "/embed-config.js") {
|
|
374
|
+
const aiServerUrl = resolvePublicUrl({ headers });
|
|
375
|
+
const payload = {
|
|
376
|
+
aiServerUrl,
|
|
377
|
+
apiUrl: `${aiServerUrl}/api/chat`,
|
|
378
|
+
aiServerWsUrl: `${toHttpWsUrl(aiServerUrl)}/api/ws`,
|
|
379
|
+
debug: true,
|
|
380
|
+
logLevel: "debug",
|
|
381
|
+
maintainerProUrl: maintainerProUrl,
|
|
382
|
+
maintainerProApiKey:
|
|
383
|
+
pickEnv(env, "MAINTAINER_PRO_CLIENT_API_KEY") ||
|
|
384
|
+
pickEnv(env, "NEXT_PUBLIC_MAINTAINER_PRO_CLIENT_API_KEY"),
|
|
385
|
+
};
|
|
386
|
+
logger.debug({ aiServerUrl }, "embed-config");
|
|
387
|
+
outHeaders["content-type"] = "text/javascript; charset=utf-8";
|
|
388
|
+
return {
|
|
389
|
+
status: 200,
|
|
390
|
+
headers: outHeaders,
|
|
391
|
+
body: Buffer.from(`window.__MAINTAINER_PRO__=${JSON.stringify(payload)};`),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
if (pathname === "/api/chat") {
|
|
395
|
+
logger.info({ method }, "http /api/chat");
|
|
396
|
+
const handler = method === "GET" ? handlers.GET : handlers.POST;
|
|
397
|
+
const host = headers.host || `127.0.0.1:${port}`;
|
|
398
|
+
const hdrs = new Headers();
|
|
399
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
400
|
+
hdrs.set(key, value);
|
|
401
|
+
}
|
|
402
|
+
/** @type {RequestInit} */
|
|
403
|
+
const init = { method, headers: hdrs };
|
|
404
|
+
if (method !== "GET" && method !== "HEAD" && body.length) {
|
|
405
|
+
init.body = body;
|
|
406
|
+
init.duplex = "half";
|
|
407
|
+
}
|
|
408
|
+
const request = new Request(`http://${host}${url}`, init);
|
|
409
|
+
const response = await handler(request);
|
|
410
|
+
if (method === "POST") {
|
|
411
|
+
const clone = response.clone();
|
|
412
|
+
const text = await clone.text();
|
|
413
|
+
let data = {};
|
|
414
|
+
try {
|
|
415
|
+
data = text ? JSON.parse(text) : {};
|
|
416
|
+
} catch {
|
|
417
|
+
data = {};
|
|
528
418
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
419
|
+
const conversationId =
|
|
420
|
+
typeof data.conversationId === "string" ? data.conversationId : "";
|
|
421
|
+
const parentMessageId =
|
|
422
|
+
typeof data.parentMessageId === "string"
|
|
423
|
+
? data.parentMessageId
|
|
424
|
+
: undefined;
|
|
425
|
+
publishChatResult(
|
|
426
|
+
conversationId,
|
|
427
|
+
{
|
|
428
|
+
ok: response.ok,
|
|
429
|
+
status: response.status,
|
|
430
|
+
data,
|
|
431
|
+
},
|
|
432
|
+
undefined,
|
|
433
|
+
parentMessageId
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
const buf = Buffer.from(await response.arrayBuffer());
|
|
437
|
+
response.headers.forEach((value, key) => {
|
|
438
|
+
if (key.toLowerCase() === "transfer-encoding") return;
|
|
439
|
+
outHeaders[key] = value;
|
|
536
440
|
});
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
const file = resolveUiFile(uiDir, pathname);
|
|
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
|
-
);
|
|
441
|
+
return { status: response.status, headers: outHeaders, body: buf };
|
|
545
442
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
443
|
+
const file = resolveUiFile(uiDir, pathname);
|
|
444
|
+
if (!file) {
|
|
445
|
+
if (pathname === "/ai-ui.iife.js") {
|
|
446
|
+
logger.warn(
|
|
447
|
+
"GET /ai-ui.iife.js 404 — @maintainer-pro/ai-ui is not installed next to ai-server"
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
outHeaders["content-type"] = "text/plain; charset=utf-8";
|
|
451
|
+
return { status: 404, headers: outHeaders, body: Buffer.from("Not found") };
|
|
452
|
+
}
|
|
453
|
+
outHeaders["content-type"] = contentType(file);
|
|
454
|
+
return { status: 200, headers: outHeaders, body: fs.readFileSync(file) };
|
|
549
455
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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"
|
|
456
|
+
|
|
457
|
+
async function historyForTurn(conversationId, userMessage, userMessageId) {
|
|
458
|
+
const history = db.listMessages ? await db.listMessages(conversationId) : [];
|
|
459
|
+
let rows = history.filter(
|
|
460
|
+
(row) =>
|
|
461
|
+
(row.role === "user" || row.role === "assistant") &&
|
|
462
|
+
row.provider !== "working" &&
|
|
463
|
+
Boolean(row.content?.trim())
|
|
577
464
|
);
|
|
578
|
-
if (userMessageId
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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)"
|
|
465
|
+
if (userMessageId) {
|
|
466
|
+
const end = rows.findIndex((row) => row.id === userMessageId);
|
|
467
|
+
if (end >= 0) {
|
|
468
|
+
rows = rows.slice(0, end + 1);
|
|
469
|
+
} else {
|
|
470
|
+
rows = rows.filter(
|
|
471
|
+
(row) => row.queueStatus !== "queued" || row.content === userMessage
|
|
606
472
|
);
|
|
607
|
-
return { conversationId, accepted: false, busy: true };
|
|
608
473
|
}
|
|
474
|
+
} else {
|
|
475
|
+
rows = rows.filter((row) => row.queueStatus !== "queued");
|
|
609
476
|
}
|
|
610
|
-
|
|
611
|
-
|
|
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 (
|
|
477
|
+
const messages = rows.map((row) => ({ role: row.role, content: row.content }));
|
|
478
|
+
if (
|
|
625
479
|
userMessage &&
|
|
626
480
|
!messages.some((row) => row.role === "user" && row.content === userMessage)
|
|
627
481
|
) {
|
|
628
482
|
messages.push({ role: "user", content: userMessage });
|
|
629
483
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
484
|
+
return messages;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function runChatPost(body) {
|
|
488
|
+
logger.debug(
|
|
489
|
+
{
|
|
490
|
+
conversationId: body.conversationId,
|
|
491
|
+
skipPersistUser: body.skipPersistUser,
|
|
492
|
+
userMessageId: body.userMessageId,
|
|
493
|
+
messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
|
|
494
|
+
attachmentCount: Array.isArray(body.attachments)
|
|
495
|
+
? body.attachments.length
|
|
496
|
+
: 0,
|
|
497
|
+
workspaceDir,
|
|
498
|
+
},
|
|
499
|
+
"runChatPost"
|
|
500
|
+
);
|
|
501
|
+
const response = await handlers.POST(
|
|
502
|
+
new Request("http://127.0.0.1/api/chat", {
|
|
503
|
+
method: "POST",
|
|
504
|
+
headers: { "content-type": "application/json" },
|
|
505
|
+
body: JSON.stringify(body),
|
|
506
|
+
})
|
|
507
|
+
);
|
|
508
|
+
const text = await response.text();
|
|
509
|
+
let data = {};
|
|
510
|
+
try {
|
|
511
|
+
data = text ? JSON.parse(text) : {};
|
|
512
|
+
} catch {
|
|
513
|
+
data = { error: text || "invalid chat response" };
|
|
514
|
+
}
|
|
515
|
+
logger.debug(
|
|
516
|
+
{ conversationId: body.conversationId, ok: response.ok, status: response.status },
|
|
517
|
+
"runChatPost done"
|
|
518
|
+
);
|
|
519
|
+
return { ok: response.ok, status: response.status, data };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function publishChatResult(
|
|
523
|
+
conversationId,
|
|
524
|
+
result,
|
|
525
|
+
fallbackMessageId,
|
|
526
|
+
parentMessageId
|
|
527
|
+
) {
|
|
528
|
+
if (!conversationId) return;
|
|
529
|
+
if (!result.ok) {
|
|
530
|
+
logger.debug({ conversationId, status: result.status }, "publish chat.run.failed");
|
|
531
|
+
hub.publish(conversationId, {
|
|
532
|
+
type: "chat.run.failed",
|
|
533
|
+
conversationId,
|
|
534
|
+
error: result.data?.error ?? `chat failed (${result.status})`,
|
|
535
|
+
userMessageId: parentMessageId,
|
|
536
|
+
});
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const reply = typeof result.data?.text === "string" ? result.data.text : "";
|
|
540
|
+
if (!reply) {
|
|
541
|
+
logger.debug({ conversationId }, "skip publish (empty reply)");
|
|
542
|
+
return;
|
|
637
543
|
}
|
|
544
|
+
const messageId =
|
|
545
|
+
typeof result.data?.messageId === "string"
|
|
546
|
+
? result.data.messageId
|
|
547
|
+
: fallbackMessageId;
|
|
548
|
+
logger.debug(
|
|
549
|
+
{ conversationId, messageId, provider: result.data?.provider, chars: reply.length },
|
|
550
|
+
"publish message.created"
|
|
551
|
+
);
|
|
552
|
+
hub.publish(conversationId, {
|
|
553
|
+
type: "message.created",
|
|
554
|
+
conversationId,
|
|
555
|
+
message: {
|
|
556
|
+
id: messageId,
|
|
557
|
+
role: "assistant",
|
|
558
|
+
content: reply,
|
|
559
|
+
senderType: "ai",
|
|
560
|
+
senderName: "AI",
|
|
561
|
+
provider: result.data?.provider ?? null,
|
|
562
|
+
parentMessageId: parentMessageId ?? null,
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
runWorkingTurn = async (event) => {
|
|
568
|
+
const userMessageId = event.message.id;
|
|
569
|
+
const content = event.message.content ?? "";
|
|
570
|
+
const conversationId = event.conversationId;
|
|
571
|
+
if (!userMessageId || !content.trim()) {
|
|
572
|
+
logger.debug({ event }, "skip working turn (empty)");
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (runningTurns.has(userMessageId)) {
|
|
576
|
+
logger.debug({ userMessageId }, "skip working turn (already running)");
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const busy = runningByConversation.get(conversationId);
|
|
580
|
+
if (busy && busy !== userMessageId) {
|
|
581
|
+
deferredTurns.set(conversationId, event);
|
|
582
|
+
logger.info(
|
|
583
|
+
{ conversationId, busy, userMessageId },
|
|
584
|
+
"defer working turn (conversation busy)"
|
|
585
|
+
);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
runningTurns.add(userMessageId);
|
|
589
|
+
runningByConversation.set(conversationId, userMessageId);
|
|
638
590
|
let nextQueued = null;
|
|
639
591
|
try {
|
|
592
|
+
const messages = await historyForTurn(
|
|
593
|
+
conversationId,
|
|
594
|
+
content,
|
|
595
|
+
userMessageId
|
|
596
|
+
);
|
|
597
|
+
if (!messages.length) {
|
|
598
|
+
messages.push({ role: "user", content });
|
|
599
|
+
}
|
|
600
|
+
logger.info(
|
|
601
|
+
{ conversationId, userMessageId, historyLength: messages.length, workspaceDir },
|
|
602
|
+
"ai turn"
|
|
603
|
+
);
|
|
640
604
|
hub.publish(conversationId, {
|
|
641
605
|
type: "chat.turn",
|
|
642
606
|
conversationId,
|
|
643
607
|
userMessageId,
|
|
644
608
|
});
|
|
609
|
+
const assistantMessageId = randomUUID();
|
|
645
610
|
const result = await runChatPost({
|
|
646
611
|
conversationId,
|
|
647
612
|
messages,
|
|
648
|
-
userMessage,
|
|
649
|
-
skipPersistUser:
|
|
613
|
+
userMessage: content,
|
|
614
|
+
skipPersistUser: true,
|
|
650
615
|
userMessageId,
|
|
651
616
|
assistantMessageId,
|
|
652
|
-
senderType:
|
|
653
|
-
senderName:
|
|
617
|
+
senderType: event.message.senderType === "client" ? "client" : undefined,
|
|
618
|
+
senderName: event.message.senderName ?? undefined,
|
|
654
619
|
});
|
|
655
620
|
publishChatResult(conversationId, result, assistantMessageId, userMessageId);
|
|
656
621
|
if (!result.ok) {
|
|
657
|
-
|
|
658
|
-
}
|
|
659
|
-
if (
|
|
622
|
+
logger.warn({ status: result.status }, "ai turn failed");
|
|
623
|
+
} else if (
|
|
660
624
|
result.data?.nextQueued &&
|
|
661
625
|
typeof result.data.nextQueued.id === "string"
|
|
662
626
|
) {
|
|
663
627
|
nextQueued = result.data.nextQueued;
|
|
664
628
|
}
|
|
665
|
-
|
|
629
|
+
} catch (err) {
|
|
630
|
+
logger.error({ err }, "ai turn failed");
|
|
631
|
+
hub.publish(conversationId, {
|
|
632
|
+
type: "chat.run.failed",
|
|
633
|
+
conversationId,
|
|
634
|
+
error: err instanceof Error ? err.message : String(err),
|
|
635
|
+
userMessageId,
|
|
636
|
+
});
|
|
666
637
|
} finally {
|
|
667
|
-
|
|
668
|
-
if (
|
|
669
|
-
conversationId &&
|
|
670
|
-
userMessageId &&
|
|
671
|
-
runningByConversation.get(conversationId) === userMessageId
|
|
672
|
-
) {
|
|
638
|
+
runningTurns.delete(userMessageId);
|
|
639
|
+
if (runningByConversation.get(conversationId) === userMessageId) {
|
|
673
640
|
runningByConversation.delete(conversationId);
|
|
674
641
|
}
|
|
675
642
|
const deferred = deferredTurns.get(conversationId);
|
|
676
643
|
if (deferred) deferredTurns.delete(conversationId);
|
|
677
644
|
const followUp =
|
|
678
|
-
nextQueued?.id &&
|
|
645
|
+
nextQueued?.id && nextQueued.content?.trim()
|
|
679
646
|
? {
|
|
680
647
|
conversationId,
|
|
681
648
|
message: {
|
|
@@ -702,18 +669,195 @@ hub = attachChatWebSocket(server, {
|
|
|
702
669
|
});
|
|
703
670
|
}
|
|
704
671
|
}
|
|
705
|
-
}
|
|
706
|
-
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
const server = http.createServer((req, res) => {
|
|
675
|
+
void (async () => {
|
|
676
|
+
const chunks = [];
|
|
677
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
678
|
+
const result = await handleHttp({
|
|
679
|
+
method: req.method,
|
|
680
|
+
url: req.url,
|
|
681
|
+
headers: req.headers,
|
|
682
|
+
body: Buffer.concat(chunks),
|
|
683
|
+
});
|
|
684
|
+
res.statusCode = result.status;
|
|
685
|
+
for (const [key, value] of Object.entries(result.headers || {})) {
|
|
686
|
+
res.setHeader(key, value);
|
|
687
|
+
}
|
|
688
|
+
res.end(result.body);
|
|
689
|
+
})().catch((err) => {
|
|
690
|
+
logger.error({ err }, "http failed");
|
|
691
|
+
if (!res.headersSent) {
|
|
692
|
+
res.statusCode = 500;
|
|
693
|
+
res.end("Internal error");
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
hub = attachChatWebSocket(server, {
|
|
699
|
+
logger,
|
|
700
|
+
onChatRun: async (msg) => {
|
|
701
|
+
const conversationId =
|
|
702
|
+
typeof msg.conversationId === "string" ? msg.conversationId : "";
|
|
703
|
+
const userMessage =
|
|
704
|
+
typeof msg.userMessage === "string"
|
|
705
|
+
? msg.userMessage
|
|
706
|
+
: typeof msg.content === "string"
|
|
707
|
+
? msg.content
|
|
708
|
+
: "";
|
|
709
|
+
const userMessageId =
|
|
710
|
+
typeof msg.userMessageId === "string" ? msg.userMessageId : undefined;
|
|
711
|
+
logger.info(
|
|
712
|
+
{
|
|
713
|
+
conversationId,
|
|
714
|
+
userMessageId,
|
|
715
|
+
chars: userMessage.length,
|
|
716
|
+
hasMessages: Array.isArray(msg.messages),
|
|
717
|
+
messageCount: Array.isArray(msg.messages) ? msg.messages.length : 0,
|
|
718
|
+
skipPersistUser: msg.skipPersistUser,
|
|
719
|
+
},
|
|
720
|
+
"ws chat.run"
|
|
721
|
+
);
|
|
722
|
+
if (userMessageId && runningTurns.has(userMessageId)) {
|
|
723
|
+
logger.debug({ userMessageId }, "ws chat.run duplicate");
|
|
724
|
+
return { conversationId, accepted: true, duplicate: true };
|
|
725
|
+
}
|
|
726
|
+
if (conversationId) {
|
|
727
|
+
const busy = runningByConversation.get(conversationId);
|
|
728
|
+
if (busy && busy !== userMessageId) {
|
|
729
|
+
if (userMessageId && userMessage.trim()) {
|
|
730
|
+
deferredTurns.set(conversationId, {
|
|
731
|
+
conversationId,
|
|
732
|
+
message: {
|
|
733
|
+
id: userMessageId,
|
|
734
|
+
content: userMessage,
|
|
735
|
+
senderType:
|
|
736
|
+
typeof msg.senderType === "string" ? msg.senderType : "client",
|
|
737
|
+
senderName:
|
|
738
|
+
typeof msg.senderName === "string" ? msg.senderName : null,
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
logger.info(
|
|
742
|
+
{ conversationId, busy, userMessageId },
|
|
743
|
+
"ws chat.run deferred (conversation busy)"
|
|
744
|
+
);
|
|
745
|
+
return { conversationId, accepted: true, deferred: true };
|
|
746
|
+
}
|
|
747
|
+
logger.warn(
|
|
748
|
+
{ conversationId, busy, userMessageId },
|
|
749
|
+
"ws chat.run skipped (conversation busy)"
|
|
750
|
+
);
|
|
751
|
+
return { conversationId, accepted: false, busy: true };
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
let messages = Array.isArray(msg.messages)
|
|
755
|
+
? msg.messages.filter(
|
|
756
|
+
(row) =>
|
|
757
|
+
row &&
|
|
758
|
+
(row.role === "user" || row.role === "assistant") &&
|
|
759
|
+
typeof row.content === "string"
|
|
760
|
+
)
|
|
761
|
+
: [];
|
|
762
|
+
if (!messages.length && conversationId) {
|
|
763
|
+
messages = await historyForTurn(
|
|
764
|
+
conversationId,
|
|
765
|
+
userMessage,
|
|
766
|
+
userMessageId
|
|
767
|
+
);
|
|
768
|
+
logger.info(
|
|
769
|
+
{ conversationId, history: messages.length },
|
|
770
|
+
"ws chat.run loaded history"
|
|
771
|
+
);
|
|
772
|
+
} else if (
|
|
773
|
+
userMessage &&
|
|
774
|
+
!messages.some((row) => row.role === "user" && row.content === userMessage)
|
|
775
|
+
) {
|
|
776
|
+
messages.push({ role: "user", content: userMessage });
|
|
777
|
+
}
|
|
778
|
+
const assistantMessageId =
|
|
779
|
+
typeof msg.assistantMessageId === "string" && msg.assistantMessageId
|
|
780
|
+
? msg.assistantMessageId
|
|
781
|
+
: randomUUID();
|
|
782
|
+
if (userMessageId) runningTurns.add(userMessageId);
|
|
783
|
+
if (conversationId && userMessageId) {
|
|
784
|
+
runningByConversation.set(conversationId, userMessageId);
|
|
785
|
+
}
|
|
786
|
+
let nextQueued = null;
|
|
787
|
+
try {
|
|
788
|
+
hub.publish(conversationId, {
|
|
789
|
+
type: "chat.turn",
|
|
790
|
+
conversationId,
|
|
791
|
+
userMessageId,
|
|
792
|
+
});
|
|
793
|
+
const result = await runChatPost({
|
|
794
|
+
conversationId,
|
|
795
|
+
messages,
|
|
796
|
+
userMessage,
|
|
797
|
+
skipPersistUser: msg.skipPersistUser !== false,
|
|
798
|
+
userMessageId,
|
|
799
|
+
assistantMessageId,
|
|
800
|
+
senderType: typeof msg.senderType === "string" ? msg.senderType : undefined,
|
|
801
|
+
senderName: typeof msg.senderName === "string" ? msg.senderName : undefined,
|
|
802
|
+
});
|
|
803
|
+
publishChatResult(conversationId, result, assistantMessageId, userMessageId);
|
|
804
|
+
if (!result.ok) {
|
|
805
|
+
throw new Error(result.data?.error ?? `chat failed (${result.status})`);
|
|
806
|
+
}
|
|
807
|
+
if (
|
|
808
|
+
result.data?.nextQueued &&
|
|
809
|
+
typeof result.data.nextQueued.id === "string"
|
|
810
|
+
) {
|
|
811
|
+
nextQueued = result.data.nextQueued;
|
|
812
|
+
}
|
|
813
|
+
return { conversationId, ...result.data };
|
|
814
|
+
} finally {
|
|
815
|
+
if (userMessageId) runningTurns.delete(userMessageId);
|
|
816
|
+
if (
|
|
817
|
+
conversationId &&
|
|
818
|
+
userMessageId &&
|
|
819
|
+
runningByConversation.get(conversationId) === userMessageId
|
|
820
|
+
) {
|
|
821
|
+
runningByConversation.delete(conversationId);
|
|
822
|
+
}
|
|
823
|
+
const deferred = deferredTurns.get(conversationId);
|
|
824
|
+
if (deferred) deferredTurns.delete(conversationId);
|
|
825
|
+
const followUp =
|
|
826
|
+
nextQueued?.id && String(nextQueued.content ?? "").trim()
|
|
827
|
+
? {
|
|
828
|
+
conversationId,
|
|
829
|
+
message: {
|
|
830
|
+
id: nextQueued.id,
|
|
831
|
+
content: nextQueued.content,
|
|
832
|
+
senderType: "client",
|
|
833
|
+
},
|
|
834
|
+
}
|
|
835
|
+
: deferred && deferred.message?.id !== userMessageId
|
|
836
|
+
? deferred
|
|
837
|
+
: null;
|
|
838
|
+
if (followUp) {
|
|
839
|
+
logger.info(
|
|
840
|
+
{
|
|
841
|
+
conversationId,
|
|
842
|
+
from: userMessageId,
|
|
843
|
+
next: followUp.message.id,
|
|
844
|
+
via: nextQueued?.id ? "nextQueued" : "deferred",
|
|
845
|
+
},
|
|
846
|
+
"chain next queued turn"
|
|
847
|
+
);
|
|
848
|
+
queueMicrotask(() => {
|
|
849
|
+
void runWorkingTurn(followUp);
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
},
|
|
854
|
+
});
|
|
707
855
|
|
|
708
|
-
async function start() {
|
|
709
856
|
if (db.ready) {
|
|
710
857
|
try {
|
|
711
858
|
const info = await db.ready;
|
|
712
859
|
logger.info(
|
|
713
|
-
{
|
|
714
|
-
conversations: info.conversations,
|
|
715
|
-
messages: info.messages,
|
|
716
|
-
},
|
|
860
|
+
{ conversations: info.conversations, messages: info.messages, workspaceDir },
|
|
717
861
|
"cache ready"
|
|
718
862
|
);
|
|
719
863
|
} catch (err) {
|
|
@@ -723,7 +867,7 @@ async function start() {
|
|
|
723
867
|
|
|
724
868
|
const recoverMs = Math.max(
|
|
725
869
|
5_000,
|
|
726
|
-
Number(
|
|
870
|
+
Number(pickEnv(env, "AI_QUEUE_RECOVER_MS", "20000") || 20_000) || 20_000
|
|
727
871
|
);
|
|
728
872
|
let recoverInFlight = false;
|
|
729
873
|
const sweepStuckQueues = async (reason) => {
|
|
@@ -774,11 +918,46 @@ async function start() {
|
|
|
774
918
|
}, recoverMs);
|
|
775
919
|
if (typeof recoverTimer.unref === "function") recoverTimer.unref();
|
|
776
920
|
|
|
777
|
-
|
|
921
|
+
const close = () =>
|
|
922
|
+
new Promise((resolve) => {
|
|
923
|
+
clearInterval(recoverTimer);
|
|
924
|
+
try {
|
|
925
|
+
db.stop?.();
|
|
926
|
+
} catch {
|
|
927
|
+
/* ignore */
|
|
928
|
+
}
|
|
929
|
+
try {
|
|
930
|
+
hub.close();
|
|
931
|
+
} catch {
|
|
932
|
+
/* ignore */
|
|
933
|
+
}
|
|
934
|
+
server.close(() => resolve());
|
|
935
|
+
setTimeout(resolve, 2000);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
if (shouldListen) {
|
|
939
|
+
try {
|
|
940
|
+
await new Promise((resolve, reject) => {
|
|
941
|
+
const onError = (err) => {
|
|
942
|
+
server.off("listening", onListen);
|
|
943
|
+
reject(err);
|
|
944
|
+
};
|
|
945
|
+
const onListen = () => {
|
|
946
|
+
server.off("error", onError);
|
|
947
|
+
resolve();
|
|
948
|
+
};
|
|
949
|
+
server.once("error", onError);
|
|
950
|
+
server.listen(port, onListen);
|
|
951
|
+
});
|
|
952
|
+
} catch (err) {
|
|
953
|
+
await close();
|
|
954
|
+
throw err;
|
|
955
|
+
}
|
|
778
956
|
const base =
|
|
779
|
-
(
|
|
780
|
-
|
|
781
|
-
|
|
957
|
+
(pickEnv(env, "AI_SERVER_URL") || pickEnv(env, "AI_SERVER_BASE_URL")).replace(
|
|
958
|
+
/\/$/,
|
|
959
|
+
""
|
|
960
|
+
) || `http://localhost:${port}`;
|
|
782
961
|
logger.info(`chat → ${base}/api/chat`);
|
|
783
962
|
logger.info(`ws → ${toHttpWsUrl(base)}/api/ws`);
|
|
784
963
|
logger.info(`ui → ${base}/`);
|
|
@@ -793,23 +972,40 @@ async function start() {
|
|
|
793
972
|
);
|
|
794
973
|
}
|
|
795
974
|
logger.info(`workspace ${workspaceDir}`);
|
|
796
|
-
|
|
797
|
-
{
|
|
798
|
-
logLevel: logger.level,
|
|
799
|
-
nodeEnv: process.env.NODE_ENV,
|
|
800
|
-
queueRecoverMs: recoverMs,
|
|
801
|
-
},
|
|
802
|
-
"logger ready"
|
|
803
|
-
);
|
|
804
|
-
});
|
|
805
|
-
}
|
|
975
|
+
}
|
|
806
976
|
|
|
807
|
-
|
|
977
|
+
return {
|
|
978
|
+
port,
|
|
979
|
+
workspaceDir,
|
|
980
|
+
server,
|
|
981
|
+
close,
|
|
982
|
+
runChat: runChatPost,
|
|
983
|
+
handleHttp,
|
|
984
|
+
};
|
|
985
|
+
}
|
|
808
986
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
987
|
+
export async function runCli(argv = process.argv.slice(2)) {
|
|
988
|
+
const args = parseArgs(argv);
|
|
989
|
+
if (args.help) {
|
|
990
|
+
console.log(HELP);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
const envFile = path.resolve(
|
|
994
|
+
typeof args.envFile === "string" ? args.envFile : path.join(process.cwd(), ".env")
|
|
995
|
+
);
|
|
996
|
+
loadEnvFile(envFile);
|
|
997
|
+
if (!process.env.NODE_ENV?.trim()) {
|
|
998
|
+
process.env.NODE_ENV = "development";
|
|
999
|
+
}
|
|
1000
|
+
const instance = await startAiServer({
|
|
1001
|
+
port: Number(args.port || process.env.PORT || 3000),
|
|
1002
|
+
workspaceDir: args.workspace || process.env.AI_CLI_WORKSPACE || process.cwd(),
|
|
1003
|
+
uiDir: args.ui || process.env.AI_SERVER_UI || process.cwd(),
|
|
1004
|
+
corsOrigins: args.corsOrigin || undefined,
|
|
814
1005
|
});
|
|
1006
|
+
const shutdown = () => {
|
|
1007
|
+
void instance.close().finally(() => process.exit(0));
|
|
1008
|
+
};
|
|
1009
|
+
process.on("SIGINT", shutdown);
|
|
1010
|
+
process.on("SIGTERM", shutdown);
|
|
815
1011
|
}
|