aicodeswitch 5.2.13 → 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/dist/server/agent-map/agent-map-service.js +355 -51
- package/dist/server/conversions/index.js +10 -2
- package/dist/server/conversions/thinking/providers.js +12 -7
- package/dist/server/fs-database.js +6 -0
- package/dist/server/main.js +7 -30
- package/dist/server/performance-tracker.js +78 -8
- package/dist/server/proxy-server.js +104 -26
- package/dist/server/transformers/chunk-collector.js +112 -25
- package/dist/ui/assets/{index-4liE4bV8.css → index-CQVhBlBB.css} +1 -1
- 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 +3 -1
- package/scripts/dev.js +76 -30
- package/dist/server/tools-service.js +0 -203
- package/dist/server/websocket-service.js +0 -148
- package/dist/ui/assets/index-CJMAVkIN.js +0 -813
|
@@ -28,15 +28,27 @@ const events_1 = require("events");
|
|
|
28
28
|
const activity_extractor_1 = require("./activity-extractor");
|
|
29
29
|
const session_meta_1 = require("./session-meta");
|
|
30
30
|
const notifier_1 = require("../notifier");
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const SWEEP_INTERVAL_MS = 15000; // 每 15s 扫描一次状态迁移
|
|
31
|
+
const IDLE_WINDOW_MS = 10 * 60000; // 无在途请求后,10min 内 → idle;超出 → completed
|
|
32
|
+
const SWEEP_INTERVAL_MS = 15000; // 每 15s 扫描一次状态迁移(含在途陈旧度检查)
|
|
34
33
|
const MAX_EVENTS_PER_SESSION = 200;
|
|
35
34
|
const MAX_GLOBAL_EVENTS = 500;
|
|
36
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;
|
|
37
44
|
// 「一轮结束」OS 通知冷却:同一会话在此窗口内的多次 active→idle(典型来自主轮之后的
|
|
38
45
|
// 后台/计数/compact 等续发请求,每个 end_turn 都会再触发一次迁移)只弹一次,避免重复打扰。
|
|
39
46
|
const NOTIFY_COOLDOWN_MS = 60000;
|
|
47
|
+
// 「任务结束」防抖延迟(同时作用于:展示状态保持 active 的时长 + 结束通知的弹前等待):
|
|
48
|
+
// 检测到任务结束时,先保持「进行中」满该时长,期满且无新请求才落实「空闲」并弹通知;若期间发起新
|
|
49
|
+
// 主请求(用户/客户端马上继续下一轮),lastActivityAt 刷新 + 取消挂起通知 → 继续保持「进行中」。
|
|
50
|
+
// 避免「每轮结束都闪一下空闲 / 都弹通知」,也兜底吸收 end_turn 误判。
|
|
51
|
+
const NOTIFY_DEBOUNCE_MS = 8000;
|
|
40
52
|
// 启动重建:只回填最近 1h 内有活动的会话,每会话最多回填 N 条事件,避免重复解析大量历史分片
|
|
41
53
|
const REBUILD_SINCE_MS = 60 * 60000;
|
|
42
54
|
const REBUILD_EVENTS_PER_SESSION = 30;
|
|
@@ -83,17 +95,29 @@ class AgentMapService extends events_1.EventEmitter {
|
|
|
83
95
|
value: false
|
|
84
96
|
});
|
|
85
97
|
// 阈值可调(供测试/配置覆盖)
|
|
86
|
-
Object.defineProperty(this, "
|
|
98
|
+
Object.defineProperty(this, "idleWindowMs", {
|
|
87
99
|
enumerable: true,
|
|
88
100
|
configurable: true,
|
|
89
101
|
writable: true,
|
|
90
|
-
value:
|
|
102
|
+
value: IDLE_WINDOW_MS
|
|
91
103
|
});
|
|
92
|
-
Object.defineProperty(this, "
|
|
104
|
+
Object.defineProperty(this, "sseSilenceMs", {
|
|
93
105
|
enumerable: true,
|
|
94
106
|
configurable: true,
|
|
95
107
|
writable: true,
|
|
96
|
-
value:
|
|
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
|
|
97
121
|
});
|
|
98
122
|
this.setMaxListeners(50);
|
|
99
123
|
}
|
|
@@ -131,9 +155,14 @@ class AgentMapService extends events_1.EventEmitter {
|
|
|
131
155
|
continue;
|
|
132
156
|
const lastRequestAt = s.lastRequestAt || s.firstRequestAt || now;
|
|
133
157
|
const { status, reason } = this.inferStatus({
|
|
134
|
-
lastRequestAt,
|
|
158
|
+
lastActivityAt: lastRequestAt,
|
|
135
159
|
lastStatusCode: s.lastStatusCode,
|
|
136
160
|
inFlight: 0,
|
|
161
|
+
streamingInFlight: 0,
|
|
162
|
+
thinkingInFlight: 0,
|
|
163
|
+
streamFirstChunkAt: 0,
|
|
164
|
+
inFlightSince: 0,
|
|
165
|
+
lastTurnEnd: null,
|
|
137
166
|
now,
|
|
138
167
|
});
|
|
139
168
|
this.states.set(s.id, {
|
|
@@ -145,6 +174,10 @@ class AgentMapService extends events_1.EventEmitter {
|
|
|
145
174
|
lastRequestAt,
|
|
146
175
|
requestCount: s.requestCount || 0,
|
|
147
176
|
totalTokens: s.totalTokens || 0,
|
|
177
|
+
inputTokens: s.inputTokens || 0,
|
|
178
|
+
outputTokens: s.outputTokens || 0,
|
|
179
|
+
runningInputTokens: 0,
|
|
180
|
+
runningOutputTokens: 0,
|
|
148
181
|
lastToolName: s.lastToolName,
|
|
149
182
|
lastActivitySummary: s.lastActivitySummary,
|
|
150
183
|
lastStatusCode: s.lastStatusCode,
|
|
@@ -152,6 +185,13 @@ class AgentMapService extends events_1.EventEmitter {
|
|
|
152
185
|
status,
|
|
153
186
|
statusReason: reason,
|
|
154
187
|
inFlight: 0,
|
|
188
|
+
lastActivityAt: lastRequestAt,
|
|
189
|
+
streamingInFlight: 0,
|
|
190
|
+
thinkingInFlight: 0,
|
|
191
|
+
streamFirstChunkAt: 0,
|
|
192
|
+
inFlightSince: 0,
|
|
193
|
+
notifiedForTurn: false,
|
|
194
|
+
notifyTimer: null,
|
|
155
195
|
});
|
|
156
196
|
}
|
|
157
197
|
this.broadcastStats();
|
|
@@ -283,7 +323,13 @@ class AgentMapService extends events_1.EventEmitter {
|
|
|
283
323
|
}
|
|
284
324
|
});
|
|
285
325
|
}
|
|
286
|
-
// ============ 在途请求注册表 ============
|
|
326
|
+
// ============ 在途请求注册表 + 可靠活动追踪 ============
|
|
327
|
+
/**
|
|
328
|
+
* 请求开始:在途计数 + 活动时钟刷新。
|
|
329
|
+
* - background=true(count_tokens / compact / 后台请求):刷新活动、计入在途,用于「是否还活着」判定;
|
|
330
|
+
* 但不重置 notifiedForTurn —— 这样主任务结束后紧跟的后台请求不会再次触发结束通知。
|
|
331
|
+
* - background=false(主轮请求):视为「新一轮用户任务」,重置 notifiedForTurn,允许结束时再弹一次通知。
|
|
332
|
+
*/
|
|
287
333
|
startRequest(sessionId, agent, opts) {
|
|
288
334
|
if (!sessionId || sessionId === '-')
|
|
289
335
|
return;
|
|
@@ -301,14 +347,38 @@ class AgentMapService extends events_1.EventEmitter {
|
|
|
301
347
|
lastRequestAt: now,
|
|
302
348
|
requestCount: 0,
|
|
303
349
|
totalTokens: 0,
|
|
350
|
+
inputTokens: 0,
|
|
351
|
+
outputTokens: 0,
|
|
352
|
+
runningInputTokens: 0,
|
|
353
|
+
runningOutputTokens: 0,
|
|
304
354
|
status: 'active',
|
|
305
355
|
statusReason: 'in-flight',
|
|
306
356
|
inFlight: 0,
|
|
357
|
+
lastActivityAt: now,
|
|
358
|
+
streamingInFlight: 0,
|
|
359
|
+
thinkingInFlight: 0,
|
|
360
|
+
streamFirstChunkAt: 0,
|
|
361
|
+
inFlightSince: now,
|
|
362
|
+
notifiedForTurn: false,
|
|
363
|
+
notifyTimer: null,
|
|
307
364
|
};
|
|
308
365
|
this.states.set(sessionId, st);
|
|
309
366
|
}
|
|
367
|
+
if (st.inFlight === 0) {
|
|
368
|
+
// 新的一批在途开始:记录起跑时刻(同步请求 5min 超时基线)
|
|
369
|
+
st.inFlightSince = now;
|
|
370
|
+
st.streamFirstChunkAt = 0;
|
|
371
|
+
}
|
|
310
372
|
st.inFlight += 1;
|
|
373
|
+
if (opts === null || opts === void 0 ? void 0 : opts.thinking)
|
|
374
|
+
st.thinkingInFlight += 1;
|
|
311
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
|
+
}
|
|
312
382
|
// 有在途请求必然 active
|
|
313
383
|
if (st.status !== 'active') {
|
|
314
384
|
st.status = 'active';
|
|
@@ -316,17 +386,95 @@ class AgentMapService extends events_1.EventEmitter {
|
|
|
316
386
|
}
|
|
317
387
|
this.emitSession(st);
|
|
318
388
|
}
|
|
319
|
-
|
|
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) {
|
|
320
422
|
if (!sessionId || sessionId === '-')
|
|
321
423
|
return;
|
|
322
424
|
const st = this.states.get(sessionId);
|
|
323
425
|
if (!st)
|
|
324
426
|
return;
|
|
325
427
|
st.inFlight = Math.max(0, st.inFlight - 1);
|
|
326
|
-
|
|
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;
|
|
327
474
|
}
|
|
328
475
|
// ============ 请求收尾:抽事件 + 重算状态 + 广播 ============
|
|
329
476
|
onFinalized(ctx) {
|
|
477
|
+
var _a;
|
|
330
478
|
if (!ctx.sessionId || ctx.sessionId === '-')
|
|
331
479
|
return;
|
|
332
480
|
const extractInput = {
|
|
@@ -374,8 +522,19 @@ class AgentMapService extends events_1.EventEmitter {
|
|
|
374
522
|
lastRequestAt: ctx.timestamp,
|
|
375
523
|
requestCount: 0,
|
|
376
524
|
totalTokens: 0,
|
|
525
|
+
inputTokens: 0,
|
|
526
|
+
outputTokens: 0,
|
|
527
|
+
runningInputTokens: 0,
|
|
528
|
+
runningOutputTokens: 0,
|
|
377
529
|
status: 'active',
|
|
378
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,
|
|
379
538
|
};
|
|
380
539
|
this.states.set(ctx.sessionId, st);
|
|
381
540
|
}
|
|
@@ -392,6 +551,10 @@ class AgentMapService extends events_1.EventEmitter {
|
|
|
392
551
|
st.requestCount += 1;
|
|
393
552
|
if (ctx.tokensDelta)
|
|
394
553
|
st.totalTokens += ctx.tokensDelta;
|
|
554
|
+
if (ctx.inputTokensDelta)
|
|
555
|
+
st.inputTokens += ctx.inputTokensDelta;
|
|
556
|
+
if (ctx.outputTokensDelta)
|
|
557
|
+
st.outputTokens += ctx.outputTokensDelta;
|
|
395
558
|
if (ctx.model)
|
|
396
559
|
st.lastModel = ctx.model;
|
|
397
560
|
if (ctx.statusCode != null)
|
|
@@ -400,21 +563,31 @@ class AgentMapService extends events_1.EventEmitter {
|
|
|
400
563
|
st.lastActivitySummary = summary;
|
|
401
564
|
if (toolName)
|
|
402
565
|
st.lastToolName = toolName;
|
|
403
|
-
//
|
|
566
|
+
// 本轮响应是否表示「一轮结束」:
|
|
567
|
+
// - true = end_turn 等明确结束 → 任务完成,可弹通知
|
|
568
|
+
// - false = tool_use 等还要继续 → 客户端将本地执行工具后再次请求,此时静默、保持「进行中」
|
|
569
|
+
// - null = 无法判定 → 兜底按结束处理
|
|
404
570
|
const turnEnd = (0, activity_extractor_1.detectTurnEnd)(ctx.agent, ctx.downstreamResponseBody, ctx.responseBody);
|
|
405
571
|
st.lastTurnEnd = turnEnd;
|
|
406
|
-
//
|
|
407
|
-
const prevStatus = st.status;
|
|
572
|
+
// 重算状态:请求已结束(endRequest 已把 inFlight 减 1)。inferStatus 会在「结束」情形下防抖保持 active。
|
|
408
573
|
const { status, reason } = this.inferStatus({
|
|
409
|
-
|
|
574
|
+
lastActivityAt: st.lastActivityAt,
|
|
410
575
|
lastStatusCode: st.lastStatusCode,
|
|
411
576
|
inFlight: st.inFlight,
|
|
577
|
+
streamingInFlight: st.streamingInFlight,
|
|
578
|
+
thinkingInFlight: st.thinkingInFlight,
|
|
579
|
+
streamFirstChunkAt: st.streamFirstChunkAt,
|
|
580
|
+
inFlightSince: st.inFlightSince,
|
|
581
|
+
lastTurnEnd: turnEnd,
|
|
412
582
|
now,
|
|
413
|
-
turnEnd,
|
|
414
583
|
});
|
|
415
584
|
st.status = status;
|
|
416
585
|
st.statusReason = reason;
|
|
417
|
-
|
|
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
|
+
}
|
|
418
591
|
// 记录事件
|
|
419
592
|
if (events.length > 0) {
|
|
420
593
|
this.recordEvents(ctx.sessionId, events);
|
|
@@ -466,10 +639,61 @@ class AgentMapService extends events_1.EventEmitter {
|
|
|
466
639
|
if (meta.title)
|
|
467
640
|
st.title = meta.title;
|
|
468
641
|
st.metaResolved = true;
|
|
642
|
+
// legacy 会话(修复前累积、拆分从未持久化):点开详情时按日志回填输入/输出拆分
|
|
643
|
+
this.backfillTokenSplit(st).catch(err => console.error('[AgentMap] backfillTokenSplit error:', err));
|
|
469
644
|
}
|
|
470
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) };
|
|
471
646
|
});
|
|
472
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
|
+
}
|
|
473
697
|
// ============ 任务结束 OS 通知 ============
|
|
474
698
|
setNotifyEnabled(enabled) {
|
|
475
699
|
this.notifyEnabled = !!enabled;
|
|
@@ -498,25 +722,60 @@ class AgentMapService extends events_1.EventEmitter {
|
|
|
498
722
|
notifyTest() {
|
|
499
723
|
(0, notifier_1.notify)({ title: '🔔 AICodeSwitch', body: '测试通知:通知功能可用' });
|
|
500
724
|
}
|
|
501
|
-
/**
|
|
502
|
-
|
|
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) {
|
|
503
733
|
if (!this.notifyEnabled)
|
|
504
734
|
return;
|
|
505
|
-
if (
|
|
506
|
-
return;
|
|
507
|
-
// 499 = 用户主动放弃停止任务,属于预期行为,不弹系统通知
|
|
735
|
+
if (st.notifiedForTurn)
|
|
736
|
+
return; // 本轮已调度
|
|
508
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)
|
|
509
764
|
return;
|
|
510
|
-
// 冷却去重:主轮结束后常伴有后台/计数/compact 等续发请求,各自 end_turn 会重复触发 active→idle,
|
|
511
|
-
// 在冷却窗口内只弹一次,避免同一任务弹两条通知。
|
|
512
765
|
const now = Date.now();
|
|
513
766
|
if (st.lastNotifyAt && now - st.lastNotifyAt < NOTIFY_COOLDOWN_MS)
|
|
514
767
|
return;
|
|
515
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
|
+
}
|
|
516
775
|
const agentName = st.agent === 'codex' ? 'Codex' : 'Claude Code';
|
|
517
776
|
(0, notifier_1.notify)({
|
|
518
|
-
title:
|
|
519
|
-
body: st.title || st.lastActivitySummary || '
|
|
777
|
+
title: `${isError ? '⚠️' : '✅'} AICodeSwitch · ${agentName}`,
|
|
778
|
+
body: st.title || st.lastActivitySummary || (isError ? '任务出现异常' : '任务已结束,等待下一步'),
|
|
520
779
|
});
|
|
521
780
|
}
|
|
522
781
|
recordEvents(sessionId, events) {
|
|
@@ -531,35 +790,58 @@ class AgentMapService extends events_1.EventEmitter {
|
|
|
531
790
|
this.globalEvents.splice(0, this.globalEvents.length - MAX_GLOBAL_EVENTS);
|
|
532
791
|
}
|
|
533
792
|
}
|
|
534
|
-
// ============
|
|
793
|
+
// ============ 状态推断(纯函数,基于「在途 + 真实活动时钟 + 轮次语义」) ============
|
|
535
794
|
inferStatus(args) {
|
|
536
|
-
//
|
|
537
|
-
if (args.inFlight > 0)
|
|
538
|
-
return { status: 'active', reason: 'in-flight' };
|
|
539
|
-
// 末次失败 → error(即使时间较近也标 error,下一次成功会刷回 active)
|
|
795
|
+
// 末次上游 5xx → error(即使时间较近也标 error,下一次成功会刷回 active)
|
|
540
796
|
if (args.lastStatusCode != null && args.lastStatusCode >= 500) {
|
|
541
|
-
return { status: 'error', reason: `upstream ${args.lastStatusCode}
|
|
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 };
|
|
542
822
|
}
|
|
543
|
-
|
|
544
|
-
|
|
823
|
+
// 无在途请求:按「上一轮响应语义 + 距最近活动时长」判 idle / completed,并决定是否可通知
|
|
824
|
+
const elapsed = args.now - args.lastActivityAt;
|
|
825
|
+
// 499 = 客户端主动断开(用户放弃停止任务):视为「已取消」,立即进入 idle
|
|
545
826
|
if (args.lastStatusCode === 499) {
|
|
546
827
|
if (elapsed <= this.idleWindowMs)
|
|
547
|
-
return { status: 'idle', reason: 'client cancelled' };
|
|
548
|
-
return { status: 'completed', reason: 'cancelled earlier' };
|
|
828
|
+
return { status: 'idle', reason: 'client cancelled', notifyEligible: true };
|
|
829
|
+
return { status: 'completed', reason: 'cancelled earlier', notifyEligible: true };
|
|
549
830
|
}
|
|
550
|
-
//
|
|
551
|
-
if (args.
|
|
831
|
+
// tool_use:客户端正在本地执行工具,随后会再发请求 → 保持「进行中」,且**不可通知**(避免每次工具调用误弹)
|
|
832
|
+
if (args.lastTurnEnd === false) {
|
|
552
833
|
if (elapsed <= this.idleWindowMs)
|
|
553
|
-
return { status: '
|
|
554
|
-
return { status: '
|
|
555
|
-
}
|
|
556
|
-
// 仍将继续(tool_use)或信号未知 → 回退到活跃时间窗
|
|
557
|
-
if (elapsed <= this.activeWindowMs) {
|
|
558
|
-
return { status: 'active', reason: args.turnEnd === false ? 'tool loop' : 'recent request' };
|
|
834
|
+
return { status: 'active', reason: 'executing tool locally', notifyEligible: false };
|
|
835
|
+
return { status: 'idle', reason: 'tool exec abandoned', notifyEligible: false };
|
|
559
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 };
|
|
560
842
|
if (elapsed <= this.idleWindowMs)
|
|
561
|
-
return { status: 'idle', reason: '
|
|
562
|
-
return { status: 'completed', reason: 'inactive' };
|
|
843
|
+
return { status: 'idle', reason: 'turn ended', notifyEligible: true };
|
|
844
|
+
return { status: 'completed', reason: 'inactive', notifyEligible: true };
|
|
563
845
|
}
|
|
564
846
|
// ============ 定时清扫 ============
|
|
565
847
|
sweep() {
|
|
@@ -568,17 +850,34 @@ class AgentMapService extends events_1.EventEmitter {
|
|
|
568
850
|
this.states.forEach(st => {
|
|
569
851
|
const prevStatus = st.status;
|
|
570
852
|
const { status, reason } = this.inferStatus({
|
|
571
|
-
|
|
853
|
+
lastActivityAt: st.lastActivityAt,
|
|
572
854
|
lastStatusCode: st.lastStatusCode,
|
|
573
855
|
inFlight: st.inFlight,
|
|
856
|
+
streamingInFlight: st.streamingInFlight,
|
|
857
|
+
thinkingInFlight: st.thinkingInFlight,
|
|
858
|
+
streamFirstChunkAt: st.streamFirstChunkAt,
|
|
859
|
+
inFlightSince: st.inFlightSince,
|
|
860
|
+
lastTurnEnd: st.lastTurnEnd,
|
|
574
861
|
now,
|
|
575
|
-
turnEnd: st.lastTurnEnd,
|
|
576
862
|
});
|
|
577
|
-
|
|
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
|
+
}
|
|
578
875
|
st.status = status;
|
|
579
876
|
st.statusReason = reason;
|
|
580
877
|
changed = true;
|
|
581
|
-
|
|
878
|
+
// 停滞/泄漏/异常导致脱离 active → 调度防抖通知(正常结束已在 onFinalized 调度,notifiedForTurn 去重)
|
|
879
|
+
if (status !== 'active')
|
|
880
|
+
this.considerEndNotify(st, status === 'error');
|
|
582
881
|
this.emitSession(st);
|
|
583
882
|
}
|
|
584
883
|
});
|
|
@@ -640,7 +939,10 @@ class AgentMapService extends events_1.EventEmitter {
|
|
|
640
939
|
firstRequestAt: st.firstRequestAt,
|
|
641
940
|
lastRequestAt: st.lastRequestAt,
|
|
642
941
|
requestCount: st.requestCount,
|
|
643
|
-
|
|
942
|
+
// 展示值 = 已落盘累计 + 在途实时累计,使节点在流式过程中随 token 增长实时上移
|
|
943
|
+
totalTokens: st.totalTokens + st.runningInputTokens + st.runningOutputTokens,
|
|
944
|
+
inputTokens: st.inputTokens + st.runningInputTokens,
|
|
945
|
+
outputTokens: st.outputTokens + st.runningOutputTokens,
|
|
644
946
|
lastToolName: st.lastToolName,
|
|
645
947
|
lastActivitySummary: st.lastActivitySummary,
|
|
646
948
|
lastStatusCode: st.lastStatusCode,
|
|
@@ -696,6 +998,8 @@ class AgentMapService extends events_1.EventEmitter {
|
|
|
696
998
|
clearInterval(this.sweepTimer);
|
|
697
999
|
this.sweepTimer = null;
|
|
698
1000
|
}
|
|
1001
|
+
// 清理所有挂起的防抖通知定时器,避免销毁后仍触发
|
|
1002
|
+
this.states.forEach(st => this.cancelNotifyTimer(st));
|
|
699
1003
|
this.states.clear();
|
|
700
1004
|
this.sessionEvents.clear();
|
|
701
1005
|
this.globalEvents = [];
|
|
@@ -276,8 +276,16 @@ function buildTargetBody(options) {
|
|
|
276
276
|
if (result.messages) {
|
|
277
277
|
result.messages = (0, mapper_js_2.fixThinkingHistory)(result.messages, 'completions');
|
|
278
278
|
}
|
|
279
|
-
//
|
|
280
|
-
|
|
279
|
+
// stream_options 剥离策略由 providerConfig.streamOptions 决定:
|
|
280
|
+
// 'supported' → 保留(DeepSeek 等支持 include_usage,能拿到真实 usage)
|
|
281
|
+
// 'unsupported' → 剥离(已知会报错的供应商)
|
|
282
|
+
// 'auto'/缺省 → 维持历史行为:reasoning_content 剥离
|
|
283
|
+
const streamOpt = providerConfig.streamOptions || 'auto';
|
|
284
|
+
const shouldStripStreamOptions = streamOpt === 'unsupported' ||
|
|
285
|
+
(streamOpt === 'auto' && isReasoningContentCompletion);
|
|
286
|
+
if (shouldStripStreamOptions) {
|
|
287
|
+
delete result.stream_options;
|
|
288
|
+
}
|
|
281
289
|
}
|
|
282
290
|
// 注入 thinking 参数(如 thinking: { type: 'enabled' })和 effort 参数
|
|
283
291
|
if (providerConfig.supportsThinking || providerConfig.supportsEffort) {
|
|
@@ -6,17 +6,21 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.DEFAULT_CONFIG = void 0;
|
|
7
7
|
exports.getReasoningConfig = getReasoningConfig;
|
|
8
8
|
exports.applyReasoningConfig = applyReasoningConfig;
|
|
9
|
+
// streamOptions 语义(决定流式请求是否保留 stream_options.include_usage):
|
|
10
|
+
// 'supported' → 已知支持 include_usage 的供应商,保留 → 流式可拿到真实 usage
|
|
11
|
+
// 'unsupported' → 已知会对 stream_options 报错的供应商,剥离
|
|
12
|
+
// 'auto' → 未明确,由 conversions/index.ts 按 outputFormat 兜底(reasoning_content 剥离,其余保留)
|
|
9
13
|
const PROVIDER_CONFIGS = [
|
|
10
|
-
{ patterns: ['deepseek'], config: { supportsThinking: true, supportsEffort: true, thinkingParam: 'thinking', effortParam: 'reasoning_effort', effortValueMode: 'deepseek', outputFormat: 'reasoning_content' } },
|
|
11
|
-
{ patterns: ['moonshot', 'kimi'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content' } },
|
|
12
|
-
{ patterns: ['qwen', 'dashscope'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'enable_thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content' } },
|
|
13
|
-
{ patterns: ['zhipu', 'glm', 'bigmodel'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content' } },
|
|
14
|
+
{ patterns: ['deepseek'], config: { supportsThinking: true, supportsEffort: true, thinkingParam: 'thinking', effortParam: 'reasoning_effort', effortValueMode: 'deepseek', outputFormat: 'reasoning_content', streamOptions: 'supported' } },
|
|
15
|
+
{ patterns: ['moonshot', 'kimi'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content', streamOptions: 'supported' } },
|
|
16
|
+
{ patterns: ['qwen', 'dashscope'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'enable_thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content', streamOptions: 'supported' } },
|
|
17
|
+
{ patterns: ['zhipu', 'glm', 'bigmodel'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content', streamOptions: 'supported' } },
|
|
14
18
|
{ patterns: ['minimax'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'reasoning_split', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_details' } },
|
|
15
|
-
{ patterns: ['mimo', 'xiaomimimo'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content' } },
|
|
19
|
+
{ patterns: ['mimo', 'xiaomimimo'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content', streamOptions: 'auto' } },
|
|
16
20
|
{ patterns: ['openrouter'], config: { supportsThinking: false, supportsEffort: true, thinkingParam: 'none', effortParam: 'reasoning.effort', effortValueMode: 'openrouter', outputFormat: 'reasoning' } },
|
|
17
|
-
{ patterns: ['siliconflow'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'enable_thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content' } },
|
|
21
|
+
{ patterns: ['siliconflow'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'enable_thinking', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content', streamOptions: 'supported' } },
|
|
18
22
|
{ patterns: ['stepfun', 'step'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'none', effortParam: 'reasoning_effort', effortValueMode: 'low_high', outputFormat: 'reasoning' } },
|
|
19
|
-
{ patterns: ['agnes'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'chat_template_kwargs', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content' } },
|
|
23
|
+
{ patterns: ['agnes'], config: { supportsThinking: true, supportsEffort: false, thinkingParam: 'chat_template_kwargs', effortParam: 'none', effortValueMode: 'passthrough', outputFormat: 'reasoning_content', streamOptions: 'auto' } },
|
|
20
24
|
];
|
|
21
25
|
const DEFAULT_CONFIG = {
|
|
22
26
|
supportsThinking: false,
|
|
@@ -25,6 +29,7 @@ const DEFAULT_CONFIG = {
|
|
|
25
29
|
effortParam: 'none',
|
|
26
30
|
effortValueMode: 'passthrough',
|
|
27
31
|
outputFormat: 'reasoning_content',
|
|
32
|
+
streamOptions: 'auto',
|
|
28
33
|
};
|
|
29
34
|
exports.DEFAULT_CONFIG = DEFAULT_CONFIG;
|
|
30
35
|
/** Detect reasoning config for a given provider */
|