@myassis/gateway 1.0.81 → 1.0.83
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/config/index.js +4 -0
- package/dist/main.js +57 -36
- package/dist/services/ServiceManager.js +193 -63
- package/dist/services/memory/MemoryManager.js +179 -37
- package/dist/services/session/Session.js +121 -1
- package/dist/services/session/SessionManager.js +2 -0
- package/dist/services/tools/plan.js +1 -10
- package/package.json +1 -1
package/dist/config/index.js
CHANGED
|
@@ -91,6 +91,10 @@ exports.appConfig = {
|
|
|
91
91
|
oldToolContentLimit: parseInt(process.env.OLD_TOOL_CONTENT_LIMIT || '500', 10),
|
|
92
92
|
/** 单次请求内最大工具调用轮数 */
|
|
93
93
|
maxToolRounds: parseInt(process.env.MAX_TOOL_ROUNDS || '200', 10),
|
|
94
|
+
/** 是否启用后台预压缩:回复结束后预先生成摘要,降低下一轮首字延迟 */
|
|
95
|
+
enablePrecompression: process.env.ENABLE_PRECOMPRESSION !== 'false',
|
|
96
|
+
/** 回复结束后延迟多久启动预压缩(毫秒),留出时间给用户连续追问 */
|
|
97
|
+
precompressionDelay: parseInt(process.env.PRECOMPRESSION_DELAY || '3000', 10),
|
|
94
98
|
/** 触发上下文压缩的字符阈值 */
|
|
95
99
|
summaryTriggerChars: parseInt(process.env.SUMMARY_TRIGGER_CHARS || '60000', 10),
|
|
96
100
|
appName: '我的助手'
|
package/dist/main.js
CHANGED
|
@@ -31,6 +31,21 @@ const WebSocketService_js_1 = require("./services/WebSocketService.js");
|
|
|
31
31
|
const TaskSchedulerService_js_1 = require("./services/TaskSchedulerService.js");
|
|
32
32
|
const ServiceManager_js_1 = require("./services/ServiceManager.js");
|
|
33
33
|
const logger = (0, shared_1.getLogger)('index');
|
|
34
|
+
// 内置放通的官方域名(含子域名),无需用户手动 addCors
|
|
35
|
+
const DEFAULT_CORS_DOMAINS = ['my-assis.com'];
|
|
36
|
+
// 判断来源主机是否命中某个域名(自身或其子域名)
|
|
37
|
+
function matchDomain(originHost, domain) {
|
|
38
|
+
// 允许配置成裸域名(app.example.com)、带协议的完整来源(https://app.example.com)或通配符(*.example.com)
|
|
39
|
+
const d = domain
|
|
40
|
+
.trim()
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/^https?:\/\//, '')
|
|
43
|
+
.replace(/\/.*$/, '')
|
|
44
|
+
.replace(/^\*\./, '');
|
|
45
|
+
if (!d)
|
|
46
|
+
return false;
|
|
47
|
+
return originHost === d || originHost.endsWith('.' + d);
|
|
48
|
+
}
|
|
34
49
|
// ─── CLI 模式 ─────────────────────────────────────────
|
|
35
50
|
// gateway install | start | stop | uninstall | status | update
|
|
36
51
|
const cliCommand = process.argv[2];
|
|
@@ -137,9 +152,11 @@ if (cliCommand) {
|
|
|
137
152
|
}
|
|
138
153
|
}
|
|
139
154
|
else if (cliCommand === 'listCors') {
|
|
155
|
+
console.log(`内置 CORS 域名 (${DEFAULT_CORS_DOMAINS.length}):`);
|
|
156
|
+
DEFAULT_CORS_DOMAINS.forEach(d => console.log(` - *.${d}`));
|
|
140
157
|
const domains = persistStore_js_1.persistStore.getCorsDomains();
|
|
141
158
|
if (domains.length === 0) {
|
|
142
|
-
console.log('暂无自定义 CORS
|
|
159
|
+
console.log('暂无自定义 CORS 域名(仅使用内置内网与内置域名规则)');
|
|
143
160
|
}
|
|
144
161
|
else {
|
|
145
162
|
console.log(`自定义 CORS 域名 (${domains.length}):`);
|
|
@@ -147,35 +164,27 @@ if (cliCommand) {
|
|
|
147
164
|
}
|
|
148
165
|
}
|
|
149
166
|
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 显示本帮助信息
|
|
167
|
+
console.log(`我的助手 Gateway CLI
|
|
168
|
+
|
|
169
|
+
用法: gateway <命令>
|
|
170
|
+
|
|
171
|
+
服务管理命令:
|
|
172
|
+
install 安装 Gateway 服务(后台运行)
|
|
173
|
+
uninstall 卸载 Gateway 服务
|
|
174
|
+
start 启动 Gateway 服务
|
|
175
|
+
stop 停止 Gateway 服务
|
|
176
|
+
restart 重启 Gateway 服务
|
|
177
|
+
update 更新 Gateway(需重新安装)
|
|
178
|
+
status 查看服务状态
|
|
179
|
+
|
|
180
|
+
CORS 管理命令:
|
|
181
|
+
addCors <域名> 添加允许的跨域域名
|
|
182
|
+
removeCors <域名> 移除允许的跨域域名
|
|
183
|
+
listCors 列出所有跨域域名(含内置域名)
|
|
184
|
+
|
|
185
|
+
其他:
|
|
186
|
+
--help, -h 显示本帮助信息
|
|
170
187
|
`);
|
|
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
188
|
}
|
|
180
189
|
else {
|
|
181
190
|
const fnMap = {
|
|
@@ -233,19 +242,31 @@ else {
|
|
|
233
242
|
if (lanPatterns.some(pattern => pattern.test(origin))) {
|
|
234
243
|
return callback(null, true);
|
|
235
244
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
245
|
+
let originHost = '';
|
|
246
|
+
try {
|
|
247
|
+
originHost = new URL(origin).hostname.toLowerCase();
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
originHost = origin.toLowerCase();
|
|
251
|
+
}
|
|
252
|
+
// 检查内置官方域名与用户自定义域名(均支持子域名匹配)
|
|
253
|
+
const allowedDomains = [...DEFAULT_CORS_DOMAINS, ...persistStore_js_1.persistStore.getCorsDomains()];
|
|
254
|
+
if (allowedDomains.some(d => matchDomain(originHost, d))) {
|
|
243
255
|
return callback(null, true);
|
|
244
256
|
}
|
|
245
257
|
callback(new Error('Not allowed by CORS'));
|
|
246
258
|
},
|
|
247
259
|
credentials: true,
|
|
248
260
|
};
|
|
261
|
+
// Chrome Private Network Access:公网页面(网页版)访问本机 Gateway 时,
|
|
262
|
+
// 预检请求会带 Access-Control-Request-Private-Network,必须显式放行。
|
|
263
|
+
// 必须注册在 cors() 之前,否则 cors 会先结束 OPTIONS 预检,响应头加不上。
|
|
264
|
+
app.use((req, res, next) => {
|
|
265
|
+
if (req.headers['access-control-request-private-network']) {
|
|
266
|
+
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
267
|
+
}
|
|
268
|
+
next();
|
|
269
|
+
});
|
|
249
270
|
app.use((0, cors_1.default)(corsOptions));
|
|
250
271
|
app.use(express_1.default.json({ limit: '10mb' }));
|
|
251
272
|
// Health check
|
|
@@ -112,65 +112,168 @@ function getStoredPid() {
|
|
|
112
112
|
* - 因此自杀场景改走 process.emit('SIGTERM'),复用 main.ts 已注册的优雅关闭逻辑,
|
|
113
113
|
* 并在响应返回后异步退出,避免 HTTP 调用方读不到结果。
|
|
114
114
|
*/
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
/**
|
|
116
|
+
* 探测进程是否存活(signal 0 不会真的发信号)
|
|
117
|
+
*/
|
|
118
|
+
function isPidAlive(pid) {
|
|
119
|
+
try {
|
|
120
|
+
process.kill(pid, 0);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 轮询等待指定进程退出,返回是否已退出
|
|
129
|
+
*/
|
|
130
|
+
async function waitForPidExit(pid, timeoutMs) {
|
|
131
|
+
const start = Date.now();
|
|
132
|
+
while (Date.now() - start < timeoutMs) {
|
|
133
|
+
if (!isPidAlive(pid))
|
|
134
|
+
return true;
|
|
135
|
+
await new Promise(r => setTimeout(r, 200));
|
|
136
|
+
}
|
|
137
|
+
return !isPidAlive(pid);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* 读取守护进程 PID
|
|
141
|
+
*/
|
|
142
|
+
function getDaemonPid() {
|
|
143
|
+
try {
|
|
144
|
+
if (!fs_1.default.existsSync(GATEWAY_DAEMON_PID_FILE))
|
|
145
|
+
return null;
|
|
146
|
+
// 去除 PowerShell Out-File -Encoding UTF8 写入的 BOM,否则 parseInt 会得到 NaN
|
|
147
|
+
const raw = fs_1.default.readFileSync(GATEWAY_DAEMON_PID_FILE, 'utf8').replace(/^\uFEFF/, '').trim();
|
|
148
|
+
const pid = parseInt(raw, 10);
|
|
149
|
+
return isNaN(pid) ? null : pid;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* 结束守护进程(运行 launcher.ps1 的那个 PowerShell)
|
|
157
|
+
*
|
|
158
|
+
* 必须在结束 gateway 之前调用:守护进程每 5s 轮询一次,若发现 gateway 已退出
|
|
159
|
+
* 且此刻停止标记不存在,就会立即重新拉起,表现为「stop 之后网关又活了」。
|
|
160
|
+
*/
|
|
161
|
+
async function stopDaemonProcess() {
|
|
162
|
+
if (process.platform !== 'win32')
|
|
118
163
|
return;
|
|
119
|
-
|
|
120
|
-
if (
|
|
164
|
+
const daemonPid = getDaemonPid();
|
|
165
|
+
if (daemonPid && daemonPid !== process.pid && isPidAlive(daemonPid)) {
|
|
121
166
|
try {
|
|
122
|
-
|
|
167
|
+
process.kill(daemonPid, 'SIGTERM');
|
|
123
168
|
}
|
|
124
169
|
catch { /* ignore */ }
|
|
125
|
-
|
|
126
|
-
setTimeout(() => {
|
|
170
|
+
if (!(await waitForPidExit(daemonPid, 3000))) {
|
|
127
171
|
try {
|
|
128
|
-
process.
|
|
172
|
+
process.kill(daemonPid, 'SIGKILL');
|
|
129
173
|
}
|
|
130
174
|
catch { /* ignore */ }
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}, 100).unref();
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
// —— 场景 2:停的是别人(孤儿进程 / 旧实例)—— 才真正发信号
|
|
137
|
-
try {
|
|
138
|
-
process.kill(pid, 'SIGTERM');
|
|
139
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
175
|
+
await waitForPidExit(daemonPid, 2000);
|
|
176
|
+
}
|
|
140
177
|
}
|
|
141
|
-
catch { /* ignore */ }
|
|
142
|
-
// 强制终止(如果还没退出)
|
|
143
178
|
try {
|
|
144
|
-
|
|
179
|
+
fs_1.default.unlinkSync(GATEWAY_DAEMON_PID_FILE);
|
|
145
180
|
}
|
|
146
181
|
catch { /* ignore */ }
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* PID 文件丢失/过期时的兜底:按 exe 路径匹配并结束 gateway 进程
|
|
185
|
+
*
|
|
186
|
+
* PID 文件位于 %TEMP%,可能被系统清理策略删除,此时仅靠 PID 会漏杀。
|
|
187
|
+
* 仅在打包为 exe 时启用:开发模式下 execPath 是 node.exe,按路径匹配会误杀其它 Node 进程。
|
|
188
|
+
*/
|
|
189
|
+
async function stopGatewayByExePath() {
|
|
190
|
+
if (process.platform !== 'win32' || !isPackagedExe())
|
|
191
|
+
return 0;
|
|
192
|
+
const exe = process.execPath;
|
|
193
|
+
const name = path_1.default.basename(exe, path_1.default.extname(exe));
|
|
194
|
+
const q = (v) => v.replace(/'/g, "''");
|
|
147
195
|
try {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
catch { /* ignore */ }
|
|
161
|
-
await new Promise(r => setTimeout(r, 500));
|
|
196
|
+
const { stdout } = await execAsync(`powershell -NoProfile -Command "Get-Process -Name '${q(name)}' -ErrorAction SilentlyContinue | ` +
|
|
197
|
+
`Where-Object { $_.Id -ne ${process.pid} -and $_.Path -ieq '${q(exe)}' } | ` +
|
|
198
|
+
`ForEach-Object { $_.Id }"`, { timeout: 10000, windowsHide: true });
|
|
199
|
+
const pids = stdout.split(/\r?\n/)
|
|
200
|
+
.map(line => parseInt(line.trim(), 10))
|
|
201
|
+
.filter(n => !isNaN(n) && n !== process.pid);
|
|
202
|
+
for (const orphan of pids) {
|
|
203
|
+
try {
|
|
204
|
+
process.kill(orphan, 'SIGTERM');
|
|
205
|
+
}
|
|
206
|
+
catch { /* ignore */ }
|
|
207
|
+
if (!(await waitForPidExit(orphan, 3000))) {
|
|
162
208
|
try {
|
|
163
|
-
process.kill(
|
|
209
|
+
process.kill(orphan, 'SIGKILL');
|
|
164
210
|
}
|
|
165
211
|
catch { /* ignore */ }
|
|
166
212
|
}
|
|
167
213
|
}
|
|
214
|
+
return pids.length;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* 停止 Gateway 用户进程(优雅终止)
|
|
222
|
+
*
|
|
223
|
+
* 注意:当 pid 指向当前进程自己时,绝不能使用 process.kill(pid)。
|
|
224
|
+
* - Windows 上 Node 的 process.kill 等同 TerminateProcess,无清理机会,
|
|
225
|
+
* server.close() / WebSocket / 文件句柄都不会被释放,会导致后续 exe 解锁超时。
|
|
226
|
+
* - 因此自杀场景改走 process.emit('SIGTERM'),复用 main.ts 已注册的优雅关闭逻辑,
|
|
227
|
+
* 并在响应返回后异步退出,避免 HTTP 调用方读不到结果。
|
|
228
|
+
*
|
|
229
|
+
* 返回:self 表示停的是当前进程(无法在本进程内校验最终结果),
|
|
230
|
+
* stopped 表示已确认目标进程退出。
|
|
231
|
+
*/
|
|
232
|
+
async function stopGatewayProcess() {
|
|
233
|
+
const pid = getStoredPid();
|
|
234
|
+
// 先断开守护进程,否则它会在 gateway 退出后立刻把它重新拉起
|
|
235
|
+
await stopDaemonProcess();
|
|
236
|
+
// —— 场景 1:要停的就是自己 —— 走优雅退出
|
|
237
|
+
if (pid && pid === process.pid) {
|
|
238
|
+
try {
|
|
239
|
+
fs_1.default.unlinkSync(GATEWAY_PID_FILE);
|
|
240
|
+
}
|
|
168
241
|
catch { /* ignore */ }
|
|
242
|
+
// 异步触发,确保当前调用栈(含 HTTP 响应)有机会先返回
|
|
243
|
+
setTimeout(() => {
|
|
244
|
+
try {
|
|
245
|
+
process.emit('SIGTERM');
|
|
246
|
+
}
|
|
247
|
+
catch { /* ignore */ }
|
|
248
|
+
// 兜底:5s 内 SIGTERM handler 没把进程关掉,再强退
|
|
249
|
+
setTimeout(() => process.exit(0), 5000).unref();
|
|
250
|
+
}, 100).unref();
|
|
251
|
+
return { stopped: true, self: true };
|
|
252
|
+
}
|
|
253
|
+
// —— 场景 2:停的是别人(CLI 停后台实例 / 孤儿进程)—— 才真正发信号
|
|
254
|
+
let stopped = true;
|
|
255
|
+
if (pid && isPidAlive(pid)) {
|
|
169
256
|
try {
|
|
170
|
-
|
|
257
|
+
process.kill(pid, 'SIGTERM');
|
|
171
258
|
}
|
|
172
259
|
catch { /* ignore */ }
|
|
260
|
+
stopped = await waitForPidExit(pid, 5000);
|
|
261
|
+
if (!stopped) {
|
|
262
|
+
// 优雅关闭超时,强制终止
|
|
263
|
+
try {
|
|
264
|
+
process.kill(pid, 'SIGKILL');
|
|
265
|
+
}
|
|
266
|
+
catch { /* ignore */ }
|
|
267
|
+
stopped = await waitForPidExit(pid, 3000);
|
|
268
|
+
}
|
|
173
269
|
}
|
|
270
|
+
try {
|
|
271
|
+
fs_1.default.unlinkSync(GATEWAY_PID_FILE);
|
|
272
|
+
}
|
|
273
|
+
catch { /* ignore */ }
|
|
274
|
+
// 兜底:清理没被 PID 文件记录到的残留进程
|
|
275
|
+
await stopGatewayByExePath();
|
|
276
|
+
return { stopped, self: false };
|
|
174
277
|
}
|
|
175
278
|
/**
|
|
176
279
|
* 写入 Gateway 启动脚本(用户级,不弹出窗口)
|
|
@@ -226,8 +329,14 @@ function writeGatewayLauncherScript(exePath, scriptPath, workDir) {
|
|
|
226
329
|
'# 守护循环:监控进程状态,异常退出则自动重启',
|
|
227
330
|
'while ($true) {',
|
|
228
331
|
' try {',
|
|
229
|
-
' # 每
|
|
230
|
-
' Start-Sleep -Seconds
|
|
332
|
+
' # 每 1 秒检查一次:轮询间隔过长会错过 stop 写入的停止标记,导致误重启',
|
|
333
|
+
' Start-Sleep -Seconds 1',
|
|
334
|
+
'',
|
|
335
|
+
' # 优先判定停止标记:无论 gateway 是否已退出,主动停止时守护都应该退出',
|
|
336
|
+
' if (Test-Path $stopFlag) {',
|
|
337
|
+
" Write-Log '检测到停止标记,守护进程退出'",
|
|
338
|
+
' break',
|
|
339
|
+
' }',
|
|
231
340
|
'',
|
|
232
341
|
' # 进程已退出',
|
|
233
342
|
' if ($proc.HasExited) {',
|
|
@@ -364,6 +473,11 @@ async function startServiceWindows() {
|
|
|
364
473
|
return { success: true, message: 'Gateway 已在运行' };
|
|
365
474
|
}
|
|
366
475
|
try {
|
|
476
|
+
// 清除上一次 stop 可能残留的停止标记,否则新守护会立即退出
|
|
477
|
+
try {
|
|
478
|
+
fs_1.default.unlinkSync(GATEWAY_STOP_FLAG_FILE);
|
|
479
|
+
}
|
|
480
|
+
catch { /* ignore */ }
|
|
367
481
|
const exe = getGatewayExePath();
|
|
368
482
|
const workDir = path_1.default.dirname(exe);
|
|
369
483
|
if (!fs_1.default.existsSync(GATEWAY_LAUNCHER_FILE)) {
|
|
@@ -388,9 +502,15 @@ async function startServiceWindows() {
|
|
|
388
502
|
}
|
|
389
503
|
async function restartServiceWindows() {
|
|
390
504
|
try {
|
|
391
|
-
await stopService();
|
|
505
|
+
const stopResult = await stopService();
|
|
506
|
+
if (!stopResult.success) {
|
|
507
|
+
return { success: false, message: `重启失败(停止阶段): ${stopResult.message}` };
|
|
508
|
+
}
|
|
392
509
|
await new Promise(r => setTimeout(r, 1000));
|
|
393
|
-
await startService();
|
|
510
|
+
const startResult = await startService();
|
|
511
|
+
if (!startResult.success) {
|
|
512
|
+
return { success: false, message: `重启失败(启动阶段): ${startResult.message}` };
|
|
513
|
+
}
|
|
394
514
|
return { success: true, message: 'Gateway 重启成功' };
|
|
395
515
|
}
|
|
396
516
|
catch (err) {
|
|
@@ -399,23 +519,33 @@ async function restartServiceWindows() {
|
|
|
399
519
|
}
|
|
400
520
|
async function stopServiceWindows() {
|
|
401
521
|
try {
|
|
522
|
+
if (!isGatewayRunning() && !getDaemonPid()) {
|
|
523
|
+
return { success: true, message: 'Gateway 未在运行' };
|
|
524
|
+
}
|
|
402
525
|
// 写入停止标记,告知守护进程这是主动停止,不要重启
|
|
403
526
|
try {
|
|
527
|
+
fs_1.default.mkdirSync(path_1.default.dirname(GATEWAY_STOP_FLAG_FILE), { recursive: true });
|
|
404
528
|
fs_1.default.writeFileSync(GATEWAY_STOP_FLAG_FILE, '1', 'utf8');
|
|
405
529
|
}
|
|
406
530
|
catch { /* ignore */ }
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
531
|
+
const { stopped, self } = await stopGatewayProcess();
|
|
532
|
+
// 自停场景(HTTP 接口调用)退出发生在本函数返回之后,无法在此校验,
|
|
533
|
+
// 也不能删停止标记(它要留给守护进程判定主动停止)
|
|
534
|
+
if (self) {
|
|
535
|
+
return { success: true, message: 'Gateway 正在退出' };
|
|
536
|
+
}
|
|
537
|
+
// 确认退出后才能清理停止标记,否则残留守护会把 gateway 重新拉起
|
|
538
|
+
if (!stopped) {
|
|
539
|
+
return { success: false, message: 'Gateway 进程未能终止,请手动结束进程后重试' };
|
|
540
|
+
}
|
|
411
541
|
try {
|
|
412
542
|
fs_1.default.unlinkSync(GATEWAY_STOP_FLAG_FILE);
|
|
413
543
|
}
|
|
414
544
|
catch { /* ignore */ }
|
|
415
|
-
|
|
416
|
-
|
|
545
|
+
// 最终校验:避免在没真正停掉时还报成功
|
|
546
|
+
if (isGatewayRunning()) {
|
|
547
|
+
return { success: false, message: 'Gateway 仍在运行,停止失败' };
|
|
417
548
|
}
|
|
418
|
-
catch { /* ignore */ }
|
|
419
549
|
return { success: true, message: 'Gateway 已停止' };
|
|
420
550
|
}
|
|
421
551
|
catch (err) {
|
|
@@ -451,21 +581,21 @@ async function installLinux() {
|
|
|
451
581
|
if (installed)
|
|
452
582
|
await stopService();
|
|
453
583
|
const execStart = isPkg ? exe : `${exe} ${script}`;
|
|
454
|
-
const unitContent = `[Unit]
|
|
455
|
-
Description=${exports.SERVICE_DISPLAY_NAME}
|
|
456
|
-
After=network.target
|
|
457
|
-
|
|
458
|
-
[Service]
|
|
459
|
-
Type=simple
|
|
460
|
-
User=${process.env.USER || 'root'}
|
|
461
|
-
WorkingDirectory=${workDir}
|
|
462
|
-
ExecStart=${execStart}
|
|
463
|
-
Restart=always
|
|
464
|
-
RestartSec=5
|
|
465
|
-
Environment=NODE_ENV=production
|
|
466
|
-
|
|
467
|
-
[Install]
|
|
468
|
-
WantedBy=multi-user.target
|
|
584
|
+
const unitContent = `[Unit]
|
|
585
|
+
Description=${exports.SERVICE_DISPLAY_NAME}
|
|
586
|
+
After=network.target
|
|
587
|
+
|
|
588
|
+
[Service]
|
|
589
|
+
Type=simple
|
|
590
|
+
User=${process.env.USER || 'root'}
|
|
591
|
+
WorkingDirectory=${workDir}
|
|
592
|
+
ExecStart=${execStart}
|
|
593
|
+
Restart=always
|
|
594
|
+
RestartSec=5
|
|
595
|
+
Environment=NODE_ENV=production
|
|
596
|
+
|
|
597
|
+
[Install]
|
|
598
|
+
WantedBy=multi-user.target
|
|
469
599
|
`;
|
|
470
600
|
await fs_1.default.promises.writeFile('/tmp/myassis-gateway.service', unitContent, 'utf8');
|
|
471
601
|
await execAsync('cp /tmp/myassis-gateway.service /etc/systemd/system/myassis-gateway.service', { timeout: 10000 });
|
|
@@ -38,6 +38,15 @@ const DEFAULT_CONFIG = {
|
|
|
38
38
|
summaryTriggerChars: index_js_2.appConfig.summaryTriggerChars,
|
|
39
39
|
enabled: true,
|
|
40
40
|
};
|
|
41
|
+
/**
|
|
42
|
+
* 会话级压缩任务表:sessionId -> 进行中的压缩任务。
|
|
43
|
+
*
|
|
44
|
+
* 前台请求与后台预压缩共用这张表,确保同一会话同一时刻只有一次摘要生成:
|
|
45
|
+
* 否则两边会各自调用一次摘要模型(浪费配额),并竞争写入 lastMessageSummary。
|
|
46
|
+
*/
|
|
47
|
+
const compressionTasks = new Map();
|
|
48
|
+
/** 后台预压缩的中断控制器,用于会话删除时取消 */
|
|
49
|
+
const backgroundAborts = new Map();
|
|
41
50
|
/** SSE 辅助方法:res 为 null 时跳过写入(本地执行模式) */
|
|
42
51
|
const sendSSE = (res, data) => {
|
|
43
52
|
if (!res)
|
|
@@ -103,7 +112,10 @@ class MemoryManager {
|
|
|
103
112
|
return fallback;
|
|
104
113
|
return summarized[summarized.length - 1].createdAt;
|
|
105
114
|
}
|
|
106
|
-
|
|
115
|
+
/**
|
|
116
|
+
* 制定压缩计划:只做判断与切分,不触发任何模型调用。
|
|
117
|
+
*/
|
|
118
|
+
plan() {
|
|
107
119
|
const keep = index_js_2.appConfig.historyKeep;
|
|
108
120
|
// 复制数组:getMessages() 返回的是会话内部的活引用,不可原地修改
|
|
109
121
|
let messages = [...this.session.getMessages()];
|
|
@@ -111,29 +123,25 @@ class MemoryManager {
|
|
|
111
123
|
// 子 agent 的最后一条消息由调用方单独拼接,此处排除
|
|
112
124
|
messages = messages.slice(0, -1);
|
|
113
125
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
const empty = {
|
|
127
|
+
action: 'none', toSummarize: [], ledger: [], retained: messages,
|
|
128
|
+
newBoundaryAt: 0, prevBoundaryAt: null, prevLedger: [], prevRetained: messages,
|
|
129
|
+
};
|
|
130
|
+
if (!this.shouldSummarize(messages) || messages.length <= keep) {
|
|
131
|
+
return empty;
|
|
119
132
|
}
|
|
120
|
-
|
|
121
|
-
if (!hasSummary) {
|
|
122
|
-
// 首次摘要生成
|
|
133
|
+
if (!this.session.lastMessageSummary) {
|
|
123
134
|
const toSummarize = messages.slice(0, -keep);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
this.toSummaryMessage(summary, boundary, '', toSummarize),
|
|
135
|
-
...messages.slice(-keep),
|
|
136
|
-
];
|
|
135
|
+
return {
|
|
136
|
+
action: 'first',
|
|
137
|
+
toSummarize,
|
|
138
|
+
ledger: toSummarize,
|
|
139
|
+
retained: messages.slice(-keep),
|
|
140
|
+
newBoundaryAt: this.boundaryOf(toSummarize, Date.now()),
|
|
141
|
+
prevBoundaryAt: null,
|
|
142
|
+
prevLedger: [],
|
|
143
|
+
prevRetained: messages,
|
|
144
|
+
};
|
|
137
145
|
}
|
|
138
146
|
// 增量摘要:边界之后的消息才是“新消息”
|
|
139
147
|
const boundaryAt = this.session.lastMessageSummaryAt;
|
|
@@ -141,29 +149,163 @@ class MemoryManager {
|
|
|
141
149
|
// 边界之前的消息已被摘要取代,其工具调用需通过账本保留
|
|
142
150
|
const omitted = messages.filter((m) => m.createdAt <= boundaryAt);
|
|
143
151
|
if (!this.shouldSummarize(newMessages) || newMessages.length <= keep) {
|
|
144
|
-
return
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
152
|
+
return {
|
|
153
|
+
action: 'reuse',
|
|
154
|
+
toSummarize: [], ledger: [], retained: [],
|
|
155
|
+
newBoundaryAt: boundaryAt,
|
|
156
|
+
prevBoundaryAt: boundaryAt,
|
|
157
|
+
prevLedger: omitted,
|
|
158
|
+
prevRetained: newMessages,
|
|
159
|
+
};
|
|
148
160
|
}
|
|
149
|
-
// 新消息超过阈值,基于旧摘要增量重算
|
|
150
161
|
const toSummarize = newMessages.slice(0, -keep);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
162
|
+
return {
|
|
163
|
+
action: 'incremental',
|
|
164
|
+
toSummarize,
|
|
165
|
+
ledger: [...omitted, ...toSummarize],
|
|
166
|
+
retained: newMessages.slice(-keep),
|
|
167
|
+
newBoundaryAt: this.boundaryOf(toSummarize, boundaryAt),
|
|
168
|
+
prevBoundaryAt: boundaryAt,
|
|
169
|
+
prevLedger: omitted,
|
|
170
|
+
prevRetained: newMessages,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* 执行压缩计划并写入会话。
|
|
175
|
+
*
|
|
176
|
+
* @returns 摘要文本;null 表示失败/中断(调用方应沿用旧摘要或原始历史)
|
|
177
|
+
*/
|
|
178
|
+
async runPlan(plan) {
|
|
179
|
+
if (plan.action !== 'first' && plan.action !== 'incremental')
|
|
180
|
+
return null;
|
|
181
|
+
const summary = await this.generateSummaryAsync(plan.toSummarize, plan.action === 'incremental' ? this.session.lastMessageSummary : null);
|
|
182
|
+
if (!summary)
|
|
183
|
+
return null;
|
|
184
|
+
// 并发保护:若边界已被其他压缩任务推进,说明有更新的摘要,放弃本次结果
|
|
185
|
+
if (plan.prevBoundaryAt !== null
|
|
186
|
+
&& this.session.lastMessageSummaryAt !== plan.prevBoundaryAt) {
|
|
187
|
+
logger.info('摘要边界已被其他任务推进,丢弃本次压缩结果');
|
|
188
|
+
return null;
|
|
157
189
|
}
|
|
158
|
-
const nextBoundary = this.boundaryOf(toSummarize, boundaryAt);
|
|
159
190
|
this.session.lastMessageSummary = summary;
|
|
160
|
-
this.session.lastMessageSummaryAt =
|
|
191
|
+
this.session.lastMessageSummaryAt = plan.newBoundaryAt;
|
|
161
192
|
this.session.save();
|
|
193
|
+
return summary;
|
|
194
|
+
}
|
|
195
|
+
async getHistoryMessagesAsync() {
|
|
196
|
+
// 若后台预压缩正在进行,等它完成即可直接复用结果,避免重复调用摘要模型
|
|
197
|
+
const pending = compressionTasks.get(this.session.id);
|
|
198
|
+
if (pending) {
|
|
199
|
+
logger.debug('等待进行中的压缩任务完成');
|
|
200
|
+
try {
|
|
201
|
+
await pending;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// 后台任务失败不影响前台,重新按当前状态决策
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const plan = this.plan();
|
|
208
|
+
if (plan.action === 'none') {
|
|
209
|
+
return plan.retained;
|
|
210
|
+
}
|
|
211
|
+
if (plan.action === 'reuse') {
|
|
212
|
+
return [
|
|
213
|
+
this.toSummaryMessage(this.session.lastMessageSummary, plan.prevBoundaryAt, '', plan.prevLedger),
|
|
214
|
+
...plan.prevRetained,
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
// 前台压缩同样登记到任务表,阻止后台预压缩重复执行
|
|
218
|
+
const task = this.runPlan(plan);
|
|
219
|
+
compressionTasks.set(this.session.id, task.then(() => undefined, () => undefined));
|
|
220
|
+
let summary = null;
|
|
221
|
+
try {
|
|
222
|
+
summary = await task;
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
compressionTasks.delete(this.session.id);
|
|
226
|
+
}
|
|
227
|
+
if (!summary) {
|
|
228
|
+
// 摘要失败/被中断:沿用旧摘要或退回原始历史,不写入残缺摘要
|
|
229
|
+
if (plan.action === 'incremental') {
|
|
230
|
+
return [
|
|
231
|
+
this.toSummaryMessage(this.session.lastMessageSummary, plan.prevBoundaryAt, '', plan.prevLedger),
|
|
232
|
+
...plan.prevRetained,
|
|
233
|
+
];
|
|
234
|
+
}
|
|
235
|
+
return plan.prevRetained;
|
|
236
|
+
}
|
|
162
237
|
return [
|
|
163
|
-
this.toSummaryMessage(summary,
|
|
164
|
-
...
|
|
238
|
+
this.toSummaryMessage(summary, plan.newBoundaryAt, '', plan.ledger),
|
|
239
|
+
...plan.retained,
|
|
165
240
|
];
|
|
166
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* 是否值得后台预压缩:仅当确实需要生成新摘要时才启动。
|
|
244
|
+
*/
|
|
245
|
+
needsPrecompression() {
|
|
246
|
+
const action = this.plan().action;
|
|
247
|
+
return action === 'first' || action === 'incremental';
|
|
248
|
+
}
|
|
249
|
+
// ========== 后台预压缩 ==========
|
|
250
|
+
/**
|
|
251
|
+
* 在上一轮回复结束后于后台预先生成摘要,让下一轮请求直接命中结果。
|
|
252
|
+
*
|
|
253
|
+
* 设计要点:
|
|
254
|
+
* - 与前台共用 compressionTasks 表,同一会话不会重复调用摘要模型;
|
|
255
|
+
* - 失败只记日志,绝不抛出:预压缩是纯优化,失败时前台会自行压缩;
|
|
256
|
+
* - 使用独立 AbortController,不受上一轮请求的 signal 影响
|
|
257
|
+
* (否则请求结束时 signal 被 abort,后台任务会立刻死掉)。
|
|
258
|
+
*/
|
|
259
|
+
static schedulePrecompression(session) {
|
|
260
|
+
if (!index_js_2.appConfig.enablePrecompression)
|
|
261
|
+
return;
|
|
262
|
+
if (compressionTasks.has(session.id))
|
|
263
|
+
return;
|
|
264
|
+
// 正在生成时不预压缩:消息还会继续追加,此刻的摘要边界会立即过期
|
|
265
|
+
if (session.isGenerating)
|
|
266
|
+
return;
|
|
267
|
+
const controller = new AbortController();
|
|
268
|
+
const manager = new MemoryManager(session, controller.signal, true, null);
|
|
269
|
+
if (!manager.needsPrecompression())
|
|
270
|
+
return;
|
|
271
|
+
logger.info(`启动后台预压缩: session=${session.id}`);
|
|
272
|
+
backgroundAborts.set(session.id, controller);
|
|
273
|
+
const task = (async () => {
|
|
274
|
+
const startedAt = Date.now();
|
|
275
|
+
try {
|
|
276
|
+
const plan = manager.plan();
|
|
277
|
+
if (plan.action !== 'first' && plan.action !== 'incremental')
|
|
278
|
+
return;
|
|
279
|
+
const summary = await manager.runPlan(plan);
|
|
280
|
+
if (summary) {
|
|
281
|
+
logger.info(`后台预压缩完成: session=${session.id} 耗时=${Date.now() - startedAt}ms`);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
logger.info(`后台预压缩未产生新摘要: session=${session.id}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
// 预压缩失败不影响任何用户可见行为
|
|
289
|
+
logger.warn(`后台预压缩失败: session=${session.id} ${error?.message}`);
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
compressionTasks.delete(session.id);
|
|
293
|
+
backgroundAborts.delete(session.id);
|
|
294
|
+
}
|
|
295
|
+
})();
|
|
296
|
+
compressionTasks.set(session.id, task);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* 取消会话的后台预压缩(会话删除或用户重新发起请求时调用)
|
|
300
|
+
*/
|
|
301
|
+
static cancelPrecompression(sessionId) {
|
|
302
|
+
const controller = backgroundAborts.get(sessionId);
|
|
303
|
+
if (controller) {
|
|
304
|
+
controller.abort();
|
|
305
|
+
backgroundAborts.delete(sessionId);
|
|
306
|
+
logger.debug(`已取消后台预压缩: session=${sessionId}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
167
309
|
/**
|
|
168
310
|
* 格式化工具调用内容,提取关键信息供摘要模型理解
|
|
169
311
|
*/
|
|
@@ -114,6 +114,8 @@ class Session {
|
|
|
114
114
|
unreadCount = 0;
|
|
115
115
|
currentMessageId = null;
|
|
116
116
|
abortController = null;
|
|
117
|
+
/** 后台预压缩的延迟定时器 */
|
|
118
|
+
precompressionTimer = null;
|
|
117
119
|
toInsertMessages;
|
|
118
120
|
constructor(data) {
|
|
119
121
|
this.id = data.id;
|
|
@@ -493,6 +495,59 @@ class Session {
|
|
|
493
495
|
lines.push('调用时必须传入完整步骤列表,且同一时刻最多一个步骤为 in_progress。');
|
|
494
496
|
return lines.join('\n');
|
|
495
497
|
}
|
|
498
|
+
/**
|
|
499
|
+
* 把当前计划推送到 Desktop。
|
|
500
|
+
*
|
|
501
|
+
* 用动态 import 获取 webSocketService:WebSocketService 反向依赖本文件的
|
|
502
|
+
* handleApprovalResponse,静态引入会形成循环依赖。
|
|
503
|
+
*/
|
|
504
|
+
async notifyPlanUpdate() {
|
|
505
|
+
try {
|
|
506
|
+
const { webSocketService } = await Promise.resolve().then(() => __importStar(require('../WebSocketService.js')));
|
|
507
|
+
webSocketService.sendToUser(String(this.userId), {
|
|
508
|
+
type: 'plan_updated',
|
|
509
|
+
payload: {
|
|
510
|
+
sessionId: this.id,
|
|
511
|
+
agentId: this.agentId,
|
|
512
|
+
steps: this.plan,
|
|
513
|
+
updatedAt: this.planUpdatedAt,
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
logger.error('推送计划更新失败:', error);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* 构造「收尾时计划未完成」的提醒。
|
|
523
|
+
*
|
|
524
|
+
* 模型准备结束回复时,如果计划里还有未完成的步骤,说明它要么漏了同步,
|
|
525
|
+
* 要么还有事情没做完。返回提醒文案要求它先处理;无需提醒时返回 null。
|
|
526
|
+
*/
|
|
527
|
+
buildPlanFinalizeReminder() {
|
|
528
|
+
if (this.plan.length === 0 || this.isPlanCompleted())
|
|
529
|
+
return null;
|
|
530
|
+
const unfinished = this.plan.filter(item => item.status !== 'completed');
|
|
531
|
+
const list = unfinished.map(item => `「${item.step}」`).join('、');
|
|
532
|
+
return `【计划未完成提醒】你准备结束本轮回复,但计划中仍有 ${unfinished.length} 个步骤未标记完成:${list}。
|
|
533
|
+
`
|
|
534
|
+
+ `请判断:如果这些步骤其实已经做完了,立即调用 updatePlan 把它们全部置为 completed;
|
|
535
|
+
`
|
|
536
|
+
+ `如果确实还没做,请继续完成剩余工作并同步计划状态。不要在计划未收尾的情况下结束回复。`;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* 本轮结束时兜底处理未完成的计划。
|
|
540
|
+
*
|
|
541
|
+
* 即使提醒过模型,它仍可能不收尾。此时清空计划,避免 Desktop 的
|
|
542
|
+
* 进度圈永远停留在未完成状态。
|
|
543
|
+
*/
|
|
544
|
+
async finalizePlanOnComplete() {
|
|
545
|
+
if (this.plan.length === 0 || this.isPlanCompleted())
|
|
546
|
+
return;
|
|
547
|
+
logger.warn(`会话 ${this.id} 结束时计划仍未完成,已自动清除计划以释放进度圈`);
|
|
548
|
+
this.clearPlan();
|
|
549
|
+
await this.notifyPlanUpdate();
|
|
550
|
+
}
|
|
496
551
|
// 清除未读消息数并持久化
|
|
497
552
|
clearUnreadCount() {
|
|
498
553
|
this.unreadCount = 0;
|
|
@@ -549,6 +604,8 @@ class Session {
|
|
|
549
604
|
if (this.isPlanCompleted()) {
|
|
550
605
|
this.clearPlan();
|
|
551
606
|
}
|
|
607
|
+
// 新请求到来:放弃排队中的预压缩,避免与前台压缩重复调用摘要模型
|
|
608
|
+
this.abortPrecompression();
|
|
552
609
|
// Add user message
|
|
553
610
|
this.getAbortController();
|
|
554
611
|
this.isGenerating = true;
|
|
@@ -641,6 +698,8 @@ class Session {
|
|
|
641
698
|
let toolRound = 0;
|
|
642
699
|
// 计划模式:距上次调用 updatePlan 已经过的工具轮次,用于在中间过程强制提醒模型同步进度
|
|
643
700
|
let roundsSincePlanUpdate = 0;
|
|
701
|
+
// 计划模式:收尾时提醒模型补完计划的次数,避免反复提醒导致死循环
|
|
702
|
+
let planFinalizeNudges = 0;
|
|
644
703
|
// 当前裁剪级别,遇到上下文超限时逐级加重
|
|
645
704
|
let trimLevel = ContextBuilder_js_1.TrimLevel.OldToolPayload;
|
|
646
705
|
/** 判断错误是否为上下文超限 */
|
|
@@ -995,7 +1054,25 @@ class Session {
|
|
|
995
1054
|
return await processModelResponse();
|
|
996
1055
|
}
|
|
997
1056
|
else {
|
|
998
|
-
// ==========
|
|
1057
|
+
// ========== 没有工具调用,准备结束本轮 ==========
|
|
1058
|
+
// 计划模式:计划还没收尾时,先提醒模型补完状态(最多提醒一次,避免死循环)
|
|
1059
|
+
if (planFinalizeNudges < 1) {
|
|
1060
|
+
const finalizeReminder = this.buildPlanFinalizeReminder();
|
|
1061
|
+
if (finalizeReminder) {
|
|
1062
|
+
planFinalizeNudges++;
|
|
1063
|
+
messages.push({
|
|
1064
|
+
role: 'assistant',
|
|
1065
|
+
content: llmResult.content || llmResult.reasoningContent || '',
|
|
1066
|
+
attachments: []
|
|
1067
|
+
});
|
|
1068
|
+
messages.push({
|
|
1069
|
+
role: 'user',
|
|
1070
|
+
content: finalizeReminder,
|
|
1071
|
+
attachments: []
|
|
1072
|
+
});
|
|
1073
|
+
return await processModelResponse();
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
999
1076
|
if (llmResult.content || llmResult.reasoningContent) {
|
|
1000
1077
|
// 分段发送内容,每段约50个字符
|
|
1001
1078
|
const chunkSize = 1;
|
|
@@ -1052,20 +1129,63 @@ class Session {
|
|
|
1052
1129
|
logger.error('Error closing SSE connection:', e);
|
|
1053
1130
|
}
|
|
1054
1131
|
}
|
|
1132
|
+
// 计划模式:本轮已结束,若计划仍未完成则兜底清除,避免进度圈残留
|
|
1133
|
+
void this.finalizePlanOnComplete();
|
|
1055
1134
|
// 非当前查看的 session,助手回复完成后增加未读计数
|
|
1056
1135
|
(0, SessionManager_js_1.getSessionManager)(this.userId).onMessageComplete(this.id);
|
|
1057
1136
|
// 只在非 abort 的情况下保存(abort 由 stopGenerating 负责保存)
|
|
1058
1137
|
if (this.currentMessageId !== null) {
|
|
1059
1138
|
this.saveMessage();
|
|
1060
1139
|
}
|
|
1140
|
+
// 后台预压缩:本轮已结束,提前生成摘要让下一轮直接命中,降低首字延迟。
|
|
1141
|
+
// 延迟启动是为了给用户的连续追问让路(追问会取消它)。
|
|
1142
|
+
this.schedulePrecompression();
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* 延迟调度后台预压缩。
|
|
1148
|
+
*
|
|
1149
|
+
* 不 await:预压缩是纯优化,绝不能阻塞本轮请求的收尾。
|
|
1150
|
+
*/
|
|
1151
|
+
schedulePrecompression() {
|
|
1152
|
+
if (!index_js_2.appConfig.enablePrecompression)
|
|
1153
|
+
return;
|
|
1154
|
+
this.cancelPrecompressionTimer();
|
|
1155
|
+
this.precompressionTimer = setTimeout(() => {
|
|
1156
|
+
this.precompressionTimer = null;
|
|
1157
|
+
try {
|
|
1158
|
+
MemoryManager_js_1.MemoryManager.schedulePrecompression(this);
|
|
1159
|
+
}
|
|
1160
|
+
catch (error) {
|
|
1161
|
+
logger.warn('调度后台预压缩失败:', error?.message);
|
|
1061
1162
|
}
|
|
1163
|
+
}, index_js_2.appConfig.precompressionDelay);
|
|
1164
|
+
// 不阻止进程退出
|
|
1165
|
+
this.precompressionTimer.unref?.();
|
|
1166
|
+
}
|
|
1167
|
+
/** 取消尚未触发的预压缩定时器 */
|
|
1168
|
+
cancelPrecompressionTimer() {
|
|
1169
|
+
if (this.precompressionTimer) {
|
|
1170
|
+
clearTimeout(this.precompressionTimer);
|
|
1171
|
+
this.precompressionTimer = null;
|
|
1062
1172
|
}
|
|
1063
1173
|
}
|
|
1174
|
+
/**
|
|
1175
|
+
* 用户发起新请求或会话销毁时,放弃排队中/进行中的预压缩。
|
|
1176
|
+
*
|
|
1177
|
+
* 前台会自行压缩,且新消息会使预压缩的边界立即过期。
|
|
1178
|
+
*/
|
|
1179
|
+
abortPrecompression() {
|
|
1180
|
+
this.cancelPrecompressionTimer();
|
|
1181
|
+
MemoryManager_js_1.MemoryManager.cancelPrecompression(this.id);
|
|
1182
|
+
}
|
|
1064
1183
|
/**
|
|
1065
1184
|
* 停止当前正在进行的生成
|
|
1066
1185
|
*/
|
|
1067
1186
|
stopGenerating() {
|
|
1068
1187
|
this.isGenerating = false;
|
|
1188
|
+
this.abortPrecompression();
|
|
1069
1189
|
// 停止生成后清除计划,避免 Desktop 的进度圈停留在未完成状态
|
|
1070
1190
|
this.clearPlan();
|
|
1071
1191
|
if (this.abortController) {
|
|
@@ -149,6 +149,8 @@ class SessionManager {
|
|
|
149
149
|
const session = this.sessions.get(sessionId);
|
|
150
150
|
if (!session)
|
|
151
151
|
return false;
|
|
152
|
+
// 先取消后台预压缩:否则任务完成后会向已删除的会话写入摘要
|
|
153
|
+
session.abortPrecompression();
|
|
152
154
|
SessionStore_js_1.sessionStore.transaction(() => {
|
|
153
155
|
SessionStore_js_1.sessionStore.deleteMessagesBySessionId(sessionId);
|
|
154
156
|
SessionStore_js_1.sessionStore.deleteSession(sessionId);
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.updatePlanTool = void 0;
|
|
4
4
|
const SessionManager_js_1 = require("../session/SessionManager.js");
|
|
5
|
-
const WebSocketService_js_1 = require("../WebSocketService.js");
|
|
6
5
|
const shared_1 = require("@myassis/shared");
|
|
7
6
|
const logger = (0, shared_1.getLogger)('PlanTool');
|
|
8
7
|
const VALID_STATUS = ['pending', 'in_progress', 'completed'];
|
|
@@ -84,15 +83,7 @@ exports.updatePlanTool = {
|
|
|
84
83
|
}
|
|
85
84
|
session.updatePlan(steps);
|
|
86
85
|
// 推送计划进度到 Desktop,驱动底部进度圈刷新
|
|
87
|
-
|
|
88
|
-
type: 'plan_updated',
|
|
89
|
-
payload: {
|
|
90
|
-
sessionId,
|
|
91
|
-
agentId: session.agentId,
|
|
92
|
-
steps: session.plan,
|
|
93
|
-
updatedAt: session.planUpdatedAt,
|
|
94
|
-
},
|
|
95
|
-
});
|
|
86
|
+
await session.notifyPlanUpdate();
|
|
96
87
|
const completed = steps.filter(s => s.status === 'completed').length;
|
|
97
88
|
return {
|
|
98
89
|
success: true,
|