@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 +61 -36
- package/dist/middleware/broadcast.js +160 -0
- package/dist/routes/agent.js +4 -2
- package/dist/routes/auth.js +5 -4
- package/dist/services/ServiceManager.js +193 -63
- package/dist/services/WebSocketService.js +110 -47
- package/dist/services/dataService.js +5 -6
- package/dist/services/session/Session.js +137 -3
- package/dist/services/tools/plan.js +1 -10
- package/dist/stores/authStore.js +132 -23
- package/package.json +1 -1
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
+
}
|
package/dist/routes/agent.js
CHANGED
|
@@ -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' });
|
package/dist/routes/auth.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 请求上下文;
|