@roaming-ai/dsh-group-chat 0.2.2 → 0.3.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.
Files changed (59) hide show
  1. package/README.md +3 -2
  2. package/lib/client.js +984 -296
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +524 -52
  5. package/lib/types/client/components/Bubble.d.ts +6 -3
  6. package/lib/types/client/components/ChatPanel.d.ts +1 -0
  7. package/lib/types/client/components/ConstraintList.d.ts +11 -0
  8. package/lib/types/client/components/FailCard.d.ts +9 -0
  9. package/lib/types/client/components/Fold.d.ts +19 -0
  10. package/lib/types/client/components/HoverTip.d.ts +22 -0
  11. package/lib/types/client/components/MessageFlow.d.ts +1 -0
  12. package/lib/types/client/components/MsgActions.d.ts +14 -0
  13. package/lib/types/client/hooks/useComposer.d.ts +6 -1
  14. package/lib/types/client/lib/composer-draft.d.ts +17 -0
  15. package/lib/types/core/constraints.d.ts +61 -0
  16. package/lib/types/core/errors.d.ts +54 -0
  17. package/lib/types/core/types.d.ts +22 -0
  18. package/lib/types/host/api/actions.d.ts +1 -1
  19. package/lib/types/host/engine/conversation.d.ts +5 -3
  20. package/lib/types/host/engine/fold.d.ts +17 -0
  21. package/lib/types/host/engine/index.d.ts +1 -0
  22. package/lib/types/host/engine/retitle.d.ts +5 -5
  23. package/lib/types/host/service.d.ts +1 -1
  24. package/lib/types/host/state.d.ts +2 -0
  25. package/lib/types/index.d.ts +2 -0
  26. package/package.json +1 -1
  27. package/src/client/GroupChatPanel.tsx +18 -3
  28. package/src/client/components/AsidePanel.tsx +10 -8
  29. package/src/client/components/Bubble.tsx +45 -18
  30. package/src/client/components/ChatPanel.tsx +17 -12
  31. package/src/client/components/Composer.tsx +26 -20
  32. package/src/client/components/ConstraintList.tsx +69 -0
  33. package/src/client/components/FailCard.tsx +49 -0
  34. package/src/client/components/Fold.tsx +72 -0
  35. package/src/client/components/HoverTip.tsx +134 -0
  36. package/src/client/components/MessageFlow.tsx +77 -54
  37. package/src/client/components/MsgActions.tsx +85 -0
  38. package/src/client/components/NavPanel.tsx +6 -1
  39. package/src/client/components/ThinkRow.tsx +7 -3
  40. package/src/client/components/ToolRow.tsx +7 -3
  41. package/src/client/hooks/useComposer.ts +40 -3
  42. package/src/client/hooks/useGroupChatState.ts +5 -4
  43. package/src/client/lib/composer-draft.ts +47 -0
  44. package/src/client/lib/styles.ts +73 -12
  45. package/src/client/react-dom-shim.d.ts +2 -0
  46. package/src/core/constraints.ts +210 -0
  47. package/src/core/errors.ts +184 -0
  48. package/src/core/json.ts +1 -0
  49. package/src/core/types.ts +28 -3
  50. package/src/host/api/actions.ts +35 -1
  51. package/src/host/broadcast.ts +3 -3
  52. package/src/host/engine/conversation.ts +103 -38
  53. package/src/host/engine/fold.ts +114 -0
  54. package/src/host/engine/index.ts +1 -0
  55. package/src/host/engine/retitle.ts +16 -11
  56. package/src/host/persistence/persistence.ts +18 -1
  57. package/src/host/service.ts +1 -1
  58. package/src/host/state.ts +5 -4
  59. package/src/index.ts +2 -0
package/lib/index.js CHANGED
@@ -46,6 +46,71 @@ function mountOnce(packageName, fn) {
46
46
  });
47
47
  }
48
48
  //#endregion
49
+ //#region src/core/errors.ts
50
+ const PREFIX = "模型输出异常终止: ";
51
+ const LEGACY_ROLE = /^角色「([^」]+)」发言失败[::]\s*/;
52
+ /** 剥旧系统胶囊与引擎包装前缀,保留供应商原文。 */
53
+ function unwrapSpeakFailure(raw) {
54
+ const text = String(raw || "").trim();
55
+ const legacy = parseLegacyRoleFailure(text);
56
+ const body = legacy ? legacy.rest : text;
57
+ return body.startsWith(PREFIX) ? body.slice(10).trim() : body;
58
+ }
59
+ /** 旧系统胶囊文案:角色「名」发言失败:原文。对不上则 null。 */
60
+ function parseLegacyRoleFailure(text) {
61
+ const m = LEGACY_ROLE.exec(String(text || ""));
62
+ if (!m) return null;
63
+ return {
64
+ roleName: m[1],
65
+ rest: String(text).slice(m[0].length)
66
+ };
67
+ }
68
+ /** 发言失败卡:error 标记,或旧系统胶囊文案。 */
69
+ function isSpeakFailure(m) {
70
+ return !!m.error || !!parseLegacyRoleFailure(m.text);
71
+ }
72
+ /**
73
+ * 把旧系统失败行挂到对应角色(恰好一名命中才迁)。
74
+ * 返回是否改写了记录。
75
+ */
76
+ function repairFailedMessage(m, roles) {
77
+ if (!isSpeakFailure(m)) return false;
78
+ let changed = false;
79
+ if (!m.error) {
80
+ m.error = true;
81
+ changed = true;
82
+ }
83
+ if (!m.failedRoleId && m.speaker !== "user" && m.speaker !== "system") {
84
+ m.failedRoleId = m.speaker;
85
+ changed = true;
86
+ }
87
+ if (!m.failedRoleId && m.speaker === "system") {
88
+ const parsed = parseLegacyRoleFailure(m.text);
89
+ if (parsed) {
90
+ const hits = roles.filter((r) => r.name === parsed.roleName);
91
+ if (hits.length === 1) {
92
+ const role = hits[0];
93
+ m.speaker = role.id;
94
+ m.failedRoleId = role.id;
95
+ m.text = unwrapSpeakFailure(parsed.rest);
96
+ if (!m.model && (role.provider || role.model)) m.model = (role.provider || "") + " / " + (role.model || "");
97
+ changed = true;
98
+ }
99
+ }
100
+ } else if (m.failedRoleId && m.speaker === "system") {
101
+ m.speaker = m.failedRoleId;
102
+ changed = true;
103
+ }
104
+ if (m.failedRoleId || m.speaker !== "user" && m.speaker !== "system") {
105
+ const unwrapped = unwrapSpeakFailure(m.text);
106
+ if (unwrapped !== m.text) {
107
+ m.text = unwrapped;
108
+ changed = true;
109
+ }
110
+ }
111
+ return changed;
112
+ }
113
+ //#endregion
49
114
  //#region src/core/types.ts
