@myassis/gateway 1.0.82 → 1.0.84

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.
@@ -40,6 +40,13 @@ const ContextBuilder_js_1 = require("../memory/ContextBuilder.js");
40
40
  const SessionManager_js_1 = require("./SessionManager.js");
41
41
  const logger = (0, shared_1.getLogger)('Session');
42
42
  const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
43
+ /**
44
+ * 不向其他终端镜像的 SSE 事件类型。
45
+ *
46
+ * heartbeat 仅用于保持 SSE 连接存活;approval_pending 需要发起端弹窗确认,
47
+ * 广播出去会让多个终端同时弹窗并重复回传批准结果。
48
+ */
49
+ const NON_MIRRORED_SSE_EVENTS = new Set(['heartbeat', 'approval_pending']);
43
50
  // 批准等待缓存:token -> { resolve, reject, expiresAt }
44
51
  const approvalWaiters = new Map();
45
52
  /**
@@ -495,6 +502,71 @@ class Session {
495
502
  lines.push('调用时必须传入完整步骤列表,且同一时刻最多一个步骤为 in_progress。');
496
503
  return lines.join('\n');
497
504
  }
505
+ /**
506
+ * 把当前计划推送到 Desktop。
507
+ *
508
+ * 用动态 import 获取 webSocketService:WebSocketService 反向依赖本文件的
509
+ * handleApprovalResponse,静态引入会形成循环依赖。
510
+ */
511
+ async getWebSocketService() {
512
+ try {
513
+ const { webSocketService } = await Promise.resolve().then(() => __importStar(require('../WebSocketService.js')));
514
+ return webSocketService;
515
+ }
516
+ catch (error) {
517
+ logger.error('加载 WebSocketService 失败:', error);
518
+ return null;
519
+ }
520
+ }
521
+ async notifyPlanUpdate() {
522
+ try {
523
+ const webSocketService = await this.getWebSocketService();
524
+ if (!webSocketService)
525
+ return;
526
+ webSocketService.sendToUser(String(this.userId), {
527
+ type: 'plan_updated',
528
+ payload: {
529
+ sessionId: this.id,
530
+ agentId: this.agentId,
531
+ steps: this.plan,
532
+ updatedAt: this.planUpdatedAt,
533
+ },
534
+ });
535
+ }
536
+ catch (error) {
537
+ logger.error('推送计划更新失败:', error);
538
+ }
539
+ }
540
+ /**
541
+ * 构造「收尾时计划未完成」的提醒。
542
+ *
543
+ * 模型准备结束回复时,如果计划里还有未完成的步骤,说明它要么漏了同步,
544
+ * 要么还有事情没做完。返回提醒文案要求它先处理;无需提醒时返回 null。
545
+ */
546
+ buildPlanFinalizeReminder() {
547
+ if (this.plan.length === 0 || this.isPlanCompleted())
548
+ return null;
549
+ const unfinished = this.plan.filter(item => item.status !== 'completed');
550
+ const list = unfinished.map(item => `「${item.step}」`).join('、');
551
+ return `【计划未完成提醒】你准备结束本轮回复,但计划中仍有 ${unfinished.length} 个步骤未标记完成:${list}。
552
+ `
553
+ + `请判断:如果这些步骤其实已经做完了,立即调用 updatePlan 把它们全部置为 completed;
554
+ `
555
+ + `如果确实还没做,请继续完成剩余工作并同步计划状态。不要在计划未收尾的情况下结束回复。`;
556
+ }
557
+ /**
558
+ * 本轮结束时兜底处理未完成的计划。
559
+ *
560
+ * 即使提醒过模型,它仍可能不收尾。此时清空计划,避免 Desktop 的
561
+ * 进度圈永远停留在未完成状态。
562
+ */
563
+ async finalizePlanOnComplete() {
564
+ if (this.plan.length === 0 || this.isPlanCompleted())
565
+ return;
566
+ logger.warn(`会话 ${this.id} 结束时计划仍未完成,已自动清除计划以释放进度圈`);
567
+ this.clearPlan();
568
+ await this.notifyPlanUpdate();
569
+ }
498
570
  // 清除未读消息数并持久化
