@myassis/gateway 1.0.84 → 1.0.85

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.
@@ -488,7 +488,9 @@ router.post('/sessions/:sessionId/reset', async (req, res) => {
488
488
  try {
489
489
  const userId = req.userId;
490
490
  const { sessionId } = req.params;
491
- const stopped = (0, index_js_2.getSessionManager)(userId).stopSession(sessionId);
491
+ // 终端标识:停止事件广播给其他终端时排除发起端
492
+ const clientId = req.body?.clientId || req.headers['x-client-id'] || undefined;
493
+ const stopped = (0, index_js_2.getSessionManager)(userId).stopSession(sessionId, clientId);
492
494
  res.json({ success: true, data: { stopped } });
493
495
  }
494
496
  catch (error) {
@@ -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.toInsertMessages.push(message);
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: id ? id : (0, uuid_1.v4)(),
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.toInsertMessages.push(message);
288
+ this.enqueueMessageSave(message);
276
289
  return message;
277
290
  }
278
291
  /**
279
- * Save a single message to store
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 (let message of this.toInsertMessages) {
287
- store.insertMessage(this.id, message);
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
  /**
@@ -739,6 +798,9 @@ class Session {
739
798
  if (!childAgent) {
740
799
  this.addUserMessage(content, userMessageId, attachments);
741
800
  messages.push(this.messages.at(-1));
801
+ // 用户消息一旦确定就立即落库:后面的模型调用可能耗时数分钟,
802
+ // 期间进程退出不该让用户刚发的话凭空消失。
803
+ void this.saveMessage();
742
804
  }
743
805
  else {
744
806
  messages.push(this.getUserMessage(content, userMessageId, attachments));
@@ -1081,6 +1143,11 @@ class Session {
1081
1143
  };
1082
1144
  await Promise.all(llmResult.toolCalls.map(x => startToolCall(x)));
1083
1145
  toolCalls.push(toolCall);
1146
+ // 本轮工具已执行完,增量落库一次。工具轮次可能很多且每轮都耗时,
1147
+ // 这里保存能让崩溃后仍保留已完成的工具调用记录。
1148
+ if (!childAgent) {
1149
+ this.persistAssistantProgress(llmResult.content || llmResult.reasoningContent || '', toolCalls, [...new Set(modelNames)].join(','));
1150
+ }
1084
1151
  // 计划模式:本轮是否调用了 updatePlan
1085
1152
  const calledUpdatePlan = llmResult.toolCalls.some((tc) => tc.toolName === 'updatePlan');
1086
1153
  roundsSincePlanUpdate = calledUpdatePlan ? 0 : roundsSincePlanUpdate + 1;
@@ -1147,6 +1214,9 @@ class Session {
1147
1214
  }
1148
1215
  if (!childAgent) {
1149
1216
  this.addAssistantMessage(llmResult.content || llmResult.reasoningContent, toolCalls, [...new Set(modelNames)].join(','), this.currentMessageId);
1217
+ // 正文已完整产出,先落库再发 complete:
1218
+ // 客户端收到 complete 后就认为这条消息定稿了,此时库里必须已经有它。
1219
+ await this.saveMessage();
1150
1220
  }
1151
1221
  sendSSE(res, { type: 'complete', modelName: [...new Set(modelNames)].join(",") });
1152
1222
  sendSSE(res, { type: '[DONE]' });
@@ -1161,7 +1231,9 @@ class Session {
1161
1231
  logger.error('stream chat ', error);
1162
1232
  if (!childAgent) {
1163
1233
  if (toolCalls) {
1164
- this.addAssistantMessage('', toolCalls, [...new Set(modelNames)].join(','), this.currentMessageId);
1234
+ // '' 覆盖会把已经增量保存的正文清空,这里保留已有内容
1235
+ const saved = this.messages.find(m => m.id === this.currentMessageId && m.role === 'assistant');
1236
+ this.addAssistantMessage(saved?.content || '', toolCalls, [...new Set(modelNames)].join(','), this.currentMessageId);
1165
1237
  }
1166
1238
  if (error.message !== 'aborted') {
1167
1239
  try {
@@ -1242,7 +1314,9 @@ class Session {
1242
1314
  /**
1243
1315
  * 停止当前正在进行的生成
1244
1316
  */
1245
- stopGenerating() {
1317
+ stopGenerating(clientId) {
1318
+ const wasGenerating = this.isGenerating;
1319
+ const stoppedMessageId = this.currentMessageId;
1246
1320
  this.isGenerating = false;
1247
1321
  this.abortPrecompression();
1248
1322
  // 停止生成后清除计划,避免 Desktop 的进度圈停留在未完成状态
@@ -1251,6 +1325,39 @@ class Session {
1251
1325
  this.abortController.abort();
1252
1326
  this.abortController = null;
1253
1327
  }
1328
+ // 通知其他终端本轮已被停止。
1329
+ //
1330
+ // 中断走的是 abort 分支,streamChat 既不会发 error 也不会发 [DONE]
1331
+ // (见 catch 中的 error.message !== 'aborted' 判断),
1332
+ // 因此镜像终端收不到任何终止事件,会永远卡在「思考中」占位和 loading 态。
1333
+ // 这里必须显式广播一次。
1334
+ if (wasGenerating) {
1335
+ void this.notifySessionStopped(stoppedMessageId, clientId);
1336
+ }
1337
+ }
1338
+ /**
1339
+ * 向其他终端广播「本轮生成已停止」。
1340
+ *
1341
+ * @param assistantMessageId 被停止的助手消息 id,接收端据此定位占位气泡
1342
+ * @param excludeClientId 发起停止的终端(它自己已在本地做过收尾,无需再处理)
1343
+ */
1344
+ async notifySessionStopped(assistantMessageId, excludeClientId) {
1345
+ try {
1346
+ const webSocketService = await this.getWebSocketService();
1347
+ if (!webSocketService)
1348
+ return;
1349
+ webSocketService.sendToUser(String(this.userId), {
1350
+ type: 'session_stopped',
1351
+ payload: {
1352
+ sessionId: this.id,
1353
+ agentId: this.agentId,
1354
+ assistantMessageId,
1355
+ },
1356
+ }, { excludeClientId });
1357
+ }
1358
+ catch (error) {
1359
+ logger.error('推送会话停止事件失败:', error);
1360
+ }
1254
1361
  }
1255
1362
  /**
1256
1363
  * 获取 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
- session.stopGenerating();
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
@@ -142,25 +142,48 @@ class SessionStore {
142
142
  }
143
143
  // ========== Message Operations ==========
144
144
  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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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);
149
+ }
150
+ /**
151
+ * 写入或更新消息(按 id 幂等)。
152
+ *
153
+ * 会话进行中需要多次落库同一条助手消息(工具轮次结束、正文就绪、收尾),
154
+ * 用 INSERT 会撞主键,用 UPDATE 首次又没有行,因此统一走 UPSERT。
155
+ *
156
+ * 冲突时刻意不更新 session_id / role / created_at:
157
+ * created_at 是消息列表的排序依据(findMessagesBySessionId ORDER BY created_at),
158
+ * 若每次落库都刷新它,同一轮的用户消息与助手消息顺序会被打乱。
159
+ */
160
+ upsertMessage(sessionId, msg) {
161
+ this.db.prepare(`
162
+ INSERT INTO messages (id, session_id, role, content, attachments, tool_calls, created_at, updated_at, model_name, feedback)
163
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
164
+ ON CONFLICT(id) DO UPDATE SET
165
+ content = excluded.content,
166
+ attachments = excluded.attachments,
167
+ tool_calls = excluded.tool_calls,
168
+ updated_at = excluded.updated_at,
169
+ model_name = excluded.model_name,
170
+ feedback = excluded.feedback
148
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);
149
172
  }
150
173
  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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
174
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
154
177
  `);
155
178
  for (const msg of messages) {
156
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);
157
180
  }
158
181
  }
159
182
  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 = ?
183
+ this.db.prepare(`
184
+ UPDATE messages
185
+ SET content = ?, attachments = ?, tool_calls = ?, updated_at = ?, model_name = ?, feedback = ?
186
+ WHERE id = ? AND session_id = ?
164
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);
165
188
  }
166
189
  deleteMessage(messageId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myassis/gateway",
3
- "version": "1.0.84",
3
+ "version": "1.0.85",
4
4
  "description": "我的助手 Gateway Service - 本地 AI 网关服务,支持认证、WebSocket 实时通信和任务调度",
5
5
  "main": "dist/index.js",
6
6
  "bin": {