50
115
  /** 全部合法档位(展示顺序)。 */
51
116
  const PERMISSION_TIERS = [
@@ -67,6 +132,16 @@ function migrateTier(permissionTier, allowCommands) {
67
132
  if (tier !== void 0) return tier;
68
133
  return allowCommands === true ? "workspace_write" : "view_only";
69
134
  }
135
+ /** 全部合法约束类型。 */
136
+ const CONSTRAINT_KINDS = [
137
+ "decided",
138
+ "rejected",
139
+ "open"
140
+ ];
141
+ /** 合法 kind 原样,其余 undefined。 */
142
+ function asConstraintKind(value) {
143
+ return typeof value === "string" && CONSTRAINT_KINDS.includes(value) ? value : void 0;
144
+ }
70
145
  /** 可选数值参数安全化:数字则原样,否则 undefined。 */
71
146
  function asNumber(value) {
72
147
  return typeof value === "number" && !Number.isNaN(value) ? value : void 0;
@@ -116,7 +191,8 @@ function createHostState(ctx) {
116
191
  pendingConfirm: null,
117
192
  confirmSignal: null,
118
193
  childProc: null,
119
- finished: null
194
+ finished: null,
195
+ replaceMessageId: null
120
196
  },
121
197
  store: null,
122
198
  revision: 1,
@@ -124,12 +200,10 @@ function createHostState(ctx) {
124
200
  nid: (p) => p + "-" + core.idSeq++,
125
201
  lastCreated: null,
126
202
  newSession: (groupId, name) => {
127
- let n = 0;
128
- for (const s of core.sessions.values()) if (s.groupId === groupId) n++;
129
203
  const s = {
130
204
  id: randomUUID(),
131
205
  groupId,
132
- name: name || "会话 " + (n + 1),
206
+ name: name || "新会话",
133
207
  topic: "",
134
208
  messageIds: [],
135
209
  createdAt: Date.now()
@@ -144,7 +218,7 @@ function createHostState(ctx) {
144
218
  //#region src/host/api/actions.ts
145
219
  /**
146
220
  * 动作分发(handleAction,POST /api/group-chat/action 的载荷):
147
- * mutate(12 种 CRUD/配置操作)| send | stop | confirmCommand | models | efforts。
221
+ * mutate(12 种 CRUD/配置操作)| send | retrySpeak | stop | confirmCommand | models | efforts。
148
222
  * @module dsh-group-chat/host/api/actions
149
223
  */
150
224
  /** 创建动作分发面。 */
@@ -382,6 +456,8 @@ function createActions(core, deps) {
382
456
  };
383
457
  for (const mid of sess.messageIds) messages.delete(mid);
384
458
  sess.messageIds = [];
459
+ sess.constraints = void 0;
460
+ sess.constraintsUpToSeq = void 0;
385
461
  schedulePersist({ session: sess.id });
386
462
  touch();
387
463
  }
@@ -426,10 +502,56 @@ function createActions(core, deps) {
426
502
  run.queue = queue;
427
503
  run.stopping = false;
428
504
  run.finished = null;
505
+ run.replaceMessageId = null;
429
506
  touch();
430
507
  runLoop(sess).catch((e) => console.error("group-chat run failed", e));
431
508
  return { ok: true };
432
509
  };
510
+ /** 对失败卡原地重试:只让该角色再讲一次,成功后覆盖同一条消息。 */
511
+ const retrySpeak = (args) => {
512
+ const sess = sessions.get(String(args && args.sessionId || ""));
513
+ if (!sess) return {
514
+ ok: false,
515
+ error: "会话不存在"
516
+ };
517
+ if (run.running) return {
518
+ ok: false,
519
+ error: "已有对话进行中,请先停止"
520
+ };
521
+ const msg = messages.get(String(args && args.messageId || ""));
522
+ if (!msg || msg.sessionId !== sess.id || !isSpeakFailure(msg)) return {
523
+ ok: false,
524
+ error: "没有可重试的失败发言"
525
+ };
526
+ const g = groups.get(sess.groupId);
527
+ if (!g) return {
528
+ ok: false,
529
+ error: "群组不存在"
530
+ };
531
+ if (repairFailedMessage(msg, g.roleIds.map((id) => roles.get(id)).filter((r) => Boolean(r)))) {
532
+ schedulePersist({ session: sess.id });
533
+ touch();
534
+ }
535
+ const roleId = msg.failedRoleId || (msg.speaker !== "user" && msg.speaker !== "system" ? msg.speaker : "");
536
+ const role = roleId ? roles.get(roleId) : void 0;
537
+ if (!role || role.groupId !== sess.groupId) return {
538
+ ok: false,
539
+ error: "失败角色已不存在,无法重试"
540
+ };
541
+ if (!role.enabled) return {
542
+ ok: false,
543
+ error: "该角色已停用,无法重试"
544
+ };
545
+ run.running = true;
546
+ run.sessionId = sess.id;
547
+ run.queue = [role.id];
548
+ run.stopping = false;
549
+ run.finished = null;
550
+ run.replaceMessageId = msg.id;
551
+ touch();
552
+ runLoop(sess, { replaceMessageId: msg.id }).catch((e) => console.error("group-chat retry failed", e));
553
+ return { ok: true };
554
+ };
433
555
  const stop = (args) => {
434
556
  if (run.running && (!args || !args.sessionId || run.sessionId === args.sessionId)) {
435
557
  run.stopping = true;
@@ -527,6 +649,7 @@ function createActions(core, deps) {
527
649
  lastCreated: core.lastCreated
528
650
  };
529
651
  if (kind === "send") return send(body);
652
+ if (kind === "retrySpeak") return retrySpeak(body);
530
653
  if (kind === "stop") return stop(body);
531
654
  if (kind === "confirmCommand") return confirmCommand(body);
532
655
  if (kind === "models") return {
@@ -738,7 +861,8 @@ function createBroadcast(core) {
738
861
  partial: core.run.partial,
739
862
  partialReasoning: core.run.partialReasoning,
740
863
  pendingConfirm: core.run.pendingConfirm,
741
- finished: core.run.finished
864
+ finished: core.run.finished,
865
+ replaceMessageId: core.run.replaceMessageId
742
866
  },
743
867
  lastCreated: core.lastCreated,
744
868
  groups: [...core.groups.values()].map((g) => ({
@@ -754,6 +878,7 @@ function createBroadcast(core) {
754
878
  groupId: s.groupId,
755
879
  name: s.name,
756
880
  topic: s.topic,
881
+ ...s.constraints && s.constraints.length ? { constraints: s.constraints } : {},
757
882
  messageIds: s.messageIds.slice(),
758
883
  createdAt: s.createdAt
759
884
  })),
@@ -779,6 +904,7 @@ function createBroadcast(core) {
779
904
  reasoning: m.reasoning,
780
905
  model: m.model,
781
906
  error: m.error,
907
+ failedRoleId: m.failedRoleId,
782
908
  toolCalls: m.toolCalls,
783
909
  ts: m.ts
784
910
  }))
@@ -846,8 +972,274 @@ const TOOL_SCHEMAS = [
846
972
  }
847
973
  }
848
974
  ];
975
+ /** 折叠失败时临时原文总长上限。 */
976
+ const TEMP_MAX_CHARS = 16e3;
977
+ const KIND_LABEL = {
978
+ decided: "已定",
979
+ rejected: "否决",
980
+ open: "未决"
981
+ };
982
+ /** 已折入水位(缺省 0)。 */
983
+ function constraintsWatermark(sess) {
984
+ return typeof sess.constraintsUpToSeq === "number" && sess.constraintsUpToSeq > 0 ? sess.constraintsUpToSeq : 0;
985
+ }
986
+ /** 重试:只取失败卡之前的时间线;untilId 不在列表则原样。 */
987
+ function prefixIds(ids, untilId) {
988
+ if (!untilId) return ids;
989
+ const i = ids.indexOf(untilId);
990
+ return i >= 0 ? ids.slice(0, i) : ids;
991
+ }
992
+ /**
993
+ * 新挤出:seq > 水位 且不在最近 40 条。只扫窗口外前缀(旧→新)。
994
+ * untilId:重试时把窗口截到该消息之前,不带上后面已经发生的发言。
995
+ */
996
+ function squeezedMessages(messages, sess, untilId) {
997
+ const ids = prefixIds(sess.messageIds, untilId);
998
+ if (ids.length <= 40) return [];
999
+ const end = ids.length - 40;
1000
+ const upTo = constraintsWatermark(sess);
1001
+ const last = messages.get(ids[end - 1]);
1002
+ if (last && last.seq <= upTo) return [];
1003
+ const out = [];
1004
+ for (let i = 0; i < end; i++) {
1005
+ const m = messages.get(ids[i]);
1006
+ if (!m || m.seq <= upTo) continue;
1007
+ out.push(m);
1008
+ }
1009
+ return out;
1010
+ }
1011
+ /** 说话人展示名(折叠输入 / transcript 共用)。 */
1012
+ function speakerLabel(speaker, roleName) {
1013
+ if (speaker === "user") return "用户";
1014
+ if (speaker === "system") return "系统";
1015
+ return roleName || "成员";
1016
+ }
1017
+ /** 单条消息压成 transcript 行(正文 8k + 工具一行摘要)。 */
1018
+ function formatTranscriptLine(m, name) {
1019
+ let text = m.text || "";
1020
+ if (text.length > 8e3) text = text.slice(0, 8e3) + "…(已截断)";
1021
+ let line = "【" + name + "】" + text;
1022
+ if (Array.isArray(m.toolCalls)) for (const c of m.toolCalls) {
1023
+ if (!c || typeof c.tool !== "string") continue;
1024
+ let brief = "";
1025
+ try {
1026
+ brief = JSON.stringify(c.args) || "";
1027
+ } catch {
1028
+ brief = "";
1029
+ }
1030
+ if (brief.length > 60) brief = brief.slice(0, 60) + "…";
1031
+ const st = c.status === "ok" ? "成功" : c.status === "denied" ? "用户拒绝" : "失败";
1032
+ let ob = String(c.output || "");
1033
+ if (ob.length > 200) ob = ob.slice(0, 200) + "…";
1034
+ line += "\n [工具] " + c.tool + " " + brief + " → " + st + (ob ? "(" + ob.replace(/\s+/g, " ") + ")" : "");
1035
+ }
1036
+ return line;
1037
+ }
1038
+ /**
1039
+ * 单轮折叠消耗前缀:从最旧挤出起,最多 40 条 / 16k;系统行与失败卡计入消耗但不进模型。
1040
+ * 水位只能推到 consumed 的 max seq,剩余留待下一轮。
1041
+ */
1042
+ function takeFoldBatch(squeezed, nameOf) {
1043
+ const consumed = [];
1044
+ const lines = [];
1045
+ let hasUser = false;
1046
+ let chars = 0;
1047
+ for (const m of squeezed) {
1048
+ if (m.speaker === "system" || m.error) {
1049
+ consumed.push(m);
1050
+ if (consumed.length >= 40) break;
1051
+ continue;
1052
+ }
1053
+ const line = formatTranscriptLine(m, nameOf(m));
1054
+ const extra = line.length + (lines.length ? 2 : 0);
1055
+ if (lines.length > 0 && chars + extra > 16e3) break;
1056
+ if (m.speaker === "user") hasUser = true;
1057
+ lines.push(line);
1058
+ chars += extra;
1059
+ consumed.push(m);
1060
+ if (consumed.length >= 40) break;
1061
+ }
1062
+ return {
1063
+ consumed,
1064
+ lines,
1065
+ hasUser,
1066
+ allSystem: lines.length === 0
1067
+ };
1068
+ }
1069
+ /**
1070
+ * 未折入的挤出原文(失败缓冲):只格式化最近 20 条,再按 16k 从最旧往下丢。
1071
+ */
1072
+ function tempTranscript(squeezed, nameOf) {
1073
+ if (!squeezed.length) return "";
1074
+ const usable = squeezed.filter((m) => m.speaker !== "system" && !m.error);
1075
+ if (!usable.length) return "";
1076
+ const lines = (usable.length > 20 ? usable.slice(-20) : usable).map((m) => formatTranscriptLine(m, nameOf(m)));
1077
+ let start = 0;
1078
+ let total = lines[0] ? lines[0].length : 0;
1079
+ for (let i = 1; i < lines.length; i++) total += 2 + lines[i].length;
1080
+ while (start < lines.length - 1 && total > 16e3) {
1081
+ total -= lines[start].length + 2;
1082
+ start++;
1083
+ }
1084
+ let block = lines.slice(start).join("\n\n");
1085
+ if (block.length > 16e3) block = block.slice(0, TEMP_MAX_CHARS) + "…(已截断)";
1086
+ return block;
1087
+ }
1088
+ /** 水位 = 本批挤出的 max(seq);空批为 0。 */
1089
+ function squeezedMaxSeq(squeezed) {
1090
+ let max = 0;
1091
+ for (const m of squeezed) if (m.seq > max) max = m.seq;
1092
+ return max;
1093
+ }
1094
+ /** hydrate / 模型输出:非法 kind 丢条目;空 text 丢;条数与总长截断。 */
1095
+ function sanitizeConstraints(raw) {
1096
+ if (!Array.isArray(raw)) return [];
1097
+ const out = [];
1098
+ let total = 0;
1099
+ for (const item of raw) {
1100
+ if (out.length >= 12) break;
1101
+ if (!item || typeof item !== "object") continue;
1102
+ const kind = asConstraintKind(item.kind);
1103
+ const text = typeof item.text === "string" ? item.text.trim() : "";
1104
+ if (!kind || !text) continue;
1105
+ const clipped = text.length > 160 ? text.slice(0, 160) + "…" : text;
1106
+ const cost = KIND_LABEL[kind].length + clipped.length;
1107
+ if (total + cost > 1200) break;
1108
+ out.push({
1109
+ kind,
1110
+ text: clipped
1111
+ });
1112
+ total += cost;
1113
+ }
1114
+ return out;
1115
+ }
1116
+ /**
1117
+ * 解析折叠模型输出。null = 解析失败(水位不推);[] = 无新结论(水位推、备忘不动)。
1118
+ */
1119
+ function parseConstraints(raw) {
1120
+ const body = raw.replace(/```(?:json)?/g, "");
1121
+ const l = body.indexOf("{");
1122
+ const r = body.lastIndexOf("}");
1123
+ if (l < 0 || r <= l) return null;
1124
+ try {
1125
+ const o = JSON.parse(body.slice(l, r + 1));
1126
+ if (!Object.prototype.hasOwnProperty.call(o, "constraints")) return null;
1127
+ if (!Array.isArray(o.constraints)) return null;
1128
+ return sanitizeConstraints(o.constraints);
1129
+ } catch {
1130
+ return null;
1131
+ }
1132
+ }
1133
+ /** 本批无用户消息时,新的已定/否决降为未决。 */
1134
+ function downgradeWithoutUser(list, hasUser) {
1135
+ if (hasUser) return list;
1136
+ return list.map((c) => c.kind === "open" ? c : {
1137
+ kind: "open",
1138
+ text: c.text
1139
+ });
1140
+ }
1141
+ /** system 提示词「已确认约束」块;空则空串。 */
1142
+ function constraintBlock(list) {
1143
+ if (!list || !list.length) return "";
1144
+ return "\n# 已确认约束\n" + list.map((c) => "- " + KIND_LABEL[c.kind] + ":" + c.text).join("\n");
1145
+ }
1146
+ //#endregion
1147
+ //#region src/host/engine/fold.ts
1148
+ /** DSH 默认模型(与 retitle 同一读取面;缺位返回 null)。 */
1149
+ const defaultModel$1 = (core) => {
1150
+ try {
1151
+ const svc = core.ctx.reflect.get("agentDefaultModel");
1152
+ const sel = svc ? svc.currentSelection() : null;
1153
+ return sel && typeof sel.provider === "string" && typeof sel.model === "string" && sel.provider && sel.model ? {
1154
+ provider: sel.provider,
1155
+ model: sel.model
1156
+ } : null;
1157
+ } catch {
1158
+ return null;
1159
+ }
1160
+ };
1161
+ /**
1162
+ * 每轮 send 结束后折叠窗口外约束:fire-and-forget、不产生消息、静默失败。
1163
+ * 同会话去重;会话已清空则 abort。内存 {constraints, constraintsUpToSeq} 一次挂上。
1164
+ */
1165
+ function createFold(core, deps) {
1166
+ const { llm, messages, roles } = core;
1167
+ const { touch, schedulePersist } = deps;
1168
+ const folding = /* @__PURE__ */ new Set();
1169
+ const nameOf = (speaker) => speakerLabel(speaker, (roles.get(speaker) || { name: void 0 }).name);
1170
+ return async (sess) => {
1171
+ if (folding.has(sess.id)) return;
1172
+ const squeezed = squeezedMessages(messages, sess);
1173
+ if (!squeezed.length) return;
1174
+ const input = takeFoldBatch(squeezed, (m) => nameOf(m.speaker));
1175
+ const watermark = squeezedMaxSeq(input.consumed);
1176
+ if (watermark <= 0) return;
1177
+ const commit = (next) => {
1178
+ const live = core.sessions.get(sess.id);
1179
+ if (!live || live.messageIds.length === 0) return;
1180
+ if (next && next.length) live.constraints = next;
1181
+ live.constraintsUpToSeq = watermark;
1182
+ schedulePersist({ session: live.id });
1183
+ touch();
1184
+ };
1185
+ if (input.allSystem) {
1186
+ commit(void 0);
1187
+ return;
1188
+ }
1189
+ const dm = defaultModel$1(core);
1190
+ if (!dm) return;
1191
+ folding.add(sess.id);
1192
+ try {
1193
+ const existing = (sess.constraints || []).map((c) => "- " + KIND_LABEL[c.kind] + ":" + c.text);
1194
+ const sys = [
1195
+ "你是群聊会话的约束整理助手。把已经离开最近对话窗口的旧消息压成无主结论/约束备忘。",
1196
+ "",
1197
+ "# 规则",
1198
+ "- 只输出一行 JSON:{\"constraints\":[{\"kind\":\"decided|rejected|open\",\"text\":\"…\"}, ...]}",
1199
+ "- kind 只能是 decided(已定)/ rejected(否决)/ open(未决)",
1200
+ "- 无主:条目不写说话人。8–12 条、合计不超过 1200 字,最多 12 条",
1201
+ "- 已定/否决只能依据【用户】原文;角色对打一律标 open(未决)",
1202
+ "- 同主题:新已定覆盖旧未决;旧已定不能因角色反对改写,除非【用户】改口",
1203
+ "- 没有新结论时输出 {\"constraints\":[]}(保留旧备忘)",
1204
+ "- 超预算时按 已定 > 未决 > 过程叙述 取舍;语言跟随记录",
1205
+ "",
1206
+ "当前备忘:",
1207
+ existing.length ? existing.join("\n") : "(空)"
1208
+ ].join("\n");
1209
+ const user = "旧消息(从旧到新,含工具一行摘要):\n\n" + input.lines.join("\n\n");
1210
+ let acc = "";
1211
+ for await (const chunk of llm.stream({
1212
+ provider: dm.provider,
1213
+ model: dm.model,
1214
+ system: sys,
1215
+ purpose: "session-title",
1216
+ messages: [{
1217
+ id: "g" + core.revision + "-c0",
1218
+ role: "user",
1219
+ content: [{
1220
+ type: "text",
1221
+ text: user
1222
+ }],
1223
+ source: { kind: "user" }
1224
+ }]
1225
+ })) if (chunk.type === "text-delta") {
1226
+ acc += chunk.text;
1227
+ if (acc.length > 4e3) break;
1228
+ } else if (chunk.type === "finish") break;
1229
+ const parsed = parseConstraints(acc);
1230
+ if (parsed === null) return;
1231
+ commit(parsed.length ? downgradeWithoutUser(parsed, input.hasUser) : void 0);
1232
+ } catch (e) {
1233
+ console.error("[dsh-group-chat] 会话约束折叠失败(跳过,不影响对话):", e);
1234
+ } finally {
1235
+ folding.delete(sess.id);
1236
+ }
1237
+ };
1238
+ }
849
1239
  //#endregion
850
1240
  //#region src/host/engine/retitle.ts
1241
+ /** 仍是新建占位名(含改名之前的「会话 N」存量),自动标题尚未落地。 */
1242
+ const isPlaceholderName = (name) => name === "新会话" || /^会话 \d+$/.test(name);
851
1243
  /** DSH 默认模型(agentDefaultModel 服务缺位或未配置时返回 null,调用方静默跳过)。 */
852
1244
  const defaultModel = (core) => {
853
1245
  try {
@@ -866,7 +1258,7 @@ const titleTranscript = (core, sess) => {
866
1258
  const out = [];
867
1259
  for (const mid of sess.messageIds.slice(-40)) {
868
1260
  const m = core.messages.get(mid);
869
- if (!m || m.speaker === "system" || !m.text) continue;
1261
+ if (!m || m.speaker === "system" || m.error || !m.text) continue;
870
1262
  const name = m.speaker === "user" ? "用户" : (core.roles.get(m.speaker) || { name: void 0 }).name || "成员";
871
1263
  out.push("【" + name + "】" + m.text.replace(/\s+/g, " ").slice(0, 500));
872
1264
  }
@@ -892,9 +1284,9 @@ const parseRetitle = (raw) => {
892
1284
  };
893
1285
  /**
894
1286
  * 每轮结束后根据聊天内容整理会话名称与主题:后台 fire-and-forget、不产生
895
- * 消息、静默失败。名称 =「类别 emoji + 对象|目标」(类别固定、对象稳定、
896
- * 目标实质变化才改);主题 = 演进式一句话摘要(对象+目标+当前焦点),注入
897
- * 后续轮次的角色上下文。手动编辑过的字段永久跳过(隐式固定,apply 时复查)。
1287
+ * 消息、静默失败。名称只在仍为默认占位时生成一次(「类别 emoji + 对象|目标」);
1288
+ * 主题 = 演进式一句话摘要(对象+目标+当前焦点),每轮更新,注入后续角色上下文。
1289
+ * 手动编辑过的字段永久跳过(隐式固定,apply 时复查)。
898
1290
  */
899
1291
  function createRetitle(core, deps) {
900
1292
  const { llm } = core;
@@ -904,7 +1296,8 @@ function createRetitle(core, deps) {
904
1296
  return async (sess) => {
905
1297
  if (retitling.has(sess.id)) return;
906
1298
  const dm = defaultModel(core);
907
- if (!dm || sess.namePinned && sess.topicPinned) return;
1299
+ const nameFrozen = !!sess.namePinned || !isPlaceholderName(sess.name);
1300
+ if (!dm || nameFrozen && sess.topicPinned) return;
908
1301
  const transcript = titleTranscript(core, sess);
909
1302
  if (!transcript) return;
910
1303
  retitling.add(sess.id);
@@ -915,9 +1308,9 @@ function createRetitle(core, deps) {
915
1308
  "# 名称规则",
916
1309
  "- 格式:「类别 emoji + 对象|目标」,例如「🔎 缓存选型|Redis 与本地 KV 对比」",
917
1310
  "- 类别固定六选一:🔎 调研对比(多方案/多观点比较)、💡 头脑风暴(创意发散)、⚖️ 方案评审(评审已有方案或产物)、🛠️ 排查修复(定位与解决问题)、📝 方法整理(总结沉淀方法与知识)、🗣️ 通用讨论(其余兜底)",
918
- "- 对象在前且稳定:把辨识度最高的讨论对象放最前;省略群组名(外层已展示);除非讨论对象实质变化,沿用当前名称里的对象名;「继续」「追问」等不改变主线",
1311
+ "- 对象在前:把辨识度最高的讨论对象放最前;省略群组名(外层已展示)",
919
1312
  "- 目标 = 当前正在做的事,动宾短语,保持简洁",
920
- "- 名称总长不超过 16 个字",
1313
+ "- 名称总长不超过 16 个字;名称只生成一次,不要为了追问/继续而改名",
921
1314
  "",
922
1315
  "# 主题规则",
923
1316
  "- 一句话演进式摘要:讨论对象 + 当前目标 + 当前焦点/分歧点",
@@ -927,7 +1320,7 @@ function createRetitle(core, deps) {
927
1320
  "- 语言跟随用户消息的主要语言;保留产品名与技术名词",
928
1321
  "- 只输出一行 JSON:{\"name\": \"…\", \"topic\": \"…\"},不要输出其他内容",
929
1322
  "",
930
- "当前名称:" + (sess.namePinned ? "(已手动固定,本次不要输出 name 字段)" : sess.name),
1323
+ "当前名称:" + (nameFrozen ? "(已固定,本次不要输出 name 字段)" : sess.name),
931
1324
  "当前主题:" + (sess.topicPinned ? "(已手动固定,本次不要输出 topic 字段)" : sess.topic || "(空)")
932
1325
  ].join("\n");
933
1326
  let acc = "";
@@ -951,7 +1344,7 @@ function createRetitle(core, deps) {
951
1344
  } else if (chunk.type === "finish") break;
952
1345
  const parsed = parseRetitle(acc);
953
1346
  let changed = false;
954
- if (parsed.name && !sess.namePinned) {
1347
+ if (parsed.name && !sess.namePinned && isPlaceholderName(sess.name)) {
955
1348
  sess.name = parsed.name;
956
1349
  changed = true;
957
1350
  }
@@ -974,7 +1367,7 @@ function createRetitle(core, deps) {
974
1367
  //#region src/host/engine/conversation.ts
975
1368
  /**
976
1369
  * 对话引擎:消息追加、群聊记录转写、角色发言(speak:prompt 构建 + 流式
977
- * 轮次 + 工具回注循环)、多轮 runLoop(每轮结束触发 retitle 标题整理)。
1370
+ * 轮次 + 工具回注循环)、多轮 runLoop(结束时后台 retitle + 窗口外约束折叠)。
978
1371
  * @module dsh-group-chat/host/engine/conversation
979
1372
  */
980
1373
  /** 创建对话引擎。 */
@@ -985,6 +1378,11 @@ function createConversation(core, deps) {
985
1378
  touch,
986
1379
  schedulePersist
987
1380
  });
1381
+ const fold = createFold(core, {
1382
+ touch,
1383
+ schedulePersist
1384
+ });
1385
+ const nameOf = (speaker) => speakerLabel(speaker, (roles.get(speaker) || { name: void 0 }).name);
988
1386
  const appendMessage = (sess, speaker, text, extra) => {
989
1387
  const msg = {
990
1388
  id: core.nid("msg"),
@@ -1003,6 +1401,7 @@ function createConversation(core, deps) {
1003
1401
  if (extra.thinkingSummary !== void 0) msg.thinkingSummary = extra.thinkingSummary;
1004
1402
  if (extra.model !== void 0) msg.model = extra.model;
1005
1403
  if (extra.error !== void 0) msg.error = extra.error;
1404
+ if (extra.failedRoleId !== void 0) msg.failedRoleId = extra.failedRoleId;
1006
1405
  if (Array.isArray(extra.toolCalls) && extra.toolCalls.length > 0) msg.toolCalls = extra.toolCalls;
1007
1406
  }
1008
1407
  messages.set(msg.id, msg);
@@ -1011,35 +1410,76 @@ function createConversation(core, deps) {
1011
1410
  schedulePersist({ session: sess.id });
1012
1411
  return msg;
1013
1412
  };
1014
- /** 群聊记录 角色上下文块(最近 40 条、逐条 8k 截断、工具行压缩)。 */
1015
- const transcriptBlock = (sess) => {
1413
+ /** 把失败回合写成该角色的消息(speaker = 角色 id),不再用系统胶囊顶替。 */
1414
+ const writeFailure = (sess, role, err, replaceId) => {
1415
+ const raw = unwrapSpeakFailure(String(err && err.message || err));
1416
+ const extra = {
1417
+ error: true,
1418
+ failedRoleId: role.id,
1419
+ model: role.provider + " / " + role.model
1420
+ };
1421
+ if (replaceId) {
1422
+ const existing = messages.get(replaceId);
1423
+ if (existing && existing.sessionId === sess.id) {
1424
+ existing.speaker = role.id;
1425
+ existing.text = raw;
1426
+ existing.error = true;
1427
+ existing.failedRoleId = role.id;
1428
+ existing.model = extra.model;
1429
+ existing.reasoning = void 0;
1430
+ existing.reasoningFull = void 0;
1431
+ existing.thinkingSummary = void 0;
1432
+ existing.toolCalls = void 0;
1433
+ existing.ts = Date.now();
1434
+ touch();
1435
+ schedulePersist({ session: sess.id });
1436
+ return;
1437
+ }
1438
+ }
1439
+ appendMessage(sess, role.id, raw, extra);
1440
+ };
1441
+ /** 成功发言写入:replaceId 存在则原地覆盖失败卡。 */
1442
+ const writeSuccess = (sess, role, out, replaceId) => {
1443
+ const extra = {
1444
+ model: role.provider + " / " + role.model,
1445
+ reasoning: out.reasoning,
1446
+ toolCalls: out.toolCalls
1447
+ };
1448
+ if (replaceId) {
1449
+ const existing = messages.get(replaceId);
1450
+ if (existing && existing.sessionId === sess.id) {
1451
+ existing.speaker = role.id;
1452
+ existing.text = out.text;
1453
+ existing.error = void 0;
1454
+ existing.failedRoleId = void 0;
1455
+ existing.model = extra.model;
1456
+ existing.reasoning = out.reasoning;
1457
+ existing.reasoningFull = void 0;
1458
+ existing.thinkingSummary = void 0;
1459
+ existing.toolCalls = Array.isArray(out.toolCalls) && out.toolCalls.length > 0 ? out.toolCalls : void 0;
1460
+ existing.ts = Date.now();
1461
+ touch();
1462
+ schedulePersist({ session: sess.id });
1463
+ return;
1464
+ }
1465
+ }
1466
+ appendMessage(sess, role.id, out.text, extra);
1467
+ };
1468
+ /** 群聊记录 → 角色上下文块(最近 40 条 + 未折入临时原文)。失败卡不进上下文。重试截到该条之前。 */
1469
+ const transcriptBlock = (sess, skipId) => {
1016
1470
  const out = [];
1017
- for (const mid of sess.messageIds) {
1471
+ for (const mid of prefixIds(sess.messageIds, skipId).slice(-40)) {
1018
1472
  const m = messages.get(mid);
1019
- if (!m) continue;
1020
- const name = m.speaker === "user" ? "用户" : m.speaker === "system" ? "系统" : (roles.get(m.speaker) || { name: void 0 }).name || "成员";
1021
- let text = m.text || "";
1022
- if (text.length > 8e3) text = text.slice(0, 8e3) + "…(已截断)";
1023
- let line = "【" + name + "】" + text;
1024
- if (Array.isArray(m.toolCalls)) for (const c of m.toolCalls) {
1025
- if (!c || typeof c.tool !== "string") continue;
1026
- let brief = "";
1027
- try {
1028
- brief = JSON.stringify(c.args) || "";
1029
- } catch {
1030
- brief = "";
1031
- }
1032
- if (brief.length > 60) brief = brief.slice(0, 60) + "…";
1033
- const st = c.status === "ok" ? "成功" : c.status === "denied" ? "用户拒绝" : "失败";
1034
- let ob = String(c.output || "");
1035
- if (ob.length > 200) ob = ob.slice(0, 200) + "…";
1036
- line += "\n [工具] " + c.tool + " " + brief + " → " + st + (ob ? "(" + ob.replace(/\s+/g, " ") + ")" : "");
1037
- }
1038
- out.push(line);
1473
+ if (!m || m.id === skipId || m.error) continue;
1474
+ out.push(formatTranscriptLine(m, nameOf(m.speaker)));
1039
1475
  }
1040
- return out.slice(-40).join("\n\n");
1476
+ const live = out.join("\n\n");
1477
+ const temp = tempTranscript(squeezedMessages(messages, sess, skipId), (m) => nameOf(m.speaker));
1478
+ if (!temp) return live;
1479
+ if (!live) return temp;
1480
+ return temp + "\n\n" + live;
1041
1481
  };
1042
- const speak = async (g, sess, role) => {
1482
+ const speak = async (g, sess, role, skipId) => {
1043
1483
  const ws = await materials.loadWorkspaceFiles(g);
1044
1484
  const parts = ws.parts;
1045
1485
  const sys = [
@@ -1049,6 +1489,7 @@ function createConversation(core, deps) {
1049
1489
  "- 名称:" + role.name,
1050
1490
  "- 人设:" + (role.persona ? role.persona : "(未填写,请以积极协作者的身份参与讨论)"),
1051
1491
  sess.topic ? "\n# 本会话主题\n" + sess.topic : "",
1492
+ constraintBlock(sess.constraints),
1052
1493
  materials.materialBlock(parts, ws.dir),
1053
1494
  ws.dir ? "\n# 可用工具\n你可以调用工具在群组工作区目录(" + ws.dir + ")内查看文件与目录" + (g.permissionTier === "workspace_write" ? "、执行 shell 命令(命令需用户逐条确认,请优先用于运行测试)" : g.permissionTier === "full_access" ? "、执行 shell 命令(命令将直接执行、无需确认,请谨慎并优先用于运行测试)" : "") + "。需要事实依据时优先用工具查看,不要凭空猜测。" : "",
1054
1495
  "\n# 发言要求",
@@ -1056,7 +1497,7 @@ function createConversation(core, deps) {
1056
1497
  "- 回应群内最新讨论(消息中「@你的名字」表示用户点名要求你回应,被点名时请优先回应);与其他成员自然对话;有不同观点可以提出并说明理由",
1057
1498
  "- 保持简洁,通常不超过 300 字"
1058
1499
  ].filter((s) => s !== "").join("\n");
1059
- const history = transcriptBlock(sess);
1500
+ const history = transcriptBlock(sess, skipId);
1060
1501
  const intro = history ? "以下是本会话的群聊记录(从旧到新):\n\n" + history : "本会话刚刚开始,请围绕主题做简短开场发言。";
1061
1502
  let wsRoot = null;
1062
1503
  if (ws.dir) try {
@@ -1276,10 +1717,12 @@ function createConversation(core, deps) {
1276
1717
  toolCalls
1277
1718
  };
1278
1719
  };
1279
- const runLoop = async (sess) => {
1720
+ const runLoop = async (sess, opts) => {
1280
1721
  const g = groups.get(sess.groupId);
1281
1722
  const startCount = sess.messageIds.length;
1282
1723
  let failed = false;
1724
+ let replaceId = opts && opts.replaceMessageId;
1725
+ run.replaceMessageId = replaceId || null;
1283
1726
  try {
1284
1727
  while (run.queue.length > 0 && !run.stopping) {
1285
1728
  const roleId = run.queue.shift();
@@ -1289,16 +1732,27 @@ function createConversation(core, deps) {
1289
1732
  run.partialReasoning = "";
1290
1733
  touch();
1291
1734
  if (!role) continue;
1735
+ const targetId = replaceId;
1736
+ replaceId = void 0;
1292
1737
  try {
1293
- const out = await speak(g, sess, role);
1294
- if (!run.stopping && (out.text || Array.isArray(out.toolCalls) && out.toolCalls.length > 0)) appendMessage(sess, role.id, out.text, {
1295
- model: role.provider + " / " + role.model,
1296
- reasoning: out.reasoning,
1297
- toolCalls: out.toolCalls
1298
- });
1738
+ const out = await speak(g, sess, role, targetId);
1739
+ if (!run.stopping && (out.text || Array.isArray(out.toolCalls) && out.toolCalls.length > 0)) writeSuccess(sess, role, out, targetId);
1740
+ else if (!run.stopping && targetId) {
1741
+ failed = true;
1742
+ run.currentRoleId = null;
1743
+ run.partial = "";
1744
+ run.partialReasoning = "";
1745
+ run.replaceMessageId = null;
1746
+ writeFailure(sess, role, "模型没有返回内容", targetId);
1747
+ break;
1748
+ }
1299
1749
  } catch (e) {
1300
1750
  failed = true;
1301
- appendMessage(sess, "system", "角色「" + role.name + "」发言失败:" + String(e && e.message || e), { error: true });
1751
+ run.currentRoleId = null;
1752
+ run.partial = "";
1753
+ run.partialReasoning = "";
1754
+ run.replaceMessageId = null;
1755
+ writeFailure(sess, role, e, targetId);
1302
1756
  break;
1303
1757
  }
1304
1758
  }
@@ -1318,9 +1772,13 @@ function createConversation(core, deps) {
1318
1772
  run.confirmSignal = null;
1319
1773
  run.childProc = null;
1320
1774
  run.stopping = false;
1775
+ run.replaceMessageId = null;
1321
1776
  touch();
1322
1777
  }
1323
- if (sess.messageIds.length > startCount) retitle(sess);
1778
+ if (sess.messageIds.length > startCount || opts && opts.replaceMessageId) {
1779
+ retitle(sess);
1780
+ fold(sess);
1781
+ }
1324
1782
  };
1325
1783
  return {
1326
1784
  appendMessage,
@@ -1607,6 +2065,7 @@ function messageJson(m) {
1607
2065
  if (m.reasoningFull !== void 0) o.reasoningFull = m.reasoningFull;
1608
2066
  if (m.thinkingSummary !== void 0) o.thinkingSummary = m.thinkingSummary;
1609
2067
  if (m.error !== void 0) o.error = m.error;
2068
+ if (typeof m.failedRoleId === "string" && m.failedRoleId) o.failedRoleId = m.failedRoleId;
1610
2069
  if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) o.toolCalls = m.toolCalls;
1611
2070
  return o;
1612
2071
  }
@@ -2021,6 +2480,8 @@ function createPersistence(core) {
2021
2480
  topic: s.topic,
2022
2481
  ...s.namePinned ? { namePinned: true } : {},
2023
2482
  ...s.topicPinned ? { topicPinned: true } : {},
2483
+ ...s.constraints && s.constraints.length ? { constraints: s.constraints } : {},
2484
+ ...typeof s.constraintsUpToSeq === "number" && s.constraintsUpToSeq > 0 ? { constraintsUpToSeq: s.constraintsUpToSeq } : {},
2024
2485
  createdAt: s.createdAt,
2025
2486
  messages: s.messageIds.map((mid) => core.messages.get(mid)).filter(Boolean).map((m) => messageJson(m)).filter(Boolean)
2026
2487
  });
@@ -2128,6 +2589,9 @@ function createPersistence(core) {
2128
2589
  if (typeof doc.topic === "string") sess.topic = doc.topic;
2129
2590
  if (doc.namePinned === true) sess.namePinned = true;
2130
2591
  if (doc.topicPinned === true) sess.topicPinned = true;
2592
+ const constraints = sanitizeConstraints(doc.constraints);
2593
+ if (constraints.length) sess.constraints = constraints;
2594
+ if (typeof doc.constraintsUpToSeq === "number" && doc.constraintsUpToSeq > 0) sess.constraintsUpToSeq = doc.constraintsUpToSeq;
2131
2595
  if (typeof doc.createdAt === "number") sess.createdAt = doc.createdAt;
2132
2596
  let fallbackSeq = 0;
2133
2597
  for (const m of Array.isArray(doc.messages) ? doc.messages : []) {
@@ -2144,11 +2608,19 @@ function createPersistence(core) {
2144
2608
  thinkingSummary: m.thinkingSummary !== void 0 ? String(m.thinkingSummary) : void 0,
2145
2609
  model: m.model,
2146
2610
  error: m.error,
2611
+ failedRoleId: typeof m.failedRoleId === "string" && m.failedRoleId ? m.failedRoleId : void 0,
2147
2612
  toolCalls: Array.isArray(m.toolCalls) ? m.toolCalls : void 0,
2148
2613
  ts: typeof m.ts === "number" ? m.ts : Date.now()
2149
2614
  });
2150
2615
  sess.messageIds.push(m.id);
2151
2616
  }
2617
+ const groupRoles = g.roleIds.map((rid) => core.roles.get(rid)).filter((r) => Boolean(r));
2618
+ let repaired = false;
2619
+ for (const mid of sess.messageIds) {
2620
+ const rec = core.messages.get(mid);
2621
+ if (rec && repairFailedMessage(rec, groupRoles)) repaired = true;
2622
+ }
2623
+ if (repaired) schedulePersist({ session: sid });
2152
2624
  }
2153
2625
  core.sessions.set(sid, sess);
2154
2626
  g.sessionIds.push(sid);
@@ -2223,7 +2695,7 @@ function createPersistence(core) {
2223
2695
  sessionIds: []
2224
2696
  };
2225
2697
  core.groups.set(g.id, g);
2226
- const sess = core.newSession(g.id, "会话 1");
2698
+ const sess = core.newSession(g.id);
2227
2699
  g.sessionIds.push(sess.id);
2228
2700
  autoSessions.push(sess.id);
2229
2701
  }