agentgui 1.0.147 → 1.0.149
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 +4 -4
- package/lib/speech.js +45 -6
- package/package.json +1 -1
- package/server.js +123 -47
- package/static/js/client.js +116 -181
package/database.js
CHANGED
|
@@ -335,14 +335,14 @@ export const queries = {
|
|
|
335
335
|
|
|
336
336
|
getSessionsProcessingLongerThan(minutes) {
|
|
337
337
|
const cutoff = Date.now() - (minutes * 60 * 1000);
|
|
338
|
-
const stmt = db.prepare(
|
|
339
|
-
return stmt.all(
|
|
338
|
+
const stmt = db.prepare("SELECT * FROM sessions WHERE status IN ('active', 'pending') AND started_at < ?");
|
|
339
|
+
return stmt.all(cutoff);
|
|
340
340
|
},
|
|
341
341
|
|
|
342
342
|
cleanupOrphanedSessions(days) {
|
|
343
343
|
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
|
|
344
|
-
const stmt = db.prepare(
|
|
345
|
-
const result = stmt.run(
|
|
344
|
+
const stmt = db.prepare("DELETE FROM sessions WHERE status IN ('active', 'pending') AND started_at < ?");
|
|
345
|
+
const result = stmt.run(cutoff);
|
|
346
346
|
return result.changes || 0;
|
|
347
347
|
},
|
|
348
348
|
|
package/lib/speech.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { pipeline, env } from '@huggingface/transformers';
|
|
2
1
|
import { createRequire } from 'module';
|
|
3
2
|
import fs from 'fs';
|
|
4
3
|
import path from 'path';
|
|
@@ -13,13 +12,21 @@ const SPEAKER_EMBEDDINGS_URL = 'https://huggingface.co/datasets/Xenova/transform
|
|
|
13
12
|
const SPEAKER_EMBEDDINGS_PATH = path.join(DATA_DIR, 'speaker_embeddings.bin');
|
|
14
13
|
const SAMPLE_RATE_TTS = 16000;
|
|
15
14
|
const SAMPLE_RATE_STT = 16000;
|
|
15
|
+
const MIN_WAV_SIZE = 44;
|
|
16
16
|
|
|
17
|
+
let transformersModule = null;
|
|
17
18
|
let sttPipeline = null;
|
|
18
19
|
let ttsPipeline = null;
|
|
19
20
|
let speakerEmbeddings = null;
|
|
20
21
|
let sttLoading = false;
|
|
21
22
|
let ttsLoading = false;
|
|
22
23
|
|
|
24
|
+
async function loadTransformers() {
|
|
25
|
+
if (transformersModule) return transformersModule;
|
|
26
|
+
transformersModule = await import('@huggingface/transformers');
|
|
27
|
+
return transformersModule;
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
function whisperModelPath() {
|
|
24
31
|
try {
|
|
25
32
|
const webtalkDir = path.dirname(require.resolve('webtalk'));
|
|
@@ -46,10 +53,12 @@ async function getSTT() {
|
|
|
46
53
|
if (sttPipeline) return sttPipeline;
|
|
47
54
|
if (sttLoading) {
|
|
48
55
|
while (sttLoading) await new Promise(r => setTimeout(r, 100));
|
|
56
|
+
if (!sttPipeline) throw new Error('STT pipeline failed to load');
|
|
49
57
|
return sttPipeline;
|
|
50
58
|
}
|
|
51
59
|
sttLoading = true;
|
|
52
60
|
try {
|
|
61
|
+
const { pipeline, env } = await loadTransformers();
|
|
53
62
|
const modelPath = whisperModelPath();
|
|
54
63
|
const isLocal = !modelPath.includes('/') || fs.existsSync(modelPath);
|
|
55
64
|
env.allowLocalModels = true;
|
|
@@ -60,6 +69,9 @@ async function getSTT() {
|
|
|
60
69
|
local_files_only: isLocal,
|
|
61
70
|
});
|
|
62
71
|
return sttPipeline;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
sttPipeline = null;
|
|
74
|
+
throw new Error('STT model load failed: ' + err.message);
|
|
63
75
|
} finally {
|
|
64
76
|
sttLoading = false;
|
|
65
77
|
}
|
|
@@ -69,10 +81,12 @@ async function getTTS() {
|
|
|
69
81
|
if (ttsPipeline) return ttsPipeline;
|
|
70
82
|
if (ttsLoading) {
|
|
71
83
|
while (ttsLoading) await new Promise(r => setTimeout(r, 100));
|
|
84
|
+
if (!ttsPipeline) throw new Error('TTS pipeline failed to load');
|
|
72
85
|
return ttsPipeline;
|
|
73
86
|
}
|
|
74
87
|
ttsLoading = true;
|
|
75
88
|
try {
|
|
89
|
+
const { pipeline, env } = await loadTransformers();
|
|
76
90
|
env.allowRemoteModels = true;
|
|
77
91
|
ttsPipeline = await pipeline('text-to-speech', 'Xenova/speecht5_tts', {
|
|
78
92
|
device: 'cpu',
|
|
@@ -80,6 +94,9 @@ async function getTTS() {
|
|
|
80
94
|
});
|
|
81
95
|
await ensureSpeakerEmbeddings();
|
|
82
96
|
return ttsPipeline;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
ttsPipeline = null;
|
|
99
|
+
throw new Error('TTS model load failed: ' + err.message);
|
|
83
100
|
} finally {
|
|
84
101
|
ttsLoading = false;
|
|
85
102
|
}
|
|
@@ -159,12 +176,22 @@ function encodeWav(float32Audio, sampleRate) {
|
|
|
159
176
|
}
|
|
160
177
|
|
|
161
178
|
async function transcribe(audioBuffer) {
|
|
162
|
-
const stt = await getSTT();
|
|
163
|
-
let audio;
|
|
164
179
|
const buf = Buffer.isBuffer(audioBuffer) ? audioBuffer : Buffer.from(audioBuffer);
|
|
180
|
+
if (buf.length < MIN_WAV_SIZE) {
|
|
181
|
+
throw new Error('Audio too short (' + buf.length + ' bytes)');
|
|
182
|
+
}
|
|
183
|
+
let audio;
|
|
165
184
|
const isWav = buf.length > 4 && buf.toString('ascii', 0, 4) === 'RIFF';
|
|
166
185
|
if (isWav) {
|
|
167
|
-
|
|
186
|
+
let decoded;
|
|
187
|
+
try {
|
|
188
|
+
decoded = decodeWavToFloat32(buf);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
throw new Error('WAV decode failed: ' + err.message);
|
|
191
|
+
}
|
|
192
|
+
if (!decoded.audio || decoded.audio.length === 0) {
|
|
193
|
+
throw new Error('WAV contains no audio samples');
|
|
194
|
+
}
|
|
168
195
|
audio = resampleTo16k(decoded.audio, decoded.sampleRate);
|
|
169
196
|
} else {
|
|
170
197
|
const sampleCount = Math.floor(buf.byteLength / 4);
|
|
@@ -173,8 +200,20 @@ async function transcribe(audioBuffer) {
|
|
|
173
200
|
new Uint8Array(aligned).set(buf.subarray(0, sampleCount * 4));
|
|
174
201
|
audio = new Float32Array(aligned);
|
|
175
202
|
}
|
|
176
|
-
|
|
177
|
-
|
|
203
|
+
if (audio.length < 100) {
|
|
204
|
+
throw new Error('Audio too short for transcription');
|
|
205
|
+
}
|
|
206
|
+
const stt = await getSTT();
|
|
207
|
+
let result;
|
|
208
|
+
try {
|
|
209
|
+
result = await stt(audio);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
throw new Error('Transcription engine error: ' + err.message);
|
|
212
|
+
}
|
|
213
|
+
if (!result || typeof result.text !== 'string') {
|
|
214
|
+
return '';
|
|
215
|
+
}
|
|
216
|
+
return result.text;
|
|
178
217
|
}
|
|
179
218
|
|
|
180
219
|
async function synthesize(text) {
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -8,7 +8,11 @@ import { execSync } from 'child_process';
|
|
|
8
8
|
import { createRequire } from 'module';
|
|
9
9
|
import { queries } from './database.js';
|
|
10
10
|
import { runClaudeWithStreaming } from './lib/claude-runner.js';
|
|
11
|
-
|
|
11
|
+
let speechModule = null;
|
|
12
|
+
async function getSpeech() {
|
|
13
|
+
if (!speechModule) speechModule = await import('./lib/speech.js');
|
|
14
|
+
return speechModule;
|
|
15
|
+
}
|
|
12
16
|
|
|
13
17
|
const require = createRequire(import.meta.url);
|
|
14
18
|
const express = require('express');
|
|
@@ -19,6 +23,9 @@ const SYSTEM_PROMPT = `Write all responses as clean semantic HTML. Use tags like
|
|
|
19
23
|
|
|
20
24
|
const activeExecutions = new Map();
|
|
21
25
|
const messageQueues = new Map();
|
|
26
|
+
const STUCK_AGENT_THRESHOLD_MS = 600000;
|
|
27
|
+
const NO_PID_GRACE_PERIOD_MS = 60000;
|
|
28
|
+
const STALE_SESSION_MIN_AGE_MS = 30000;
|
|
22
29
|
|
|
23
30
|
const debugLog = (msg) => {
|
|
24
31
|
const timestamp = new Date().toISOString();
|
|
@@ -327,6 +334,27 @@ const server = http.createServer(async (req, res) => {
|
|
|
327
334
|
return;
|
|
328
335
|
}
|
|
329
336
|
|
|
337
|
+
const fullLoadMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/full$/);
|
|
338
|
+
if (fullLoadMatch && req.method === 'GET') {
|
|
339
|
+
const conversationId = fullLoadMatch[1];
|
|
340
|
+
const conv = queries.getConversation(conversationId);
|
|
341
|
+
if (!conv) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; }
|
|
342
|
+
const latestSession = queries.getLatestSession(conversationId);
|
|
343
|
+
const isActivelyStreaming = activeExecutions.has(conversationId) ||
|
|
344
|
+
(latestSession && latestSession.status === 'active');
|
|
345
|
+
const chunks = queries.getConversationChunks(conversationId);
|
|
346
|
+
const msgResult = queries.getPaginatedMessages(conversationId, 100, 0);
|
|
347
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
348
|
+
res.end(JSON.stringify({
|
|
349
|
+
conversation: conv,
|
|
350
|
+
isActivelyStreaming,
|
|
351
|
+
latestSession,
|
|
352
|
+
chunks,
|
|
353
|
+
messages: msgResult.messages
|
|
354
|
+
}));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
330
358
|
const conversationChunksMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/chunks$/);
|
|
331
359
|
if (conversationChunksMatch && req.method === 'GET') {
|
|
332
360
|
const conversationId = conversationChunksMatch[1];
|
|
@@ -450,13 +478,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
450
478
|
res.end(JSON.stringify({ error: 'No audio data' }));
|
|
451
479
|
return;
|
|
452
480
|
}
|
|
481
|
+
const { transcribe } = await getSpeech();
|
|
453
482
|
const text = await transcribe(audioBuffer);
|
|
454
483
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
455
|
-
res.end(JSON.stringify({ text: text.trim() }));
|
|
484
|
+
res.end(JSON.stringify({ text: (text || '').trim() }));
|
|
456
485
|
} catch (err) {
|
|
457
486
|
debugLog('[STT] Error: ' + err.message);
|
|
458
|
-
res.
|
|
459
|
-
|
|
487
|
+
if (!res.headersSent) {
|
|
488
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
489
|
+
}
|
|
490
|
+
res.end(JSON.stringify({ error: err.message || 'STT failed' }));
|
|
460
491
|
}
|
|
461
492
|
return;
|
|
462
493
|
}
|
|
@@ -470,20 +501,29 @@ const server = http.createServer(async (req, res) => {
|
|
|
470
501
|
res.end(JSON.stringify({ error: 'No text provided' }));
|
|
471
502
|
return;
|
|
472
503
|
}
|
|
504
|
+
const { synthesize } = await getSpeech();
|
|
473
505
|
const wavBuffer = await synthesize(text);
|
|
474
506
|
res.writeHead(200, { 'Content-Type': 'audio/wav', 'Content-Length': wavBuffer.length });
|
|
475
507
|
res.end(wavBuffer);
|
|
476
508
|
} catch (err) {
|
|
477
509
|
debugLog('[TTS] Error: ' + err.message);
|
|
478
|
-
res.
|
|
479
|
-
|
|
510
|
+
if (!res.headersSent) {
|
|
511
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
512
|
+
}
|
|
513
|
+
res.end(JSON.stringify({ error: err.message || 'TTS failed' }));
|
|
480
514
|
}
|
|
481
515
|
return;
|
|
482
516
|
}
|
|
483
517
|
|
|
484
518
|
if (routePath === '/api/speech-status' && req.method === 'GET') {
|
|
485
|
-
|
|
486
|
-
|
|
519
|
+
try {
|
|
520
|
+
const { getStatus } = await getSpeech();
|
|
521
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
522
|
+
res.end(JSON.stringify(getStatus()));
|
|
523
|
+
} catch (err) {
|
|
524
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
525
|
+
res.end(JSON.stringify({ sttReady: false, ttsReady: false, sttLoading: false, ttsLoading: false }));
|
|
526
|
+
}
|
|
487
527
|
return;
|
|
488
528
|
}
|
|
489
529
|
|
|
@@ -611,7 +651,7 @@ function persistChunkWithRetry(sessionId, conversationId, sequence, blockType, b
|
|
|
611
651
|
|
|
612
652
|
async function processMessageWithStreaming(conversationId, messageId, sessionId, content, agentId) {
|
|
613
653
|
const startTime = Date.now();
|
|
614
|
-
activeExecutions.set(conversationId, { pid: null, startTime, sessionId });
|
|
654
|
+
activeExecutions.set(conversationId, { pid: null, startTime, sessionId, lastActivity: startTime });
|
|
615
655
|
queries.setIsStreaming(conversationId, true);
|
|
616
656
|
queries.updateSession(sessionId, { status: 'active' });
|
|
617
657
|
|
|
@@ -628,6 +668,8 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
|
|
|
628
668
|
|
|
629
669
|
const onEvent = (parsed) => {
|
|
630
670
|
eventCount++;
|
|
671
|
+
const entry = activeExecutions.get(conversationId);
|
|
672
|
+
if (entry) entry.lastActivity = Date.now();
|
|
631
673
|
debugLog(`[stream] Event ${eventCount}: type=${parsed.type}`);
|
|
632
674
|
|
|
633
675
|
if (parsed.type === 'system') {
|
|
@@ -1008,25 +1050,27 @@ server.on('error', (err) => {
|
|
|
1008
1050
|
function recoverStaleSessions() {
|
|
1009
1051
|
try {
|
|
1010
1052
|
const staleSessions = queries.getActiveSessions ? queries.getActiveSessions() : [];
|
|
1053
|
+
const now = Date.now();
|
|
1011
1054
|
let recoveredCount = 0;
|
|
1012
1055
|
for (const session of staleSessions) {
|
|
1013
|
-
if (
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
}
|
|
1056
|
+
if (activeExecutions.has(session.conversationId)) continue;
|
|
1057
|
+
const sessionAge = now - session.started_at;
|
|
1058
|
+
if (sessionAge < STALE_SESSION_MIN_AGE_MS) continue;
|
|
1059
|
+
queries.updateSession(session.id, {
|
|
1060
|
+
status: 'error',
|
|
1061
|
+
error: 'Agent died unexpectedly (server restart)',
|
|
1062
|
+
completed_at: now
|
|
1063
|
+
});
|
|
1064
|
+
queries.setIsStreaming(session.conversationId, false);
|
|
1065
|
+
broadcastSync({
|
|
1066
|
+
type: 'streaming_error',
|
|
1067
|
+
sessionId: session.id,
|
|
1068
|
+
conversationId: session.conversationId,
|
|
1069
|
+
error: 'Agent died unexpectedly (server restart)',
|
|
1070
|
+
recoverable: false,
|
|
1071
|
+
timestamp: now
|
|
1072
|
+
});
|
|
1073
|
+
recoveredCount++;
|
|
1030
1074
|
}
|
|
1031
1075
|
if (recoveredCount > 0) {
|
|
1032
1076
|
console.log(`[RECOVERY] Recovered ${recoveredCount} stale active session(s)`);
|
|
@@ -1036,31 +1080,63 @@ function recoverStaleSessions() {
|
|
|
1036
1080
|
}
|
|
1037
1081
|
}
|
|
1038
1082
|
|
|
1083
|
+
function isProcessAlive(pid) {
|
|
1084
|
+
try {
|
|
1085
|
+
process.kill(pid, 0);
|
|
1086
|
+
return true;
|
|
1087
|
+
} catch (err) {
|
|
1088
|
+
if (err.code === 'EPERM') return true;
|
|
1089
|
+
return false;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function markAgentDead(conversationId, entry, reason) {
|
|
1094
|
+
if (!activeExecutions.has(conversationId)) return;
|
|
1095
|
+
activeExecutions.delete(conversationId);
|
|
1096
|
+
queries.setIsStreaming(conversationId, false);
|
|
1097
|
+
if (entry.sessionId) {
|
|
1098
|
+
queries.updateSession(entry.sessionId, {
|
|
1099
|
+
status: 'error',
|
|
1100
|
+
error: reason,
|
|
1101
|
+
completed_at: Date.now()
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
broadcastSync({
|
|
1105
|
+
type: 'streaming_error',
|
|
1106
|
+
sessionId: entry.sessionId,
|
|
1107
|
+
conversationId,
|
|
1108
|
+
error: reason,
|
|
1109
|
+
recoverable: false,
|
|
1110
|
+
timestamp: Date.now()
|
|
1111
|
+
});
|
|
1112
|
+
drainMessageQueue(conversationId);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1039
1115
|
function performAgentHealthCheck() {
|
|
1116
|
+
const now = Date.now();
|
|
1040
1117
|
for (const [conversationId, entry] of activeExecutions) {
|
|
1041
|
-
if (!entry
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1118
|
+
if (!entry) continue;
|
|
1119
|
+
|
|
1120
|
+
if (entry.pid) {
|
|
1121
|
+
if (!isProcessAlive(entry.pid)) {
|
|
1122
|
+
debugLog(`[HEALTH] Agent PID ${entry.pid} for conv ${conversationId} is dead`);
|
|
1123
|
+
markAgentDead(conversationId, entry, 'Agent process died unexpectedly');
|
|
1124
|
+
} else if (now - entry.lastActivity > STUCK_AGENT_THRESHOLD_MS) {
|
|
1125
|
+
debugLog(`[HEALTH] Agent PID ${entry.pid} for conv ${conversationId} has no activity for ${Math.round((now - entry.lastActivity) / 1000)}s`);
|
|
1126
|
+
broadcastSync({
|
|
1127
|
+
type: 'streaming_error',
|
|
1128
|
+
sessionId: entry.sessionId,
|
|
1129
|
+
conversationId,
|
|
1130
|
+
error: 'Agent may be stuck (no activity for 10 minutes)',
|
|
1131
|
+
recoverable: true,
|
|
1132
|
+
timestamp: now
|
|
1053
1133
|
});
|
|
1054
1134
|
}
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
conversationId,
|
|
1059
|
-
|
|
1060
|
-
recoverable: false,
|
|
1061
|
-
timestamp: Date.now()
|
|
1062
|
-
});
|
|
1063
|
-
drainMessageQueue(conversationId);
|
|
1135
|
+
} else {
|
|
1136
|
+
if (now - entry.startTime > NO_PID_GRACE_PERIOD_MS) {
|
|
1137
|
+
debugLog(`[HEALTH] Agent for conv ${conversationId} never reported PID after ${Math.round((now - entry.startTime) / 1000)}s`);
|
|
1138
|
+
markAgentDead(conversationId, entry, 'Agent failed to start (no PID reported)');
|
|
1139
|
+
}
|
|
1064
1140
|
}
|
|
1065
1141
|
}
|
|
1066
1142
|
}
|
package/static/js/client.js
CHANGED
|
@@ -420,7 +420,6 @@ class AgentGUIClient {
|
|
|
420
420
|
if (outputEl) {
|
|
421
421
|
let messagesEl = outputEl.querySelector('.conversation-messages');
|
|
422
422
|
if (!messagesEl) {
|
|
423
|
-
// Load existing conversation history before starting the stream
|
|
424
423
|
const conv = this.state.currentConversation;
|
|
425
424
|
const wdInfo = conv?.workingDirectory ? ` - ${this.escapeHtml(conv.workingDirectory)}` : '';
|
|
426
425
|
outputEl.innerHTML = `
|
|
@@ -431,14 +430,16 @@ class AgentGUIClient {
|
|
|
431
430
|
<div class="conversation-messages"></div>
|
|
432
431
|
`;
|
|
433
432
|
messagesEl = outputEl.querySelector('.conversation-messages');
|
|
434
|
-
// Load prior messages into the container
|
|
435
433
|
try {
|
|
436
|
-
const
|
|
437
|
-
if (
|
|
438
|
-
const
|
|
439
|
-
const priorChunks =
|
|
434
|
+
const fullResp = await fetch(window.__BASE_URL + `/api/conversations/${data.conversationId}/full`);
|
|
435
|
+
if (fullResp.ok) {
|
|
436
|
+
const fullData = await fullResp.json();
|
|
437
|
+
const priorChunks = (fullData.chunks || []).map(c => ({
|
|
438
|
+
...c,
|
|
439
|
+
block: typeof c.data === 'string' ? JSON.parse(c.data) : c.data
|
|
440
|
+
}));
|
|
441
|
+
const userMsgs = (fullData.messages || []).filter(m => m.role === 'user');
|
|
440
442
|
if (priorChunks.length > 0) {
|
|
441
|
-
const userMsgs = (msgData.messages || []).filter(m => m.role === 'user');
|
|
442
443
|
const sessionOrder = [];
|
|
443
444
|
const sessionGroups = {};
|
|
444
445
|
priorChunks.forEach(c => {
|
|
@@ -468,7 +469,7 @@ class AgentGUIClient {
|
|
|
468
469
|
messagesEl.insertAdjacentHTML('beforeend', `<div class="message message-user" data-msg-id="${m.id}"><div class="message-role">User</div>${this.renderMessageContent(m.content)}<div class="message-timestamp">${new Date(m.created_at).toLocaleString()}</div></div>`);
|
|
469
470
|
}
|
|
470
471
|
} else {
|
|
471
|
-
messagesEl.innerHTML = this.renderMessages(
|
|
472
|
+
messagesEl.innerHTML = this.renderMessages(fullData.messages || []);
|
|
472
473
|
}
|
|
473
474
|
}
|
|
474
475
|
} catch (e) {
|
|
@@ -1174,135 +1175,64 @@ class AgentGUIClient {
|
|
|
1174
1175
|
|
|
1175
1176
|
async loadConversationMessages(conversationId) {
|
|
1176
1177
|
try {
|
|
1177
|
-
// Save scroll position of current conversation before switching
|
|
1178
1178
|
if (this.state.currentConversation?.id) {
|
|
1179
1179
|
this.saveScrollPosition(this.state.currentConversation.id);
|
|
1180
1180
|
}
|
|
1181
|
-
|
|
1182
|
-
// Stop any existing polling when switching conversations
|
|
1183
1181
|
this.stopChunkPolling();
|
|
1184
|
-
|
|
1185
|
-
// Clear streaming state from previous conversation view
|
|
1186
|
-
// (the actual streaming continues on the server, we just stop tracking it on the UI side)
|
|
1187
1182
|
if (this.state.isStreaming && this.state.currentConversation?.id !== conversationId) {
|
|
1188
1183
|
this.state.isStreaming = false;
|
|
1189
1184
|
this.state.currentSession = null;
|
|
1190
1185
|
}
|
|
1191
1186
|
|
|
1192
|
-
const convResponse = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}`);
|
|
1193
|
-
const { conversation, isActivelyStreaming, latestSession } = await convResponse.json();
|
|
1194
|
-
this.state.currentConversation = conversation;
|
|
1195
|
-
|
|
1196
|
-
// Update URL with conversation ID
|
|
1197
1187
|
this.updateUrlForConversation(conversationId);
|
|
1198
|
-
|
|
1199
1188
|
if (this.wsManager.isConnected) {
|
|
1200
1189
|
this.wsManager.sendMessage({ type: 'subscribe', conversationId });
|
|
1201
1190
|
}
|
|
1202
1191
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
const shouldResumeStreaming = isActivelyStreaming && latestSession &&
|
|
1207
|
-
(latestSession.status === 'active' || latestSession.status === 'pending');
|
|
1192
|
+
const resp = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}/full`);
|
|
1193
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
1194
|
+
const { conversation, isActivelyStreaming, latestSession, chunks: rawChunks, messages: allMessages } = await resp.json();
|
|
1208
1195
|
|
|
1209
|
-
|
|
1210
|
-
try {
|
|
1211
|
-
const chunks = await this.fetchChunks(conversationId, 0);
|
|
1212
|
-
|
|
1213
|
-
const outputEl = document.getElementById('output');
|
|
1214
|
-
if (outputEl) {
|
|
1215
|
-
const wdInfo = conversation.workingDirectory ? ` - ${this.escapeHtml(conversation.workingDirectory)}` : '';
|
|
1216
|
-
outputEl.innerHTML = `
|
|
1217
|
-
<div class="conversation-header">
|
|
1218
|
-
<h2>${this.escapeHtml(conversation.title || 'Conversation')}</h2>
|
|
1219
|
-
<p class="text-secondary">${conversation.agentType || 'unknown'} - ${new Date(conversation.created_at).toLocaleDateString()}${wdInfo}</p>
|
|
1220
|
-
</div>
|
|
1221
|
-
<div class="conversation-messages"></div>
|
|
1222
|
-
`;
|
|
1223
|
-
|
|
1224
|
-
// Render all chunks
|
|
1225
|
-
const messagesEl = outputEl.querySelector('.conversation-messages');
|
|
1226
|
-
if (chunks.length > 0) {
|
|
1227
|
-
// Fetch user messages to interleave with session chunks
|
|
1228
|
-
let userMessages = [];
|
|
1229
|
-
try {
|
|
1230
|
-
const msgResp = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}/messages`);
|
|
1231
|
-
if (msgResp.ok) {
|
|
1232
|
-
const msgData = await msgResp.json();
|
|
1233
|
-
userMessages = (msgData.messages || []).filter(m => m.role === 'user');
|
|
1234
|
-
}
|
|
1235
|
-
} catch (_) {}
|
|
1236
|
-
|
|
1237
|
-
// Group chunks by session, preserving order
|
|
1238
|
-
const sessionOrder = [];
|
|
1239
|
-
const sessionChunks = {};
|
|
1240
|
-
chunks.forEach(chunk => {
|
|
1241
|
-
if (!sessionChunks[chunk.sessionId]) {
|
|
1242
|
-
sessionChunks[chunk.sessionId] = [];
|
|
1243
|
-
sessionOrder.push(chunk.sessionId);
|
|
1244
|
-
}
|
|
1245
|
-
sessionChunks[chunk.sessionId].push(chunk);
|
|
1246
|
-
});
|
|
1196
|
+
this.state.currentConversation = conversation;
|
|
1247
1197
|
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
// Render user messages that came before this session
|
|
1255
|
-
while (userMsgIdx < userMessages.length && userMessages[userMsgIdx].created_at <= sessionStart) {
|
|
1256
|
-
const msg = userMessages[userMsgIdx];
|
|
1257
|
-
const userDiv = document.createElement('div');
|
|
1258
|
-
userDiv.className = 'message message-user';
|
|
1259
|
-
userDiv.setAttribute('data-msg-id', msg.id);
|
|
1260
|
-
userDiv.innerHTML = `
|
|
1261
|
-
<div class="message-role">User</div>
|
|
1262
|
-
${this.renderMessageContent(msg.content)}
|
|
1263
|
-
<div class="message-timestamp">${new Date(msg.created_at).toLocaleString()}</div>
|
|
1264
|
-
`;
|
|
1265
|
-
messagesEl.appendChild(userDiv);
|
|
1266
|
-
userMsgIdx++;
|
|
1267
|
-
}
|
|
1198
|
+
const chunks = (rawChunks || []).map(chunk => ({
|
|
1199
|
+
...chunk,
|
|
1200
|
+
block: typeof chunk.data === 'string' ? JSON.parse(chunk.data) : chunk.data
|
|
1201
|
+
}));
|
|
1202
|
+
const userMessages = (allMessages || []).filter(m => m.role === 'user');
|
|
1268
1203
|
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
messageDiv.className = `message message-assistant${isCurrentActiveSession ? ' streaming-message' : ''}`;
|
|
1272
|
-
messageDiv.id = isCurrentActiveSession ? `streaming-${sessionId}` : `message-${sessionId}`;
|
|
1273
|
-
messageDiv.innerHTML = '<div class="message-role">Assistant</div><div class="message-blocks streaming-blocks"></div>';
|
|
1274
|
-
|
|
1275
|
-
const blocksEl = messageDiv.querySelector('.message-blocks');
|
|
1276
|
-
sessionChunkList.forEach(chunk => {
|
|
1277
|
-
if (chunk.block && chunk.block.type) {
|
|
1278
|
-
const element = this.renderer.renderBlock(chunk.block, chunk);
|
|
1279
|
-
if (element) {
|
|
1280
|
-
blocksEl.appendChild(element);
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
});
|
|
1204
|
+
const shouldResumeStreaming = isActivelyStreaming && latestSession &&
|
|
1205
|
+
(latestSession.status === 'active' || latestSession.status === 'pending');
|
|
1284
1206
|
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
const ts = document.createElement('div');
|
|
1296
|
-
ts.className = 'message-timestamp';
|
|
1297
|
-
ts.textContent = new Date(sessionChunkList[sessionChunkList.length - 1].created_at).toLocaleString();
|
|
1298
|
-
messageDiv.appendChild(ts);
|
|
1299
|
-
}
|
|
1207
|
+
const outputEl = document.getElementById('output');
|
|
1208
|
+
if (outputEl) {
|
|
1209
|
+
const wdInfo = conversation.workingDirectory ? ` - ${this.escapeHtml(conversation.workingDirectory)}` : '';
|
|
1210
|
+
outputEl.innerHTML = `
|
|
1211
|
+
<div class="conversation-header">
|
|
1212
|
+
<h2>${this.escapeHtml(conversation.title || 'Conversation')}</h2>
|
|
1213
|
+
<p class="text-secondary">${conversation.agentType || 'unknown'} - ${new Date(conversation.created_at).toLocaleDateString()}${wdInfo}</p>
|
|
1214
|
+
</div>
|
|
1215
|
+
<div class="conversation-messages"></div>
|
|
1216
|
+
`;
|
|
1300
1217
|
|
|
1301
|
-
|
|
1302
|
-
|
|
1218
|
+
const messagesEl = outputEl.querySelector('.conversation-messages');
|
|
1219
|
+
if (chunks.length > 0) {
|
|
1220
|
+
const sessionOrder = [];
|
|
1221
|
+
const sessionChunks = {};
|
|
1222
|
+
chunks.forEach(chunk => {
|
|
1223
|
+
if (!sessionChunks[chunk.sessionId]) {
|
|
1224
|
+
sessionChunks[chunk.sessionId] = [];
|
|
1225
|
+
sessionOrder.push(chunk.sessionId);
|
|
1226
|
+
}
|
|
1227
|
+
sessionChunks[chunk.sessionId].push(chunk);
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
let userMsgIdx = 0;
|
|
1231
|
+
sessionOrder.forEach((sessionId) => {
|
|
1232
|
+
const sessionChunkList = sessionChunks[sessionId];
|
|
1233
|
+
const sessionStart = sessionChunkList[0].created_at;
|
|
1303
1234
|
|
|
1304
|
-
|
|
1305
|
-
while (userMsgIdx < userMessages.length) {
|
|
1235
|
+
while (userMsgIdx < userMessages.length && userMessages[userMsgIdx].created_at <= sessionStart) {
|
|
1306
1236
|
const msg = userMessages[userMsgIdx];
|
|
1307
1237
|
const userDiv = document.createElement('div');
|
|
1308
1238
|
userDiv.className = 'message message-user';
|
|
@@ -1315,78 +1245,83 @@ class AgentGUIClient {
|
|
|
1315
1245
|
messagesEl.appendChild(userDiv);
|
|
1316
1246
|
userMsgIdx++;
|
|
1317
1247
|
}
|
|
1318
|
-
} else {
|
|
1319
|
-
// Fall back to messages if no chunks
|
|
1320
|
-
const messagesResponse = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}/messages`);
|
|
1321
|
-
if (messagesResponse.ok) {
|
|
1322
|
-
const messagesData = await messagesResponse.json();
|
|
1323
|
-
messagesEl.innerHTML = this.renderMessages(messagesData.messages || []);
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
1248
|
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
// Subscribe to WebSocket updates for BOTH conversation and session
|
|
1341
|
-
if (this.wsManager.isConnected) {
|
|
1342
|
-
this.wsManager.subscribeToSession(latestSession.id);
|
|
1343
|
-
this.wsManager.sendMessage({ type: 'subscribe', conversationId });
|
|
1344
|
-
}
|
|
1249
|
+
const isCurrentActiveSession = shouldResumeStreaming && latestSession && latestSession.id === sessionId;
|
|
1250
|
+
const messageDiv = document.createElement('div');
|
|
1251
|
+
messageDiv.className = `message message-assistant${isCurrentActiveSession ? ' streaming-message' : ''}`;
|
|
1252
|
+
messageDiv.id = isCurrentActiveSession ? `streaming-${sessionId}` : `message-${sessionId}`;
|
|
1253
|
+
messageDiv.innerHTML = '<div class="message-role">Assistant</div><div class="message-blocks streaming-blocks"></div>';
|
|
1254
|
+
|
|
1255
|
+
const blocksEl = messageDiv.querySelector('.message-blocks');
|
|
1256
|
+
sessionChunkList.forEach(chunk => {
|
|
1257
|
+
if (chunk.block && chunk.block.type) {
|
|
1258
|
+
const element = this.renderer.renderBlock(chunk.block, chunk);
|
|
1259
|
+
if (element) blocksEl.appendChild(element);
|
|
1260
|
+
}
|
|
1261
|
+
});
|
|
1345
1262
|
|
|
1346
|
-
|
|
1347
|
-
|
|
1263
|
+
if (isCurrentActiveSession) {
|
|
1264
|
+
const indicatorDiv = document.createElement('div');
|
|
1265
|
+
indicatorDiv.className = 'streaming-indicator';
|
|
1266
|
+
indicatorDiv.style = 'display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0;color:var(--color-text-secondary);font-size:0.875rem;';
|
|
1267
|
+
indicatorDiv.innerHTML = `
|
|
1268
|
+
<span class="animate-spin" style="display:inline-block;width:1rem;height:1rem;border:2px solid var(--color-border);border-top-color:var(--color-primary);border-radius:50%;"></span>
|
|
1269
|
+
<span class="streaming-indicator-label">Processing...</span>
|
|
1270
|
+
`;
|
|
1271
|
+
messageDiv.appendChild(indicatorDiv);
|
|
1272
|
+
} else {
|
|
1273
|
+
const ts = document.createElement('div');
|
|
1274
|
+
ts.className = 'message-timestamp';
|
|
1275
|
+
ts.textContent = new Date(sessionChunkList[sessionChunkList.length - 1].created_at).toLocaleString();
|
|
1276
|
+
messageDiv.appendChild(ts);
|
|
1277
|
+
}
|
|
1348
1278
|
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
const lastChunkTime = chunks.length > 0
|
|
1352
|
-
? chunks[chunks.length - 1].created_at
|
|
1353
|
-
: 0;
|
|
1279
|
+
messagesEl.appendChild(messageDiv);
|
|
1280
|
+
});
|
|
1354
1281
|
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1282
|
+
while (userMsgIdx < userMessages.length) {
|
|
1283
|
+
const msg = userMessages[userMsgIdx];
|
|
1284
|
+
const userDiv = document.createElement('div');
|
|
1285
|
+
userDiv.className = 'message message-user';
|
|
1286
|
+
userDiv.setAttribute('data-msg-id', msg.id);
|
|
1287
|
+
userDiv.innerHTML = `
|
|
1288
|
+
<div class="message-role">User</div>
|
|
1289
|
+
${this.renderMessageContent(msg.content)}
|
|
1290
|
+
<div class="message-timestamp">${new Date(msg.created_at).toLocaleString()}</div>
|
|
1291
|
+
`;
|
|
1292
|
+
messagesEl.appendChild(userDiv);
|
|
1293
|
+
userMsgIdx++;
|
|
1294
|
+
}
|
|
1295
|
+
} else {
|
|
1296
|
+
messagesEl.innerHTML = this.renderMessages(allMessages || []);
|
|
1297
|
+
}
|
|
1358
1298
|
|
|
1359
|
-
|
|
1360
|
-
|
|
1299
|
+
if (shouldResumeStreaming && latestSession) {
|
|
1300
|
+
this.state.isStreaming = true;
|
|
1301
|
+
this.state.currentSession = {
|
|
1302
|
+
id: latestSession.id,
|
|
1303
|
+
conversationId: conversationId,
|
|
1304
|
+
agentId: conversation.agentType || 'claude-code',
|
|
1305
|
+
startTime: latestSession.created_at
|
|
1306
|
+
};
|
|
1307
|
+
|
|
1308
|
+
if (this.wsManager.isConnected) {
|
|
1309
|
+
this.wsManager.subscribeToSession(latestSession.id);
|
|
1310
|
+
this.wsManager.sendMessage({ type: 'subscribe', conversationId });
|
|
1361
1311
|
}
|
|
1362
1312
|
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
const messagesData = await messagesResponse.json();
|
|
1373
|
-
|
|
1374
|
-
const outputEl = document.getElementById('output');
|
|
1375
|
-
if (outputEl) {
|
|
1376
|
-
const wdInfo = conversation.workingDirectory ? ` - ${this.escapeHtml(conversation.workingDirectory)}` : '';
|
|
1377
|
-
outputEl.innerHTML = `
|
|
1378
|
-
<div class="conversation-header">
|
|
1379
|
-
<h2>${this.escapeHtml(conversation.title || 'Conversation')}</h2>
|
|
1380
|
-
<p class="text-secondary">${conversation.agentType || 'unknown'} - ${new Date(conversation.created_at).toLocaleDateString()}${wdInfo}</p>
|
|
1381
|
-
</div>
|
|
1382
|
-
<div class="conversation-messages">
|
|
1383
|
-
${this.renderMessages(messagesData.messages || [])}
|
|
1384
|
-
</div>
|
|
1385
|
-
`;
|
|
1386
|
-
|
|
1387
|
-
// Restore scroll position after rendering
|
|
1388
|
-
this.restoreScrollPosition(conversationId);
|
|
1313
|
+
this.updateUrlForConversation(conversationId, latestSession.id);
|
|
1314
|
+
|
|
1315
|
+
const lastChunkTime = chunks.length > 0
|
|
1316
|
+
? chunks[chunks.length - 1].created_at
|
|
1317
|
+
: 0;
|
|
1318
|
+
|
|
1319
|
+
this.chunkPollState.lastFetchTimestamp = lastChunkTime;
|
|
1320
|
+
this.startChunkPolling(conversationId);
|
|
1321
|
+
this.disableControls();
|
|
1389
1322
|
}
|
|
1323
|
+
|
|
1324
|
+
this.restoreScrollPosition(conversationId);
|
|
1390
1325
|
}
|
|
1391
1326
|
} catch (error) {
|
|
1392
1327
|
console.error('Failed to load conversation messages:', error);
|