agentgui 1.0.632 → 1.0.634
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/claude-runner.js +15 -7
- package/lib/ws-optimizer.js +19 -25
- package/lib/ws-protocol.js +17 -14
- package/package.json +2 -1
- package/static/index.html +1 -1
- package/static/js/client.js +36 -370
- package/static/js/websocket-manager.js +31 -24
- package/static/js/ws-client.js +14 -29
- package/static/js/kalman-filter.js +0 -67
package/lib/claude-runner.js
CHANGED
|
@@ -49,6 +49,7 @@ class AgentRunner {
|
|
|
49
49
|
this.buildArgs = config.buildArgs || this.defaultBuildArgs;
|
|
50
50
|
this.parseOutput = config.parseOutput || this.defaultParseOutput;
|
|
51
51
|
this.supportsStdin = config.supportsStdin ?? true;
|
|
52
|
+
this.closeStdin = config.closeStdin ?? false; // close stdin so process doesn't block waiting for input
|
|
52
53
|
this.supportedFeatures = config.supportedFeatures || [];
|
|
53
54
|
this.protocolHandler = config.protocolHandler || null;
|
|
54
55
|
this.requiresAdapter = config.requiresAdapter || false;
|
|
@@ -91,7 +92,11 @@ class AgentRunner {
|
|
|
91
92
|
if (Object.keys(this.spawnEnv).length > 0) {
|
|
92
93
|
spawnOpts.env = { ...spawnOpts.env, ...this.spawnEnv };
|
|
93
94
|
}
|
|
95
|
+
if (this.closeStdin) {
|
|
96
|
+
spawnOpts.stdio = ['ignore', 'pipe', 'pipe'];
|
|
97
|
+
}
|
|
94
98
|
const proc = spawn(this.command, args, spawnOpts);
|
|
99
|
+
console.log(`[${this.id}] Spawned PID ${proc.pid} closeStdin=${this.closeStdin}`);
|
|
95
100
|
|
|
96
101
|
if (config.onPid) {
|
|
97
102
|
try { config.onPid(proc.pid); } catch (e) {}
|
|
@@ -116,15 +121,14 @@ class AgentRunner {
|
|
|
116
121
|
reject(new Error(`${this.name} timeout after ${timeout}ms`));
|
|
117
122
|
}, timeout);
|
|
118
123
|
|
|
119
|
-
// Write to stdin if
|
|
120
|
-
// Don't close stdin - keep it open for steering/injection during execution
|
|
124
|
+
// Write prompt to stdin if agent uses stdin protocol (not positional args)
|
|
121
125
|
if (this.supportsStdin) {
|
|
122
126
|
proc.stdin.write(prompt);
|
|
123
|
-
// Don't call stdin.end() - agents need open stdin for
|
|
127
|
+
// Don't call stdin.end() - agents need open stdin for steering
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
proc.stdout.on('error', () => {});
|
|
127
|
-
proc.stderr.on('error', () => {});
|
|
131
|
+
if (proc.stderr) proc.stderr.on('error', () => {});
|
|
128
132
|
proc.stdout.on('data', (chunk) => {
|
|
129
133
|
if (timedOut) return;
|
|
130
134
|
|
|
@@ -152,7 +156,7 @@ class AgentRunner {
|
|
|
152
156
|
}
|
|
153
157
|
});
|
|
154
158
|
|
|
155
|
-
proc.stderr.on('data', (chunk) => {
|
|
159
|
+
if (proc.stderr) proc.stderr.on('data', (chunk) => {
|
|
156
160
|
const errorText = chunk.toString();
|
|
157
161
|
console.error(`[${this.id}] stderr:`, errorText);
|
|
158
162
|
|
|
@@ -605,9 +609,11 @@ registry.register({
|
|
|
605
609
|
name: 'Claude Code',
|
|
606
610
|
command: 'claude',
|
|
607
611
|
protocol: 'direct',
|
|
608
|
-
supportsStdin:
|
|
612
|
+
supportsStdin: false,
|
|
613
|
+
closeStdin: true, // must close stdin or claude 2.1.72 hangs waiting for input in --print mode
|
|
614
|
+
useJsonRpcStdin: false,
|
|
609
615
|
supportedFeatures: ['streaming', 'resume', 'system-prompt', 'permissions-skip'],
|
|
610
|
-
spawnEnv: { MAX_THINKING_TOKENS: '0' },
|
|
616
|
+
spawnEnv: { MAX_THINKING_TOKENS: '0', AGENTGUI_SUBPROCESS: '1' },
|
|
611
617
|
|
|
612
618
|
buildArgs(prompt, config) {
|
|
613
619
|
const {
|
|
@@ -627,6 +633,8 @@ registry.register({
|
|
|
627
633
|
if (model) flags.push('--model', model);
|
|
628
634
|
if (resumeSessionId) flags.push('--resume', resumeSessionId);
|
|
629
635
|
if (systemPrompt) flags.push('--append-system-prompt', systemPrompt);
|
|
636
|
+
// Pass prompt as positional arg (works with claude 2.1.72+)
|
|
637
|
+
flags.push(prompt);
|
|
630
638
|
|
|
631
639
|
return flags;
|
|
632
640
|
},
|
package/lib/ws-optimizer.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { pack } from 'msgpackr';
|
|
2
2
|
|
|
3
3
|
const MESSAGE_PRIORITY = {
|
|
4
4
|
high: ['streaming_error', 'streaming_complete', 'rate_limit_hit', 'streaming_cancelled', 'run_cancelled', 'tool_install_complete', 'tool_update_complete', 'tool_install_failed', 'tool_update_failed'],
|
|
@@ -33,19 +33,21 @@ class ClientQueue {
|
|
|
33
33
|
this.normalPriority = [];
|
|
34
34
|
this.lowPriority = [];
|
|
35
35
|
this.timer = null;
|
|
36
|
-
this.
|
|
36
|
+
this.lastKey = null;
|
|
37
37
|
this.messageCount = 0;
|
|
38
38
|
this.bytesSent = 0;
|
|
39
39
|
this.windowStart = Date.now();
|
|
40
40
|
this.rateLimitWarned = false;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
add(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
add(event, priority) {
|
|
44
|
+
// Deduplicate by type+seq key
|
|
45
|
+
const key = event.type + (event.seq ?? '') + (event.sessionId ?? '');
|
|
46
|
+
if (this.lastKey === key) return;
|
|
47
|
+
this.lastKey = key;
|
|
48
|
+
if (priority === 3) this.highPriority.push(event);
|
|
49
|
+
else if (priority === 2) this.normalPriority.push(event);
|
|
50
|
+
else this.lowPriority.push(event);
|
|
49
51
|
if (priority === 3) this.flushImmediate();
|
|
50
52
|
else if (!this.timer) this.scheduleFlush();
|
|
51
53
|
}
|
|
@@ -82,20 +84,12 @@ class ClientQueue {
|
|
|
82
84
|
if (allowedCount <= 0) { this.scheduleFlush(); return; }
|
|
83
85
|
batch.splice(allowedCount);
|
|
84
86
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (compressed.length < payload.length * 0.9) {
|
|
90
|
-
this.ws.send(JSON.stringify({ type: '_compressed', encoding: 'gzip' }));
|
|
91
|
-
this.ws.send(compressed);
|
|
92
|
-
payload = null;
|
|
93
|
-
}
|
|
94
|
-
} catch (e) {}
|
|
95
|
-
}
|
|
96
|
-
if (payload) this.ws.send(payload);
|
|
87
|
+
// Pack as msgpackr binary — perMessageDeflate on the WS server handles gzip
|
|
88
|
+
const envelope = batch.length === 1 ? batch[0] : batch;
|
|
89
|
+
const binary = pack(envelope);
|
|
90
|
+
this.ws.send(binary);
|
|
97
91
|
this.messageCount += batch.length;
|
|
98
|
-
this.bytesSent +=
|
|
92
|
+
this.bytesSent += binary.length;
|
|
99
93
|
if (windowDuration >= 3000 && this.bytesSent > 3 * 1024 * 1024) {
|
|
100
94
|
const mbps = (this.bytesSent / windowDuration * 1000 / 1024 / 1024).toFixed(2);
|
|
101
95
|
console.warn(`[ws-optimizer] Client ${this.ws.clientId} high bandwidth: ${mbps} MB/sec`);
|
|
@@ -116,16 +110,16 @@ class WSOptimizer {
|
|
|
116
110
|
this.clientQueues = new Map();
|
|
117
111
|
}
|
|
118
112
|
|
|
119
|
-
sendToClient(ws, event
|
|
113
|
+
sendToClient(ws, event) {
|
|
120
114
|
if (ws.readyState !== 1) return;
|
|
121
115
|
let queue = this.clientQueues.get(ws);
|
|
122
116
|
if (!queue) {
|
|
123
117
|
queue = new ClientQueue(ws);
|
|
124
118
|
this.clientQueues.set(ws, queue);
|
|
125
119
|
}
|
|
126
|
-
const
|
|
127
|
-
const priority =
|
|
128
|
-
queue.add(
|
|
120
|
+
const obj = typeof event === 'string' ? JSON.parse(event) : event;
|
|
121
|
+
const priority = getPriority(obj.type);
|
|
122
|
+
queue.add(obj, priority);
|
|
129
123
|
}
|
|
130
124
|
|
|
131
125
|
removeClient(ws) {
|
package/lib/ws-protocol.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { pack, unpack } from 'msgpackr';
|
|
2
|
+
|
|
3
|
+
function sendBinary(ws, obj) {
|
|
4
|
+
if (ws.readyState === 1) ws.send(pack(obj));
|
|
5
|
+
}
|
|
6
|
+
|
|
1
7
|
class WsRouter {
|
|
2
8
|
constructor() {
|
|
3
9
|
this.handlers = new Map();
|
|
@@ -15,25 +21,19 @@ class WsRouter {
|
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
reply(ws, requestId, data) {
|
|
18
|
-
|
|
19
|
-
ws.send(JSON.stringify({ r: requestId, d: data || {} }));
|
|
20
|
-
}
|
|
24
|
+
sendBinary(ws, { r: requestId, d: data || {} });
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
replyError(ws, requestId, code, message) {
|
|
24
|
-
|
|
25
|
-
ws.send(JSON.stringify({ r: requestId, e: { c: code, m: message } }));
|
|
26
|
-
}
|
|
28
|
+
sendBinary(ws, { r: requestId, e: { c: code, m: message } });
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
send(ws, type, data) {
|
|
30
|
-
|
|
31
|
-
ws.send(JSON.stringify({ t: type, d: data || {} }));
|
|
32
|
-
}
|
|
32
|
+
sendBinary(ws, { t: type, d: data || {} });
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
broadcast(clients, type, data) {
|
|
36
|
-
const msg =
|
|
36
|
+
const msg = pack({ t: type, d: data || {} });
|
|
37
37
|
for (const ws of clients) {
|
|
38
38
|
if (ws.readyState === 1) ws.send(msg);
|
|
39
39
|
}
|
|
@@ -42,11 +42,14 @@ class WsRouter {
|
|
|
42
42
|
async onMessage(ws, rawData) {
|
|
43
43
|
let parsed;
|
|
44
44
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
// Accept binary (msgpackr) or text (JSON fallback / legacy hot-reload)
|
|
46
|
+
if (Buffer.isBuffer(rawData) || rawData instanceof Uint8Array) {
|
|
47
|
+
parsed = unpack(rawData);
|
|
48
|
+
} else {
|
|
49
|
+
parsed = JSON.parse(rawData.toString());
|
|
49
50
|
}
|
|
51
|
+
} catch {
|
|
52
|
+
sendBinary(ws, { r: null, e: { c: 400, m: 'Invalid message' } });
|
|
50
53
|
return;
|
|
51
54
|
}
|
|
52
55
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentgui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.634",
|
|
4
4
|
"description": "Multi-agent ACP client with real-time communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"form-data": "^4.0.5",
|
|
34
34
|
"fsbrowse": "^0.2.18",
|
|
35
35
|
"google-auth-library": "^10.5.0",
|
|
36
|
+
"gpt-tokenizer": "^3.4.0",
|
|
36
37
|
"msgpackr": "^1.11.8",
|
|
37
38
|
"node-pty": "^1.0.0",
|
|
38
39
|
"onnxruntime-node": "1.21.0",
|
package/static/index.html
CHANGED
|
@@ -3244,7 +3244,7 @@
|
|
|
3244
3244
|
<script defer src="/gm/js/event-processor.js"></script>
|
|
3245
3245
|
<script defer src="/gm/js/streaming-renderer.js"></script>
|
|
3246
3246
|
<script defer src="/gm/js/image-loader.js"></script>
|
|
3247
|
-
<script
|
|
3247
|
+
<script src="/gm/lib/msgpackr.min.js"></script>
|
|
3248
3248
|
<script defer src="/gm/js/event-consolidator.js"></script>
|
|
3249
3249
|
<script defer src="/gm/js/websocket-manager.js"></script>
|
|
3250
3250
|
<script defer src="/gm/js/ws-client.js"></script>
|
package/static/js/client.js
CHANGED
|
@@ -62,19 +62,6 @@ class AgentGUIClient {
|
|
|
62
62
|
this._isLoadingConversation = false;
|
|
63
63
|
this._modelCache = new Map();
|
|
64
64
|
|
|
65
|
-
this.chunkPollState = {
|
|
66
|
-
isPolling: false,
|
|
67
|
-
lastFetchTimestamp: 0,
|
|
68
|
-
pollTimer: null,
|
|
69
|
-
backoffDelay: 100,
|
|
70
|
-
maxBackoffDelay: 400,
|
|
71
|
-
abortController: null
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
this._pollIntervalByTier = {
|
|
75
|
-
excellent: 100, good: 200, fair: 400, poor: 800, bad: 1500, unknown: 200
|
|
76
|
-
};
|
|
77
|
-
|
|
78
65
|
this._renderedSeqs = new Map();
|
|
79
66
|
this._inflightRequests = new Map();
|
|
80
67
|
this._previousConvAbort = null;
|
|
@@ -83,16 +70,10 @@ class AgentGUIClient {
|
|
|
83
70
|
this._loadInProgress = {}; // { [conversationId]: { requestId, abortController, timestamp, prevConversationId } }
|
|
84
71
|
this._currentRequestId = 0; // Auto-incrementing request counter
|
|
85
72
|
|
|
86
|
-
this._scrollKalman = typeof KalmanFilter !== 'undefined' ? new KalmanFilter({ processNoise: 50, measurementNoise: 100 }) : null;
|
|
87
73
|
this._scrollTarget = 0;
|
|
88
74
|
this._scrollAnimating = false;
|
|
89
75
|
this._scrollLerpFactor = config.scrollAnimationSpeed || 0.15;
|
|
90
76
|
|
|
91
|
-
this._chunkTimingKalman = typeof KalmanFilter !== 'undefined' ? new KalmanFilter({ processNoise: 10, measurementNoise: 200 }) : null;
|
|
92
|
-
this._lastChunkArrival = 0;
|
|
93
|
-
this._chunkTimingUpdateCount = 0;
|
|
94
|
-
this._chunkMissedPredictions = 0;
|
|
95
|
-
|
|
96
77
|
this._consolidator = typeof EventConsolidator !== 'undefined' ? new EventConsolidator() : null;
|
|
97
78
|
|
|
98
79
|
this._serverProcessingEstimate = 2000;
|
|
@@ -603,7 +584,6 @@ class AgentGUIClient {
|
|
|
603
584
|
this.state.currentConversation = null;
|
|
604
585
|
this.state.currentSession = null;
|
|
605
586
|
this.updateUrlForConversation(null);
|
|
606
|
-
this.stopChunkPolling();
|
|
607
587
|
this.enableControls();
|
|
608
588
|
this._showWelcomeScreen();
|
|
609
589
|
if (this.ui.messageInput) {
|
|
@@ -936,26 +916,9 @@ class AgentGUIClient {
|
|
|
936
916
|
this.scrollToBottom(true);
|
|
937
917
|
}
|
|
938
918
|
|
|
939
|
-
//
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
const initialChunks = await this.fetchChunks(data.conversationId, 0);
|
|
943
|
-
// Filter to only chunks from the current session
|
|
944
|
-
const sessionChunks = initialChunks.filter(c => c.sessionId === data.sessionId && c.block && c.block.type);
|
|
945
|
-
if (sessionChunks.length > 0) {
|
|
946
|
-
this.renderChunkBatch(sessionChunks);
|
|
947
|
-
// Update lastFetchTimestamp so polling doesn't duplicate these chunks
|
|
948
|
-
const lastChunk = sessionChunks[sessionChunks.length - 1];
|
|
949
|
-
if (lastChunk && lastChunk.created_at) {
|
|
950
|
-
this.chunkPollState.lastFetchTimestamp = lastChunk.created_at;
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
} catch (e) {
|
|
954
|
-
console.warn('Initial chunk fetch failed:', e.message);
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
// Start polling for chunks from database
|
|
958
|
-
this.startChunkPolling(data.conversationId);
|
|
919
|
+
// Reset rendered block seq tracker for this session
|
|
920
|
+
this._renderedSeqs = this._renderedSeqs || {};
|
|
921
|
+
this._renderedSeqs[data.sessionId] = new Set();
|
|
959
922
|
|
|
960
923
|
// Show queue/steer UI when streaming starts (for busy prompt)
|
|
961
924
|
this.showStreamingPromptButtons();
|
|
@@ -978,30 +941,33 @@ class AgentGUIClient {
|
|
|
978
941
|
}
|
|
979
942
|
|
|
980
943
|
handleStreamingProgress(data) {
|
|
981
|
-
|
|
982
|
-
// This handler is kept for backward compatibility and to trigger polling updates
|
|
983
|
-
// But actual rendering happens in renderChunk() via polling
|
|
944
|
+
if (!data.block || !data.sessionId) return;
|
|
984
945
|
|
|
985
|
-
|
|
946
|
+
// Deduplicate by seq number to guarantee exactly-once rendering
|
|
947
|
+
this._renderedSeqs = this._renderedSeqs || {};
|
|
948
|
+
const seen = this._renderedSeqs[data.sessionId] || (this._renderedSeqs[data.sessionId] = new Set());
|
|
949
|
+
if (data.seq !== undefined) {
|
|
950
|
+
if (seen.has(data.seq)) return;
|
|
951
|
+
seen.add(data.seq);
|
|
952
|
+
}
|
|
986
953
|
|
|
987
954
|
const block = data.block;
|
|
988
955
|
if (!this.state.streamingBlocks) this.state.streamingBlocks = [];
|
|
989
956
|
this.state.streamingBlocks.push(block);
|
|
990
957
|
|
|
991
|
-
//
|
|
992
|
-
if (
|
|
993
|
-
const streamingEl = document.getElementById(`streaming-${data.sessionId}`);
|
|
994
|
-
if (streamingEl) {
|
|
995
|
-
const blocksEl = streamingEl.querySelector('.streaming-blocks');
|
|
996
|
-
if (blocksEl) {
|
|
997
|
-
const el = this.renderer.renderBlock(block, data, blocksEl);
|
|
998
|
-
if (el) blocksEl.appendChild(el);
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
958
|
+
// Only render for the currently-visible session
|
|
959
|
+
if (this.state.currentSession?.id !== data.sessionId) return;
|
|
1002
960
|
|
|
1003
|
-
|
|
1004
|
-
|
|
961
|
+
const streamingEl = document.getElementById(`streaming-${data.sessionId}`);
|
|
962
|
+
if (!streamingEl) return;
|
|
963
|
+
const blocksEl = streamingEl.querySelector('.streaming-blocks');
|
|
964
|
+
if (!blocksEl) return;
|
|
965
|
+
|
|
966
|
+
const el = this.renderer.renderBlock(block, data, blocksEl);
|
|
967
|
+
if (el) {
|
|
968
|
+
blocksEl.appendChild(el);
|
|
969
|
+
this.scrollToBottom();
|
|
970
|
+
}
|
|
1005
971
|
}
|
|
1006
972
|
|
|
1007
973
|
renderBlockContent(block) {
|
|
@@ -1102,9 +1068,6 @@ class AgentGUIClient {
|
|
|
1102
1068
|
this.state.streamingConversations.delete(conversationId);
|
|
1103
1069
|
this.updateBusyPromptArea(conversationId);
|
|
1104
1070
|
|
|
1105
|
-
// Stop polling for chunks
|
|
1106
|
-
this.stopChunkPolling();
|
|
1107
|
-
|
|
1108
1071
|
// Clear queue indicator on error
|
|
1109
1072
|
const queueEl = document.querySelector('.queue-indicator');
|
|
1110
1073
|
if (queueEl) queueEl.remove();
|
|
@@ -1154,7 +1117,7 @@ class AgentGUIClient {
|
|
|
1154
1117
|
this.state.streamingConversations.delete(conversationId);
|
|
1155
1118
|
this.updateBusyPromptArea(conversationId);
|
|
1156
1119
|
|
|
1157
|
-
|
|
1120
|
+
|
|
1158
1121
|
|
|
1159
1122
|
// Clear queue indicator when streaming completes
|
|
1160
1123
|
const queueEl = document.querySelector('.queue-indicator');
|
|
@@ -1182,13 +1145,10 @@ class AgentGUIClient {
|
|
|
1182
1145
|
this.saveScrollPosition(conversationId);
|
|
1183
1146
|
}
|
|
1184
1147
|
|
|
1185
|
-
//
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
console.warn('Final chunk fetch failed:', err.message);
|
|
1190
|
-
});
|
|
1191
|
-
}
|
|
1148
|
+
// Recover any blocks missed during streaming (e.g. WS reconnects)
|
|
1149
|
+
this._recoverMissedChunks().catch(err => {
|
|
1150
|
+
console.warn('Chunk recovery failed:', err.message);
|
|
1151
|
+
});
|
|
1192
1152
|
|
|
1193
1153
|
this.enableControls();
|
|
1194
1154
|
this.emit('streaming:complete', data);
|
|
@@ -1368,7 +1328,7 @@ class AgentGUIClient {
|
|
|
1368
1328
|
handleRateLimitHit(data) {
|
|
1369
1329
|
if (data.conversationId !== this.state.currentConversation?.id) return;
|
|
1370
1330
|
this.state.streamingConversations.delete(data.conversationId);
|
|
1371
|
-
|
|
1331
|
+
|
|
1372
1332
|
this.enableControls();
|
|
1373
1333
|
|
|
1374
1334
|
const cooldownMs = data.retryAfterMs || 60000;
|
|
@@ -1728,13 +1688,11 @@ class AgentGUIClient {
|
|
|
1728
1688
|
block: typeof c.data === 'string' ? JSON.parse(c.data) : c.data
|
|
1729
1689
|
})).filter(c => c.block && c.block.type);
|
|
1730
1690
|
|
|
1731
|
-
const
|
|
1732
|
-
|
|
1733
|
-
return !seqSet || !seqSet.has(c.sequence);
|
|
1734
|
-
});
|
|
1691
|
+
const seenSeqs = (this._renderedSeqs || {})[sessionId];
|
|
1692
|
+
const dedupedChunks = chunks.filter(c => !seenSeqs || !seenSeqs.has(c.sequence));
|
|
1735
1693
|
|
|
1736
1694
|
if (dedupedChunks.length > 0) {
|
|
1737
|
-
this.
|
|
1695
|
+
for (const chunk of dedupedChunks) this.renderChunk(chunk);
|
|
1738
1696
|
}
|
|
1739
1697
|
} catch (e) {
|
|
1740
1698
|
console.warn('Chunk recovery failed:', e.message);
|
|
@@ -1752,46 +1710,6 @@ class AgentGUIClient {
|
|
|
1752
1710
|
return promise;
|
|
1753
1711
|
}
|
|
1754
1712
|
|
|
1755
|
-
_getAdaptivePollInterval() {
|
|
1756
|
-
const quality = this.wsManager?.latency?.quality || 'unknown';
|
|
1757
|
-
const base = this._pollIntervalByTier[quality] || 200;
|
|
1758
|
-
const trend = this.wsManager?.latency?.trend;
|
|
1759
|
-
if (!trend || trend === 'stable') return base;
|
|
1760
|
-
const tiers = ['excellent', 'good', 'fair', 'poor', 'bad'];
|
|
1761
|
-
const idx = tiers.indexOf(quality);
|
|
1762
|
-
if (trend === 'rising' && idx < tiers.length - 1) return this._pollIntervalByTier[tiers[idx + 1]];
|
|
1763
|
-
if (trend === 'falling' && idx > 0) return this._pollIntervalByTier[tiers[idx - 1]];
|
|
1764
|
-
return base;
|
|
1765
|
-
}
|
|
1766
|
-
|
|
1767
|
-
_chunkArrivalConfidence() {
|
|
1768
|
-
if (this._chunkTimingUpdateCount < 2) return 0;
|
|
1769
|
-
const base = Math.min(1, this._chunkTimingUpdateCount / 8);
|
|
1770
|
-
const penalty = Math.min(1, this._chunkMissedPredictions * 0.33);
|
|
1771
|
-
return Math.max(0, base - penalty);
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
_predictedNextChunkArrival() {
|
|
1775
|
-
if (!this._chunkTimingKalman || this._chunkTimingUpdateCount < 2) return 0;
|
|
1776
|
-
return this._lastChunkArrival + Math.min(this._chunkTimingKalman.predict(), 5000);
|
|
1777
|
-
}
|
|
1778
|
-
|
|
1779
|
-
_schedulePreAllocation(sessionId) {
|
|
1780
|
-
if (this._placeholderTimer) clearTimeout(this._placeholderTimer);
|
|
1781
|
-
if (this._chunkArrivalConfidence() < 0.5) return;
|
|
1782
|
-
const scrollContainer = document.getElementById('output-scroll');
|
|
1783
|
-
if (!scrollContainer) return;
|
|
1784
|
-
const distFromBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight;
|
|
1785
|
-
if (distFromBottom > 150) return;
|
|
1786
|
-
const nextArrival = this._predictedNextChunkArrival();
|
|
1787
|
-
if (!nextArrival) return;
|
|
1788
|
-
const delay = Math.max(0, nextArrival - performance.now() - 100);
|
|
1789
|
-
this._placeholderTimer = setTimeout(() => {
|
|
1790
|
-
this._placeholderTimer = null;
|
|
1791
|
-
this._insertPlaceholder(sessionId);
|
|
1792
|
-
}, delay);
|
|
1793
|
-
}
|
|
1794
|
-
|
|
1795
1713
|
_insertPlaceholder(sessionId) {
|
|
1796
1714
|
this._removePlaceholder();
|
|
1797
1715
|
const streamingEl = document.getElementById(`streaming-${sessionId}`);
|
|
@@ -1857,27 +1775,14 @@ class AgentGUIClient {
|
|
|
1857
1775
|
|
|
1858
1776
|
_setupDebugHooks() {
|
|
1859
1777
|
if (typeof window === 'undefined') return;
|
|
1860
|
-
const kalmanHistory = { latency: [], scroll: [], chunkTiming: [] };
|
|
1861
1778
|
const self = this;
|
|
1862
|
-
window.
|
|
1863
|
-
latency: this.wsManager?._latencyKalman || null,
|
|
1864
|
-
scroll: this._scrollKalman || null,
|
|
1865
|
-
chunkTiming: this._chunkTimingKalman || null,
|
|
1866
|
-
history: kalmanHistory,
|
|
1779
|
+
window.__debug = {
|
|
1867
1780
|
getState: () => ({
|
|
1868
|
-
|
|
1869
|
-
scroll: self._scrollKalman?.getState() || null,
|
|
1870
|
-
chunkTiming: self._chunkTimingKalman?.getState() || null,
|
|
1781
|
+
latencyEma: self.wsManager?._latencyEma || null,
|
|
1871
1782
|
serverProcessingEstimate: self._serverProcessingEstimate,
|
|
1872
|
-
chunkConfidence: self._chunkArrivalConfidence(),
|
|
1873
1783
|
latencyTrend: self.wsManager?.latency?.trend || null
|
|
1874
1784
|
})
|
|
1875
1785
|
};
|
|
1876
|
-
|
|
1877
|
-
this.wsManager.on('latency_prediction', (data) => {
|
|
1878
|
-
kalmanHistory.latency.push({ time: Date.now(), ...data });
|
|
1879
|
-
if (kalmanHistory.latency.length > 100) kalmanHistory.latency.shift();
|
|
1880
|
-
});
|
|
1881
1786
|
}
|
|
1882
1787
|
|
|
1883
1788
|
/**
|
|
@@ -2006,174 +1911,6 @@ class AgentGUIClient {
|
|
|
2006
1911
|
}
|
|
2007
1912
|
}
|
|
2008
1913
|
|
|
2009
|
-
/**
|
|
2010
|
-
* Fetch chunks from database for a conversation
|
|
2011
|
-
* Supports incremental updates with since parameter
|
|
2012
|
-
*/
|
|
2013
|
-
async fetchChunks(conversationId, since = 0) {
|
|
2014
|
-
if (!conversationId) return [];
|
|
2015
|
-
|
|
2016
|
-
try {
|
|
2017
|
-
const data = await window.wsClient.rpc('conv.chunks', { id: conversationId, since: since > 0 ? since : 0 });
|
|
2018
|
-
if (!data.ok || !Array.isArray(data.chunks)) {
|
|
2019
|
-
throw new Error('Invalid chunks response');
|
|
2020
|
-
}
|
|
2021
|
-
|
|
2022
|
-
const chunks = data.chunks.map(chunk => ({
|
|
2023
|
-
...chunk,
|
|
2024
|
-
block: typeof chunk.data === 'string' ? JSON.parse(chunk.data) : chunk.data
|
|
2025
|
-
}));
|
|
2026
|
-
|
|
2027
|
-
return chunks;
|
|
2028
|
-
} catch (error) {
|
|
2029
|
-
if (error.name === 'AbortError') return [];
|
|
2030
|
-
console.error('Error fetching chunks:', error);
|
|
2031
|
-
throw error;
|
|
2032
|
-
}
|
|
2033
|
-
}
|
|
2034
|
-
|
|
2035
|
-
/**
|
|
2036
|
-
* Poll for new chunks at regular intervals
|
|
2037
|
-
* Uses exponential backoff on errors
|
|
2038
|
-
* Also checks session status to detect completion
|
|
2039
|
-
*/
|
|
2040
|
-
async startChunkPolling(conversationId) {
|
|
2041
|
-
if (!conversationId) return;
|
|
2042
|
-
|
|
2043
|
-
const pollState = this.chunkPollState;
|
|
2044
|
-
if (pollState.isPolling) return;
|
|
2045
|
-
|
|
2046
|
-
pollState.isPolling = true;
|
|
2047
|
-
// Only reset lastFetchTimestamp if it wasn't already set by initial fetch
|
|
2048
|
-
if (pollState.lastFetchTimestamp === 0) {
|
|
2049
|
-
pollState.lastFetchTimestamp = Date.now();
|
|
2050
|
-
}
|
|
2051
|
-
pollState.backoffDelay = this._getAdaptivePollInterval();
|
|
2052
|
-
pollState.sessionCheckCounter = 0;
|
|
2053
|
-
pollState.emptyPollCount = 0;
|
|
2054
|
-
|
|
2055
|
-
const checkSessionStatus = async () => {
|
|
2056
|
-
if (!this.state.currentSession?.id) return false;
|
|
2057
|
-
let session;
|
|
2058
|
-
try { ({ session } = await window.wsClient.rpc('sess.get', { id: this.state.currentSession.id })); } catch { return false; }
|
|
2059
|
-
if (session && (session.status === 'complete' || session.status === 'error')) {
|
|
2060
|
-
if (session.status === 'complete') {
|
|
2061
|
-
this.handleStreamingComplete({ sessionId: session.id, conversationId, timestamp: Date.now() });
|
|
2062
|
-
} else {
|
|
2063
|
-
this.handleStreamingError({ sessionId: session.id, conversationId, error: session.error || 'Unknown error', timestamp: Date.now() });
|
|
2064
|
-
}
|
|
2065
|
-
return true;
|
|
2066
|
-
}
|
|
2067
|
-
return false;
|
|
2068
|
-
};
|
|
2069
|
-
|
|
2070
|
-
const pollOnce = async () => {
|
|
2071
|
-
if (!pollState.isPolling) return;
|
|
2072
|
-
|
|
2073
|
-
try {
|
|
2074
|
-
pollState.sessionCheckCounter++;
|
|
2075
|
-
const shouldCheckSession = pollState.sessionCheckCounter % 3 === 0 || pollState.emptyPollCount >= 3;
|
|
2076
|
-
if (shouldCheckSession) {
|
|
2077
|
-
const done = await checkSessionStatus();
|
|
2078
|
-
if (done) return;
|
|
2079
|
-
if (pollState.emptyPollCount >= 3) pollState.emptyPollCount = 0;
|
|
2080
|
-
}
|
|
2081
|
-
|
|
2082
|
-
const chunks = await this.fetchChunks(conversationId, pollState.lastFetchTimestamp);
|
|
2083
|
-
|
|
2084
|
-
if (chunks.length > 0) {
|
|
2085
|
-
pollState.backoffDelay = this._getAdaptivePollInterval();
|
|
2086
|
-
pollState.emptyPollCount = 0;
|
|
2087
|
-
const lastChunk = chunks[chunks.length - 1];
|
|
2088
|
-
pollState.lastFetchTimestamp = lastChunk.created_at;
|
|
2089
|
-
|
|
2090
|
-
const now = performance.now();
|
|
2091
|
-
if (this._lastChunkArrival > 0 && this._chunkTimingKalman) {
|
|
2092
|
-
const delta = now - this._lastChunkArrival;
|
|
2093
|
-
this._chunkTimingKalman.update(delta);
|
|
2094
|
-
this._chunkTimingUpdateCount++;
|
|
2095
|
-
this._chunkMissedPredictions = 0;
|
|
2096
|
-
}
|
|
2097
|
-
this._lastChunkArrival = now;
|
|
2098
|
-
|
|
2099
|
-
this.renderChunkBatch(chunks.filter(c => c.block && c.block.type));
|
|
2100
|
-
if (this.state.currentSession?.id) this._schedulePreAllocation(this.state.currentSession.id);
|
|
2101
|
-
} else {
|
|
2102
|
-
pollState.emptyPollCount++;
|
|
2103
|
-
if (this._chunkTimingUpdateCount > 0) this._chunkMissedPredictions++;
|
|
2104
|
-
pollState.backoffDelay = Math.min(pollState.backoffDelay + 50, 500);
|
|
2105
|
-
}
|
|
2106
|
-
|
|
2107
|
-
if (pollState.isPolling) {
|
|
2108
|
-
let nextDelay = pollState.backoffDelay;
|
|
2109
|
-
if (this._chunkArrivalConfidence() >= 0.3 && this._chunkTimingKalman) {
|
|
2110
|
-
const predicted = this._chunkTimingKalman.predict();
|
|
2111
|
-
const elapsed = performance.now() - this._lastChunkArrival;
|
|
2112
|
-
const untilNext = predicted - elapsed - 20;
|
|
2113
|
-
nextDelay = Math.max(50, Math.min(2000, untilNext));
|
|
2114
|
-
if (this._chunkMissedPredictions >= 3) {
|
|
2115
|
-
this._chunkTimingKalman.setProcessNoise(20);
|
|
2116
|
-
} else {
|
|
2117
|
-
this._chunkTimingKalman.setProcessNoise(10);
|
|
2118
|
-
}
|
|
2119
|
-
}
|
|
2120
|
-
pollState.pollTimer = setTimeout(pollOnce, nextDelay);
|
|
2121
|
-
}
|
|
2122
|
-
} catch (error) {
|
|
2123
|
-
console.warn('Chunk poll error:', error.message);
|
|
2124
|
-
pollState.backoffDelay = Math.min(pollState.backoffDelay * 2, pollState.maxBackoffDelay);
|
|
2125
|
-
if (pollState.isPolling) {
|
|
2126
|
-
pollState.pollTimer = setTimeout(pollOnce, pollState.backoffDelay);
|
|
2127
|
-
}
|
|
2128
|
-
}
|
|
2129
|
-
};
|
|
2130
|
-
|
|
2131
|
-
pollOnce();
|
|
2132
|
-
}
|
|
2133
|
-
|
|
2134
|
-
/**
|
|
2135
|
-
* Stop polling for chunks
|
|
2136
|
-
*/
|
|
2137
|
-
stopChunkPolling() {
|
|
2138
|
-
const pollState = this.chunkPollState;
|
|
2139
|
-
|
|
2140
|
-
if (pollState.pollTimer) {
|
|
2141
|
-
clearTimeout(pollState.pollTimer);
|
|
2142
|
-
pollState.pollTimer = null;
|
|
2143
|
-
}
|
|
2144
|
-
|
|
2145
|
-
if (pollState.abortController) {
|
|
2146
|
-
pollState.abortController.abort();
|
|
2147
|
-
pollState.abortController = null;
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2150
|
-
pollState.isPolling = false;
|
|
2151
|
-
this._scrollAnimating = false;
|
|
2152
|
-
if (this._scrollKalman) this._scrollKalman.reset();
|
|
2153
|
-
if (this._chunkTimingKalman) this._chunkTimingKalman.reset();
|
|
2154
|
-
this._chunkTimingUpdateCount = 0;
|
|
2155
|
-
this._chunkMissedPredictions = 0;
|
|
2156
|
-
this._lastChunkArrival = 0;
|
|
2157
|
-
if (this._placeholderTimer) { clearTimeout(this._placeholderTimer); this._placeholderTimer = null; }
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
/**
|
|
2161
|
-
* Fetch any remaining chunks after streaming completes
|
|
2162
|
-
* Ensures all output is visible without requiring a page refresh
|
|
2163
|
-
*/
|
|
2164
|
-
async fetchRemainingChunks(conversationId, sessionId) {
|
|
2165
|
-
try {
|
|
2166
|
-
const lastTimestamp = this.chunkPollState.lastFetchTimestamp || 0;
|
|
2167
|
-
const chunks = await this.fetchChunks(conversationId, lastTimestamp);
|
|
2168
|
-
const sessionChunks = chunks.filter(c => c.sessionId === sessionId && c.block && c.block.type);
|
|
2169
|
-
if (sessionChunks.length > 0) {
|
|
2170
|
-
this.renderChunkBatch(sessionChunks);
|
|
2171
|
-
}
|
|
2172
|
-
} catch (err) {
|
|
2173
|
-
console.error('Failed to fetch remaining chunks:', err);
|
|
2174
|
-
}
|
|
2175
|
-
}
|
|
2176
|
-
|
|
2177
1914
|
/**
|
|
2178
1915
|
* Render a single chunk to the output
|
|
2179
1916
|
*/
|
|
@@ -2203,71 +1940,6 @@ class AgentGUIClient {
|
|
|
2203
1940
|
this.scrollToBottom();
|
|
2204
1941
|
}
|
|
2205
1942
|
|
|
2206
|
-
renderChunkBatch(chunks) {
|
|
2207
|
-
if (!chunks.length) return;
|
|
2208
|
-
const deduped = [];
|
|
2209
|
-
for (const chunk of chunks) {
|
|
2210
|
-
const sid = chunk.sessionId;
|
|
2211
|
-
if (!this._renderedSeqs.has(sid)) this._renderedSeqs.set(sid, new Set());
|
|
2212
|
-
const seqSet = this._renderedSeqs.get(sid);
|
|
2213
|
-
if (chunk.sequence !== undefined && seqSet.has(chunk.sequence)) continue;
|
|
2214
|
-
if (chunk.sequence !== undefined) seqSet.add(chunk.sequence);
|
|
2215
|
-
deduped.push(chunk);
|
|
2216
|
-
}
|
|
2217
|
-
if (!deduped.length) return;
|
|
2218
|
-
|
|
2219
|
-
let toRender = deduped;
|
|
2220
|
-
if (this._consolidator) {
|
|
2221
|
-
const { consolidated, stats } = this._consolidator.consolidate(deduped);
|
|
2222
|
-
toRender = consolidated;
|
|
2223
|
-
for (const c of consolidated) {
|
|
2224
|
-
if (c._mergedSequences) {
|
|
2225
|
-
const seqSet = this._renderedSeqs.get(c.sessionId);
|
|
2226
|
-
if (seqSet) c._mergedSequences.forEach(s => seqSet.add(s));
|
|
2227
|
-
}
|
|
2228
|
-
}
|
|
2229
|
-
if (stats.textMerged || stats.toolsCollapsed || stats.systemSuperseded) {
|
|
2230
|
-
console.log('Consolidation:', stats);
|
|
2231
|
-
}
|
|
2232
|
-
}
|
|
2233
|
-
|
|
2234
|
-
this._removePlaceholder();
|
|
2235
|
-
const groups = {};
|
|
2236
|
-
for (const chunk of toRender) {
|
|
2237
|
-
const sid = chunk.sessionId;
|
|
2238
|
-
if (!groups[sid]) groups[sid] = [];
|
|
2239
|
-
groups[sid].push(chunk);
|
|
2240
|
-
}
|
|
2241
|
-
let appended = false;
|
|
2242
|
-
for (const sid of Object.keys(groups)) {
|
|
2243
|
-
const streamingEl = document.getElementById(`streaming-${sid}`);
|
|
2244
|
-
if (!streamingEl) continue;
|
|
2245
|
-
const blocksEl = streamingEl.querySelector('.streaming-blocks');
|
|
2246
|
-
if (!blocksEl) continue;
|
|
2247
|
-
for (const chunk of groups[sid]) {
|
|
2248
|
-
if (chunk.block.type === 'tool_result') {
|
|
2249
|
-
const matchById = chunk.block.tool_use_id && blocksEl.querySelector(`.block-tool-use[data-tool-use-id="${chunk.block.tool_use_id}"]`);
|
|
2250
|
-
const lastEl = blocksEl.lastElementChild;
|
|
2251
|
-
const toolUseEl = matchById || (lastEl?.classList?.contains('block-tool-use') ? lastEl : null);
|
|
2252
|
-
if (toolUseEl) {
|
|
2253
|
-
toolUseEl.classList.remove('has-success', 'has-error');
|
|
2254
|
-
toolUseEl.classList.add(chunk.block.is_error ? 'has-error' : 'has-success');
|
|
2255
|
-
const parentIsOpen = toolUseEl.hasAttribute('open');
|
|
2256
|
-
const contextWithParent = { ...chunk, parentIsOpen };
|
|
2257
|
-
const el = this.renderer.renderBlock(chunk.block, contextWithParent, blocksEl);
|
|
2258
|
-
if (el) { toolUseEl.appendChild(el); appended = true; }
|
|
2259
|
-
continue;
|
|
2260
|
-
}
|
|
2261
|
-
}
|
|
2262
|
-
const el = this.renderer.renderBlock(chunk.block, chunk, blocksEl);
|
|
2263
|
-
if (!el) { appended = true; continue; }
|
|
2264
|
-
blocksEl.appendChild(el);
|
|
2265
|
-
appended = true;
|
|
2266
|
-
}
|
|
2267
|
-
}
|
|
2268
|
-
if (appended) this.scrollToBottom();
|
|
2269
|
-
}
|
|
2270
|
-
|
|
2271
1943
|
/**
|
|
2272
1944
|
* Load agents
|
|
2273
1945
|
*/
|
|
@@ -2744,7 +2416,7 @@ class AgentGUIClient {
|
|
|
2744
2416
|
const prevConversationId = this.state.currentConversation?.id;
|
|
2745
2417
|
const availableFallback = this.state.conversations?.find(c => c.id !== conversationId) || null;
|
|
2746
2418
|
this.cacheCurrentConversation();
|
|
2747
|
-
|
|
2419
|
+
|
|
2748
2420
|
this.removeScrollUpDetection();
|
|
2749
2421
|
if (this.renderer.resetScrollState) this.renderer.resetScrollState();
|
|
2750
2422
|
this._userScrolledUp = false;
|
|
@@ -3031,12 +2703,6 @@ class AgentGUIClient {
|
|
|
3031
2703
|
|
|
3032
2704
|
this.updateUrlForConversation(conversationId, latestSession.id);
|
|
3033
2705
|
|
|
3034
|
-
const lastChunkTime = chunks.length > 0
|
|
3035
|
-
? chunks[chunks.length - 1].created_at
|
|
3036
|
-
: 0;
|
|
3037
|
-
|
|
3038
|
-
this.chunkPollState.lastFetchTimestamp = lastChunkTime;
|
|
3039
|
-
this.startChunkPolling(conversationId);
|
|
3040
2706
|
// IMMUTABLE: Prompt remains enabled - syncPromptState will set correct state
|
|
3041
2707
|
this.syncPromptState(conversationId);
|
|
3042
2708
|
} else {
|
|
@@ -3421,7 +3087,7 @@ class AgentGUIClient {
|
|
|
3421
3087
|
* Cleanup resources
|
|
3422
3088
|
*/
|
|
3423
3089
|
destroy() {
|
|
3424
|
-
|
|
3090
|
+
|
|
3425
3091
|
this.renderer.destroy();
|
|
3426
3092
|
this.wsManager.destroy();
|
|
3427
3093
|
this.eventHandlers = {};
|
|
@@ -40,7 +40,7 @@ class WebSocketManager {
|
|
|
40
40
|
pingCounter: 0
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
this.
|
|
43
|
+
this._latencyEma = null; // exponential moving average for latency
|
|
44
44
|
this._trendHistory = [];
|
|
45
45
|
this._trendCount = 0;
|
|
46
46
|
this._reconnectedAt = 0;
|
|
@@ -84,6 +84,7 @@ class WebSocketManager {
|
|
|
84
84
|
|
|
85
85
|
try {
|
|
86
86
|
this.ws = new WebSocket(this.config.url);
|
|
87
|
+
this.ws.binaryType = 'arraybuffer';
|
|
87
88
|
this.ws.onopen = () => this.onOpen();
|
|
88
89
|
this.ws.onmessage = (event) => this.onMessage(event);
|
|
89
90
|
this.ws.onerror = (error) => this.onError(error);
|
|
@@ -129,9 +130,19 @@ class WebSocketManager {
|
|
|
129
130
|
this.emit('connected', { timestamp: Date.now() });
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
onMessage(event) {
|
|
133
|
+
async onMessage(event) {
|
|
133
134
|
try {
|
|
134
|
-
|
|
135
|
+
let parsed;
|
|
136
|
+
if (event.data instanceof Blob || event.data instanceof ArrayBuffer) {
|
|
137
|
+
// Binary frame: msgpackr-encoded (perMessageDeflate decompressed by browser)
|
|
138
|
+
const buf = event.data instanceof Blob
|
|
139
|
+
? await event.data.arrayBuffer()
|
|
140
|
+
: event.data;
|
|
141
|
+
parsed = msgpackr.unpack(new Uint8Array(buf));
|
|
142
|
+
} else {
|
|
143
|
+
// Fallback: plain JSON (ping/pong control frames, legacy)
|
|
144
|
+
parsed = JSON.parse(event.data);
|
|
145
|
+
}
|
|
135
146
|
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
|
136
147
|
this.stats.totalMessagesReceived += messages.length;
|
|
137
148
|
|
|
@@ -154,6 +165,11 @@ class WebSocketManager {
|
|
|
154
165
|
);
|
|
155
166
|
}
|
|
156
167
|
|
|
168
|
+
// RPC reply envelopes — emit for WsClient to intercept, then skip broadcast
|
|
169
|
+
if (data.r !== undefined && !data.type) {
|
|
170
|
+
this.emit('message', data);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
157
173
|
this.emit('message', data);
|
|
158
174
|
if (data.type) this.emit('message:' + data.type, data);
|
|
159
175
|
}
|
|
@@ -181,21 +197,12 @@ class WebSocketManager {
|
|
|
181
197
|
|
|
182
198
|
this.latency.current = rtt;
|
|
183
199
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const result = this._latencyKalman.update(rtt);
|
|
191
|
-
this.latency.predicted = result.estimate;
|
|
192
|
-
this.latency.predictedNext = this._latencyKalman.predict();
|
|
193
|
-
this.latency.avg = result.estimate;
|
|
194
|
-
} else {
|
|
195
|
-
this.latency.avg = samples.reduce((a, b) => a + b, 0) / samples.length;
|
|
196
|
-
this.latency.predicted = this.latency.avg;
|
|
197
|
-
this.latency.predictedNext = this.latency.avg;
|
|
198
|
-
}
|
|
200
|
+
// EMA smoothing (α=0.2 → slow adaptation, less noise)
|
|
201
|
+
const alpha = 0.2;
|
|
202
|
+
this._latencyEma = this._latencyEma === null ? rtt : alpha * rtt + (1 - alpha) * this._latencyEma;
|
|
203
|
+
this.latency.avg = this._latencyEma;
|
|
204
|
+
this.latency.predicted = this._latencyEma;
|
|
205
|
+
this.latency.predictedNext = this._latencyEma;
|
|
199
206
|
|
|
200
207
|
if (samples.length > 1) {
|
|
201
208
|
const mean = samples.reduce((a, b) => a + b, 0) / samples.length;
|
|
@@ -233,7 +240,7 @@ class WebSocketManager {
|
|
|
233
240
|
predicted: this.latency.predicted,
|
|
234
241
|
predictedNext: this.latency.predictedNext,
|
|
235
242
|
trend: this.latency.trend,
|
|
236
|
-
gain:
|
|
243
|
+
gain: 0
|
|
237
244
|
});
|
|
238
245
|
|
|
239
246
|
this._checkDegradation();
|
|
@@ -370,8 +377,8 @@ class WebSocketManager {
|
|
|
370
377
|
this._hiddenAt = Date.now();
|
|
371
378
|
return;
|
|
372
379
|
}
|
|
373
|
-
if (this._hiddenAt &&
|
|
374
|
-
this.
|
|
380
|
+
if (this._hiddenAt && Date.now() - this._hiddenAt > 30000) {
|
|
381
|
+
this._latencyEma = null;
|
|
375
382
|
this._trendHistory = [];
|
|
376
383
|
this.latency.trend = 'stable';
|
|
377
384
|
}
|
|
@@ -436,7 +443,7 @@ class WebSocketManager {
|
|
|
436
443
|
}
|
|
437
444
|
|
|
438
445
|
try {
|
|
439
|
-
this.ws.send(
|
|
446
|
+
this.ws.send(msgpackr.pack(data));
|
|
440
447
|
this.stats.totalMessagesSent++;
|
|
441
448
|
return true;
|
|
442
449
|
} catch (error) {
|
|
@@ -460,7 +467,7 @@ class WebSocketManager {
|
|
|
460
467
|
this.messageBuffer = [];
|
|
461
468
|
for (const message of messages) {
|
|
462
469
|
try {
|
|
463
|
-
this.ws.send(
|
|
470
|
+
this.ws.send(msgpackr.pack(message));
|
|
464
471
|
this.stats.totalMessagesSent++;
|
|
465
472
|
} catch (error) {
|
|
466
473
|
this.bufferMessage(message);
|
|
@@ -484,7 +491,7 @@ class WebSocketManager {
|
|
|
484
491
|
if (type === 'session') msg.sessionId = id;
|
|
485
492
|
else msg.conversationId = id;
|
|
486
493
|
try {
|
|
487
|
-
this.ws.send(
|
|
494
|
+
this.ws.send(msgpackr.pack(msg));
|
|
488
495
|
this.stats.totalMessagesSent++;
|
|
489
496
|
} catch (_) {}
|
|
490
497
|
}
|
package/static/js/ws-client.js
CHANGED
|
@@ -10,36 +10,21 @@ class WsClient {
|
|
|
10
10
|
_install() {
|
|
11
11
|
if (this._installed) return;
|
|
12
12
|
this._installed = true;
|
|
13
|
-
|
|
14
|
-
this._ws.
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
clearTimeout(p.timer);
|
|
24
|
-
if (msg.e) {
|
|
25
|
-
p.reject(Object.assign(new Error(msg.e.m || 'RPC error'), { code: msg.e.c }));
|
|
26
|
-
} else {
|
|
27
|
-
p.resolve(msg.d);
|
|
28
|
-
}
|
|
29
|
-
} else {
|
|
30
|
-
passthrough.push(msg);
|
|
31
|
-
}
|
|
13
|
+
// Listen on decoded message objects — websocket-manager emits 'message' with decoded obj
|
|
14
|
+
this._ws.on('message', (data) => {
|
|
15
|
+
if (data.r && this._pending.has(data.r)) {
|
|
16
|
+
const p = this._pending.get(data.r);
|
|
17
|
+
this._pending.delete(data.r);
|
|
18
|
+
clearTimeout(p.timer);
|
|
19
|
+
if (data.e) {
|
|
20
|
+
p.reject(Object.assign(new Error(data.e.m || 'RPC error'), { code: data.e.c }));
|
|
21
|
+
} else {
|
|
22
|
+
p.resolve(data.d);
|
|
32
23
|
}
|
|
33
|
-
|
|
34
|
-
const rebuilt = passthrough.length === 1
|
|
35
|
-
? JSON.stringify(passthrough[0])
|
|
36
|
-
: JSON.stringify(passthrough);
|
|
37
|
-
origOnMessage({ data: rebuilt });
|
|
38
|
-
}
|
|
39
|
-
} catch (_) {
|
|
40
|
-
origOnMessage(event);
|
|
24
|
+
return; // consumed — don't re-emit
|
|
41
25
|
}
|
|
42
|
-
|
|
26
|
+
// Non-RPC messages are already emitted by websocket-manager; nothing to do
|
|
27
|
+
});
|
|
43
28
|
this._ws.on('disconnected', () => this.cancelAll());
|
|
44
29
|
}
|
|
45
30
|
|
|
@@ -79,7 +64,7 @@ class WsClient {
|
|
|
79
64
|
}
|
|
80
65
|
|
|
81
66
|
cancelAll() {
|
|
82
|
-
for (const [
|
|
67
|
+
for (const [, p] of this._pending) {
|
|
83
68
|
clearTimeout(p.timer);
|
|
84
69
|
p.reject(new Error('Connection lost'));
|
|
85
70
|
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
class KalmanFilter {
|
|
2
|
-
constructor(config = {}) {
|
|
3
|
-
this._initEst = config.initialEstimate || 0;
|
|
4
|
-
this._initErr = config.initialError || 1000;
|
|
5
|
-
this._q = Math.max(config.processNoise || 1, 0.001);
|
|
6
|
-
this._r = config.measurementNoise || 10;
|
|
7
|
-
this._est = this._initEst;
|
|
8
|
-
this._err = this._initErr;
|
|
9
|
-
this._gain = 0;
|
|
10
|
-
this._initialized = false;
|
|
11
|
-
this._lastValid = this._initEst;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
update(measurement) {
|
|
15
|
-
if (!Number.isFinite(measurement)) {
|
|
16
|
-
return { estimate: this._est, error: this._err, gain: this._gain };
|
|
17
|
-
}
|
|
18
|
-
if (measurement < 0) measurement = 0;
|
|
19
|
-
if (!this._initialized) {
|
|
20
|
-
this._est = measurement;
|
|
21
|
-
this._err = this._r;
|
|
22
|
-
this._initialized = true;
|
|
23
|
-
this._lastValid = measurement;
|
|
24
|
-
this._gain = 1;
|
|
25
|
-
return { estimate: this._est, error: this._err, gain: this._gain };
|
|
26
|
-
}
|
|
27
|
-
let r = this._r;
|
|
28
|
-
if (this._est > 0 && Math.abs(measurement - this._est) > this._est * 10) {
|
|
29
|
-
r = r * 100;
|
|
30
|
-
}
|
|
31
|
-
const predErr = this._err + this._q;
|
|
32
|
-
this._gain = predErr / (predErr + r);
|
|
33
|
-
this._est = this._est + this._gain * (measurement - this._est);
|
|
34
|
-
this._err = (1 - this._gain) * predErr;
|
|
35
|
-
if (this._err < 1e-10) this._err = 1e-10;
|
|
36
|
-
this._lastValid = this._est;
|
|
37
|
-
return { estimate: this._est, error: this._err, gain: this._gain };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
predict() {
|
|
41
|
-
return this._est;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
setProcessNoise(q) { this._q = Math.max(q, 0.001); }
|
|
45
|
-
setMeasurementNoise(r) { this._r = r; }
|
|
46
|
-
|
|
47
|
-
getState() {
|
|
48
|
-
return {
|
|
49
|
-
estimate: this._est,
|
|
50
|
-
error: this._err,
|
|
51
|
-
gain: this._gain,
|
|
52
|
-
processNoise: this._q,
|
|
53
|
-
measurementNoise: this._r,
|
|
54
|
-
initialized: this._initialized
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
reset() {
|
|
59
|
-
this._est = this._initEst;
|
|
60
|
-
this._err = this._initErr;
|
|
61
|
-
this._gain = 0;
|
|
62
|
-
this._initialized = false;
|
|
63
|
-
this._lastValid = this._initEst;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (typeof module !== 'undefined' && module.exports) module.exports = KalmanFilter;
|