incognitolm commited on
Commit
8a206ae
·
1 Parent(s): 6cf0e88
Files changed (2) hide show
  1. server/index.js +4 -1
  2. server/wsHandler.js +137 -83
server/index.js CHANGED
@@ -95,7 +95,10 @@ wss.on('connection', (ws, req) => {
95
 
96
  ws.on('message', async raw => {
97
  try { await handleWsMessage(ws, JSON.parse(raw.toString()), wsClients); }
98
- catch (ex) { safeSend(ws, { type: 'error', message: 'Invalid message' }); console.log("Invalid message: " + ex); }
 
 
 
99
  });
100
  ws.on('close', () => {
101
  const c = wsClients.get(ws);
 
95
 
96
  ws.on('message', async raw => {
97
  try { await handleWsMessage(ws, JSON.parse(raw.toString()), wsClients); }
98
+ catch (ex) {
99
+ console.error("Invalid message error:", ex.message, "\nStack:", ex.stack);
100
+ safeSend(ws, { type: 'error', message: 'Invalid message: ' + ex.message });
101
+ }
102
  });
103
  ws.on('close', () => {
104
  const c = wsClients.get(ws);
server/wsHandler.js CHANGED
@@ -13,25 +13,31 @@ import { streamChat, extractSessionName } from './chatStream.js';
13
  import crypto from 'crypto';
14
 
15
  /**
16
- * Message Structure Documentation
 
 
 
17
  *
18
- * Each message in session.history contains:
19
  * {
20
- * id: "msg-123...", // Unique identifier for this message
21
  * role: "user" | "assistant",
22
  * content: string | array,
23
  * timestamp: number,
24
  * versions: [
25
- * {
26
  * content: string | array,
27
- * nextMessageId: "msg-456..." | null, // ID of next message when this version is active
 
 
 
28
  * timestamp: number
29
  * },
30
  * ...
31
  * ],
32
- * currentVersionIdx: 0, // Which version is currently active
33
- * toolCalls?: [...], // For assistant messages with tool calls
34
- * }\n *\n * VERSION HISTORY: Changed from storing full message arrays in \"tail\" field\n * to storing only nextMessageId references. This reduces payload size significantly\n * while maintaining backward compatibility through normalizeHistoryStructure().\n *\n * HISTORY RECONSTRUCTION: When switching versions, the message chain is rebuilt\n * by following nextMessageId pointers from the selected version.\n */
 
35
 
36
  const activeStreams = new Map();
37
 
@@ -56,7 +62,6 @@ function bcast(wsClients, userId, data, excludeWs) {
56
  const handlers = {
57
  'ping': (ws) => { safeSend(ws, { type: 'pong' }); },
58
 
59
- // Verify turnstile token sent over websocket
60
  'turnstile:verify': async (ws, msg, client) => {
61
  try {
62
  const token = msg?.token;
@@ -81,7 +86,6 @@ const handlers = {
81
  client.deviceToken = deviceSessionStore.create(user.id, client.ip, client.userAgent);
82
  sessionStore.markOnline(user.id, ws);
83
 
84
- // If the client provided a persistent tempId, use and save it on the ws client
85
  if (clientTempId) client.tempId = clientTempId;
86
  const tId = client.tempId;
87
  await sessionStore.transferTempToUser(tId, user.id, accessToken);
@@ -106,7 +110,6 @@ const handlers = {
106
  },
107
 
108
  'auth:guest': (ws, msg, client) => {
109
- // Honor a client-supplied persistent tempId and persist it on the ws client
110
  const t = msg.tempId || client.tempId;
111
  client.tempId = t;
112
  sessionStore.initTemp(t);
@@ -191,9 +194,17 @@ const handlers = {
191
  let fullText = '';
192
  const assetsCollected = [], toolCallsCollected = [];
193
 
 
 
 
 
194
  await streamChat(ws, {
195
- history: normalizeHistoryStructure(session.history || []), userMessage: content, tools: tools || {},
196
- accessToken: client.accessToken, clientId: msg.clientId, abortSignal: abort.signal,
 
 
 
 
197
  onToken(t) { fullText += t; safeSend(ws, { type: 'chat:token', token: t, sessionId }); },
198
  onToolCall(call) {
199
  safeSend(ws, { type: 'chat:toolCall', call, sessionId });
@@ -211,34 +222,39 @@ const handlers = {
211
  return { ...c, state: resolved.state || 'resolved', result: resolved.result };
212
  });
213
  const asstEntry = buildEntry('assistant', finalText, mergedCalls);
214
- let newHistory = normalizeHistoryStructure([...(session.history || []), userEntry, asstEntry]);
215
-
216
- // Update nextMessageId pointers for all versioned messages to link to the next message
217
- for (let i = 0; i < newHistory.length - 1; i++) {
218
- const msg = newHistory[i];
219
- const nextMsg = newHistory[i + 1];
220
- if (msg.versions) {
221
- for (const version of msg.versions) {
222
- if (!version.nextMessageId) {
223
- version.nextMessageId = nextMsg.id;
224
- }
225
- }
226
- }
 
 
 
 
227
  }
228
 
229
- // Use session name from XML tag; fall back to existing name or default
 
230
  let newName = session.name;
231
  if (sessionNameFromTag) {
232
  newName = sessionNameFromTag;
233
  } else if (!session.history?.length || session.name === 'New Chat') {
234
- // No tag and first message — keep as "New Chat" (no extra API call)
235
  newName = session.name;
236
  }
237
 
238
  if (client.userId)
239
  await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory, name: newName });
240
  else sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory, name: newName });
241
- safeSend(ws, { type: aborted ? 'chat:aborted' : 'chat:done', sessionId, name: newName, history: newHistory });
 
242
  },
243
  onError(err) { activeStreams.delete(ws); safeSend(ws, { type: 'chat:error', error: String(err), sessionId }); },
244
  });
@@ -252,25 +268,41 @@ const handlers = {
252
  ? sessionStore.getUserSession(client.userId, sessionId)
253
  : sessionStore.getTempSession(client.tempId, sessionId);
254
  if (!session) return;
255
- const history = normalizeHistoryStructure(session.history || []);
256
- const m = history[messageIndex];
257
- if (!m) return;
258
- const currentVersionIdx = m.currentVersionIdx ?? 0;
259
- const currentVersion = m.versions?.[currentVersionIdx];
260
- const nextMessageId = currentVersion?.nextMessageId || (history[messageIndex + 1]?.id || null);
261
- if (!m.versions) {
262
- m.versions = [{ content: m.content, nextMessageId: nextMessageId, timestamp: m.timestamp || Date.now() }];
263
- }
264
- m.versions.push({ content: newContent, nextMessageId: nextMessageId, timestamp: Date.now() });
265
- m.currentVersionIdx = m.versions.length - 1;
266
- m.content = newContent;
267
- const newHistory = history.slice(0, messageIndex + 1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  if (client.userId) {
269
  await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory });
270
  } else {
271
  sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory });
272
  }
273
- safeSend(ws, { type: 'chat:messageEdited', sessionId, messageIndex, message: m, history: newHistory });
 
274
  },
275
 
276
  'chat:selectVersion': async (ws, msg, client) => {
@@ -279,18 +311,32 @@ const handlers = {
279
  ? sessionStore.getUserSession(client.userId, sessionId)
280
  : sessionStore.getTempSession(client.tempId, sessionId);
281
  if (!session) return;
282
- const history = normalizeHistoryStructure(session.history || []);
283
- const m = history[messageIndex];
284
- if (!m?.versions || versionIdx < 0 || versionIdx >= m.versions.length) return;
285
- m.currentVersionIdx = versionIdx;
286
- m.content = m.versions[versionIdx].content;
287
- const newHistory = reconstructHistoryFromVersion(history, messageIndex, versionIdx);
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  if (client.userId) {
289
  await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory });
290
  } else {
291
  sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory });
292
  }
293
- safeSend(ws, { type: 'chat:versionSelected', sessionId, history: newHistory });
 
294
  },
295
 
296
  'settings:get': async (ws, msg, client) => {
@@ -341,45 +387,53 @@ function buildEntry(role, content, toolCalls = []) {
341
  role,
342
  content,
343
  timestamp: Date.now(),
344
- versions: [{ content, nextMessageId: null, timestamp: Date.now() }],
345
  currentVersionIdx: 0,
346
  ...(normalizedCalls.length ? { toolCalls: normalizedCalls } : {})
347
  };
348
  }
349
 
350
- function normalizeHistoryStructure(history) {
351
- if (!history || !Array.isArray(history)) return [];
352
- const normalized = history.map((msg, idx) => {
353
- if (!msg.id) msg.id = `msg-${msg.timestamp || Date.now()}-${idx}`;
354
- if (msg.versions && Array.isArray(msg.versions)) {
355
- msg.versions = msg.versions.map(v => {
356
- if (v.tail !== undefined && !v.nextMessageId) {
357
- v.nextMessageId = v.tail && v.tail.length > 0 ? v.tail[0].id : null;
 
 
 
 
 
358
  }
359
- if (!v.nextMessageId) v.nextMessageId = null;
360
- return v;
361
- });
362
- }
363
- return msg;
364
- });
365
- return normalized;
366
  }
367
 
368
- function reconstructHistoryFromVersion(history, messageIndex, versionIdx) {
369
- const baseHistory = history.slice(0, messageIndex + 1);
370
- const branchMessage = baseHistory[messageIndex];
371
- if (!branchMessage?.versions || versionIdx >= branchMessage.versions.length) {
372
- return baseHistory;
373
- }
374
- const version = branchMessage.versions[versionIdx];
375
- if (!version.nextMessageId) return baseHistory;
376
- const reconstructed = [...baseHistory];
377
- let currentId = version.nextMessageId;
378
- while (currentId) {
379
- const nextMsg = history.find(m => m.id === currentId);
380
- if (!nextMsg) break;
381
- reconstructed.push(nextMsg);
382
- currentId = nextMsg.versions?.[nextMsg.currentVersionIdx]?.nextMessageId || null;
383
  }
384
- return reconstructed;
385
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  import crypto from 'crypto';
14
 
15
  /**
16
+ * Message Structure: Tree-based with versioned tails
17
+ *
18
+ * Each message has versions, and each version has a complete tail of subsequent messages.
19
+ * Messages only exist within parent tails (no separate flat array).
20
  *
 
21
  * {
22
+ * id: "msg-123",
23
  * role: "user" | "assistant",
24
  * content: string | array,
25
  * timestamp: number,
26
  * versions: [
27
+ * {
28
  * content: string | array,
29
+ * tail: [ // Full message objects
30
+ * { id, role, content, timestamp, versions: [...], currentVersionIdx, ... },
31
+ * ...
32
+ * ],
33
  * timestamp: number
34
  * },
35
  * ...
36
  * ],
37
+ * currentVersionIdx: 0,
38
+ * toolCalls?: [...]
39
+ * }
40
+ */
41
 
42
  const activeStreams = new Map();
43
 
 
62
  const handlers = {
63
  'ping': (ws) => { safeSend(ws, { type: 'pong' }); },
64
 
 
65
  'turnstile:verify': async (ws, msg, client) => {
66
  try {
67
  const token = msg?.token;
 
86
  client.deviceToken = deviceSessionStore.create(user.id, client.ip, client.userAgent);
87
  sessionStore.markOnline(user.id, ws);
88
 
 
89
  if (clientTempId) client.tempId = clientTempId;
90
  const tId = client.tempId;
91
  await sessionStore.transferTempToUser(tId, user.id, accessToken);
 
110
  },
111
 
112
  'auth:guest': (ws, msg, client) => {
 
113
  const t = msg.tempId || client.tempId;
114
  client.tempId = t;
115
  sessionStore.initTemp(t);
 
194
  let fullText = '';
195
  const assetsCollected = [], toolCallsCollected = [];
196
 
197
+ // Extract flat history from tree structure
198
+ const rootMessage = session.history?.[0];
199
+ const flatHistory = rootMessage ? extractFlatHistory(rootMessage) : [];
200
+
201
  await streamChat(ws, {
202
+ history: flatHistory,
203
+ userMessage: content,
204
+ tools: tools || {},
205
+ accessToken: client.accessToken,
206
+ clientId: msg.clientId,
207
+ abortSignal: abort.signal,
208
  onToken(t) { fullText += t; safeSend(ws, { type: 'chat:token', token: t, sessionId }); },
209
  onToolCall(call) {
210
  safeSend(ws, { type: 'chat:toolCall', call, sessionId });
 
222
  return { ...c, state: resolved.state || 'resolved', result: resolved.result };
223
  });
224
  const asstEntry = buildEntry('assistant', finalText, mergedCalls);
225
+
226
+ // Rebuild tree structure with new messages appended
227
+ let newRootMessage = rootMessage ? JSON.parse(JSON.stringify(rootMessage)) : null;
228
+
229
+ if (!newRootMessage) {
230
+ // First message in session
231
+ newRootMessage = userEntry;
232
+ const asstWrap = { ...asstEntry };
233
+ newRootMessage.versions[0].tail = [asstWrap];
234
+ } else {
235
+ // Append to current tail
236
+ const currentVerIdx = newRootMessage.currentVersionIdx ?? 0;
237
+ let currentTail = newRootMessage.versions[currentVerIdx].tail || [];
238
+ currentTail = JSON.parse(JSON.stringify(currentTail));
239
+ currentTail.push(userEntry);
240
+ currentTail.push(asstEntry);
241
+ newRootMessage.versions[currentVerIdx].tail = currentTail;
242
  }
243
 
244
+ const newHistory = [newRootMessage];
245
+
246
  let newName = session.name;
247
  if (sessionNameFromTag) {
248
  newName = sessionNameFromTag;
249
  } else if (!session.history?.length || session.name === 'New Chat') {
 
250
  newName = session.name;
251
  }
252
 
253
  if (client.userId)
254
  await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory, name: newName });
255
  else sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory, name: newName });
256
+
257
+ safeSend(ws, { type: aborted ? 'chat:aborted' : 'chat:done', sessionId, name: newName, history: extractFlatHistory(newRootMessage) });
258
  },
259
  onError(err) { activeStreams.delete(ws); safeSend(ws, { type: 'chat:error', error: String(err), sessionId }); },
260
  });
 
