agentgui 1.0.151 → 1.0.153
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/database.js +112 -66
- package/lib/claude-runner.js +23 -2
- package/package.json +1 -1
- package/server.js +326 -129
- package/static/index.html +95 -33
- package/static/js/client.js +129 -128
- package/static/js/conversations.js +63 -15
- package/static/js/event-processor.js +1 -3
- package/static/js/streaming-renderer.js +130 -158
- package/static/js/syntax-highlighter.js +1 -3
- package/static/js/ui-components.js +1 -3
- package/static/js/websocket-manager.js +16 -27
- package/static/styles.css +0 -1989
package/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import http from 'http';
|
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
|
+
import zlib from 'zlib';
|
|
5
6
|
import { fileURLToPath } from 'url';
|
|
6
7
|
import { WebSocketServer } from 'ws';
|
|
7
8
|
import { execSync } from 'child_process';
|
|
@@ -23,9 +24,11 @@ const SYSTEM_PROMPT = `Write all responses as clean semantic HTML. Use tags like
|
|
|
23
24
|
|
|
24
25
|
const activeExecutions = new Map();
|
|
25
26
|
const messageQueues = new Map();
|
|
27
|
+
const rateLimitState = new Map();
|
|
26
28
|
const STUCK_AGENT_THRESHOLD_MS = 600000;
|
|
27
29
|
const NO_PID_GRACE_PERIOD_MS = 60000;
|
|
28
30
|
const STALE_SESSION_MIN_AGE_MS = 30000;
|
|
31
|
+
const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60000;
|
|
29
32
|
|
|
30
33
|
const debugLog = (msg) => {
|
|
31
34
|
const timestamp = new Date().toISOString();
|
|
@@ -146,6 +149,36 @@ function parseBody(req) {
|
|
|
146
149
|
});
|
|
147
150
|
}
|
|
148
151
|
|
|
152
|
+
function acceptsEncoding(req, encoding) {
|
|
153
|
+
const accept = req.headers['accept-encoding'] || '';
|
|
154
|
+
return accept.includes(encoding);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function compressAndSend(req, res, statusCode, contentType, body) {
|
|
158
|
+
const raw = typeof body === 'string' ? Buffer.from(body) : body;
|
|
159
|
+
if (raw.length < 860) {
|
|
160
|
+
res.writeHead(statusCode, { 'Content-Type': contentType, 'Content-Length': raw.length });
|
|
161
|
+
res.end(raw);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (acceptsEncoding(req, 'br')) {
|
|
165
|
+
const compressed = zlib.brotliCompressSync(raw, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } });
|
|
166
|
+
res.writeHead(statusCode, { 'Content-Type': contentType, 'Content-Encoding': 'br', 'Content-Length': compressed.length });
|
|
167
|
+
res.end(compressed);
|
|
168
|
+
} else if (acceptsEncoding(req, 'gzip')) {
|
|
169
|
+
const compressed = zlib.gzipSync(raw, { level: 6 });
|
|
170
|
+
res.writeHead(statusCode, { 'Content-Type': contentType, 'Content-Encoding': 'gzip', 'Content-Length': compressed.length });
|
|
171
|
+
res.end(compressed);
|
|
172
|
+
} else {
|
|
173
|
+
res.writeHead(statusCode, { 'Content-Type': contentType, 'Content-Length': raw.length });
|
|
174
|
+
res.end(raw);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function sendJSON(req, res, statusCode, data) {
|
|
179
|
+
compressAndSend(req, res, statusCode, 'application/json', JSON.stringify(data));
|
|
180
|
+
}
|
|
181
|
+
|
|
149
182
|
const server = http.createServer(async (req, res) => {
|
|
150
183
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
151
184
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
@@ -179,8 +212,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
179
212
|
const pathOnly = routePath.split('?')[0];
|
|
180
213
|
|
|
181
214
|
if (pathOnly === '/api/conversations' && req.method === 'GET') {
|
|
182
|
-
|
|
183
|
-
res.end(JSON.stringify({ conversations: queries.getConversationsList() }));
|
|
215
|
+
sendJSON(req, res, 200, { conversations: queries.getConversationsList() });
|
|
184
216
|
return;
|
|
185
217
|
}
|
|
186
218
|
|
|
@@ -189,8 +221,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
189
221
|
const conversation = queries.createConversation(body.agentId, body.title, body.workingDirectory || null);
|
|
190
222
|
queries.createEvent('conversation.created', { agentId: body.agentId, workingDirectory: conversation.workingDirectory }, conversation.id);
|
|
191
223
|
broadcastSync({ type: 'conversation_created', conversation });
|
|
192
|
-
|
|
193
|
-
res.end(JSON.stringify({ conversation }));
|
|
224
|
+
sendJSON(req, res, 201, { conversation });
|
|
194
225
|
return;
|
|
195
226
|
}
|
|
196
227
|
|
|
@@ -198,39 +229,36 @@ const server = http.createServer(async (req, res) => {
|
|
|
198
229
|
if (convMatch) {
|
|
199
230
|
if (req.method === 'GET') {
|
|
200
231
|
const conv = queries.getConversation(convMatch[1]);
|
|
201
|
-
if (!conv) {
|
|
232
|
+
if (!conv) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
|
|
202
233
|
|
|
203
234
|
// Check both in-memory and database for active streaming status
|
|
204
235
|
const latestSession = queries.getLatestSession(convMatch[1]);
|
|
205
236
|
const isActivelyStreaming = activeExecutions.has(convMatch[1]) ||
|
|
206
237
|
(latestSession && latestSession.status === 'active');
|
|
207
238
|
|
|
208
|
-
|
|
209
|
-
res.end(JSON.stringify({
|
|
239
|
+
sendJSON(req, res, 200, {
|
|
210
240
|
conversation: conv,
|
|
211
241
|
isActivelyStreaming,
|
|
212
242
|
latestSession
|
|
213
|
-
})
|
|
243
|
+
});
|
|
214
244
|
return;
|
|
215
245
|
}
|
|
216
246
|
|
|
217
247
|
if (req.method === 'POST' || req.method === 'PUT') {
|
|
218
248
|
const body = await parseBody(req);
|
|
219
249
|
const conv = queries.updateConversation(convMatch[1], body);
|
|
220
|
-
if (!conv) {
|
|
250
|
+
if (!conv) { sendJSON(req, res, 404, { error: 'Conversation not found' }); return; }
|
|
221
251
|
queries.createEvent('conversation.updated', body, convMatch[1]);
|
|
222
252
|
broadcastSync({ type: 'conversation_updated', conversation: conv });
|
|
223
|
-
|
|
224
|
-
res.end(JSON.stringify({ conversation: conv }));
|
|
253
|
+
sendJSON(req, res, 200, { conversation: conv });
|
|
225
254
|
return;
|
|
226
255
|
}
|
|
227
256
|
|
|
228
257
|
if (req.method === 'DELETE') {
|
|
229
258
|
const deleted = queries.deleteConversation(convMatch[1]);
|
|
230
|
-
if (!deleted) {
|
|
259
|
+
if (!deleted) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
|
|
231
260
|
broadcastSync({ type: 'conversation_deleted', conversationId: convMatch[1] });
|
|
232
|
-
|
|
233
|
-
res.end(JSON.stringify({ deleted: true }));
|
|
261
|
+
sendJSON(req, res, 200, { deleted: true });
|
|
234
262
|
return;
|
|
235
263
|
}
|
|
236
264
|
}
|
|
@@ -242,15 +270,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
242
270
|
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 100);
|
|
243
271
|
const offset = Math.max(parseInt(url.searchParams.get('offset') || '0'), 0);
|
|
244
272
|
const result = queries.getPaginatedMessages(messagesMatch[1], limit, offset);
|
|
245
|
-
|
|
246
|
-
res.end(JSON.stringify(result));
|
|
273
|
+
sendJSON(req, res, 200, result);
|
|
247
274
|
return;
|
|
248
275
|
}
|
|
249
276
|
|
|
250
277
|
if (req.method === 'POST') {
|
|
251
278
|
const conversationId = messagesMatch[1];
|
|
252
279
|
const conv = queries.getConversation(conversationId);
|
|
253
|
-
if (!conv) {
|
|
280
|
+
if (!conv) { sendJSON(req, res, 404, { error: 'Conversation not found' }); return; }
|
|
254
281
|
const body = await parseBody(req);
|
|
255
282
|
const idempotencyKey = body.idempotencyKey || null;
|
|
256
283
|
const message = queries.createMessage(conversationId, 'user', body.content, idempotencyKey);
|
|
@@ -258,8 +285,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
258
285
|
broadcastSync({ type: 'message_created', conversationId, message, timestamp: Date.now() });
|
|
259
286
|
const session = queries.createSession(conversationId);
|
|
260
287
|
queries.createEvent('session.created', { messageId: message.id, sessionId: session.id }, conversationId, session.id);
|
|
261
|
-
|
|
262
|
-
res.end(JSON.stringify({ message, session, idempotencyKey }));
|
|
288
|
+
sendJSON(req, res, 201, { message, session, idempotencyKey });
|
|
263
289
|
// Fire-and-forget with proper error handling
|
|
264
290
|
processMessage(conversationId, message.id, body.content, body.agentId)
|
|
265
291
|
.catch(err => debugLog(`[processMessage] Uncaught error: ${err.message}`));
|
|
@@ -272,10 +298,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
272
298
|
const conversationId = streamMatch[1];
|
|
273
299
|
const body = await parseBody(req);
|
|
274
300
|
const conv = queries.getConversation(conversationId);
|
|
275
|
-
if (!conv) {
|
|
301
|
+
if (!conv) { sendJSON(req, res, 404, { error: 'Conversation not found' }); return; }
|
|
276
302
|
|
|
277
|
-
const prompt = body.content || '';
|
|
278
|
-
const agentId = body.agentId || 'claude-code';
|
|
303
|
+
const prompt = body.content || body.message || '';
|
|
304
|
+
const agentId = body.agentId || conv.agentType || conv.agentId || 'claude-code';
|
|
279
305
|
|
|
280
306
|
const userMessage = queries.createMessage(conversationId, 'user', prompt);
|
|
281
307
|
queries.createEvent('message.created', { role: 'user', messageId: userMessage.id }, conversationId);
|
|
@@ -290,16 +316,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
290
316
|
const queueLength = messageQueues.get(conversationId).length;
|
|
291
317
|
broadcastSync({ type: 'queue_status', conversationId, queueLength, messageId: userMessage.id, timestamp: Date.now() });
|
|
292
318
|
|
|
293
|
-
|
|
294
|
-
res.end(JSON.stringify({ message: userMessage, queued: true, queuePosition: queueLength }));
|
|
319
|
+
sendJSON(req, res, 200, { message: userMessage, queued: true, queuePosition: queueLength });
|
|
295
320
|
return;
|
|
296
321
|
}
|
|
297
322
|
|
|
298
323
|
const session = queries.createSession(conversationId);
|
|
299
324
|
queries.createEvent('session.created', { messageId: userMessage.id, sessionId: session.id }, conversationId, session.id);
|
|
300
325
|
|
|
301
|
-
|
|
302
|
-
res.end(JSON.stringify({ message: userMessage, session, streamId: session.id }));
|
|
326
|
+
sendJSON(req, res, 200, { message: userMessage, session, streamId: session.id });
|
|
303
327
|
|
|
304
328
|
broadcastSync({
|
|
305
329
|
type: 'streaming_start',
|
|
@@ -318,19 +342,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
318
342
|
const messageMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/messages\/([^/]+)$/);
|
|
319
343
|
if (messageMatch && req.method === 'GET') {
|
|
320
344
|
const msg = queries.getMessage(messageMatch[2]);
|
|
321
|
-
if (!msg || msg.conversationId !== messageMatch[1]) {
|
|
322
|
-
|
|
323
|
-
res.end(JSON.stringify({ message: msg }));
|
|
345
|
+
if (!msg || msg.conversationId !== messageMatch[1]) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
|
|
346
|
+
sendJSON(req, res, 200, { message: msg });
|
|
324
347
|
return;
|
|
325
348
|
}
|
|
326
349
|
|
|
327
350
|
const sessionMatch = pathOnly.match(/^\/api\/sessions\/([^/]+)$/);
|
|
328
351
|
if (sessionMatch && req.method === 'GET') {
|
|
329
352
|
const sess = queries.getSession(sessionMatch[1]);
|
|
330
|
-
if (!sess) {
|
|
353
|
+
if (!sess) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
|
|
331
354
|
const events = queries.getSessionEvents(sessionMatch[1]);
|
|
332
|
-
|
|
333
|
-
res.end(JSON.stringify({ session: sess, events }));
|
|
355
|
+
sendJSON(req, res, 200, { session: sess, events });
|
|
334
356
|
return;
|
|
335
357
|
}
|
|
336
358
|
|
|
@@ -338,20 +360,33 @@ const server = http.createServer(async (req, res) => {
|
|
|
338
360
|
if (fullLoadMatch && req.method === 'GET') {
|
|
339
361
|
const conversationId = fullLoadMatch[1];
|
|
340
362
|
const conv = queries.getConversation(conversationId);
|
|
341
|
-
if (!conv) {
|
|
363
|
+
if (!conv) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
|
|
342
364
|
const latestSession = queries.getLatestSession(conversationId);
|
|
343
365
|
const isActivelyStreaming = activeExecutions.has(conversationId) ||
|
|
344
366
|
(latestSession && latestSession.status === 'active');
|
|
345
|
-
|
|
367
|
+
|
|
368
|
+
const url = new URL(req.url, 'http://localhost');
|
|
369
|
+
const chunkLimit = Math.min(parseInt(url.searchParams.get('chunkLimit') || '500'), 5000);
|
|
370
|
+
const allChunks = url.searchParams.get('allChunks') === '1';
|
|
371
|
+
|
|
372
|
+
const totalChunks = queries.getConversationChunkCount(conversationId);
|
|
373
|
+
let chunks;
|
|
374
|
+
if (allChunks || totalChunks <= chunkLimit) {
|
|
375
|
+
chunks = queries.getConversationChunks(conversationId);
|
|
376
|
+
} else {
|
|
377
|
+
chunks = queries.getRecentConversationChunks(conversationId, chunkLimit);
|
|
378
|
+
}
|
|
346
379
|
const msgResult = queries.getPaginatedMessages(conversationId, 100, 0);
|
|
347
|
-
|
|
348
|
-
|
|
380
|
+
const rateLimitInfo = rateLimitState.get(conversationId) || null;
|
|
381
|
+
sendJSON(req, res, 200, {
|
|
349
382
|
conversation: conv,
|
|
350
383
|
isActivelyStreaming,
|
|
351
384
|
latestSession,
|
|
352
385
|
chunks,
|
|
353
|
-
|
|
354
|
-
|
|
386
|
+
totalChunks,
|
|
387
|
+
messages: msgResult.messages,
|
|
388
|
+
rateLimitInfo
|
|
389
|
+
});
|
|
355
390
|
return;
|
|
356
391
|
}
|
|
357
392
|
|
|
@@ -359,7 +394,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
359
394
|
if (conversationChunksMatch && req.method === 'GET') {
|
|
360
395
|
const conversationId = conversationChunksMatch[1];
|
|
361
396
|
const conv = queries.getConversation(conversationId);
|
|
362
|
-
if (!conv) {
|
|
397
|
+
if (!conv) { sendJSON(req, res, 404, { error: 'Conversation not found' }); return; }
|
|
363
398
|
|
|
364
399
|
const url = new URL(req.url, 'http://localhost');
|
|
365
400
|
const since = parseInt(url.searchParams.get('since') || '0');
|
|
@@ -367,8 +402,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
367
402
|
const allChunks = queries.getConversationChunks(conversationId);
|
|
368
403
|
debugLog(`[chunks] Conv ${conversationId}: ${allChunks.length} total chunks`);
|
|
369
404
|
const chunks = since > 0 ? allChunks.filter(c => c.created_at > since) : allChunks;
|
|
370
|
-
|
|
371
|
-
res.end(JSON.stringify({ ok: true, chunks }));
|
|
405
|
+
sendJSON(req, res, 200, { ok: true, chunks });
|
|
372
406
|
return;
|
|
373
407
|
}
|
|
374
408
|
|
|
@@ -376,14 +410,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
376
410
|
if (sessionChunksMatch && req.method === 'GET') {
|
|
377
411
|
const sessionId = sessionChunksMatch[1];
|
|
378
412
|
const sess = queries.getSession(sessionId);
|
|
379
|
-
if (!sess) {
|
|
413
|
+
if (!sess) { sendJSON(req, res, 404, { error: 'Session not found' }); return; }
|
|
380
414
|
|
|
381
415
|
const url = new URL(req.url, 'http://localhost');
|
|
382
416
|
const since = parseInt(url.searchParams.get('since') || '0');
|
|
383
417
|
|
|
384
418
|
const chunks = queries.getChunksSince(sessionId, since);
|
|
385
|
-
|
|
386
|
-
res.end(JSON.stringify({ ok: true, chunks }));
|
|
419
|
+
sendJSON(req, res, 200, { ok: true, chunks });
|
|
387
420
|
return;
|
|
388
421
|
}
|
|
389
422
|
|
|
@@ -391,13 +424,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
391
424
|
const convId = pathOnly.match(/^\/api\/conversations\/([^/]+)\/sessions\/latest$/)[1];
|
|
392
425
|
const latestSession = queries.getLatestSession(convId);
|
|
393
426
|
if (!latestSession) {
|
|
394
|
-
|
|
395
|
-
res.end(JSON.stringify({ session: null }));
|
|
427
|
+
sendJSON(req, res, 200, { session: null });
|
|
396
428
|
return;
|
|
397
429
|
}
|
|
398
430
|
const events = queries.getSessionEvents(latestSession.id);
|
|
399
|
-
|
|
400
|
-
res.end(JSON.stringify({ session: latestSession, events }));
|
|
431
|
+
sendJSON(req, res, 200, { session: latestSession, events });
|
|
401
432
|
return;
|
|
402
433
|
}
|
|
403
434
|
|
|
@@ -432,39 +463,33 @@ const server = http.createServer(async (req, res) => {
|
|
|
432
463
|
executionData.events = executionData.events.filter(e => e.type === filterType);
|
|
433
464
|
}
|
|
434
465
|
|
|
435
|
-
|
|
436
|
-
res.end(JSON.stringify(executionData));
|
|
466
|
+
sendJSON(req, res, 200, executionData);
|
|
437
467
|
} catch (err) {
|
|
438
|
-
|
|
439
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
468
|
+
sendJSON(req, res, 400, { error: err.message });
|
|
440
469
|
}
|
|
441
470
|
return;
|
|
442
471
|
}
|
|
443
472
|
|
|
444
473
|
if (routePath === '/api/agents' && req.method === 'GET') {
|
|
445
|
-
|
|
446
|
-
res.end(JSON.stringify({ agents: discoveredAgents }));
|
|
474
|
+
sendJSON(req, res, 200, { agents: discoveredAgents });
|
|
447
475
|
return;
|
|
448
476
|
}
|
|
449
477
|
|
|
450
478
|
|
|
451
479
|
if (routePath === '/api/import/claude-code' && req.method === 'GET') {
|
|
452
480
|
const result = queries.importClaudeCodeConversations();
|
|
453
|
-
|
|
454
|
-
res.end(JSON.stringify({ imported: result }));
|
|
481
|
+
sendJSON(req, res, 200, { imported: result });
|
|
455
482
|
return;
|
|
456
483
|
}
|
|
457
484
|
|
|
458
485
|
if (routePath === '/api/discover/claude-code' && req.method === 'GET') {
|
|
459
486
|
const discovered = queries.discoverClaudeCodeConversations();
|
|
460
|
-
|
|
461
|
-
res.end(JSON.stringify({ discovered }));
|
|
487
|
+
sendJSON(req, res, 200, { discovered });
|
|
462
488
|
return;
|
|
463
489
|
}
|
|
464
490
|
|
|
465
491
|
if (routePath === '/api/home' && req.method === 'GET') {
|
|
466
|
-
|
|
467
|
-
res.end(JSON.stringify({ home: os.homedir(), cwd: STARTUP_CWD }));
|
|
492
|
+
sendJSON(req, res, 200, { home: os.homedir(), cwd: STARTUP_CWD });
|
|
468
493
|
return;
|
|
469
494
|
}
|
|
470
495
|
|
|
@@ -474,20 +499,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
474
499
|
for await (const chunk of req) chunks.push(chunk);
|
|
475
500
|
const audioBuffer = Buffer.concat(chunks);
|
|
476
501
|
if (audioBuffer.length === 0) {
|
|
477
|
-
|
|
478
|
-
res.end(JSON.stringify({ error: 'No audio data' }));
|
|
502
|
+
sendJSON(req, res, 400, { error: 'No audio data' });
|
|
479
503
|
return;
|
|
480
504
|
}
|
|
481
505
|
const { transcribe } = await getSpeech();
|
|
482
506
|
const text = await transcribe(audioBuffer);
|
|
483
|
-
|
|
484
|
-
res.end(JSON.stringify({ text: (text || '').trim() }));
|
|
507
|
+
sendJSON(req, res, 200, { text: (text || '').trim() });
|
|
485
508
|
} catch (err) {
|
|
486
509
|
debugLog('[STT] Error: ' + err.message);
|
|
487
|
-
if (!res.headersSent) {
|
|
488
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
489
|
-
}
|
|
490
|
-
res.end(JSON.stringify({ error: err.message || 'STT failed' }));
|
|
510
|
+
if (!res.headersSent) sendJSON(req, res, 500, { error: err.message || 'STT failed' });
|
|
491
511
|
}
|
|
492
512
|
return;
|
|
493
513
|
}
|
|
@@ -497,8 +517,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
497
517
|
const body = await parseBody(req);
|
|
498
518
|
const text = body.text || '';
|
|
499
519
|
if (!text) {
|
|
500
|
-
|
|
501
|
-
res.end(JSON.stringify({ error: 'No text provided' }));
|
|
520
|
+
sendJSON(req, res, 400, { error: 'No text provided' });
|
|
502
521
|
return;
|
|
503
522
|
}
|
|
504
523
|
const { synthesize } = await getSpeech();
|
|
@@ -507,10 +526,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
507
526
|
res.end(wavBuffer);
|
|
508
527
|
} catch (err) {
|
|
509
528
|
debugLog('[TTS] Error: ' + err.message);
|
|
510
|
-
if (!res.headersSent) {
|
|
511
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
512
|
-
}
|
|
513
|
-
res.end(JSON.stringify({ error: err.message || 'TTS failed' }));
|
|
529
|
+
if (!res.headersSent) sendJSON(req, res, 500, { error: err.message || 'TTS failed' });
|
|
514
530
|
}
|
|
515
531
|
return;
|
|
516
532
|
}
|
|
@@ -518,11 +534,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
518
534
|
if (routePath === '/api/speech-status' && req.method === 'GET') {
|
|
519
535
|
try {
|
|
520
536
|
const { getStatus } = await getSpeech();
|
|
521
|
-
|
|
522
|
-
res.end(JSON.stringify(getStatus()));
|
|
537
|
+
sendJSON(req, res, 200, getStatus());
|
|
523
538
|
} catch (err) {
|
|
524
|
-
|
|
525
|
-
res.end(JSON.stringify({ sttReady: false, ttsReady: false, sttLoading: false, ttsLoading: false }));
|
|
539
|
+
sendJSON(req, res, 200, { sttReady: false, ttsReady: false, sttLoading: false, ttsLoading: false });
|
|
526
540
|
}
|
|
527
541
|
return;
|
|
528
542
|
}
|
|
@@ -538,11 +552,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
538
552
|
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
539
553
|
.map(e => ({ name: e.name }))
|
|
540
554
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
541
|
-
|
|
542
|
-
res.end(JSON.stringify({ folders }));
|
|
555
|
+
sendJSON(req, res, 200, { folders });
|
|
543
556
|
} catch (err) {
|
|
544
|
-
|
|
545
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
557
|
+
sendJSON(req, res, 400, { error: err.message });
|
|
546
558
|
}
|
|
547
559
|
return;
|
|
548
560
|
}
|
|
@@ -565,8 +577,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
565
577
|
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' });
|
|
566
578
|
res.end(fileContent);
|
|
567
579
|
} catch (err) {
|
|
568
|
-
|
|
569
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
580
|
+
sendJSON(req, res, 400, { error: err.message });
|
|
570
581
|
}
|
|
571
582
|
return;
|
|
572
583
|
}
|
|
@@ -574,7 +585,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
574
585
|
// Handle conversation detail routes - serve index.html for client-side routing
|
|
575
586
|
if (pathOnly.match(/^\/conversations\/[^\/]+$/)) {
|
|
576
587
|
const indexPath = path.join(staticDir, 'index.html');
|
|
577
|
-
serveFile(indexPath, res);
|
|
588
|
+
serveFile(indexPath, res, req);
|
|
578
589
|
return;
|
|
579
590
|
}
|
|
580
591
|
|
|
@@ -589,34 +600,59 @@ const server = http.createServer(async (req, res) => {
|
|
|
589
600
|
filePath = path.join(filePath, 'index.html');
|
|
590
601
|
fs.stat(filePath, (err2) => {
|
|
591
602
|
if (err2) { res.writeHead(404); res.end('Not found'); return; }
|
|
592
|
-
serveFile(filePath, res);
|
|
603
|
+
serveFile(filePath, res, req);
|
|
593
604
|
});
|
|
594
605
|
} else {
|
|
595
|
-
serveFile(filePath, res);
|
|
606
|
+
serveFile(filePath, res, req);
|
|
596
607
|
}
|
|
597
608
|
});
|
|
598
609
|
} catch (e) {
|
|
599
610
|
console.error('Server error:', e.message);
|
|
600
|
-
|
|
601
|
-
res.end(JSON.stringify({ error: e.message }));
|
|
611
|
+
sendJSON(req, res, 500, { error: e.message });
|
|
602
612
|
}
|
|
603
613
|
});
|
|
604
614
|
|
|
605
615
|
const MIME_TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml' };
|
|
606
616
|
|
|
607
|
-
function
|
|
617
|
+
function generateETag(stats) {
|
|
618
|
+
return `"${stats.mtimeMs.toString(36)}-${stats.size.toString(36)}"`;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function serveFile(filePath, res, req) {
|
|
608
622
|
const ext = path.extname(filePath).toLowerCase();
|
|
609
623
|
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
610
624
|
|
|
611
625
|
if (ext !== '.html') {
|
|
612
626
|
fs.stat(filePath, (err, stats) => {
|
|
613
627
|
if (err) { res.writeHead(500); res.end('Server error'); return; }
|
|
614
|
-
|
|
628
|
+
const etag = generateETag(stats);
|
|
629
|
+
if (req && req.headers['if-none-match'] === etag) {
|
|
630
|
+
res.writeHead(304);
|
|
631
|
+
res.end();
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const headers = {
|
|
615
635
|
'Content-Type': contentType,
|
|
616
636
|
'Content-Length': stats.size,
|
|
617
|
-
'
|
|
618
|
-
|
|
619
|
-
|
|
637
|
+
'ETag': etag,
|
|
638
|
+
'Cache-Control': 'public, max-age=3600, must-revalidate'
|
|
639
|
+
};
|
|
640
|
+
if (acceptsEncoding(req, 'br') && stats.size > 860) {
|
|
641
|
+
const stream = fs.createReadStream(filePath);
|
|
642
|
+
headers['Content-Encoding'] = 'br';
|
|
643
|
+
delete headers['Content-Length'];
|
|
644
|
+
res.writeHead(200, headers);
|
|
645
|
+
stream.pipe(zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } })).pipe(res);
|
|
646
|
+
} else if (acceptsEncoding(req, 'gzip') && stats.size > 860) {
|
|
647
|
+
const stream = fs.createReadStream(filePath);
|
|
648
|
+
headers['Content-Encoding'] = 'gzip';
|
|
649
|
+
delete headers['Content-Length'];
|
|
650
|
+
res.writeHead(200, headers);
|
|
651
|
+
stream.pipe(zlib.createGzip({ level: 6 })).pipe(res);
|
|
652
|
+
} else {
|
|
653
|
+
res.writeHead(200, headers);
|
|
654
|
+
fs.createReadStream(filePath).pipe(res);
|
|
655
|
+
}
|
|
620
656
|
});
|
|
621
657
|
return;
|
|
622
658
|
}
|
|
@@ -629,24 +665,52 @@ function serveFile(filePath, res) {
|
|
|
629
665
|
if (watch) {
|
|
630
666
|
content += `\n<script>(function(){const ws=new WebSocket('ws://'+location.host+'${BASE_URL}/hot-reload');ws.onmessage=e=>{if(JSON.parse(e.data).type==='reload')location.reload()};})();</script>`;
|
|
631
667
|
}
|
|
632
|
-
|
|
633
|
-
res.end(content);
|
|
668
|
+
compressAndSend(req, res, 200, contentType, content);
|
|
634
669
|
});
|
|
635
670
|
}
|
|
636
671
|
|
|
637
|
-
function
|
|
638
|
-
|
|
672
|
+
function createChunkBatcher() {
|
|
673
|
+
const pending = [];
|
|
674
|
+
let timer = null;
|
|
675
|
+
const BATCH_SIZE = 10;
|
|
676
|
+
const BATCH_INTERVAL = 50;
|
|
677
|
+
|
|
678
|
+
function flush() {
|
|
679
|
+
if (pending.length === 0) return;
|
|
680
|
+
const batch = pending.splice(0);
|
|
639
681
|
try {
|
|
640
|
-
|
|
682
|
+
const tx = queries._db ? queries._db.transaction(() => {
|
|
683
|
+
for (const c of batch) queries.createChunk(c.sessionId, c.conversationId, c.sequence, c.type, c.data);
|
|
684
|
+
}) : null;
|
|
685
|
+
if (tx) { tx(); } else {
|
|
686
|
+
for (const c of batch) {
|
|
687
|
+
try { queries.createChunk(c.sessionId, c.conversationId, c.sequence, c.type, c.data); } catch (e) { debugLog(`[chunk] ${e.message}`); }
|
|
688
|
+
}
|
|
689
|
+
}
|
|
641
690
|
} catch (err) {
|
|
642
|
-
debugLog(`[chunk]
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
return null;
|
|
691
|
+
debugLog(`[chunk-batch] Batch write failed: ${err.message}`);
|
|
692
|
+
for (const c of batch) {
|
|
693
|
+
try { queries.createChunk(c.sessionId, c.conversationId, c.sequence, c.type, c.data); } catch (_) {}
|
|
646
694
|
}
|
|
647
695
|
}
|
|
648
696
|
}
|
|
649
|
-
|
|
697
|
+
|
|
698
|
+
function add(sessionId, conversationId, sequence, blockType, blockData) {
|
|
699
|
+
pending.push({ sessionId, conversationId, sequence, type: blockType, data: blockData });
|
|
700
|
+
if (pending.length >= BATCH_SIZE) {
|
|
701
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
702
|
+
flush();
|
|
703
|
+
} else if (!timer) {
|
|
704
|
+
timer = setTimeout(() => { timer = null; flush(); }, BATCH_INTERVAL);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function drain() {
|
|
709
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
710
|
+
flush();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return { add, drain };
|
|
650
714
|
}
|
|
651
715
|
|
|
652
716
|
async function processMessageWithStreaming(conversationId, messageId, sessionId, content, agentId) {
|
|
@@ -654,6 +718,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
|
|
|
654
718
|
activeExecutions.set(conversationId, { pid: null, startTime, sessionId, lastActivity: startTime });
|
|
655
719
|
queries.setIsStreaming(conversationId, true);
|
|
656
720
|
queries.updateSession(sessionId, { status: 'active' });
|
|
721
|
+
const batcher = createChunkBatcher();
|
|
657
722
|
|
|
658
723
|
try {
|
|
659
724
|
debugLog(`[stream] Starting: conversationId=${conversationId}, sessionId=${sessionId}`);
|
|
@@ -683,7 +748,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
|
|
|
683
748
|
};
|
|
684
749
|
|
|
685
750
|
currentSequence++;
|
|
686
|
-
|
|
751
|
+
batcher.add(sessionId, conversationId, currentSequence, 'system', systemBlock);
|
|
687
752
|
|
|
688
753
|
broadcastSync({
|
|
689
754
|
type: 'streaming_progress',
|
|
@@ -698,7 +763,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
|
|
|
698
763
|
allBlocks.push(block);
|
|
699
764
|
|
|
700
765
|
currentSequence++;
|
|
701
|
-
|
|
766
|
+
batcher.add(sessionId, conversationId, currentSequence, block.type || 'assistant', block);
|
|
702
767
|
|
|
703
768
|
broadcastSync({
|
|
704
769
|
type: 'streaming_progress',
|
|
@@ -720,7 +785,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
|
|
|
720
785
|
};
|
|
721
786
|
|
|
722
787
|
currentSequence++;
|
|
723
|
-
|
|
788
|
+
batcher.add(sessionId, conversationId, currentSequence, 'tool_result', toolResultBlock);
|
|
724
789
|
|
|
725
790
|
broadcastSync({
|
|
726
791
|
type: 'streaming_progress',
|
|
@@ -744,7 +809,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
|
|
|
744
809
|
};
|
|
745
810
|
|
|
746
811
|
currentSequence++;
|
|
747
|
-
|
|
812
|
+
batcher.add(sessionId, conversationId, currentSequence, 'result', resultBlock);
|
|
748
813
|
|
|
749
814
|
broadcastSync({
|
|
750
815
|
type: 'streaming_progress',
|
|
@@ -777,11 +842,13 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
|
|
|
777
842
|
};
|
|
778
843
|
|
|
779
844
|
const { outputs, sessionId: claudeSessionId } = await runClaudeWithStreaming(content, cwd, agentId || 'claude-code', config);
|
|
845
|
+
activeExecutions.delete(conversationId);
|
|
846
|
+
batcher.drain();
|
|
780
847
|
debugLog(`[stream] Claude returned ${outputs.length} outputs, sessionId=${claudeSessionId}`);
|
|
781
848
|
|
|
782
|
-
if (claudeSessionId
|
|
849
|
+
if (claudeSessionId) {
|
|
783
850
|
queries.setClaudeSessionId(conversationId, claudeSessionId);
|
|
784
|
-
debugLog(`[stream]
|
|
851
|
+
debugLog(`[stream] Updated claudeSessionId=${claudeSessionId}`);
|
|
785
852
|
}
|
|
786
853
|
|
|
787
854
|
// Mark session as complete
|
|
@@ -804,13 +871,47 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
|
|
|
804
871
|
const elapsed = Date.now() - startTime;
|
|
805
872
|
debugLog(`[stream] Error after ${elapsed}ms: ${error.message}`);
|
|
806
873
|
|
|
807
|
-
|
|
874
|
+
const isRateLimit = error.rateLimited ||
|
|
875
|
+
/rate.?limit|429|too many requests|overloaded|throttl/i.test(error.message);
|
|
876
|
+
|
|
808
877
|
queries.updateSession(sessionId, {
|
|
809
878
|
status: 'error',
|
|
810
879
|
error: error.message,
|
|
811
880
|
completed_at: Date.now()
|
|
812
881
|
});
|
|
813
882
|
|
|
883
|
+
if (isRateLimit) {
|
|
884
|
+
const cooldownMs = (error.retryAfterSec || 60) * 1000;
|
|
885
|
+
const retryAt = Date.now() + cooldownMs;
|
|
886
|
+
rateLimitState.set(conversationId, { retryAt, cooldownMs });
|
|
887
|
+
debugLog(`[rate-limit] Conv ${conversationId} hit rate limit, retry in ${cooldownMs}ms`);
|
|
888
|
+
|
|
889
|
+
broadcastSync({
|
|
890
|
+
type: 'rate_limit_hit',
|
|
891
|
+
sessionId,
|
|
892
|
+
conversationId,
|
|
893
|
+
retryAfterMs: cooldownMs,
|
|
894
|
+
retryAt,
|
|
895
|
+
timestamp: Date.now()
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
batcher.drain();
|
|
899
|
+
activeExecutions.delete(conversationId);
|
|
900
|
+
queries.setIsStreaming(conversationId, false);
|
|
901
|
+
|
|
902
|
+
setTimeout(() => {
|
|
903
|
+
rateLimitState.delete(conversationId);
|
|
904
|
+
debugLog(`[rate-limit] Conv ${conversationId} cooldown expired, restarting`);
|
|
905
|
+
broadcastSync({
|
|
906
|
+
type: 'rate_limit_clear',
|
|
907
|
+
conversationId,
|
|
908
|
+
timestamp: Date.now()
|
|
909
|
+
});
|
|
910
|
+
scheduleRetry(conversationId, messageId, content, agentId);
|
|
911
|
+
}, cooldownMs);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
|
|
814
915
|
broadcastSync({
|
|
815
916
|
type: 'streaming_error',
|
|
816
917
|
sessionId,
|
|
@@ -828,12 +929,32 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
|
|
|
828
929
|
timestamp: Date.now()
|
|
829
930
|
});
|
|
830
931
|
} finally {
|
|
932
|
+
batcher.drain();
|
|
831
933
|
activeExecutions.delete(conversationId);
|
|
832
934
|
queries.setIsStreaming(conversationId, false);
|
|
833
|
-
|
|
935
|
+
if (!rateLimitState.has(conversationId)) {
|
|
936
|
+
drainMessageQueue(conversationId);
|
|
937
|
+
}
|
|
834
938
|
}
|
|
835
939
|
}
|
|
836
940
|
|
|
941
|
+
function scheduleRetry(conversationId, messageId, content, agentId) {
|
|
942
|
+
const newSession = queries.createSession(conversationId);
|
|
943
|
+
queries.createEvent('session.created', { messageId, sessionId: newSession.id, retryReason: 'rate_limit' }, conversationId, newSession.id);
|
|
944
|
+
|
|
945
|
+
broadcastSync({
|
|
946
|
+
type: 'streaming_start',
|
|
947
|
+
sessionId: newSession.id,
|
|
948
|
+
conversationId,
|
|
949
|
+
messageId,
|
|
950
|
+
agentId,
|
|
951
|
+
timestamp: Date.now()
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
processMessageWithStreaming(conversationId, messageId, newSession.id, content, agentId)
|
|
955
|
+
.catch(err => debugLog(`[retry] Error: ${err.message}`));
|
|
956
|
+
}
|
|
957
|
+
|
|
837
958
|
function drainMessageQueue(conversationId) {
|
|
838
959
|
const queue = messageQueues.get(conversationId);
|
|
839
960
|
if (!queue || queue.length === 0) return;
|
|
@@ -881,7 +1002,7 @@ async function processMessage(conversationId, messageId, content, agentId) {
|
|
|
881
1002
|
systemPrompt: SYSTEM_PROMPT
|
|
882
1003
|
});
|
|
883
1004
|
|
|
884
|
-
if (claudeSessionId
|
|
1005
|
+
if (claudeSessionId) {
|
|
885
1006
|
queries.setClaudeSessionId(conversationId, claudeSessionId);
|
|
886
1007
|
}
|
|
887
1008
|
|
|
@@ -915,9 +1036,16 @@ async function processMessage(conversationId, messageId, content, agentId) {
|
|
|
915
1036
|
}
|
|
916
1037
|
}
|
|
917
1038
|
|
|
918
|
-
const wss = new WebSocketServer({
|
|
1039
|
+
const wss = new WebSocketServer({
|
|
1040
|
+
server,
|
|
1041
|
+
perMessageDeflate: {
|
|
1042
|
+
zlibDeflateOptions: { level: 6 },
|
|
1043
|
+
threshold: 256
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
919
1046
|
const hotReloadClients = [];
|
|
920
1047
|
const syncClients = new Set();
|
|
1048
|
+
const subscriptionIndex = new Map();
|
|
921
1049
|
|
|
922
1050
|
wss.on('connection', (ws, req) => {
|
|
923
1051
|
// req.url in WebSocket is just the path (e.g., '/gm/sync'), not a full URL
|
|
@@ -941,8 +1069,17 @@ wss.on('connection', (ws, req) => {
|
|
|
941
1069
|
try {
|
|
942
1070
|
const data = JSON.parse(msg);
|
|
943
1071
|
if (data.type === 'subscribe') {
|
|
944
|
-
if (data.sessionId)
|
|
945
|
-
|
|
1072
|
+
if (data.sessionId) {
|
|
1073
|
+
ws.subscriptions.add(data.sessionId);
|
|
1074
|
+
if (!subscriptionIndex.has(data.sessionId)) subscriptionIndex.set(data.sessionId, new Set());
|
|
1075
|
+
subscriptionIndex.get(data.sessionId).add(ws);
|
|
1076
|
+
}
|
|
1077
|
+
if (data.conversationId) {
|
|
1078
|
+
const key = `conv-${data.conversationId}`;
|
|
1079
|
+
ws.subscriptions.add(key);
|
|
1080
|
+
if (!subscriptionIndex.has(key)) subscriptionIndex.set(key, new Set());
|
|
1081
|
+
subscriptionIndex.get(key).add(ws);
|
|
1082
|
+
}
|
|
946
1083
|
const subTarget = data.sessionId || data.conversationId;
|
|
947
1084
|
debugLog(`[WebSocket] Client ${ws.clientId} subscribed to ${subTarget}`);
|
|
948
1085
|
ws.send(JSON.stringify({
|
|
@@ -953,6 +1090,8 @@ wss.on('connection', (ws, req) => {
|
|
|
953
1090
|
}));
|
|
954
1091
|
} else if (data.type === 'unsubscribe') {
|
|
955
1092
|
ws.subscriptions.delete(data.sessionId);
|
|
1093
|
+
const idx = subscriptionIndex.get(data.sessionId);
|
|
1094
|
+
if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(data.sessionId); }
|
|
956
1095
|
debugLog(`[WebSocket] Client ${ws.clientId} unsubscribed from ${data.sessionId}`);
|
|
957
1096
|
} else if (data.type === 'get_subscriptions') {
|
|
958
1097
|
ws.send(JSON.stringify({
|
|
@@ -979,6 +1118,10 @@ wss.on('connection', (ws, req) => {
|
|
|
979
1118
|
ws.on('pong', () => { ws.isAlive = true; });
|
|
980
1119
|
ws.on('close', () => {
|
|
981
1120
|
syncClients.delete(ws);
|
|
1121
|
+
for (const sub of ws.subscriptions) {
|
|
1122
|
+
const idx = subscriptionIndex.get(sub);
|
|
1123
|
+
if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(sub); }
|
|
1124
|
+
}
|
|
982
1125
|
console.log(`[WebSocket] Client ${ws.clientId} disconnected`);
|
|
983
1126
|
});
|
|
984
1127
|
}
|
|
@@ -987,22 +1130,56 @@ wss.on('connection', (ws, req) => {
|
|
|
987
1130
|
const BROADCAST_TYPES = new Set([
|
|
988
1131
|
'message_created', 'conversation_created', 'conversations_updated',
|
|
989
1132
|
'conversation_deleted', 'queue_status', 'streaming_start',
|
|
990
|
-
'streaming_complete', 'streaming_error'
|
|
1133
|
+
'streaming_complete', 'streaming_error', 'rate_limit_hit',
|
|
1134
|
+
'rate_limit_clear'
|
|
991
1135
|
]);
|
|
992
1136
|
|
|
1137
|
+
const wsBatchQueues = new Map();
|
|
1138
|
+
const WS_BATCH_INTERVAL = 16;
|
|
1139
|
+
|
|
1140
|
+
function flushWsBatch(ws) {
|
|
1141
|
+
const queue = wsBatchQueues.get(ws);
|
|
1142
|
+
if (!queue || queue.msgs.length === 0) return;
|
|
1143
|
+
if (ws.readyState !== 1) { wsBatchQueues.delete(ws); return; }
|
|
1144
|
+
if (queue.msgs.length === 1) {
|
|
1145
|
+
ws.send(queue.msgs[0]);
|
|
1146
|
+
} else {
|
|
1147
|
+
ws.send('[' + queue.msgs.join(',') + ']');
|
|
1148
|
+
}
|
|
1149
|
+
queue.msgs.length = 0;
|
|
1150
|
+
queue.timer = null;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function sendToClient(ws, data) {
|
|
1154
|
+
if (ws.readyState !== 1) return;
|
|
1155
|
+
let queue = wsBatchQueues.get(ws);
|
|
1156
|
+
if (!queue) { queue = { msgs: [], timer: null }; wsBatchQueues.set(ws, queue); }
|
|
1157
|
+
queue.msgs.push(data);
|
|
1158
|
+
if (!queue.timer) {
|
|
1159
|
+
queue.timer = setTimeout(() => flushWsBatch(ws), WS_BATCH_INTERVAL);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
993
1163
|
function broadcastSync(event) {
|
|
994
1164
|
if (syncClients.size === 0) return;
|
|
995
1165
|
const data = JSON.stringify(event);
|
|
996
1166
|
const isBroadcast = BROADCAST_TYPES.has(event.type);
|
|
997
1167
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
(event.sessionId && ws.subscriptions?.has(event.sessionId)) ||
|
|
1002
|
-
(event.conversationId && ws.subscriptions?.has(`conv-${event.conversationId}`))) {
|
|
1003
|
-
ws.send(data);
|
|
1004
|
-
}
|
|
1168
|
+
if (isBroadcast) {
|
|
1169
|
+
for (const ws of syncClients) sendToClient(ws, data);
|
|
1170
|
+
return;
|
|
1005
1171
|
}
|
|
1172
|
+
|
|
1173
|
+
const targets = new Set();
|
|
1174
|
+
if (event.sessionId) {
|
|
1175
|
+
const subs = subscriptionIndex.get(event.sessionId);
|
|
1176
|
+
if (subs) for (const ws of subs) targets.add(ws);
|
|
1177
|
+
}
|
|
1178
|
+
if (event.conversationId) {
|
|
1179
|
+
const subs = subscriptionIndex.get(`conv-${event.conversationId}`);
|
|
1180
|
+
if (subs) for (const ws of subs) targets.add(ws);
|
|
1181
|
+
}
|
|
1182
|
+
for (const ws of targets) sendToClient(ws, data);
|
|
1006
1183
|
}
|
|
1007
1184
|
|
|
1008
1185
|
// Heartbeat interval to detect stale connections
|
|
@@ -1166,18 +1343,38 @@ function onServerReady() {
|
|
|
1166
1343
|
|
|
1167
1344
|
}
|
|
1168
1345
|
|
|
1346
|
+
const importMtimeCache = new Map();
|
|
1347
|
+
|
|
1348
|
+
function hasIndexFilesChanged() {
|
|
1349
|
+
const projectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
1350
|
+
if (!fs.existsSync(projectsDir)) return false;
|
|
1351
|
+
let changed = false;
|
|
1352
|
+
try {
|
|
1353
|
+
const dirs = fs.readdirSync(projectsDir);
|
|
1354
|
+
for (const d of dirs) {
|
|
1355
|
+
const indexPath = path.join(projectsDir, d, 'sessions-index.json');
|
|
1356
|
+
try {
|
|
1357
|
+
const stat = fs.statSync(indexPath);
|
|
1358
|
+
const cached = importMtimeCache.get(indexPath);
|
|
1359
|
+
if (!cached || cached < stat.mtimeMs) {
|
|
1360
|
+
importMtimeCache.set(indexPath, stat.mtimeMs);
|
|
1361
|
+
changed = true;
|
|
1362
|
+
}
|
|
1363
|
+
} catch (_) {}
|
|
1364
|
+
}
|
|
1365
|
+
} catch (_) {}
|
|
1366
|
+
return changed;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1169
1369
|
function performAutoImport() {
|
|
1170
1370
|
try {
|
|
1371
|
+
if (!hasIndexFilesChanged()) return;
|
|
1171
1372
|
const imported = queries.importClaudeCodeConversations();
|
|
1172
1373
|
if (imported.length > 0) {
|
|
1173
1374
|
const importedCount = imported.filter(i => i.status === 'imported').length;
|
|
1174
|
-
const skippedCount = imported.filter(i => i.status === 'skipped').length;
|
|
1175
1375
|
if (importedCount > 0) {
|
|
1176
|
-
console.log(`[AUTO-IMPORT] Imported ${importedCount} new Claude Code conversations
|
|
1177
|
-
// Broadcast to all connected clients that conversations were updated
|
|
1376
|
+
console.log(`[AUTO-IMPORT] Imported ${importedCount} new Claude Code conversations`);
|
|
1178
1377
|
broadcastSync({ type: 'conversations_updated', count: importedCount });
|
|
1179
|
-
} else if (skippedCount > 0) {
|
|
1180
|
-
// All conversations already imported, don't spam logs
|
|
1181
1378
|
}
|
|
1182
1379
|
}
|
|
1183
1380
|
} catch (err) {
|