@myassis/gateway 1.0.85 → 1.0.86

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.
@@ -366,6 +366,33 @@ router.get('/sessions/:sessionId/messages', ensureAgentManager, async (req, res)
366
366
  res.status(500).json({ success: false, error: 'Failed to get messages' });
367
367
  }
368
368
  });
369
+ /**
370
+ * GET /api/agent/sessions/:sessionId/messages/sync?sinceSeq=N
371
+ *
372
+ * 断线重连后的增量补拉。WebSocket 重连退避最长 30s,这期间的会话流
373
+ * 事件无法补发,不拉一次就会永久分歧。只回 seq 大于终端游标的部分,
374
+ * 同时回完整 id 列表用于剪除离线期间被删掉的消息。
375
+ *
376
+ * 注意路径要注册在 /messages/:messageId 类路由之前,否则 sync 会被当成 messageId。
377
+ */
378
+ router.get('/sessions/:sessionId/messages/sync', ensureAgentManager, async (req, res) => {
379
+ try {
380
+ const userId = req.userId;
381
+ const { sessionId } = req.params;
382
+ const sinceSeq = parseInt(req.query.sinceSeq, 10) || 0;
383
+ const session = (0, index_js_2.getSessionManager)(userId).getSession(sessionId);
384
+ if (!session) {
385
+ return res.status(404).json({ success: false, error: 'Session not found' });
386
+ }
387
+ session.loadMessages();
388
+ const result = session.getMessagesChangedSince(sinceSeq);
389
+ return res.json({ success: true, data: result });
390
+ }
391
+ catch (error) {
392
+ logger.error(`Sync messages error: ${error}`);
393
+ res.status(500).json({ success: false, error: 'Failed to sync messages' });
394
+ }
395
+ });
369
396
  /**
370
397
  * DELETE /api/agent/sessions/:sessionId/messages/:messageId
371
398
  * Delete a specific message
@@ -12,6 +12,7 @@ const axios_1 = __importDefault(require("axios"));
12
12
  const shared_1 = require("@myassis/shared");
13
13
  const os_1 = __importDefault(require("os"));
14
14
  const net_1 = __importDefault(require("net"));
15
+ const index_js_1 = require("../config/index.js");
15
16
  const logger = (0, shared_1.getLogger)('ServiceManager');
16
17
  const execAsync = (0, util_1.promisify)(child_process_1.exec);
17
18
  exports.SERVICE_NAME = 'myassis-gateway';
@@ -71,6 +72,51 @@ function getGatewayExePath() {
71
72
  const nodeExec = getNodeExec();
72
73
  return nodeExec;
73
74
  }
75
+ /**
76
+ * 探测 Gateway 是否真的在提供服务。
77
+ *
78
+ * 仅看 PID 是不够的:启错的进程(比如裸 node REPL)照样存活、PID 也写入了,
79
+ * start 会报「启动成功」而客户端根本连不上。这里真实请求一次 /health。
80
+ */
81
+ async function isGatewayServing(timeoutMs = 1500) {
82
+ try {
83
+ const res = await axios_1.default.get(`http://127.0.0.1:${index_js_1.appConfig.port}/health`, {
84
+ timeout: timeoutMs,
85
+ // 只要能返回 HTTP 响应就说明服务已监听,状态码不重要
86
+ validateStatus: () => true,
87
+ });
88
+ return !!res;
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ }
94
+ /**
95
+ * Gateway 启动需要的命令行参数。
96
+ *
97
+ * 打包为 exe 时 exe 本身就是程序,无需参数;
98
+ * npm install -g 安装时 exe 是 node.exe,必须把 dist/index.js 传给它——
99
+ * 否则启动的是一个没有脚本的裸 node(REPL),进程存在、PID 也写了,
100
+ * 但根本没有监听端口,表现为「start 报成功但连不上」。
101
+ */
102
+ function getGatewayArgs() {
103
+ if (isPackagedExe())
104
+ return [];
105
+ const script = getServiceScript();
106
+ return script ? [script] : [];
107
+ }
108
+ /**
109
+ * Gateway 运行的工作目录。
110
+ *
111
+ * 非打包模式下不能用 node.exe 所在目录(例如 C:\Program Files\nodejs),
112
+ * 否则相对路径资源与数据目录定位都会错;应该用包目录。
113
+ */
114
+ function getGatewayWorkDir() {
115
+ if (isPackagedExe())
116
+ return path_1.default.dirname(process.execPath);
117
+ const script = getServiceScript();
118
+ return script ? path_1.default.dirname(path_1.default.dirname(script)) : path_1.default.dirname(process.execPath);
119
+ }
74
120
  /**
75
121
  * 检查 Gateway 进程是否在运行
76
122
  */
@@ -281,9 +327,15 @@ async function stopGatewayProcess() {
281
327
  function writeGatewayLauncherScript(exePath, scriptPath, workDir) {
282
328
  // 写入 BOM + UTF-8 内容
283
329
  const bs = (p) => p.replace(/\\/g, '\\\\');
330
+ // npm 安装模式下 exe 是 node.exe,必须带上入口脚本才能真正启动服务
331
+ const launchArgs = getGatewayArgs();
332
+ const argListLiteral = launchArgs.length > 0
333
+ ? '@(' + launchArgs.map(a => `'${bs(a)}'`).join(', ') + ')'
334
+ : '@()';
284
335
  const content = [
285
336
  "$ErrorActionPreference = 'Continue'",
286
337
  `$exe = '${bs(exePath)}'`,
338
+ `$gatewayArgs = ${argListLiteral}`,
287
339
  `$workDir = '${bs(workDir)}'`,
288
340
  `$pidFile = '${bs(GATEWAY_PID_FILE)}'`,
289
341
  `$stopFlag = '${bs(GATEWAY_STOP_FLAG_FILE)}'`,
@@ -306,7 +358,13 @@ function writeGatewayLauncherScript(exePath, scriptPath, workDir) {
306
358
  ' # 1) 管道缓冲区填满后 gateway 卡死(4KB 缓冲区无人读取)',
307
359
  ' # 2) 父 PS 句柄继承导致 gateway 写 stdout 崩溃',
308
360
  ' try {',
309
- ' $proc = Start-Process -FilePath $exe -WorkingDirectory $workDir -WindowStyle Hidden -PassThru -ErrorAction Stop',
361
+ ' # 生产模式标记:main.ts 靠它判定是否重定向 stdout 到日志文件',
362
+ " $env:NODE_ENV = 'production'",
363
+ ' if ($gatewayArgs.Count -gt 0) {',
364
+ ' $proc = Start-Process -FilePath $exe -ArgumentList $gatewayArgs -WorkingDirectory $workDir -WindowStyle Hidden -PassThru -ErrorAction Stop',
365
+ ' } else {',
366
+ ' $proc = Start-Process -FilePath $exe -WorkingDirectory $workDir -WindowStyle Hidden -PassThru -ErrorAction Stop',
367
+ ' }',
310
368
  ' if ($proc -and $proc.Id) {',
311
369
  ' $proc.Id.ToString() | Out-File -FilePath $pidFile -Encoding UTF8',
312
370
  " Write-Log \"Gateway 已启动,PID=$($proc.Id)\"",
@@ -427,7 +485,7 @@ async function unregisterRunKey() {
427
485
  // ─── Windows 用户级实现 ─────────────────────────────────────
428
486
  async function installWindows() {
429
487
  const exe = getGatewayExePath();
430
- const workDir = path_1.default.dirname(exe);
488
+ const workDir = getGatewayWorkDir();
431
489
  logger.info(`installWindows: exe=${exe}, workDir=${workDir}`);
432
490
  const { installed } = await queryServiceWindows();
433
491
  if (installed)
@@ -469,7 +527,7 @@ async function uninstallWindows() {
469
527
  }
470
528
  }
471
529
  async function startServiceWindows() {
472
- if (isGatewayRunning()) {
530
+ if (isGatewayRunning() && await isGatewayServing()) {
473
531
  return { success: true, message: 'Gateway 已在运行' };
474
532
  }
475
533
  try {
@@ -479,20 +537,32 @@ async function startServiceWindows() {
479
537
  }
480
538
  catch { /* ignore */ }
481
539
  const exe = getGatewayExePath();
482
- const workDir = path_1.default.dirname(exe);
540
+ const workDir = getGatewayWorkDir();
483
541
  if (!fs_1.default.existsSync(GATEWAY_LAUNCHER_FILE)) {
484
542
  writeGatewayLauncherScript(exe, GATEWAY_LAUNCHER_FILE, workDir);
485
543
  }
486
544
  // 使用 Start-Process 在后台启动守护脚本(-WindowStyle Hidden 隐藏窗口)
487
545
  // 守护脚本会持续运行并监控 gateway 进程,异常时自动重启
488
546
  await execAsync(`powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath powershell.exe -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-WindowStyle','Hidden','-File','${GATEWAY_LAUNCHER_FILE}' -WindowStyle Hidden"`, { timeout: 5000, windowsHide: true });
489
- const maxWait = 8000;
547
+ // 等到真正能接请求为止:进程起来到监听端口之间还要跑迁移、加载模型等,
548
+ // 只等 PID 会在服务就绪前就报成功,紧跟着的连接就会失败。
549
+ const maxWait = 30000;
490
550
  const start = Date.now();
491
- while (!isGatewayRunning() && Date.now() - start < maxWait) {
551
+ let processSeen = false;
552
+ while (Date.now() - start < maxWait) {
553
+ if (isGatewayRunning()) {
554
+ processSeen = true;
555
+ if (await isGatewayServing()) {
556
+ return { success: true, message: 'Gateway 启动成功(已启用异常自动重启)' };
557
+ }
558
+ }
492
559
  await new Promise(r => setTimeout(r, 500));
493
560
  }
494
- if (isGatewayRunning()) {
495
- return { success: true, message: 'Gateway 启动成功(已启用异常自动重启)' };
561
+ if (processSeen) {
562
+ return {
563
+ success: false,
564
+ message: `Gateway 进程已启动但 ${index_js_1.appConfig.port} 端口未就绪,请查看日志: ${path_1.default.join(GATEWAY_DATA_DIR, 'gateway-daemon.log')}`,
565
+ };
496
566
  }
497
567
  return { success: false, message: 'Gateway 进程未能启动,请检查日志' };
498
568
  }
@@ -719,7 +789,7 @@ async function ensureAutoStartHealthy() {
719
789
  logger.warn(`检测到 Run key 不健康,自动修复。current='${currentValue}'`);
720
790
  // 4) 重写 launcher 脚本到新路径
721
791
  const exe = getGatewayExePath();
722
- const workDir = path_1.default.dirname(exe);
792
+ const workDir = getGatewayWorkDir();
723
793
  writeGatewayLauncherScript(exe, GATEWAY_LAUNCHER_FILE, workDir);
724
794
  // 5) 重新注册完整 powershell 命令行
725
795
  await registerRunKey(GATEWAY_LAUNCHER_FILE);
@@ -64,12 +64,28 @@ class MemoryManager {
64
64
  config = DEFAULT_CONFIG;
65
65
  childAgent;
66
66
  res;
67
+ mirror;
67
68
  summaryModels = null;
68
- constructor(session, signal, childAgent, res = null) {
69
+ constructor(session, signal, childAgent, res = null, mirror = null) {
69
70
  this.session = session;
70
71
  this.signal = signal;
71
72
  this.childAgent = childAgent;
72
73
  this.res = res;
74
+ this.mirror = mirror;
75
+ }
76
+ /**
77
+ * 发送 SSE 事件:先镜像给该用户的其他终端,再写回发起端。
78
+ *
79
+ * 旧实现只写 res,导致「上下文压缩中」等事件无法多终端同步。
80
+ */
81
+ emitSSE(data) {
82
+ try {
83
+ this.mirror?.(data);
84
+ }
85
+ catch {
86
+ // 镜像失败不影响发起端
87
+ }
88
+ sendSSE(this.res, data);
73
89
  }
74
90
  /**
75
91
  * 构造注入上下文的摘要消息。
@@ -421,7 +437,7 @@ ${conversation}
421
437
  async generateSummaryAsync(messages, lastSummary) {
422
438
  // 非 childAgent 会话通知 Desktop 显示"上下文压缩中"
423
439
  if (!this.childAgent) {
424
- sendSSE(this.res, { type: 'context_compressing' });
440
+ this.emitSSE({ type: 'context_compressing' });
425
441
  }
426
442
  const MAX_INPUT_CHARS = 30000; // 单次摘要最大输入字符数
427
443
  // 格式化所有消息用于估算长度
@@ -440,6 +440,25 @@ class Session {
440
440
  getMessageCount() {
441
441
  return this.messages.length;
442
442
  }
443
+ /**
444
+ * 取断线期间的消息变更(新增 / 修改 / 删除)。
445
+ *
446
+ * WebSocket 重连的退避最长 30s,这期间错过的会话流事件无法补发,
447
+ * 而重连后又没有任何信号告诉终端「你落后了」,各终端就会永久分歧。
448
+ * 终端报上自己的 seq 游标,这里只回变更部分。
449
+ *
450
+ * @param sinceSeq 终端已知的最大 seq,0 表示全量
451
+ */
452
+ getMessagesChangedSince(sinceSeq) {
453
+ const store = this.store;
454
+ return {
455
+ messages: store.findMessagesChangedSince(this.id, sinceSeq),
456
+ // 删除不留痕迹,光靠 seq 发现不了离线期间被删的消息,
457
+ // 因此一并回完整 id 列表让终端剪除多余项
458
+ messageIds: store.findMessageIdsBySessionId(this.id),
459
+ maxSeq: store.getMaxMessageSeq(),
460
+ };
461
+ }
443
462
  /**
444
463
  * Get messages by page (descending order)
445
464
  */
@@ -691,8 +710,6 @@ class Session {
691
710
  const settings = await dataService_js_1.settingsService.get(token);
692
711
  const streamDelay = this.getStreamDelay(settings.streamSpeed);
693
712
  this.currentMessageId = assistantMessageId;
694
- const memoryManager = new MemoryManager_js_1.MemoryManager(this, this.abortController.signal, childAgent, res);
695
- const historyMessages = await memoryManager.getHistoryMessagesAsync();
696
713
  // 同一用户可能同时在多个终端登录,而 SSE 只能回给发起请求的那个终端。
697
714
  // 这里预先取到 WebSocketService,把每个 SSE 事件同步给该用户的其他连接,
698
715
  // 从而让所有终端看到同一份会话流。子 Agent 的内部流不需要同步。
@@ -719,6 +736,25 @@ class Session {
719
736
  logger.error('SSE mirror error:', error);
720
737
  }
721
738
  };
739
+ // 其他终端没有参与本次请求,需要补一条用户消息才能对齐会话。
740
+ // 必须在 message_start 之前同步:否则接收端会先插入助手占位,
741
+ // 导致助手消息排在用户消息前面。
742
+ //
743
+ // 同时带上助手占位标记:接收端据此在用户消息后面立即插入「思考中」
744
+ // 占位气泡,与发起端表现一致(占位文案由接收端按自身语言生成)。
745
+ if (!childAgent) {
746
+ mirrorToOtherClients({
747
+ type: 'user_message',
748
+ message: this.getUserMessage(content, userMessageId, attachments),
749
+ assistantPlaceholder: true,
750
+ });
751
+ }
752
+ // 必须在加载历史(可能触发上下文压缩)之前发出:
753
+ // context_compressing 是对助手消息的原地更新,接收端必须先有占位气泡。
754
+ // MemoryManager 会在压缩历史时发出 context_compressing,
755
+ // 把镜像函数传进去,使该事件也能同步到其他终端。
756
+ const memoryManager = new MemoryManager_js_1.MemoryManager(this, this.abortController.signal, childAgent, res, mirrorToOtherClients);
757
+ const historyMessages = await memoryManager.getHistoryMessagesAsync();
722
758
  // SSE 辅助方法:res 为 null 时跳过写入(本地执行模式),但仍同步给其他终端
723
759
  const sendSSE = (res, data) => {
724
760
  mirrorToOtherClients(data);
@@ -741,19 +777,6 @@ class Session {
741
777
  clearInterval(heartbeatInterval);
742
778
  }
743
779
  }, 15000);
744
- // 其他终端没有参与本次请求,需要补一条用户消息才能对齐会话。
745
- // 必须在 message_start 之前同步:否则接收端会先插入助手占位,
746
- // 导致助手消息排在用户消息前面。
747
- //
748
- // 同时带上助手占位标记:接收端据此在用户消息后面立即插入「思考中」
749
- // 占位气泡,与发起端表现一致(占位文案由接收端按自身语言生成)。
750
- if (!childAgent) {
751
- mirrorToOtherClients({
752
- type: 'user_message',
753
- message: this.getUserMessage(content, userMessageId, attachments),
754
- assistantPlaceholder: true,
755
- });
756
- }
757
780
  // Send message start event
758
781
  sendSSE(res, { type: 'message_start' });
759
782
  // Build messages for API call (保留 tool_call_id 等必要字段)
@@ -141,11 +141,36 @@ class SessionStore {
141
141
  return rows.map(row => this.rowToSessionData(row));
142
142
  }
143
143
  // ========== Message Operations ==========
144
+ /**
145
+ * 取下一个消息行版本号。
146
+ *
147
+ * 刷新(insert / update)都要取新号,因此 seq 是「行版本」而不是「插入序号」:
148
+ * 助手消息在一轮里会被反复更新,只有每次都抬号,断线重连的终端才能
149
+ * 靠「seq > 本地游标」把被改过的消息也拉回来。
150
+ *
151
+ * 刷号器单独持久化(message_seq 表),不用 MAX(seq)+1:
152
+ * 删掉最后一条消息后 MAX 会回退,号被重用,已经看过该 seq 的终端就会
153
+ * 永久错过新消息。
154
+ *
155
+ * 注意排序仍用 created_at:seq 只作为变更游标,抬号不得改变消息在界面上的位置。
156
+ */
157
+ nextSeq() {
158
+ // 用 UPDATE ... RETURNING 一步完成自增与读取:
159
+ // 每个 Session 都新建一个 SessionStore(各自一个到同一文件的连接),
160
+ // 先 UPDATE 再 SELECT 在连接间不原子,会发出重复的 seq。
161
+ const row = this.db.prepare('UPDATE message_seq SET value = value + 1 WHERE id = 1 RETURNING value').get();
162
+ return row?.value ?? 1;
163
+ }
164
+ /** 当前最大消息行版本号(终端用作同步游标基准) */
165
+ getMaxMessageSeq() {
166
+ const row = this.db.prepare('SELECT value FROM message_seq WHERE id = 1').get();
167
+ return row?.value ?? 0;
168
+ }
144
169
  insertMessage(sessionId, msg) {
145
170
  this.db.prepare(`
146
- INSERT INTO messages (id, session_id, role, content, attachments, tool_calls, created_at, updated_at, model_name, feedback)
147
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
148
- `).run(msg.id, sessionId, msg.role, msg.content, JSON.stringify(msg.attachments || []), msg.toolCalls ? JSON.stringify(msg.toolCalls) : null, msg.createdAt, msg.updatedAt || msg.createdAt, msg.modelName || null, msg.feedback || null);
171
+ INSERT INTO messages (id, session_id, role, content, attachments, tool_calls, created_at, updated_at, model_name, feedback, seq)
172
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
173
+ `).run(msg.id, sessionId, msg.role, msg.content, JSON.stringify(msg.attachments || []), msg.toolCalls ? JSON.stringify(msg.toolCalls) : null, msg.createdAt, msg.updatedAt || msg.createdAt, msg.modelName || null, msg.feedback || null, this.nextSeq());
149
174
  }
150
175
  /**
151
176
  * 写入或更新消息(按 id 幂等)。
@@ -159,32 +184,33 @@ class SessionStore {
159
184
  */
160
185
  upsertMessage(sessionId, msg) {
161
186
  this.db.prepare(`
162
- INSERT INTO messages (id, session_id, role, content, attachments, tool_calls, created_at, updated_at, model_name, feedback)
163
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
187
+ INSERT INTO messages (id, session_id, role, content, attachments, tool_calls, created_at, updated_at, model_name, feedback, seq)
188
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
164
189
  ON CONFLICT(id) DO UPDATE SET
165
190
  content = excluded.content,
166
191
  attachments = excluded.attachments,
167
192
  tool_calls = excluded.tool_calls,
168
193
  updated_at = excluded.updated_at,
169
194
  model_name = excluded.model_name,
170
- feedback = excluded.feedback
171
- `).run(msg.id, sessionId, msg.role, msg.content, JSON.stringify(msg.attachments || []), msg.toolCalls ? JSON.stringify(msg.toolCalls) : null, msg.createdAt, msg.updatedAt || msg.createdAt, msg.modelName || null, msg.feedback || null);
195
+ feedback = excluded.feedback,
196
+ seq = excluded.seq
197
+ `).run(msg.id, sessionId, msg.role, msg.content, JSON.stringify(msg.attachments || []), msg.toolCalls ? JSON.stringify(msg.toolCalls) : null, msg.createdAt, msg.updatedAt || msg.createdAt, msg.modelName || null, msg.feedback || null, this.nextSeq());
172
198
  }
173
199
  insertMessages(sessionId, messages) {
174
200
  const stmt = this.db.prepare(`
175
- INSERT INTO messages (id, session_id, role, content, attachments, tool_calls,created_at, updated_at, model_name, feedback)
176
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
201
+ INSERT INTO messages (id, session_id, role, content, attachments, tool_calls,created_at, updated_at, model_name, feedback, seq)
202
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
177
203
  `);
178
204
  for (const msg of messages) {
179
- stmt.run(msg.id, sessionId, msg.role, msg.content, JSON.stringify(msg.attachments || []), msg.toolCalls ? JSON.stringify(msg.toolCalls) : null, msg.createdAt, msg.updatedAt || msg.createdAt, msg.modelName || null, msg.feedback || null);
205
+ stmt.run(msg.id, sessionId, msg.role, msg.content, JSON.stringify(msg.attachments || []), msg.toolCalls ? JSON.stringify(msg.toolCalls) : null, msg.createdAt, msg.updatedAt || msg.createdAt, msg.modelName || null, msg.feedback || null, this.nextSeq());
180
206
  }
181
207
  }
182
208
  updateMessage(sessionId, msg) {
183
209
  this.db.prepare(`
184
210
  UPDATE messages
185
- SET content = ?, attachments = ?, tool_calls = ?, updated_at = ?, model_name = ?, feedback = ?
211
+ SET content = ?, attachments = ?, tool_calls = ?, updated_at = ?, model_name = ?, feedback = ?, seq = ?
186
212
  WHERE id = ? AND session_id = ?
187
- `).run(msg.content, JSON.stringify(msg.attachments || []), msg.toolCalls ? JSON.stringify(msg.toolCalls) : null, msg.updatedAt || msg.createdAt, msg.modelName || null, msg.feedback || null, msg.id, sessionId);
213
+ `).run(msg.content, JSON.stringify(msg.attachments || []), msg.toolCalls ? JSON.stringify(msg.toolCalls) : null, msg.updatedAt || msg.createdAt, msg.modelName || null, msg.feedback || null, this.nextSeq(), msg.id, sessionId);
188
214
  }
189
215
  deleteMessage(messageId) {
190
216
  this.db.prepare('DELETE FROM messages WHERE id = ?').run(messageId);
@@ -194,7 +220,29 @@ class SessionStore {
194
220
  }
195
221
  findMessagesBySessionId(sessionId) {
196
222
  const rows = this.db.prepare('SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC').all(sessionId);
197
- return rows.map(row => ({
223
+ return rows.map(row => this.rowToMessage(row));
224
+ }
225
+ /**
226
+ * 取会话内 seq 大于 sinceSeq 的消息(新增与被修改的都在内)。
227
+ *
228
+ * 终端断线重连后报上自己的游标,只拉缺失的部分,不必全量重拉。
229
+ */
230
+ findMessagesChangedSince(sessionId, sinceSeq) {
231
+ const rows = this.db.prepare('SELECT * FROM messages WHERE session_id = ? AND seq > ? ORDER BY seq ASC').all(sessionId, sinceSeq);
232
+ return rows.map(row => this.rowToMessage(row));
233
+ }
234
+ /**
235
+ * 取会话内全部消息 id(按展示顺序)。
236
+ *
237
+ * 删除不会留下带 seq 的行,光靠增量查询发现不了「离线期间被删掉的消息」,
238
+ * 因此同步时一并返回 id 列表让终端剪除多余消息。
239
+ */
240
+ findMessageIdsBySessionId(sessionId) {
241
+ const rows = this.db.prepare('SELECT id FROM messages WHERE session_id = ? ORDER BY created_at ASC').all(sessionId);
242
+ return rows.map(row => row.id);
243
+ }
244
+ rowToMessage(row) {
245
+ return {
198
246
  id: row.id,
199
247
  sessionId: row.session_id,
200
248
  role: row.role,
@@ -204,8 +252,9 @@ class SessionStore {
204
252
  createdAt: row.created_at,
205
253
  updatedAt: row.updated_at,
206
254
  modelName: row.model_name,
207
- feedback: row.feedback || undefined
208
- }));
255
+ feedback: row.feedback || undefined,
256
+ seq: row.seq ?? undefined
257
+ };
209
258
  }
210
259
  rowToSessionData(row) {
211
260
  return {
@@ -0,0 +1,27 @@
1
+ -- 为消息表增加全局单调递增的 seq,用于多终端增量同步。
2
+ --
3
+ -- 原先消息只按 created_at 排序,没有单调游标:WebSocket 断线重连
4
+ -- (退避最长 30s)期间丢的事件无法补拉,各终端会永久分歧。
5
+ -- 有了 seq,终端只需报上自己看到的最大 seq 就能拉回缺失部分。
6
+ ALTER TABLE messages ADD COLUMN seq INTEGER;
7
+
8
+ -- 存量数据按 created_at(同时间再按 id)回填,保证与原有展示顺序一致
9
+ UPDATE messages SET seq = (
10
+ SELECT COUNT(*) FROM messages m2
11
+ WHERE m2.created_at < messages.created_at
12
+ OR (m2.created_at = messages.created_at AND m2.id <= messages.id)
13
+ ) WHERE seq IS NULL;
14
+
15
+ CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq);
16
+ CREATE INDEX IF NOT EXISTS idx_messages_session_seq ON messages(session_id, seq);
17
+
18
+ -- seq 发号器单独持久化:不能用 MAX(seq)+1 发号,
19
+ -- 删除最后一条消息后号会被重用,已经看到该 seq 的终端就会永久错过新消息。
20
+ CREATE TABLE IF NOT EXISTS message_seq (
21
+ id INTEGER PRIMARY KEY,
22
+ value INTEGER NOT NULL
23
+ );
24
+
25
+ INSERT INTO message_seq (id, value)
26
+ SELECT 1, IFNULL((SELECT MAX(seq) FROM messages), 0)
27
+ WHERE NOT EXISTS (SELECT 1 FROM message_seq WHERE id = 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myassis/gateway",
3
- "version": "1.0.85",
3
+ "version": "1.0.86",
4
4
  "description": "我的助手 Gateway Service - 本地 AI 网关服务,支持认证、WebSocket 实时通信和任务调度",
5
5
  "main": "dist/index.js",
6
6
  "bin": {