aicodeswitch 5.2.12 → 6.0.0
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/README.md +6 -2
- package/UPGRADE.md +5 -4
- package/bin/utils/get-server.js +3 -3
- package/dist/server/access-keys/index.js +2 -2
- package/dist/server/access-keys/key-logger.js +63 -309
- package/dist/server/agent-map/activity-extractor.js +383 -0
- package/dist/server/agent-map/agent-map-service.js +1011 -0
- package/dist/server/agent-map/index.js +10 -0
- package/dist/server/agent-map/routes.js +88 -0
- package/dist/server/agent-map/session-meta.js +300 -0
- package/dist/server/conversions/index.js +10 -2
- package/dist/server/conversions/thinking/providers.js +12 -7
- package/dist/server/fs-database.js +169 -926
- package/dist/server/log-store/index.js +21 -0
- package/dist/server/log-store/log-store.js +1367 -0
- package/dist/server/log-store/types.js +2 -0
- package/dist/server/main.js +141 -46
- package/dist/server/notifier.js +100 -0
- package/dist/server/performance-tracker.js +78 -8
- package/dist/server/proxy-server.js +173 -56
- package/dist/server/transformers/chunk-collector.js +112 -25
- package/dist/ui/assets/index-CQVhBlBB.css +1 -0
- package/dist/ui/assets/index-QZBX5HHX.js +1186 -0
- package/dist/ui/assets/three-CFpmPosW.js +3802 -0
- package/dist/ui/index.html +3 -2
- package/package.json +4 -2
- package/scripts/dev.js +283 -0
- package/dist/server/tools-service.js +0 -203
- package/dist/server/websocket-service.js +0 -148
- package/dist/ui/assets/index-BFVjD9Y2.js +0 -799
- package/dist/ui/assets/index-Dm34-4zP.css +0 -1
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.agentMapService = exports.AgentMapService = void 0;
|
|
13
|
+
/**
|
|
14
|
+
* Agent Map 服务(单例)
|
|
15
|
+
*
|
|
16
|
+
* 职责:
|
|
17
|
+
* 1. 维护在途请求注册表(sessionId → 在途计数),用于 active 判定
|
|
18
|
+
* 2. 维护每个 Session 的运行时聚合态(SessionMapItem)—— 状态由活跃度自动推断
|
|
19
|
+
* 3. 维护活动事件环形缓冲(每 session + 全局)
|
|
20
|
+
* 4. 通过 EventEmitter 向 SSE 路由广播 session-update / activity / stats
|
|
21
|
+
* 5. 定时清扫:idle → completed 的状态迁移
|
|
22
|
+
*
|
|
23
|
+
* 数据来源:proxy-server 的 finalizeLog(含 AccessKey 与普通路由两条分支)。
|
|
24
|
+
* 服务自身持有运行时态,不依赖 dbManager 做状态推断(兼容 global / access-key 两种会话存储)。
|
|
25
|
+
* dbManager 仅用于 attach 时种子化已有全局 Session。
|
|
26
|
+
*/
|
|
27
|
+
const events_1 = require("events");
|
|
28
|
+
const activity_extractor_1 = require("./activity-extractor");
|
|
29
|
+
const session_meta_1 = require("./session-meta");
|
|
30
|
+
const notifier_1 = require("../notifier");
|
|
31
|
+
const IDLE_WINDOW_MS = 10 * 60000; // 无在途请求后,10min 内 → idle;超出 → completed
|
|
32
|
+
const SWEEP_INTERVAL_MS = 15000; // 每 15s 扫描一次状态迁移(含在途陈旧度检查)
|
|
33
|
+
const MAX_EVENTS_PER_SESSION = 200;
|
|
34
|
+
const MAX_GLOBAL_EVENTS = 500;
|
|
35
|
+
const RECENT_WINDOW_FOR_STATS_MS = 60000;
|
|
36
|
+
// SSE 流「静默」判定:流已开始产出后,超过该时长无新 chunk 视为停滞(用户规范:30s)。
|
|
37
|
+
const SSE_SILENCE_MS = 30000;
|
|
38
|
+
// Thinking(思考)请求的静默上限:思考类模型在首 Token 后、正式内容前常有较长静默思考期
|
|
39
|
+
// (部分上游不流式下发 thinking token),需比普通 SSE 30s 更宽松,避免误判停滞。
|
|
40
|
+
const THINKING_SILENCE_MS = 3 * 60000;
|
|
41
|
+
// 同步请求 / SSE 首 Token 前的「最长存活」保护:服务中断导致请求永不返回时,
|
|
42
|
+
// 超过该时长强制结束(用户规范:5min),避免会话永远卡在「进行中」。
|
|
43
|
+
const SYNC_MAX_MS = 5 * 60000;
|
|
44
|
+
// 「一轮结束」OS 通知冷却:同一会话在此窗口内的多次 active→idle(典型来自主轮之后的
|
|
45
|
+
// 后台/计数/compact 等续发请求,每个 end_turn 都会再触发一次迁移)只弹一次,避免重复打扰。
|
|
46
|
+
const NOTIFY_COOLDOWN_MS = 60000;
|
|
47
|
+
// 「任务结束」防抖延迟(同时作用于:展示状态保持 active 的时长 + 结束通知的弹前等待):
|
|
48
|
+
// 检测到任务结束时,先保持「进行中」满该时长,期满且无新请求才落实「空闲」并弹通知;若期间发起新
|
|
49
|
+
// 主请求(用户/客户端马上继续下一轮),lastActivityAt 刷新 + 取消挂起通知 → 继续保持「进行中」。
|
|
50
|
+
// 避免「每轮结束都闪一下空闲 / 都弹通知」,也兜底吸收 end_turn 误判。
|
|
51
|
+
const NOTIFY_DEBOUNCE_MS = 8000;
|
|
52
|
+
// 启动重建:只回填最近 1h 内有活动的会话,每会话最多回填 N 条事件,避免重复解析大量历史分片
|
|
53
|
+
const REBUILD_SINCE_MS = 60 * 60000;
|
|
54
|
+
const REBUILD_EVENTS_PER_SESSION = 30;
|
|
55
|
+
// 点节点按需重建:取更宽松的窗口,保证老节点点开后仍能看到近期活动路径
|
|
56
|
+
const ONDEMAND_SINCE_MS = 24 * 60 * 60000;
|
|
57
|
+
class AgentMapService extends events_1.EventEmitter {
|
|
58
|
+
constructor() {
|
|
59
|
+
super();
|
|
60
|
+
Object.defineProperty(this, "db", {
|
|
61
|
+
enumerable: true,
|
|
62
|
+
configurable: true,
|
|
63
|
+
writable: true,
|
|
64
|
+
value: null
|
|
65
|
+
});
|
|
66
|
+
Object.defineProperty(this, "states", {
|
|
67
|
+
enumerable: true,
|
|
68
|
+
configurable: true,
|
|
69
|
+
writable: true,
|
|
70
|
+
value: new Map()
|
|
71
|
+
});
|
|
72
|
+
Object.defineProperty(this, "sessionEvents", {
|
|
73
|
+
enumerable: true,
|
|
74
|
+
configurable: true,
|
|
75
|
+
writable: true,
|
|
76
|
+
value: new Map()
|
|
77
|
+
});
|
|
78
|
+
Object.defineProperty(this, "globalEvents", {
|
|
79
|
+
enumerable: true,
|
|
80
|
+
configurable: true,
|
|
81
|
+
writable: true,
|
|
82
|
+
value: []
|
|
83
|
+
});
|
|
84
|
+
Object.defineProperty(this, "sweepTimer", {
|
|
85
|
+
enumerable: true,
|
|
86
|
+
configurable: true,
|
|
87
|
+
writable: true,
|
|
88
|
+
value: null
|
|
89
|
+
});
|
|
90
|
+
// 任务结束 OS 通知(服务端交付)。开关开启后始终弹,不再区分页面是否后台。
|
|
91
|
+
Object.defineProperty(this, "notifyEnabled", {
|
|
92
|
+
enumerable: true,
|
|
93
|
+
configurable: true,
|
|
94
|
+
writable: true,
|
|
95
|
+
value: false
|
|
96
|
+
});
|
|
97
|
+
// 阈值可调(供测试/配置覆盖)
|
|
98
|
+
Object.defineProperty(this, "idleWindowMs", {
|
|
99
|
+
enumerable: true,
|
|
100
|
+
configurable: true,
|
|
101
|
+
writable: true,
|
|
102
|
+
value: IDLE_WINDOW_MS
|
|
103
|
+
});
|
|
104
|
+
Object.defineProperty(this, "sseSilenceMs", {
|
|
105
|
+
enumerable: true,
|
|
106
|
+
configurable: true,
|
|
107
|
+
writable: true,
|
|
108
|
+
value: SSE_SILENCE_MS
|
|
109
|
+
});
|
|
110
|
+
Object.defineProperty(this, "thinkingSilenceMs", {
|
|
111
|
+
enumerable: true,
|
|
112
|
+
configurable: true,
|
|
113
|
+
writable: true,
|
|
114
|
+
value: THINKING_SILENCE_MS
|
|
115
|
+
});
|
|
116
|
+
Object.defineProperty(this, "syncMaxMs", {
|
|
117
|
+
enumerable: true,
|
|
118
|
+
configurable: true,
|
|
119
|
+
writable: true,
|
|
120
|
+
value: SYNC_MAX_MS
|
|
121
|
+
});
|
|
122
|
+
this.setMaxListeners(50);
|
|
123
|
+
}
|
|
124
|
+
/** 由 main.ts 在 dbManager 就绪后调用 */
|
|
125
|
+
attach(db) {
|
|
126
|
+
var _a, _b;
|
|
127
|
+
this.db = db;
|
|
128
|
+
// 恢复持久化的通知开关,使重启后无需浏览器即可正常弹通知
|
|
129
|
+
try {
|
|
130
|
+
this.notifyEnabled = !!((_b = (_a = db.getConfig) === null || _a === void 0 ? void 0 : _a.call(db)) === null || _b === void 0 ? void 0 : _b.agentMapNotifyEnabled);
|
|
131
|
+
}
|
|
132
|
+
catch ( /* ignore */_c) { /* ignore */ }
|
|
133
|
+
this.seedFromDb().catch(err => {
|
|
134
|
+
console.error('[AgentMap] seed error:', err);
|
|
135
|
+
});
|
|
136
|
+
if (!this.sweepTimer) {
|
|
137
|
+
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
|
|
138
|
+
// 不阻止进程退出
|
|
139
|
+
if (typeof this.sweepTimer.unref === 'function')
|
|
140
|
+
this.sweepTimer.unref();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
seedFromDb() {
|
|
144
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
145
|
+
var _a;
|
|
146
|
+
if (!((_a = this.db) === null || _a === void 0 ? void 0 : _a.getSessions))
|
|
147
|
+
return;
|
|
148
|
+
try {
|
|
149
|
+
const sessions = yield this.db.getSessions(undefined, 500, 0);
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
for (const s of sessions) {
|
|
152
|
+
if (!s || !s.id)
|
|
153
|
+
continue;
|
|
154
|
+
if (this.states.has(s.id))
|
|
155
|
+
continue;
|
|
156
|
+
const lastRequestAt = s.lastRequestAt || s.firstRequestAt || now;
|
|
157
|
+
const { status, reason } = this.inferStatus({
|
|
158
|
+
lastActivityAt: lastRequestAt,
|
|
159
|
+
lastStatusCode: s.lastStatusCode,
|
|
160
|
+
inFlight: 0,
|
|
161
|
+
streamingInFlight: 0,
|
|
162
|
+
thinkingInFlight: 0,
|
|
163
|
+
streamFirstChunkAt: 0,
|
|
164
|
+
inFlightSince: 0,
|
|
165
|
+
lastTurnEnd: null,
|
|
166
|
+
now,
|
|
167
|
+
});
|
|
168
|
+
this.states.set(s.id, {
|
|
169
|
+
sessionId: s.id,
|
|
170
|
+
agent: s.targetType || 'claude-code',
|
|
171
|
+
source: 'global',
|
|
172
|
+
title: s.title,
|
|
173
|
+
firstRequestAt: s.firstRequestAt || lastRequestAt,
|
|
174
|
+
lastRequestAt,
|
|
175
|
+
requestCount: s.requestCount || 0,
|
|
176
|
+
totalTokens: s.totalTokens || 0,
|
|
177
|
+
inputTokens: s.inputTokens || 0,
|
|
178
|
+
outputTokens: s.outputTokens || 0,
|
|
179
|
+
runningInputTokens: 0,
|
|
180
|
+
runningOutputTokens: 0,
|
|
181
|
+
lastToolName: s.lastToolName,
|
|
182
|
+
lastActivitySummary: s.lastActivitySummary,
|
|
183
|
+
lastStatusCode: s.lastStatusCode,
|
|
184
|
+
lastModel: s.model,
|
|
185
|
+
status,
|
|
186
|
+
statusReason: reason,
|
|
187
|
+
inFlight: 0,
|
|
188
|
+
lastActivityAt: lastRequestAt,
|
|
189
|
+
streamingInFlight: 0,
|
|
190
|
+
thinkingInFlight: 0,
|
|
191
|
+
streamFirstChunkAt: 0,
|
|
192
|
+
inFlightSince: 0,
|
|
193
|
+
notifiedForTurn: false,
|
|
194
|
+
notifyTimer: null,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
this.broadcastStats();
|
|
198
|
+
// 从 Session Log 重建最近一批会话的活动事件(填充全局活动流 + 详情路径),见 rebuildSessionEvents
|
|
199
|
+
this.rebuildRecentEvents().catch(err => console.error('[AgentMap] rebuildRecentEvents error:', err));
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
console.error('[AgentMap] seedFromDb error:', err);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 从已加载的请求日志现算活动事件(与 onFinalized 同源解析,含连续相同 prompt 去重)。
|
|
208
|
+
* 不再触发任何 DB 读取,供 rebuildSessionEvents / rebuildRecentEvents 复用。
|
|
209
|
+
*/
|
|
210
|
+
buildEventsFromLogs(sessionId, agent, logs) {
|
|
211
|
+
if (!Array.isArray(logs) || logs.length === 0)
|
|
212
|
+
return [];
|
|
213
|
+
const sorted = [...logs].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
|
214
|
+
const out = [];
|
|
215
|
+
for (const log of sorted) {
|
|
216
|
+
const usage = log.usage;
|
|
217
|
+
const tokensDelta = (usage === null || usage === void 0 ? void 0 : usage.totalTokens) || (((usage === null || usage === void 0 ? void 0 : usage.inputTokens) || 0) + ((usage === null || usage === void 0 ? void 0 : usage.outputTokens) || 0));
|
|
218
|
+
const input = {
|
|
219
|
+
sessionId,
|
|
220
|
+
agent,
|
|
221
|
+
timestamp: log.timestamp,
|
|
222
|
+
source: 'global',
|
|
223
|
+
body: log.body,
|
|
224
|
+
downstreamResponseBody: log.downstreamResponseBody,
|
|
225
|
+
responseBody: log.responseBody,
|
|
226
|
+
statusCode: log.statusCode,
|
|
227
|
+
tokensDelta,
|
|
228
|
+
};
|
|
229
|
+
const evs = (0, activity_extractor_1.extractActivityEvents)(input);
|
|
230
|
+
// 连续相同 prompt 去重(与 onFinalized 一致)
|
|
231
|
+
for (const e of evs) {
|
|
232
|
+
if (e.kind === 'prompt' && out.length > 0) {
|
|
233
|
+
const last = out[out.length - 1];
|
|
234
|
+
if (last.kind === 'prompt' && last.summary === e.summary)
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
out.push(e);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (out.length > MAX_EVENTS_PER_SESSION)
|
|
241
|
+
out.splice(0, out.length - MAX_EVENTS_PER_SESSION);
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* 从某会话的请求日志现算重建活动事件(与 onFinalized 同源解析,含连续相同 prompt 去重)。
|
|
246
|
+
* 用于重启后恢复活动路径/全局活动流;失败或无日志返回 []。
|
|
247
|
+
* since 下推到 DB 层,先用索引 ref 的时间戳过滤,老分片完全不读。
|
|
248
|
+
*/
|
|
249
|
+
rebuildSessionEvents(sessionId, agent, since) {
|
|
250
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
251
|
+
var _a;
|
|
252
|
+
if (!((_a = this.db) === null || _a === void 0 ? void 0 : _a.getLogsBySessionId))
|
|
253
|
+
return [];
|
|
254
|
+
try {
|
|
255
|
+
const logs = yield this.db.getLogsBySessionId(sessionId, MAX_EVENTS_PER_SESSION, since);
|
|
256
|
+
return this.buildEventsFromLogs(sessionId, agent, logs);
|
|
257
|
+
}
|
|
258
|
+
catch (_b) {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* 启动时为最近 1 小时内有活动的 global 会话重建活动事件,填充全局活动流(fire-and-forget)。
|
|
265
|
+
* 通过 getRecentLogsBySessions 跨会话合并分片读取,每个分片只 loadLogShard 一次,
|
|
266
|
+
* 避免「每会话独立读同一分片」导致的重复解析与内存爆炸。
|
|
267
|
+
*/
|
|
268
|
+
rebuildRecentEvents() {
|
|
269
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
270
|
+
if (!this.db)
|
|
271
|
+
return;
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
const since = now - REBUILD_SINCE_MS;
|
|
274
|
+
const recentStates = Array.from(this.states.values())
|
|
275
|
+
.filter(st => st.source === 'global' && st.lastRequestAt >= since)
|
|
276
|
+
.sort((a, b) => b.lastRequestAt - a.lastRequestAt);
|
|
277
|
+
if (recentStates.length === 0) {
|
|
278
|
+
this.emit('stats', this.computeStats());
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
let logsBySession;
|
|
282
|
+
if (this.db.getRecentLogsBySessions) {
|
|
283
|
+
// 批量回填:跨会话分片去重
|
|
284
|
+
logsBySession = yield this.db.getRecentLogsBySessions(recentStates.map(st => st.sessionId), { since, perSessionLimit: REBUILD_EVENTS_PER_SESSION });
|
|
285
|
+
}
|
|
286
|
+
else if (this.db.getLogsBySessionId) {
|
|
287
|
+
// 兼容降级:逐会话回填(仍带 since 时间窗过滤)
|
|
288
|
+
logsBySession = new Map();
|
|
289
|
+
for (const st of recentStates) {
|
|
290
|
+
try {
|
|
291
|
+
const logs = yield this.db.getLogsBySessionId(st.sessionId, REBUILD_EVENTS_PER_SESSION, since);
|
|
292
|
+
if (logs.length > 0)
|
|
293
|
+
logsBySession.set(st.sessionId, logs);
|
|
294
|
+
}
|
|
295
|
+
catch (_a) {
|
|
296
|
+
// 忽略单会话失败
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
this.emit('stats', this.computeStats());
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
for (const st of recentStates) {
|
|
305
|
+
const logs = logsBySession.get(st.sessionId);
|
|
306
|
+
if (!logs || logs.length === 0)
|
|
307
|
+
continue;
|
|
308
|
+
const evs = this.buildEventsFromLogs(st.sessionId, st.agent, logs);
|
|
309
|
+
if (evs.length > 0) {
|
|
310
|
+
this.sessionEvents.set(st.sessionId, evs);
|
|
311
|
+
this.globalEvents.push(...evs);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (this.globalEvents.length > MAX_GLOBAL_EVENTS) {
|
|
315
|
+
this.globalEvents.splice(0, this.globalEvents.length - MAX_GLOBAL_EVENTS);
|
|
316
|
+
}
|
|
317
|
+
this.globalEvents.sort((a, b) => a.ts - b.ts);
|
|
318
|
+
this.emit('stats', this.computeStats()); // 通知已连接客户端活动流已就绪
|
|
319
|
+
// 把重建出的最近 100 条全局活动按时间正序回放给已连接客户端(前端 onActivity 会倒序 prepend,正好 newest 在顶)
|
|
320
|
+
// 解决「客户端在 rebuild 完成前就连上、init 快照里 feed 为空」的时序问题
|
|
321
|
+
for (const e of this.globalEvents.slice(-100)) {
|
|
322
|
+
this.emit('activity', e);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
// ============ 在途请求注册表 + 可靠活动追踪 ============
|
|
327
|
+
/**
|
|
328
|
+
* 请求开始:在途计数 + 活动时钟刷新。
|
|
329
|
+
* - background=true(count_tokens / compact / 后台请求):刷新活动、计入在途,用于「是否还活着」判定;
|
|
330
|
+
* 但不重置 notifiedForTurn —— 这样主任务结束后紧跟的后台请求不会再次触发结束通知。
|
|
331
|
+
* - background=false(主轮请求):视为「新一轮用户任务」,重置 notifiedForTurn,允许结束时再弹一次通知。
|
|
332
|
+
*/
|
|
333
|
+
startRequest(sessionId, agent, opts) {
|
|
334
|
+
if (!sessionId || sessionId === '-')
|
|
335
|
+
return;
|
|
336
|
+
const now = Date.now();
|
|
337
|
+
let st = this.states.get(sessionId);
|
|
338
|
+
if (!st) {
|
|
339
|
+
st = {
|
|
340
|
+
sessionId,
|
|
341
|
+
agent,
|
|
342
|
+
source: (opts === null || opts === void 0 ? void 0 : opts.source) || 'global',
|
|
343
|
+
keyId: opts === null || opts === void 0 ? void 0 : opts.keyId,
|
|
344
|
+
keyName: opts === null || opts === void 0 ? void 0 : opts.keyName,
|
|
345
|
+
title: opts === null || opts === void 0 ? void 0 : opts.title,
|
|
346
|
+
firstRequestAt: now,
|
|
347
|
+
lastRequestAt: now,
|
|
348
|
+
requestCount: 0,
|
|
349
|
+
totalTokens: 0,
|
|
350
|
+
inputTokens: 0,
|
|
351
|
+
outputTokens: 0,
|
|
352
|
+
runningInputTokens: 0,
|
|
353
|
+
runningOutputTokens: 0,
|
|
354
|
+
status: 'active',
|
|
355
|
+
statusReason: 'in-flight',
|
|
356
|
+
inFlight: 0,
|
|
357
|
+
lastActivityAt: now,
|
|
358
|
+
streamingInFlight: 0,
|
|
359
|
+
thinkingInFlight: 0,
|
|
360
|
+
streamFirstChunkAt: 0,
|
|
361
|
+
inFlightSince: now,
|
|
362
|
+
notifiedForTurn: false,
|
|
363
|
+
notifyTimer: null,
|
|
364
|
+
};
|
|
365
|
+
this.states.set(sessionId, st);
|
|
366
|
+
}
|
|
367
|
+
if (st.inFlight === 0) {
|
|
368
|
+
// 新的一批在途开始:记录起跑时刻(同步请求 5min 超时基线)
|
|
369
|
+
st.inFlightSince = now;
|
|
370
|
+
st.streamFirstChunkAt = 0;
|
|
371
|
+
}
|
|
372
|
+
st.inFlight += 1;
|
|
373
|
+
if (opts === null || opts === void 0 ? void 0 : opts.thinking)
|
|
374
|
+
st.thinkingInFlight += 1;
|
|
375
|
+
st.lastRequestAt = now;
|
|
376
|
+
st.lastActivityAt = now;
|
|
377
|
+
if (!(opts === null || opts === void 0 ? void 0 : opts.background)) {
|
|
378
|
+
// 主轮(用户发起的新任务):会话再次活跃 → 取消上一轮可能挂起的结束通知(防抖),并重置通知标记
|
|
379
|
+
this.cancelNotifyTimer(st);
|
|
380
|
+
st.notifiedForTurn = false;
|
|
381
|
+
}
|
|
382
|
+
// 有在途请求必然 active
|
|
383
|
+
if (st.status !== 'active') {
|
|
384
|
+
st.status = 'active';
|
|
385
|
+
st.statusReason = 'in-flight';
|
|
386
|
+
}
|
|
387
|
+
this.emitSession(st);
|
|
388
|
+
}
|
|
389
|
+
/** SSE 流真正开始(pipeline 已建立)。记一个在途流,并刷新活动时钟(首 Token 仍待 heartbeat 标记)。 */
|
|
390
|
+
markStreaming(sessionId) {
|
|
391
|
+
if (!sessionId || sessionId === '-')
|
|
392
|
+
return;
|
|
393
|
+
const st = this.states.get(sessionId);
|
|
394
|
+
if (!st)
|
|
395
|
+
return;
|
|
396
|
+
st.streamingInFlight += 1;
|
|
397
|
+
st.lastActivityAt = Date.now();
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* SSE chunk 心跳:每流经一个下游 chunk 刷新一次活动时钟(节流由调用方负责)。
|
|
401
|
+
* 可附带当前流式请求的实时累计 usage(input/output):写入 running 计数并广播 session-update,
|
|
402
|
+
* 使前端节点在流式过程中随 token 增长实时上移。finalize 时 onFinalized 会把最终值并入累计并清零 running。
|
|
403
|
+
*/
|
|
404
|
+
heartbeat(sessionId, usage) {
|
|
405
|
+
if (!sessionId || sessionId === '-')
|
|
406
|
+
return;
|
|
407
|
+
const st = this.states.get(sessionId);
|
|
408
|
+
if (!st)
|
|
409
|
+
return;
|
|
410
|
+
const now = Date.now();
|
|
411
|
+
st.lastActivityAt = now;
|
|
412
|
+
if (st.streamFirstChunkAt === 0)
|
|
413
|
+
st.streamFirstChunkAt = now;
|
|
414
|
+
if (usage) {
|
|
415
|
+
st.runningInputTokens = usage.inputTokens || 0;
|
|
416
|
+
st.runningOutputTokens = usage.outputTokens || 0;
|
|
417
|
+
this.emitSession(st);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/** 请求结束:在途计数递减(流式请求同时递减在途流计数,思考请求递减思考计数)。不立即改 status,由 onFinalized / reevaluate / sweep 决定。 */
|
|
421
|
+
endRequest(sessionId, opts) {
|
|
422
|
+
if (!sessionId || sessionId === '-')
|
|
423
|
+
return;
|
|
424
|
+
const st = this.states.get(sessionId);
|
|
425
|
+
if (!st)
|
|
426
|
+
return;
|
|
427
|
+
st.inFlight = Math.max(0, st.inFlight - 1);
|
|
428
|
+
if (opts === null || opts === void 0 ? void 0 : opts.isStream)
|
|
429
|
+
st.streamingInFlight = Math.max(0, st.streamingInFlight - 1);
|
|
430
|
+
if (opts === null || opts === void 0 ? void 0 : opts.thinking)
|
|
431
|
+
st.thinkingInFlight = Math.max(0, st.thinkingInFlight - 1);
|
|
432
|
+
st.lastActivityAt = Date.now();
|
|
433
|
+
if (st.inFlight === 0) {
|
|
434
|
+
st.inFlightSince = 0;
|
|
435
|
+
st.streamingInFlight = 0;
|
|
436
|
+
st.thinkingInFlight = 0;
|
|
437
|
+
st.streamFirstChunkAt = 0;
|
|
438
|
+
// 在途已清空:实时累计让位给 onFinalized 的最终累计,避免双算
|
|
439
|
+
st.runningInputTokens = 0;
|
|
440
|
+
st.runningOutputTokens = 0;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* 重算单个会话状态并广播(含通知判定)。供 onFinalized / 安全网 / REST 主动刷新复用。
|
|
445
|
+
* 返回是否有状态变化。
|
|
446
|
+
*/
|
|
447
|
+
reevaluate(sessionId) {
|
|
448
|
+
if (!sessionId || sessionId === '-')
|
|
449
|
+
return false;
|
|
450
|
+
const st = this.states.get(sessionId);
|
|
451
|
+
if (!st)
|
|
452
|
+
return false;
|
|
453
|
+
const prevStatus = st.status;
|
|
454
|
+
const { status, reason } = this.inferStatus({
|
|
455
|
+
lastActivityAt: st.lastActivityAt,
|
|
456
|
+
lastStatusCode: st.lastStatusCode,
|
|
457
|
+
inFlight: st.inFlight,
|
|
458
|
+
streamingInFlight: st.streamingInFlight,
|
|
459
|
+
thinkingInFlight: st.thinkingInFlight,
|
|
460
|
+
streamFirstChunkAt: st.streamFirstChunkAt,
|
|
461
|
+
inFlightSince: st.inFlightSince,
|
|
462
|
+
lastTurnEnd: st.lastTurnEnd,
|
|
463
|
+
now: Date.now(),
|
|
464
|
+
});
|
|
465
|
+
if (status === prevStatus && reason === st.statusReason)
|
|
466
|
+
return false;
|
|
467
|
+
st.status = status;
|
|
468
|
+
st.statusReason = reason;
|
|
469
|
+
// 安全网路径(finalizeLog 未走 onFinalized 的泄漏场景):脱离 active 时补调度防抖通知
|
|
470
|
+
if (status !== 'active')
|
|
471
|
+
this.considerEndNotify(st, status === 'error');
|
|
472
|
+
this.emitSession(st);
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
// ============ 请求收尾:抽事件 + 重算状态 + 广播 ============
|
|
476
|
+
onFinalized(ctx) {
|
|
477
|
+
var _a;
|
|
478
|
+
if (!ctx.sessionId || ctx.sessionId === '-')
|
|
479
|
+
return;
|
|
480
|
+
const extractInput = {
|
|
481
|
+
sessionId: ctx.sessionId,
|
|
482
|
+
agent: ctx.agent,
|
|
483
|
+
timestamp: ctx.timestamp,
|
|
484
|
+
source: ctx.source,
|
|
485
|
+
keyId: ctx.keyId,
|
|
486
|
+
keyName: ctx.keyName,
|
|
487
|
+
body: ctx.body,
|
|
488
|
+
downstreamResponseBody: ctx.downstreamResponseBody,
|
|
489
|
+
responseBody: ctx.responseBody,
|
|
490
|
+
statusCode: ctx.statusCode,
|
|
491
|
+
tokensDelta: ctx.tokensDelta,
|
|
492
|
+
};
|
|
493
|
+
const rawEvents = (0, activity_extractor_1.extractActivityEvents)(extractInput);
|
|
494
|
+
// 跨轮次 prompt 去重:若本轮 prompt 与该会话「最近一次真正写入的 prompt」文本相同,则丢弃。
|
|
495
|
+
// 比旧逻辑(仅当上一条已记录事件本身也是相同 prompt 才丢)更稳:无论两条相同提问之间
|
|
496
|
+
// 夹了多少 tool_use / response(工具循环 / 客户端重试 / 末条 user 同时含 text+tool_result)都能命中。
|
|
497
|
+
const existingState = this.states.get(ctx.sessionId);
|
|
498
|
+
const prevPromptSummary = existingState === null || existingState === void 0 ? void 0 : existingState.lastPromptSummary;
|
|
499
|
+
const firstPromptIdx = rawEvents.findIndex(e => e.kind === 'prompt');
|
|
500
|
+
let events = rawEvents;
|
|
501
|
+
let recordedPromptSummary = prevPromptSummary;
|
|
502
|
+
if (firstPromptIdx >= 0
|
|
503
|
+
&& prevPromptSummary
|
|
504
|
+
&& rawEvents[firstPromptIdx].summary === prevPromptSummary) {
|
|
505
|
+
events = rawEvents.filter((_, i) => i !== firstPromptIdx);
|
|
506
|
+
}
|
|
507
|
+
else if (firstPromptIdx >= 0) {
|
|
508
|
+
recordedPromptSummary = rawEvents[firstPromptIdx].summary;
|
|
509
|
+
}
|
|
510
|
+
const { summary, toolName } = (0, activity_extractor_1.deriveLastActivity)(events);
|
|
511
|
+
const now = Date.now();
|
|
512
|
+
let st = this.states.get(ctx.sessionId);
|
|
513
|
+
if (!st) {
|
|
514
|
+
st = {
|
|
515
|
+
sessionId: ctx.sessionId,
|
|
516
|
+
agent: ctx.agent,
|
|
517
|
+
source: ctx.source || 'global',
|
|
518
|
+
keyId: ctx.keyId,
|
|
519
|
+
keyName: ctx.keyName,
|
|
520
|
+
title: ctx.title,
|
|
521
|
+
firstRequestAt: ctx.timestamp,
|
|
522
|
+
lastRequestAt: ctx.timestamp,
|
|
523
|
+
requestCount: 0,
|
|
524
|
+
totalTokens: 0,
|
|
525
|
+
inputTokens: 0,
|
|
526
|
+
outputTokens: 0,
|
|
527
|
+
runningInputTokens: 0,
|
|
528
|
+
runningOutputTokens: 0,
|
|
529
|
+
status: 'active',
|
|
530
|
+
inFlight: 0,
|
|
531
|
+
lastActivityAt: ctx.timestamp,
|
|
532
|
+
streamingInFlight: 0,
|
|
533
|
+
thinkingInFlight: 0,
|
|
534
|
+
streamFirstChunkAt: 0,
|
|
535
|
+
inFlightSince: ctx.timestamp,
|
|
536
|
+
notifiedForTurn: false,
|
|
537
|
+
notifyTimer: null,
|
|
538
|
+
};
|
|
539
|
+
this.states.set(ctx.sessionId, st);
|
|
540
|
+
}
|
|
541
|
+
st.agent = ctx.agent;
|
|
542
|
+
st.source = ctx.source || st.source || 'global';
|
|
543
|
+
if (ctx.keyId)
|
|
544
|
+
st.keyId = ctx.keyId;
|
|
545
|
+
if (ctx.keyName)
|
|
546
|
+
st.keyName = ctx.keyName;
|
|
547
|
+
if (ctx.title && !st.title)
|
|
548
|
+
st.title = ctx.title;
|
|
549
|
+
st.lastPromptSummary = recordedPromptSummary;
|
|
550
|
+
st.lastRequestAt = ctx.timestamp;
|
|
551
|
+
st.requestCount += 1;
|
|
552
|
+
if (ctx.tokensDelta)
|
|
553
|
+
st.totalTokens += ctx.tokensDelta;
|
|
554
|
+
if (ctx.inputTokensDelta)
|
|
555
|
+
st.inputTokens += ctx.inputTokensDelta;
|
|
556
|
+
if (ctx.outputTokensDelta)
|
|
557
|
+
st.outputTokens += ctx.outputTokensDelta;
|
|
558
|
+
if (ctx.model)
|
|
559
|
+
st.lastModel = ctx.model;
|
|
560
|
+
if (ctx.statusCode != null)
|
|
561
|
+
st.lastStatusCode = ctx.statusCode;
|
|
562
|
+
if (summary)
|
|
563
|
+
st.lastActivitySummary = summary;
|
|
564
|
+
if (toolName)
|
|
565
|
+
st.lastToolName = toolName;
|
|
566
|
+
// 本轮响应是否表示「一轮结束」:
|
|
567
|
+
// - true = end_turn 等明确结束 → 任务完成,可弹通知
|
|
568
|
+
// - false = tool_use 等还要继续 → 客户端将本地执行工具后再次请求,此时静默、保持「进行中」
|
|
569
|
+
// - null = 无法判定 → 兜底按结束处理
|
|
570
|
+
const turnEnd = (0, activity_extractor_1.detectTurnEnd)(ctx.agent, ctx.downstreamResponseBody, ctx.responseBody);
|
|
571
|
+
st.lastTurnEnd = turnEnd;
|
|
572
|
+
// 重算状态:请求已结束(endRequest 已把 inFlight 减 1)。inferStatus 会在「结束」情形下防抖保持 active。
|
|
573
|
+
const { status, reason } = this.inferStatus({
|
|
574
|
+
lastActivityAt: st.lastActivityAt,
|
|
575
|
+
lastStatusCode: st.lastStatusCode,
|
|
576
|
+
inFlight: st.inFlight,
|
|
577
|
+
streamingInFlight: st.streamingInFlight,
|
|
578
|
+
thinkingInFlight: st.thinkingInFlight,
|
|
579
|
+
streamFirstChunkAt: st.streamFirstChunkAt,
|
|
580
|
+
inFlightSince: st.inFlightSince,
|
|
581
|
+
lastTurnEnd: turnEnd,
|
|
582
|
+
now,
|
|
583
|
+
});
|
|
584
|
+
st.status = status;
|
|
585
|
+
st.statusReason = reason;
|
|
586
|
+
// 任务结束检测:无在途且末响应非 tool_use(end_turn / 未知)→ 调度防抖通知。
|
|
587
|
+
// tool_use 不调度(客户端将本地执行工具后继续);防抖期内来新主请求会取消它。
|
|
588
|
+
if (st.inFlight === 0 && st.lastTurnEnd !== false) {
|
|
589
|
+
this.considerEndNotify(st, ((_a = st.lastStatusCode) !== null && _a !== void 0 ? _a : 0) >= 500);
|
|
590
|
+
}
|
|
591
|
+
// 记录事件
|
|
592
|
+
if (events.length > 0) {
|
|
593
|
+
this.recordEvents(ctx.sessionId, events);
|
|
594
|
+
for (const e of events) {
|
|
595
|
+
this.emit('activity', e);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
this.emitSession(st);
|
|
599
|
+
// 异步解析本地会话元信息(项目路径 + 原始标题);仅 global 来源、且未解析过
|
|
600
|
+
this.enrichSession(st);
|
|
601
|
+
}
|
|
602
|
+
/** 从本机 Claude/Codex 会话存储读取项目路径与原始标题并回填(access-key 不解析) */
|
|
603
|
+
enrichSession(st) {
|
|
604
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
605
|
+
if (st.source === 'access-key' || st.metaResolved)
|
|
606
|
+
return;
|
|
607
|
+
st.metaResolved = true;
|
|
608
|
+
try {
|
|
609
|
+
const meta = yield (0, session_meta_1.resolveSessionMeta)(st.sessionId, st.agent);
|
|
610
|
+
let changed = false;
|
|
611
|
+
if (meta.projectPath && !st.projectPath) {
|
|
612
|
+
st.projectPath = meta.projectPath;
|
|
613
|
+
changed = true;
|
|
614
|
+
}
|
|
615
|
+
// 原始标题更标准,命中即覆盖日志截取的标题
|
|
616
|
+
if (meta.title) {
|
|
617
|
+
st.title = meta.title;
|
|
618
|
+
changed = true;
|
|
619
|
+
}
|
|
620
|
+
if (changed)
|
|
621
|
+
this.emitSession(st);
|
|
622
|
+
}
|
|
623
|
+
catch ( /* ignore */_a) { /* ignore */ }
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
/** 按需解析(供 REST 端点 / 详情 popover)。返回 source 便于前端给出恰当提示 */
|
|
627
|
+
getSessionMeta(sessionId) {
|
|
628
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
629
|
+
const st = this.states.get(sessionId);
|
|
630
|
+
const source = st ? st.source : 'unknown';
|
|
631
|
+
if (source === 'access-key' || source === 'unknown') {
|
|
632
|
+
return { source, projectPath: st === null || st === void 0 ? void 0 : st.projectPath, title: st === null || st === void 0 ? void 0 : st.title };
|
|
633
|
+
}
|
|
634
|
+
// global:命中缓存或现解析
|
|
635
|
+
const meta = yield (0, session_meta_1.resolveSessionMeta)(sessionId, (st === null || st === void 0 ? void 0 : st.agent) || 'claude-code');
|
|
636
|
+
if (st) {
|
|
637
|
+
if (meta.projectPath && !st.projectPath)
|
|
638
|
+
st.projectPath = meta.projectPath;
|
|
639
|
+
if (meta.title)
|
|
640
|
+
st.title = meta.title;
|
|
641
|
+
st.metaResolved = true;
|
|
642
|
+
// legacy 会话(修复前累积、拆分从未持久化):点开详情时按日志回填输入/输出拆分
|
|
643
|
+
this.backfillTokenSplit(st).catch(err => console.error('[AgentMap] backfillTokenSplit error:', err));
|
|
644
|
+
}
|
|
645
|
+
return { source, projectPath: meta.projectPath || (st === null || st === void 0 ? void 0 : st.projectPath), title: meta.title || (st === null || st === void 0 ? void 0 : st.title) };
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* 历史 token 输入/输出拆分回填。
|
|
650
|
+
* 仅对 legacy 会话生效:累计 token > 0 但拆分为 0(修复前从未持久化)。
|
|
651
|
+
* 从该会话的全部请求日志现算 usage.inputTokens / outputTokens 之和,写回 RuntimeState + DB(绝对值),
|
|
652
|
+
* 之后新请求照常在回填基线上累加。每个会话只回填一次(tokenSplitBackfilled)。
|
|
653
|
+
*/
|
|
654
|
+
backfillTokenSplit(st) {
|
|
655
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
656
|
+
var _a, _b;
|
|
657
|
+
if (st.tokenSplitBackfilled)
|
|
658
|
+
return;
|
|
659
|
+
if (st.source !== 'global')
|
|
660
|
+
return;
|
|
661
|
+
// 仅 legacy 标记:累计有值但拆分为 0。新会话(拆分已随请求累加)不触发。
|
|
662
|
+
if (!(st.totalTokens > 0 && st.inputTokens === 0 && st.outputTokens === 0)) {
|
|
663
|
+
st.tokenSplitBackfilled = true;
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (!((_a = this.db) === null || _a === void 0 ? void 0 : _a.getLogsBySessionId) || !((_b = this.db) === null || _b === void 0 ? void 0 : _b.updateSession)) {
|
|
667
|
+
st.tokenSplitBackfilled = true;
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
st.tokenSplitBackfilled = true; // 先置位,防并发重复回填
|
|
671
|
+
try {
|
|
672
|
+
// 取全部日志(默认 limit=100 会漏算大 session),传一个大上限以覆盖完整历史
|
|
673
|
+
const logs = yield this.db.getLogsBySessionId(st.sessionId, Number.MAX_SAFE_INTEGER);
|
|
674
|
+
let inputTokens = 0;
|
|
675
|
+
let outputTokens = 0;
|
|
676
|
+
for (const log of logs) {
|
|
677
|
+
const u = log.usage;
|
|
678
|
+
if (!u)
|
|
679
|
+
continue;
|
|
680
|
+
inputTokens += u.inputTokens || u.promptTokens || 0;
|
|
681
|
+
outputTokens += u.outputTokens || u.completionTokens || 0;
|
|
682
|
+
}
|
|
683
|
+
if (inputTokens > 0 || outputTokens > 0) {
|
|
684
|
+
st.inputTokens = inputTokens;
|
|
685
|
+
st.outputTokens = outputTokens;
|
|
686
|
+
// 绝对写入 DB(不走 upsertSession 的累加语义),后续新请求在其上累加
|
|
687
|
+
yield this.db.updateSession(st.sessionId, { inputTokens, outputTokens });
|
|
688
|
+
this.emitSession(st);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
catch (err) {
|
|
692
|
+
st.tokenSplitBackfilled = false; // 失败则允许下次重试
|
|
693
|
+
throw err;
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
// ============ 任务结束 OS 通知 ============
|
|
698
|
+
setNotifyEnabled(enabled) {
|
|
699
|
+
this.notifyEnabled = !!enabled;
|
|
700
|
+
this.persistNotifyEnabled(this.notifyEnabled);
|
|
701
|
+
}
|
|
702
|
+
/** 异步把通知开关写入 AppConfig(read-modify-write,best-effort,不阻塞调用) */
|
|
703
|
+
persistNotifyEnabled(enabled) {
|
|
704
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
705
|
+
var _a, _b;
|
|
706
|
+
if (!((_a = this.db) === null || _a === void 0 ? void 0 : _a.getConfig) || !((_b = this.db) === null || _b === void 0 ? void 0 : _b.updateConfig))
|
|
707
|
+
return;
|
|
708
|
+
try {
|
|
709
|
+
const cfg = this.db.getConfig();
|
|
710
|
+
if (cfg.agentMapNotifyEnabled === enabled)
|
|
711
|
+
return;
|
|
712
|
+
cfg.agentMapNotifyEnabled = enabled;
|
|
713
|
+
yield this.db.updateConfig(cfg);
|
|
714
|
+
}
|
|
715
|
+
catch ( /* ignore */_c) { /* ignore */ }
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
/** 兼容旧端点:不再区分前后台,调用为 no-op(开关开启后始终弹) */
|
|
719
|
+
setPageHidden(_hidden) { }
|
|
720
|
+
getNotifyEnabled() { return this.notifyEnabled; }
|
|
721
|
+
/** 发一条测试通知(供 UI「测试」按钮验证 OS 是否真弹) */
|
|
722
|
+
notifyTest() {
|
|
723
|
+
(0, notifier_1.notify)({ title: '🔔 AICodeSwitch', body: '测试通知:通知功能可用' });
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* 结束通知调度(基于「结束检测」,而非展示状态迁移):
|
|
727
|
+
* - onFinalized 检测到一轮结束(inFlight==0 且非 tool_use)时调用;
|
|
728
|
+
* - sweep / reevaluate 检测到停滞/泄漏脱离 active 时调用。
|
|
729
|
+
* 每轮只调度一次(notifiedForTurn 去重);499(用户取消)不调度。实际弹通知由 scheduleNotify 防抖。
|
|
730
|
+
* 展示状态的「防抖保持 active」由 inferStatus 负责,fireNotify 落实 idle,二者经 NOTIFY_DEBOUNCE_MS 对齐。
|
|
731
|
+
*/
|
|
732
|
+
considerEndNotify(st, isError) {
|
|
733
|
+
if (!this.notifyEnabled)
|
|
734
|
+
return;
|
|
735
|
+
if (st.notifiedForTurn)
|
|
736
|
+
return; // 本轮已调度
|
|
737
|
+
if (st.lastStatusCode === 499)
|
|
738
|
+
return; // 用户主动取消,不弹
|
|
739
|
+
st.notifiedForTurn = true;
|
|
740
|
+
this.scheduleNotify(st, isError);
|
|
741
|
+
}
|
|
742
|
+
/** 安排一条防抖结束通知(先取消已挂起的,再重新计时)。 */
|
|
743
|
+
scheduleNotify(st, isError) {
|
|
744
|
+
this.cancelNotifyTimer(st);
|
|
745
|
+
st.notifyTimer = setTimeout(() => {
|
|
746
|
+
st.notifyTimer = null;
|
|
747
|
+
this.fireNotify(st, isError);
|
|
748
|
+
}, NOTIFY_DEBOUNCE_MS);
|
|
749
|
+
// 不阻止进程退出
|
|
750
|
+
if (typeof st.notifyTimer.unref === 'function')
|
|
751
|
+
st.notifyTimer.unref();
|
|
752
|
+
}
|
|
753
|
+
/** 取消挂起的防抖通知(会话再次活跃时调用)。 */
|
|
754
|
+
cancelNotifyTimer(st) {
|
|
755
|
+
if (st.notifyTimer) {
|
|
756
|
+
clearTimeout(st.notifyTimer);
|
|
757
|
+
st.notifyTimer = null;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/** 真正弹出 OS 通知(防抖窗口结束、未被取消时调用)。过 60s 冷却兜底。同时把「防抖保持的 active」落实为 idle。 */
|
|
761
|
+
fireNotify(st, isError) {
|
|
762
|
+
// 防抖期间用户可能关掉了通知开关 → 不弹
|
|
763
|
+
if (!this.notifyEnabled)
|
|
764
|
+
return;
|
|
765
|
+
const now = Date.now();
|
|
766
|
+
if (st.lastNotifyAt && now - st.lastNotifyAt < NOTIFY_COOLDOWN_MS)
|
|
767
|
+
return;
|
|
768
|
+
st.lastNotifyAt = now;
|
|
769
|
+
// 把展示状态从「防抖保持的 active」落实为 idle,与通知同步;仅当确无在途请求(不覆盖后台请求进行中的 active)
|
|
770
|
+
if (st.inFlight === 0 && st.status === 'active') {
|
|
771
|
+
st.status = isError ? 'error' : 'idle';
|
|
772
|
+
st.statusReason = isError ? 'upstream error' : 'turn ended';
|
|
773
|
+
this.emitSession(st);
|
|
774
|
+
}
|
|
775
|
+
const agentName = st.agent === 'codex' ? 'Codex' : 'Claude Code';
|
|
776
|
+
(0, notifier_1.notify)({
|
|
777
|
+
title: `${isError ? '⚠️' : '✅'} AICodeSwitch · ${agentName}`,
|
|
778
|
+
body: st.title || st.lastActivitySummary || (isError ? '任务出现异常' : '任务已结束,等待下一步'),
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
recordEvents(sessionId, events) {
|
|
782
|
+
const arr = this.sessionEvents.get(sessionId) || [];
|
|
783
|
+
arr.push(...events);
|
|
784
|
+
if (arr.length > MAX_EVENTS_PER_SESSION) {
|
|
785
|
+
arr.splice(0, arr.length - MAX_EVENTS_PER_SESSION);
|
|
786
|
+
}
|
|
787
|
+
this.sessionEvents.set(sessionId, arr);
|
|
788
|
+
this.globalEvents.push(...events);
|
|
789
|
+
if (this.globalEvents.length > MAX_GLOBAL_EVENTS) {
|
|
790
|
+
this.globalEvents.splice(0, this.globalEvents.length - MAX_GLOBAL_EVENTS);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
// ============ 状态推断(纯函数,基于「在途 + 真实活动时钟 + 轮次语义」) ============
|
|
794
|
+
inferStatus(args) {
|
|
795
|
+
// 末次上游 5xx → error(即使时间较近也标 error,下一次成功会刷回 active)
|
|
796
|
+
if (args.lastStatusCode != null && args.lastStatusCode >= 500) {
|
|
797
|
+
return { status: 'error', reason: `upstream ${args.lastStatusCode}`, notifyEligible: true };
|
|
798
|
+
}
|
|
799
|
+
// 在途请求:判定是否「真正存活」(区分同步 / SSE / 思考)。在途期间任何「停滞→idle」都可通知。
|
|
800
|
+
if (args.inFlight > 0) {
|
|
801
|
+
if (args.streamingInFlight > 0) {
|
|
802
|
+
if (!args.streamFirstChunkAt) {
|
|
803
|
+
// 首 Token 前:思考类模型 TTFT 可能较长,按同步超时 5min 等待
|
|
804
|
+
const since = args.inFlightSince || args.lastActivityAt;
|
|
805
|
+
if (args.now - since <= this.syncMaxMs)
|
|
806
|
+
return { status: 'active', reason: 'awaiting first token', notifyEligible: true };
|
|
807
|
+
return { status: 'idle', reason: 'stream stalled (no first token)', notifyEligible: true };
|
|
808
|
+
}
|
|
809
|
+
// 已开始产出:思考请求用更宽的 THINKING_SILENCE_MS,否则 SSE_SILENCE_MS(30s)
|
|
810
|
+
const limit = args.thinkingInFlight > 0 ? this.thinkingSilenceMs : this.sseSilenceMs;
|
|
811
|
+
const since = args.streamFirstChunkAt;
|
|
812
|
+
if (args.now - since <= limit) {
|
|
813
|
+
return { status: 'active', reason: args.thinkingInFlight > 0 ? 'thinking' : 'streaming', notifyEligible: true };
|
|
814
|
+
}
|
|
815
|
+
return { status: 'idle', reason: args.thinkingInFlight > 0 ? 'thinking timeout' : 'stream stalled', notifyEligible: true };
|
|
816
|
+
}
|
|
817
|
+
// 同步请求:5min 内视为仍在处理;超过则视作服务中断、强制结束
|
|
818
|
+
const since = args.inFlightSince || args.lastActivityAt;
|
|
819
|
+
if (args.now - since <= this.syncMaxMs)
|
|
820
|
+
return { status: 'active', reason: 'in-flight', notifyEligible: true };
|
|
821
|
+
return { status: 'idle', reason: 'sync timeout', notifyEligible: true };
|
|
822
|
+
}
|
|
823
|
+
// 无在途请求:按「上一轮响应语义 + 距最近活动时长」判 idle / completed,并决定是否可通知
|
|
824
|
+
const elapsed = args.now - args.lastActivityAt;
|
|
825
|
+
// 499 = 客户端主动断开(用户放弃停止任务):视为「已取消」,立即进入 idle
|
|
826
|
+
if (args.lastStatusCode === 499) {
|
|
827
|
+
if (elapsed <= this.idleWindowMs)
|
|
828
|
+
return { status: 'idle', reason: 'client cancelled', notifyEligible: true };
|
|
829
|
+
return { status: 'completed', reason: 'cancelled earlier', notifyEligible: true };
|
|
830
|
+
}
|
|
831
|
+
// tool_use:客户端正在本地执行工具,随后会再发请求 → 保持「进行中」,且**不可通知**(避免每次工具调用误弹)
|
|
832
|
+
if (args.lastTurnEnd === false) {
|
|
833
|
+
if (elapsed <= this.idleWindowMs)
|
|
834
|
+
return { status: 'active', reason: 'executing tool locally', notifyEligible: false };
|
|
835
|
+
return { status: 'idle', reason: 'tool exec abandoned', notifyEligible: false };
|
|
836
|
+
}
|
|
837
|
+
// end_turn(true)或无法判定(null,兜底按结束处理):
|
|
838
|
+
// 先防抖保持 active 满 NOTIFY_DEBOUNCE_MS(避免请求边界处「闪一下空闲」),期满才落实 idle/completed。
|
|
839
|
+
// 防抖期内若发起新主请求,lastActivityAt 会被刷新 → 继续保持 active,不闪、不弹通知。
|
|
840
|
+
if (elapsed <= NOTIFY_DEBOUNCE_MS)
|
|
841
|
+
return { status: 'active', reason: 'turn ending (debounced)', notifyEligible: true };
|
|
842
|
+
if (elapsed <= this.idleWindowMs)
|
|
843
|
+
return { status: 'idle', reason: 'turn ended', notifyEligible: true };
|
|
844
|
+
return { status: 'completed', reason: 'inactive', notifyEligible: true };
|
|
845
|
+
}
|
|
846
|
+
// ============ 定时清扫 ============
|
|
847
|
+
sweep() {
|
|
848
|
+
const now = Date.now();
|
|
849
|
+
let changed = false;
|
|
850
|
+
this.states.forEach(st => {
|
|
851
|
+
const prevStatus = st.status;
|
|
852
|
+
const { status, reason } = this.inferStatus({
|
|
853
|
+
lastActivityAt: st.lastActivityAt,
|
|
854
|
+
lastStatusCode: st.lastStatusCode,
|
|
855
|
+
inFlight: st.inFlight,
|
|
856
|
+
streamingInFlight: st.streamingInFlight,
|
|
857
|
+
thinkingInFlight: st.thinkingInFlight,
|
|
858
|
+
streamFirstChunkAt: st.streamFirstChunkAt,
|
|
859
|
+
inFlightSince: st.inFlightSince,
|
|
860
|
+
lastTurnEnd: st.lastTurnEnd,
|
|
861
|
+
now,
|
|
862
|
+
});
|
|
863
|
+
// 状态迁移,或在途请求已陈旧/泄漏(inFlight>0 却不再存活)→ 清零泄漏的计数器
|
|
864
|
+
const statusChanged = status !== prevStatus;
|
|
865
|
+
const leakedInFlight = st.inFlight > 0 && status !== 'active';
|
|
866
|
+
if (statusChanged || leakedInFlight) {
|
|
867
|
+
if (leakedInFlight) {
|
|
868
|
+
// 修复「永远卡在进行中」:endRequest 未配对到达(早退/异常)导致的在途泄漏,这里兜底清零
|
|
869
|
+
st.inFlight = 0;
|
|
870
|
+
st.streamingInFlight = 0;
|
|
871
|
+
st.thinkingInFlight = 0;
|
|
872
|
+
st.inFlightSince = 0;
|
|
873
|
+
st.streamFirstChunkAt = 0;
|
|
874
|
+
}
|
|
875
|
+
st.status = status;
|
|
876
|
+
st.statusReason = reason;
|
|
877
|
+
changed = true;
|
|
878
|
+
// 停滞/泄漏/异常导致脱离 active → 调度防抖通知(正常结束已在 onFinalized 调度,notifiedForTurn 去重)
|
|
879
|
+
if (status !== 'active')
|
|
880
|
+
this.considerEndNotify(st, status === 'error');
|
|
881
|
+
this.emitSession(st);
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
if (changed)
|
|
885
|
+
this.broadcastStats();
|
|
886
|
+
}
|
|
887
|
+
// ============ 广播 ============
|
|
888
|
+
emitSession(st) {
|
|
889
|
+
this.emit('session-update', this.toMapItem(st));
|
|
890
|
+
this.broadcastStats();
|
|
891
|
+
}
|
|
892
|
+
broadcastStats() {
|
|
893
|
+
this.emit('stats', this.computeStats());
|
|
894
|
+
}
|
|
895
|
+
// ============ 读取(供 REST / SSE init) ============
|
|
896
|
+
getSnapshot() {
|
|
897
|
+
return {
|
|
898
|
+
sessions: Array.from(this.states.values()).map(s => this.toMapItem(s)),
|
|
899
|
+
events: this.getRecentGlobalEvents(100),
|
|
900
|
+
stats: this.computeStats(),
|
|
901
|
+
serverTime: Date.now(),
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
getSessionEvents(sessionId, since) {
|
|
905
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
906
|
+
var _a;
|
|
907
|
+
let arr = this.sessionEvents.get(sessionId);
|
|
908
|
+
// 按需重建:缓冲为空、会话存在、且能读日志 → 从 Session Log 现算(重启后点开老节点)
|
|
909
|
+
if ((!arr || arr.length === 0) && this.states.has(sessionId) && ((_a = this.db) === null || _a === void 0 ? void 0 : _a.getLogsBySessionId)) {
|
|
910
|
+
const st = this.states.get(sessionId);
|
|
911
|
+
if (st.source === 'global') {
|
|
912
|
+
// 按需重建:取最近 24h,既保证老节点点开后能看到活动路径,又避免解析全部历史
|
|
913
|
+
const evs = yield this.rebuildSessionEvents(sessionId, st.agent, Date.now() - ONDEMAND_SINCE_MS);
|
|
914
|
+
if (evs.length > 0) {
|
|
915
|
+
this.sessionEvents.set(sessionId, evs);
|
|
916
|
+
arr = evs;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
const base = arr || [];
|
|
921
|
+
if (since == null)
|
|
922
|
+
return base.slice(-MAX_EVENTS_PER_SESSION);
|
|
923
|
+
return base.filter(e => e.ts > since);
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
getRecentGlobalEvents(limit) {
|
|
927
|
+
return this.globalEvents.slice(-limit).reverse();
|
|
928
|
+
}
|
|
929
|
+
toMapItem(st) {
|
|
930
|
+
return {
|
|
931
|
+
sessionId: st.sessionId,
|
|
932
|
+
agent: st.agent,
|
|
933
|
+
source: st.source,
|
|
934
|
+
keyId: st.keyId,
|
|
935
|
+
keyName: st.keyName,
|
|
936
|
+
title: st.title,
|
|
937
|
+
status: st.status,
|
|
938
|
+
statusReason: st.statusReason,
|
|
939
|
+
firstRequestAt: st.firstRequestAt,
|
|
940
|
+
lastRequestAt: st.lastRequestAt,
|
|
941
|
+
requestCount: st.requestCount,
|
|
942
|
+
// 展示值 = 已落盘累计 + 在途实时累计,使节点在流式过程中随 token 增长实时上移
|
|
943
|
+
totalTokens: st.totalTokens + st.runningInputTokens + st.runningOutputTokens,
|
|
944
|
+
inputTokens: st.inputTokens + st.runningInputTokens,
|
|
945
|
+
outputTokens: st.outputTokens + st.runningOutputTokens,
|
|
946
|
+
lastToolName: st.lastToolName,
|
|
947
|
+
lastActivitySummary: st.lastActivitySummary,
|
|
948
|
+
lastStatusCode: st.lastStatusCode,
|
|
949
|
+
lastModel: st.lastModel,
|
|
950
|
+
inFlight: st.inFlight,
|
|
951
|
+
projectPath: st.projectPath,
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
computeStats() {
|
|
955
|
+
let active = 0, idle = 0, completed = 0, error = 0, inFlight = 0;
|
|
956
|
+
const now = Date.now();
|
|
957
|
+
for (const st of this.states.values()) {
|
|
958
|
+
inFlight += st.inFlight;
|
|
959
|
+
switch (st.status) {
|
|
960
|
+
case 'active':
|
|
961
|
+
active++;
|
|
962
|
+
break;
|
|
963
|
+
case 'idle':
|
|
964
|
+
idle++;
|
|
965
|
+
break;
|
|
966
|
+
case 'completed':
|
|
967
|
+
completed++;
|
|
968
|
+
break;
|
|
969
|
+
case 'error':
|
|
970
|
+
error++;
|
|
971
|
+
break;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
const recentCutoff = now - RECENT_WINDOW_FOR_STATS_MS;
|
|
975
|
+
let recentToolCalls = 0;
|
|
976
|
+
let recentTokens = 0;
|
|
977
|
+
for (const e of this.globalEvents) {
|
|
978
|
+
if (e.ts < recentCutoff)
|
|
979
|
+
continue;
|
|
980
|
+
if (e.kind === 'tool_use')
|
|
981
|
+
recentToolCalls++;
|
|
982
|
+
if (e.tokensDelta)
|
|
983
|
+
recentTokens += e.tokensDelta;
|
|
984
|
+
}
|
|
985
|
+
return {
|
|
986
|
+
totalSessions: this.states.size,
|
|
987
|
+
activeSessions: active,
|
|
988
|
+
idleSessions: idle,
|
|
989
|
+
completedSessions: completed,
|
|
990
|
+
errorSessions: error,
|
|
991
|
+
inFlightRequests: inFlight,
|
|
992
|
+
recentToolCalls,
|
|
993
|
+
recentTokens,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
destroy() {
|
|
997
|
+
if (this.sweepTimer) {
|
|
998
|
+
clearInterval(this.sweepTimer);
|
|
999
|
+
this.sweepTimer = null;
|
|
1000
|
+
}
|
|
1001
|
+
// 清理所有挂起的防抖通知定时器,避免销毁后仍触发
|
|
1002
|
+
this.states.forEach(st => this.cancelNotifyTimer(st));
|
|
1003
|
+
this.states.clear();
|
|
1004
|
+
this.sessionEvents.clear();
|
|
1005
|
+
this.globalEvents = [];
|
|
1006
|
+
this.removeAllListeners();
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
exports.AgentMapService = AgentMapService;
|
|
1010
|
+
// 全局单例(在 dbManager 就绪前 no-op;attach 后激活)
|
|
1011
|
+
exports.agentMapService = new AgentMapService();
|