268
  ? sessionStore.getUserSession(client.userId, sessionId)
269
  : sessionStore.getTempSession(client.tempId, sessionId);
270
  if (!session) return;
271
+
272
+ const rootMessage = session.history?.[0];
273
+ if (!rootMessage) return;
274
+
275
+ const flatHistory = extractFlatHistory(rootMessage);
276
+ const targetMsg = flatHistory[messageIndex];
277
+ if (!targetMsg) return;
278
+
279
+ // Find the target message in the tree and add new version
280
+ const newRoot = JSON.parse(JSON.stringify(rootMessage));
281
+ const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
282
+ // Get current tail
283
+ const currentVerIdx = msgInTree.currentVersionIdx ?? 0;
284
+ const currentTail = msgInTree.versions[currentVerIdx]?.tail || [];
285
+
286
+ // Add new version with same tail as current
287
+ msgInTree.versions.push({
288
+ content: newContent,
289
+ tail: JSON.parse(JSON.stringify(currentTail)),
290
+ timestamp: Date.now()
291
+ });
292
+ msgInTree.currentVersionIdx = msgInTree.versions.length - 1;
293
+ msgInTree.content = newContent;
294
+ });
295
+
296
+ if (!found) return;
297
+
298
+ const newHistory = [newRoot];
299
  if (client.userId) {
300
  await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory });
