jbc-dssi 1.1.7 → 1.1.8
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/package.json
CHANGED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* groups/bridge/engine/round.js —— 「一轮怎么跑」:投递、等待窗口、计时与超时。
|
|
3
|
+
*
|
|
4
|
+
* move-only 第一刀(2026-11-02):从 `groups/bridge/engine.js` **原样搬来** `deliver` 与
|
|
5
|
+
* 等待窗口常量,**零逻辑改动**。协作方(会话绑定 / 结果落库 / 纯函数)一律由
|
|
6
|
+
* engine.js 注入,本文件**不引用任何同目录子模块**、也不反向 import engine.js。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 群任务等待窗口(2026-11-01 治本:把"240 秒硬超时"改成三段式)。
|
|
13
|
+
*
|
|
14
|
+
* 旧行为:prompt 后 240s 没等到 turn/end 就 settle({missed}),行落成「[未回应]」。
|
|
15
|
+
* 可构建/发布/上传这类长任务普遍超过 4 分钟:成员仍在执行、稍后确有回复,却已被
|
|
16
|
+
* 判"未回应",而且结果被丢弃 —— 这就是"未回应总伴随活干完了"的根因。
|
|
17
|
+
*
|
|
18
|
+
* 新口径:
|
|
19
|
+
* ① TURN_IDLE_TIMEOUT_MS 降级成**静默看门狗**:只观测、只记日志,绝不结算;
|
|
20
|
+
* ② 总预算按任务类型给:普通 10 分钟、长任务(发版/构建/发布/上传…)30 分钟;
|
|
21
|
+
* ③ 预算耗尽 ≠ 失败:落中性「(仍在执行,结果稍后补上)」占位;
|
|
22
|
+
* ④ 占位后继续**安静跟随**该会话(不广播,避免串到下一成员的流式块),
|
|
23
|
+
* turn/end 一到就把真实正文/用量/步数作为**同一轮的补发行**落库(回填)。
|
|
24
|
+
*/
|
|
25
|
+
export const TURN_IDLE_TIMEOUT_MS = 240_000;
|
|
26
|
+
export const TURN_BUDGET_DEFAULT_MS = 10 * 60_000;
|
|
27
|
+
export const TURN_BUDGET_LONG_MS = 30 * 60_000;
|
|
28
|
+
/** 预算耗尽后还愿意等多久的补发窗口(超过就放弃回填,只保留中性占位)。 */
|
|
29
|
+
export const LATE_WATCH_MAX_MS = 2 * 60 * 60_000;
|
|
30
|
+
/** 长任务识别:文案里出现这些词就给长预算(纯文本判定,不做语义猜测)。 */
|
|
31
|
+
const LONG_TASK_RE = /发版|发布|构建|打包|上传|部署|安装包|APK|apk|release|deploy|build|publish|npm|Gitee|gitee/;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 按任务文案挑等待预算:长任务 30 分钟,其余 10 分钟。
|
|
35
|
+
* 纯函数(不依赖运行时),单测直接钉边界。
|
|
36
|
+
*/
|
|
37
|
+
export function taskBudgetMs(taskText, { defaultMs = TURN_BUDGET_DEFAULT_MS, longMs = TURN_BUDGET_LONG_MS } = {}) {
|
|
38
|
+
return LONG_TASK_RE.test(String(taskText ?? '')) ? longMs : defaultMs;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 造 `deliver`:投递一轮给一个成员,等回复完成后结算。
|
|
43
|
+
*
|
|
44
|
+
* 注入项(全部由 engine.js 提供,本文件不直接依赖它们):
|
|
45
|
+
* - `ctx` / `log` / `broadcast`:DSH 上下文与日志/广播;
|
|
46
|
+
* - `timing`:等待窗口参数(生产用默认值;测试注入小值);
|
|
47
|
+
* - `ensureSession(group, memberId)`:会话绑定(session.js);
|
|
48
|
+
* - `emitLateReply` / `emitLateFailure`:迟到结果回填(settle.js);
|
|
49
|
+
* - `assistantText` / `normalizeUsage` / `sumUsage`:纯函数。
|
|
50
|
+
*
|
|
51
|
+
* @returns {(group:object, member:object, prompt:string, attachments?:object, opts?:object) => Promise<{
|
|
52
|
+
* text?:string, failed?:string, stillRunning?:boolean, usage?:object|null, steps?:number, stepUsages?:Array<object>}>}
|
|
53
|
+
*/
|
|
54
|
+
export function createDeliver({
|
|
55
|
+
ctx,
|
|
56
|
+
log,
|
|
57
|
+
broadcast,
|
|
58
|
+
timing,
|
|
59
|
+
ensureSession,
|
|
60
|
+
emitLateReply,
|
|
61
|
+
emitLateFailure,
|
|
62
|
+
assistantText,
|
|
63
|
+
normalizeUsage,
|
|
64
|
+
sumUsage,
|
|
65
|
+
}) {
|
|
66
|
+
/**
|
|
67
|
+
* 投递一轮给一个成员,等回复完成后 settle。
|
|
68
|
+
*
|
|
69
|
+
* @returns {Promise<{text?:string, failed?:string, stillRunning?:boolean,
|
|
70
|
+
* usage?:object|null, steps?:number, stepUsages?:Array<object>}>}
|
|
71
|
+
* `stillRunning` = 总预算耗尽(任务可能还在执行,结果稍后以补发行回填)。
|
|
72
|
+
* `stepUsages` = 本轮每一步的用量(每一步一条,按到达顺序);它和 `usage`
|
|
73
|
+
* (各步求和)一起进房间日志,供读侧还原与兼容老客户端。
|
|
74
|
+
*/
|
|
75
|
+
return function deliver(group, member, prompt, attachments = {}, opts = {}) {
|
|
76
|
+
const budgetMs = typeof opts.budgetMs === 'number' ? opts.budgetMs : timing.defaultBudgetMs;
|
|
77
|
+
const roundNo = opts.round?.rounds ?? 0;
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
let settled = false;
|
|
80
|
+
let stillRunning = false;
|
|
81
|
+
let lateWait = false;
|
|
82
|
+
let followClosed = false;
|
|
83
|
+
let idleTimer = null;
|
|
84
|
+
let budgetTimer = null;
|
|
85
|
+
let lateTimer = null;
|
|
86
|
+
/*
|
|
87
|
+
* ★ 结算判据:等「整个 turn 结束」,不是「第一条 assistant/message」。
|
|
88
|
+
*
|
|
89
|
+
* 2026-10-30 真机实测(解压群会话事件逐条核对):一个 turn 会被拆成多个 step,
|
|
90
|
+
* **每一步都发一条 assistant/message**。例如用户说「早上好」那一轮:
|
|
91
|
+
*
|
|
92
|
+
* step1..8 blocks=[reasoning, tool-call(bash)] ← 没有 text
|
|
93
|
+
* step9 blocks=[reasoning, text(806)] ← 这才是回答
|
|
94
|
+
* turn/end reason={kind:'completed'}
|
|
95
|
+
*
|
|
96
|
+
* 旧实现遇到 step1 就 settle 并 ac.abort():assistantText 取不到 text →
|
|
97
|
+
* 落成「[未回应]」,后面 8 步全被掐掉。用户看到的就是
|
|
98
|
+
* 「明明有输出、有思维过程、有用量(74K tok),却显示未回应」。
|
|
99
|
+
*
|
|
100
|
+
* 现在:轮内**累积**最后一条带正文的消息,只在 turn/end 结算。
|
|
101
|
+
*/
|
|
102
|
+
let prompted = false;
|
|
103
|
+
let minTurn = null;
|
|
104
|
+
let lastText = '';
|
|
105
|
+
/*
|
|
106
|
+
* 本轮用量 = 各 step 的 usage **累加**(见 [sumUsage])。
|
|
107
|
+
*
|
|
108
|
+
* 2026-10-31 之前这里是 `lastUsage`(只留最后一条 assistant/message),
|
|
109
|
+
* 于是「共 N 步」配的是"最后一步"的 token,步数越多的轮次少算越多。
|
|
110
|
+
*/
|
|
111
|
+
let turnUsage = null;
|
|
112
|
+
/*
|
|
113
|
+
* 本轮**逐步**用量(2026-11-01 新增):每一步一条,与 turnUsage 同源。
|
|
114
|
+
*
|
|
115
|
+
* 为什么存这个:旧存储只留聚合值,刷新/重进后无法还原「每一步花了多少」,
|
|
116
|
+
* 历史记录也就永远只能信当时写下的那个数。逐步入库后,读侧可以用它求和还原 ——
|
|
117
|
+
* 面板在实时、刷新、重进、换端任何时刻都是同一份数据的计算结果。
|
|
118
|
+
*/
|
|
119
|
+
const stepUsages = [];
|
|
120
|
+
/*
|
|
121
|
+
* 「共 N 步」的账:turn → 这一轮出现过的 step 号集合。
|
|
122
|
+
*
|
|
123
|
+
* 与单聊**同一个算法**(ChatScreen 的 `turnStepNums`):assistant/message
|
|
124
|
+
* 每个 step 发一条,收集本轮的 step 号去重后求个数;重试时同一个 step 可能
|
|
125
|
+
* 重复到达,用集合才不会多算。
|
|
126
|
+
*/
|
|
127
|
+
const turnStepNums = new Map();
|
|
128
|
+
let lastTurn = -1;
|
|
129
|
+
|
|
130
|
+
const clearIdle = () => {
|
|
131
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
132
|
+
};
|
|
133
|
+
const settle = (result) => {
|
|
134
|
+
if (settled) return;
|
|
135
|
+
settled = true;
|
|
136
|
+
if (budgetTimer) { clearTimeout(budgetTimer); budgetTimer = null; }
|
|
137
|
+
clearIdle();
|
|
138
|
+
resolve(result);
|
|
139
|
+
};
|
|
140
|
+
/*
|
|
141
|
+
* 静默看门狗:任意帧到达就重置;静默只记日志,**绝不结算**。
|
|
142
|
+
* (长命令执行期间可能长时间没有事件,那不等于失联。)
|
|
143
|
+
*/
|
|
144
|
+
const noteActivity = () => {
|
|
145
|
+
if (settled) return;
|
|
146
|
+
clearIdle();
|
|
147
|
+
idleTimer = setTimeout(() => {
|
|
148
|
+
log?.(`[groups] ${member.name} 静默 ${timing.idleMs}ms,任务可能仍在执行(不结算、不判未回应)`);
|
|
149
|
+
}, timing.idleMs);
|
|
150
|
+
};
|
|
151
|
+
let sessionId = null;
|
|
152
|
+
(async () => {
|
|
153
|
+
try {
|
|
154
|
+
sessionId = await ensureSession(group, member.id);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
settle({ failed: String(err?.message ?? err) });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const ac = new AbortController();
|
|
161
|
+
const stream = ctx.sessionController.follow(
|
|
162
|
+
{ address: { kind: 'session', sessionId }, assistantStream: true, maxMessages: 1 },
|
|
163
|
+
ac.signal,
|
|
164
|
+
);
|
|
165
|
+
void (async () => {
|
|
166
|
+
try {
|
|
167
|
+
for await (const frame of stream) {
|
|
168
|
+
/*
|
|
169
|
+
* 本轮已 settle(拿到 assistant/message / 被判失败)就立刻停手。
|
|
170
|
+
*
|
|
171
|
+
* ★ 原来只在 followClosed(投递失败)时 break,settle 之后若流里还有
|
|
172
|
+
* 缓冲帧(end、尾包)会继续广播 room_stream。而那些帧到达手机时,
|
|
173
|
+
* 下一位成员往往已经开始说了 —— 手机侧流式块是「每群一个槽位」,
|
|
174
|
+
* 于是上一位的尾巴被画进了下一位的块里,表现为
|
|
175
|
+
* 「上一成员没有输出,下一成员的话就来了」(用户实测原话)。
|
|
176
|
+
*/
|
|
177
|
+
// 成功/明确失败才停;预算耗尽后进入"补发观察",继续安静跟随却不再广播
|
|
178
|
+
if (followClosed || (settled && !lateWait)) break;
|
|
179
|
+
noteActivity();
|
|
180
|
+
// 群消息流式增量:与智能体单聊同一推送机制 —— assistant-stream 帧原样
|
|
181
|
+
// 广播给手机(room_stream),手机侧用单聊同款 reduceLive 边收边画;
|
|
182
|
+
// 完整文本仍由 assistant/message → settle → room_line 落盘(下方处理)。
|
|
183
|
+
if (frame?.type === 'assistant-stream') {
|
|
184
|
+
if (!settled) {
|
|
185
|
+
broadcast?.({ type: 'room_stream', groupId: group.id, memberId: member.id, name: member.name, frame });
|
|
186
|
+
}
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (frame?.type === 'event') {
|
|
190
|
+
const event = frame.event;
|
|
191
|
+
const data = event?.data ?? {};
|
|
192
|
+
/*
|
|
193
|
+
* prompt 之前 follow 会先回放历史(maxMessages 条)——
|
|
194
|
+
* 那些事件属于上一轮,绝不能拿来结算本轮。
|
|
195
|
+
*/
|
|
196
|
+
if (!prompted) continue;
|
|
197
|
+
// 回放的残余事件也可能晚到:只认本轮及之后的 turn。
|
|
198
|
+
if (minTurn !== null && typeof data.turn === 'number' && data.turn < minTurn) continue;
|
|
199
|
+
|
|
200
|
+
if (typeof data.turn === 'number') lastTurn = data.turn;
|
|
201
|
+
if (event?.type === 'turn/start') {
|
|
202
|
+
if (minTurn === null && typeof data.turn === 'number') minTurn = data.turn;
|
|
203
|
+
} else if (event?.type === 'assistant/message') {
|
|
204
|
+
// 工具调用那几步没有 text,不能拿它们当回答;有正文才覆盖。
|
|
205
|
+
const text = assistantText(event);
|
|
206
|
+
if (text) lastText = text;
|
|
207
|
+
/*
|
|
208
|
+
* 步数:**所有** assistant/message 都要记,包括只调工具、不画块的那几步
|
|
209
|
+
* (与单聊一致 —— token 大头正是这些步骤花掉的)。
|
|
210
|
+
*/
|
|
211
|
+
const step = typeof data.step === 'number' ? data.step : -1;
|
|
212
|
+
const usage = normalizeUsage(data.usage);
|
|
213
|
+
if (usage) {
|
|
214
|
+
/*
|
|
215
|
+
* 逐步入库:步号 + 这一步的五个 token 字段。
|
|
216
|
+
* 同一步重试会各存一条(用量按 attempt 累加,与单聊/Web 一致),
|
|
217
|
+
* 步数仍由 turnStepNums 按 step 号去重,两者互不影响。
|
|
218
|
+
*/
|
|
219
|
+
stepUsages.push({ step, ...usage });
|
|
220
|
+
turnUsage = sumUsage(turnUsage, usage);
|
|
221
|
+
}
|
|
222
|
+
if (step >= 0 && typeof data.turn === 'number') {
|
|
223
|
+
if (!turnStepNums.has(data.turn)) turnStepNums.set(data.turn, new Set());
|
|
224
|
+
turnStepNums.get(data.turn).add(step);
|
|
225
|
+
}
|
|
226
|
+
} else if (event?.type === 'turn/end') {
|
|
227
|
+
const kind = data.reason?.kind;
|
|
228
|
+
/*
|
|
229
|
+
* 本轮步数:取 turn/end 指向的那一轮(拿不到就退回最后见过的一轮)。
|
|
230
|
+
* 0 = 没统计到(老版本插件 / 事件里没带 step)→ 手机侧整段不显示。
|
|
231
|
+
*/
|
|
232
|
+
const endTurn = typeof data.turn === 'number' ? data.turn : lastTurn;
|
|
233
|
+
const steps = turnStepNums.get(endTurn)?.size ?? 0;
|
|
234
|
+
if (kind && kind !== 'completed') {
|
|
235
|
+
const failed = String(data.reason?.message ?? kind);
|
|
236
|
+
if (settled && lateWait) emitLateFailure(group, member, failed, roundNo);
|
|
237
|
+
else settle({ failed });
|
|
238
|
+
} else if (settled && lateWait) {
|
|
239
|
+
/*
|
|
240
|
+
* 预算耗尽后迟到的真实结果 → 作为**同一轮的补发行**落库(回填,不丢弃)。
|
|
241
|
+
* 这是"不用 240 秒误判长任务"的最后一环。
|
|
242
|
+
*/
|
|
243
|
+
emitLateReply(group, member, { text: lastText, usage: turnUsage, steps, stepUsages }, roundNo);
|
|
244
|
+
followClosed = true;
|
|
245
|
+
} else {
|
|
246
|
+
settle({ text: lastText, usage: turnUsage, steps, stepUsages });
|
|
247
|
+
}
|
|
248
|
+
if (lateTimer) { clearTimeout(lateTimer); lateTimer = null; }
|
|
249
|
+
ac.abort();
|
|
250
|
+
} else if (event?.type === 'turn/error' || event?.type === 'turn/failed') {
|
|
251
|
+
settle({ failed: String(event?.data?.error?.message ?? '会话失败') });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} catch (err) {
|
|
256
|
+
if (!settled && !followClosed) settle({ failed: String(err?.message ?? err) });
|
|
257
|
+
}
|
|
258
|
+
})();
|
|
259
|
+
|
|
260
|
+
void (async () => {
|
|
261
|
+
try {
|
|
262
|
+
await new Promise((resolveNext) => setTimeout(resolveNext, 10));
|
|
263
|
+
/*
|
|
264
|
+
* 投递内容 = prompt 文字,后面按顺序挂上附件 part。
|
|
265
|
+
*
|
|
266
|
+
* 形状与单聊 PROMPT 完全一致:{type:'image', mediaType, data} /
|
|
267
|
+
* {type:'file', receiptId}。这样成员用的就是同一套模型能力。
|
|
268
|
+
*/
|
|
269
|
+
const content = [{ type: 'text', text: prompt }];
|
|
270
|
+
for (const image of (attachments?.images ?? [])) {
|
|
271
|
+
content.push({
|
|
272
|
+
type: 'image',
|
|
273
|
+
mediaType: typeof image?.mediaType === 'string' ? image.mediaType : 'image/jpeg',
|
|
274
|
+
data: image.data,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
for (const file of (attachments?.files ?? [])) {
|
|
278
|
+
content.push({ type: 'file', receiptId: file.receiptId });
|
|
279
|
+
}
|
|
280
|
+
await ctx.sessionController.prompt(
|
|
281
|
+
{
|
|
282
|
+
requestId: randomUUID(),
|
|
283
|
+
sessionId,
|
|
284
|
+
mode: 'queue',
|
|
285
|
+
content,
|
|
286
|
+
},
|
|
287
|
+
AbortSignal.timeout(30_000),
|
|
288
|
+
);
|
|
289
|
+
// 请求已进队列:此后到达的事件才属于本轮(之前的都是历史回放)。
|
|
290
|
+
prompted = true;
|
|
291
|
+
noteActivity();
|
|
292
|
+
/*
|
|
293
|
+
* 总预算(任务类型决定 10 / 30 分钟)。耗尽 ≠ 失败:
|
|
294
|
+
* 落中性「(仍在执行,结果稍后补上)」占位,并继续安静跟随等真实结果回填。
|
|
295
|
+
*/
|
|
296
|
+
budgetTimer = setTimeout(() => {
|
|
297
|
+
if (settled) return;
|
|
298
|
+
lateWait = true;
|
|
299
|
+
stillRunning = true;
|
|
300
|
+
settle({ stillRunning: true });
|
|
301
|
+
lateTimer = setTimeout(() => {
|
|
302
|
+
lateWait = false;
|
|
303
|
+
followClosed = true;
|
|
304
|
+
ac.abort();
|
|
305
|
+
log?.(`[groups] ${member.name} 补发窗口 ${timing.lateMaxMs}ms 内仍未结束,放弃回填(中性占位保留)`);
|
|
306
|
+
}, timing.lateMaxMs);
|
|
307
|
+
}, budgetMs);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
if (!settled) {
|
|
310
|
+
followClosed = true;
|
|
311
|
+
ac.abort();
|
|
312
|
+
settle({ failed: String(err?.message ?? err) });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
})();
|
|
316
|
+
})();
|
|
317
|
+
});
|
|
318
|
+
};
|
|
319
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* groups/bridge/engine/session.js —— 「任务/成员怎么绑定到 DSH Session」。
|
|
3
|
+
*
|
|
4
|
+
* move-only 第一刀(2026-11-02):从 `groups/bridge/engine.js` **原样搬来**
|
|
5
|
+
* `ensureGroupSession`,**零逻辑改动**。ctx/dssAgent 由 engine.js 注入;
|
|
6
|
+
* 本文件**不引用任何同目录子模块**、也不反向 import engine.js。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 造「群会话绑定器」:懒创建并持久化 `memberId -> sessionId` 映射。
|
|
11
|
+
*
|
|
12
|
+
* @param {object} deps
|
|
13
|
+
* @param {object} deps.ctx DSH 上下文(仅用公开方法)
|
|
14
|
+
* @param {object} deps.state 群状态(groupSessions)
|
|
15
|
+
* @param {object} deps.dssAgent dssAgent 引擎(createSession 公开方法)
|
|
16
|
+
* @param {Function} deps.save 持久化(串行写)
|
|
17
|
+
*/
|
|
18
|
+
export function createSessionBinder({ ctx, state, dssAgent, save }) {
|
|
19
|
+
/** 群会话:懒创建并持久化映射(memberId -> sessionId)。 */
|
|
20
|
+
async function ensureGroupSession(group, memberId) {
|
|
21
|
+
const map = (state.groupSessions[group.id] ??= {});
|
|
22
|
+
if (map[memberId]) return map[memberId];
|
|
23
|
+
const { sessionId } = await dssAgent.createSession({
|
|
24
|
+
entityId: memberId,
|
|
25
|
+
title: `[群]${group.name}`,
|
|
26
|
+
});
|
|
27
|
+
map[memberId] = sessionId;
|
|
28
|
+
void save?.();
|
|
29
|
+
return sessionId;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { ensureGroupSession };
|
|
33
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* groups/bridge/engine/settle.js —— 「这轮怎么收口」:房间行落库、迟到回填、读侧快照。
|
|
3
|
+
*
|
|
4
|
+
* move-only 第一刀(2026-11-02):从 `groups/bridge/engine.js` **原样搬来**
|
|
5
|
+
* `emitLine` / `emitLateReply` / `emitLateFailure` / `emitReplyLine` / `roomSnapshot`,
|
|
6
|
+
* **零逻辑改动**。依赖(state/save/broadcast/log/now/aggregateStepUsages)全部由
|
|
7
|
+
* engine.js 注入;本文件**不引用任何同目录子模块**、也不反向 import engine.js。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** 房间日志最多保留条数(超出丢最旧,防无限膨胀)。 */
|
|
11
|
+
const ROOM_LOG_MAX = 60;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 造「房间行读写器」。
|
|
15
|
+
*
|
|
16
|
+
* @param {object} deps
|
|
17
|
+
* @param {object} deps.state 群状态(rooms)
|
|
18
|
+
* @param {Function} deps.save 持久化(串行写)
|
|
19
|
+
* @param {Function} deps.broadcast 群事件广播
|
|
20
|
+
* @param {Function} deps.log 日志
|
|
21
|
+
* @param {Function} deps.now 时钟(默认 Date.now)
|
|
22
|
+
* @param {Function} deps.aggregateStepUsages 逐步用量求和(engine.js 注入)
|
|
23
|
+
*/
|
|
24
|
+
export function createSettler({ state, save, broadcast, log, now = () => Date.now(), aggregateStepUsages }) {
|
|
25
|
+
/**
|
|
26
|
+
* 追加一行到房间日志。
|
|
27
|
+
*
|
|
28
|
+
* ★ seq 必须**单调递增、永不复用**。
|
|
29
|
+
*
|
|
30
|
+
* 过去拿「数组长度」当 seq,而房间日志满 ROOM_LOG_MAX 后要 shift 掉最旧的 ——
|
|
31
|
+
* 长度从此恒定在 60,于是第 61 条之后每条 seq 都是 60。手机侧 LazyColumn
|
|
32
|
+
* 以 seq 作 key,重复 key 直接崩:
|
|
33
|
+
* `IllegalArgumentException: Key "60" was already used`(2026-10-29 真机实测)。
|
|
34
|
+
*
|
|
35
|
+
* 改成「上一条 seq + 1」:与长度彻底解耦,truncate 多少次都不会回退。
|
|
36
|
+
* 调用方原先传的 seq 一律忽略(统一在这里分配,避免两处算法漂移)。
|
|
37
|
+
*/
|
|
38
|
+
function emitLine(groupId, line) {
|
|
39
|
+
const room = (state.rooms[groupId] ??= []);
|
|
40
|
+
const last = room[room.length - 1];
|
|
41
|
+
const seq = (typeof last?.seq === 'number' ? last.seq : -1) + 1;
|
|
42
|
+
const entry = { ...line, seq };
|
|
43
|
+
room.push(entry);
|
|
44
|
+
while (room.length > ROOM_LOG_MAX) room.shift();
|
|
45
|
+
void save?.();
|
|
46
|
+
broadcast?.({ type: 'room_line', groupId, line: entry });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 预算耗尽后迟到的真实回复:作为**同一轮的补发行**追加(不改已有行、不丢结果)。 */
|
|
50
|
+
function emitLateReply(group, member, result, roundNo) {
|
|
51
|
+
const text = String(result.text ?? '').trim() || '[未回应]';
|
|
52
|
+
emitLine(group.id, {
|
|
53
|
+
at: now(),
|
|
54
|
+
role: 'member',
|
|
55
|
+
memberId: member.id,
|
|
56
|
+
name: member.name,
|
|
57
|
+
text,
|
|
58
|
+
usage: result.usage ?? null,
|
|
59
|
+
steps: result.steps ?? 0,
|
|
60
|
+
stepUsages: Array.isArray(result.stepUsages) && result.stepUsages.length > 0 ? result.stepUsages : undefined,
|
|
61
|
+
late: true,
|
|
62
|
+
round: roundNo,
|
|
63
|
+
});
|
|
64
|
+
log?.(`[groups] ${member.name} 的迟到回复已补发(round=${roundNo})`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 预算耗尽后迟到的明确失败:只有明确失败才报错(中性"仍在执行"不报失败)。 */
|
|
68
|
+
function emitLateFailure(group, member, failed, roundNo) {
|
|
69
|
+
emitLine(group.id, {
|
|
70
|
+
at: now(), role: 'system', name: member.name,
|
|
71
|
+
text: `${member.name} 回应失败:${failed}`,
|
|
72
|
+
late: true,
|
|
73
|
+
round: roundNo,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function emitReplyLine(group, round, member, result) {
|
|
78
|
+
const trimmed = String(result.text ?? '').trim();
|
|
79
|
+
/*
|
|
80
|
+
* 状态口径分离(2026-11-01):预算耗尽 → 中性「(仍在执行…)」,**不是**未回应;
|
|
81
|
+
* 「[未回应]」只留给"正常结束但整轮没有正文"(纯工具调用)这种诚实兜底。
|
|
82
|
+
*/
|
|
83
|
+
const text = trimmed || (result.stillRunning ? '(仍在执行,结果稍后补上)' : '[未回应]');
|
|
84
|
+
emitLine(group.id, {
|
|
85
|
+
at: now(),
|
|
86
|
+
role: 'member',
|
|
87
|
+
memberId: member.id,
|
|
88
|
+
name: member.name,
|
|
89
|
+
text,
|
|
90
|
+
usage: result.usage ?? null,
|
|
91
|
+
steps: result.steps ?? 0,
|
|
92
|
+
// 只有真的收到过 usage 才写这个字段;否则 JSON 里不出现(= 老格式,读侧回落)
|
|
93
|
+
stepUsages: Array.isArray(result.stepUsages) && result.stepUsages.length > 0
|
|
94
|
+
? result.stepUsages
|
|
95
|
+
: undefined,
|
|
96
|
+
// 中性"仍在执行"标记(老 app 忽略未知字段;新读侧/排查可据此区分)
|
|
97
|
+
stillRunning: result.stillRunning === true ? true : undefined,
|
|
98
|
+
round: round?.rounds ?? 0,
|
|
99
|
+
});
|
|
100
|
+
round.totalTurns += 1;
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 打开群聊页时拉房间历史。
|
|
106
|
+
*
|
|
107
|
+
* 【读侧口径(2026-11-01)】
|
|
108
|
+
* 带 `stepUsages` 的新记录:用量由逐步数据**当场求和**再下发 —— 刷新/重进拿到的
|
|
109
|
+
* 值和实时那一刻完全同源(这份和也是 `usage` 字段的来源,二者恒等)。
|
|
110
|
+
* 没有 `stepUsages` 的旧记录:**原样返回**,显示当时写下的历史值。按负责人决定
|
|
111
|
+
* 历史不救 —— 这里不猜、不补 0、不拿别的字段假装重算。
|
|
112
|
+
*/
|
|
113
|
+
function roomSnapshot(group) {
|
|
114
|
+
return (state.rooms[group.id] ?? []).slice(-ROOM_LOG_MAX).map((line) => {
|
|
115
|
+
if (!Array.isArray(line?.stepUsages) || line.stepUsages.length === 0) return line;
|
|
116
|
+
const usage = aggregateStepUsages(line.stepUsages);
|
|
117
|
+
return usage ? { ...line, usage } : line;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { emitLine, emitLateReply, emitLateFailure, emitReplyLine, roomSnapshot };
|
|
122
|
+
}
|
|
@@ -17,44 +17,24 @@
|
|
|
17
17
|
* 按主管回复里的 @ 点名分发下一批。
|
|
18
18
|
*
|
|
19
19
|
* 不依赖 DSH 内部实现:会话操作全部走 ctx.sessionController 的公开方法。
|
|
20
|
+
*
|
|
21
|
+
* ── 结构(2026-11-02 巨石拆除第一刀,move-only,导出未变)───────────────
|
|
22
|
+
* 本文件变「门面 + 编排」;实现按职责搬进同目录子模块,子模块之间零引用,
|
|
23
|
+
* 依赖一律由这里注入:
|
|
24
|
+
* - `engine/round.js` 一轮怎么跑:投递、静默看门狗、预算/补发窗口(`deliver`)
|
|
25
|
+
* - `engine/settle.js` 一轮怎么收口:房间行落库、迟到回填、读侧快照
|
|
26
|
+
* - `engine/session.js` 成员会话绑定(懒创建 + 持久化映射)
|
|
27
|
+
* 对外 API 保持一字不变:`createRoundEngine`、`taskBudgetMs`(后者从 round.js 转发)。
|
|
20
28
|
*/
|
|
21
29
|
|
|
22
|
-
import { randomUUID } from 'node:crypto';
|
|
23
30
|
import { mentionsIn } from './mentions.js';
|
|
24
31
|
import { buildManagerPrompt, buildMemberPrompt } from './prompt.js';
|
|
32
|
+
import { createDeliver, taskBudgetMs, TURN_IDLE_TIMEOUT_MS, TURN_BUDGET_DEFAULT_MS, TURN_BUDGET_LONG_MS, LATE_WATCH_MAX_MS } from './engine/round.js';
|
|
33
|
+
import { createSettler } from './engine/settle.js';
|
|
34
|
+
import { createSessionBinder } from './engine/session.js';
|
|
25
35
|
|
|
26
|
-
|
|
27
|
-
const ROOM_LOG_MAX = 60;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* 群任务等待窗口(2026-11-01 治本:把"240 秒硬超时"改成三段式)。
|
|
31
|
-
*
|
|
32
|
-
* 旧行为:prompt 后 240s 没等到 turn/end 就 settle({missed}),行落成「[未回应]」。
|
|
33
|
-
* 可构建/发布/上传这类长任务普遍超过 4 分钟:成员仍在执行、稍后确有回复,却已被
|
|
34
|
-
* 判"未回应",而且结果被丢弃 —— 这就是"未回应总伴随活干完了"的根因。
|
|
35
|
-
*
|
|
36
|
-
* 新口径:
|
|
37
|
-
* ① TURN_IDLE_TIMEOUT_MS 降级成**静默看门狗**:只观测、只记日志,绝不结算;
|
|
38
|
-
* ② 总预算按任务类型给:普通 10 分钟、长任务(发版/构建/发布/上传…)30 分钟;
|
|
39
|
-
* ③ 预算耗尽 ≠ 失败:落中性「(仍在执行,结果稍后补上)」占位;
|
|
40
|
-
* ④ 占位后继续**安静跟随**该会话(不广播,避免串到下一成员的流式块),
|
|
41
|
-
* turn/end 一到就把真实正文/用量/步数作为**同一轮的补发行**落库(回填)。
|
|
42
|
-
*/
|
|
43
|
-
const TURN_IDLE_TIMEOUT_MS = 240_000;
|
|
44
|
-
const TURN_BUDGET_DEFAULT_MS = 10 * 60_000;
|
|
45
|
-
const TURN_BUDGET_LONG_MS = 30 * 60_000;
|
|
46
|
-
/** 预算耗尽后还愿意等多久的补发窗口(超过就放弃回填,只保留中性占位)。 */
|
|
47
|
-
const LATE_WATCH_MAX_MS = 2 * 60 * 60_000;
|
|
48
|
-
/** 长任务识别:文案里出现这些词就给长预算(纯文本判定,不做语义猜测)。 */
|
|
49
|
-
const LONG_TASK_RE = /发版|发布|构建|打包|上传|部署|安装包|APK|apk|release|deploy|build|publish|npm|Gitee|gitee/;
|
|
36
|
+
export { taskBudgetMs };
|
|
50
37
|
|
|
51
|
-
/**
|
|
52
|
-
* 按任务文案挑等待预算:长任务 30 分钟,其余 10 分钟。
|
|
53
|
-
* 纯函数(不依赖运行时),单测直接钉边界。
|
|
54
|
-
*/
|
|
55
|
-
export function taskBudgetMs(taskText, { defaultMs = TURN_BUDGET_DEFAULT_MS, longMs = TURN_BUDGET_LONG_MS } = {}) {
|
|
56
|
-
return LONG_TASK_RE.test(String(taskText ?? '')) ? longMs : defaultMs;
|
|
57
|
-
}
|
|
58
38
|
/**
|
|
59
39
|
* 轮次上限(最高红线,用户定稿):
|
|
60
40
|
* 「分发任务 → 接收反馈」算 1 轮;1~4 轮内可提前结束;第 5 轮收齐反馈后必须强制汇报,
|
|
@@ -172,372 +152,34 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast,
|
|
|
172
152
|
const budgetFor = (taskText) =>
|
|
173
153
|
taskBudgetMs(taskText, { defaultMs: timing.defaultBudgetMs, longMs: timing.longBudgetMs });
|
|
174
154
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** 群会话:懒创建并持久化映射(memberId -> sessionId)。 */
|
|
200
|
-
async function ensureGroupSession(group, memberId) {
|
|
201
|
-
const map = (state.groupSessions[group.id] ??= {});
|
|
202
|
-
if (map[memberId]) return map[memberId];
|
|
203
|
-
const { sessionId } = await dssAgent.createSession({
|
|
204
|
-
entityId: memberId,
|
|
205
|
-
title: `[群]${group.name}`,
|
|
206
|
-
});
|
|
207
|
-
map[memberId] = sessionId;
|
|
208
|
-
void save?.();
|
|
209
|
-
return sessionId;
|
|
210
|
-
}
|
|
155
|
+
/* ── 子模块装配(依赖由门面注入;子模块之间零引用)── */
|
|
156
|
+
const { emitLine, emitLateReply, emitLateFailure, emitReplyLine, roomSnapshot } = createSettler({
|
|
157
|
+
state,
|
|
158
|
+
save,
|
|
159
|
+
broadcast,
|
|
160
|
+
log,
|
|
161
|
+
now,
|
|
162
|
+
aggregateStepUsages,
|
|
163
|
+
});
|
|
164
|
+
const { ensureGroupSession } = createSessionBinder({ ctx, state, dssAgent, save });
|
|
165
|
+
const deliver = createDeliver({
|
|
166
|
+
ctx,
|
|
167
|
+
log,
|
|
168
|
+
broadcast,
|
|
169
|
+
timing,
|
|
170
|
+
ensureSession: ensureGroupSession,
|
|
171
|
+
emitLateReply,
|
|
172
|
+
emitLateFailure,
|
|
173
|
+
assistantText,
|
|
174
|
+
normalizeUsage,
|
|
175
|
+
sumUsage,
|
|
176
|
+
});
|
|
211
177
|
|
|
212
178
|
/** 最近群消息(含刚落的用户行/成员行),给 prompt 做上下文。 */
|
|
213
179
|
function recentLinesOf(group) {
|
|
214
180
|
return (state.rooms[group.id] ?? []).slice(-RECENT_LINES_MAX);
|
|
215
181
|
}
|
|
216
182
|
|
|
217
|
-
/**
|
|
218
|
-
* 投递一轮给一个成员,等回复完成后 settle。
|
|
219
|
-
*
|
|
220
|
-
* @returns {Promise<{text?:string, failed?:string, stillRunning?:boolean,
|
|
221
|
-
* usage?:object|null, steps?:number, stepUsages?:Array<object>}>}
|
|
222
|
-
* `stillRunning` = 总预算耗尽(任务可能还在执行,结果稍后以补发行回填)。
|
|
223
|
-
* `stepUsages` = 本轮每一步的用量(每一步一条,按到达顺序);它和 `usage`
|
|
224
|
-
* (各步求和)一起进房间日志,供读侧还原与兼容老客户端。
|
|
225
|
-
*/
|
|
226
|
-
function deliver(group, member, prompt, attachments = {}, opts = {}) {
|
|
227
|
-
const budgetMs = typeof opts.budgetMs === 'number' ? opts.budgetMs : timing.defaultBudgetMs;
|
|
228
|
-
const roundNo = opts.round?.rounds ?? 0;
|
|
229
|
-
return new Promise((resolve) => {
|
|
230
|
-
let settled = false;
|
|
231
|
-
let stillRunning = false;
|
|
232
|
-
let lateWait = false;
|
|
233
|
-
let followClosed = false;
|
|
234
|
-
let idleTimer = null;
|
|
235
|
-
let budgetTimer = null;
|
|
236
|
-
let lateTimer = null;
|
|
237
|
-
/*
|
|
238
|
-
* ★ 结算判据:等「整个 turn 结束」,不是「第一条 assistant/message」。
|
|
239
|
-
*
|
|
240
|
-
* 2026-10-30 真机实测(解压群会话事件逐条核对):一个 turn 会被拆成多个 step,
|
|
241
|
-
* **每一步都发一条 assistant/message**。例如用户说「早上好」那一轮:
|
|
242
|
-
*
|
|
243
|
-
* step1..8 blocks=[reasoning, tool-call(bash)] ← 没有 text
|
|
244
|
-
* step9 blocks=[reasoning, text(806)] ← 这才是回答
|
|
245
|
-
* turn/end reason={kind:'completed'}
|
|
246
|
-
*
|
|
247
|
-
* 旧实现遇到 step1 就 settle 并 ac.abort():assistantText 取不到 text →
|
|
248
|
-
* 落成「[未回应]」,后面 8 步全被掐掉。用户看到的就是
|
|
249
|
-
* 「明明有输出、有思维过程、有用量(74K tok),却显示未回应」。
|
|
250
|
-
*
|
|
251
|
-
* 现在:轮内**累积**最后一条带正文的消息,只在 turn/end 结算。
|
|
252
|
-
*/
|
|
253
|
-
let prompted = false;
|
|
254
|
-
let minTurn = null;
|
|
255
|
-
let lastText = '';
|
|
256
|
-
/*
|
|
257
|
-
* 本轮用量 = 各 step 的 usage **累加**(见 [sumUsage])。
|
|
258
|
-
*
|
|
259
|
-
* 2026-10-31 之前这里是 `lastUsage`(只留最后一条 assistant/message),
|
|
260
|
-
* 于是「共 N 步」配的是"最后一步"的 token,步数越多的轮次少算越多。
|
|
261
|
-
*/
|
|
262
|
-
let turnUsage = null;
|
|
263
|
-
/*
|
|
264
|
-
* 本轮**逐步**用量(2026-11-01 新增):每一步一条,与 turnUsage 同源。
|
|
265
|
-
*
|
|
266
|
-
* 为什么存这个:旧存储只留聚合值,刷新/重进后无法还原「每一步花了多少」,
|
|
267
|
-
* 历史记录也就永远只能信当时写下的那个数。逐步入库后,读侧可以用它求和还原 ——
|
|
268
|
-
* 面板在实时、刷新、重进、换端任何时刻都是同一份数据的计算结果。
|
|
269
|
-
*/
|
|
270
|
-
const stepUsages = [];
|
|
271
|
-
/*
|
|
272
|
-
* 「共 N 步」的账:turn → 这一轮出现过的 step 号集合。
|
|
273
|
-
*
|
|
274
|
-
* 与单聊**同一个算法**(ChatScreen 的 `turnStepNums`):assistant/message
|
|
275
|
-
* 每个 step 发一条,收集本轮的 step 号去重后求个数;重试时同一个 step 可能
|
|
276
|
-
* 重复到达,用集合才不会多算。
|
|
277
|
-
*/
|
|
278
|
-
const turnStepNums = new Map();
|
|
279
|
-
let lastTurn = -1;
|
|
280
|
-
|
|
281
|
-
const clearIdle = () => {
|
|
282
|
-
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
283
|
-
};
|
|
284
|
-
const settle = (result) => {
|
|
285
|
-
if (settled) return;
|
|
286
|
-
settled = true;
|
|
287
|
-
if (budgetTimer) { clearTimeout(budgetTimer); budgetTimer = null; }
|
|
288
|
-
clearIdle();
|
|
289
|
-
resolve(result);
|
|
290
|
-
};
|
|
291
|
-
/*
|
|
292
|
-
* 静默看门狗:任意帧到达就重置;静默只记日志,**绝不结算**。
|
|
293
|
-
* (长命令执行期间可能长时间没有事件,那不等于失联。)
|
|
294
|
-
*/
|
|
295
|
-
const noteActivity = () => {
|
|
296
|
-
if (settled) return;
|
|
297
|
-
clearIdle();
|
|
298
|
-
idleTimer = setTimeout(() => {
|
|
299
|
-
log?.(`[groups] ${member.name} 静默 ${timing.idleMs}ms,任务可能仍在执行(不结算、不判未回应)`);
|
|
300
|
-
}, timing.idleMs);
|
|
301
|
-
};
|
|
302
|
-
let sessionId = null;
|
|
303
|
-
(async () => {
|
|
304
|
-
try {
|
|
305
|
-
sessionId = await ensureGroupSession(group, member.id);
|
|
306
|
-
} catch (err) {
|
|
307
|
-
settle({ failed: String(err?.message ?? err) });
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const ac = new AbortController();
|
|
312
|
-
const stream = ctx.sessionController.follow(
|
|
313
|
-
{ address: { kind: 'session', sessionId }, assistantStream: true, maxMessages: 1 },
|
|
314
|
-
ac.signal,
|
|
315
|
-
);
|
|
316
|
-
void (async () => {
|
|
317
|
-
try {
|
|
318
|
-
for await (const frame of stream) {
|
|
319
|
-
/*
|
|
320
|
-
* 本轮已 settle(拿到 assistant/message / 被判失败)就立刻停手。
|
|
321
|
-
*
|
|
322
|
-
* ★ 原来只在 followClosed(投递失败)时 break,settle 之后若流里还有
|
|
323
|
-
* 缓冲帧(end、尾包)会继续广播 room_stream。而那些帧到达手机时,
|
|
324
|
-
* 下一位成员往往已经开始说了 —— 手机侧流式块是「每群一个槽位」,
|
|
325
|
-
* 于是上一位的尾巴被画进了下一位的块里,表现为
|
|
326
|
-
* 「上一成员没有输出,下一成员的话就来了」(用户实测原话)。
|
|
327
|
-
*/
|
|
328
|
-
// 成功/明确失败才停;预算耗尽后进入"补发观察",继续安静跟随却不再广播
|
|
329
|
-
if (followClosed || (settled && !lateWait)) break;
|
|
330
|
-
noteActivity();
|
|
331
|
-
// 群消息流式增量:与智能体单聊同一推送机制 —— assistant-stream 帧原样
|
|
332
|
-
// 广播给手机(room_stream),手机侧用单聊同款 reduceLive 边收边画;
|
|
333
|
-
// 完整文本仍由 assistant/message → settle → room_line 落盘(下方处理)。
|
|
334
|
-
if (frame?.type === 'assistant-stream') {
|
|
335
|
-
if (!settled) {
|
|
336
|
-
broadcast?.({ type: 'room_stream', groupId: group.id, memberId: member.id, name: member.name, frame });
|
|
337
|
-
}
|
|
338
|
-
continue;
|
|
339
|
-
}
|
|
340
|
-
if (frame?.type === 'event') {
|
|
341
|
-
const event = frame.event;
|
|
342
|
-
const data = event?.data ?? {};
|
|
343
|
-
/*
|
|
344
|
-
* prompt 之前 follow 会先回放历史(maxMessages 条)——
|
|
345
|
-
* 那些事件属于上一轮,绝不能拿来结算本轮。
|
|
346
|
-
*/
|
|
347
|
-
if (!prompted) continue;
|
|
348
|
-
// 回放的残余事件也可能晚到:只认本轮及之后的 turn。
|
|
349
|
-
if (minTurn !== null && typeof data.turn === 'number' && data.turn < minTurn) continue;
|
|
350
|
-
|
|
351
|
-
if (typeof data.turn === 'number') lastTurn = data.turn;
|
|
352
|
-
if (event?.type === 'turn/start') {
|
|
353
|
-
if (minTurn === null && typeof data.turn === 'number') minTurn = data.turn;
|
|
354
|
-
} else if (event?.type === 'assistant/message') {
|
|
355
|
-
// 工具调用那几步没有 text,不能拿它们当回答;有正文才覆盖。
|
|
356
|
-
const text = assistantText(event);
|
|
357
|
-
if (text) lastText = text;
|
|
358
|
-
/*
|
|
359
|
-
* 步数:**所有** assistant/message 都要记,包括只调工具、不画块的那几步
|
|
360
|
-
* (与单聊一致 —— token 大头正是这些步骤花掉的)。
|
|
361
|
-
*/
|
|
362
|
-
const step = typeof data.step === 'number' ? data.step : -1;
|
|
363
|
-
const usage = normalizeUsage(data.usage);
|
|
364
|
-
if (usage) {
|
|
365
|
-
/*
|
|
366
|
-
* 逐步入库:步号 + 这一步的五个 token 字段。
|
|
367
|
-
* 同一步重试会各存一条(用量按 attempt 累加,与单聊/Web 一致),
|
|
368
|
-
* 步数仍由 turnStepNums 按 step 号去重,两者互不影响。
|
|
369
|
-
*/
|
|
370
|
-
stepUsages.push({ step, ...usage });
|
|
371
|
-
turnUsage = sumUsage(turnUsage, usage);
|
|
372
|
-
}
|
|
373
|
-
if (step >= 0 && typeof data.turn === 'number') {
|
|
374
|
-
if (!turnStepNums.has(data.turn)) turnStepNums.set(data.turn, new Set());
|
|
375
|
-
turnStepNums.get(data.turn).add(step);
|
|
376
|
-
}
|
|
377
|
-
} else if (event?.type === 'turn/end') {
|
|
378
|
-
const kind = data.reason?.kind;
|
|
379
|
-
/*
|
|
380
|
-
* 本轮步数:取 turn/end 指向的那一轮(拿不到就退回最后见过的一轮)。
|
|
381
|
-
* 0 = 没统计到(老版本插件 / 事件里没带 step)→ 手机侧整段不显示。
|
|
382
|
-
*/
|
|
383
|
-
const endTurn = typeof data.turn === 'number' ? data.turn : lastTurn;
|
|
384
|
-
const steps = turnStepNums.get(endTurn)?.size ?? 0;
|
|
385
|
-
if (kind && kind !== 'completed') {
|
|
386
|
-
const failed = String(data.reason?.message ?? kind);
|
|
387
|
-
if (settled && lateWait) emitLateFailure(group, member, failed, roundNo);
|
|
388
|
-
else settle({ failed });
|
|
389
|
-
} else if (settled && lateWait) {
|
|
390
|
-
/*
|
|
391
|
-
* 预算耗尽后迟到的真实结果 → 作为**同一轮的补发行**落库(回填,不丢弃)。
|
|
392
|
-
* 这是"不用 240 秒误判长任务"的最后一环。
|
|
393
|
-
*/
|
|
394
|
-
emitLateReply(group, member, { text: lastText, usage: turnUsage, steps, stepUsages }, roundNo);
|
|
395
|
-
followClosed = true;
|
|
396
|
-
} else {
|
|
397
|
-
settle({ text: lastText, usage: turnUsage, steps, stepUsages });
|
|
398
|
-
}
|
|
399
|
-
if (lateTimer) { clearTimeout(lateTimer); lateTimer = null; }
|
|
400
|
-
ac.abort();
|
|
401
|
-
} else if (event?.type === 'turn/error' || event?.type === 'turn/failed') {
|
|
402
|
-
settle({ failed: String(event?.data?.error?.message ?? '会话失败') });
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
} catch (err) {
|
|
407
|
-
if (!settled && !followClosed) settle({ failed: String(err?.message ?? err) });
|
|
408
|
-
}
|
|
409
|
-
})();
|
|
410
|
-
|
|
411
|
-
void (async () => {
|
|
412
|
-
try {
|
|
413
|
-
await new Promise((resolveNext) => setTimeout(resolveNext, 10));
|
|
414
|
-
/*
|
|
415
|
-
* 投递内容 = prompt 文字,后面按顺序挂上附件 part。
|
|
416
|
-
*
|
|
417
|
-
* 形状与单聊 PROMPT 完全一致:{type:'image', mediaType, data} /
|
|
418
|
-
* {type:'file', receiptId}。这样成员用的就是同一套模型能力。
|
|
419
|
-
*/
|
|
420
|
-
const content = [{ type: 'text', text: prompt }];
|
|
421
|
-
for (const image of (attachments?.images ?? [])) {
|
|
422
|
-
content.push({
|
|
423
|
-
type: 'image',
|
|
424
|
-
mediaType: typeof image?.mediaType === 'string' ? image.mediaType : 'image/jpeg',
|
|
425
|
-
data: image.data,
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
for (const file of (attachments?.files ?? [])) {
|
|
429
|
-
content.push({ type: 'file', receiptId: file.receiptId });
|
|
430
|
-
}
|
|
431
|
-
await ctx.sessionController.prompt(
|
|
432
|
-
{
|
|
433
|
-
requestId: randomUUID(),
|
|
434
|
-
sessionId,
|
|
435
|
-
mode: 'queue',
|
|
436
|
-
content,
|
|
437
|
-
},
|
|
438
|
-
AbortSignal.timeout(30_000),
|
|
439
|
-
);
|
|
440
|
-
// 请求已进队列:此后到达的事件才属于本轮(之前的都是历史回放)。
|
|
441
|
-
prompted = true;
|
|
442
|
-
noteActivity();
|
|
443
|
-
/*
|
|
444
|
-
* 总预算(任务类型决定 10 / 30 分钟)。耗尽 ≠ 失败:
|
|
445
|
-
* 落中性「(仍在执行,结果稍后补上)」占位,并继续安静跟随等真实结果回填。
|
|
446
|
-
*/
|
|
447
|
-
budgetTimer = setTimeout(() => {
|
|
448
|
-
if (settled) return;
|
|
449
|
-
lateWait = true;
|
|
450
|
-
stillRunning = true;
|
|
451
|
-
settle({ stillRunning: true });
|
|
452
|
-
lateTimer = setTimeout(() => {
|
|
453
|
-
lateWait = false;
|
|
454
|
-
followClosed = true;
|
|
455
|
-
ac.abort();
|
|
456
|
-
log?.(`[groups] ${member.name} 补发窗口 ${timing.lateMaxMs}ms 内仍未结束,放弃回填(中性占位保留)`);
|
|
457
|
-
}, timing.lateMaxMs);
|
|
458
|
-
}, budgetMs);
|
|
459
|
-
} catch (err) {
|
|
460
|
-
if (!settled) {
|
|
461
|
-
followClosed = true;
|
|
462
|
-
ac.abort();
|
|
463
|
-
settle({ failed: String(err?.message ?? err) });
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
})();
|
|
467
|
-
})();
|
|
468
|
-
});
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* 把一次投递的结果落成房间行(成员/主管同一套:手机侧按 memberId 显示名字与主管标)。
|
|
473
|
-
*
|
|
474
|
-
* 房间行里的「本轮用量」有两种格式,**读侧必须都认**:
|
|
475
|
-
*
|
|
476
|
-
* 旧格式(2026-10-31 及以前落盘的行,只保留聚合值、无法重算):
|
|
477
|
-
* { usage: {inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, totalTokens}, steps: 10 }
|
|
478
|
-
* 新格式(2026-11-01 起,多一个逐步数组):
|
|
479
|
-
* { usage: {...恒等于逐步求和...}, steps: 10,
|
|
480
|
-
* stepUsages: [{ step: 1, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, totalTokens }, ...] }
|
|
481
|
-
*
|
|
482
|
-
* `usage` 新格式里照写:给还没升级的老手机读(它们只认这个字段)。
|
|
483
|
-
* 新手机的 GroupStore 优先用 `stepUsages` 求和 —— 刷新/重进后与实时同源。
|
|
484
|
-
* 旧行没有 `stepUsages`:读侧原样显示历史值,不补 0、不重算、不报错(历史不救)。
|
|
485
|
-
*/
|
|
486
|
-
/** 预算耗尽后迟到的真实回复:作为**同一轮的补发行**追加(不改已有行、不丢结果)。 */
|
|
487
|
-
function emitLateReply(group, member, result, roundNo) {
|
|
488
|
-
const text = String(result.text ?? '').trim() || '[未回应]';
|
|
489
|
-
emitLine(group.id, {
|
|
490
|
-
at: now(),
|
|
491
|
-
role: 'member',
|
|
492
|
-
memberId: member.id,
|
|
493
|
-
name: member.name,
|
|
494
|
-
text,
|
|
495
|
-
usage: result.usage ?? null,
|
|
496
|
-
steps: result.steps ?? 0,
|
|
497
|
-
stepUsages: Array.isArray(result.stepUsages) && result.stepUsages.length > 0 ? result.stepUsages : undefined,
|
|
498
|
-
late: true,
|
|
499
|
-
round: roundNo,
|
|
500
|
-
});
|
|
501
|
-
log?.(`[groups] ${member.name} 的迟到回复已补发(round=${roundNo})`);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/** 预算耗尽后迟到的明确失败:只有明确失败才报错(中性"仍在执行"不报失败)。 */
|
|
505
|
-
function emitLateFailure(group, member, failed, roundNo) {
|
|
506
|
-
emitLine(group.id, {
|
|
507
|
-
at: now(), role: 'system', name: member.name,
|
|
508
|
-
text: `${member.name} 回应失败:${failed}`,
|
|
509
|
-
late: true,
|
|
510
|
-
round: roundNo,
|
|
511
|
-
});
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
function emitReplyLine(group, round, member, result) {
|
|
515
|
-
const trimmed = String(result.text ?? '').trim();
|
|
516
|
-
/*
|
|
517
|
-
* 状态口径分离(2026-11-01):预算耗尽 → 中性「(仍在执行…)」,**不是**未回应;
|
|
518
|
-
* 「[未回应]」只留给"正常结束但整轮没有正文"(纯工具调用)这种诚实兜底。
|
|
519
|
-
*/
|
|
520
|
-
const text = trimmed || (result.stillRunning ? '(仍在执行,结果稍后补上)' : '[未回应]');
|
|
521
|
-
emitLine(group.id, {
|
|
522
|
-
at: now(),
|
|
523
|
-
role: 'member',
|
|
524
|
-
memberId: member.id,
|
|
525
|
-
name: member.name,
|
|
526
|
-
text,
|
|
527
|
-
usage: result.usage ?? null,
|
|
528
|
-
steps: result.steps ?? 0,
|
|
529
|
-
// 只有真的收到过 usage 才写这个字段;否则 JSON 里不出现(= 老格式,读侧回落)
|
|
530
|
-
stepUsages: Array.isArray(result.stepUsages) && result.stepUsages.length > 0
|
|
531
|
-
? result.stepUsages
|
|
532
|
-
: undefined,
|
|
533
|
-
// 中性"仍在执行"标记(老 app 忽略未知字段;新读侧/排查可据此区分)
|
|
534
|
-
stillRunning: result.stillRunning === true ? true : undefined,
|
|
535
|
-
round: round?.rounds ?? 0,
|
|
536
|
-
});
|
|
537
|
-
round.totalTurns += 1;
|
|
538
|
-
return text;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
183
|
/**
|
|
542
184
|
* 用户直接 @ 成员:被点名的成员直接回答,不经主管(用户定稿:直接 @ 生效)。
|
|
543
185
|
* 不算「分发→反馈」轮次,也不触发任何接力。
|
|
@@ -776,22 +418,5 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast,
|
|
|
776
418
|
}
|
|
777
419
|
}
|
|
778
420
|
|
|
779
|
-
/**
|
|
780
|
-
* 打开群聊页时拉房间历史。
|
|
781
|
-
*
|
|
782
|
-
* 【读侧口径(2026-11-01)】
|
|
783
|
-
* 带 `stepUsages` 的新记录:用量由逐步数据**当场求和**再下发 —— 刷新/重进拿到的
|
|
784
|
-
* 值和实时那一刻完全同源(这份和也是 `usage` 字段的来源,二者恒等)。
|
|
785
|
-
* 没有 `stepUsages` 的旧记录:**原样返回**,显示当时写下的历史值。按负责人决定
|
|
786
|
-
* 历史不救 —— 这里不猜、不补 0、不拿别的字段假装重算。
|
|
787
|
-
*/
|
|
788
|
-
function roomSnapshot(group) {
|
|
789
|
-
return (state.rooms[group.id] ?? []).slice(-ROOM_LOG_MAX).map((line) => {
|
|
790
|
-
if (!Array.isArray(line?.stepUsages) || line.stepUsages.length === 0) return line;
|
|
791
|
-
const usage = aggregateStepUsages(line.stepUsages);
|
|
792
|
-
return usage ? { ...line, usage } : line;
|
|
793
|
-
});
|
|
794
|
-
}
|
|
795
|
-
|
|
796
421
|
return { handleUserMessage, roomSnapshot, ensureGroupSession };
|
|
797
422
|
}
|