agentgui 1.0.673 → 1.0.675
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/tool-manager.js +71 -0
- package/lib/ws-handlers-conv.js +41 -5
- package/lib/ws-handlers-run.js +26 -0
- package/package.json +1 -1
- package/server.js +94 -7
- package/static/js/client.js +35 -23
- package/static/js/conversations.js +4 -1
- package/static/js/features.js +1 -0
- package/static/js/streaming-renderer.js +25 -8
- package/static/js/websocket-manager.js +21 -0
package/lib/tool-manager.js
CHANGED
|
@@ -546,3 +546,74 @@ export async function autoProvision(broadcast) {
|
|
|
546
546
|
}
|
|
547
547
|
log('Provisioning complete');
|
|
548
548
|
}
|
|
549
|
+
|
|
550
|
+
// Periodic tool update checker - runs in background every 6 hours
|
|
551
|
+
let updateCheckInterval = null;
|
|
552
|
+
const UPDATE_CHECK_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours
|
|
553
|
+
|
|
554
|
+
export function startPeriodicUpdateCheck(broadcast) {
|
|
555
|
+
const log = (msg) => console.log('[TOOLS-PERIODIC] ' + msg);
|
|
556
|
+
|
|
557
|
+
if (updateCheckInterval) {
|
|
558
|
+
log('Update check already running');
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
log('Starting periodic tool update checker (every 6 hours)');
|
|
563
|
+
|
|
564
|
+
// Run check immediately on startup (non-blocking)
|
|
565
|
+
setImmediate(() => {
|
|
566
|
+
checkAndUpdateTools(broadcast).catch(err => {
|
|
567
|
+
log(`Initial check failed: ${err.message}`);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// Then run periodically every 6 hours
|
|
572
|
+
updateCheckInterval = setInterval(() => {
|
|
573
|
+
checkAndUpdateTools(broadcast).catch(err => {
|
|
574
|
+
log(`Periodic check failed: ${err.message}`);
|
|
575
|
+
});
|
|
576
|
+
}, UPDATE_CHECK_INTERVAL);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function stopPeriodicUpdateCheck() {
|
|
580
|
+
if (updateCheckInterval) {
|
|
581
|
+
clearInterval(updateCheckInterval);
|
|
582
|
+
updateCheckInterval = null;
|
|
583
|
+
console.log('[TOOLS-PERIODIC] Update check stopped');
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function checkAndUpdateTools(broadcast) {
|
|
588
|
+
const log = (msg) => console.log('[TOOLS-PERIODIC] ' + msg);
|
|
589
|
+
log('Checking for tool updates...');
|
|
590
|
+
|
|
591
|
+
for (const tool of TOOLS) {
|
|
592
|
+
try {
|
|
593
|
+
const status = await checkToolViaBunx(tool.pkg, tool.pluginId, tool.category, tool.frameWork, false);
|
|
594
|
+
|
|
595
|
+
if (status.upgradeNeeded) {
|
|
596
|
+
log(`Update available for ${tool.id}: ${status.installedVersion} -> ${status.publishedVersion}`);
|
|
597
|
+
broadcast({ type: 'tool_update_available', toolId: tool.id, data: { installedVersion: status.installedVersion, publishedVersion: status.publishedVersion } });
|
|
598
|
+
|
|
599
|
+
// Auto-update in background (non-blocking)
|
|
600
|
+
log(`Auto-updating ${tool.id}...`);
|
|
601
|
+
const result = await update(tool.id, (msg) => {
|
|
602
|
+
broadcast({ type: 'tool_update_progress', toolId: tool.id, data: msg });
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
if (result.success) {
|
|
606
|
+
log(`${tool.id} auto-updated to v${result.version}`);
|
|
607
|
+
broadcast({ type: 'tool_update_complete', toolId: tool.id, data: { ...result, autoUpdated: true } });
|
|
608
|
+
} else {
|
|
609
|
+
log(`${tool.id} auto-update failed: ${result.error}`);
|
|
610
|
+
broadcast({ type: 'tool_update_failed', toolId: tool.id, data: { ...result, autoUpdated: true } });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
} catch (err) {
|
|
614
|
+
log(`Error checking ${tool.id}: ${err.message}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
log('Update check complete');
|
|
619
|
+
}
|
package/lib/ws-handlers-conv.js
CHANGED
|
@@ -7,7 +7,17 @@ function expandTilde(p) { return p && p.startsWith('~') ? path.join(os.homedir()
|
|
|
7
7
|
|
|
8
8
|
export function register(router, deps) {
|
|
9
9
|
const { queries, activeExecutions, messageQueues, rateLimitState,
|
|
10
|
-
broadcastSync, processMessageWithStreaming, activeProcessesByConvId, steeringTimeouts } = deps;
|
|
10
|
+
broadcastSync, processMessageWithStreaming, activeProcessesByConvId, steeringTimeouts, cleanupExecution } = deps;
|
|
11
|
+
|
|
12
|
+
// Per-conversation queue seq counter for event ordering
|
|
13
|
+
const queueSeqByConv = new Map();
|
|
14
|
+
|
|
15
|
+
function getNextQueueSeq(conversationId) {
|
|
16
|
+
const current = queueSeqByConv.get(conversationId) || 0;
|
|
17
|
+
const next = current + 1;
|
|
18
|
+
queueSeqByConv.set(conversationId, next);
|
|
19
|
+
return next;
|
|
20
|
+
}
|
|
11
21
|
|
|
12
22
|
router.handle('conv.ls', () => {
|
|
13
23
|
const conversations = queries.getConversationsList();
|
|
@@ -95,8 +105,10 @@ export function register(router, deps) {
|
|
|
95
105
|
const { pid, sessionId } = entry;
|
|
96
106
|
if (pid) { try { process.kill(-pid, 'SIGKILL'); } catch { try { process.kill(pid, 'SIGKILL'); } catch {} } }
|
|
97
107
|
if (sessionId) queries.updateSession(sessionId, { status: 'interrupted', completed_at: Date.now() });
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
|
|
109
|
+
// Use atomic cleanup function to ensure state consistency
|
|
110
|
+
cleanupExecution(p.id, false);
|
|
111
|
+
|
|
100
112
|
broadcastSync({ type: 'streaming_complete', sessionId, conversationId: p.id, interrupted: true, timestamp: Date.now() });
|
|
101
113
|
return { ok: true, cancelled: true, conversationId: p.id, sessionId };
|
|
102
114
|
});
|
|
@@ -245,6 +257,14 @@ export function register(router, deps) {
|
|
|
245
257
|
|
|
246
258
|
// Message is queued - don't broadcast as message_created, let queue_status handle the UI update
|
|
247
259
|
const qp = enqueue(p.id, prompt, agentId, model, userMessage.id, subAgent);
|
|
260
|
+
const seq = getNextQueueSeq(p.id);
|
|
261
|
+
broadcastSync({
|
|
262
|
+
type: 'queue_status',
|
|
263
|
+
conversationId: p.id,
|
|
264
|
+
queueLength: messageQueues.get(p.id)?.length || 1,
|
|
265
|
+
seq,
|
|
266
|
+
timestamp: Date.now()
|
|
267
|
+
});
|
|
248
268
|
return { message: userMessage, queued: true, queuePosition: qp };
|
|
249
269
|
});
|
|
250
270
|
|
|
@@ -260,7 +280,14 @@ export function register(router, deps) {
|
|
|
260
280
|
if (idx === -1) notFound('Queued message not found');
|
|
261
281
|
queue.splice(idx, 1);
|
|
262
282
|
if (queue.length === 0) messageQueues.delete(p.id);
|
|
263
|
-
|
|
283
|
+
const seq = getNextQueueSeq(p.id);
|
|
284
|
+
broadcastSync({
|
|
285
|
+
type: 'queue_status',
|
|
286
|
+
conversationId: p.id,
|
|
287
|
+
queueLength: queue?.length || 0,
|
|
288
|
+
seq,
|
|
289
|
+
timestamp: Date.now()
|
|
290
|
+
});
|
|
264
291
|
return { deleted: true };
|
|
265
292
|
});
|
|
266
293
|
|
|
@@ -271,7 +298,16 @@ export function register(router, deps) {
|
|
|
271
298
|
if (!item) notFound('Queued message not found');
|
|
272
299
|
if (p.content !== undefined) item.content = p.content;
|
|
273
300
|
if (p.agentId !== undefined) item.agentId = p.agentId;
|
|
274
|
-
|
|
301
|
+
const seq = getNextQueueSeq(p.id);
|
|
302
|
+
broadcastSync({
|
|
303
|
+
type: 'queue_updated',
|
|
304
|
+
conversationId: p.id,
|
|
305
|
+
messageId: p.messageId,
|
|
306
|
+
content: item.content,
|
|
307
|
+
agentId: item.agentId,
|
|
308
|
+
seq,
|
|
309
|
+
timestamp: Date.now()
|
|
310
|
+
});
|
|
275
311
|
return { updated: true, item };
|
|
276
312
|
});
|
|
277
313
|
}
|
package/lib/ws-handlers-run.js
CHANGED
|
@@ -152,6 +152,32 @@ function register(router, deps) {
|
|
|
152
152
|
if (!run || run.thread_id !== threadId) err(404, 'Run not found on thread');
|
|
153
153
|
return run;
|
|
154
154
|
});
|
|
155
|
+
|
|
156
|
+
router.handle('thread.run.steer', async (p) => {
|
|
157
|
+
const threadId = need(p, 'id'), runId = need(p, 'runId'), instruction = need(p, 'instruction');
|
|
158
|
+
const thread = getThreadOrThrow(threadId);
|
|
159
|
+
const run = getRunOrThrow(runId);
|
|
160
|
+
if (run.thread_id !== threadId) err(400, 'Run does not belong to specified thread');
|
|
161
|
+
if (!['active', 'pending'].includes(run.status)) err(409, 'Run is not active or pending');
|
|
162
|
+
|
|
163
|
+
// Cancel current run
|
|
164
|
+
const cancelled = queries.cancelRun(runId);
|
|
165
|
+
const ex = killExecution(threadId, runId);
|
|
166
|
+
broadcastSync({ type: 'run_cancelled', runId, threadId, sessionId: ex?.sessionId, timestamp: Date.now() });
|
|
167
|
+
|
|
168
|
+
// Create new run with steering instruction injected
|
|
169
|
+
// The instruction will be processed as a follow-up in the same thread context
|
|
170
|
+
const newRun = queries.createRun(run.agent_id, threadId, { content: instruction }, run.config);
|
|
171
|
+
startExecution(newRun.run_id, threadId, run.agent_id, { content: instruction }, run.config);
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
steered: true,
|
|
175
|
+
cancelled_run: cancelled,
|
|
176
|
+
new_run: newRun,
|
|
177
|
+
instruction: instruction,
|
|
178
|
+
message: 'Run interrupted and restarted with steering instruction'
|
|
179
|
+
};
|
|
180
|
+
});
|
|
155
181
|
}
|
|
156
182
|
|
|
157
183
|
export { register };
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -311,6 +311,45 @@ const debugLog = (msg) => {
|
|
|
311
311
|
console.error(`[${timestamp}] ${msg}`);
|
|
312
312
|
};
|
|
313
313
|
|
|
314
|
+
// Atomic cleanup function - ensures consistent state across all data structures
|
|
315
|
+
function cleanupExecution(conversationId, broadcastCompletion = false) {
|
|
316
|
+
debugLog(`[cleanup] Starting cleanup for ${conversationId}`);
|
|
317
|
+
|
|
318
|
+
// Clean in-memory maps in atomic block
|
|
319
|
+
activeExecutions.delete(conversationId);
|
|
320
|
+
const proc = activeProcessesByConvId.get(conversationId);
|
|
321
|
+
activeProcessesByConvId.delete(conversationId);
|
|
322
|
+
|
|
323
|
+
// Cancel steering timeout if present
|
|
324
|
+
const steeringTimeout = steeringTimeouts.get(conversationId);
|
|
325
|
+
if (steeringTimeout) {
|
|
326
|
+
clearTimeout(steeringTimeout);
|
|
327
|
+
steeringTimeouts.delete(conversationId);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Try to kill process if still alive
|
|
331
|
+
if (proc) {
|
|
332
|
+
try {
|
|
333
|
+
if (proc.kill) proc.kill('SIGTERM');
|
|
334
|
+
} catch (e) {
|
|
335
|
+
debugLog(`[cleanup] Error killing process: ${e.message}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Clean database state
|
|
340
|
+
queries.setIsStreaming(conversationId, false);
|
|
341
|
+
|
|
342
|
+
if (broadcastCompletion) {
|
|
343
|
+
broadcastSync({
|
|
344
|
+
type: 'execution_cleaned_up',
|
|
345
|
+
conversationId,
|
|
346
|
+
timestamp: Date.now()
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
debugLog(`[cleanup] Cleanup complete for ${conversationId}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
314
353
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
315
354
|
const rootDir = process.env.PORTABLE_EXE_DIR || __dirname;
|
|
316
355
|
const PORT = process.env.PORT || 3000;
|
|
@@ -3317,6 +3356,9 @@ function createChunkBatcher() {
|
|
|
3317
3356
|
return { add, drain };
|
|
3318
3357
|
}
|
|
3319
3358
|
|
|
3359
|
+
// Global broadcast sequence counter for event ordering
|
|
3360
|
+
let broadcastSeq = 0;
|
|
3361
|
+
|
|
3320
3362
|
function parseRateLimitResetTime(text) {
|
|
3321
3363
|
const match = text.match(/resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*\(?(UTC|[A-Z]{2,4})\)?/i);
|
|
3322
3364
|
if (!match) return 300;
|
|
@@ -3832,10 +3874,18 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
|
|
|
3832
3874
|
});
|
|
3833
3875
|
} finally {
|
|
3834
3876
|
batcher.drain();
|
|
3835
|
-
|
|
3877
|
+
// Use atomic cleanup but only if not in rate limit recovery
|
|
3836
3878
|
if (!rateLimitState.has(conversationId)) {
|
|
3837
|
-
|
|
3879
|
+
cleanupExecution(conversationId);
|
|
3838
3880
|
drainMessageQueue(conversationId);
|
|
3881
|
+
} else {
|
|
3882
|
+
// Rate limit in flight - keep execution entry for now, but clean process handle
|
|
3883
|
+
activeProcessesByConvId.delete(conversationId);
|
|
3884
|
+
const steeringTimeout = steeringTimeouts.get(conversationId);
|
|
3885
|
+
if (steeringTimeout) {
|
|
3886
|
+
clearTimeout(steeringTimeout);
|
|
3887
|
+
steeringTimeouts.delete(conversationId);
|
|
3888
|
+
}
|
|
3839
3889
|
}
|
|
3840
3890
|
}
|
|
3841
3891
|
}
|
|
@@ -3871,6 +3921,16 @@ function scheduleRetry(conversationId, messageId, content, agentId, model, subAg
|
|
|
3871
3921
|
.catch(err => {
|
|
3872
3922
|
debugLog(`[rate-limit] Retry failed: ${err.message}`);
|
|
3873
3923
|
console.error(`[rate-limit] Retry error for conv ${conversationId}:`, err);
|
|
3924
|
+
// Clean up state on retry failure
|
|
3925
|
+
cleanupExecution(conversationId);
|
|
3926
|
+
broadcastSync({
|
|
3927
|
+
type: 'streaming_error',
|
|
3928
|
+
sessionId: newSession.id,
|
|
3929
|
+
conversationId,
|
|
3930
|
+
error: `Rate limit retry failed: ${err.message}`,
|
|
3931
|
+
recoverable: false,
|
|
3932
|
+
timestamp: Date.now()
|
|
3933
|
+
});
|
|
3874
3934
|
});
|
|
3875
3935
|
}
|
|
3876
3936
|
|
|
@@ -3915,7 +3975,21 @@ function drainMessageQueue(conversationId) {
|
|
|
3915
3975
|
activeExecutions.set(conversationId, { pid: null, startTime, sessionId: session.id, lastActivity: startTime });
|
|
3916
3976
|
|
|
3917
3977
|
processMessageWithStreaming(conversationId, next.messageId, session.id, next.content, next.agentId, next.model, next.subAgent)
|
|
3918
|
-
.catch(err =>
|
|
3978
|
+
.catch(err => {
|
|
3979
|
+
debugLog(`[queue] Error processing queued message: ${err.message}`);
|
|
3980
|
+
// CRITICAL: Clean up state on error so next message can be retried
|
|
3981
|
+
cleanupExecution(conversationId);
|
|
3982
|
+
broadcastSync({
|
|
3983
|
+
type: 'streaming_error',
|
|
3984
|
+
sessionId: session.id,
|
|
3985
|
+
conversationId,
|
|
3986
|
+
error: `Queue processing failed: ${err.message}`,
|
|
3987
|
+
recoverable: true,
|
|
3988
|
+
timestamp: Date.now()
|
|
3989
|
+
});
|
|
3990
|
+
// Try to drain next message in queue
|
|
3991
|
+
setTimeout(() => drainMessageQueue(conversationId), 100);
|
|
3992
|
+
});
|
|
3919
3993
|
}
|
|
3920
3994
|
|
|
3921
3995
|
|
|
@@ -4026,7 +4100,8 @@ const wsRouter = new WsRouter();
|
|
|
4026
4100
|
|
|
4027
4101
|
registerConvHandlers(wsRouter, {
|
|
4028
4102
|
queries, activeExecutions, messageQueues, rateLimitState,
|
|
4029
|
-
broadcastSync, processMessageWithStreaming, activeProcessesByConvId, steeringTimeouts
|
|
4103
|
+
broadcastSync, processMessageWithStreaming, activeProcessesByConvId, steeringTimeouts,
|
|
4104
|
+
cleanupExecution
|
|
4030
4105
|
});
|
|
4031
4106
|
|
|
4032
4107
|
console.log('[INIT] About to call registerSessionHandlers, discoveredAgents.length:', discoveredAgents.length);
|
|
@@ -4540,10 +4615,12 @@ function onServerReady() {
|
|
|
4540
4615
|
}, 6000);
|
|
4541
4616
|
}).catch(err => console.error('[ACP] Startup error:', err.message));
|
|
4542
4617
|
|
|
4543
|
-
const toolIds = ['cli-claude', 'cli-opencode', 'cli-gemini', 'cli-kilo', 'cli-codex', 'gm-cc', 'gm-oc', 'gm-gc', 'gm-kilo'];
|
|
4618
|
+
const toolIds = ['cli-claude', 'cli-opencode', 'cli-gemini', 'cli-kilo', 'cli-codex', 'cli-agent-browser', 'gm-cc', 'gm-oc', 'gm-gc', 'gm-kilo'];
|
|
4544
4619
|
queries.initializeToolInstallations(toolIds.map(id => ({ id })));
|
|
4545
4620
|
console.log('[TOOLS] Starting background provisioning...');
|
|
4546
|
-
|
|
4621
|
+
|
|
4622
|
+
// Create broadcast handler for tool events
|
|
4623
|
+
const toolBroadcaster = (evt) => {
|
|
4547
4624
|
broadcastSync(evt);
|
|
4548
4625
|
if (evt.type === 'tool_install_complete' || evt.type === 'tool_update_complete') {
|
|
4549
4626
|
const d = evt.data || {};
|
|
@@ -4558,7 +4635,17 @@ function onServerReady() {
|
|
|
4558
4635
|
queries.updateToolStatus(evt.toolId, { status: 'installed', version: d.installedVersion || null, installed_at: Date.now() });
|
|
4559
4636
|
}
|
|
4560
4637
|
}
|
|
4561
|
-
}
|
|
4638
|
+
};
|
|
4639
|
+
|
|
4640
|
+
// Initial provisioning (blocks until complete)
|
|
4641
|
+
toolManager.autoProvision(toolBroadcaster)
|
|
4642
|
+
.catch(err => console.error('[TOOLS] Auto-provision error:', err.message))
|
|
4643
|
+
.then(() => {
|
|
4644
|
+
// Start periodic update checker AFTER initial provisioning completes
|
|
4645
|
+
// This runs in background and doesn't block GUI
|
|
4646
|
+
console.log('[TOOLS] Starting periodic update checker...');
|
|
4647
|
+
toolManager.startPeriodicUpdateCheck(toolBroadcaster);
|
|
4648
|
+
});
|
|
4562
4649
|
|
|
4563
4650
|
ensureModelsDownloaded().then(async ok => {
|
|
4564
4651
|
if (ok) console.log('[MODELS] Speech models ready');
|
package/static/js/client.js
CHANGED
|
@@ -157,6 +157,9 @@ class AgentGUIClient {
|
|
|
157
157
|
this._recoverMissedChunks();
|
|
158
158
|
this.updateSendButtonState();
|
|
159
159
|
this.enablePromptArea();
|
|
160
|
+
if (this.state.currentConversation?.id) {
|
|
161
|
+
this.updateBusyPromptArea(this.state.currentConversation.id);
|
|
162
|
+
}
|
|
160
163
|
this.emit('ws:connected');
|
|
161
164
|
// Check if server was updated while client was loaded - reload if version changed
|
|
162
165
|
if (window.__SERVER_VERSION) {
|
|
@@ -2045,8 +2048,6 @@ class AgentGUIClient {
|
|
|
2045
2048
|
</div>
|
|
2046
2049
|
</div>
|
|
2047
2050
|
`;
|
|
2048
|
-
// Keep loading spinner visible during hydration
|
|
2049
|
-
this.showLoadingSpinner();
|
|
2050
2051
|
}
|
|
2051
2052
|
|
|
2052
2053
|
async streamToConversation(conversationId, prompt, agentId, model, subAgent) {
|
|
@@ -2084,6 +2085,7 @@ class AgentGUIClient {
|
|
|
2084
2085
|
|
|
2085
2086
|
if (result.queued) {
|
|
2086
2087
|
console.log('Message queued, position:', result.queuePosition);
|
|
2088
|
+
this.enableControls();
|
|
2087
2089
|
return;
|
|
2088
2090
|
}
|
|
2089
2091
|
|
|
@@ -2491,22 +2493,20 @@ class AgentGUIClient {
|
|
|
2491
2493
|
}
|
|
2492
2494
|
|
|
2493
2495
|
/**
|
|
2494
|
-
* Disable UI controls during
|
|
2495
|
-
* NOTE: Prompt area is IMMUTABLE - always enabled while connected.
|
|
2496
|
-
* Streaming state is rendered via queue/steer buttons, not input disabling.
|
|
2496
|
+
* Disable UI controls during execution - prevents double-sends
|
|
2497
2497
|
*/
|
|
2498
2498
|
disableControls() {
|
|
2499
|
-
|
|
2500
|
-
// Never disable input during streaming - use queue/steer visibility instead
|
|
2499
|
+
if (this.ui.sendButton) this.ui.sendButton.disabled = true;
|
|
2501
2500
|
}
|
|
2502
2501
|
|
|
2503
2502
|
/**
|
|
2504
|
-
* Enable UI controls
|
|
2505
|
-
* NOTE: Prompt area is always enabled when connected.
|
|
2503
|
+
* Enable UI controls after execution completes or fails
|
|
2506
2504
|
*/
|
|
2507
2505
|
enableControls() {
|
|
2508
|
-
|
|
2509
|
-
|
|
2506
|
+
if (this.ui.sendButton) {
|
|
2507
|
+
this.ui.sendButton.disabled = !this.wsManager?.isConnected;
|
|
2508
|
+
}
|
|
2509
|
+
this.updateBusyPromptArea(this.state.currentConversation?.id);
|
|
2510
2510
|
}
|
|
2511
2511
|
|
|
2512
2512
|
/**
|
|
@@ -2686,10 +2686,6 @@ class AgentGUIClient {
|
|
|
2686
2686
|
this.conversationCache.delete(conversationId);
|
|
2687
2687
|
this.syncPromptState(conversationId);
|
|
2688
2688
|
this.restoreScrollPosition(conversationId);
|
|
2689
|
-
// Hydration complete - hide loading spinner
|
|
2690
|
-
this.hideLoadingSpinner();
|
|
2691
|
-
// Prompt state is immutable: computed from shouldResumeStreaming via syncPromptState
|
|
2692
|
-
// Do not call enableControls/disableControls here - prompt state is determined by streaming status
|
|
2693
2689
|
return;
|
|
2694
2690
|
}
|
|
2695
2691
|
}
|
|
@@ -2700,9 +2696,8 @@ class AgentGUIClient {
|
|
|
2700
2696
|
|
|
2701
2697
|
let fullData;
|
|
2702
2698
|
try {
|
|
2703
|
-
// Load only recent chunks initially (lazy load older ones)
|
|
2704
|
-
// Use chunkLimit of 50 to make page load faster
|
|
2705
2699
|
fullData = await window.wsClient.rpc('conv.full', { id: conversationId, chunkLimit: 50 });
|
|
2700
|
+
if (convSignal.aborted) return;
|
|
2706
2701
|
} catch (e) {
|
|
2707
2702
|
if (e.code === 404) {
|
|
2708
2703
|
console.warn('Conversation no longer exists:', conversationId);
|
|
@@ -2724,6 +2719,7 @@ class AgentGUIClient {
|
|
|
2724
2719
|
}
|
|
2725
2720
|
throw e;
|
|
2726
2721
|
}
|
|
2722
|
+
if (convSignal.aborted) return;
|
|
2727
2723
|
const { conversation, isActivelyStreaming, latestSession, chunks: rawChunks, totalChunks, messages: allMessages } = fullData;
|
|
2728
2724
|
|
|
2729
2725
|
window.ConversationState?.selectConversation(conversationId, 'server_load', 1);
|
|
@@ -2736,18 +2732,32 @@ class AgentGUIClient {
|
|
|
2736
2732
|
...chunk,
|
|
2737
2733
|
block: typeof chunk.data === 'string' ? JSON.parse(chunk.data) : chunk.data
|
|
2738
2734
|
}));
|
|
2739
|
-
|
|
2735
|
+
|
|
2736
|
+
// Fetch queue to exclude queued messages from being rendered as regular user messages
|
|
2737
|
+
let queuedMessageIds = new Set();
|
|
2738
|
+
try {
|
|
2739
|
+
const { queue } = await window.wsClient.rpc('q.ls', { id: conversationId });
|
|
2740
|
+
if (queue && queue.length > 0) {
|
|
2741
|
+
queuedMessageIds = new Set(queue.map(q => q.messageId));
|
|
2742
|
+
}
|
|
2743
|
+
} catch (e) {
|
|
2744
|
+
console.warn('Failed to fetch queue:', e.message);
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
// Filter out queued messages from user messages - they'll be rendered in queue indicator instead
|
|
2748
|
+
const userMessages = (allMessages || []).filter(m => m.role === 'user' && !queuedMessageIds.has(m.id));
|
|
2740
2749
|
const hasMoreChunks = totalChunks && chunks.length < totalChunks;
|
|
2741
2750
|
|
|
2742
2751
|
const clientKnowsStreaming = this.state.streamingConversations.has(conversationId);
|
|
2743
2752
|
const shouldResumeStreaming = latestSession &&
|
|
2744
2753
|
(latestSession.status === 'active' || latestSession.status === 'pending');
|
|
2745
2754
|
|
|
2746
|
-
// IMMUTABLE: Update streaming state and disable prompt atomically
|
|
2747
2755
|
if (shouldResumeStreaming) {
|
|
2748
2756
|
this.state.streamingConversations.set(conversationId, true);
|
|
2757
|
+
window.dispatchEvent(new CustomEvent('ws-message', { detail: { type: 'streaming_start', conversationId, sessionId: latestSession?.id } }));
|
|
2749
2758
|
} else {
|
|
2750
2759
|
this.state.streamingConversations.delete(conversationId);
|
|
2760
|
+
window.dispatchEvent(new CustomEvent('ws-message', { detail: { type: 'streaming_complete', conversationId } }));
|
|
2751
2761
|
}
|
|
2752
2762
|
|
|
2753
2763
|
if (this.ui.messageInput) {
|
|
@@ -2948,12 +2958,13 @@ class AgentGUIClient {
|
|
|
2948
2958
|
|
|
2949
2959
|
this.restoreScrollPosition(conversationId);
|
|
2950
2960
|
this.setupScrollUpDetection(conversationId);
|
|
2951
|
-
|
|
2952
|
-
|
|
2961
|
+
|
|
2962
|
+
// Fetch and display queue items so queued messages show in yellow blocks, not as user messages
|
|
2963
|
+
this.fetchAndRenderQueue(conversationId);
|
|
2964
|
+
|
|
2953
2965
|
}
|
|
2954
2966
|
} catch (error) {
|
|
2955
2967
|
if (error.name === 'AbortError') return;
|
|
2956
|
-
this.hideLoadingSpinner();
|
|
2957
2968
|
console.error('Failed to load conversation messages:', error);
|
|
2958
2969
|
// Resume from last successful conversation if available, or fall back to any available conversation
|
|
2959
2970
|
const fallbackConv = prevConversationId ? prevConversationId : availableFallback?.id;
|
|
@@ -3198,7 +3209,7 @@ class AgentGUIClient {
|
|
|
3198
3209
|
}
|
|
3199
3210
|
|
|
3200
3211
|
saveAgentAndModelToConversation() {
|
|
3201
|
-
const convId = this.state.currentConversation;
|
|
3212
|
+
const convId = this.state.currentConversation?.id;
|
|
3202
3213
|
if (!convId || this._agentLocked) return;
|
|
3203
3214
|
const agentId = this.getEffectiveAgentId();
|
|
3204
3215
|
const subAgent = this.getEffectiveSubAgent();
|
|
@@ -3338,6 +3349,7 @@ let agentGUIClient = null;
|
|
|
3338
3349
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
3339
3350
|
try {
|
|
3340
3351
|
agentGUIClient = new AgentGUIClient();
|
|
3352
|
+
window.agentGuiClient = agentGUIClient;
|
|
3341
3353
|
await agentGUIClient.init();
|
|
3342
3354
|
console.log('AgentGUI ready');
|
|
3343
3355
|
} catch (error) {
|
|
@@ -433,8 +433,11 @@ class ConversationManager {
|
|
|
433
433
|
|
|
434
434
|
this._updateConversations(convList, 'poll');
|
|
435
435
|
|
|
436
|
+
const clientStreamingMap = window.agentGuiClient?.state?.streamingConversations;
|
|
436
437
|
for (const conv of this.conversations) {
|
|
437
|
-
|
|
438
|
+
const serverStreaming = conv.isStreaming === 1 || conv.isStreaming === true;
|
|
439
|
+
const clientStreaming = clientStreamingMap ? clientStreamingMap.has(conv.id) : false;
|
|
440
|
+
if (serverStreaming || clientStreaming) {
|
|
438
441
|
this.streamingConversations.add(conv.id);
|
|
439
442
|
} else {
|
|
440
443
|
this.streamingConversations.delete(conv.id);
|
package/static/js/features.js
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
}
|
|
40
40
|
function closeSidebar() {
|
|
41
41
|
sidebar.classList.remove('mobile-visible');
|
|
42
|
+
sidebar.classList.add('collapsed'); // Ensure sidebar is hidden on close
|
|
42
43
|
if (overlay) overlay.classList.remove('visible');
|
|
43
44
|
}
|
|
44
45
|
if (toggleBtn) toggleBtn.addEventListener('click', function(e) { e.stopPropagation(); toggleSidebar(); });
|
|
@@ -136,23 +136,35 @@ class StreamingRenderer {
|
|
|
136
136
|
|
|
137
137
|
/**
|
|
138
138
|
* Check if event is a duplicate
|
|
139
|
+
* For streaming_progress events, use seq+sessionId for precise dedup
|
|
140
|
+
* For other events, use type+id or type+sessionId
|
|
139
141
|
*/
|
|
140
142
|
isDuplicate(event) {
|
|
141
143
|
const key = this.getEventKey(event);
|
|
142
144
|
if (!key) return false;
|
|
143
145
|
|
|
144
|
-
const
|
|
145
|
-
const now = Date.now();
|
|
146
|
+
const lastSeq = this.dedupMap.get(key);
|
|
146
147
|
|
|
147
|
-
|
|
148
|
-
|
|
148
|
+
// For streaming_progress with seq, compare seq numbers directly
|
|
149
|
+
if (event.type === 'streaming_progress' && event.seq !== undefined && lastSeq !== undefined) {
|
|
150
|
+
if (event.seq <= lastSeq) {
|
|
151
|
+
return true; // Same or older seq = duplicate
|
|
152
|
+
}
|
|
153
|
+
this.dedupMap.set(key, event.seq);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// For other events, use time-based dedup
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
if (lastSeq && typeof lastSeq === 'number' && lastSeq > now - 500) {
|
|
160
|
+
return true; // Recent duplicate
|
|
149
161
|
}
|
|
150
162
|
|
|
151
163
|
this.dedupMap.set(key, now);
|
|
152
164
|
if (this.dedupMap.size > 5000) {
|
|
153
|
-
const cutoff = now -
|
|
154
|
-
for (const [k,
|
|
155
|
-
if (
|
|
165
|
+
const cutoff = now - 5000;
|
|
166
|
+
for (const [k, v] of this.dedupMap) {
|
|
167
|
+
if (typeof v === 'number' && v < cutoff) this.dedupMap.delete(k);
|
|
156
168
|
}
|
|
157
169
|
}
|
|
158
170
|
return false;
|
|
@@ -160,10 +172,15 @@ class StreamingRenderer {
|
|
|
160
172
|
|
|
161
173
|
/**
|
|
162
174
|
* Generate deduplication key for event
|
|
175
|
+
* Use sessionId:seq for streaming_progress, fallback to type:id
|
|
163
176
|
*/
|
|
164
177
|
getEventKey(event) {
|
|
165
178
|
if (!event.type) return null;
|
|
166
|
-
|
|
179
|
+
// For streaming events, use sessionId as primary key
|
|
180
|
+
if (event.sessionId) {
|
|
181
|
+
return `${event.sessionId}:${event.type}`;
|
|
182
|
+
}
|
|
183
|
+
return `${event.type}:${event.id || ''}`;
|
|
167
184
|
}
|
|
168
185
|
|
|
169
186
|
/**
|
|
@@ -480,8 +480,14 @@ class WebSocketManager {
|
|
|
480
480
|
}
|
|
481
481
|
|
|
482
482
|
resubscribeAll() {
|
|
483
|
+
// After reconnect, query server state for all conversations with active subscriptions
|
|
484
|
+
// This ensures client streaming state matches server state
|
|
485
|
+
const conversationIds = new Set();
|
|
483
486
|
for (const key of this.activeSubscriptions) {
|
|
484
487
|
const [type, id] = key.split(':');
|
|
488
|
+
if (type === 'conversation') {
|
|
489
|
+
conversationIds.add(id);
|
|
490
|
+
}
|
|
485
491
|
const msg = { type: 'subscribe', timestamp: Date.now() };
|
|
486
492
|
if (type === 'session') msg.sessionId = id;
|
|
487
493
|
else msg.conversationId = id;
|
|
@@ -490,6 +496,21 @@ class WebSocketManager {
|
|
|
490
496
|
this.stats.totalMessagesSent++;
|
|
491
497
|
} catch (_) {}
|
|
492
498
|
}
|
|
499
|
+
|
|
500
|
+
// After resubscribing, query streaming state for each conversation
|
|
501
|
+
// This prevents stale UI state after network hiccup
|
|
502
|
+
if (conversationIds.size > 0) {
|
|
503
|
+
conversationIds.forEach(convId => {
|
|
504
|
+
this.sendMessage({
|
|
505
|
+
type: 'conv.get',
|
|
506
|
+
id: convId,
|
|
507
|
+
timestamp: Date.now()
|
|
508
|
+
}).catch(() => {
|
|
509
|
+
// Silently ignore query failures - server will send streaming_start
|
|
510
|
+
// on subscription confirmation if execution is active
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
}
|
|
493
514
|
}
|
|
494
515
|
|
|
495
516
|
unsubscribeFromSession(sessionId) {
|