agentgui 1.0.698 → 1.0.700
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/CLAUDE.md +1 -1
- package/lib/execution-machine.js +35 -0
- package/lib/ws-handlers-conv.js +51 -34
- package/package.json +1 -1
- package/server.js +45 -27
- package/static/js/client.js +43 -28
- package/static/js/conv-machine.js +21 -10
- package/static/js/websocket-manager.js +18 -1
package/CLAUDE.md
CHANGED
|
@@ -73,7 +73,7 @@ XState v5 machines provide formal state tracking alongside (not replacing) the e
|
|
|
73
73
|
- WebSocket endpoint is at `BASE_URL + /sync`. Supports subscribe/unsubscribe by sessionId or conversationId, and ping.
|
|
74
74
|
- All WS RPC uses msgpack binary encoding (lib/codec.js). Wire format: `{ r, m, p }` request, `{ r, d }` reply, `{ type, seq }` broadcast push.
|
|
75
75
|
- `perMessageDeflate` is disabled on the WS server — msgpack binary doesn't compress well and brotli/gzip was blocking the event loop. HTTP-layer gzip handles static assets.
|
|
76
|
-
- Static assets use `Cache-Control:
|
|
76
|
+
- Static assets use `Cache-Control: public, no-cache` + ETag. Browser always revalidates (sends If-None-Match), server returns 304 if unchanged. Compressed once on first request, served from RAM (`_assetCache` Map keyed by etag).
|
|
77
77
|
- Deployment: runs behind Traefik/Caddy which handles TLS and can support WebTransport/QUIC.
|
|
78
78
|
|
|
79
79
|
## Environment Variables
|
package/lib/execution-machine.js
CHANGED
|
@@ -38,6 +38,9 @@ const machine = createMachine({
|
|
|
38
38
|
queue: [...context.queue, event.item],
|
|
39
39
|
})),
|
|
40
40
|
},
|
|
41
|
+
SET_QUEUE: {
|
|
42
|
+
actions: assign(({ event }) => ({ queue: event.queue })),
|
|
43
|
+
},
|
|
41
44
|
COMPLETE: [
|
|
42
45
|
{
|
|
43
46
|
guard: ({ context }) => context.queue.length > 0,
|
|
@@ -102,10 +105,21 @@ const machine = createMachine({
|
|
|
102
105
|
});
|
|
103
106
|
|
|
104
107
|
const actors = new Map();
|
|
108
|
+
// Per-convId listeners: Map<convId, Set<(snapshot) => void>>
|
|
109
|
+
const listeners = new Map();
|
|
110
|
+
|
|
111
|
+
function notifyListeners(convId, snapshot) {
|
|
112
|
+
const set = listeners.get(convId);
|
|
113
|
+
if (!set) return;
|
|
114
|
+
for (const fn of set) {
|
|
115
|
+
try { fn(snapshot); } catch (_) {}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
105
118
|
|
|
106
119
|
export function getOrCreate(convId) {
|
|
107
120
|
if (actors.has(convId)) return actors.get(convId);
|
|
108
121
|
const actor = createActor(machine);
|
|
122
|
+
actor.subscribe(snapshot => notifyListeners(convId, snapshot));
|
|
109
123
|
actor.start();
|
|
110
124
|
actors.set(convId, actor);
|
|
111
125
|
return actor;
|
|
@@ -118,6 +132,7 @@ export function get(convId) {
|
|
|
118
132
|
export function remove(convId) {
|
|
119
133
|
const actor = actors.get(convId);
|
|
120
134
|
if (actor) { actor.stop(); actors.delete(convId); }
|
|
135
|
+
listeners.delete(convId);
|
|
121
136
|
}
|
|
122
137
|
|
|
123
138
|
export function snapshot(convId) {
|
|
@@ -130,18 +145,38 @@ export function isStreaming(convId) {
|
|
|
130
145
|
return s ? s.value === 'streaming' || s.value === 'rate_limited' : false;
|
|
131
146
|
}
|
|
132
147
|
|
|
148
|
+
export function isActive(convId) {
|
|
149
|
+
const s = snapshot(convId);
|
|
150
|
+
if (!s) return false;
|
|
151
|
+
return s.value === 'streaming' || s.value === 'rate_limited' || s.value === 'draining';
|
|
152
|
+
}
|
|
153
|
+
|
|
133
154
|
export function getContext(convId) {
|
|
134
155
|
const s = snapshot(convId);
|
|
135
156
|
return s ? s.context : null;
|
|
136
157
|
}
|
|
137
158
|
|
|
159
|
+
export function getQueue(convId) {
|
|
160
|
+
const ctx = getContext(convId);
|
|
161
|
+
return ctx ? ctx.queue : [];
|
|
162
|
+
}
|
|
163
|
+
|
|
138
164
|
export function send(convId, event) {
|
|
139
165
|
const actor = getOrCreate(convId);
|
|
140
166
|
actor.send(event);
|
|
141
167
|
return actor.getSnapshot();
|
|
142
168
|
}
|
|
143
169
|
|
|
170
|
+
// Subscribe to state transitions for a conversation.
|
|
171
|
+
// Returns an unsubscribe function.
|
|
172
|
+
export function subscribe(convId, fn) {
|
|
173
|
+
if (!listeners.has(convId)) listeners.set(convId, new Set());
|
|
174
|
+
listeners.get(convId).add(fn);
|
|
175
|
+
return () => listeners.get(convId)?.delete(fn);
|
|
176
|
+
}
|
|
177
|
+
|
|
144
178
|
export function stopAll() {
|
|
145
179
|
for (const [, actor] of actors) actor.stop();
|
|
146
180
|
actors.clear();
|
|
181
|
+
listeners.clear();
|
|
147
182
|
}
|
package/lib/ws-handlers-conv.js
CHANGED
|
@@ -55,7 +55,7 @@ export function register(router, deps) {
|
|
|
55
55
|
|
|
56
56
|
router.handle('conv.ls', () => {
|
|
57
57
|
const conversations = queries.getConversationsList();
|
|
58
|
-
for (const c of conversations) { if (c.isStreaming && !
|
|
58
|
+
for (const c of conversations) { if (c.isStreaming && !execMachine.isActive(c.id)) c.isStreaming = 0; }
|
|
59
59
|
return { conversations };
|
|
60
60
|
});
|
|
61
61
|
|
|
@@ -72,7 +72,7 @@ export function register(router, deps) {
|
|
|
72
72
|
const conv = queries.getConversation(p.id);
|
|
73
73
|
if (!conv) notFound();
|
|
74
74
|
const machineSnap = execMachine.snapshot(p.id);
|
|
75
|
-
return { conversation: conv, isActivelyStreaming:
|
|
75
|
+
return { conversation: conv, isActivelyStreaming: execMachine.isActive(p.id), latestSession: queries.getLatestSession(p.id), executionState: machineSnap?.value || 'idle' };
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
router.handle('conv.upd', (p) => {
|
|
@@ -110,7 +110,7 @@ export function register(router, deps) {
|
|
|
110
110
|
const machineSnap = execMachine.snapshot(p.id);
|
|
111
111
|
return {
|
|
112
112
|
conversation: conv,
|
|
113
|
-
isActivelyStreaming:
|
|
113
|
+
isActivelyStreaming: execMachine.isActive(p.id),
|
|
114
114
|
executionState: machineSnap?.value || 'idle',
|
|
115
115
|
latestSession: queries.getLatestSession(p.id),
|
|
116
116
|
chunks,
|
|
@@ -141,15 +141,13 @@ export function register(router, deps) {
|
|
|
141
141
|
});
|
|
142
142
|
|
|
143
143
|
router.handle('conv.cancel', (p) => {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
144
|
+
if (!execMachine.isActive(p.id)) notFound('No active execution to cancel');
|
|
145
|
+
const ctx = execMachine.getContext(p.id);
|
|
146
|
+
const pid = ctx?.pid || activeExecutions.get(p.id)?.pid;
|
|
147
|
+
const sessionId = ctx?.sessionId || activeExecutions.get(p.id)?.sessionId;
|
|
147
148
|
if (pid) { try { process.kill(-pid, 'SIGKILL'); } catch { try { process.kill(pid, 'SIGKILL'); } catch {} } }
|
|
148
149
|
if (sessionId) queries.updateSession(sessionId, { status: 'interrupted', completed_at: Date.now() });
|
|
149
|
-
|
|
150
|
-
// Use atomic cleanup function to ensure state consistency
|
|
151
150
|
cleanupExecution(p.id, false);
|
|
152
|
-
|
|
153
151
|
broadcastSync({ type: 'streaming_complete', sessionId, conversationId: p.id, interrupted: true, timestamp: Date.now() });
|
|
154
152
|
return { ok: true, cancelled: true, conversationId: p.id, sessionId };
|
|
155
153
|
});
|
|
@@ -158,9 +156,8 @@ export function register(router, deps) {
|
|
|
158
156
|
const conv = queries.getConversation(p.id);
|
|
159
157
|
if (!conv) notFound('Conversation not found');
|
|
160
158
|
if (!p.content) fail(400, 'Missing content');
|
|
161
|
-
const entry = activeExecutions.get(p.id);
|
|
162
159
|
const message = queries.createMessage(p.id, 'user', '[INJECTED] ' + p.content);
|
|
163
|
-
if (!
|
|
160
|
+
if (!execMachine.isActive(p.id)) {
|
|
164
161
|
const agentId = conv.agentId || 'claude-code';
|
|
165
162
|
const session = queries.createSession(p.id, agentId, 'pending');
|
|
166
163
|
processMessageWithStreaming(p.id, message.id, session.id, message.content, agentId, conv.model || null, conv.subAgent || null);
|
|
@@ -173,10 +170,11 @@ export function register(router, deps) {
|
|
|
173
170
|
const conv = queries.getConversation(p.id);
|
|
174
171
|
if (!conv) notFound('Conversation not found');
|
|
175
172
|
|
|
176
|
-
|
|
177
|
-
if (!entry) fail(409, 'No active execution to steer');
|
|
173
|
+
if (!execMachine.isActive(p.id)) fail(409, 'No active execution to steer');
|
|
178
174
|
|
|
179
|
-
const
|
|
175
|
+
const ctx = execMachine.getContext(p.id);
|
|
176
|
+
const pid = ctx?.pid || activeExecutions.get(p.id)?.pid;
|
|
177
|
+
const sessionId = ctx?.sessionId || activeExecutions.get(p.id)?.sessionId;
|
|
180
178
|
|
|
181
179
|
if (pid) {
|
|
182
180
|
try { process.kill(-pid, 'SIGKILL'); } catch { try { process.kill(pid, 'SIGKILL'); } catch (e) {} }
|
|
@@ -184,6 +182,7 @@ export function register(router, deps) {
|
|
|
184
182
|
|
|
185
183
|
if (sessionId) queries.updateSession(sessionId, { status: 'interrupted', completed_at: Date.now() });
|
|
186
184
|
queries.setIsStreaming(p.id, false);
|
|
185
|
+
execMachine.send(p.id, { type: 'CANCEL' });
|
|
187
186
|
activeExecutions.delete(p.id);
|
|
188
187
|
|
|
189
188
|
// Clear claudeSessionId so new execution starts fresh without --resume on a killed session
|
|
@@ -218,6 +217,8 @@ export function register(router, deps) {
|
|
|
218
217
|
function startExecution(convId, message, agentId, model, content, subAgent) {
|
|
219
218
|
const session = queries.createSession(convId);
|
|
220
219
|
queries.createEvent('session.created', { messageId: message.id, sessionId: session.id }, convId, session.id);
|
|
220
|
+
// Machine is authoritative — START event sets sessionId in context
|
|
221
|
+
execMachine.send(convId, { type: 'START', sessionId: session.id });
|
|
221
222
|
activeExecutions.set(convId, { pid: null, startTime: Date.now(), sessionId: session.id, lastActivity: Date.now() });
|
|
222
223
|
queries.setIsStreaming(convId, true);
|
|
223
224
|
broadcastSync({ type: 'streaming_start', sessionId: session.id, conversationId: convId, messageId: message.id, agentId, timestamp: Date.now() });
|
|
@@ -226,9 +227,13 @@ export function register(router, deps) {
|
|
|
226
227
|
}
|
|
227
228
|
|
|
228
229
|
function enqueue(convId, content, agentId, model, messageId, subAgent) {
|
|
230
|
+
const item = { content, agentId, model, messageId, subAgent };
|
|
231
|
+
// Machine is authoritative for queue — ENQUEUE event adds to machine context.queue
|
|
232
|
+
execMachine.send(convId, { type: 'ENQUEUE', item });
|
|
233
|
+
// Keep messageQueues in sync for legacy REST endpoints
|
|
229
234
|
if (!messageQueues.has(convId)) messageQueues.set(convId, []);
|
|
230
|
-
messageQueues.get(convId).push(
|
|
231
|
-
const queueLength =
|
|
235
|
+
messageQueues.get(convId).push(item);
|
|
236
|
+
const queueLength = execMachine.getQueue(convId).length;
|
|
232
237
|
broadcastSync({ type: 'queue_status', conversationId: convId, queueLength, messageId, timestamp: Date.now() });
|
|
233
238
|
return queueLength;
|
|
234
239
|
}
|
|
@@ -249,14 +254,13 @@ export function register(router, deps) {
|
|
|
249
254
|
const message = queries.createMessage(p.id, 'user', p.content, idempotencyKey);
|
|
250
255
|
queries.createEvent('message.created', { role: 'user', messageId: message.id }, p.id);
|
|
251
256
|
|
|
252
|
-
//
|
|
253
|
-
if (!
|
|
257
|
+
// Machine is authoritative: gate on machine state, not Map
|
|
258
|
+
if (!execMachine.isActive(p.id)) {
|
|
254
259
|
broadcastSync({ type: 'message_created', conversationId: p.id, message, timestamp: Date.now() });
|
|
255
260
|
const session = startExecution(p.id, message, agentId, model, p.content, subAgent);
|
|
256
261
|
return { message, session, idempotencyKey };
|
|
257
262
|
}
|
|
258
263
|
|
|
259
|
-
// Message is queued - don't broadcast as message_created, let queue_status handle the UI update
|
|
260
264
|
const qp = enqueue(p.id, p.content, agentId, model, message.id, subAgent);
|
|
261
265
|
return { message, queued: true, queuePosition: qp, idempotencyKey };
|
|
262
266
|
});
|
|
@@ -283,20 +287,19 @@ export function register(router, deps) {
|
|
|
283
287
|
const userMessage = queries.createMessage(p.id, 'user', prompt);
|
|
284
288
|
queries.createEvent('message.created', { role: 'user', messageId: userMessage.id }, p.id);
|
|
285
289
|
|
|
286
|
-
//
|
|
287
|
-
if (!
|
|
290
|
+
// Machine is authoritative: gate on machine state, not Map
|
|
291
|
+
if (!execMachine.isActive(p.id)) {
|
|
288
292
|
broadcastSync({ type: 'message_created', conversationId: p.id, message: userMessage, timestamp: Date.now() });
|
|
289
293
|
const session = startExecution(p.id, userMessage, agentId, model, prompt, subAgent);
|
|
290
294
|
return { message: userMessage, session, streamId: session.id };
|
|
291
295
|
}
|
|
292
296
|
|
|
293
|
-
// Message is queued - don't broadcast as message_created, let queue_status handle the UI update
|
|
294
297
|
const qp = enqueue(p.id, prompt, agentId, model, userMessage.id, subAgent);
|
|
295
298
|
const seq = getNextQueueSeq(p.id);
|
|
296
299
|
broadcastSync({
|
|
297
300
|
type: 'queue_status',
|
|
298
301
|
conversationId: p.id,
|
|
299
|
-
queueLength:
|
|
302
|
+
queueLength: execMachine.getQueue(p.id).length,
|
|
300
303
|
seq,
|
|
301
304
|
timestamp: Date.now()
|
|
302
305
|
});
|
|
@@ -305,21 +308,34 @@ export function register(router, deps) {
|
|
|
305
308
|
|
|
306
309
|
router.handle('q.ls', (p) => {
|
|
307
310
|
if (!queries.getConversation(p.id)) notFound('Conversation not found');
|
|
308
|
-
|
|
311
|
+
// Read queue from machine context (authoritative), fall back to Map for compatibility
|
|
312
|
+
const machineQueue = execMachine.getQueue(p.id);
|
|
313
|
+
return { queue: machineQueue.length > 0 ? machineQueue : (messageQueues.get(p.id) || []) };
|
|
309
314
|
});
|
|
310
315
|
|
|
311
316
|
router.handle('q.del', (p) => {
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (
|
|
317
|
+
const machineQueue = execMachine.getQueue(p.id);
|
|
318
|
+
const mapQueue = messageQueues.get(p.id);
|
|
319
|
+
if (!machineQueue.length && !mapQueue) notFound('Queue not found');
|
|
320
|
+
// Remove from both machine and Map
|
|
321
|
+
const idx = machineQueue.findIndex(q => q.messageId === p.messageId);
|
|
322
|
+
if (idx === -1 && (!mapQueue || mapQueue.findIndex(q => q.messageId === p.messageId) === -1)) notFound('Queued message not found');
|
|
323
|
+
// Update machine queue via direct send
|
|
324
|
+
if (idx !== -1) {
|
|
325
|
+
const newQueue = [...machineQueue];
|
|
326
|
+
newQueue.splice(idx, 1);
|
|
327
|
+
execMachine.send(p.id, { type: 'SET_QUEUE', queue: newQueue });
|
|
328
|
+
}
|
|
329
|
+
if (mapQueue) {
|
|
330
|
+
const mi = mapQueue.findIndex(q => q.messageId === p.messageId);
|
|
331
|
+
if (mi !== -1) mapQueue.splice(mi, 1);
|
|
332
|
+
if (mapQueue.length === 0) messageQueues.delete(p.id);
|
|
333
|
+
}
|
|
318
334
|
const seq = getNextQueueSeq(p.id);
|
|
319
335
|
broadcastSync({
|
|
320
336
|
type: 'queue_status',
|
|
321
337
|
conversationId: p.id,
|
|
322
|
-
queueLength:
|
|
338
|
+
queueLength: execMachine.getQueue(p.id).length,
|
|
323
339
|
seq,
|
|
324
340
|
timestamp: Date.now()
|
|
325
341
|
});
|
|
@@ -327,9 +343,10 @@ export function register(router, deps) {
|
|
|
327
343
|
});
|
|
328
344
|
|
|
329
345
|
router.handle('q.upd', (p) => {
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
346
|
+
const machineQueue = execMachine.getQueue(p.id);
|
|
347
|
+
const mapQueue = messageQueues.get(p.id);
|
|
348
|
+
if (!machineQueue.length && !mapQueue) notFound('Queue not found');
|
|
349
|
+
const item = machineQueue.find(q => q.messageId === p.messageId) || mapQueue?.find(q => q.messageId === p.messageId);
|
|
333
350
|
if (!item) notFound('Queued message not found');
|
|
334
351
|
if (p.content !== undefined) item.content = p.content;
|
|
335
352
|
if (p.agentId !== undefined) item.agentId = p.agentId;
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -319,18 +319,19 @@ function logError(op, err, ctx = {}) {
|
|
|
319
319
|
} catch (_) {}
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
-
// Atomic cleanup function -
|
|
322
|
+
// Atomic cleanup function - machine is authoritative, Map stays in sync
|
|
323
323
|
function cleanupExecution(conversationId, broadcastCompletion = false) {
|
|
324
324
|
debugLog(`[cleanup] Starting cleanup for ${conversationId}`);
|
|
325
325
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
// Sync execution machine to idle state
|
|
326
|
+
// Machine drives cleanup: send CANCEL (clears queue, transitions to idle)
|
|
329
327
|
const machineSnap = execMachine.snapshot(conversationId);
|
|
330
328
|
if (machineSnap && machineSnap.value !== 'idle') {
|
|
331
329
|
execMachine.send(conversationId, { type: 'CANCEL' });
|
|
332
330
|
}
|
|
333
331
|
|
|
332
|
+
// Keep Map in sync with machine
|
|
333
|
+
activeExecutions.delete(conversationId);
|
|
334
|
+
|
|
334
335
|
// Clean database state
|
|
335
336
|
queries.setIsStreaming(conversationId, false);
|
|
336
337
|
|
|
@@ -3304,11 +3305,9 @@ function serveFile(filePath, res, req) {
|
|
|
3304
3305
|
res.end();
|
|
3305
3306
|
return;
|
|
3306
3307
|
}
|
|
3307
|
-
|
|
3308
|
-
//
|
|
3309
|
-
const cacheControl =
|
|
3310
|
-
? 'public, max-age=31536000, immutable'
|
|
3311
|
-
: 'public, max-age=3600, must-revalidate';
|
|
3308
|
+
// Use ETag-based revalidation: browser always checks with server, serves from cache on 304
|
|
3309
|
+
// Avoids stale immutable assets when server files change during development
|
|
3310
|
+
const cacheControl = 'public, no-cache';
|
|
3312
3311
|
|
|
3313
3312
|
const sendCached = (cached) => {
|
|
3314
3313
|
if (acceptsEncoding(req, 'gzip') && cached.gz) {
|
|
@@ -3774,10 +3773,10 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
|
|
|
3774
3773
|
batcher.drain();
|
|
3775
3774
|
debugLog(`[stream] Claude returned ${outputs.length} outputs, sessionId=${claudeSessionId}`);
|
|
3776
3775
|
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3776
|
+
// Clear claudeSessionId after successful completion so next message starts fresh
|
|
3777
|
+
// --resume on a completed session causes "No response requested." from the gm plugin
|
|
3778
|
+
queries.setClaudeSessionId(conversationId, null);
|
|
3779
|
+
debugLog(`[stream] Cleared claudeSessionId after successful completion`);
|
|
3781
3780
|
|
|
3782
3781
|
// Mark session as complete
|
|
3783
3782
|
queries.updateSession(sessionId, {
|
|
@@ -3983,20 +3982,36 @@ function scheduleRetry(conversationId, messageId, content, agentId, model, subAg
|
|
|
3983
3982
|
}
|
|
3984
3983
|
|
|
3985
3984
|
function drainMessageQueue(conversationId) {
|
|
3986
|
-
|
|
3987
|
-
|
|
3985
|
+
// Machine context queue is authoritative; fall back to legacy Map
|
|
3986
|
+
const machineQueue = execMachine.getQueue(conversationId);
|
|
3987
|
+
const mapQueue = messageQueues.get(conversationId);
|
|
3988
|
+
if (machineQueue.length === 0 && (!mapQueue || mapQueue.length === 0)) return;
|
|
3989
|
+
|
|
3990
|
+
let next;
|
|
3991
|
+
if (machineQueue.length > 0) {
|
|
3992
|
+
// Consume from machine via COMPLETE transition (draining state pops nextItem)
|
|
3993
|
+
execMachine.send(conversationId, { type: 'COMPLETE' });
|
|
3994
|
+
const ctx = execMachine.getContext(conversationId);
|
|
3995
|
+
next = ctx?.nextItem;
|
|
3996
|
+
// Also keep Map in sync
|
|
3997
|
+
if (mapQueue && mapQueue.length > 0) mapQueue.shift();
|
|
3998
|
+
if (mapQueue && mapQueue.length === 0) messageQueues.delete(conversationId);
|
|
3999
|
+
} else {
|
|
4000
|
+
next = mapQueue.shift();
|
|
4001
|
+
if (mapQueue.length === 0) messageQueues.delete(conversationId);
|
|
4002
|
+
}
|
|
3988
4003
|
|
|
3989
|
-
|
|
3990
|
-
if (queue.length === 0) messageQueues.delete(conversationId);
|
|
4004
|
+
if (!next) return;
|
|
3991
4005
|
|
|
3992
4006
|
debugLog(`[queue] Draining next message for ${conversationId}, messageId=${next.messageId}`);
|
|
3993
4007
|
|
|
3994
|
-
|
|
4008
|
+
const remainingQueueLength = execMachine.getQueue(conversationId).length || messageQueues.get(conversationId)?.length || 0;
|
|
4009
|
+
|
|
3995
4010
|
broadcastSync({
|
|
3996
4011
|
type: 'queue_item_dequeued',
|
|
3997
4012
|
conversationId,
|
|
3998
4013
|
messageId: next.messageId,
|
|
3999
|
-
queueLength:
|
|
4014
|
+
queueLength: remainingQueueLength,
|
|
4000
4015
|
timestamp: Date.now()
|
|
4001
4016
|
});
|
|
4002
4017
|
|
|
@@ -4009,24 +4024,25 @@ function drainMessageQueue(conversationId) {
|
|
|
4009
4024
|
conversationId,
|
|
4010
4025
|
messageId: next.messageId,
|
|
4011
4026
|
agentId: next.agentId,
|
|
4012
|
-
queueLength:
|
|
4027
|
+
queueLength: remainingQueueLength,
|
|
4013
4028
|
timestamp: Date.now()
|
|
4014
4029
|
});
|
|
4015
4030
|
|
|
4016
4031
|
broadcastSync({
|
|
4017
4032
|
type: 'queue_status',
|
|
4018
4033
|
conversationId,
|
|
4019
|
-
queueLength:
|
|
4034
|
+
queueLength: remainingQueueLength,
|
|
4020
4035
|
timestamp: Date.now()
|
|
4021
4036
|
});
|
|
4022
4037
|
|
|
4023
4038
|
const startTime = Date.now();
|
|
4039
|
+
// Machine START event makes machine authoritative for this execution
|
|
4040
|
+
execMachine.send(conversationId, { type: 'START', sessionId: session.id });
|
|
4024
4041
|
activeExecutions.set(conversationId, { pid: null, startTime, sessionId: session.id, lastActivity: startTime });
|
|
4025
4042
|
|
|
4026
4043
|
processMessageWithStreaming(conversationId, next.messageId, session.id, next.content, next.agentId, next.model, next.subAgent)
|
|
4027
4044
|
.catch(err => {
|
|
4028
4045
|
debugLog(`[queue] Error processing queued message: ${err.message}`);
|
|
4029
|
-
// CRITICAL: Clean up state on error so next message can be retried
|
|
4030
4046
|
cleanupExecution(conversationId);
|
|
4031
4047
|
broadcastSync({
|
|
4032
4048
|
type: 'streaming_error',
|
|
@@ -4036,7 +4052,6 @@ function drainMessageQueue(conversationId) {
|
|
|
4036
4052
|
recoverable: true,
|
|
4037
4053
|
timestamp: Date.now()
|
|
4038
4054
|
});
|
|
4039
|
-
// Try to drain next message in queue
|
|
4040
4055
|
setTimeout(() => drainMessageQueue(conversationId), 100);
|
|
4041
4056
|
});
|
|
4042
4057
|
}
|
|
@@ -4202,16 +4217,19 @@ wsRouter.onLegacy((data, ws) => {
|
|
|
4202
4217
|
}));
|
|
4203
4218
|
|
|
4204
4219
|
// Notify client if this conversation has an active streaming execution
|
|
4205
|
-
|
|
4220
|
+
// Machine is authoritative for streaming state check on subscribe
|
|
4221
|
+
if (data.conversationId && execMachine.isActive(data.conversationId)) {
|
|
4222
|
+
const ctx = execMachine.getContext(data.conversationId);
|
|
4206
4223
|
const execution = activeExecutions.get(data.conversationId);
|
|
4224
|
+
const sessionId = ctx?.sessionId || execution?.sessionId;
|
|
4207
4225
|
const conv = queries.getConversation(data.conversationId);
|
|
4208
|
-
const
|
|
4226
|
+
const queueLength = execMachine.getQueue(data.conversationId).length || messageQueues.get(data.conversationId)?.length || 0;
|
|
4209
4227
|
sendWs(ws, ({
|
|
4210
4228
|
type: 'streaming_start',
|
|
4211
|
-
sessionId
|
|
4229
|
+
sessionId,
|
|
4212
4230
|
conversationId: data.conversationId,
|
|
4213
4231
|
agentId: conv?.agentType || conv?.agentId || 'claude-code',
|
|
4214
|
-
queueLength
|
|
4232
|
+
queueLength,
|
|
4215
4233
|
resumed: true,
|
|
4216
4234
|
seq: ++broadcastSeq,
|
|
4217
4235
|
timestamp: Date.now()
|
package/static/js/client.js
CHANGED
|
@@ -228,7 +228,7 @@ class AgentGUIClient {
|
|
|
228
228
|
// Save draft from previous conversation before switching
|
|
229
229
|
this.saveDraftPrompt();
|
|
230
230
|
|
|
231
|
-
const isStreaming =
|
|
231
|
+
const isStreaming = this._convIsStreaming(convId);
|
|
232
232
|
if (!isStreaming && window.switchView) {
|
|
233
233
|
window.switchView('chat');
|
|
234
234
|
}
|
|
@@ -242,7 +242,7 @@ class AgentGUIClient {
|
|
|
242
242
|
const view = e.detail.view;
|
|
243
243
|
if (view === 'chat') {
|
|
244
244
|
const convId = this.state.currentConversation?.id;
|
|
245
|
-
const isStreaming =
|
|
245
|
+
const isStreaming = this._convIsStreaming(convId);
|
|
246
246
|
if (isStreaming) {
|
|
247
247
|
this.disableControls();
|
|
248
248
|
} else {
|
|
@@ -252,6 +252,25 @@ class AgentGUIClient {
|
|
|
252
252
|
});
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
// Authoritative streaming check: conv machine is source of truth, Map is fallback cache
|
|
256
|
+
_convIsStreaming(convId) {
|
|
257
|
+
if (!convId) return false;
|
|
258
|
+
if (typeof convMachineAPI !== 'undefined') return convMachineAPI.isStreaming(convId);
|
|
259
|
+
return this.state.streamingConversations.has(convId);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Mark conversation as streaming in both machine and cache Map
|
|
263
|
+
_setConvStreaming(convId, streaming, sessionId, agentId) {
|
|
264
|
+
if (!convId) return;
|
|
265
|
+
if (streaming) {
|
|
266
|
+
this.state.streamingConversations.set(convId, true);
|
|
267
|
+
if (typeof convMachineAPI !== 'undefined') convMachineAPI.send(convId, { type: 'STREAM_START', sessionId, agentId });
|
|
268
|
+
} else {
|
|
269
|
+
this.state.streamingConversations.delete(convId);
|
|
270
|
+
if (typeof convMachineAPI !== 'undefined') convMachineAPI.send(convId, { type: 'COMPLETE' });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
255
274
|
/**
|
|
256
275
|
* Setup renderer event listeners
|
|
257
276
|
*/
|
|
@@ -511,7 +530,7 @@ class AgentGUIClient {
|
|
|
511
530
|
if (this.ui.injectButton) {
|
|
512
531
|
this.ui.injectButton.addEventListener('click', async () => {
|
|
513
532
|
if (!this.state.currentConversation) return;
|
|
514
|
-
const isStreaming = this.
|
|
533
|
+
const isStreaming = this._convIsStreaming(this.state.currentConversation.id);
|
|
515
534
|
|
|
516
535
|
if (isStreaming) {
|
|
517
536
|
const message = this.ui.messageInput?.value || '';
|
|
@@ -810,8 +829,7 @@ class AgentGUIClient {
|
|
|
810
829
|
// just track the state but do not modify the DOM or start polling
|
|
811
830
|
if (this.state.currentConversation?.id !== data.conversationId) {
|
|
812
831
|
console.log('Streaming started for non-active conversation:', data.conversationId);
|
|
813
|
-
this.
|
|
814
|
-
if (typeof convMachineAPI !== 'undefined') convMachineAPI.send(data.conversationId, { type: 'STREAM_START', sessionId: data.sessionId, agentId: data.agentId });
|
|
832
|
+
this._setConvStreaming(data.conversationId, true, data.sessionId, data.agentId);
|
|
815
833
|
console.log('[SYNC] streaming_start - non-active conv:', { convId: data.conversationId, sessionId: data.sessionId, streamingCount: this.state.streamingConversations.size });
|
|
816
834
|
this.updateBusyPromptArea(data.conversationId);
|
|
817
835
|
this.emit('streaming:start', data);
|
|
@@ -826,8 +844,7 @@ class AgentGUIClient {
|
|
|
826
844
|
return;
|
|
827
845
|
}
|
|
828
846
|
|
|
829
|
-
this.
|
|
830
|
-
if (typeof convMachineAPI !== 'undefined') convMachineAPI.send(data.conversationId, { type: 'STREAM_START', sessionId: data.sessionId, agentId: data.agentId });
|
|
847
|
+
this._setConvStreaming(data.conversationId, true, data.sessionId, data.agentId);
|
|
831
848
|
this.updateBusyPromptArea(data.conversationId);
|
|
832
849
|
this.state.currentSession = {
|
|
833
850
|
id: data.sessionId,
|
|
@@ -1125,13 +1142,13 @@ class AgentGUIClient {
|
|
|
1125
1142
|
// If this event is for a conversation we are NOT currently viewing, just track state
|
|
1126
1143
|
if (conversationId && this.state.currentConversation?.id !== conversationId) {
|
|
1127
1144
|
console.log('Streaming error for non-active conversation:', conversationId);
|
|
1128
|
-
this.
|
|
1145
|
+
this._setConvStreaming(conversationId, false);
|
|
1129
1146
|
this.updateBusyPromptArea(conversationId);
|
|
1130
1147
|
this.emit('streaming:error', data);
|
|
1131
1148
|
return;
|
|
1132
1149
|
}
|
|
1133
1150
|
|
|
1134
|
-
this.
|
|
1151
|
+
this._setConvStreaming(conversationId, false);
|
|
1135
1152
|
this.updateBusyPromptArea(conversationId);
|
|
1136
1153
|
|
|
1137
1154
|
// Clear queue indicator on error
|
|
@@ -1197,16 +1214,14 @@ class AgentGUIClient {
|
|
|
1197
1214
|
|
|
1198
1215
|
if (conversationId && this.state.currentConversation?.id !== conversationId) {
|
|
1199
1216
|
console.log('Streaming completed for non-active conversation:', conversationId);
|
|
1200
|
-
this.
|
|
1201
|
-
if (typeof convMachineAPI !== 'undefined') convMachineAPI.send(conversationId, { type: 'COMPLETE' });
|
|
1217
|
+
this._setConvStreaming(conversationId, false);
|
|
1202
1218
|
console.log('[SYNC] streaming_complete - non-active conv:', { convId: conversationId, streamingCount: this.state.streamingConversations.size });
|
|
1203
1219
|
this.updateBusyPromptArea(conversationId);
|
|
1204
1220
|
this.emit('streaming:complete', data);
|
|
1205
1221
|
return;
|
|
1206
1222
|
}
|
|
1207
1223
|
|
|
1208
|
-
this.
|
|
1209
|
-
if (typeof convMachineAPI !== 'undefined') convMachineAPI.send(conversationId, { type: 'COMPLETE' });
|
|
1224
|
+
this._setConvStreaming(conversationId, false);
|
|
1210
1225
|
console.log('[SYNC] streaming_complete - active conv:', { convId: conversationId, streamingCount: this.state.streamingConversations.size, interrupted: data.interrupted });
|
|
1211
1226
|
this.updateBusyPromptArea(conversationId);
|
|
1212
1227
|
|
|
@@ -1302,7 +1317,7 @@ class AgentGUIClient {
|
|
|
1302
1317
|
this.state.currentConversation.messageCount = (this.state.currentConversation.messageCount || 0) + 1;
|
|
1303
1318
|
}
|
|
1304
1319
|
|
|
1305
|
-
if (data.message.role === 'assistant' && this.
|
|
1320
|
+
if (data.message.role === 'assistant' && this._convIsStreaming(data.conversationId)) {
|
|
1306
1321
|
this.emit('message:created', data);
|
|
1307
1322
|
return;
|
|
1308
1323
|
}
|
|
@@ -1442,7 +1457,7 @@ class AgentGUIClient {
|
|
|
1442
1457
|
|
|
1443
1458
|
handleRateLimitHit(data) {
|
|
1444
1459
|
if (data.conversationId !== this.state.currentConversation?.id) return;
|
|
1445
|
-
this.
|
|
1460
|
+
this._setConvStreaming(data.conversationId, false);
|
|
1446
1461
|
|
|
1447
1462
|
this.enableControls();
|
|
1448
1463
|
|
|
@@ -1683,8 +1698,8 @@ class AgentGUIClient {
|
|
|
1683
1698
|
|
|
1684
1699
|
const pendingId = 'pending-' + Date.now() + '-' + Math.random().toString(36).substr(2, 6);
|
|
1685
1700
|
|
|
1686
|
-
//
|
|
1687
|
-
const isStreaming = this.
|
|
1701
|
+
// Conv machine is authoritative: check machine state for optimistic message gating
|
|
1702
|
+
const isStreaming = this._convIsStreaming(this.state.currentConversation?.id);
|
|
1688
1703
|
if (!isStreaming) {
|
|
1689
1704
|
this._showOptimisticMessage(pendingId, savedPrompt);
|
|
1690
1705
|
}
|
|
@@ -1705,7 +1720,7 @@ class AgentGUIClient {
|
|
|
1705
1720
|
}
|
|
1706
1721
|
|
|
1707
1722
|
if (conv?.id) {
|
|
1708
|
-
const isNewConversation = !conv.messageCount && !this.
|
|
1723
|
+
const isNewConversation = !conv.messageCount && !this._convIsStreaming(conv.id);
|
|
1709
1724
|
const agentId = (isNewConversation ? this.getCurrentAgent() : null) || conv?.agentType || this.getCurrentAgent();
|
|
1710
1725
|
const subAgent = this.getEffectiveSubAgent() || conv?.subAgent || null;
|
|
1711
1726
|
const model = this.ui.modelSelector?.value || null;
|
|
@@ -1941,7 +1956,7 @@ class AgentGUIClient {
|
|
|
1941
1956
|
// Sync-to-display debugging
|
|
1942
1957
|
getSyncState: () => ({
|
|
1943
1958
|
currentConversation: self.state.currentConversation,
|
|
1944
|
-
isStreaming: self.
|
|
1959
|
+
isStreaming: self._convIsStreaming(self.state.currentConversation?.id),
|
|
1945
1960
|
streamingConversations: Array.from(self.state.streamingConversations),
|
|
1946
1961
|
convMachineStates: typeof convMachineAPI !== 'undefined' ? Object.fromEntries([...window.__convMachines].map(([k, a]) => [k, a.getSnapshot().value])) : {},
|
|
1947
1962
|
wsConnectionState: self.wsManager?._wsActor?.getSnapshot().value || 'unknown',
|
|
@@ -2561,7 +2576,7 @@ class AgentGUIClient {
|
|
|
2561
2576
|
if (!convId) return;
|
|
2562
2577
|
const outputEl = document.getElementById('output');
|
|
2563
2578
|
if (!outputEl || !outputEl.firstChild) return;
|
|
2564
|
-
if (this.
|
|
2579
|
+
if (this._convIsStreaming(convId)) return;
|
|
2565
2580
|
|
|
2566
2581
|
this.saveScrollPosition(convId);
|
|
2567
2582
|
const clone = outputEl.cloneNode(true);
|
|
@@ -2661,7 +2676,7 @@ class AgentGUIClient {
|
|
|
2661
2676
|
|
|
2662
2677
|
var prevId = this.state.currentConversation?.id;
|
|
2663
2678
|
if (prevId && prevId !== conversationId) {
|
|
2664
|
-
if (this.wsManager.isConnected && !this.
|
|
2679
|
+
if (this.wsManager.isConnected && !this._convIsStreaming(prevId)) {
|
|
2665
2680
|
this.wsManager.sendMessage({ type: 'unsubscribe', conversationId: prevId });
|
|
2666
2681
|
}
|
|
2667
2682
|
this.state.currentSession = null;
|
|
@@ -2689,7 +2704,7 @@ class AgentGUIClient {
|
|
|
2689
2704
|
window.ConversationState?.selectConversation(conversationId, 'dom_cache_load', 1);
|
|
2690
2705
|
this.state.currentConversation = cached.conversation;
|
|
2691
2706
|
window.dispatchEvent(new CustomEvent('conversation-changed', { detail: { conversationId, conversation: cached.conversation } }));
|
|
2692
|
-
const cachedHasActivity = cached.conversation.messageCount > 0 || this.
|
|
2707
|
+
const cachedHasActivity = cached.conversation.messageCount > 0 || this._convIsStreaming(conversationId);
|
|
2693
2708
|
this.applyAgentAndModelSelection(cached.conversation, cachedHasActivity);
|
|
2694
2709
|
this.conversationCache.delete(conversationId);
|
|
2695
2710
|
this.syncPromptState(conversationId);
|
|
@@ -2733,7 +2748,7 @@ class AgentGUIClient {
|
|
|
2733
2748
|
window.ConversationState?.selectConversation(conversationId, 'server_load', 1);
|
|
2734
2749
|
this.state.currentConversation = conversation;
|
|
2735
2750
|
window.dispatchEvent(new CustomEvent('conversation-changed', { detail: { conversationId, conversation } }));
|
|
2736
|
-
const hasActivity = (allMessages && allMessages.length > 0) || isActivelyStreaming || latestSession || this.
|
|
2751
|
+
const hasActivity = (allMessages && allMessages.length > 0) || isActivelyStreaming || latestSession || this._convIsStreaming(conversationId);
|
|
2737
2752
|
this.applyAgentAndModelSelection(conversation, hasActivity);
|
|
2738
2753
|
|
|
2739
2754
|
const chunks = (rawChunks || []).map(chunk => ({
|
|
@@ -2756,15 +2771,15 @@ class AgentGUIClient {
|
|
|
2756
2771
|
const userMessages = (allMessages || []).filter(m => m.role === 'user' && !queuedMessageIds.has(m.id));
|
|
2757
2772
|
const hasMoreChunks = totalChunks && chunks.length < totalChunks;
|
|
2758
2773
|
|
|
2759
|
-
const clientKnowsStreaming = this.
|
|
2774
|
+
const clientKnowsStreaming = this._convIsStreaming(conversationId);
|
|
2760
2775
|
const shouldResumeStreaming = latestSession &&
|
|
2761
2776
|
(latestSession.status === 'active' || latestSession.status === 'pending');
|
|
2762
2777
|
|
|
2763
2778
|
if (shouldResumeStreaming) {
|
|
2764
|
-
this.
|
|
2779
|
+
this._setConvStreaming(conversationId, true, latestSession?.id, null);
|
|
2765
2780
|
window.dispatchEvent(new CustomEvent('ws-message', { detail: { type: 'streaming_start', conversationId, sessionId: latestSession?.id } }));
|
|
2766
2781
|
} else {
|
|
2767
|
-
this.
|
|
2782
|
+
this._setConvStreaming(conversationId, false);
|
|
2768
2783
|
window.dispatchEvent(new CustomEvent('ws-message', { detail: { type: 'streaming_complete', conversationId } }));
|
|
2769
2784
|
}
|
|
2770
2785
|
|
|
@@ -2940,7 +2955,7 @@ class AgentGUIClient {
|
|
|
2940
2955
|
}
|
|
2941
2956
|
|
|
2942
2957
|
if (shouldResumeStreaming && latestSession) {
|
|
2943
|
-
this.
|
|
2958
|
+
this._setConvStreaming(conversationId, true, latestSession.id, null);
|
|
2944
2959
|
this.state.currentSession = {
|
|
2945
2960
|
id: latestSession.id,
|
|
2946
2961
|
conversationId: conversationId,
|
|
@@ -3009,7 +3024,7 @@ class AgentGUIClient {
|
|
|
3009
3024
|
|
|
3010
3025
|
updateBusyPromptArea(conversationId) {
|
|
3011
3026
|
if (this.state.currentConversation?.id !== conversationId) return;
|
|
3012
|
-
const isStreaming = this.
|
|
3027
|
+
const isStreaming = this._convIsStreaming(conversationId);
|
|
3013
3028
|
const isConnected = this.wsManager?.isConnected;
|
|
3014
3029
|
|
|
3015
3030
|
const injectBtn = document.getElementById('injectBtn');
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Per-conversation UI state machine using XState v5
|
|
2
2
|
// Tracks IDLE -> STREAMING -> QUEUED states per conversation
|
|
3
3
|
|
|
4
|
-
const { createMachine, createActor, assign } = XState;
|
|
4
|
+
const { createMachine: cmCreateMachine, createActor: cmCreateActor, assign: cmAssign } = XState;
|
|
5
5
|
|
|
6
|
-
const convMachine =
|
|
6
|
+
const convMachine = cmCreateMachine({
|
|
7
7
|
id: 'conv-ui',
|
|
8
8
|
initial: 'idle',
|
|
9
9
|
context: {
|
|
@@ -13,11 +13,11 @@ const convMachine = createMachine({
|
|
|
13
13
|
},
|
|
14
14
|
states: {
|
|
15
15
|
idle: {
|
|
16
|
-
entry:
|
|
16
|
+
entry: cmAssign({ sessionId: null, agentId: null, queueLength: 0 }),
|
|
17
17
|
on: {
|
|
18
18
|
STREAM_START: {
|
|
19
19
|
target: 'streaming',
|
|
20
|
-
actions:
|
|
20
|
+
actions: cmAssign(({ event }) => ({
|
|
21
21
|
sessionId: event.sessionId || null,
|
|
22
22
|
agentId: event.agentId || null,
|
|
23
23
|
})),
|
|
@@ -35,10 +35,10 @@ const convMachine = createMachine({
|
|
|
35
35
|
],
|
|
36
36
|
ERROR: 'idle',
|
|
37
37
|
QUEUE_UPDATE: {
|
|
38
|
-
actions:
|
|
38
|
+
actions: cmAssign(({ event }) => ({ queueLength: event.queueLength || 0 })),
|
|
39
39
|
},
|
|
40
40
|
STREAM_START: {
|
|
41
|
-
actions:
|
|
41
|
+
actions: cmAssign(({ event }) => ({
|
|
42
42
|
sessionId: event.sessionId || null,
|
|
43
43
|
agentId: event.agentId || null,
|
|
44
44
|
})),
|
|
@@ -49,13 +49,13 @@ const convMachine = createMachine({
|
|
|
49
49
|
on: {
|
|
50
50
|
STREAM_START: {
|
|
51
51
|
target: 'streaming',
|
|
52
|
-
actions:
|
|
52
|
+
actions: cmAssign(({ event }) => ({
|
|
53
53
|
sessionId: event.sessionId || null,
|
|
54
54
|
agentId: event.agentId || null,
|
|
55
55
|
})),
|
|
56
56
|
},
|
|
57
57
|
QUEUE_UPDATE: {
|
|
58
|
-
actions:
|
|
58
|
+
actions: cmAssign(({ event }) => ({ queueLength: event.queueLength || 0 })),
|
|
59
59
|
guard: ({ event }) => event.queueLength > 0,
|
|
60
60
|
},
|
|
61
61
|
QUEUE_EMPTY: 'idle',
|
|
@@ -70,7 +70,7 @@ const convMachines = new Map();
|
|
|
70
70
|
|
|
71
71
|
function getOrCreate(convId) {
|
|
72
72
|
if (convMachines.has(convId)) return convMachines.get(convId);
|
|
73
|
-
const actor =
|
|
73
|
+
const actor = cmCreateActor(convMachine);
|
|
74
74
|
actor.start();
|
|
75
75
|
convMachines.set(convId, actor);
|
|
76
76
|
return actor;
|
|
@@ -91,6 +91,17 @@ function isStreaming(convId) {
|
|
|
91
91
|
return getState(convId) === 'streaming';
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// isActive: streaming OR queued — use this to gate optimistic messages
|
|
95
|
+
function isActive(convId) {
|
|
96
|
+
const s = getState(convId);
|
|
97
|
+
return s === 'streaming' || s === 'queued';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getQueueLength(convId) {
|
|
101
|
+
const actor = convMachines.get(convId);
|
|
102
|
+
return actor ? actor.getSnapshot().context.queueLength : 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
94
105
|
function remove(convId) {
|
|
95
106
|
const actor = convMachines.get(convId);
|
|
96
107
|
if (actor) { actor.stop(); convMachines.delete(convId); }
|
|
@@ -98,5 +109,5 @@ function remove(convId) {
|
|
|
98
109
|
|
|
99
110
|
if (typeof window !== 'undefined') {
|
|
100
111
|
window.__convMachines = convMachines;
|
|
101
|
-
window.convMachineAPI = { getOrCreate, send, getState, isStreaming, remove };
|
|
112
|
+
window.convMachineAPI = { getOrCreate, send, getState, isStreaming, isActive, getQueueLength, remove };
|
|
102
113
|
}
|
|
@@ -18,6 +18,8 @@ class WebSocketManager {
|
|
|
18
18
|
this.ws = null;
|
|
19
19
|
this._isConnected = false;
|
|
20
20
|
this._isConnecting = false;
|
|
21
|
+
this._isManuallyDisconnected = false;
|
|
22
|
+
this._reconnectCount = 0;
|
|
21
23
|
this.isManuallyDisconnected = false;
|
|
22
24
|
this.reconnectCount = 0;
|
|
23
25
|
this.reconnectTimer = null;
|
|
@@ -92,6 +94,22 @@ class WebSocketManager {
|
|
|
92
94
|
}
|
|
93
95
|
set connectionState(v) { this._connectionState = v; }
|
|
94
96
|
|
|
97
|
+
// Machine context is authoritative for these flags
|
|
98
|
+
get isManuallyDisconnected() {
|
|
99
|
+
if (this._wsActor) return !!this._wsActor.getSnapshot().context.manualDisconnect;
|
|
100
|
+
return this._isManuallyDisconnected;
|
|
101
|
+
}
|
|
102
|
+
set isManuallyDisconnected(v) {
|
|
103
|
+
this._isManuallyDisconnected = v;
|
|
104
|
+
if (this._wsActor && v) this._wsActor.send({ type: 'MANUAL_DISCONNECT' });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
get reconnectCount() {
|
|
108
|
+
if (this._wsActor) return this._wsActor.getSnapshot().context.reconnectCount;
|
|
109
|
+
return this._reconnectCount;
|
|
110
|
+
}
|
|
111
|
+
set reconnectCount(v) { this._reconnectCount = v; }
|
|
112
|
+
|
|
95
113
|
getWebSocketURL() {
|
|
96
114
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
97
115
|
const baseURL = window.__BASE_URL || '/gm';
|
|
@@ -555,7 +573,6 @@ class WebSocketManager {
|
|
|
555
573
|
|
|
556
574
|
disconnect() {
|
|
557
575
|
this.isManuallyDisconnected = true;
|
|
558
|
-
if (this._wsActor) this._wsActor.send({ type: 'MANUAL_DISCONNECT' });
|
|
559
576
|
this.reconnectCount = 0;
|
|
560
577
|
this.stopHeartbeat();
|
|
561
578
|
if (this.reconnectTimer) {
|