agentgui 1.0.821 → 1.0.823
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/CHANGELOG.md +17 -0
- package/package.json +1 -1
- package/static/index.html +4 -1
- package/static/js/conv-list-renderer.js +197 -0
- package/static/js/conv-sidebar-clone.js +91 -0
- package/static/js/conversations.js +1 -0
- package/static/js/ui-components-rendering.js +61 -182
- package/static/js/ui-components.js +88 -187
- package/static/js/websocket-manager.js +107 -642
- package/static/js/ws-core.js +162 -0
- package/static/js/ws-latency.js +88 -0
- package/.prd +0 -42
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
class WebSocketManager {
|
|
2
|
+
constructor(config = {}) {
|
|
3
|
+
this.config = {
|
|
4
|
+
url: config.url || this.getWebSocketURL(),
|
|
5
|
+
reconnectDelays: config.reconnectDelays || [500, 1000, 2000, 4000, 8000, 15000, 30000],
|
|
6
|
+
maxReconnectDelay: config.maxReconnectDelay || 30000,
|
|
7
|
+
heartbeatInterval: config.heartbeatInterval || 15000,
|
|
8
|
+
messageTimeout: config.messageTimeout || 60000,
|
|
9
|
+
maxBufferedMessages: config.maxBufferedMessages || 1000,
|
|
10
|
+
pongTimeout: config.pongTimeout || 5000,
|
|
11
|
+
latencyWindowSize: config.latencyWindowSize || 10,
|
|
12
|
+
...config
|
|
13
|
+
};
|
|
14
|
+
this.ws = null;
|
|
15
|
+
this._isConnected = false;
|
|
16
|
+
this._isConnecting = false;
|
|
17
|
+
this._isManuallyDisconnected = false;
|
|
18
|
+
this._reconnectCount = 0;
|
|
19
|
+
this.isManuallyDisconnected = false;
|
|
20
|
+
this.reconnectCount = 0;
|
|
21
|
+
this.reconnectTimer = null;
|
|
22
|
+
this.messageBuffer = [];
|
|
23
|
+
this.requestMap = new Map();
|
|
24
|
+
this.heartbeatTimer = null;
|
|
25
|
+
this._connectionState = 'disconnected';
|
|
26
|
+
this.activeSubscriptions = new Set();
|
|
27
|
+
this._wsActor = typeof createWsActor === 'function' ? createWsActor() : null;
|
|
28
|
+
this.connectionEstablishedAt = 0;
|
|
29
|
+
this.cachedVoiceList = null;
|
|
30
|
+
this.voiceListListeners = [];
|
|
31
|
+
this.latency = { samples: [], current: 0, avg: 0, jitter: 0, quality: 'unknown', predicted: 0, predictedNext: 0, trend: 'stable', missedPongs: 0, pingCounter: 0 };
|
|
32
|
+
this._latencyEma = null;
|
|
33
|
+
this._trendHistory = [];
|
|
34
|
+
this._trendCount = 0;
|
|
35
|
+
this._reconnectedAt = 0;
|
|
36
|
+
this.stats = { totalConnections: 0, totalReconnects: 0, totalMessagesSent: 0, totalMessagesReceived: 0, totalErrors: 0, totalTimeouts: 0, avgLatency: 0, lastConnectedTime: null, connectionDuration: 0 };
|
|
37
|
+
this.lastSeqBySession = {};
|
|
38
|
+
this.listeners = {};
|
|
39
|
+
this._onVisibilityChange = this._handleVisibilityChange.bind(this);
|
|
40
|
+
this._onOnline = this._handleOnline.bind(this);
|
|
41
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', this._onVisibilityChange);
|
|
42
|
+
if (typeof window !== 'undefined') window.addEventListener('online', this._onOnline);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get isConnected() { return this._wsActor ? this._wsActor.getSnapshot().value === 'connected' : this._isConnected; }
|
|
46
|
+
set isConnected(v) { this._isConnected = v; }
|
|
47
|
+
get isConnecting() { return this._wsActor ? this._wsActor.getSnapshot().value === 'connecting' : this._isConnecting; }
|
|
48
|
+
set isConnecting(v) { this._isConnecting = v; }
|
|
49
|
+
get connectionState() { return this._wsActor ? this._wsActor.getSnapshot().value : this._connectionState; }
|
|
50
|
+
set connectionState(v) { this._connectionState = v; }
|
|
51
|
+
get isManuallyDisconnected() { return this._wsActor ? !!this._wsActor.getSnapshot().context.manualDisconnect : this._isManuallyDisconnected; }
|
|
52
|
+
set isManuallyDisconnected(v) { this._isManuallyDisconnected = v; if (this._wsActor && v) this._wsActor.send({ type: 'MANUAL_DISCONNECT' }); }
|
|
53
|
+
get reconnectCount() { return this._wsActor ? this._wsActor.getSnapshot().context.reconnectCount : this._reconnectCount; }
|
|
54
|
+
set reconnectCount(v) { this._reconnectCount = v; }
|
|
55
|
+
|
|
56
|
+
getWebSocketURL() {
|
|
57
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
58
|
+
const baseURL = window.__BASE_URL || '/gm';
|
|
59
|
+
let url = `${protocol}//${window.location.host}${baseURL}/sync`;
|
|
60
|
+
if (window.__WS_TOKEN) url += `?token=${encodeURIComponent(window.__WS_TOKEN)}`;
|
|
61
|
+
return url;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async connect() {
|
|
65
|
+
if (this.isConnected || this.isConnecting) return this.ws;
|
|
66
|
+
this.isManuallyDisconnected = false;
|
|
67
|
+
this._isConnecting = true;
|
|
68
|
+
if (this._wsActor) this._wsActor.send({ type: 'CONNECT' });
|
|
69
|
+
this.setConnectionState('connecting');
|
|
70
|
+
try {
|
|
71
|
+
this.ws = new WebSocket(this.config.url);
|
|
72
|
+
this.ws.binaryType = 'arraybuffer';
|
|
73
|
+
this.ws.onopen = () => this.onOpen();
|
|
74
|
+
this.ws.onmessage = (event) => this.onMessage(event);
|
|
75
|
+
this.ws.onerror = (error) => this.onError(error);
|
|
76
|
+
this.ws.onclose = () => this.onClose();
|
|
77
|
+
return await this.waitForConnection(this.config.messageTimeout);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
this.isConnecting = false;
|
|
80
|
+
this.stats.totalErrors++;
|
|
81
|
+
this.scheduleReconnect();
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
waitForConnection(timeout = 5000) {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
const timer = setTimeout(() => reject(new Error('WebSocket connection timeout')), timeout);
|
|
89
|
+
const check = () => {
|
|
90
|
+
if (this.isConnected || this.ws?.readyState === WebSocket.OPEN) { clearTimeout(timer); resolve(this.ws); }
|
|
91
|
+
else setTimeout(check, 50);
|
|
92
|
+
};
|
|
93
|
+
check();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
onOpen() {
|
|
98
|
+
this._isConnected = true;
|
|
99
|
+
this._isConnecting = false;
|
|
100
|
+
if (this._wsActor) this._wsActor.send({ type: 'OPEN' });
|
|
101
|
+
this.connectionEstablishedAt = Date.now();
|
|
102
|
+
this._reconnectedAt = this.stats.totalConnections > 0 ? Date.now() : 0;
|
|
103
|
+
this.stats.totalConnections++;
|
|
104
|
+
this.stats.lastConnectedTime = Date.now();
|
|
105
|
+
this.latency.missedPongs = 0;
|
|
106
|
+
this.setConnectionState('connected');
|
|
107
|
+
this.flushMessageBuffer();
|
|
108
|
+
this.resubscribeAll();
|
|
109
|
+
this.startHeartbeat();
|
|
110
|
+
this.emit('connected', { timestamp: Date.now() });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async onMessage(event) {
|
|
114
|
+
try {
|
|
115
|
+
const buf = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
|
|
116
|
+
const parsed = window._codec ? window._codec.decode(buf) : msgpackr.unpack(new Uint8Array(buf));
|
|
117
|
+
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
|
118
|
+
this.stats.totalMessagesReceived += messages.length;
|
|
119
|
+
for (const data of messages) {
|
|
120
|
+
if (data.type === 'pong') { this._handlePong(data); continue; }
|
|
121
|
+
if (data.type === 'voice_list') {
|
|
122
|
+
this.cachedVoiceList = data.voices || [];
|
|
123
|
+
for (const listener of this.voiceListListeners) { try { listener(this.cachedVoiceList); } catch (_) {} }
|
|
124
|
+
}
|
|
125
|
+
if (data.seq !== undefined && data.sessionId) {
|
|
126
|
+
this.lastSeqBySession[data.sessionId] = Math.max(this.lastSeqBySession[data.sessionId] || -1, data.seq);
|
|
127
|
+
}
|
|
128
|
+
this.emit('message', data);
|
|
129
|
+
if (data.type) this.emit('message:' + data.type, data);
|
|
130
|
+
}
|
|
131
|
+
} catch (error) { this.stats.totalErrors++; }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
onError(error) {
|
|
135
|
+
this.stats.totalErrors++;
|
|
136
|
+
if (this._wsActor) this._wsActor.send({ type: 'ERROR' });
|
|
137
|
+
this.emit('error', { error, timestamp: Date.now() });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
onClose() {
|
|
141
|
+
this._isConnected = false;
|
|
142
|
+
this._isConnecting = false;
|
|
143
|
+
if (this._wsActor) this._wsActor.send({ type: 'CLOSE' });
|
|
144
|
+
this.setConnectionState('disconnected');
|
|
145
|
+
this.stopHeartbeat();
|
|
146
|
+
if (this.stats.lastConnectedTime) this.stats.connectionDuration = Date.now() - this.stats.lastConnectedTime;
|
|
147
|
+
this.emit('disconnected', { timestamp: Date.now() });
|
|
148
|
+
if (!this.isManuallyDisconnected) this.scheduleReconnect();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
scheduleReconnect() {
|
|
152
|
+
if (this.isManuallyDisconnected || this.reconnectTimer) return;
|
|
153
|
+
const delays = this.config.reconnectDelays;
|
|
154
|
+
const baseDelay = this.reconnectCount < delays.length ? delays[this.reconnectCount] : this.config.maxReconnectDelay;
|
|
155
|
+
const delay = Math.round(baseDelay + Math.random() * 0.3 * baseDelay);
|
|
156
|
+
this.reconnectCount++;
|
|
157
|
+
this.stats.totalReconnects++;
|
|
158
|
+
this.setConnectionState('reconnecting');
|
|
159
|
+
this.emit('reconnecting', { delay, attempt: this.reconnectCount, nextAttemptAt: Date.now() + delay });
|
|
160
|
+
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect().catch(() => {}); }, delay);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Object.assign(WebSocketManager.prototype, {
|
|
2
|
+
startHeartbeat() {
|
|
3
|
+
this.stopHeartbeat();
|
|
4
|
+
this.heartbeatTimer = setInterval(() => { this.ping(); }, this.config.heartbeatInterval);
|
|
5
|
+
},
|
|
6
|
+
|
|
7
|
+
stopHeartbeat() {
|
|
8
|
+
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
|
|
9
|
+
},
|
|
10
|
+
|
|
11
|
+
_reportLatency() {
|
|
12
|
+
this.emit('latency', { current: this.latency.current, avg: this.latency.avg, jitter: this.latency.jitter, quality: this.latency.quality, trend: this.latency.trend });
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
_handleVisibilityChange() {
|
|
16
|
+
if (document.visibilityState === 'visible' && !this.isConnected && !this.isManuallyDisconnected) this.connect().catch(() => {});
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
_handleOnline() {
|
|
20
|
+
if (!this.isConnected && !this.isManuallyDisconnected) this.connect().catch(() => {});
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
ping() {
|
|
24
|
+
if (!this.isConnected) return;
|
|
25
|
+
this.latency.pingCounter++;
|
|
26
|
+
const id = this.latency.pingCounter;
|
|
27
|
+
const pongTimer = setTimeout(() => {
|
|
28
|
+
this.latency.missedPongs++;
|
|
29
|
+
this.emit('latency', { current: null, avg: this.latency.avg, jitter: this.latency.jitter, quality: 'poor', missed: true });
|
|
30
|
+
if (this.latency.missedPongs >= 3) { this.onClose(); }
|
|
31
|
+
}, this.config.pongTimeout);
|
|
32
|
+
this.requestMap.set('ping:' + id, { type: 'ping', sentAt: Date.now(), pongTimer });
|
|
33
|
+
try {
|
|
34
|
+
this.ws.send(window._codec ? window._codec.encode({ type: 'ping', id }) : msgpackr.pack({ type: 'ping', id }));
|
|
35
|
+
} catch (_) { clearTimeout(pongTimer); this.requestMap.delete('ping:' + id); }
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
_handlePong(data) {
|
|
39
|
+
const key = 'ping:' + data.id;
|
|
40
|
+
const pending = this.requestMap.get(key);
|
|
41
|
+
if (!pending) return;
|
|
42
|
+
clearTimeout(pending.pongTimer);
|
|
43
|
+
this.requestMap.delete(key);
|
|
44
|
+
this.latency.missedPongs = 0;
|
|
45
|
+
const rtt = Date.now() - pending.sentAt;
|
|
46
|
+
this._recordLatency(rtt);
|
|
47
|
+
this._checkDegradation();
|
|
48
|
+
this._reportLatency();
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
_recordLatency(rtt) {
|
|
52
|
+
const samples = this.latency.samples;
|
|
53
|
+
samples.push(rtt);
|
|
54
|
+
if (samples.length > this.config.latencyWindowSize) samples.shift();
|
|
55
|
+
this.latency.current = rtt;
|
|
56
|
+
this.latency.avg = Math.round(samples.reduce((a, b) => a + b, 0) / samples.length);
|
|
57
|
+
const jitterSamples = samples.slice(1).map((v, i) => Math.abs(v - samples[i]));
|
|
58
|
+
this.latency.jitter = jitterSamples.length ? Math.round(jitterSamples.reduce((a, b) => a + b, 0) / jitterSamples.length) : 0;
|
|
59
|
+
this.latency.quality = this._qualityTier(this.latency.avg);
|
|
60
|
+
if (this._latencyEma === null) this._latencyEma = rtt;
|
|
61
|
+
else this._latencyEma = 0.3 * rtt + 0.7 * this._latencyEma;
|
|
62
|
+
this.latency.predicted = Math.round(this._latencyEma);
|
|
63
|
+
this.stats.avgLatency = this.latency.avg;
|
|
64
|
+
this._trendHistory.push(rtt);
|
|
65
|
+
if (this._trendHistory.length > 5) this._trendHistory.shift();
|
|
66
|
+
if (this._trendHistory.length >= 3) {
|
|
67
|
+
const h = this._trendHistory;
|
|
68
|
+
const slope = (h[h.length - 1] - h[0]) / h.length;
|
|
69
|
+
this.latency.trend = slope > 5 ? 'rising' : slope < -5 ? 'falling' : 'stable';
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
_qualityTier(avg) {
|
|
74
|
+
if (avg < 50) return 'excellent';
|
|
75
|
+
if (avg < 150) return 'good';
|
|
76
|
+
if (avg < 300) return 'fair';
|
|
77
|
+
return 'poor';
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
_checkDegradation() {
|
|
81
|
+
const tier = this.latency.quality;
|
|
82
|
+
const prev = this._lastQualityTier;
|
|
83
|
+
if (tier !== prev) {
|
|
84
|
+
this._lastQualityTier = tier;
|
|
85
|
+
this.emit('quality_change', { quality: tier, latency: this.latency.avg });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
package/.prd
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
[
|
|
2
|
-
{
|
|
3
|
-
"id": "split-conversations-js",
|
|
4
|
-
"subject": "Split conversations.js into 3 files under 200 lines each",
|
|
5
|
-
"status": "pending",
|
|
6
|
-
"description": "conversations.js is 742L. Natural split: (1) conv-folder-browser.js — FolderBrowser methods L189-470 (~90L), (2) conv-list.js — ConversationManager render/CRUD/WS methods L480-742 (~180L), (3) conversations.js — core class constructor/init/loadAgents/agents/format methods L1-190 (~190L). All three reference shared ConversationManager instance.",
|
|
7
|
-
"effort": "large",
|
|
8
|
-
"category": "refactor",
|
|
9
|
-
"blocking": [],
|
|
10
|
-
"blockedBy": [],
|
|
11
|
-
"acceptance": [
|
|
12
|
-
"conversations.js ≤200 lines",
|
|
13
|
-
"conv-folder-browser.js ≤200 lines and loaded in index.html",
|
|
14
|
-
"conv-list.js ≤200 lines and loaded in index.html",
|
|
15
|
-
"window.conversationManager still works post-split",
|
|
16
|
-
"DOM-ready guard at end of file preserved (if/else is correct, not a duplicate)"
|
|
17
|
-
],
|
|
18
|
-
"edge_cases": [
|
|
19
|
-
"Methods cross-call — must verify all internal references resolve after split"
|
|
20
|
-
]
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
"id": "split-websocket-manager",
|
|
24
|
-
"subject": "Split websocket-manager.js into connection and messaging files",
|
|
25
|
-
"status": "pending",
|
|
26
|
-
"description": "websocket-manager.js is 650L with a single WebSocketManager class. Split into: (1) ws-connection.js — raw WebSocket open/close/reconnect logic, (2) ws-subscriptions.js — subscribe/unsubscribe/broadcast logic, (3) websocket-manager.js — coordinator that imports both (≤200L each).",
|
|
27
|
-
"effort": "large",
|
|
28
|
-
"category": "refactor",
|
|
29
|
-
"blocking": [],
|
|
30
|
-
"blockedBy": [],
|
|
31
|
-
"acceptance": [
|
|
32
|
-
"websocket-manager.js ≤200 lines",
|
|
33
|
-
"ws-connection.js ≤200 lines",
|
|
34
|
-
"ws-subscriptions.js ≤200 lines",
|
|
35
|
-
"All three loaded in index.html in correct order",
|
|
36
|
-
"wsManager API unchanged from client.js perspective"
|
|
37
|
-
],
|
|
38
|
-
"edge_cases": [
|
|
39
|
-
"ws-machine.js wraps WebSocketManager — must still find it at window.wsManager"
|
|
40
|
-
]
|
|
41
|
-
}
|
|
42
|
-
]
|