jbc-dssi 1.0.2 → 1.1.1

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jbc-dssi",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "description": "鲸编程 - 不只是手机遥控操作电脑,而是将电脑级的Agent能力装进手机",
6
6
  "keywords": [
@@ -1,41 +1,42 @@
1
1
  /**
2
- * groups/engine.js —— 群聊轮次引擎(按鲸编程群规实现)。
2
+ * groups/bridge/engine.js —— 群聊任务调度引擎(2026-10-31 新群规)。
3
3
  *
4
- * 群规(用户定稿):
5
- * - 用户 @谁 → 只有该成员搭话;用户无 @ → 所有成员各接一轮;
6
- * - 成员发言后,除非明确点名谁来接话,否则其他人不接话;
7
- * - 群主管发言 → 其他所有成员必须回答一轮(次序由主管按语义决定),只答一轮;
8
- * - 一律禁止第二轮、第三轮无休止接话(防刷屏失控)。
4
+ * 新模型(用户定稿,替换旧的「@ 接话 / 主管触发全员」规则):
5
+ * - **群主管是唯一入口**:用户不直接 @ 具体成员时,整条指令只投给主管;
6
+ * 主管负责意图分析 → 拆解 → 精准分发(禁止全员广播)→ 收反馈 → 决策。
7
+ * - **用户可以直接 @ 成员**:被点名的成员直接回答,不经主管。
8
+ * - **成员只在被分派时发言**:反馈给主管,不互相接话、不点名、不替他人发言。
9
+ * - **轮次**:每「分发任务 → 接收反馈」算 1 轮;1~4 轮内可提前结束;
10
+ * 第 5 轮(`MAX_ROUNDS`)反馈收齐后**强制熔断**,只允许汇报,不允许第 6 轮。
9
11
  *
10
- * 实现:一次投递一个成员(串行)。用户消息进群 → 按 @/全员算出首轮目标 →
12
+ * 谁守什么:轮次、熔断、分发合法性(禁止 @all / 不能分给自己)由本文件**硬控**;
13
+ * 拆解质量、汇报措辞靠 prompt(模型不可靠,红线必须落在代码里)。
14
+ *
15
+ * 实现:一次投递一个成员(串行)。用户消息进群 → 判定直投 or 交主管 →
11
16
  * 逐个投递到成员的群会话(懒创建,dssAgent.createSession)→ 收集回复 →
12
- * 按群规判定接力(主管触发全员 / 点名接力),每成员每轮次最多发言 MAX_TURNS_EACH 次。
17
+ * 按主管回复里的 @ 点名分发下一批。
13
18
  *
14
- * 不依赖 DSH 内部实现:会话操作全部走 ctx.sessionController 的公开方法;
15
- * 单测用假的 sessionController / dssAgent 驱动,不需要真实模型。
19
+ * 不依赖 DSH 内部实现:会话操作全部走 ctx.sessionController 的公开方法。
16
20
  */
17
21
 
18
22
  import { randomUUID } from 'node:crypto';
19
23
  import { mentionsIn } from './mentions.js';
20
- import { buildTurnPrompt } from './prompt.js';
24
+ import { buildManagerPrompt, buildMemberPrompt } from './prompt.js';
21
25
 
22
26
  /** 房间日志最多保留条数(超出丢最旧,防无限膨胀)。 */
23
27
  const ROOM_LOG_MAX = 60;
24
28
  /** 单个成员一轮回复的等待上限。 */
25
29
  const TURN_TIMEOUT_MS = 240_000;
26
30
  /**
27
- * 每个成员在**一次用户提问**里最多发言次数。
28
- *
29
- * 曾经是 2:主管发言触发全员、成员回复里又按群规 @ 点名,两者叠加会把已经答过的
30
- * 成员再拉回来答第二轮 —— 真机实测「一次提问,三个人各答两遍」(room 日志 6 条
31
- * 成员消息,21:26:44 → 21:26:56)。用户要的是「每人一轮」,所以收紧到 1。
32
- *
33
- * 收紧后接力语义不变:点名只对**本轮还没说过话**的成员生效 —— 用户 @A,A 答完
34
- * @B,B 仍会答(B 还没说);但 A→B→A 这种回环到第二个人就停。
31
+ * 轮次上限(最高红线,用户定稿):
32
+ * 「分发任务 → 接收反馈」算 1 轮;1~4 轮内可提前结束;第 5 轮收齐反馈后必须强制汇报,
33
+ * 不允许第 6 轮。engine 硬控,不依赖模型自觉。
35
34
  */
36
- const MAX_TURNS_EACH = 1;
37
- /** 每轮次总步数保险(防失控的最后一道闸)。 */
38
- const MAX_ROUND_STEPS = 16;
35
+ const MAX_ROUNDS = 5;
36
+ /** 主管连续不按规范分发 / 熔断后仍要分发时的容忍次数(超过直接终止)。 */
37
+ const MAX_REFUSALS = 2;
38
+ /** 群上下文随投递带给模型的最大条数(与 prompt.js 的截断口径一致)。 */
39
+ const RECENT_LINES_MAX = 8;
39
40
 
40
41
  /** 从 assistant/message 事件里取正文(与 DSH 会话事件形状对齐)。 */
41
42
  function assistantText(event) {
@@ -54,9 +55,6 @@ function assistantText(event) {
54
55
  *
55
56
  * 老版本 DSH 不发 `usage` → 返回 null,手机侧不显示那一项(不显示比显示 0 诚实)。
56
57
  * 全 0 也按"没有"处理:说明这轮根本没统计到,画一个 0 tok 会误导。
57
- *
58
- * @param {object|undefined} usage
59
- * @returns {{inputTokens:number,outputTokens:number,cacheReadTokens:number,cacheWriteTokens:number,totalTokens:number}|null}
60
58
  */
61
59
  function normalizeUsage(usage) {
62
60
  if (!usage || typeof usage !== 'object') return null;
@@ -68,7 +66,37 @@ function normalizeUsage(usage) {
68
66
  cacheWriteTokens: num(usage.cacheWriteTokens),
69
67
  totalTokens: num(usage.totalTokens),
70
68
  };
71
- return out.totalTokens > 0 || out.inputTokens > 0 || out.outputTokens > 0 ? out : null;
69
+ const any = out.totalTokens > 0 || out.inputTokens > 0 || out.outputTokens > 0
70
+ || out.cacheReadTokens > 0 || out.cacheWriteTokens > 0;
71
+ return any ? out : null;
72
+ }
73
+
74
+ /**
75
+ * 把一次模型调用的 usage 累加进本轮 —— **2026-10-31 修的就是这里**。
76
+ *
77
+ * 【为什么必须累加】
78
+ * 一个 turn 会被拆成多个 step,**每一步都发一条 assistant/message、各带这一步的
79
+ * usage**。旧实现 `lastUsage = usage` 只留最后一条,于是面板出现自相矛盾的数字:
80
+ * 「共 10 步」却只有 60,686 tok —— 而第 10 步单独就 60,686(59,008+240+1,438),
81
+ * 前 9 步全丢。真机那一轮(2026-10-31 21:26:57~21:27:45,群会话 session-66663e11)
82
+ * 逐步核对:10 步合计 539,775 tok(缓存命中 521,728 / 未命中 9,159 / 输出 8,888),
83
+ * 谷价 ≈¥0.0551;旧口径只显示 60,686 tok / ≈¥0.0072,少算约 9 步。
84
+ *
85
+ * 【口径】与单聊(`android/.../data/Transcript.kt` 的 `addUsage`)和 Web UI 的
86
+ * `deriveTurnTokenUsage` 完全一致:每条 assistant/message 的 usage 各自相加;
87
+ * totalTokens 也逐条相加,**不**用 input+output 重算(会与 Web 差出数字)。
88
+ * 同一 step 的重试会产生多条 assistant/message:用量按 attempt 累加(与单聊一致),
89
+ * 步数则由调用方按 step 号去重。
90
+ */
91
+ function sumUsage(acc, u) {
92
+ if (!acc) return u;
93
+ return {
94
+ inputTokens: acc.inputTokens + u.inputTokens,
95
+ outputTokens: acc.outputTokens + u.outputTokens,
96
+ cacheReadTokens: acc.cacheReadTokens + u.cacheReadTokens,
97
+ cacheWriteTokens: acc.cacheWriteTokens + u.cacheWriteTokens,
98
+ totalTokens: acc.totalTokens + u.totalTokens,
99
+ };
72
100
  }
73
101
 
74
102
  /**
@@ -81,7 +109,7 @@ function normalizeUsage(usage) {
81
109
  * @param {Function} opts.broadcast 群事件广播
82
110
  */
83
111
  export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }) {
84
- /** 当前活跃轮次(同一时刻只跑一个群的一轮,防止消息乱序)。 */
112
+ /** 当前活跃任务(同一时刻只跑一个群的一轮,防止消息乱序)。 */
85
113
  let activeRound = null;
86
114
 
87
115
  const now = () => Date.now();
@@ -123,11 +151,16 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
123
151
  return sessionId;
124
152
  }
125
153
 
154
+ /** 最近群消息(含刚落的用户行/成员行),给 prompt 做上下文。 */
155
+ function recentLinesOf(group) {
156
+ return (state.rooms[group.id] ?? []).slice(-RECENT_LINES_MAX);
157
+ }
158
+
126
159
  /**
127
160
  * 投递一轮给一个成员,等回复完成后 settle。
128
- * @returns {Promise<{text?:string, failed?:string, missed?:boolean}>}
161
+ * @returns {Promise<{text?:string, failed?:string, missed?:boolean, usage?:object|null, steps?:number}>}
129
162
  */
130
- function deliver(group, members, member, message, trigger, recentLines, attachments = {}) {
163
+ function deliver(group, member, prompt, attachments = {}) {
131
164
  return new Promise((resolve) => {
132
165
  let settled = false;
133
166
  let followClosed = false;
@@ -151,14 +184,19 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
151
184
  let prompted = false;
152
185
  let minTurn = null;
153
186
  let lastText = '';
154
- let lastUsage = null;
187
+ /*
188
+ * 本轮用量 = 各 step 的 usage **累加**(见 [sumUsage])。
189
+ *
190
+ * 2026-10-31 之前这里是 `lastUsage`(只留最后一条 assistant/message),
191
+ * 于是「共 N 步」配的是"最后一步"的 token,步数越多的轮次少算越多。
192
+ */
193
+ let turnUsage = null;
155
194
  /*
156
195
  * 「共 N 步」的账:turn → 这一轮出现过的 step 号集合。
157
196
  *
158
197
  * 与单聊**同一个算法**(ChatScreen 的 `turnStepNums`):assistant/message
159
198
  * 每个 step 发一条,收集本轮的 step 号去重后求个数;重试时同一个 step 可能
160
- * 重复到达,用集合才不会多算。手机侧拿到这个整数,直接喂给同一个用量浮层,
161
- * 不在手机侧另造一套统计。
199
+ * 重复到达,用集合才不会多算。
162
200
  */
163
201
  const turnStepNums = new Map();
164
202
  let lastTurn = -1;
@@ -178,15 +216,6 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
178
216
  return;
179
217
  }
180
218
 
181
- const prompt = buildTurnPrompt({
182
- group,
183
- members,
184
- selfId: member.id,
185
- recentLines,
186
- message,
187
- trigger,
188
- });
189
-
190
219
  const ac = new AbortController();
191
220
  const stream = ctx.sessionController.follow(
192
221
  { address: { kind: 'session', sessionId }, assistantStream: true, maxMessages: 1 },
@@ -233,7 +262,7 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
233
262
  const text = assistantText(event);
234
263
  if (text) lastText = text;
235
264
  const usage = normalizeUsage(data.usage);
236
- if (usage) lastUsage = usage;
265
+ if (usage) turnUsage = sumUsage(turnUsage, usage);
237
266
  /*
238
267
  * 步数:**所有** assistant/message 都要记,包括只调工具、不画块的那几步
239
268
  * (与单聊一致 —— token 大头正是这些步骤花掉的)。
@@ -250,12 +279,11 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
250
279
  } else {
251
280
  /*
252
281
  * 本轮步数:取 turn/end 指向的那一轮(拿不到就退回最后见过的一轮)。
253
- * 0 = 没统计到(老版本插件 / 事件里没带 step)→ 手机侧整段不显示,
254
- * 与单聊「steps == 0 不写共 0 步」同一语义。
282
+ * 0 = 没统计到(老版本插件 / 事件里没带 step)→ 手机侧整段不显示。
255
283
  */
256
284
  const endTurn = typeof data.turn === 'number' ? data.turn : lastTurn;
257
285
  const steps = turnStepNums.get(endTurn)?.size ?? 0;
258
- settle({ text: lastText, usage: lastUsage, steps });
286
+ settle({ text: lastText, usage: turnUsage, steps });
259
287
  }
260
288
  ac.abort();
261
289
  } else if (event?.type === 'turn/error' || event?.type === 'turn/failed') {
@@ -272,14 +300,10 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
272
300
  try {
273
301
  await new Promise((resolveNext) => setTimeout(resolveNext, 10));
274
302
  /*
275
- * 投递内容 =「群上下文 + 这条消息」的文字,后面按顺序挂上附件 part。
303
+ * 投递内容 = prompt 文字,后面按顺序挂上附件 part。
276
304
  *
277
305
  * 形状与单聊 PROMPT 完全一致:{type:'image', mediaType, data} /
278
- * {type:'file', receiptId}。这样成员用的就是同一套模型能力 ——
279
- * 图能直接看,文件由会话自己解析,不需要我们另造通路。
280
- *
281
- * 图片 base64 是手机**只上传一次**、这里在每个成员的 prompt 里各带一份:
282
- * 走的是本机内存,不额外过网络;群成员上限很小,代价可接受。
306
+ * {type:'file', receiptId}。这样成员用的就是同一套模型能力。
283
307
  */
284
308
  const content = [{ type: 'text', text: prompt }];
285
309
  for (const image of (attachments?.images ?? [])) {
@@ -304,15 +328,7 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
304
328
  // 请求已进队列:此后到达的事件才属于本轮(之前的都是历史回放)。
305
329
  prompted = true;
306
330
  timeoutTimer = setTimeout(() => {
307
- if (!settled) {
308
- emitLine(group.id, {
309
- at: now(),
310
- role: 'system',
311
- name: member.name,
312
- text: `${member.name} 这一轮迟迟没有回应。`,
313
- });
314
- settle({ missed: true });
315
- }
331
+ if (!settled) settle({ missed: true });
316
332
  }, TURN_TIMEOUT_MS);
317
333
  } catch (err) {
318
334
  if (!settled) {
@@ -326,88 +342,167 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
326
342
  });
327
343
  }
328
344
 
329
- /** 把一条消息投给"目标成员序列",按群规判定接力,直到队列空或到达上限。 */
330
- async function runQueue(group, members, targets, opener, openerText, trigger, attachments = {}) {
331
- const round = {
332
- groupId: group.id,
333
- turns: new Map(),
334
- totalTurns: 0,
335
- };
336
- activeRound = round;
337
- const queue = [];
338
- // 防重复入队:同一成员同一时刻只在队列里排一次
339
- // (主管触发全员时,原始目标里的 b/c 与"全员接力"里的 b/c 不能各排一次)。
340
- const queued = new Set();
341
- const enqueue = (member) => {
342
- if (queued.has(member.id)) return;
343
- queued.add(member.id);
344
- queue.push(member);
345
- };
346
- for (const m of targets) enqueue(m);
345
+ /** 把一次投递的结果落成房间行(成员/主管同一套:手机侧按 memberId 显示名字与主管标)。 */
346
+ function emitReplyLine(group, round, member, result) {
347
+ const text = String(result.text ?? '').trim() || '[未回应]';
348
+ emitLine(group.id, {
349
+ at: now(),
350
+ role: 'member',
351
+ memberId: member.id,
352
+ name: member.name,
353
+ text,
354
+ usage: result.usage ?? null,
355
+ steps: result.steps ?? 0,
356
+ round: round?.rounds ?? 0,
357
+ });
358
+ round.totalTurns += 1;
359
+ return text;
360
+ }
347
361
 
348
- try {
349
- while (queue.length > 0 && round.totalTurns < MAX_ROUND_STEPS) {
350
- if (activeRound !== round) break;
351
- const member = queue.shift();
352
- queued.delete(member.id);
353
- const used = round.turns.get(member.id) ?? 0;
354
- if (used >= MAX_TURNS_EACH) continue;
362
+ /**
363
+ * 用户直接 @ 成员:被点名的成员直接回答,不经主管(用户定稿:直接 @ 生效)。
364
+ * 不算「分发→反馈」轮次,也不触发任何接力。
365
+ */
366
+ async function runDirect(group, round, targets, members, text, attachments) {
367
+ for (const member of targets) {
368
+ const prompt = buildMemberPrompt({
369
+ group,
370
+ members,
371
+ selfId: member.id,
372
+ recentLines: recentLinesOf(group),
373
+ task: text,
374
+ mode: 'user-direct',
375
+ });
376
+ const result = await deliver(group, member, prompt, attachments);
377
+ if (result.failed) {
378
+ emitLine(group.id, {
379
+ at: now(), role: 'system', name: member.name,
380
+ text: `${member.name} 回应失败:${result.failed}`,
381
+ });
382
+ continue;
383
+ }
384
+ emitReplyLine(group, round, member, result);
385
+ }
386
+ }
355
387
 
356
- round.turns.set(member.id, used + 1);
357
- round.totalTurns += 1;
388
+ /**
389
+ * 主管统筹:分发 → 反馈 → 决策,直到主管输出汇报或第 MAX_ROUNDS 轮熔断。
390
+ */
391
+ async function runCoordinated(group, round, manager, members, userMessage, attachments) {
392
+ /** 已完成的「分发→反馈」轮次。 */
393
+ let roundIndex = 0;
394
+ /** 上一轮主管分发后收集到的成员反馈。 */
395
+ let feedbacks = [];
396
+ /** 是否进入强制汇报阶段(第 MAX_ROUNDS 轮反馈收齐后)。 */
397
+ let forcedReport = false;
398
+ /** 主管连续不按规范动作的次数(防死循环)。 */
399
+ let refusals = 0;
358
400
 
359
- const recentLines = (state.rooms[group.id] ?? []).slice(-8);
360
- const result = await deliver(group, members, member, openerText, trigger, recentLines, attachments);
401
+ while (true) {
402
+ const prompt = buildManagerPrompt({
403
+ group,
404
+ members,
405
+ selfId: manager.id,
406
+ recentLines: recentLinesOf(group),
407
+ userMessage,
408
+ roundIndex,
409
+ feedbacks,
410
+ maxRounds: MAX_ROUNDS,
411
+ forcedReport,
412
+ });
413
+ // 用户附件只在第一轮(计划阶段)带给主管;后续复查阶段不再重复投递。
414
+ const managerAttachments = roundIndex === 0 && feedbacks.length === 0 ? attachments : {};
415
+ const result = await deliver(group, manager, prompt, managerAttachments);
416
+ if (result.failed) {
417
+ emitLine(group.id, {
418
+ at: now(), role: 'system', name: manager.name,
419
+ text: `群主管回应失败:${result.failed}`,
420
+ });
421
+ return;
422
+ }
423
+ emitReplyLine(group, round, manager, result);
424
+ const managerText = String(result.text ?? '').trim();
361
425
 
362
- if (result.failed) {
363
- emitLine(group.id, {
364
- at: now(), role: 'system', name: member.name,
365
- text: `${member.name} 回应失败:${result.failed}`,
366
- });
367
- continue;
426
+ /* ── 解析主管这一句:是「分发」还是「汇报」 ── */
427
+ const { all, found } = mentionsIn(managerText, members);
428
+ const targets = found.filter((m) => m.id !== manager.id);
429
+ const badDispatch = all || (found.length > 0 && targets.length === 0);
430
+
431
+ if (targets.length === 0) {
432
+ if (!badDispatch) {
433
+ // 没有点名任何人 = 主管在汇报 → 结束本轮任务
434
+ return;
368
435
  }
369
436
  /*
370
- * 空回复兜底:`result.text ?? '[未回应]'` 挡不住空串 —— 成员只输出思考、
371
- * 或本次没吐正文时 text === '',会落一条**空气泡**(成员名下面什么都没有),
372
- * 用户看到的就是「这个人没说话,下一个就来了」。空串一律按未回应处理。
437
+ * 非法分发:@全体(禁止全员广播)或只 @ 自己(主管不是执行者)。
438
+ * 不降级成"悄悄忽略"——明确回一条系统提示并让主管重来,否则用户会以为
439
+ * 任务已经分发下去了。
373
440
  */
374
- const text = String(result.text ?? '').trim() || '[未回应]';
441
+ refusals += 1;
375
442
  emitLine(group.id, {
376
- at: now(), role: 'member', memberId: member.id, name: member.name, text,
377
- // 本轮用量(可能为 null:老版本 DSH 不发 / 这一轮没统计到)
378
- usage: result.usage ?? null,
379
- // 本轮步数:用量浮层里「共 N 步」用;0 = 没统计到,手机侧不显示
380
- steps: result.steps ?? 0,
443
+ at: now(), role: 'system', name: '',
444
+ text: '群主管的分发不符合规范(禁止全员广播、不能把任务分给自己)。请求重新分发。',
381
445
  });
446
+ if (refusals >= MAX_REFUSALS) {
447
+ emitLine(group.id, {
448
+ at: now(), role: 'system', name: '',
449
+ text: '群主管连续未按规范分发,本轮任务已终止。',
450
+ });
451
+ return;
452
+ }
453
+ forcedReport = true;
454
+ continue;
455
+ }
382
456
 
383
- /*
384
- * 接力判定(群规):
385
- * 1) 群主管发言 → 其他所有成员必须回答一轮(未点名按成员顺序,点名按点名顺序);
386
- * 2) 非主管成员发言 → 只有明确点名时才接力给被点名者;
387
- * 3) 每成员每轮次最多 MAX_TURNS_EACH 次。
388
- */
389
- const { found } = mentionsIn(text, members);
390
- if (member.id === group.managerId) {
391
- const others = found.length > 0
392
- ? found
393
- : members.filter((m) => m.id !== group.managerId);
394
- const next = others.filter((m) => (round.turns.get(m.id) ?? 0) < MAX_TURNS_EACH);
395
- if (next.length > 0) {
396
- for (const m of next) enqueue(m);
397
- trigger = 'manager';
398
- }
399
- } else if (found.length > 0) {
400
- const next = found.filter((m) => (round.turns.get(m.id) ?? 0) < MAX_TURNS_EACH);
401
- if (next.length > 0) {
402
- for (const m of next) enqueue(m);
403
- trigger = 'member';
404
- }
457
+ /* ── 到了第 MAX_ROUNDS 轮:只允许汇报,不允许再分发 ── */
458
+ if (roundIndex >= MAX_ROUNDS) {
459
+ round.fused = true;
460
+ refusals += 1;
461
+ emitLine(group.id, {
462
+ at: now(), role: 'system', name: '',
463
+ text: `已到第 ${MAX_ROUNDS} 轮,禁止继续分发,必须立即总结汇报。`,
464
+ });
465
+ if (refusals >= MAX_REFUSALS) {
466
+ emitLine(group.id, {
467
+ at: now(), role: 'system', name: '',
468
+ text: `已强制熔断(第 ${MAX_ROUNDS} 轮上限),任务终止。`,
469
+ });
470
+ return;
405
471
  }
472
+ forcedReport = true;
473
+ continue;
406
474
  }
407
- } finally {
408
- if (activeRound === round) activeRound = null;
475
+
476
+ /* ── 合法分发:进入下一轮,逐个投递给被点名的成员 ── */
477
+ roundIndex += 1;
478
+ round.rounds = roundIndex;
479
+ feedbacks = [];
480
+ for (const target of targets) {
481
+ const memberPrompt = buildMemberPrompt({
482
+ group,
483
+ members,
484
+ selfId: target.id,
485
+ recentLines: recentLinesOf(group),
486
+ task: managerText,
487
+ mode: 'manager-task',
488
+ });
489
+ const memberResult = await deliver(group, target, memberPrompt, attachments);
490
+ if (memberResult.failed) {
491
+ emitLine(group.id, {
492
+ at: now(), role: 'system', name: target.name,
493
+ text: `${target.name} 回应失败:${memberResult.failed}`,
494
+ });
495
+ // 失败也要计为本轮反馈,否则轮次会卡死、主管永远等不到人
496
+ round.totalTurns += 1;
497
+ feedbacks.push({ name: target.name, text: String(memberResult.failed), failed: true });
498
+ continue;
499
+ }
500
+ const text = emitReplyLine(group, round, target, memberResult);
501
+ feedbacks.push({ name: target.name, text, missed: memberResult.missed === true });
502
+ }
503
+ // 第 MAX_ROUNDS 轮反馈收齐 → 下一句主管只能汇报
504
+ forcedReport = roundIndex >= MAX_ROUNDS;
409
505
  }
410
- broadcast?.({ type: 'round_end', groupId: group.id, totalTurns: round.totalTurns });
411
506
  }
412
507
 
413
508
  /**
@@ -415,6 +510,7 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
415
510
  * @param {object} group 群实体
416
511
  * @param {{id:string,name:string}[]} members 群成员(含主管)
417
512
  * @param {string} text 用户消息
513
+ * @param {{images?:object[],files?:object[]}} attachments 附件(与单聊同形)
418
514
  */
419
515
  async function handleUserMessage(group, members, text, attachments = {}) {
420
516
  const images = Array.isArray(attachments?.images) ? attachments.images : [];
@@ -428,13 +524,15 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
428
524
  ].filter(Boolean).join('、');
429
525
  const openerText = attachNote ? `${body}\n(本条消息附带:${attachNote})` : body;
430
526
 
527
+ const manager = members.find((m) => m.id === group.managerId) ?? null;
528
+
431
529
  if (activeRound !== null) {
432
530
  /*
433
531
  * 上一轮还没跑完:提示一句就返回。
434
532
  *
435
533
  * ★ 这条过去是「只广播、不落盘」—— 于是它带的 seq 是临时算的,很容易和
436
534
  * 房间里最后一条撞上;手机侧 LazyColumn 拿 seq 当 key,重复 key 直接崩。
437
- * 现在统一走 emitLine:seq 由它分配、同时落盘,和其余行同一套语义。
535
+ * 现在统一走 emitLine:seq 由它分配、同时落盘。
438
536
  */
439
537
  emitLine(group.id, {
440
538
  at: now(), role: 'system',
@@ -442,10 +540,18 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
442
540
  });
443
541
  return;
444
542
  }
543
+ if (!manager) {
544
+ // 新规:群必须有主管。历史群(或数据被手工改坏)兜底:不落用户行、明确提示。
545
+ emitLine(group.id, {
546
+ at: now(), role: 'system',
547
+ name: '', text: '本群还没有主管,无法开始任务。请先在群设置里指定主管。',
548
+ });
549
+ return;
550
+ }
551
+
445
552
  /*
446
553
  * 用户行落盘:带附件元信息(手机侧据此把图/文件名画回自己的气泡)。
447
- * 只存 `{isImage, ref, name}` —— 字节在手机本地缓存里,不在房间日志里存一份
448
- * (日志要落盘、要随快照全量下发,塞 base64 会把群历史撑爆)。
554
+ * 只存 `{isImage, ref, name}` —— 字节在手机本地缓存里,不在房间日志里存一份。
449
555
  */
450
556
  const attachmentMeta = [
451
557
  ...images.map((i) => ({ isImage: true, ref: typeof i?.ref === 'string' ? i.ref : '' })),
@@ -461,11 +567,31 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
461
567
  attachments: attachmentMeta,
462
568
  });
463
569
 
464
- const { all, found } = mentionsIn(text ?? '', members);
465
- const targets = all || (found.length === 0)
466
- ? members
467
- : found;
468
- await runQueue(group, members, targets, openerText, openerText, 'user', { images, files });
570
+ const round = { groupId: group.id, rounds: 0, totalTurns: 0, fused: false };
571
+ activeRound = round;
572
+ try {
573
+ /*
574
+ * 路由(用户定稿):
575
+ * - 用户直接 @ 了具体成员 → 只投给被 @ 的成员(绕过主管);
576
+ * - 其余情况(没 @ / 只 @ 主管 / @all)→ 整条指令交给主管统筹。
577
+ */
578
+ const { found } = mentionsIn(text ?? '', members);
579
+ const direct = found.filter((m) => m.id !== manager.id);
580
+ if (direct.length > 0) {
581
+ await runDirect(group, round, direct, members, openerText, { images, files });
582
+ } else {
583
+ await runCoordinated(group, round, manager, members, openerText, { images, files });
584
+ }
585
+ } finally {
586
+ if (activeRound === round) activeRound = null;
587
+ broadcast?.({
588
+ type: 'round_end',
589
+ groupId: group.id,
590
+ totalTurns: round.totalTurns,
591
+ rounds: round.rounds,
592
+ fused: round.fused === true,
593
+ });
594
+ }
469
595
  }
470
596
 
471
597
  /** 打开群聊页时拉房间历史。 */
@@ -139,7 +139,7 @@ export function createGroupsEngine({
139
139
 
140
140
  /* ── 命令(GROUPS_CMD op) ───────────────────────────────────── */
141
141
 
142
- /** 创建群:{ name, memberIds, managerId? } */
142
+ /** 创建群:{ name, memberIds, managerId }(2026-10-31 新规:**必须指定主管**)。 */
143
143
  async function createGroup(payload) {
144
144
  await ensure();
145
145
  const name = String(payload?.name ?? '').trim();
@@ -147,12 +147,14 @@ export function createGroupsEngine({
147
147
  if (!name) throw new Error('群名称不能为空');
148
148
  if (memberIds.length < 2) throw new Error('至少拉 2 个智能体进群');
149
149
  const managerId = String(payload?.managerId ?? '');
150
- if (managerId && !memberIds.includes(managerId)) throw new Error('群主管必须是群成员');
150
+ // 新群规:主管是群的唯一入口与调度中枢,没有主管群无法运转 —— 建群时必须指定。
151
+ if (!managerId) throw new Error('建群必须指定群主管');
152
+ if (!memberIds.includes(managerId)) throw new Error('群主管必须是群成员');
151
153
  const at = Date.now();
152
154
  const group = {
153
155
  id: randomUUID(),
154
156
  name,
155
- managerId: managerId || null,
157
+ managerId,
156
158
  memberIds,
157
159
  memberNames: {},
158
160
  createdAt: at,
@@ -195,14 +197,15 @@ export function createGroupsEngine({
195
197
  return { id, deleted: true };
196
198
  }
197
199
 
198
- /** 设置/更换群主管:{ id, managerId }(传空串 = 取消主管)。 */
200
+ /** 设置/更换群主管:{ id, managerId }(新规:**不允许取消**,只能换人)。 */
199
201
  async function setManager(payload) {
200
202
  await ensure();
201
203
  const group = findGroup(String(payload?.id ?? ''));
202
204
  if (!group) throw new Error('群不存在');
203
205
  const managerId = String(payload?.managerId ?? '');
204
- if (managerId && !group.memberIds.includes(managerId)) throw new Error('群主管必须是群成员');
205
- group.managerId = managerId || null;
206
+ if (!managerId) throw new Error('群必须有主管,不能取消;只能更换主管');
207
+ if (!group.memberIds.includes(managerId)) throw new Error('群主管必须是群成员');
208
+ group.managerId = managerId;
206
209
  group.updatedAt = Date.now();
207
210
  await save();
208
211
  notifyListChanged();
@@ -226,16 +229,19 @@ export function createGroupsEngine({
226
229
  return publicGroup(group);
227
230
  }
228
231
 
229
- /** 移出群:{ id, memberIds }(主管被移出时同时取消主管身份)。 */
232
+ /** 移出群:{ id, memberIds }(新规:**主管不能被移出**,必须先换主管)。 */
230
233
  async function removeMembers(payload) {
231
234
  await ensure();
232
235
  const group = findGroup(String(payload?.id ?? ''));
233
236
  if (!group) throw new Error('群不存在');
234
237
  const removeIds = Array.isArray(payload?.memberIds) ? new Set(payload.memberIds) : new Set();
238
+ if (group.managerId && removeIds.has(group.managerId)) {
239
+ throw new Error('主管不能被移出群,请先指定新的主管');
240
+ }
235
241
  group.memberIds = group.memberIds.filter((id) => !removeIds.has(id));
236
- if (group.managerId && removeIds.has(group.managerId)) group.managerId = null;
237
242
  group.updatedAt = Date.now();
238
243
  if (group.memberIds.length < 2) throw new Error('群成员不足 2 人,请解散群');
244
+ await refreshMemberNames(group);
239
245
  await save();
240
246
  notifyListChanged();
241
247
  return publicGroup(group);
@@ -246,6 +252,8 @@ export function createGroupsEngine({
246
252
  await ensure();
247
253
  const group = findGroup(String(payload?.id ?? ''));
248
254
  if (!group) throw new Error('群不存在');
255
+ // 新群规:主管是唯一入口,没有主管的群不能开始任务。
256
+ if (!group.managerId) throw new Error('本群还没有主管,请先在群设置里指定主管');
249
257
  const text = String(payload?.text ?? '').trim();
250
258
  // 附件与单聊同形:图片带 base64(mediaType/data/ref),文件带上传回执(receiptId/ref/name)
251
259
  const images = (Array.isArray(payload?.images) ? payload.images : [])
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * groups/prompt.js —— 构建投递给群成员的"群会话"消息。
3
3
  *
4
- * 成员都是 dssAgent(已创建的智能体):每条群消息以"用户身份"发进该成员的
5
- * 群会话,成员按 DSH 正常会话机制回复。这里只负责把群上下文+规则拼进 prompt,
6
- * 不碰 DSH 内部实现。
4
+ * 两种角色、两套提示词(2026-10-31 用户定稿的新群规):
5
+ * - **群主管**:唯一入口与调度中枢。接收用户指令 → 拆解 → 精准分发(禁止全员广播)
6
+ * → 收反馈 → 决策 → 1~4 轮内可停、第 5 轮强制熔断 → 三段式标准汇报。
7
+ * - **成员**:只在被分派(或用户直接 @)时发言,只做与自己相关的部分,把结果反馈给
8
+ * 主管;不互相接话、不点名、不替别人发言。
9
+ *
10
+ * 这里只管"把上下文+职责拼进 prompt",不碰 DSH 内部实现;轮次/熔断/分发合法性
11
+ * 由 engine.js 硬控(模型不可靠,红线必须由代码守)。
7
12
  */
8
13
 
9
- import { mentionsIn } from './mentions.js';
10
-
11
14
  /** 群上下文最多带多少条最近消息(控制 token,防刷屏)。 */
12
15
  const CONTEXT_LINES_MAX = 8;
13
16
  /** 单条消息最多截断多长(展示用)。 */
@@ -19,49 +22,138 @@ function lineOf(line) {
19
22
  return `${who}:${text}`;
20
23
  }
21
24
 
25
+ function memberListText(members, managerId) {
26
+ return members
27
+ .map((m) => (m.id === managerId ? `${m.name}(群主管)` : m.name))
28
+ .join('、');
29
+ }
30
+
31
+ function recentText(recentLines) {
32
+ const lines = (recentLines ?? []).slice(-CONTEXT_LINES_MAX).map(lineOf).join('\n');
33
+ return lines ? `最近群消息:\n${lines}` : '(群里还没有其他消息)';
34
+ }
35
+
22
36
  /**
37
+ * 群主管提示词。
38
+ *
23
39
  * @param {object} opts
24
40
  * @param {object} opts.group 群实体({id,name,managerId})
25
- * @param {{id:string,name:string}[]} opts.members 群成员(含自己)
26
- * @param {string} opts.selfId 投递目标的成员 id
41
+ * @param {{id:string,name:string}[]} opts.members 群成员(含主管自己)
42
+ * @param {string} opts.selfId 主管成员 id
27
43
  * @param {{role:string,name?:string,text:string}[]} opts.recentLines 最近群消息
28
- * @param {string} opts.message 当前需要回应的消息(用户/群主管/成员)
29
- * @param {string} opts.trigger 触发说明('user' | 'manager' | 'member')
44
+ * @param {string} opts.userMessage 用户原话
45
+ * @param {number} opts.roundIndex 已经完成的"分发→反馈"轮次(0 = 尚未分发)
46
+ * @param {{name:string,text:string,failed?:boolean,missed?:boolean}[]} opts.feedbacks 本轮成员反馈
47
+ * @param {number} opts.maxRounds 轮次上限(第 N 轮后必须汇报)
48
+ * @param {boolean} opts.forcedReport 是否已到强制汇报(第 N 轮反馈收齐后)
30
49
  * @returns {string}
31
50
  */
32
- export function buildTurnPrompt({ group, members, selfId, recentLines, message, trigger }) {
51
+ export function buildManagerPrompt({
52
+ group,
53
+ members,
54
+ selfId,
55
+ recentLines,
56
+ userMessage,
57
+ roundIndex,
58
+ feedbacks,
59
+ maxRounds,
60
+ forcedReport,
61
+ }) {
33
62
  const self = members.find((m) => m.id === selfId);
34
63
  const selfName = self?.name ?? '你';
35
- const lines = (recentLines ?? []).slice(-CONTEXT_LINES_MAX).map(lineOf).join('\n');
64
+ const feedbackText = (feedbacks ?? []).length
65
+ ? (feedbacks ?? [])
66
+ .map((f) => {
67
+ const tag = f.failed ? '(回应失败)' : f.missed ? '(未回应)' : '';
68
+ const text = String(f.text ?? '').replace(/\s+/g, ' ').slice(0, LINE_TEXT_MAX);
69
+ return `- ${f.name}${tag}:${text}`;
70
+ })
71
+ .join('\n')
72
+ : '(还没有成员反馈)';
36
73
 
37
- const memberList = members
38
- .map((m) => (m.id === group.managerId ? `${m.name}(群主管)` : m.name))
39
- .join('、');
74
+ const stateText = roundIndex === 0
75
+ ? '用户刚在群里发言,你**还没有开始分发**。'
76
+ : `已完成第 ${roundIndex} 轮(分发→反馈)。`;
40
77
 
41
- const triggerText =
42
- trigger === 'manager'
43
- ? '群主管刚刚发言,要求群内成员回答'
44
- : trigger === 'member'
45
- ? '群成员刚刚发言'
46
- : '你(用户)在群里发言';
78
+ const decideText = forcedReport
79
+ ? `【轮次红线】已经到第 ${maxRounds} 轮,**不允许再分发任何任务**。现在只能输出标准化汇报结束任务。`
80
+ : roundIndex === 0
81
+ ? '请先分析用户真实意图,决定是否需要成员参与:需要就按分发格式点名分派;不需要就**直接输出标准化汇报**。'
82
+ : '请决定:继续分发(用 @ 点名具体成员),或者**直接输出标准化汇报**结束任务;如果判断任务已满足用户意图,随时可以提前结束。';
47
83
 
48
84
  return [
49
- `你在工作群「${group.name}」里,群成员:${memberList}。你自己是「${selfName}」。`,
50
- '群规:',
51
- '1. 只回应这一轮,不要替其他成员发言;',
52
- '2. 如果你认为应该由某个成员接话,在回复末尾用 @成员名 点名,其他人不会自动接话;',
53
- '3. 回答要简短直接,面向群内协作。',
85
+ `你在工作群「${group.name}」里担任**群主管**。群成员:${memberListText(members, group.managerId)}。你自己是「${selfName}」。`,
86
+ '',
87
+ '【你的核心任务】',
88
+ '接收用户指令,以用户意图为唯一基准进行任务拆解与智能路由分发,监控执行进度,并严格控制交互轮次。',
89
+ '',
90
+ '【执行规则】',
91
+ '1. 意图分析与拆解:深入分析用户的真实需求,把大任务拆解为具体、可执行的子任务。如果意图模糊,必须先向用户澄清,严禁主观臆断。',
92
+ '2. 精准按需分发:根据群内各成员(智能体)的职能定位,仅把子任务分发给相关的成员。**绝对不允许全员广播**,无关成员无需回答或执行。',
93
+ '3. 进度追踪与决策:负责收集相关成员的任务反馈,评估完成情况,决定是继续安排后续任务、还是提前结束当前任务闭环。',
94
+ `4. 严格轮次控制(最高红线):每发生一次「分发任务 → 接收反馈」的交互计为 1 轮;1~${maxRounds - 1} 轮内判断已满足用户意图可随时停止并汇报;若流转到第 ${maxRounds} 轮,无论任务是否完成,必须立刻停止后续分发并向用户做阶段性汇报。`,
95
+ '5. 标准化汇报:停止或结束时的汇报必须包含三段:',
96
+ ' 【当前进度总结】',
97
+ ' 【各成员执行摘要】',
98
+ ' 【下一步建议或需用户决策的事项】',
99
+ ' 然后等待用户的新指令。',
100
+ '',
101
+ '【分发格式(硬要求)】',
102
+ '需要成员做事时,在回复里用 `@成员名` 点名,并紧跟写清**这个成员具体要做什么**;可以一次点名多个不同成员。',
103
+ '**不得点名全体(@all / @所有人),也不得点名你自己**——你不是执行者。',
104
+ '例:`@张三 请检查登录接口的边界条件,只反馈结论。`',
105
+ '',
106
+ '【行为边界】',
107
+ '· 严禁代替其他智能体回答具体业务问题(你只负责拆解、分发、汇总);',
108
+ '· 每次分发任务后,必须等待相关成员反馈,再进行下一轮动作;',
109
+ '· 除非收到明确指令,否则不得擅自终止或重置任务。',
110
+ '',
111
+ recentText(recentLines),
54
112
  '',
55
- lines ? `最近群消息:\n${lines}` : '(群里还没有其他消息)',
113
+ `【当前状态】${stateText}`,
114
+ `用户原话:「${String(userMessage ?? '')}」`,
115
+ '【本轮成员反馈】',
116
+ feedbackText,
56
117
  '',
57
- `${triggerText}:${message}`,
58
- `请回复「${selfName}」的回答:`,
118
+ decideText,
59
119
  ].join('\n');
60
120
  }
61
121
 
62
- /** 是否"明确点名谁来接话":从成员回复里解析 @(不含 @all)。 */
63
- export function handoffMentions(text, members) {
64
- const { found, all } = mentionsIn(text, members);
65
- if (all) return members.filter((m) => found.length === 0 || found.some((f) => f.id === m.id));
66
- return found;
122
+ /**
123
+ * 成员提示词(两种来源:用户直接 @ / 主管分派)。
124
+ *
125
+ * @param {object} opts
126
+ * @param {object} opts.group 群实体
127
+ * @param {{id:string,name:string}[]} opts.members 群成员
128
+ * @param {string} opts.selfId 被投递的成员 id
129
+ * @param {{role:string,name?:string,text:string}[]} opts.recentLines 最近群消息
130
+ * @param {string} opts.task 需要该成员做的事(用户原话 / 主管分派内容)
131
+ * @param {'user-direct'|'manager-task'} opts.mode 触发来源
132
+ * @returns {string}
133
+ */
134
+ export function buildMemberPrompt({ group, members, selfId, recentLines, task, mode }) {
135
+ const self = members.find((m) => m.id === selfId);
136
+ const selfName = self?.name ?? '你';
137
+ const manager = members.find((m) => m.id === group.managerId);
138
+ const source = mode === 'user-direct'
139
+ ? '用户在群里**直接点名**让你回答,不需要等群主管分派。'
140
+ : `群主管「${manager?.name ?? '主管'}」把任务分派给了你(这是你的子任务)。`;
141
+
142
+ return [
143
+ `你在工作群「${group.name}」里,你是「${selfName}」。群成员:${memberListText(members, group.managerId)}。`,
144
+ source,
145
+ '',
146
+ '【本次任务】',
147
+ String(task ?? '').trim() || '(没有具体任务,请简短回应)',
148
+ '',
149
+ '【你的职责】',
150
+ '1. 只完成与你相关的部分,不要代替其他成员发言,也不要互相 @ 点名;',
151
+ '2. 完成后给出**简短结果反馈**(结论 / 产出 / 需要决策的点),不要长篇大论;',
152
+ '3. 不要向用户提问(有问题就把问题写进反馈,由群主管汇总);',
153
+ '4. 只回应这一次,不主动接话。',
154
+ '',
155
+ recentText(recentLines),
156
+ '',
157
+ '请给出你的反馈:',
158
+ ].join('\n');
67
159
  }