301
  } else {
302
  sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory });
303
  }
304
+
305
+ safeSend(ws, { type: 'chat:messageEdited', sessionId, messageIndex, message: targetMsg, history: extractFlatHistory(newRoot) });
306
  },
307
 
308
  'chat:selectVersion': async (ws, msg, client) => {
 
311
  ? sessionStore.getUserSession(client.userId, sessionId)
312
  : sessionStore.getTempSession(client.tempId, sessionId);
313
  if (!session) return;
314
+
315
+ const rootMessage = session.history?.[0];
316
+ if (!rootMessage) return;
317
+
318
+ const flatHistory = extractFlatHistory(rootMessage);
319
+ const targetMsg = flatHistory[messageIndex];
320
+ if (!targetMsg || !targetMsg.versions || versionIdx >= targetMsg.versions.length) return;
321
+
322
+ // Find and update the message in tree, switching to specified version
323
+ const newRoot = JSON.parse(JSON.stringify(rootMessage));
324
+ const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
325
+ msgInTree.currentVersionIdx = versionIdx;
326
+ msgInTree.content = msgInTree.versions[versionIdx].content;
327
+ // Tail is automatically correct since each version has its own tail
328
+ });
329
+
330
+ if (!found) return;
331
+
332
+ const newHistory = [newRoot];
333
  if (client.userId) {
334
  await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory });
