agentgui 1.0.147 → 1.0.148
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/lib/speech.js +45 -6
- package/package.json +1 -1
- package/server.js +45 -8
- package/static/js/client.js +116 -181
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');
|
|
@@ -327,6 +331,27 @@ const server = http.createServer(async (req, res) => {
|
|
|
327
331
|
return;
|
|
328
332
|
}
|
|
329
333
|
|
|
334
|
+
const fullLoadMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/full$/);
|
|
335
|
+
if (fullLoadMatch && req.method === 'GET') {
|
|
336
|
+
const conversationId = fullLoadMatch[1];
|
|
337
|
+
const conv = queries.getConversation(conversationId);
|
|
338
|
+
if (!conv) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; }
|
|
339
|
+
const latestSession = queries.getLatestSession(conversationId);
|
|
340
|
+
const isActivelyStreaming = activeExecutions.has(conversationId) ||
|
|
341
|
+
(latestSession && latestSession.status === 'active');
|
|
342
|
+
const chunks = queries.getConversationChunks(conversationId);
|
|
343
|
+
const msgResult = queries.getPaginatedMessages(conversationId, 100, 0);
|
|
344
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
345
|
+
res.end(JSON.stringify({
|
|
346
|
+
conversation: conv,
|
|
347
|
+
isActivelyStreaming,
|
|
348
|
+
latestSession,
|
|
349
|
+
chunks,
|
|
350
|
+
messages: msgResult.messages
|
|
351
|
+
}));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
330
355
|
const conversationChunksMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/chunks$/);
|
|
331
356
|
if (conversationChunksMatch && req.method === 'GET') {
|
|
332
357
|
const conversationId = conversationChunksMatch[1];
|
|
@@ -450,13 +475,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
450
475
|
res.end(JSON.stringify({ error: 'No audio data' }));
|
|
451
476
|
return;
|
|
452
477
|
}
|
|
478
|
+
const { transcribe } = await getSpeech();
|
|
453
479
|
const text = await transcribe(audioBuffer);
|
|
454
480
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
455
|
-
res.end(JSON.stringify({ text: text.trim() }));
|
|
481
|
+
res.end(JSON.stringify({ text: (text || '').trim() }));
|
|
456
482
|
} catch (err) {
|
|
457
483
|
debugLog('[STT] Error: ' + err.message);
|
|
458
|
-
res.
|
|
459
|
-
|
|
484
|
+
if (!res.headersSent) {
|
|
485
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
486
|
+
}
|
|
487
|
+
res.end(JSON.stringify({ error: err.message || 'STT failed' }));
|
|
460
488
|
}
|
|
461
489
|
return;
|
|
462
490
|
}
|
|
@@ -470,20 +498,29 @@ const server = http.createServer(async (req, res) => {
|
|
|
470
498
|
res.end(JSON.stringify({ error: 'No text provided' }));
|
|
471
499
|
return;
|
|
472
500
|
}
|
|
501
|
+
const { synthesize } = await getSpeech();
|
|
473
502
|
const wavBuffer = await synthesize(text);
|
|
474
503
|
res.writeHead(200, { 'Content-Type': 'audio/wav', 'Content-Length': wavBuffer.length });
|
|
475
504
|
res.end(wavBuffer);
|
|
476
505
|
} catch (err) {
|
|
477
506
|
debugLog('[TTS] Error: ' + err.message);
|
|
478
|
-
res.
|
|
479
|
-
|
|
507
|
+
if (!res.headersSent) {
|
|
508
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
509
|
+
}
|
|
510
|
+
res.end(JSON.stringify({ error: err.message || 'TTS failed' }));
|
|
480
511
|
}
|
|
481
512
|
return;
|
|
482
513
|
}
|
|
483
514
|
|
|
484
515
|
if (routePath === '/api/speech-status' && req.method === 'GET') {
|
|
485
|
-
|
|
486
|
-
|
|
516
|
+
try {
|
|
517
|
+
const { getStatus } = await getSpeech();
|
|
518
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
519
|
+
res.end(JSON.stringify(getStatus()));
|
|
520
|
+
} catch (err) {
|
|
521
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
522
|
+
res.end(JSON.stringify({ sttReady: false, ttsReady: false, sttLoading: false, ttsLoading: false }));
|
|
523
|
+
}
|
|
487
524
|
return;
|
|
488
525
|
}
|
|
489
526
|
|
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);
|