499
571
  clearUnreadCount() {
500
572
  this.unreadCount = 0;
@@ -534,7 +606,7 @@ class Session {
534
606
  * Stream chat response (SSE) - 使用 ModelSelector 根据选择的模型调用
535
607
  * 支持工具调用流程
536
608
  */
537
- async streamChat(content, attachments = [], res, userMessageId, assistantMessageId, childAgent = false) {
609
+ async streamChat(content, attachments = [], res, userMessageId, assistantMessageId, childAgent = false, clientId) {
538
610
  // 如果正在生成,等待当前生成完成(最多等待 2 秒)
539
611
  if (this.isGenerating) {
540
612
  const maxWait = 2000;
@@ -562,8 +634,35 @@ class Session {
562
634
  this.currentMessageId = assistantMessageId;
563
635
  const memoryManager = new MemoryManager_js_1.MemoryManager(this, this.abortController.signal, childAgent, res);
564
636
  const historyMessages = await memoryManager.getHistoryMessagesAsync();
565
- // SSE 辅助方法:res 为 null 时跳过写入(本地执行模式)
637
+ // 同一用户可能同时在多个终端登录,而 SSE 只能回给发起请求的那个终端。
638
+ // 这里预先取到 WebSocketService,把每个 SSE 事件同步给该用户的其他连接,
639
+ // 从而让所有终端看到同一份会话流。子 Agent 的内部流不需要同步。
640
+ const wsService = childAgent ? null : await this.getWebSocketService();
641
+ /** 把 SSE 事件镜像给当前用户的其他终端(排除发起端,避免重复渲染) */
642
+ const mirrorToOtherClients = (data) => {
643
+ if (!wsService)
644
+ return;
645
+ if (NON_MIRRORED_SSE_EVENTS.has(data?.type))
646
+ return;
647
+ try {
648
+ wsService.sendToUser(String(this.userId), {
649
+ type: 'session_stream',
650
+ payload: {
651
+ sessionId: this.id,
652
+ agentId: this.agentId,
653
+ userMessageId,
654
+ assistantMessageId,
655
+ event: data,
656
+ },
657
+ }, { excludeClientId: clientId });
658
+ }
659
+ catch (error) {
660
+ logger.error('SSE mirror error:', error);
661
+ }
662
+ };
663
+ // SSE 辅助方法:res 为 null 时跳过写入(本地执行模式),但仍同步给其他终端
566
664
  const sendSSE = (res, data) => {
665
+ mirrorToOtherClients(data);
567
666
  if (!res)
568
667
  return;
569
668
  try {
@@ -583,6 +682,19 @@ class Session {
583
682
  clearInterval(heartbeatInterval);
584
683
  }
585
684
  }, 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
+ }
586
698
  // Send message start event
587
699
  sendSSE(res, { type: 'message_start' });
588
700
  // Build messages for API call (保留 tool_call_id 等必要字段)
@@ -645,6 +757,8 @@ class Session {
645
757
  let toolRound = 0;
646
758
  // 计划模式:距上次调用 updatePlan 已经过的工具轮次,用于在中间过程强制提醒模型同步进度
647
759
  let roundsSincePlanUpdate = 0;
760
+ // 计划模式:收尾时提醒模型补完计划的次数,避免反复提醒导致死循环
761
+ let planFinalizeNudges = 0;
648
762
  // 当前裁剪级别,遇到上下文超限时逐级加重
649
763
  let trimLevel = ContextBuilder_js_1.TrimLevel.OldToolPayload;
650
764
  /** 判断错误是否为上下文超限 */
@@ -999,7 +1113,25 @@ class Session {
999
1113
  return await processModelResponse();
1000
1114
  }
1001
1115
  else {
1002
- // ========== 没有工具调用,保存消息并结束 ==========
1116
+ // ========== 没有工具调用,准备结束本轮 ==========
1117
+ // 计划模式:计划还没收尾时,先提醒模型补完状态(最多提醒一次,避免死循环)
1118
+ if (planFinalizeNudges < 1) {
1119
+ const finalizeReminder = this.buildPlanFinalizeReminder();
1120
+ if (finalizeReminder) {
1121
+ planFinalizeNudges++;
1122
+ messages.push({
1123
+ role: 'assistant',
1124
+ content: llmResult.content || llmResult.reasoningContent || '',
1125
+ attachments: []
1126
+ });
1127
+ messages.push({
1128
+ role: 'user',
1129
+ content: finalizeReminder,
1130
+ attachments: []
1131
+ });
1132
+ return await processModelResponse();
1133
+ }
1134
+ }
1003
1135
  if (llmResult.content || llmResult.reasoningContent) {
1004
1136
  // 分段发送内容,每段约50个字符
1005
1137
  const chunkSize = 1;
@@ -1056,6 +1188,8 @@ class Session {
1056
1188
  logger.error('Error closing SSE connection:', e);
1057
1189
  }
1058
1190
  }
1191
+ // 计划模式:本轮已结束,若计划仍未完成则兜底清除,避免进度圈残留
1192
+ void this.finalizePlanOnComplete();
1059
1193
  // 非当前查看的 session,助手回复完成后增加未读计数
1060
1194
  (0, SessionManager_js_1.getSessionManager)(this.userId).onMessageComplete(this.id);
1061
1195
  // 只在非 abort 的情况下保存(abort 由 stopGenerating 负责保存)
@@ -2,7 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.updatePlanTool = void 0;
4
4
  const SessionManager_js_1 = require("../session/SessionManager.js");
5
- const WebSocketService_js_1 = require("../WebSocketService.js");
6
5
  const shared_1 = require("@myassis/shared");
7
6
  const logger = (0, shared_1.getLogger)('PlanTool');
8
7
  const VALID_STATUS = ['pending', 'in_progress', 'completed'];
@@ -84,15 +83,7 @@ exports.updatePlanTool = {
84
83
  }
85
84
  session.updatePlan(steps);
86
85
  // 推送计划进度到 Desktop,驱动底部进度圈刷新
87
- WebSocketService_js_1.webSocketService.sendToUser(String(userId), {
88
- type: 'plan_updated',
89
- payload: {
90
- sessionId,
91
- agentId: session.agentId,
92
- steps: session.plan,
93
- updatedAt: session.planUpdatedAt,
94
- },
95
- });
86
+ await session.notifyPlanUpdate();
96
87
  const completed = steps.filter(s => s.status === 'completed').length;
97
88
  return {
98
89
  success: true,
@@ -17,8 +17,36 @@ const logger = (0, shared_1.getLogger)('authStore');
17
17
  // 认证数据存储文件路径(使用应用数据目录,支持 STORAGE_DIR 环境变量)
18
18
  const AUTH_STORAGE_DIR = process.env.STORAGE_DIR || path_1.default.join(os_1.default.homedir(), 'myassis-gateway-storage');
19
19
  const AUTH_STORAGE_FILE = path_1.default.join(AUTH_STORAGE_DIR, 'auth', 'auth.json');
20
- // 内存中的认证数据
20
+ /**
21
+ * 内存中的认证数据:userId -> 该用户的所有终端会话(新的在前)。
22
+ *
23
+ * 同一账号可以在多个终端(桌面、移动端等)同时登录,每个终端持有
24
+ * 各自的 accessToken/refreshToken,所以这里必须保存多条而不是一条,
25
+ * 否则后登录的终端会把先登录的挤下线,先登录终端的请求会全部 401。
26
+ */
21
27
  let authMap = null;
28
+ /** 单个用户保留的最大会话数,防止反复登录导致无限增长 */
29
+ const MAX_SESSIONS_PER_USER = 20;
30
+ /** 会话是否已过期(没有 expiresAt 的旧数据视为未过期) */
31
+ function isExpired(auth) {
32
+ return typeof auth.expiresAt === 'number' && auth.expiresAt <= Date.now();
33
+ }
34
+ /**
35
+ * 规整某个用户的会话列表:去掉过期会话、按登录时间倒序、限制数量。
36
+ * 全部过期时保留最新一条,避免用户凭据被清空后无法自愈刷新。
37
+ */
38
+ function normalizeSessions(sessions) {
39
+ const sorted = [...sessions].sort((a, b) => (b.loginAt ?? 0) - (a.loginAt ?? 0));
40
+ const alive = sorted.filter((x) => !isExpired(x));
41
+ const kept = alive.length > 0 ? alive : sorted.slice(0, 1);
42
+ return kept.slice(0, MAX_SESSIONS_PER_USER);
43
+ }
44
+ /** 遍历所有用户的所有会话 */
45
+ function allSessions() {
46
+ if (!authMap)
47
+ return [];
48
+ return Array.from(authMap.values()).flat();
49
+ }
22
50
  // 确保存储目录存在
23
51
  function ensureStorageDir() {
24
52
  const dir = path_1.default.dirname(AUTH_STORAGE_FILE);
@@ -32,8 +60,13 @@ function loadFromFile() {
32
60
  if (fs_1.default.existsSync(AUTH_STORAGE_FILE)) {
33
61
  const data = fs_1.default.readFileSync(AUTH_STORAGE_FILE, 'utf-8');
34
62
  const obj = JSON.parse(data);
63
+ // 兼容旧格式:userId -> AuthData(单终端),统一升级为会话数组
64
+ const entries = Object.entries(obj).map(([userId, value]) => {
65
+ const sessions = Array.isArray(value) ? value : [value];
66
+ return [userId, normalizeSessions(sessions.filter(Boolean))];
67
+ });
35
68
  logger.debug('Auth data loaded from file');
36
- return new Map(Object.entries(obj));
69
+ return new Map(entries.filter(([, sessions]) => sessions.length > 0));
37
70
  }
38
71
  }
39
72
  catch (error) {
@@ -90,36 +123,89 @@ exports.authStore = {
90
123
  if (authMap === null) {
91
124
  this.load();
92
125
  }
93
- authMap.set(authData.user.id, authData);
126
+ const userId = authData.user.id;
127
+ const session = { ...authData, loginAt: authData.loginAt ?? Date.now() };
128
+ // 多终端并存:新增一条会话,而不是覆盖该用户已有的会话。
129
+ // 同一 token/refreshToken 重复登录时替换旧记录,避免重复堆积。
130
+ const existing = (authMap.get(userId) ?? []).filter((x) => x.accessToken !== session.accessToken &&
131
+ (!session.refreshToken || x.refreshToken !== session.refreshToken));
132
+ authMap.set(userId, normalizeSessions([session, ...existing]));
94
133
  saveAuthToFile(authMap);
95
134
  // 用户登录后初始化 SessionManager
96
- (0, index_js_1.getSessionManager)(authData.user.id).initialize();
97
- logger.debug('Auth saved for user:', authData.user.nickname);
135
+ (0, index_js_1.getSessionManager)(userId).initialize();
136
+ logger.debug(`Auth saved for user: ${authData.user.nickname}(会话数: ${authMap.get(userId).length})`);
98
137
  },
99
- //获取所有认证用户
138
+ /**
139
+ * 获取所有认证用户(每个用户一条代表性会话)
140
+ *
141
+ * 供后台任务按用户维度轮询使用,因此这里按用户去重,
142
+ * 需要某个用户的全部终端会话时用 getSessions。
143
+ */
100
144
  getAll() {
101
145
  if (authMap === null) {
102
146
  this.load();
103
147
  }
104
- return authMap;
148
+ const result = new Map();
149
+ authMap.forEach((sessions, userId) => {
150
+ const current = normalizeSessions(sessions)[0];
151
+ if (current) {
152
+ result.set(userId, current);
153
+ }
154
+ });
155
+ return result;
105
156
  },
106
157
  /**
107
- * 获取当前认证数据
158
+ * 获取某个用户的所有终端会话(新的在前)
159
+ */
160
+ getSessions(userId) {
161
+ if (authMap === null) {
162
+ this.load();
163
+ }
164
+ return normalizeSessions(authMap.get(String(userId)) ?? []);
165
+ },
166
+ /**
167
+ * 获取用户当前可用的认证数据(多终端时取最新的有效会话)
108
168
  */
109
169
  get(userId) {
110
- return authMap.get(userId);
170
+ return this.getSessions(userId)[0] || null;
171
+ },
172
+ /**
173
+ * 按 accessToken 精确获取某个终端的认证数据
174
+ */
175
+ getByToken(token) {
176
+ if (authMap === null) {
177
+ this.load();
178
+ }
179
+ return allSessions().find((x) => x.accessToken === token) || null;
180
+ },
181
+ /**
182
+ * 按 refreshToken 精确获取某个终端的认证数据
183
+ */
184
+ getByRefreshToken(refreshToken) {
185
+ if (authMap === null) {
186
+ this.load();
187
+ }
188
+ return allSessions().find((x) => x.refreshToken === refreshToken) || null;
111
189
  },
112
190
  /**
113
191
  * 获取用户信息
114
192
  */
115
193
  getUser(userId) {
116
- return authMap.get(userId).user || null;
194
+ return this.get(userId)?.user || null;
117
195
  },
118
196
  /**
119
- * 获取刷新 Token
197
+ * 获取刷新 Token(多终端时取最新会话的)
120
198
  */
121
199
  getRefreshToken(userId) {
122
- return authMap.get(userId)?.refreshToken || null;
200
+ return this.get(userId)?.refreshToken || null;
201
+ },
202
+ /**
203
+ * 按 accessToken 获取对应终端的刷新 Token
204
+ *
205
+ * 登出只应失效发起登出的那个终端,所以不能退化成用户维度的最新会话。
206
+ */
207
+ getRefreshTokenByToken(token) {
208
+ return this.getByToken(token)?.refreshToken || null;
123
209
  },
124
210
  /**
125
211
  * 更新 Token
@@ -138,8 +224,9 @@ exports.authStore = {
138
224
  return false;
139
225
  }
140
226
  // 按旧 token 定位记录;若传入的旧 token 已被替换过,则回退用新 token 匹配(幂等)
141
- const auth = Array.from(authMap.values()).find(x => x.accessToken === oldToken)
142
- || Array.from(authMap.values()).find(x => x.accessToken === newToken);
227
+ const sessions = allSessions();
228
+ const auth = sessions.find(x => x.accessToken === oldToken)
229
+ || sessions.find(x => x.accessToken === newToken);
143
230
  if (!auth) {
144
231
  logger.warn('updateToken: no auth record matched the given token');
145
232
  return false;
@@ -161,7 +248,7 @@ exports.authStore = {
161
248
  */
162
249
  getUserId(token) {
163
250
  if (authMap) {
164
- return Array.from(authMap.values()).find(x => x.accessToken === token)?.user?.id || null;
251
+ return allSessions().find(x => x.accessToken === token)?.user?.id || null;
165
252
  }
166
253
  else {
167
254
  return null;
@@ -176,22 +263,24 @@ exports.authStore = {
176
263
  logger.debug('Auth cleared');
177
264
  },
178
265
  /**
179
- * 移除单个用户的认证数据,不影响其他已登录用户
266
+ * 移除某个用户的全部终端会话(注销账号等场景)
180
267
  * @returns 是否确实移除了记录
181
268
  */
182
269
  remove(userId) {
183
270
  if (!authMap) {
184
271
  this.load();
185
272
  }
186
- const removed = authMap.delete(userId);
273
+ const removed = authMap.delete(String(userId));
187
274
  if (removed) {
188
275
  saveAuthToFile(authMap);
189
- logger.debug('Auth removed for user:', userId);
276
+ logger.debug('All auth sessions removed for user:', userId);
190
277
  }
191
278
  return removed;
192
279
  },
193
280
  /**
194
- * 按 accessToken 定位并移除单个用户的认证数据
281
+ * 按 accessToken 移除单个终端的会话
282
+ *
283
+ * 只下线发起登出的终端,该用户在其他终端的登录状态保持不变。
195
284
  * @returns 是否确实移除了记录
196
285
  */
197
286
  removeByToken(token) {
@@ -199,17 +288,37 @@ exports.authStore = {
199
288
  if (!userId) {
200
289
  return false;
201
290
  }
202
- return this.remove(userId);
291
+ const sessions = authMap.get(userId) ?? [];
292
+ const remaining = sessions.filter((x) => x.accessToken !== token);
293
+ if (remaining.length === sessions.length) {
294
+ return false;
295
+ }
296
+ if (remaining.length > 0) {
297
+ authMap.set(userId, remaining);
298
+ }
299
+ else {
300
+ authMap.delete(userId);
301
+ }
302
+ saveAuthToFile(authMap);
303
+ logger.debug(`Auth session removed for user: ${userId}(剩余会话数: ${remaining.length})`);
304
+ return true;
203
305
  },
204
306
  /**
205
307
  * 更新用户信息
206
308
  */
207
309
  updateUser(token, userData) {
208
310
  if (authMap) {
209
- const auth = Array.from(authMap.values()).find(x => x.accessToken === token);
210
- auth.user = { ...auth.user, ...userData };
311
+ const target = this.getByToken(token);
312
+ if (!target) {
313
+ logger.warn('updateUser: no auth session matched the given token');
314
+ return;
315
+ }
316
+ // 用户资料是账号级信息,需要同步到该用户的所有终端会话
317
+ this.getSessions(target.user.id).forEach((auth) => {
318
+ auth.user = { ...auth.user, ...userData };
319
+ });
211
320
  saveAuthToFile(authMap);
212
- logger.debug('User updated:', auth.user?.nickname || auth.user?.account);
321
+ logger.debug('User updated:', target.user?.nickname || target.user?.account);
213
322
  }
214
323
  },
215
324
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myassis/gateway",
3
- "version": "1.0.82",
3
+ "version": "1.0.84",
4
4
  "description": "我的助手 Gateway Service - 本地 AI 网关服务,支持认证、WebSocket 实时通信和任务调度",
5
5
  "main": "dist/index.js",
6
6
  "bin": {