@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.
package/dist/main.js CHANGED
@@ -25,12 +25,28 @@ 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");
31
32
  const TaskSchedulerService_js_1 = require("./services/TaskSchedulerService.js");
32
33
  const ServiceManager_js_1 = require("./services/ServiceManager.js");
33
34
  const logger = (0, shared_1.getLogger)('index');
35
+ // 内置放通的官方域名(含子域名),无需用户手动 addCors
36
+ const DEFAULT_CORS_DOMAINS = ['my-assis.com'];
37
+ // 判断来源主机是否命中某个域名(自身或其子域名)
38
+ function matchDomain(originHost, domain) {
39
+ // 允许配置成裸域名(app.example.com)、带协议的完整来源(https://app.example.com)或通配符(*.example.com)
40
+ const d = domain
41
+ .trim()
42
+ .toLowerCase()
43
+ .replace(/^https?:\/\//, '')
44
+ .replace(/\/.*$/, '')
45
+ .replace(/^\*\./, '');
46
+ if (!d)
47
+ return false;
48
+ return originHost === d || originHost.endsWith('.' + d);
49
+ }
34
50
  // ─── CLI 模式 ─────────────────────────────────────────
35
51
  // gateway install | start | stop | uninstall | status | update
36
52
  const cliCommand = process.argv[2];
@@ -137,9 +153,11 @@ if (cliCommand) {
137
153
  }
138
154
  }
139
155
  else if (cliCommand === 'listCors') {
156
+ console.log(`内置 CORS 域名 (${DEFAULT_CORS_DOMAINS.length}):`);
157
+ DEFAULT_CORS_DOMAINS.forEach(d => console.log(` - *.${d}`));
140
158
  const domains = persistStore_js_1.persistStore.getCorsDomains();
141
159
  if (domains.length === 0) {
142
- console.log('暂无自定义 CORS 域名(仅使用内置内网规则)');
160
+ console.log('暂无自定义 CORS 域名(仅使用内置内网与内置域名规则)');
143
161
  }
144
162
  else {
145
163
  console.log(`自定义 CORS 域名 (${domains.length}):`);
@@ -147,35 +165,27 @@ if (cliCommand) {
147
165
  }
148
166
  }
149
167
  else if (cliCommand === '--help' || cliCommand === '-h') {
150
- console.log(`我的助手 Gateway CLI
151
-
152
- 用法: gateway <命令>
153
-
154
- 服务管理命令:
155
- install 安装 Gateway 服务(后台运行)
156
- uninstall 卸载 Gateway 服务
157
- start 启动 Gateway 服务
158
- stop 停止 Gateway 服务
159
- restart 重启 Gateway 服务
160
- update 更新 Gateway(需重新安装)
161
- status 查看服务状态
162
-
163
- CORS 管理命令:
164
- addCors <域名> 添加允许的跨域域名
165
- removeCors <域名> 移除允许的跨域域名
166
- listCors 列出所有自定义跨域域名
167
-
168
- 其他:
169
- --help, -h 显示本帮助信息
168
+ console.log(`我的助手 Gateway CLI
169
+
170
+ 用法: gateway <命令>
171
+
172
+ 服务管理命令:
173
+ install 安装 Gateway 服务(后台运行)
174
+ uninstall 卸载 Gateway 服务
175
+ start 启动 Gateway 服务
176
+ stop 停止 Gateway 服务
177
+ restart 重启 Gateway 服务
178
+ update 更新 Gateway(需重新安装)
179
+ status 查看服务状态
180
+
181
+ CORS 管理命令:
182
+ addCors <域名> 添加允许的跨域域名
183
+ removeCors <域名> 移除允许的跨域域名
184
+ listCors 列出所有跨域域名(含内置域名)
185
+
186
+ 其他:
187
+ --help, -h 显示本帮助信息
170
188
  `);
171
- const domains = persistStore_js_1.persistStore.getCorsDomains();
172
- if (domains.length === 0) {
173
- console.log('暂无自定义 CORS 域名(仅使用内置内网规则)');
174
- }
175
- else {
176
- console.log(`自定义 CORS 域名 (${domains.length}):`);
177
- domains.forEach(d => console.log(` - ${d}`));
178
- }
179
189
  }
180
190
  else {
181
191
  const fnMap = {
@@ -233,25 +243,40 @@ else {
233
243
  if (lanPatterns.some(pattern => pattern.test(origin))) {
234
244
  return callback(null, true);
235
245
  }
236
- // 检查自定义域名(支持子域名匹配)
237
- const customDomains = persistStore_js_1.persistStore.getCorsDomains();
238
- const originLower = origin.toLowerCase();
239
- if (customDomains.some(d => {
240
- const dLower = d.toLowerCase();
241
- return originLower === dLower || originLower.endsWith('.' + dLower);
242
- })) {
246
+ let originHost = '';
247
+ try {
248
+ originHost = new URL(origin).hostname.toLowerCase();
249
+ }
250
+ catch {
251
+ originHost = origin.toLowerCase();
252
+ }
253
+ // 检查内置官方域名与用户自定义域名(均支持子域名匹配)
254
+ const allowedDomains = [...DEFAULT_CORS_DOMAINS, ...persistStore_js_1.persistStore.getCorsDomains()];
255
+ if (allowedDomains.some(d => matchDomain(originHost, d))) {
243
256
  return callback(null, true);
244
257
  }
245
258
  callback(new Error('Not allowed by CORS'));
246
259
  },
247
260
  credentials: true,
248
261
  };
262
+ // Chrome Private Network Access:公网页面(网页版)访问本机 Gateway 时,
263
+ // 预检请求会带 Access-Control-Request-Private-Network,必须显式放行。
264
+ // 必须注册在 cors() 之前,否则 cors 会先结束 OPTIONS 预检,响应头加不上。
265
+ app.use((req, res, next) => {
266
+ if (req.headers['access-control-request-private-network']) {
267
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
268
+ }
269
+ next();
270
+ });
249
271
  app.use((0, cors_1.default)(corsOptions));
250
272
  app.use(express_1.default.json({ limit: '10mb' }));
251
273
  // Health check
252
274
  app.get('/health', (req, res) => {
253
275
  res.json({ status: 'ok', service: 'gateway', version: '2.0.0', wsOnline: WebSocketService_js_1.webSocketService.getOnlineCount() });
254
276
  });
277
+ // 写请求成功后向该用户的其他终端广播数据变更(多终端同步)。
278
+ // 必须注册在业务路由之前:它通过包装 res.json 生效。
279
+ app.use(broadcast_js_1.broadcastDataChanges);
255
280
  // Routes
256
281
  app.use('/api/v1/auth', auth_js_1.default);
257
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 请求上下文;