@myassis/gateway 1.0.84 → 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.
- package/dist/routes/agent.js +30 -1
- package/dist/services/ServiceManager.js +79 -9
- package/dist/services/memory/MemoryManager.js +18 -2
- package/dist/services/session/Session.js +154 -24
- package/dist/services/session/SessionManager.js +3 -2
- package/dist/services/session/SessionStore.js +96 -24
- package/migrations/016_add_message_seq.sql +27 -0
- package/package.json +1 -1
package/dist/routes/agent.js
CHANGED
|
@@ -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
|
|
@@ -488,7 +515,9 @@ router.post('/sessions/:sessionId/reset', async (req, res) => {
|
|
|
488
515
|
try {
|
|
489
516
|
const userId = req.userId;
|
|
490
517
|
const { sessionId } = req.params;
|
|
491
|
-
|
|
518
|
+
// 终端标识:停止事件广播给其他终端时排除发起端
|
|
519
|
+
const clientId = req.body?.clientId || req.headers['x-client-id'] || undefined;
|
|
520
|
+
const stopped = (0, index_js_2.getSessionManager)(userId).stopSession(sessionId, clientId);
|
|
492
521
|
res.json({ success: true, data: { stopped } });
|
|
493
522
|
}
|
|
494
523
|
catch (error) {
|
|
@@ -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
|
-
'
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
547
|
+
// 等到真正能接请求为止:进程起来到监听端口之间还要跑迁移、加载模型等,
|
|
548
|
+
// 只等 PID 会在服务就绪前就报成功,紧跟着的连接就会失败。
|
|
549
|
+
const maxWait = 30000;
|
|
490
550
|
const start = Date.now();
|
|
491
|
-
|
|
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 (
|
|
495
|
-
return {
|
|
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 =
|
|
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
|
-
|
|
440
|
+
this.emitSSE({ type: 'context_compressing' });
|
|
425
441
|
}
|
|
426
442
|
const MAX_INPUT_CHARS = 30000; // 单次摘要最大输入字符数
|
|
427
443
|
// 格式化所有消息用于估算长度
|
|
@@ -255,16 +255,29 @@ class Session {
|
|
|
255
255
|
addUserMessage(content, userMessageId, attachments) {
|
|
256
256
|
const message = this.getUserMessage(content, userMessageId, attachments);
|
|
257
257
|
this.messages.push(message);
|
|
258
|
-
this.
|
|
258
|
+
this.enqueueMessageSave(message);
|
|
259
259
|
return message;
|
|
260
260
|
}
|
|
261
261
|
/**
|
|
262
262
|
* Add assistant message
|
|
263
263
|
*/
|
|
264
264
|
addAssistantMessage(content, toolCalls, modelName, id) {
|
|
265
|
+
const messageId = id ? id : (0, uuid_1.v4)();
|
|
266
|
+
// 本轮可能已经通过 persistAssistantProgress 落过盘并入过 this.messages,
|
|
267
|
+
// 这里必须复用同一条,否则 messages 里会出现两条同 id 的助手消息,
|
|
268
|
+
// 上下文重复、前端也会渲染出两个气泡。
|
|
269
|
+
const existing = this.messages.find(m => m.id === messageId && m.role === 'assistant');
|
|
270
|
+
if (existing) {
|
|
271
|
+
existing.content = content;
|
|
272
|
+
existing.toolCalls = toolCalls;
|
|
273
|
+
existing.modelName = modelName;
|
|
274
|
+
existing.updatedAt = Date.now();
|
|
275
|
+
this.enqueueMessageSave(existing);
|
|
276
|
+
return existing;
|
|
277
|
+
}
|
|
265
278
|
const message = {
|
|
266
279
|
sessionId: this.id,
|
|
267
|
-
id:
|
|
280
|
+
id: messageId,
|
|
268
281
|
role: 'assistant',
|
|
269
282
|
content,
|
|
270
283
|
toolCalls,
|
|
@@ -272,26 +285,72 @@ class Session {
|
|
|
272
285
|
modelName
|
|
273
286
|
};
|
|
274
287
|
this.messages.push(message);
|
|
275
|
-
this.
|
|
288
|
+
this.enqueueMessageSave(message);
|
|
276
289
|
return message;
|
|
277
290
|
}
|
|
278
291
|
/**
|
|
279
|
-
*
|
|
292
|
+
* 把消息加入待落库队列(按 id 去重)。
|
|
293
|
+
*
|
|
294
|
+
* 同一条助手消息在一轮里会被反复标记为待保存(每个工具轮次、正文就绪、收尾各一次),
|
|
295
|
+
* 队列里只保留一份引用即可——落库时读的是对象的最新字段。
|
|
296
|
+
*/
|
|
297
|
+
enqueueMessageSave(message) {
|
|
298
|
+
if (!this.toInsertMessages.some(m => m.id === message.id)) {
|
|
299
|
+
this.toInsertMessages.push(message);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* 冲刷待落库队列。
|
|
304
|
+
*
|
|
305
|
+
* 原先只在 streamChat 的 finally 里调用一次,意味着一轮对话(可能持续几分钟、
|
|
306
|
+
* 跑十几个工具)期间进程崩溃/被杀,用户消息和已经产出的助手内容会全部丢失。
|
|
307
|
+
* 现在改为在流程中的关键节点多次调用,每次都是幂等 UPSERT。
|
|
280
308
|
*/
|
|
281
309
|
async saveMessage() {
|
|
282
310
|
const store = this.store;
|
|
311
|
+
if (this.toInsertMessages.length === 0) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// 取出快照后立刻清空:落库期间若有新消息入队,属于下一次冲刷的范围,
|
|
315
|
+
// 不能因为本次失败/成功而被连带清掉或重复写入。
|
|
316
|
+
const pending = this.toInsertMessages;
|
|
317
|
+
this.toInsertMessages = [];
|
|
283
318
|
this.updatedAt = Date.now();
|
|
284
319
|
try {
|
|
285
320
|
store.transaction(() => {
|
|
286
|
-
for (
|
|
287
|
-
store.
|
|
321
|
+
for (const message of pending) {
|
|
322
|
+
store.upsertMessage(this.id, message);
|
|
288
323
|
}
|
|
289
|
-
this.toInsertMessages = [];
|
|
290
324
|
store.updateSession(this.toStoreData());
|
|
291
325
|
});
|
|
292
326
|
}
|
|
293
327
|
catch (error) {
|
|
294
328
|
logger.error('数据保存错误:', error);
|
|
329
|
+
// 落库失败的消息重新排回队列头部,等下一个节点重试,避免直接丢数据
|
|
330
|
+
this.toInsertMessages = pending.concat(this.toInsertMessages);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* 会话进行中增量保存助手消息。
|
|
335
|
+
*
|
|
336
|
+
* 在每个工具轮次结束、以及正文流式输出完成后调用,让数据库里始终有一份
|
|
337
|
+
* 接近最新的助手消息。这样即使进程异常退出,用户也能看到已经完成的部分,
|
|
338
|
+
* 而不是整轮消失。
|
|
339
|
+
*
|
|
340
|
+
* @param content 当前已产出的正文
|
|
341
|
+
* @param toolCalls 当前已完成的工具调用
|
|
342
|
+
* @param modelName 参与本轮的模型名
|
|
343
|
+
*/
|
|
344
|
+
persistAssistantProgress(content, toolCalls, modelName) {
|
|
345
|
+
if (!this.currentMessageId)
|
|
346
|
+
return;
|
|
347
|
+
try {
|
|
348
|
+
this.addAssistantMessage(content, toolCalls, modelName, this.currentMessageId);
|
|
349
|
+
void this.saveMessage();
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
// 增量保存是尽力而为的容灾手段,失败不能中断正在进行的对话
|
|
353
|
+
logger.warn('助手消息增量保存失败:', error?.message);
|
|
295
354
|
}
|
|
296
355
|
}
|
|
297
356
|
/**
|
|
@@ -381,6 +440,25 @@ class Session {
|
|
|
381
440
|
getMessageCount() {
|
|
382
441
|
return this.messages.length;
|
|
383
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
|
+
}
|
|
384
462
|
/**
|
|
385
463
|
* Get messages by page (descending order)
|
|
386
464
|
*/
|
|
@@ -632,8 +710,6 @@ class Session {
|
|
|
632
710
|
const settings = await dataService_js_1.settingsService.get(token);
|
|
633
711
|
const streamDelay = this.getStreamDelay(settings.streamSpeed);
|
|
634
712
|
this.currentMessageId = assistantMessageId;
|
|
635
|
-
const memoryManager = new MemoryManager_js_1.MemoryManager(this, this.abortController.signal, childAgent, res);
|
|
636
|
-
const historyMessages = await memoryManager.getHistoryMessagesAsync();
|
|
637
713
|
// 同一用户可能同时在多个终端登录,而 SSE 只能回给发起请求的那个终端。
|
|
638
714
|
// 这里预先取到 WebSocketService,把每个 SSE 事件同步给该用户的其他连接,
|
|
639
715
|
// 从而让所有终端看到同一份会话流。子 Agent 的内部流不需要同步。
|
|
@@ -660,6 +736,25 @@ class Session {
|
|
|
660
736
|
logger.error('SSE mirror error:', error);
|
|
661
737
|
}
|
|
662
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();
|
|
663
758
|
// SSE 辅助方法:res 为 null 时跳过写入(本地执行模式),但仍同步给其他终端
|
|
664
759
|
const sendSSE = (res, data) => {
|
|
665
760
|
mirrorToOtherClients(data);
|
|
@@ -682,19 +777,6 @@ class Session {
|
|
|
682
777
|
clearInterval(heartbeatInterval);
|
|
683
778
|
}
|
|
684
779
|
}, 15000);
|
|
685
|
-
// 其他终端没有参与本次请求,需要补一条用户消息才能对齐会话。
|
|
686
|
-
// 必须在 message_start 之前同步:否则接收端会先插入助手占位,
|
|
687
|
-
// 导致助手消息排在用户消息前面。
|
|
688
|
-
//
|
|
689
|
-
// 同时带上助手占位标记:接收端据此在用户消息后面立即插入「思考中」
|
|
690
|
-
// 占位气泡,与发起端表现一致(占位文案由接收端按自身语言生成)。
|
|
691
|
-
if (!childAgent) {
|
|
692
|
-
mirrorToOtherClients({
|
|
693
|
-
type: 'user_message',
|
|
694
|
-
message: this.getUserMessage(content, userMessageId, attachments),
|
|
695
|
-
assistantPlaceholder: true,
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
780
|
// Send message start event
|
|
699
781
|
sendSSE(res, { type: 'message_start' });
|
|
700
782
|
// Build messages for API call (保留 tool_call_id 等必要字段)
|
|
@@ -739,6 +821,9 @@ class Session {
|
|
|
739
821
|
if (!childAgent) {
|
|
740
822
|
this.addUserMessage(content, userMessageId, attachments);
|
|
741
823
|
messages.push(this.messages.at(-1));
|
|
824
|
+
// 用户消息一旦确定就立即落库:后面的模型调用可能耗时数分钟,
|
|
825
|
+
// 期间进程退出不该让用户刚发的话凭空消失。
|
|
826
|
+
void this.saveMessage();
|
|
742
827
|
}
|
|
743
828
|
else {
|
|
744
829
|
messages.push(this.getUserMessage(content, userMessageId, attachments));
|
|
@@ -1081,6 +1166,11 @@ class Session {
|
|
|
1081
1166
|
};
|
|
1082
1167
|
await Promise.all(llmResult.toolCalls.map(x => startToolCall(x)));
|
|
1083
1168
|
toolCalls.push(toolCall);
|
|
1169
|
+
// 本轮工具已执行完,增量落库一次。工具轮次可能很多且每轮都耗时,
|
|
1170
|
+
// 这里保存能让崩溃后仍保留已完成的工具调用记录。
|
|
1171
|
+
if (!childAgent) {
|
|
1172
|
+
this.persistAssistantProgress(llmResult.content || llmResult.reasoningContent || '', toolCalls, [...new Set(modelNames)].join(','));
|
|
1173
|
+
}
|
|
1084
1174
|
// 计划模式:本轮是否调用了 updatePlan
|
|
1085
1175
|
const calledUpdatePlan = llmResult.toolCalls.some((tc) => tc.toolName === 'updatePlan');
|
|
1086
1176
|
roundsSincePlanUpdate = calledUpdatePlan ? 0 : roundsSincePlanUpdate + 1;
|
|
@@ -1147,6 +1237,9 @@ class Session {
|
|
|
1147
1237
|
}
|
|
1148
1238
|
if (!childAgent) {
|
|
1149
1239
|
this.addAssistantMessage(llmResult.content || llmResult.reasoningContent, toolCalls, [...new Set(modelNames)].join(','), this.currentMessageId);
|
|
1240
|
+
// 正文已完整产出,先落库再发 complete:
|
|
1241
|
+
// 客户端收到 complete 后就认为这条消息定稿了,此时库里必须已经有它。
|
|
1242
|
+
await this.saveMessage();
|
|
1150
1243
|
}
|
|
1151
1244
|
sendSSE(res, { type: 'complete', modelName: [...new Set(modelNames)].join(",") });
|
|
1152
1245
|
sendSSE(res, { type: '[DONE]' });
|
|
@@ -1161,7 +1254,9 @@ class Session {
|
|
|
1161
1254
|
logger.error('stream chat ', error);
|
|
1162
1255
|
if (!childAgent) {
|
|
1163
1256
|
if (toolCalls) {
|
|
1164
|
-
|
|
1257
|
+
// 用 '' 覆盖会把已经增量保存的正文清空,这里保留已有内容
|
|
1258
|
+
const saved = this.messages.find(m => m.id === this.currentMessageId && m.role === 'assistant');
|
|
1259
|
+
this.addAssistantMessage(saved?.content || '', toolCalls, [...new Set(modelNames)].join(','), this.currentMessageId);
|
|
1165
1260
|
}
|
|
1166
1261
|
if (error.message !== 'aborted') {
|
|
1167
1262
|
try {
|
|
@@ -1242,7 +1337,9 @@ class Session {
|
|
|
1242
1337
|
/**
|
|
1243
1338
|
* 停止当前正在进行的生成
|
|
1244
1339
|
*/
|
|
1245
|
-
stopGenerating() {
|
|
1340
|
+
stopGenerating(clientId) {
|
|
1341
|
+
const wasGenerating = this.isGenerating;
|
|
1342
|
+
const stoppedMessageId = this.currentMessageId;
|
|
1246
1343
|
this.isGenerating = false;
|
|
1247
1344
|
this.abortPrecompression();
|
|
1248
1345
|
// 停止生成后清除计划,避免 Desktop 的进度圈停留在未完成状态
|
|
@@ -1251,6 +1348,39 @@ class Session {
|
|
|
1251
1348
|
this.abortController.abort();
|
|
1252
1349
|
this.abortController = null;
|
|
1253
1350
|
}
|
|
1351
|
+
// 通知其他终端本轮已被停止。
|
|
1352
|
+
//
|
|
1353
|
+
// 中断走的是 abort 分支,streamChat 既不会发 error 也不会发 [DONE]
|
|
1354
|
+
// (见 catch 中的 error.message !== 'aborted' 判断),
|
|
1355
|
+
// 因此镜像终端收不到任何终止事件,会永远卡在「思考中」占位和 loading 态。
|
|
1356
|
+
// 这里必须显式广播一次。
|
|
1357
|
+
if (wasGenerating) {
|
|
1358
|
+
void this.notifySessionStopped(stoppedMessageId, clientId);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* 向其他终端广播「本轮生成已停止」。
|
|
1363
|
+
*
|
|
1364
|
+
* @param assistantMessageId 被停止的助手消息 id,接收端据此定位占位气泡
|
|
1365
|
+
* @param excludeClientId 发起停止的终端(它自己已在本地做过收尾,无需再处理)
|
|
1366
|
+
*/
|
|
1367
|
+
async notifySessionStopped(assistantMessageId, excludeClientId) {
|
|
1368
|
+
try {
|
|
1369
|
+
const webSocketService = await this.getWebSocketService();
|
|
1370
|
+
if (!webSocketService)
|
|
1371
|
+
return;
|
|
1372
|
+
webSocketService.sendToUser(String(this.userId), {
|
|
1373
|
+
type: 'session_stopped',
|
|
1374
|
+
payload: {
|
|
1375
|
+
sessionId: this.id,
|
|
1376
|
+
agentId: this.agentId,
|
|
1377
|
+
assistantMessageId,
|
|
1378
|
+
},
|
|
1379
|
+
}, { excludeClientId });
|
|
1380
|
+
}
|
|
1381
|
+
catch (error) {
|
|
1382
|
+
logger.error('推送会话停止事件失败:', error);
|
|
1383
|
+
}
|
|
1254
1384
|
}
|
|
1255
1385
|
/**
|
|
1256
1386
|
* 获取 AbortController 用于中断请求
|
|
@@ -111,14 +111,15 @@ class SessionManager {
|
|
|
111
111
|
/**
|
|
112
112
|
* Stop session generation
|
|
113
113
|
*/
|
|
114
|
-
stopSession(sessionId) {
|
|
114
|
+
stopSession(sessionId, clientId) {
|
|
115
115
|
const session = this.getSession(sessionId);
|
|
116
116
|
if (!session) {
|
|
117
117
|
logger.warn(`Session ${sessionId} not found for stop`);
|
|
118
118
|
return false;
|
|
119
119
|
}
|
|
120
120
|
if (session.isGenerating) {
|
|
121
|
-
|
|
121
|
+
// 透传发起端标识:停止事件广播时要排除它,它已在本地收尾
|
|
122
|
+
session.stopGenerating(clientId);
|
|
122
123
|
logger.info(`Stopped session ${sessionId}`);
|
|
123
124
|
return true;
|
|
124
125
|
}
|
|
@@ -81,17 +81,17 @@ class SessionStore {
|
|
|
81
81
|
}
|
|
82
82
|
// ========== Session Operations ==========
|
|
83
83
|
insertSession(session) {
|
|
84
|
-
this.db.prepare(`
|
|
85
|
-
INSERT INTO sessions (id, user_id, agent_id, title, select_model_id, voice_state, created_at, updated_at,last_message_summary,last_message_summary_at,unread_count,is_current,message_queue,message_queue_auto_execute, use_system_mode)
|
|
86
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?,?,?,?,?,?,?,?)
|
|
84
|
+
this.db.prepare(`
|
|
85
|
+
INSERT INTO sessions (id, user_id, agent_id, title, select_model_id, voice_state, created_at, updated_at,last_message_summary,last_message_summary_at,unread_count,is_current,message_queue,message_queue_auto_execute, use_system_mode)
|
|
86
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?,?,?,?,?,?,?,?)
|
|
87
87
|
`).run(session.id, session.userId, session.agentId || null, session.title, session.selectModelId || '', JSON.stringify(session.voiceState), session.createdAt, session.updatedAt, session.lastMessageSummary, session.lastMessageSummaryAt, session.unreadCount || 0, session.isCurrent ? 1 : 0, JSON.stringify(session.messageQueue || []), session.messageQueueAutoExecute === false ? 0 : 1, session.useSystemMode ? 1 : 0);
|
|
88
88
|
}
|
|
89
89
|
updateSession(session) {
|
|
90
|
-
this.db.prepare(`
|
|
91
|
-
UPDATE sessions
|
|
92
|
-
SET user_id = ?, agent_id = ?, title = ?, select_model_id = ?,
|
|
93
|
-
voice_state = ?, updated_at = ?,last_message_summary=?,last_message_summary_at=?,unread_count=?,is_current=?,message_queue=?,message_queue_auto_execute=?, use_system_mode=?
|
|
94
|
-
WHERE id = ?
|
|
90
|
+
this.db.prepare(`
|
|
91
|
+
UPDATE sessions
|
|
92
|
+
SET user_id = ?, agent_id = ?, title = ?, select_model_id = ?,
|
|
93
|
+
voice_state = ?, updated_at = ?,last_message_summary=?,last_message_summary_at=?,unread_count=?,is_current=?,message_queue=?,message_queue_auto_execute=?, use_system_mode=?
|
|
94
|
+
WHERE id = ?
|
|
95
95
|
`).run(session.userId, session.agentId || null, session.title, session.selectModelId || '', JSON.stringify(session.voiceState), session.updatedAt, session.lastMessageSummary, session.lastMessageSummaryAt, session.unreadCount || 0, session.isCurrent ? 1 : 0, JSON.stringify(session.messageQueue || []), session.messageQueueAutoExecute === false ? 0 : 1, session.useSystemMode ? 1 : 0, session.id);
|
|
96
96
|
}
|
|
97
97
|
// 支持部分更新的 updateSession
|
|
@@ -141,27 +141,76 @@ 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
|
-
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);
|
|
170
|
+
this.db.prepare(`
|
|
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());
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* 写入或更新消息(按 id 幂等)。
|
|
177
|
+
*
|
|
178
|
+
* 会话进行中需要多次落库同一条助手消息(工具轮次结束、正文就绪、收尾),
|
|
179
|
+
* 用 INSERT 会撞主键,用 UPDATE 首次又没有行,因此统一走 UPSERT。
|
|
180
|
+
*
|
|
181
|
+
* 冲突时刻意不更新 session_id / role / created_at:
|
|
182
|
+
* created_at 是消息列表的排序依据(findMessagesBySessionId ORDER BY created_at),
|
|
183
|
+
* 若每次落库都刷新它,同一轮的用户消息与助手消息顺序会被打乱。
|
|
184
|
+
*/
|
|
185
|
+
upsertMessage(sessionId, msg) {
|
|
186
|
+
this.db.prepare(`
|
|
187
|
+
INSERT INTO messages (id, session_id, role, content, attachments, tool_calls, created_at, updated_at, model_name, feedback, seq)
|
|
188
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
189
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
190
|
+
content = excluded.content,
|
|
191
|
+
attachments = excluded.attachments,
|
|
192
|
+
tool_calls = excluded.tool_calls,
|
|
193
|
+
updated_at = excluded.updated_at,
|
|
194
|
+
model_name = excluded.model_name,
|
|
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());
|
|
149
198
|
}
|
|
150
199
|
insertMessages(sessionId, messages) {
|
|
151
|
-
const stmt = this.db.prepare(`
|
|
152
|
-
INSERT INTO messages (id, session_id, role, content, attachments, tool_calls,created_at, updated_at, model_name, feedback)
|
|
153
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
200
|
+
const stmt = this.db.prepare(`
|
|
201
|
+
INSERT INTO messages (id, session_id, role, content, attachments, tool_calls,created_at, updated_at, model_name, feedback, seq)
|
|
202
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
154
203
|
`);
|
|
155
204
|
for (const msg of messages) {
|
|
156
|
-
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());
|
|
157
206
|
}
|
|
158
207
|
}
|
|
159
208
|
updateMessage(sessionId, msg) {
|
|
160
|
-
this.db.prepare(`
|
|
161
|
-
UPDATE messages
|
|
162
|
-
SET content = ?, attachments = ?, tool_calls = ?, updated_at = ?, model_name = ?, feedback = ?
|
|
163
|
-
WHERE id = ? AND session_id = ?
|
|
164
|
-
`).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);
|
|
209
|
+
this.db.prepare(`
|
|
210
|
+
UPDATE messages
|
|
211
|
+
SET content = ?, attachments = ?, tool_calls = ?, updated_at = ?, model_name = ?, feedback = ?, seq = ?
|
|
212
|
+
WHERE id = ? AND session_id = ?
|
|
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);
|
|
165
214
|
}
|
|
166
215
|
deleteMessage(messageId) {
|
|
167
216
|
this.db.prepare('DELETE FROM messages WHERE id = ?').run(messageId);
|
|
@@ -171,7 +220,29 @@ class SessionStore {
|
|
|
171
220
|
}
|
|
172
221
|
findMessagesBySessionId(sessionId) {
|
|
173
222
|
const rows = this.db.prepare('SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC').all(sessionId);
|
|
174
|
-
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 {
|
|
175
246
|
id: row.id,
|
|
176
247
|
sessionId: row.session_id,
|
|
177
248
|
role: row.role,
|
|
@@ -181,8 +252,9 @@ class SessionStore {
|
|
|
181
252
|
createdAt: row.created_at,
|
|
182
253
|
updatedAt: row.updated_at,
|
|
183
254
|
modelName: row.model_name,
|
|
184
|
-
feedback: row.feedback || undefined
|
|
185
|
-
|
|
255
|
+
feedback: row.feedback || undefined,
|
|
256
|
+
seq: row.seq ?? undefined
|
|
257
|
+
};
|
|
186
258
|
}
|
|
187
259
|
rowToSessionData(row) {
|
|
188
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);
|