@myassis/gateway 1.0.83 → 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.
package/dist/main.js CHANGED
@@ -25,6 +25,7 @@ const upload_js_1 = __importDefault(require("./routes/upload.js"));
25
25
  const version_js_1 = __importDefault(require("./routes/version.js"));
26
26
  const quota_js_1 = __importDefault(require("./routes/quota.js"));
27
27
  const errorHandler_js_1 = require("./middleware/errorHandler.js");
28
+ const broadcast_js_1 = require("./middleware/broadcast.js");
28
29
  const index_js_2 = require("./stores/index.js");
29
30
  const persistStore_js_1 = require("./stores/persistStore.js");
30
31
  const WebSocketService_js_1 = require("./services/WebSocketService.js");
@@ -273,6 +274,9 @@ else {
273
274
  app.get('/health', (req, res) => {
274
275
  res.json({ status: 'ok', service: 'gateway', version: '2.0.0', wsOnline: WebSocketService_js_1.webSocketService.getOnlineCount() });
275
276
  });
277
+ // 写请求成功后向该用户的其他终端广播数据变更(多终端同步)。
278
+ // 必须注册在业务路由之前:它通过包装 res.json 生效。
279
+ app.use(broadcast_js_1.broadcastDataChanges);
276
280
  // Routes
277
281
  app.use('/api/v1/auth', auth_js_1.default);
278
282
  app.use('/api/v1/agent', agent_js_1.default);
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ /**
3
+ * 数据变更广播中间件
4
+ *
5
+ * 同一账号可能在多个终端登录。任何一个终端调用写接口(POST/PUT/PATCH/DELETE)
6
+ * 修改了数据后,其他终端的本地缓存就过期了,必须重新拉取才能看到最新状态。
7
+ * 本中间件在写请求成功返回后,向该用户的其他终端广播一条 `data_changed`,
8
+ * 由终端决定刷新哪部分数据。
9
+ *
10
+ * 设计取舍:
11
+ * - 只广播「什么变了」,不广播变更后的完整数据。写接口的响应体格式各异
12
+ * (有的返回实体、有的只返回 success),统一封装成本高且容易泄漏内部结构;
13
+ * 让终端按资源类型走既有的加载逻辑更可靠。
14
+ * - 只在响应成功时广播。失败的写请求没有改变任何状态,广播会导致无谓刷新。
15
+ * - 发起端通过 clientId 排除。它自己已经拿到了响应,会走本地更新逻辑,
16
+ * 再收一次广播会造成重复请求甚至覆盖掉刚提交的本地状态。
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.broadcastDataChanges = void 0;
20
+ const shared_1 = require("@myassis/shared");
21
+ const WebSocketService_js_1 = require("../services/WebSocketService.js");
22
+ const logger = (0, shared_1.getLogger)('middleware/broadcast');
23
+ /**
24
+ * 路由前缀 -> 资源类型。
25
+ *
26
+ * 以挂载路径的第一段判定,避免为每个 handler 单独标注。
27
+ */
28
+ const RESOURCE_BY_MOUNT = {
29
+ agent: 'agent',
30
+ models: 'model',
31
+ skills: 'skill',
32
+ tasks: 'task',
33
+ settings: 'settings',
34
+ quota: 'quota',
35
+ };
36
+ /**
37
+ * 不广播的路径片段。
38
+ *
39
+ * - auth/service/version:认证与本机服务管理,属于终端本地行为,
40
+ * 广播出去会让其他终端做无意义的刷新甚至误判登录态。
41
+ * - stream/reset:会话流有专门的 session_stream 同步通道(见 Session.streamChat),
42
+ * 这里再广播一次会造成重复渲染。
43
+ * - upload:上传结果通过消息体引用,消息本身会广播。
44
+ * - parse/api-key/rate/rating:不改变列表结构,或属于一次性动作。
45
+ */
46
+ const SKIPPED_PATH_SEGMENTS = ['stream', 'reset', 'parse', 'api-key', 'set-current'];
47
+ /** 业务路由的公共前缀,用于从完整路径中定位挂载段 */
48
+ const API_PREFIX_SEGMENTS = ['api', 'v1'];
49
+ /** HTTP 方法 -> 变更动作 */
50
+ function toAction(method) {
51
+ switch (method.toUpperCase()) {
52
+ case 'POST':
53
+ return 'created';
54
+ case 'PUT':
55
+ case 'PATCH':
56
+ return 'updated';
57
+ case 'DELETE':
58
+ return 'deleted';
59
+ default:
60
+ return null;
61
+ }
62
+ }
63
+ /**
64
+ * 从请求路径推断资源类型。
65
+ *
66
+ * 注意:本中间件挂在 app 级别(早于所有 router.use),此时 Express 还没有匹配到
67
+ * 任何 router,req.baseUrl 恒为空字符串,req.path 才是完整路径 /api/v1/agent/xxx。
68
+ * 因此必须跳过 api/v1 前缀后取挂载段,不能依赖 req.baseUrl。
69
+ */
70
+ function toResource(path) {
71
+ const segments = path.split('/').filter(Boolean);
72
+ let index = 0;
73
+ while (index < API_PREFIX_SEGMENTS.length && segments[index] === API_PREFIX_SEGMENTS[index]) {
74
+ index += 1;
75
+ }
76
+ const mount = segments[index];
77
+ return mount ? RESOURCE_BY_MOUNT[mount] ?? null : null;
78
+ }
79
+ /**
80
+ * 细化 agent 路由下的资源类型。
81
+ *
82
+ * /api/v1/agent 同时承载 agent、session 和 message 三种资源,
83
+ * 只看挂载路径会把会话与消息变更都标成 agent,导致终端刷新范围过大。
84
+ */
85
+ function refineAgentResource(path) {
86
+ if (/\/messages(\/|$)/.test(path))
87
+ return 'message';
88
+ if (/\/sessions(\/|$)/.test(path))
89
+ return 'session';
90
+ return 'agent';
91
+ }
92
+ /** 响应体是否表示成功(无法判定时按成功处理) */
93
+ function isSuccessful(statusCode, body) {
94
+ if (statusCode < 200 || statusCode >= 300)
95
+ return false;
96
+ if (body && typeof body === 'object' && 'success' in body) {
97
+ return body.success !== false;
98
+ }
99
+ return true;
100
+ }
101
+ /**
102
+ * 广播数据变更中间件。
103
+ *
104
+ * 挂在路由之前,通过包装 res.json 在响应发出后广播,
105
+ * 这样不需要改动任何 handler。
106
+ */
107
+ function broadcastDataChanges(req, res, next) {
108
+ const action = toAction(req.method);
109
+ if (!action) {
110
+ next();
111
+ return;
112
+ }
113
+ const resource = toResource(req.path);
114
+ if (!resource) {
115
+ next();
116
+ return;
117
+ }
118
+ const pathSegments = req.path.split('/').filter(Boolean);
119
+ if (pathSegments.some((segment) => SKIPPED_PATH_SEGMENTS.includes(segment))) {
120
+ next();
121
+ return;
122
+ }
123
+ const originalJson = res.json.bind(res);
124
+ res.json = (body) => {
125
+ // 先把响应交回给发起端,广播失败不能影响本次请求
126
+ const result = originalJson(body);
127
+ try {
128
+ // userId 由 requireAuth 写入;未认证的写请求无从判断归属,直接跳过
129
+ const userId = req.userId;
130
+ if (userId && isSuccessful(res.statusCode, body)) {
131
+ const finalResource = resource === 'agent' ? refineAgentResource(req.path) : resource;
132
+ WebSocketService_js_1.webSocketService.sendToUser(String(userId), {
133
+ type: 'data_changed',
134
+ payload: {
135
+ resource: finalResource,
136
+ action,
137
+ // 提供原始路径与方法,便于终端做更精细的判断或排查问题
138
+ path: req.originalUrl,
139
+ method: req.method,
140
+ timestamp: Date.now(),
141
+ },
142
+ }, { excludeClientId: getClientId(req) });
143
+ }
144
+ }
145
+ catch (error) {
146
+ logger.error('广播数据变更失败:', error);
147
+ }
148
+ return result;
149
+ };
150
+ next();
151
+ }
152
+ exports.broadcastDataChanges = broadcastDataChanges;
153
+ /** 读取发起端的终端标识 */
154
+ function getClientId(req) {
155
+ const header = req.headers['x-client-id'];
156
+ if (typeof header === 'string' && header)
157
+ return header;
158
+ const fromBody = req.body?.clientId;
159
+ return typeof fromBody === 'string' && fromBody ? fromBody : undefined;
160
+ }
@@ -440,7 +440,9 @@ router.post('/sessions/:sessionId/stream', ensureAgentManager, async (req, res)
440
440
  try {
441
441
  const userId = req.userId;
442
442
  const { sessionId } = req.params;
443
- const { content, attachments, userMessageId, assistantMessageId } = req.body;
443
+ const { content, attachments, userMessageId, assistantMessageId, clientId } = req.body;
444
+ // 终端标识:用于把本次流事件同步给该用户的其他终端时排除发起端
445
+ const streamClientId = clientId || req.headers['x-client-id'] || undefined;
444
446
  if (!content && attachments.length === 0) {
445
447
  return res.status(400).json({ success: false, error: 'Content or attachments are required' });
446
448
  }
@@ -454,7 +456,7 @@ router.post('/sessions/:sessionId/stream', ensureAgentManager, async (req, res)
454
456
  res.setHeader('Connection', 'keep-alive');
455
457
  res.flushHeaders();
456
458
  // Stream response
457
- await session.streamChat(content, attachments || [], res, userMessageId, assistantMessageId);
459
+ await session.streamChat(content, attachments || [], res, userMessageId, assistantMessageId, false, streamClientId);
458
460
  return;
459
461
  }
460
462
  res.status(404).json({ success: false, error: 'Session not found' });
@@ -486,7 +488,9 @@ router.post('/sessions/:sessionId/reset', async (req, res) => {
486
488
  try {
487
489
  const userId = req.userId;
488
490
  const { sessionId } = req.params;
489
- 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);
490
494
  res.json({ success: true, data: { stopped } });
491
495
  }
492
496
  catch (error) {
@@ -100,9 +100,8 @@ router.post('/refresh', async (req, res) => {
100
100
  res.status(400).json({ success: false, error: 'Missing refresh token' });
101
101
  return;
102
102
  }
103
- // 刷新前先找到持有该 refreshToken 的旧 accessToken,用于定位记录
104
- const oldToken = Array.from(index_js_2.authStore.getAll()?.values() ?? [])
105
- .find(x => x.refreshToken === refreshToken)?.accessToken;
103
+ // 刷新前先找到持有该 refreshToken 的旧 accessToken,用于定位该终端的会话
104
+ const oldToken = index_js_2.authStore.getByRefreshToken(refreshToken)?.accessToken;
106
105
  const response = await index_js_1.authApi.refresh(refreshToken);
107
106
  // server 可能返回 {accessToken} 或 {data:{accessToken}},两种都兼容
108
107
  const accessToken = response.data?.accessToken ?? response.accessToken;
@@ -139,7 +138,9 @@ router.post('/refresh', async (req, res) => {
139
138
  router.post('/logout', auth_js_1.requireAuth, async (req, res) => {
140
139
  const token = req.token;
141
140
  // 优先用客户端上报的 refreshToken,缺失时回退到 Gateway 本地持久化的那一个
142
- const refreshToken = req.body?.refreshToken || index_js_2.authStore.getRefreshToken(req.userId) || undefined;
141
+ // 只登出当前终端:refreshToken 需按本次请求的 accessToken 定位,
142
+ // 否则会失效该用户在其他终端的会话
143
+ const refreshToken = req.body?.refreshToken || index_js_2.authStore.getRefreshTokenByToken(token) || undefined;
143
144
  try {
144
145
  if (refreshToken) {
145
146
  // 用 AsyncLocalStorage 将 token 注入 server 请求上下文;
@@ -6,12 +6,14 @@
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.webSocketService = void 0;
8
8
  const ws_1 = require("ws");
9
+ const uuid_1 = require("uuid");
9
10
  const index_js_1 = require("../stores/index.js");
10
11
  const shared_1 = require("@myassis/shared");
11
12
  const Session_js_1 = require("./session/Session.js");
12
13
  const logger = (0, shared_1.getLogger)('WebSocketService');
13
14
  class WebSocketService {
14
15
  wss = null;
16
+ /** userId -> (连接 id -> 连接):同一用户的多个终端可同时在线 */
15
17
  clients = new Map();
16
18
  heartbeatInterval = null;
17
19
  /**
@@ -31,30 +33,29 @@ class WebSocketService {
31
33
  return;
32
34
  }
33
35
  const userId = clientKey;
34
- logger.info(`用户 ${userId} 已连接`);
36
+ const connectionId = (0, uuid_1.v4)();
35
37
  const client = {
38
+ id: connectionId,
39
+ clientId: this.getQueryParam(req, 'clientId'),
36
40
  ws,
37
41
  userId,
38
42
  connectedAt: Date.now(),
39
43
  isAlive: true,
40
44
  };
41
- // 如果同一用户已有连接,关闭旧连接
42
- const existingClient = this.clients.get(userId);
43
- if (existingClient && existingClient.ws.readyState === ws_1.WebSocket.OPEN) {
44
- existingClient.ws.close(4002, 'Replaced by new connection');
45
- }
46
- this.clients.set(userId, client);
45
+ // 同一用户的多终端并存:按连接 id 保存,不再挤掉旧连接,
46
+ // 这样一个用户在不同终端都能收到同一份实时消息
47
+ const connections = this.getOrCreateConnections(userId);
48
+ connections.set(connectionId, client);
49
+ logger.info(`用户 ${userId} 已连接(当前连接数: ${connections.size})`);
47
50
  // 心跳检测
48
51
  ws.on('pong', () => {
49
- if (this.clients.has(userId)) {
50
- this.clients.get(userId).isAlive = true;
51
- }
52
+ client.isAlive = true;
52
53
  });
53
54
  // 接收消息
54
55
  ws.on('message', (data) => {
55
56
  try {
56
57
  const message = JSON.parse(data.toString());
57
- this.handleMessage(userId, message);
58
+ this.handleMessage(client, message);
58
59
  }
59
60
  catch {
60
61
  logger.warn(`用户 ${userId} 发送了无效消息`);
@@ -62,35 +63,34 @@ class WebSocketService {
62
63
  });
63
64
  // 断开连接
64
65
  ws.on('close', () => {
65
- logger.info(`用户 ${userId} 已断开`);
66
- this.clients.delete(userId);
66
+ this.removeConnection(client);
67
+ logger.info(`用户 ${userId} 已断开(剩余连接数: ${this.getConnectionCount(userId)})`);
67
68
  });
68
69
  ws.on('error', (error) => {
69
70
  logger.error(`用户 ${userId} 连接错误:`, error);
70
- this.clients.delete(userId);
71
+ this.removeConnection(client);
71
72
  });
72
73
  // 发送连接成功消息
73
- this.sendToUser(userId, {
74
+ this.sendToConnection(client, {
74
75
  type: 'connected',
75
76
  payload: {
76
77
  userId,
78
+ connectionId,
77
79
  timestamp: Date.now(),
78
80
  },
79
81
  });
80
82
  });
81
83
  // 启动心跳检测
82
84
  this.heartbeatInterval = setInterval(() => {
83
- this.wss.clients.forEach((ws) => {
84
- const client = Array.from(this.clients.values()).find(c => c.ws === ws);
85
- if (!client)
86
- return;
85
+ this.forEachConnection((client) => {
87
86
  if (!client.isAlive) {
88
87
  logger.info(`用户 ${client.userId} 心跳超时,断开`);
89
- this.clients.delete(client.userId);
90
- return ws.terminate();
88
+ this.removeConnection(client);
89
+ client.ws.terminate();
90
+ return;
91
91
  }
92
92
  client.isAlive = false;
93
- ws.ping();
93
+ client.ws.ping();
94
94
  });
95
95
  }, 30000);
96
96
  logger.info('WebSocket 服务已启动,路径: /ws');
@@ -103,12 +103,7 @@ class WebSocketService {
103
103
  */
104
104
  getClientKey(req) {
105
105
  // 优先从 URL 查询参数中提取 token
106
- let token = null;
107
- const url = req.url || '';
108
- const match = url.match(/[?&]token=([^&]+)/);
109
- if (match) {
110
- token = decodeURIComponent(match[1]);
111
- }
106
+ const token = this.getQueryParam(req, 'token');
112
107
  if (token) {
113
108
  const userId = index_js_1.authStore.getUserId(token);
114
109
  if (userId) {
@@ -117,13 +112,68 @@ class WebSocketService {
117
112
  }
118
113
  return null;
119
114
  }
115
+ /**
116
+ * 读取连接 URL 上的查询参数
117
+ */
118
+ getQueryParam(req, name) {
119
+ const url = req?.url || '';
120
+ const match = url.match(new RegExp(`[?&]${name}=([^&]+)`));
121
+ return match ? decodeURIComponent(match[1]) : undefined;
122
+ }
123
+ /**
124
+ * 获取(必要时创建)用户的连接集合
125
+ */
126
+ getOrCreateConnections(userId) {
127
+ let connections = this.clients.get(userId);
128
+ if (!connections) {
129
+ connections = new Map();
130
+ this.clients.set(userId, connections);
131
+ }
132
+ return connections;
133
+ }
134
+ /**
135
+ * 移除连接,用户没有连接后清理其条目
136
+ */
137
+ removeConnection(client) {
138
+ const connections = this.clients.get(client.userId);
139
+ if (!connections)
140
+ return;
141
+ connections.delete(client.id);
142
+ if (connections.size === 0) {
143
+ this.clients.delete(client.userId);
144
+ }
145
+ }
146
+ /**
147
+ * 遍历所有连接
148
+ */
149
+ forEachConnection(handler) {
150
+ this.clients.forEach((connections) => {
151
+ Array.from(connections.values()).forEach(handler);
152
+ });
153
+ }
154
+ /**
155
+ * 向单个连接发送消息
156
+ */
157
+ sendToConnection(client, message) {
158
+ if (client.ws.readyState !== ws_1.WebSocket.OPEN)
159
+ return false;
160
+ try {
161
+ client.ws.send(JSON.stringify(message));
162
+ return true;
163
+ }
164
+ catch (error) {
165
+ logger.error(`发送消息给用户 ${client.userId} 失败:`, error);
166
+ return false;
167
+ }
168
+ }
120
169
  /**
121
170
  * 处理客户端发来的消息
122
171
  */
123
- handleMessage(userId, message) {
172
+ handleMessage(client, message) {
173
+ const userId = client.userId;
124
174
  switch (message.type) {
125
175
  case 'ping':
126
- this.sendToUser(userId, { type: 'pong', payload: { timestamp: Date.now() } });
176
+ this.sendToConnection(client, { type: 'pong', payload: { timestamp: Date.now() } });
127
177
  break;
128
178
  case 'subscribe':
129
179
  logger.debug(`用户 ${userId} 订阅: ${message.payload?.channel || 'all'}`);
@@ -140,28 +190,41 @@ class WebSocketService {
140
190
  }
141
191
  }
142
192
  /**
143
- * 向指定用户发送消息
193
+ * 向指定用户的所有终端发送消息
194
+ *
195
+ * 同一用户可能在多个终端登录,这里会广播给该用户的每个连接,
196
+ * 可通过 options.excludeClientId 跳过已经通过 SSE 收到事件的终端。
197
+ * 返回 true 表示至少有一个连接发送成功。
144
198
  */
145
- sendToUser(userId, message) {
146
- const client = this.clients.get(String(userId));
147
- if (!client || client.ws.readyState !== ws_1.WebSocket.OPEN) {
148
- return false;
149
- }
150
- try {
151
- client.ws.send(JSON.stringify(message));
152
- return true;
153
- }
154
- catch (error) {
155
- logger.error(`发送消息给用户 ${userId} 失败:`, error);
199
+ sendToUser(userId, message, options = {}) {
200
+ const connections = this.clients.get(String(userId));
201
+ if (!connections || connections.size === 0) {
156
202
  return false;
157
203
  }
204
+ let sent = false;
205
+ Array.from(connections.values()).forEach((client) => {
206
+ if (options.excludeClientId && client.clientId === options.excludeClientId) {
207
+ return;
208
+ }
209
+ sent = this.sendToConnection(client, message) || sent;
210
+ });
211
+ return sent;
158
212
  }
159
213
  /**
160
- * 检查用户是否在线
214
+ * 检查用户是否在线(任一终端在线即视为在线)
161
215
  */
162
216
  isUserOnline(userId) {
163
- const client = this.clients.get(String(userId));
164
- return !!client && client.ws.readyState === ws_1.WebSocket.OPEN;
217
+ return this.getConnectionCount(userId) > 0;
218
+ }
219
+ /**
220
+ * 获取用户当前的在线连接数
221
+ */
222
+ getConnectionCount(userId) {
223
+ const connections = this.clients.get(String(userId));
224
+ if (!connections)
225
+ return 0;
226
+ return Array.from(connections.values())
227
+ .filter((client) => client.ws.readyState === ws_1.WebSocket.OPEN).length;
165
228
  }
166
229
  /**
167
230
  * 发送任务通知
@@ -212,7 +275,7 @@ class WebSocketService {
212
275
  */
213
276
  broadcast(message) {
214
277
  const data = JSON.stringify(message);
215
- this.clients.forEach((client) => {
278
+ this.forEachConnection((client) => {
216
279
  if (client.ws.readyState === ws_1.WebSocket.OPEN) {
217
280
  try {
218
281
  client.ws.send(data);
@@ -243,7 +306,7 @@ class WebSocketService {
243
306
  clearInterval(this.heartbeatInterval);
244
307
  this.heartbeatInterval = null;
245
308
  }
246
- this.clients.forEach((client) => {
309
+ this.forEachConnection((client) => {
247
310
  client.ws.close(1001, 'Server shutting down');
248
311
  });
249
312
  this.clients.clear();
@@ -54,8 +54,8 @@ exports.authService = {
54
54
  },
55
55
  // 登出
56
56
  logout: async (token, refreshToken) => {
57
- const userId = index_js_2.authStore.getUserId(token);
58
- const revokeToken = refreshToken || (userId ? index_js_2.authStore.getRefreshToken(userId) : null);
57
+ // accessToken 定位当前终端的 refreshToken,避免踢掉其他终端
58
+ const revokeToken = refreshToken || index_js_2.authStore.getRefreshTokenByToken(token);
59
59
  if (revokeToken) {
60
60
  try {
61
61
  await index_js_1.authApi.logout(revokeToken, token);
@@ -76,12 +76,11 @@ exports.authService = {
76
76
  // 删除账号
77
77
  deleteAccount: (password, token) => index_js_1.authApi.deleteAccount({ password }, token),
78
78
  // 获取当前用户(从内存)
79
- getUser: (token) => index_js_2.authStore.get(token)?.user || null,
79
+ getUser: (token) => index_js_2.authStore.getByToken(token)?.user || null,
80
80
  // 刷新 Token
81
81
  refresh: async (refreshToken) => {
82
- // 刷新前先定位旧 accessToken
83
- const oldToken = Array.from(index_js_2.authStore.getAll()?.values() ?? [])
84
- .find(x => x.refreshToken === refreshToken)?.accessToken;
82
+ // 刷新前先定位该终端的旧 accessToken
83
+ const oldToken = index_js_2.authStore.getByRefreshToken(refreshToken)?.accessToken;
85
84
  const response = await index_js_1.authApi.refresh(refreshToken);
86
85
  const accessToken = response.data?.accessToken ?? response.accessToken;
87
86
  const newRefreshToken = response.data?.refreshToken ?? response.refreshToken;
@@ -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
  /**
@@ -248,16 +255,29 @@ class Session {
248
255
  addUserMessage(content, userMessageId, attachments) {
249
256
  const message = this.getUserMessage(content, userMessageId, attachments);
250
257
  this.messages.push(message);
251
- this.toInsertMessages.push(message);
258
+ this.enqueueMessageSave(message);
252
259
  return message;
253
260
  }
254
261
  /**
255
262
  * Add assistant message
256
263
  */
257
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
+ }
258
278
  const message = {
259
279
  sessionId: this.id,
260
- id: id ? id : (0, uuid_1.v4)(),
280
+ id: messageId,
261
281
  role: 'assistant',
262
282
  content,
263
283
  toolCalls,
@@ -265,26 +285,72 @@ class Session {
265
285
  modelName
266
286
  };
267
287
  this.messages.push(message);
268
- this.toInsertMessages.push(message);
288
+ this.enqueueMessageSave(message);
269
289
  return message;
270
290
  }
271
291
  /**
272
- * 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。
273
308
  */
274
309
  async saveMessage() {
275
310
  const store = this.store;
311
+ if (this.toInsertMessages.length === 0) {
312
+ return;
313
+ }
314
+ // 取出快照后立刻清空:落库期间若有新消息入队,属于下一次冲刷的范围,
315
+ // 不能因为本次失败/成功而被连带清掉或重复写入。
316
+ const pending = this.toInsertMessages;
317
+ this.toInsertMessages = [];
276
318
  this.updatedAt = Date.now();
277
319
  try {
278
320
  store.transaction(() => {
279
- for (let message of this.toInsertMessages) {
280
- store.insertMessage(this.id, message);
321
+ for (const message of pending) {
322
+ store.upsertMessage(this.id, message);
281
323
  }
282
- this.toInsertMessages = [];
283
324
  store.updateSession(this.toStoreData());
284
325
  });
285
326
  }
286
327
  catch (error) {
287
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);
288
354
  }
289
355
  }
290
356
  /**
@@ -501,9 +567,21 @@ class Session {
501
567
  * 用动态 import 获取 webSocketService:WebSocketService 反向依赖本文件的
502
568
  * handleApprovalResponse,静态引入会形成循环依赖。
503
569
  */
504
- async notifyPlanUpdate() {
570
+ async getWebSocketService() {
505
571
  try {
506
572
  const { webSocketService } = await Promise.resolve().then(() => __importStar(require('../WebSocketService.js')));
573
+ return webSocketService;
574
+ }
575
+ catch (error) {
576
+ logger.error('加载 WebSocketService 失败:', error);
577
+ return null;
578
+ }
579
+ }
580
+ async notifyPlanUpdate() {
581
+ try {
582
+ const webSocketService = await this.getWebSocketService();
583
+ if (!webSocketService)
584
+ return;
507
585
  webSocketService.sendToUser(String(this.userId), {
508
586
  type: 'plan_updated',
509
587
  payload: {
@@ -587,7 +665,7 @@ class Session {
587
665
  * Stream chat response (SSE) - 使用 ModelSelector 根据选择的模型调用
588
666
  * 支持工具调用流程
589
667
  */
590
- async streamChat(content, attachments = [], res, userMessageId, assistantMessageId, childAgent = false) {
668
+ async streamChat(content, attachments = [], res, userMessageId, assistantMessageId, childAgent = false, clientId) {
591
669
  // 如果正在生成,等待当前生成完成(最多等待 2 秒)
592
670
  if (this.isGenerating) {
593
671
  const maxWait = 2000;
@@ -615,8 +693,35 @@ class Session {
615
693
  this.currentMessageId = assistantMessageId;
616
694
  const memoryManager = new MemoryManager_js_1.MemoryManager(this, this.abortController.signal, childAgent, res);
617
695
  const historyMessages = await memoryManager.getHistoryMessagesAsync();
618
- // SSE 辅助方法:res 为 null 时跳过写入(本地执行模式)
696
+ // 同一用户可能同时在多个终端登录,而 SSE 只能回给发起请求的那个终端。
697
+ // 这里预先取到 WebSocketService,把每个 SSE 事件同步给该用户的其他连接,
698
+ // 从而让所有终端看到同一份会话流。子 Agent 的内部流不需要同步。
699
+ const wsService = childAgent ? null : await this.getWebSocketService();
700
+ /** 把 SSE 事件镜像给当前用户的其他终端(排除发起端,避免重复渲染) */
701
+ const mirrorToOtherClients = (data) => {
702
+ if (!wsService)
703
+ return;
704
+ if (NON_MIRRORED_SSE_EVENTS.has(data?.type))
705
+ return;
706
+ try {
707
+ wsService.sendToUser(String(this.userId), {
708
+ type: 'session_stream',
709
+ payload: {
710
+ sessionId: this.id,
711
+ agentId: this.agentId,
712
+ userMessageId,
713
+ assistantMessageId,
714
+ event: data,
715
+ },
716
+ }, { excludeClientId: clientId });
717
+ }
718
+ catch (error) {
719
+ logger.error('SSE mirror error:', error);
720
+ }
721
+ };
722
+ // SSE 辅助方法:res 为 null 时跳过写入(本地执行模式),但仍同步给其他终端
619
723
  const sendSSE = (res, data) => {
724
+ mirrorToOtherClients(data);
620
725
  if (!res)
621
726
  return;
622
727
  try {
@@ -636,6 +741,19 @@ class Session {
636
741
  clearInterval(heartbeatInterval);
637
742
  }
638
743
  }, 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
+ }
639
757
  // Send message start event
640
758
  sendSSE(res, { type: 'message_start' });
641
759
  // Build messages for API call (保留 tool_call_id 等必要字段)
@@ -680,6 +798,9 @@ class Session {
680
798
  if (!childAgent) {
681
799
  this.addUserMessage(content, userMessageId, attachments);
682
800
  messages.push(this.messages.at(-1));
801
+ // 用户消息一旦确定就立即落库:后面的模型调用可能耗时数分钟,
802
+ // 期间进程退出不该让用户刚发的话凭空消失。
803
+ void this.saveMessage();
683
804
  }
684
805
  else {
685
806
  messages.push(this.getUserMessage(content, userMessageId, attachments));
@@ -1022,6 +1143,11 @@ class Session {
1022
1143
  };
1023
1144
  await Promise.all(llmResult.toolCalls.map(x => startToolCall(x)));
1024
1145
  toolCalls.push(toolCall);
1146
+ // 本轮工具已执行完,增量落库一次。工具轮次可能很多且每轮都耗时,
1147
+ // 这里保存能让崩溃后仍保留已完成的工具调用记录。
1148
+ if (!childAgent) {
1149
+ this.persistAssistantProgress(llmResult.content || llmResult.reasoningContent || '', toolCalls, [...new Set(modelNames)].join(','));
1150
+ }
1025
1151
  // 计划模式:本轮是否调用了 updatePlan
1026
1152
  const calledUpdatePlan = llmResult.toolCalls.some((tc) => tc.toolName === 'updatePlan');
1027
1153
  roundsSincePlanUpdate = calledUpdatePlan ? 0 : roundsSincePlanUpdate + 1;
@@ -1088,6 +1214,9 @@ class Session {
1088
1214
  }
1089
1215
  if (!childAgent) {
1090
1216
  this.addAssistantMessage(llmResult.content || llmResult.reasoningContent, toolCalls, [...new Set(modelNames)].join(','), this.currentMessageId);
1217
+ // 正文已完整产出,先落库再发 complete:
1218
+ // 客户端收到 complete 后就认为这条消息定稿了,此时库里必须已经有它。
1219
+ await this.saveMessage();
1091
1220
  }
1092
1221
  sendSSE(res, { type: 'complete', modelName: [...new Set(modelNames)].join(",") });
1093
1222
  sendSSE(res, { type: '[DONE]' });
@@ -1102,7 +1231,9 @@ class Session {
1102
1231
  logger.error('stream chat ', error);
1103
1232
  if (!childAgent) {
1104
1233
  if (toolCalls) {
1105
- 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);
1106
1237
  }
1107
1238
  if (error.message !== 'aborted') {
1108
1239
  try {
@@ -1183,7 +1314,9 @@ class Session {
1183
1314
  /**
1184
1315
  * 停止当前正在进行的生成
1185
1316
  */
1186
- stopGenerating() {
1317
+ stopGenerating(clientId) {
1318
+ const wasGenerating = this.isGenerating;
1319
+ const stoppedMessageId = this.currentMessageId;
1187
1320
  this.isGenerating = false;
1188
1321
  this.abortPrecompression();
1189
1322
  // 停止生成后清除计划,避免 Desktop 的进度圈停留在未完成状态
@@ -1192,6 +1325,39 @@ class Session {
1192
1325
  this.abortController.abort();
1193
1326
  this.abortController = null;
1194
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
+ }
1195
1361
  }
1196
1362
  /**
1197
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) {
@@ -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.83",
3
+ "version": "1.0.85",
4
4
  "description": "我的助手 Gateway Service - 本地 AI 网关服务,支持认证、WebSocket 实时通信和任务调度",
5
5
  "main": "dist/index.js",
6
6
  "bin": {