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 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('SELECT * FROM sessions WHERE status = ? AND started_at < ?');
339
- return stmt.all('pending', cutoff);
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('DELETE FROM sessions WHERE status = ? AND started_at < ?');
345
- const result = stmt.run('pending', cutoff);
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
- const decoded = decodeWavToFloat32(buf);
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
- const result = await stt(audio);
177
- return result.text || '';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.147",
3
+ "version": "1.0.149",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
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
- import { transcribe, synthesize, getStatus as getSpeechStatus } from './lib/speech.js';
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.writeHead(500, { 'Content-Type': 'application/json' });
459
- res.end(JSON.stringify({ 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' }));
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.writeHead(500, { 'Content-Type': 'application/json' });
479
- res.end(JSON.stringify({ 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' }));
480
514
  }
481
515
  return;
482
516
  }
483
517
 
484
518
  if (routePath === '/api/speech-status' && req.method === 'GET') {
485
- res.writeHead(200, { 'Content-Type': 'application/json' });
486
- res.end(JSON.stringify(getSpeechStatus()));
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 (!activeExecutions.has(session.conversationId)) {
1014
- queries.updateSession(session.id, {
1015
- status: 'error',
1016
- error: 'Agent died unexpectedly (server restart)',
1017
- completed_at: Date.now()
1018
- });
1019
- queries.setIsStreaming(session.conversationId, false);
1020
- broadcastSync({
1021
- type: 'streaming_error',
1022
- sessionId: session.id,
1023
- conversationId: session.conversationId,
1024
- error: 'Agent died unexpectedly (server restart)',
1025
- recoverable: false,
1026
- timestamp: Date.now()
1027
- });
1028
- recoveredCount++;
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 || !entry.pid) continue;
1042
- try {
1043
- process.kill(entry.pid, 0);
1044
- } catch (err) {
1045
- debugLog(`[HEALTH] Agent PID ${entry.pid} for conv ${conversationId} is dead`);
1046
- activeExecutions.delete(conversationId);
1047
- queries.setIsStreaming(conversationId, false);
1048
- if (entry.sessionId) {
1049
- queries.updateSession(entry.sessionId, {
1050
- status: 'error',
1051
- error: 'Agent process died unexpectedly',
1052
- completed_at: Date.now()
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
- broadcastSync({
1056
- type: 'streaming_error',
1057
- sessionId: entry.sessionId,
1058
- conversationId,
1059
- error: 'Agent process died unexpectedly',
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
  }
@@ -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 msgResp = await fetch(window.__BASE_URL + `/api/conversations/${data.conversationId}/messages`);
437
- if (msgResp.ok) {
438
- const msgData = await msgResp.json();
439
- const priorChunks = await this.fetchChunks(data.conversationId, 0);
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(msgData.messages || []);
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
- // Check if there's an active streaming session that needs to be resumed
1204
- // isActivelyStreaming comes from the server checking both in-memory activeExecutions map
1205
- // AND database session status. Use it as primary signal, with session status as confirmation.
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
- // Try to fetch chunks first (Wave 3 architecture)
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
- // Build a timeline: match user messages to sessions by timestamp
1249
- let userMsgIdx = 0;
1250
- sessionOrder.forEach((sessionId) => {
1251
- const sessionChunkList = sessionChunks[sessionId];
1252
- const sessionStart = sessionChunkList[0].created_at;
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
- const isCurrentActiveSession = shouldResumeStreaming && latestSession && latestSession.id === sessionId;
1270
- const messageDiv = document.createElement('div');
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
- if (isCurrentActiveSession) {
1286
- const indicatorDiv = document.createElement('div');
1287
- indicatorDiv.className = 'streaming-indicator';
1288
- indicatorDiv.style = 'display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0;color:var(--color-text-secondary);font-size:0.875rem;';
1289
- indicatorDiv.innerHTML = `
1290
- <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>
1291
- <span class="streaming-indicator-label">Processing...</span>
1292
- `;
1293
- messageDiv.appendChild(indicatorDiv);
1294
- } else {
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
- messagesEl.appendChild(messageDiv);
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
- // Render any remaining user messages after the last session
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
- // Resume streaming if needed
1328
- if (shouldResumeStreaming && latestSession) {
1329
- console.log('Resuming live streaming for session:', latestSession.id);
1330
-
1331
- // Set streaming state
1332
- this.state.isStreaming = true;
1333
- this.state.currentSession = {
1334
- id: latestSession.id,
1335
- conversationId: conversationId,
1336
- agentId: conversation.agentType || 'claude-code',
1337
- startTime: latestSession.created_at
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
- // Update URL with session ID
1347
- this.updateUrlForConversation(conversationId, latestSession.id);
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
- // Get the timestamp of the last chunk to start polling from
1350
- // Use the last chunk's created_at to avoid re-fetching already-rendered chunks
1351
- const lastChunkTime = chunks.length > 0
1352
- ? chunks[chunks.length - 1].created_at
1353
- : 0;
1279
+ messagesEl.appendChild(messageDiv);
1280
+ });
1354
1281
 
1355
- // Start polling for new chunks from where we left off
1356
- this.chunkPollState.lastFetchTimestamp = lastChunkTime;
1357
- this.startChunkPolling(conversationId);
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
- // Disable controls while streaming
1360
- this.disableControls();
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
- // Restore scroll position after rendering
1364
- this.restoreScrollPosition(conversationId);
1365
- }
1366
- } catch (chunkError) {
1367
- console.warn('Failed to fetch chunks, falling back to messages:', chunkError);
1368
-
1369
- // Fallback: use messages
1370
- const messagesResponse = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}/messages`);
1371
- if (!messagesResponse.ok) throw new Error(`Failed to fetch messages: ${messagesResponse.status}`);
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);