@myassis/gateway 1.0.83 → 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.
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' });
@@ -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
  /**
@@ -501,9 +508,21 @@ class Session {
501
508
  * 用动态 import 获取 webSocketService:WebSocketService 反向依赖本文件的
502
509
  * handleApprovalResponse,静态引入会形成循环依赖。
503
510
  */
504
- async notifyPlanUpdate() {
511
+ async getWebSocketService() {
505
512
  try {
506
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;
507
526
  webSocketService.sendToUser(String(this.userId), {
508
527
  type: 'plan_updated',
509
528
  payload: {
@@ -587,7 +606,7 @@ class Session {
587
606
  * Stream chat response (SSE) - 使用 ModelSelector 根据选择的模型调用
588
607
  * 支持工具调用流程
589
608
  */
590
- async streamChat(content, attachments = [], res, userMessageId, assistantMessageId, childAgent = false) {
609
+ async streamChat(content, attachments = [], res, userMessageId, assistantMessageId, childAgent = false, clientId) {
591
610
  // 如果正在生成,等待当前生成完成(最多等待 2 秒)
592
611
  if (this.isGenerating) {
593
612
  const maxWait = 2000;
@@ -615,8 +634,35 @@ class Session {
615
634
  this.currentMessageId = assistantMessageId;
616
635
  const memoryManager = new MemoryManager_js_1.MemoryManager(this, this.abortController.signal, childAgent, res);
617
636
  const historyMessages = await memoryManager.getHistoryMessagesAsync();
618
- // 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 时跳过写入(本地执行模式),但仍同步给其他终端
619
664
  const sendSSE = (res, data) => {
665
+ mirrorToOtherClients(data);
620
666
  if (!res)
621
667
  return;
622
668
  try {
@@ -636,6 +682,19 @@ class Session {
636
682
  clearInterval(heartbeatInterval);
637
683
  }
638
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
+ }
639
698
  // Send message start event
640
699
  sendSSE(res, { type: 'message_start' });
641
700
  // Build messages for API call (保留 tool_call_id 等必要字段)
@@ -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.84",
4
4
  "description": "我的助手 Gateway Service - 本地 AI 网关服务,支持认证、WebSocket 实时通信和任务调度",
5
5
  "main": "dist/index.js",
6
6
  "bin": {