@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.
- package/dist/routes/agent.js +27 -0
- package/dist/services/ServiceManager.js +79 -9
- package/dist/services/memory/MemoryManager.js +18 -2
- package/dist/services/session/Session.js +38 -15
- package/dist/services/session/SessionStore.js +64 -15
- 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
|
|
@@ -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
|
// 格式化所有消息用于估算长度
|
|
@@ -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
|
-
|
|
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);
|