335
  } else {
336
  sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory });
337
  }
338
+
339
+ safeSend(ws, { type: 'chat:versionSelected', sessionId, history: extractFlatHistory(newRoot) });
340
  },
341
 
342
  'settings:get': async (ws, msg, client) => {
 
387
  role,
388
  content,
389
  timestamp: Date.now(),
390
+ versions: [{ content, tail: [], timestamp: Date.now() }],
391
  currentVersionIdx: 0,
392
  ...(normalizedCalls.length ? { toolCalls: normalizedCalls } : {})
393
  };
394
  }
395
 
396
+ function extractFlatHistory(rootMessage) {
397
+ if (!rootMessage) return [];
398
+ const history = [rootMessage];
399
+ const currentVerIdx = rootMessage.currentVersionIdx ?? 0;
400
+ const currentTail = rootMessage.versions[currentVerIdx]?.tail;
401
+
402
+ if (currentTail && Array.isArray(currentTail)) {
403
+ const walkTail = (tail) => {
404
+ for (const msg of tail) {
405
+ history.push(msg);
406
+ const ver = msg.versions?.[msg.currentVersionIdx ?? 0];
407
+ if (ver?.tail && Array.isArray(ver.tail)) {
408
+ walkTail(ver.tail);
409
  }
410
+ }
411
+ };
412
+ walkTail(currentTail);
413
+ }
414
+ return history;
 
 
415
  }
416
 
417
+ function findAndUpdateMessage(rootMessage, targetId, updateFn) {
418
+ if (rootMessage.id === targetId) {
419
+ updateFn(rootMessage);
420
+ return true;
 
 
 
 
 
 
 
 
 
 
 
421
  }
422
+
423
+ const search = (msg) => {
424
+ const verIdx = msg.currentVersionIdx ?? 0;
425
+ const tail = msg.versions?.[verIdx]?.tail;
426
+ if (!tail || !Array.isArray(tail)) return false;
427
+
428
+ for (const child of tail) {
429
+ if (child.id === targetId) {
430
+ updateFn(child);
431
+ return true;
432
+ }
433
+ if (search(child)) return true;
434
+ }
435
+ return false;
436
+ };
437
+
438
+ return search(rootMessage);
439
+ }