@roaming-ai/dsh-group-chat 0.2.2 → 0.3.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.
Files changed (103) hide show
  1. package/README.md +91 -27
  2. package/lib/client.js +1254 -598
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +573 -109
  5. package/lib/types/client/components/AsidePanel.d.ts +3 -19
  6. package/lib/types/client/components/Bubble.d.ts +6 -3
  7. package/lib/types/client/components/ChatPanel.d.ts +1 -0
  8. package/lib/types/client/components/ConstraintList.d.ts +11 -0
  9. package/lib/types/client/components/FailCard.d.ts +9 -0
  10. package/lib/types/client/components/Fold.d.ts +19 -0
  11. package/lib/types/client/components/HoverTip.d.ts +22 -0
  12. package/lib/types/client/components/MessageFlow.d.ts +1 -0
  13. package/lib/types/client/components/MsgActions.d.ts +14 -0
  14. package/lib/types/client/components/NavPanel.d.ts +9 -11
  15. package/lib/types/client/components/RoleDrawer.d.ts +3 -2
  16. package/lib/types/client/components/ToolRow.d.ts +2 -2
  17. package/lib/types/client/hooks/useComposer.d.ts +6 -1
  18. package/lib/types/client/hooks/useGroupChatState.d.ts +0 -16
  19. package/lib/types/client/lib/composer-draft.d.ts +17 -0
  20. package/lib/types/client/lib/model.d.ts +1 -7
  21. package/lib/types/client/utils/utils.d.ts +4 -4
  22. package/lib/types/core/constraints.d.ts +61 -0
  23. package/lib/types/core/errors.d.ts +54 -0
  24. package/lib/types/core/json.d.ts +6 -0
  25. package/lib/types/core/types.d.ts +76 -60
  26. package/lib/types/host/api/actions.d.ts +1 -1
  27. package/lib/types/host/engine/conversation.d.ts +5 -3
  28. package/lib/types/host/engine/defaults.d.ts +13 -0
  29. package/lib/types/host/engine/fold.d.ts +17 -0
  30. package/lib/types/host/engine/index.d.ts +1 -0
  31. package/lib/types/host/engine/retitle.d.ts +5 -5
  32. package/lib/types/host/service.d.ts +1 -1
  33. package/lib/types/host/state.d.ts +2 -0
  34. package/lib/types/index.d.ts +2 -0
  35. package/lib/types/shared/file-mention-grammar.d.ts +3 -12
  36. package/package.json +1 -1
  37. package/src/client/GroupChatPanel.tsx +25 -59
  38. package/src/client/components/AsidePanel.tsx +50 -23
  39. package/src/client/components/Bubble.tsx +45 -18
  40. package/src/client/components/ChatPanel.tsx +17 -12
  41. package/src/client/components/Composer.tsx +26 -20
  42. package/src/client/components/ConstraintList.tsx +64 -0
  43. package/src/client/components/FailCard.tsx +49 -0
  44. package/src/client/components/Fold.tsx +72 -0
  45. package/src/client/components/HoverTip.tsx +134 -0
  46. package/src/client/components/MessageFlow.tsx +78 -54
  47. package/src/client/components/MsgActions.tsx +85 -0
  48. package/src/client/components/NavPanel.tsx +102 -79
  49. package/src/client/components/RoleDrawer.tsx +29 -7
  50. package/src/client/components/ThinkRow.tsx +7 -3
  51. package/src/client/components/ToolRow.tsx +9 -5
  52. package/src/client/hooks/useComposer.ts +54 -56
  53. package/src/client/hooks/useGroupChatState.ts +7 -17
  54. package/src/client/lib/composer-draft.ts +47 -0
  55. package/src/client/lib/model.ts +1 -14
  56. package/src/client/lib/styles.ts +73 -12
  57. package/src/client/react-dom-shim.d.ts +2 -0
  58. package/src/client/utils/utils.ts +6 -6
  59. package/src/core/constraints.ts +204 -0
  60. package/src/core/errors.ts +174 -0
  61. package/src/core/json.ts +18 -0
  62. package/src/core/types.ts +52 -19
  63. package/src/host/api/actions.ts +49 -14
  64. package/src/host/broadcast.ts +3 -3
  65. package/src/host/engine/conversation.ts +104 -40
  66. package/src/host/engine/defaults.ts +28 -0
  67. package/src/host/engine/fold.ts +99 -0
  68. package/src/host/engine/index.ts +1 -0
  69. package/src/host/engine/retitle.ts +24 -41
  70. package/src/host/materials/materials.ts +2 -0
  71. package/src/host/persistence/persistence.ts +18 -1
  72. package/src/host/service.ts +1 -1
  73. package/src/host/state.ts +5 -4
  74. package/src/host/tools/tools.ts +2 -2
  75. package/src/index.ts +2 -0
  76. package/src/shared/file-mention-grammar.test.ts +1 -38
  77. package/src/shared/file-mention-grammar.ts +7 -33
  78. package/lib/types/client/Bubble.d.ts +0 -10
  79. package/lib/types/client/Glyph.d.ts +0 -12
  80. package/lib/types/client/GroupChatPanel.old.d.ts +0 -10
  81. package/lib/types/client/GroupChatSettingsSection.d.ts +0 -15
  82. package/lib/types/client/PermissionSelect.d.ts +0 -13
  83. package/lib/types/client/RoleDrawer.d.ts +0 -17
  84. package/lib/types/client/ThinkRow.d.ts +0 -9
  85. package/lib/types/client/ToolRow.d.ts +0 -9
  86. package/lib/types/client/api.d.ts +0 -8
  87. package/lib/types/client/components.d.ts +0 -22
  88. package/lib/types/client/drawer.d.ts +0 -17
  89. package/lib/types/client/glyph.d.ts +0 -12
  90. package/lib/types/client/model.d.ts +0 -78
  91. package/lib/types/client/panel.d.ts +0 -10
  92. package/lib/types/client/settings.d.ts +0 -15
  93. package/lib/types/client/styles.d.ts +0 -10
  94. package/lib/types/client/ui.d.ts +0 -17
  95. package/lib/types/client/utils.d.ts +0 -22
  96. package/lib/types/host/actions.d.ts +0 -34
  97. package/lib/types/host/conversation.d.ts +0 -25
  98. package/lib/types/host/http.d.ts +0 -16
  99. package/lib/types/host/materials.d.ts +0 -32
  100. package/lib/types/host/persistence.d.ts +0 -31
  101. package/lib/types/host/routes.d.ts +0 -11
  102. package/lib/types/host/store.d.ts +0 -65
  103. package/lib/types/host/tools.d.ts +0 -28
package/lib/index.js CHANGED
@@ -46,6 +46,124 @@ function mountOnce(packageName, fn) {
46
46
  });
47
47
  }
48
48
  //#endregion
49
+ //#region src/core/json.ts
50
+ /** 消息 → 落盘 JSON(可选字段无则省略;兼容保留 error)。 */
51
+ function messageJson(m) {
52
+ if (!m || typeof m.id !== "string") return null;
53
+ const o = {
54
+ id: m.id,
55
+ speaker: m.speaker,
56
+ text: String(m.text || ""),
57
+ ts: typeof m.ts === "number" ? m.ts : Date.now(),
58
+ seq: typeof m.seq === "number" ? m.seq : 0
59
+ };
60
+ if (m.model !== void 0) o.model = m.model;
61
+ if (m.reasoning !== void 0) o.reasoning = m.reasoning;
62
+ if (m.reasoningFull !== void 0) o.reasoningFull = m.reasoningFull;
63
+ if (m.thinkingSummary !== void 0) o.thinkingSummary = m.thinkingSummary;
64
+ if (m.error !== void 0) o.error = m.error;
65
+ if (typeof m.failedRoleId === "string" && m.failedRoleId) o.failedRoleId = m.failedRoleId;
66
+ if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) o.toolCalls = m.toolCalls;
67
+ return o;
68
+ }
69
+ /** 角色 → 落盘 JSON(groupId 由目录归属,不再写入)。 */
70
+ function roleJson(r) {
71
+ const o = {
72
+ id: r.id,
73
+ name: String(r.name || "成员"),
74
+ persona: String(r.persona || ""),
75
+ provider: String(r.provider || ""),
76
+ model: String(r.model || ""),
77
+ enabled: r.enabled !== false,
78
+ thinking: r.thinking === true
79
+ };
80
+ if (r.color) o.color = r.color;
81
+ if (typeof r.temperature === "number" && !Number.isNaN(r.temperature)) o.temperature = r.temperature;
82
+ if (typeof r.reasoningEffort === "string" && r.reasoningEffort && r.reasoningEffort !== "default") o.reasoningEffort = r.reasoningEffort;
83
+ return o;
84
+ }
85
+ /**
86
+ * 宽容 JSON 提取(模型输出/供应商错误原文的统一解析入口):
87
+ * 取首个 { 至末个 } 的片段解析,仅接受普通对象;失败返回 null。
88
+ * 代码围栏剥离由调用方按需先行处理(供应商错误原文不剥)。
89
+ */
90
+ function looseJson(raw) {
91
+ const start = raw.indexOf("{");
92
+ const end = raw.lastIndexOf("}");
93
+ if (start < 0 || end <= start) return null;
94
+ try {
95
+ const parsed = JSON.parse(raw.slice(start, end + 1));
96
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+ //#endregion
102
+ //#region src/core/errors.ts
103
+ const PREFIX = "模型输出异常终止: ";
104
+ const LEGACY_ROLE = /^角色「([^」]+)」发言失败[::]\s*/;
105
+ /** 剥旧系统胶囊与引擎包装前缀,保留供应商原文。 */
106
+ function unwrapSpeakFailure(raw) {
107
+ const text = String(raw || "").trim();
108
+ const legacy = parseLegacyRoleFailure(text);
109
+ const body = legacy ? legacy.rest : text;
110
+ return body.startsWith(PREFIX) ? body.slice(10).trim() : body;
111
+ }
112
+ /** 旧系统胶囊文案:角色「名」发言失败:原文。对不上则 null。 */
113
+ function parseLegacyRoleFailure(text) {
114
+ const m = LEGACY_ROLE.exec(String(text || ""));
115
+ if (!m) return null;
116
+ return {
117
+ roleName: m[1],
118
+ rest: String(text).slice(m[0].length)
119
+ };
120
+ }
121
+ /** 发言失败卡:error 标记,或旧系统胶囊文案。 */
122
+ function isSpeakFailure(m) {
123
+ return !!m.error || !!parseLegacyRoleFailure(m.text);
124
+ }
125
+ /**
126
+ * 把旧系统失败行挂到对应角色(恰好一名命中才迁)。
127
+ * 返回是否改写了记录。
128
+ */
129
+ function repairFailedMessage(m, roles) {
130
+ if (!isSpeakFailure(m)) return false;
131
+ let changed = false;
132
+ if (!m.error) {
133
+ m.error = true;
134
+ changed = true;
135
+ }
136
+ if (!m.failedRoleId && m.speaker !== "user" && m.speaker !== "system") {
137
+ m.failedRoleId = m.speaker;
138
+ changed = true;
139
+ }
140
+ if (!m.failedRoleId && m.speaker === "system") {
141
+ const parsed = parseLegacyRoleFailure(m.text);
142
+ if (parsed) {
143
+ const hits = roles.filter((r) => r.name === parsed.roleName);
144
+ if (hits.length === 1) {
145
+ const role = hits[0];
146
+ m.speaker = role.id;
147
+ m.failedRoleId = role.id;
148
+ m.text = unwrapSpeakFailure(parsed.rest);
149
+ if (!m.model && (role.provider || role.model)) m.model = (role.provider || "") + " / " + (role.model || "");
150
+ changed = true;
151
+ }
152
+ }
153
+ } else if (m.failedRoleId && m.speaker === "system") {
154
+ m.speaker = m.failedRoleId;
155
+ changed = true;
156
+ }
157
+ if (m.failedRoleId || m.speaker !== "user" && m.speaker !== "system") {
158
+ const unwrapped = unwrapSpeakFailure(m.text);
159
+ if (unwrapped !== m.text) {
160
+ m.text = unwrapped;
161
+ changed = true;
162
+ }
163
+ }
164
+ return changed;
165
+ }
166
+ //#endregion
49
167
  //#region src/core/types.ts
50
168
  /** 全部合法档位(展示顺序)。 */
51
169
  const PERMISSION_TIERS = [
@@ -67,6 +185,16 @@ function migrateTier(permissionTier, allowCommands) {
67
185
  if (tier !== void 0) return tier;
68
186
  return allowCommands === true ? "workspace_write" : "view_only";
69
187
  }
188
+ /** 全部合法约束类型。 */
189
+ const CONSTRAINT_KINDS = [
190
+ "decided",
191
+ "rejected",
192
+ "open"
193
+ ];
194
+ /** 合法 kind 原样,其余 undefined。 */
195
+ function asConstraintKind(value) {
196
+ return typeof value === "string" && CONSTRAINT_KINDS.includes(value) ? value : void 0;
197
+ }
70
198
  /** 可选数值参数安全化:数字则原样,否则 undefined。 */
71
199
  function asNumber(value) {
72
200
  return typeof value === "number" && !Number.isNaN(value) ? value : void 0;
@@ -116,7 +244,8 @@ function createHostState(ctx) {
116
244
  pendingConfirm: null,
117
245
  confirmSignal: null,
118
246
  childProc: null,
119
- finished: null
247
+ finished: null,
248
+ replaceMessageId: null
120
249
  },
121
250
  store: null,
122
251
  revision: 1,
@@ -124,12 +253,10 @@ function createHostState(ctx) {
124
253
  nid: (p) => p + "-" + core.idSeq++,
125
254
  lastCreated: null,
126
255
  newSession: (groupId, name) => {
127
- let n = 0;
128
- for (const s of core.sessions.values()) if (s.groupId === groupId) n++;
129
256
  const s = {
130
257
  id: randomUUID(),
131
258
  groupId,
132
- name: name || "会话 " + (n + 1),
259
+ name: name || "新会话",
133
260
  topic: "",
134
261
  messageIds: [],
135
262
  createdAt: Date.now()
@@ -144,7 +271,7 @@ function createHostState(ctx) {
144
271
  //#region src/host/api/actions.ts
145
272
  /**
146
273
  * 动作分发(handleAction,POST /api/group-chat/action 的载荷):
147
- * mutate(12 种 CRUD/配置操作)| send | stop | confirmCommand | models | efforts。
274
+ * mutate(12 种 CRUD/配置操作)| send | retrySpeak | stop | confirmCommand | models | efforts。
148
275
  * @module dsh-group-chat/host/api/actions
149
276
  */
150
277
  /** 创建动作分发面。 */
@@ -152,7 +279,7 @@ function createActions(core, deps) {
152
279
  const { llm, groups, sessions, roles, messages, run } = core;
153
280
  const { touch, snapshot, schedulePersist, dropDirty, appendMessage, runLoop, wakeConfirm, killChild, browse, fileSearch, disposeFileSearch } = deps;
154
281
  const mutate = (args) => {
155
- const op = args && args.op;
282
+ const op = args.op;
156
283
  if (op === "createGroup") {
157
284
  const g = {
158
285
  id: core.nid("grp"),
@@ -382,6 +509,8 @@ function createActions(core, deps) {
382
509
  };
383
510
  for (const mid of sess.messageIds) messages.delete(mid);
384
511
  sess.messageIds = [];
512
+ sess.constraints = void 0;
513
+ sess.constraintsUpToSeq = void 0;
385
514
  schedulePersist({ session: sess.id });
386
515
  touch();
387
516
  }
@@ -426,10 +555,56 @@ function createActions(core, deps) {
426
555
  run.queue = queue;
427
556
  run.stopping = false;
428
557
  run.finished = null;
558
+ run.replaceMessageId = null;
429
559
  touch();
430
560
  runLoop(sess).catch((e) => console.error("group-chat run failed", e));
431
561
  return { ok: true };
432
562
  };
563
+ /** 对失败卡原地重试:只让该角色再讲一次,成功后覆盖同一条消息。 */
564
+ const retrySpeak = (args) => {
565
+ const sess = sessions.get(String(args && args.sessionId || ""));
566
+ if (!sess) return {
567
+ ok: false,
568
+ error: "会话不存在"
569
+ };
570
+ if (run.running) return {
571
+ ok: false,
572
+ error: "已有对话进行中,请先停止"
573
+ };
574
+ const msg = messages.get(String(args && args.messageId || ""));
575
+ if (!msg || msg.sessionId !== sess.id || !isSpeakFailure(msg)) return {
576
+ ok: false,
577
+ error: "没有可重试的失败发言"
578
+ };
579
+ const g = groups.get(sess.groupId);
580
+ if (!g) return {
581
+ ok: false,
582
+ error: "群组不存在"
583
+ };
584
+ if (repairFailedMessage(msg, g.roleIds.map((id) => roles.get(id)).filter((r) => Boolean(r)))) {
585
+ schedulePersist({ session: sess.id });
586
+ touch();
587
+ }
588
+ const roleId = msg.failedRoleId || (msg.speaker !== "user" && msg.speaker !== "system" ? msg.speaker : "");
589
+ const role = roleId ? roles.get(roleId) : void 0;
590
+ if (!role || role.groupId !== sess.groupId) return {
591
+ ok: false,
592
+ error: "失败角色已不存在,无法重试"
593
+ };
594
+ if (!role.enabled) return {
595
+ ok: false,
596
+ error: "该角色已停用,无法重试"
597
+ };
598
+ run.running = true;
599
+ run.sessionId = sess.id;
600
+ run.queue = [role.id];
601
+ run.stopping = false;
602
+ run.finished = null;
603
+ run.replaceMessageId = msg.id;
604
+ touch();
605
+ runLoop(sess, { replaceMessageId: msg.id }).catch((e) => console.error("group-chat retry failed", e));
606
+ return { ok: true };
607
+ };
433
608
  const stop = (args) => {
434
609
  if (run.running && (!args || !args.sessionId || run.sessionId === args.sessionId)) {
435
610
  run.stopping = true;
@@ -527,6 +702,7 @@ function createActions(core, deps) {
527
702
  lastCreated: core.lastCreated
528
703
  };
529
704
  if (kind === "send") return send(body);
705
+ if (kind === "retrySpeak") return retrySpeak(body);
530
706
  if (kind === "stop") return stop(body);
531
707
  if (kind === "confirmCommand") return confirmCommand(body);
532
708
  if (kind === "models") return {
@@ -738,7 +914,8 @@ function createBroadcast(core) {
738
914
  partial: core.run.partial,
739
915
  partialReasoning: core.run.partialReasoning,
740
916
  pendingConfirm: core.run.pendingConfirm,
741
- finished: core.run.finished
917
+ finished: core.run.finished,
918
+ replaceMessageId: core.run.replaceMessageId
742
919
  },
743
920
  lastCreated: core.lastCreated,
744
921
  groups: [...core.groups.values()].map((g) => ({
@@ -754,6 +931,7 @@ function createBroadcast(core) {
754
931
  groupId: s.groupId,
755
932
  name: s.name,
756
933
  topic: s.topic,
934
+ ...s.constraints && s.constraints.length ? { constraints: s.constraints } : {},
757
935
  messageIds: s.messageIds.slice(),
758
936
  createdAt: s.createdAt
759
937
  })),
@@ -779,6 +957,7 @@ function createBroadcast(core) {
779
957
  reasoning: m.reasoning,
780
958
  model: m.model,
781
959
  error: m.error,
960
+ failedRoleId: m.failedRoleId,
782
961
  toolCalls: m.toolCalls,
783
962
  ts: m.ts
784
963
  }))
@@ -846,8 +1025,176 @@ const TOOL_SCHEMAS = [
846
1025
  }
847
1026
  }
848
1027
  ];
1028
+ /** 折叠失败时临时原文总长上限。 */
1029
+ const TEMP_MAX_CHARS = 16e3;
1030
+ const KIND_LABEL = {
1031
+ decided: "已定",
1032
+ rejected: "否决",
1033
+ open: "未决"
1034
+ };
1035
+ /** 已折入水位(缺省 0)。 */
1036
+ function constraintsWatermark(sess) {
1037
+ return typeof sess.constraintsUpToSeq === "number" && sess.constraintsUpToSeq > 0 ? sess.constraintsUpToSeq : 0;
1038
+ }
1039
+ /** 重试:只取失败卡之前的时间线;untilId 不在列表则原样。 */
1040
+ function prefixIds(ids, untilId) {
1041
+ if (!untilId) return ids;
1042
+ const i = ids.indexOf(untilId);
1043
+ return i >= 0 ? ids.slice(0, i) : ids;
1044
+ }
1045
+ /**
1046
+ * 新挤出:seq > 水位 且不在最近 40 条。只扫窗口外前缀(旧→新)。
1047
+ * untilId:重试时把窗口截到该消息之前,不带上后面已经发生的发言。
1048
+ */
1049
+ function squeezedMessages(messages, sess, untilId) {
1050
+ const ids = prefixIds(sess.messageIds, untilId);
1051
+ if (ids.length <= 40) return [];
1052
+ const end = ids.length - 40;
1053
+ const upTo = constraintsWatermark(sess);
1054
+ const last = messages.get(ids[end - 1]);
1055
+ if (last && last.seq <= upTo) return [];
1056
+ const out = [];
1057
+ for (let i = 0; i < end; i++) {
1058
+ const m = messages.get(ids[i]);
1059
+ if (!m || m.seq <= upTo) continue;
1060
+ out.push(m);
1061
+ }
1062
+ return out;
1063
+ }
1064
+ /** 说话人展示名(折叠输入 / transcript 共用)。 */
1065
+ function speakerLabel(speaker, roleName) {
1066
+ if (speaker === "user") return "用户";
1067
+ if (speaker === "system") return "系统";
1068
+ return roleName || "成员";
1069
+ }
1070
+ /** 单条消息压成 transcript 行(正文 8k + 工具一行摘要)。 */
1071
+ function formatTranscriptLine(m, name) {
1072
+ let text = m.text || "";
1073
+ if (text.length > 8e3) text = text.slice(0, 8e3) + "…(已截断)";
1074
+ let line = "【" + name + "】" + text;
1075
+ if (Array.isArray(m.toolCalls)) for (const c of m.toolCalls) {
1076
+ if (!c || typeof c.tool !== "string") continue;
1077
+ let brief = "";
1078
+ try {
1079
+ brief = JSON.stringify(c.args) || "";
1080
+ } catch {
1081
+ brief = "";
1082
+ }
1083
+ if (brief.length > 60) brief = brief.slice(0, 60) + "…";
1084
+ const st = c.status === "ok" ? "成功" : c.status === "denied" ? "用户拒绝" : "失败";
1085
+ let ob = String(c.output || "");
1086
+ if (ob.length > 200) ob = ob.slice(0, 200) + "…";
1087
+ line += "\n [工具] " + c.tool + " " + brief + " → " + st + (ob ? "(" + ob.replace(/\s+/g, " ") + ")" : "");
1088
+ }
1089
+ return line;
1090
+ }
1091
+ /**
1092
+ * 单轮折叠消耗前缀:从最旧挤出起,最多 40 条 / 16k;系统行与失败卡计入消耗但不进模型。
1093
+ * 水位只能推到 consumed 的 max seq,剩余留待下一轮。
1094
+ */
1095
+ function takeFoldBatch(squeezed, nameOf) {
1096
+ const consumed = [];
1097
+ const lines = [];
1098
+ let hasUser = false;
1099
+ let chars = 0;
1100
+ for (const m of squeezed) {
1101
+ if (m.speaker === "system" || m.error) {
1102
+ consumed.push(m);
1103
+ if (consumed.length >= 40) break;
1104
+ continue;
1105
+ }
1106
+ const line = formatTranscriptLine(m, nameOf(m));
1107
+ const extra = line.length + (lines.length ? 2 : 0);
1108
+ if (lines.length > 0 && chars + extra > 16e3) break;
1109
+ if (m.speaker === "user") hasUser = true;
1110
+ lines.push(line);
1111
+ chars += extra;
1112
+ consumed.push(m);
1113
+ if (consumed.length >= 40) break;
1114
+ }
1115
+ return {
1116
+ consumed,
1117
+ lines,
1118
+ hasUser,
1119
+ allSystem: lines.length === 0
1120
+ };
1121
+ }
1122
+ /**
1123
+ * 未折入的挤出原文(失败缓冲):只格式化最近 20 条,再按 16k 从最旧往下丢。
1124
+ */
1125
+ function tempTranscript(squeezed, nameOf) {
1126
+ if (!squeezed.length) return "";
1127
+ const usable = squeezed.filter((m) => m.speaker !== "system" && !m.error);
1128
+ if (!usable.length) return "";
1129
+ const lines = (usable.length > 20 ? usable.slice(-20) : usable).map((m) => formatTranscriptLine(m, nameOf(m)));
1130
+ let start = 0;
1131
+ let total = lines[0] ? lines[0].length : 0;
1132
+ for (let i = 1; i < lines.length; i++) total += 2 + lines[i].length;
1133
+ while (start < lines.length - 1 && total > 16e3) {
1134
+ total -= lines[start].length + 2;
1135
+ start++;
1136
+ }
1137
+ let block = lines.slice(start).join("\n\n");
1138
+ if (block.length > 16e3) block = block.slice(0, TEMP_MAX_CHARS) + "…(已截断)";
1139
+ return block;
1140
+ }
1141
+ /** 水位 = 本批挤出的 max(seq);空批为 0。 */
1142
+ function squeezedMaxSeq(squeezed) {
1143
+ let max = 0;
1144
+ for (const m of squeezed) if (m.seq > max) max = m.seq;
1145
+ return max;
1146
+ }
1147
+ /** hydrate / 模型输出:非法 kind 丢条目;空 text 丢;条数与总长截断。 */
1148
+ function sanitizeConstraints(raw) {
1149
+ if (!Array.isArray(raw)) return [];
1150
+ const out = [];
1151
+ let total = 0;
1152
+ for (const item of raw) {
1153
+ if (out.length >= 12) break;
1154
+ if (!item || typeof item !== "object") continue;
1155
+ const kind = asConstraintKind(item.kind);
1156
+ const text = typeof item.text === "string" ? item.text.trim() : "";
1157
+ if (!kind || !text) continue;
1158
+ const clipped = text.length > 160 ? text.slice(0, 160) + "…" : text;
1159
+ const cost = KIND_LABEL[kind].length + clipped.length;
1160
+ if (total + cost > 1200) break;
1161
+ out.push({
1162
+ kind,
1163
+ text: clipped
1164
+ });
1165
+ total += cost;
1166
+ }
1167
+ return out;
1168
+ }
1169
+ /**
1170
+ * 解析折叠模型输出。null = 解析失败(水位不推);[] = 无新结论(水位推、备忘不动)。
1171
+ */
1172
+ function parseConstraints(raw) {
1173
+ const o = looseJson(raw.replace(/```(?:json)?/g, ""));
1174
+ if (o === null) return null;
1175
+ if (!Object.prototype.hasOwnProperty.call(o, "constraints")) return null;
1176
+ if (!Array.isArray(o.constraints)) return null;
1177
+ return sanitizeConstraints(o.constraints);
1178
+ }
1179
+ /** 本批无用户消息时,新的已定/否决降为未决。 */
1180
+ function downgradeWithoutUser(list, hasUser) {
1181
+ if (hasUser) return list;
1182
+ return list.map((c) => c.kind === "open" ? c : {
1183
+ kind: "open",
1184
+ text: c.text
1185
+ });
1186
+ }
1187
+ /** system 提示词「已确认约束」块;空则空串。 */
1188
+ function constraintBlock(list) {
1189
+ if (!list || !list.length) return "";
1190
+ return "\n# 已确认约束\n" + list.map((c) => "- " + KIND_LABEL[c.kind] + ":" + c.text).join("\n");
1191
+ }
849
1192
  //#endregion
850
- //#region src/host/engine/retitle.ts
1193
+ //#region src/host/engine/defaults.ts
1194
+ /**
1195
+ * 引擎共享小件:DSH 默认模型读取 + 发言人展示名(retitle / fold / conversation 共用)。
1196
+ * @module dsh-group-chat/host/engine/defaults
1197
+ */
851
1198
  /** DSH 默认模型(agentDefaultModel 服务缺位或未配置时返回 null,调用方静默跳过)。 */
852
1199
  const defaultModel = (core) => {
853
1200
  try {
@@ -861,40 +1208,117 @@ const defaultModel = (core) => {
861
1208
  return null;
862
1209
  }
863
1210
  };
1211
+ /** 发言人展示名(roles 表内查角色名;user/system 固定文案,见 speakerLabel)。 */
1212
+ const speakerNameOf = (roles, speaker) => speakerLabel(speaker, roles.get(speaker)?.name);
1213
+ //#endregion
1214
+ //#region src/host/engine/fold.ts
1215
+ /**
1216
+ * 每轮 send 结束后折叠窗口外约束:fire-and-forget、不产生消息、静默失败。
1217
+ * 同会话去重;会话已清空则 abort。内存 {constraints, constraintsUpToSeq} 一次挂上。
1218
+ */
1219
+ function createFold(core, deps) {
1220
+ const { llm, messages, roles } = core;
1221
+ const { touch, schedulePersist } = deps;
1222
+ const folding = /* @__PURE__ */ new Set();
1223
+ return async (sess) => {
1224
+ if (folding.has(sess.id)) return;
1225
+ const squeezed = squeezedMessages(messages, sess);
1226
+ if (!squeezed.length) return;
1227
+ const input = takeFoldBatch(squeezed, (m) => speakerNameOf(roles, m.speaker));
1228
+ const watermark = squeezedMaxSeq(input.consumed);
1229
+ if (watermark <= 0) return;
1230
+ const commit = (next) => {
1231
+ const live = core.sessions.get(sess.id);
1232
+ if (!live || live.messageIds.length === 0) return;
1233
+ if (next && next.length) live.constraints = next;
1234
+ live.constraintsUpToSeq = watermark;
1235
+ schedulePersist({ session: live.id });
1236
+ touch();
1237
+ };
1238
+ if (input.allSystem) {
1239
+ commit(void 0);
1240
+ return;
1241
+ }
1242
+ const dm = defaultModel(core);
1243
+ if (!dm) return;
1244
+ folding.add(sess.id);
1245
+ try {
1246
+ const existing = (sess.constraints || []).map((c) => "- " + KIND_LABEL[c.kind] + ":" + c.text);
1247
+ const sys = [
1248
+ "你是群聊会话的约束整理助手。把已经离开最近对话窗口的旧消息压成无主结论/约束备忘。",
1249
+ "",
1250
+ "# 规则",
1251
+ "- 只输出一行 JSON:{\"constraints\":[{\"kind\":\"decided|rejected|open\",\"text\":\"…\"}, ...]}",
1252
+ "- kind 只能是 decided(已定)/ rejected(否决)/ open(未决)",
1253
+ "- 无主:条目不写说话人。8–12 条、合计不超过 1200 字,最多 12 条",
1254
+ "- 已定/否决只能依据【用户】原文;角色对打一律标 open(未决)",
1255
+ "- 同主题:新已定覆盖旧未决;旧已定不能因角色反对改写,除非【用户】改口",
1256
+ "- 没有新结论时输出 {\"constraints\":[]}(保留旧备忘)",
1257
+ "- 超预算时按 已定 > 未决 > 过程叙述 取舍;语言跟随记录",
1258
+ "",
1259
+ "当前备忘:",
1260
+ existing.length ? existing.join("\n") : "(空)"
1261
+ ].join("\n");
1262
+ const user = "旧消息(从旧到新,含工具一行摘要):\n\n" + input.lines.join("\n\n");
1263
+ let acc = "";
1264
+ for await (const chunk of llm.stream({
1265
+ provider: dm.provider,
1266
+ model: dm.model,
1267
+ system: sys,
1268
+ purpose: "session-title",
1269
+ messages: [{
1270
+ id: "g" + core.revision + "-c0",
1271
+ role: "user",
1272
+ content: [{
1273
+ type: "text",
1274
+ text: user
1275
+ }],
1276
+ source: { kind: "user" }
1277
+ }]
1278
+ })) if (chunk.type === "text-delta") {
1279
+ acc += chunk.text;
1280
+ if (acc.length > 4e3) break;
1281
+ } else if (chunk.type === "finish") break;
1282
+ const parsed = parseConstraints(acc);
1283
+ if (parsed === null) return;
1284
+ commit(parsed.length ? downgradeWithoutUser(parsed, input.hasUser) : void 0);
1285
+ } catch (e) {
1286
+ console.error("[dsh-group-chat] 会话约束折叠失败(跳过,不影响对话):", e);
1287
+ } finally {
1288
+ folding.delete(sess.id);
1289
+ }
1290
+ };
1291
+ }
1292
+ //#endregion
1293
+ //#region src/host/engine/retitle.ts
1294
+ /** 仍是新建占位名(含改名之前的「会话 N」存量),自动标题尚未落地。 */
1295
+ const isPlaceholderName = (name) => name === "新会话" || /^会话 \d+$/.test(name);
864
1296
  /** 命名输入:最近 40 条非系统消息的紧凑转写(每条 500 字符封顶,命名不需要全文)。 */
865
1297
  const titleTranscript = (core, sess) => {
866
1298
  const out = [];
867
1299
  for (const mid of sess.messageIds.slice(-40)) {
868
1300
  const m = core.messages.get(mid);
869
- if (!m || m.speaker === "system" || !m.text) continue;
870
- const name = m.speaker === "user" ? "用户" : (core.roles.get(m.speaker) || { name: void 0 }).name || "成员";
871
- out.push("【" + name + "】" + m.text.replace(/\s+/g, " ").slice(0, 500));
1301
+ if (!m || m.speaker === "system" || m.error || !m.text) continue;
1302
+ out.push("" + speakerNameOf(core.roles, m.speaker) + "】" + m.text.replace(/\s+/g, " ").slice(0, 500));
872
1303
  }
873
1304
  return out.join("\n");
874
1305
  };
875
- /** 宽容解析模型输出:剥代码围栏 → 取首个 { 至末个 } 的 JSON → 校验并封顶字段。 */
1306
+ /** 宽容解析模型输出:剥代码围栏 → looseJson → 校验并封顶字段。 */
876
1307
  const parseRetitle = (raw) => {
877
- const body = raw.replace(/```(?:json)?/g, "");
878
- const l = body.indexOf("{");
879
- const r = body.lastIndexOf("}");
880
- if (l < 0 || r <= l) return {};
881
- try {
882
- const o = JSON.parse(body.slice(l, r + 1));
883
- const name = typeof o.name === "string" ? o.name.trim() : "";
884
- const topic = typeof o.topic === "string" ? o.topic.trim() : "";
885
- return {
886
- name: name ? name.slice(0, 24) : void 0,
887
- topic: topic ? topic.slice(0, 120) : void 0
888
- };
889
- } catch {
890
- return {};
891
- }
1308
+ const o = looseJson(raw.replace(/```(?:json)?/g, ""));
1309
+ if (!o) return {};
1310
+ const name = typeof o.name === "string" ? o.name.trim() : "";
1311
+ const topic = typeof o.topic === "string" ? o.topic.trim() : "";
1312
+ return {
1313
+ name: name ? name.slice(0, 24) : void 0,
1314
+ topic: topic ? topic.slice(0, 120) : void 0
1315
+ };
892
1316
  };
893
1317
  /**
894
1318
  * 每轮结束后根据聊天内容整理会话名称与主题:后台 fire-and-forget、不产生
895
- * 消息、静默失败。名称 =「类别 emoji + 对象|目标」(类别固定、对象稳定、
896
- * 目标实质变化才改);主题 = 演进式一句话摘要(对象+目标+当前焦点),注入
897
- * 后续轮次的角色上下文。手动编辑过的字段永久跳过(隐式固定,apply 时复查)。
1319
+ * 消息、静默失败。名称只在仍为默认占位时生成一次(「类别 emoji + 对象|目标」);
1320
+ * 主题 = 演进式一句话摘要(对象+目标+当前焦点),每轮更新,注入后续角色上下文。
1321
+ * 手动编辑过的字段永久跳过(隐式固定,apply 时复查)。
898
1322
  */
899
1323
  function createRetitle(core, deps) {
900
1324
  const { llm } = core;
@@ -904,7 +1328,8 @@ function createRetitle(core, deps) {
904
1328
  return async (sess) => {
905
1329
  if (retitling.has(sess.id)) return;
906
1330
  const dm = defaultModel(core);
907
- if (!dm || sess.namePinned && sess.topicPinned) return;
1331
+ const nameFrozen = !!sess.namePinned || !isPlaceholderName(sess.name);
1332
+ if (!dm || nameFrozen && sess.topicPinned) return;
908
1333
  const transcript = titleTranscript(core, sess);
909
1334
  if (!transcript) return;
910
1335
  retitling.add(sess.id);
@@ -915,9 +1340,9 @@ function createRetitle(core, deps) {
915
1340
  "# 名称规则",
916
1341
  "- 格式:「类别 emoji + 对象|目标」,例如「🔎 缓存选型|Redis 与本地 KV 对比」",
917
1342
  "- 类别固定六选一:🔎 调研对比(多方案/多观点比较)、💡 头脑风暴(创意发散)、⚖️ 方案评审(评审已有方案或产物)、🛠️ 排查修复(定位与解决问题)、📝 方法整理(总结沉淀方法与知识)、🗣️ 通用讨论(其余兜底)",
918
- "- 对象在前且稳定:把辨识度最高的讨论对象放最前;省略群组名(外层已展示);除非讨论对象实质变化,沿用当前名称里的对象名;「继续」「追问」等不改变主线",
1343
+ "- 对象在前:把辨识度最高的讨论对象放最前;省略群组名(外层已展示)",
919
1344
  "- 目标 = 当前正在做的事,动宾短语,保持简洁",
920
- "- 名称总长不超过 16 个字",
1345
+ "- 名称总长不超过 16 个字;名称只生成一次,不要为了追问/继续而改名",
921
1346
  "",
922
1347
  "# 主题规则",
923
1348
  "- 一句话演进式摘要:讨论对象 + 当前目标 + 当前焦点/分歧点",
@@ -927,7 +1352,7 @@ function createRetitle(core, deps) {
927
1352
  "- 语言跟随用户消息的主要语言;保留产品名与技术名词",
928
1353
  "- 只输出一行 JSON:{\"name\": \"…\", \"topic\": \"…\"},不要输出其他内容",
929
1354
  "",
930
- "当前名称:" + (sess.namePinned ? "(已手动固定,本次不要输出 name 字段)" : sess.name),
1355
+ "当前名称:" + (nameFrozen ? "(已固定,本次不要输出 name 字段)" : sess.name),
931
1356
  "当前主题:" + (sess.topicPinned ? "(已手动固定,本次不要输出 topic 字段)" : sess.topic || "(空)")
932
1357
  ].join("\n");
933
1358
  let acc = "";
@@ -951,7 +1376,7 @@ function createRetitle(core, deps) {
951
1376
  } else if (chunk.type === "finish") break;
952
1377
  const parsed = parseRetitle(acc);
953
1378
  let changed = false;
954
- if (parsed.name && !sess.namePinned) {
1379
+ if (parsed.name && !sess.namePinned && isPlaceholderName(sess.name)) {
955
1380
  sess.name = parsed.name;
956
1381
  changed = true;
957
1382
  }
@@ -974,7 +1399,7 @@ function createRetitle(core, deps) {
974
1399
  //#region src/host/engine/conversation.ts
975
1400
  /**
976
1401
  * 对话引擎:消息追加、群聊记录转写、角色发言(speak:prompt 构建 + 流式
977
- * 轮次 + 工具回注循环)、多轮 runLoop(每轮结束触发 retitle 标题整理)。
1402
+ * 轮次 + 工具回注循环)、多轮 runLoop(结束时后台 retitle + 窗口外约束折叠)。
978
1403
  * @module dsh-group-chat/host/engine/conversation
979
1404
  */
980
1405
  /** 创建对话引擎。 */
@@ -985,6 +1410,10 @@ function createConversation(core, deps) {
985
1410
  touch,
986
1411
  schedulePersist
987
1412
  });
1413
+ const fold = createFold(core, {
1414
+ touch,
1415
+ schedulePersist
1416
+ });
988
1417
  const appendMessage = (sess, speaker, text, extra) => {
989
1418
  const msg = {
990
1419
  id: core.nid("msg"),
@@ -1003,6 +1432,7 @@ function createConversation(core, deps) {
1003
1432
  if (extra.thinkingSummary !== void 0) msg.thinkingSummary = extra.thinkingSummary;
1004
1433
  if (extra.model !== void 0) msg.model = extra.model;
1005
1434
  if (extra.error !== void 0) msg.error = extra.error;
1435
+ if (extra.failedRoleId !== void 0) msg.failedRoleId = extra.failedRoleId;
1006
1436
  if (Array.isArray(extra.toolCalls) && extra.toolCalls.length > 0) msg.toolCalls = extra.toolCalls;
1007
1437
  }
1008
1438
  messages.set(msg.id, msg);
@@ -1011,35 +1441,72 @@ function createConversation(core, deps) {
1011
1441
  schedulePersist({ session: sess.id });
1012
1442
  return msg;
1013
1443
  };
1014
- /** 群聊记录 角色上下文块(最近 40 条、逐条 8k 截断、工具行压缩)。 */
1015
- const transcriptBlock = (sess) => {
1444
+ /** 原地覆盖既有消息(重试槽位);目标不存在或不属于本会话时返回 false,由调用方走追加。 */
1445
+ const overwriteMessage = (sess, replaceId, patch) => {
1446
+ if (!replaceId) return false;
1447
+ const existing = messages.get(replaceId);
1448
+ if (!existing || existing.sessionId !== sess.id) return false;
1449
+ Object.assign(existing, patch, { ts: Date.now() });
1450
+ touch();
1451
+ schedulePersist({ session: sess.id });
1452
+ return true;
1453
+ };
1454
+ /** 把失败回合写成该角色的消息(speaker = 角色 id),不再用系统胶囊顶替。 */
1455
+ const writeFailure = (sess, role, err, replaceId) => {
1456
+ const raw = unwrapSpeakFailure(String(err && err.message || err));
1457
+ const model = role.provider + " / " + role.model;
1458
+ const extra = {
1459
+ error: true,
1460
+ failedRoleId: role.id,
1461
+ model
1462
+ };
1463
+ if (overwriteMessage(sess, replaceId, {
1464
+ speaker: role.id,
1465
+ text: raw,
1466
+ ...extra,
1467
+ reasoning: void 0,
1468
+ reasoningFull: void 0,
1469
+ thinkingSummary: void 0,
1470
+ toolCalls: void 0
1471
+ })) return;
1472
+ appendMessage(sess, role.id, raw, extra);
1473
+ };
1474
+ /** 成功发言写入:replaceId 存在则原地覆盖失败卡。 */
1475
+ const writeSuccess = (sess, role, out, replaceId) => {
1476
+ const model = role.provider + " / " + role.model;
1477
+ const toolCalls = Array.isArray(out.toolCalls) && out.toolCalls.length > 0 ? out.toolCalls : void 0;
1478
+ if (overwriteMessage(sess, replaceId, {
1479
+ speaker: role.id,
1480
+ text: out.text,
1481
+ error: void 0,
1482
+ failedRoleId: void 0,
1483
+ model,
1484
+ reasoning: out.reasoning,
1485
+ reasoningFull: void 0,
1486
+ thinkingSummary: void 0,
1487
+ toolCalls
1488
+ })) return;
1489
+ appendMessage(sess, role.id, out.text, {
1490
+ model,
1491
+ reasoning: out.reasoning,
1492
+ toolCalls: out.toolCalls
1493
+ });
1494
+ };
1495
+ /** 群聊记录 → 角色上下文块(最近 40 条 + 未折入临时原文)。失败卡不进上下文。重试截到该条之前。 */
1496
+ const transcriptBlock = (sess, skipId) => {
1016
1497
  const out = [];
1017
- for (const mid of sess.messageIds) {
1498
+ for (const mid of prefixIds(sess.messageIds, skipId).slice(-40)) {
1018
1499
  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);
1500
+ if (!m || m.id === skipId || m.error) continue;
1501
+ out.push(formatTranscriptLine(m, speakerNameOf(roles, m.speaker)));
1039
1502
  }
1040
- return out.slice(-40).join("\n\n");
1503
+ const live = out.join("\n\n");
1504
+ const temp = tempTranscript(squeezedMessages(messages, sess, skipId), (m) => speakerNameOf(roles, m.speaker));
1505
+ if (!temp) return live;
1506
+ if (!live) return temp;
1507
+ return temp + "\n\n" + live;
1041
1508
  };
1042
- const speak = async (g, sess, role) => {
1509
+ const speak = async (g, sess, role, skipId) => {
1043
1510
  const ws = await materials.loadWorkspaceFiles(g);
1044
1511
  const parts = ws.parts;
1045
1512
  const sys = [
@@ -1049,6 +1516,7 @@ function createConversation(core, deps) {
1049
1516
  "- 名称:" + role.name,
1050
1517
  "- 人设:" + (role.persona ? role.persona : "(未填写,请以积极协作者的身份参与讨论)"),
1051
1518
  sess.topic ? "\n# 本会话主题\n" + sess.topic : "",
1519
+ constraintBlock(sess.constraints),
1052
1520
  materials.materialBlock(parts, ws.dir),
1053
1521
  ws.dir ? "\n# 可用工具\n你可以调用工具在群组工作区目录(" + ws.dir + ")内查看文件与目录" + (g.permissionTier === "workspace_write" ? "、执行 shell 命令(命令需用户逐条确认,请优先用于运行测试)" : g.permissionTier === "full_access" ? "、执行 shell 命令(命令将直接执行、无需确认,请谨慎并优先用于运行测试)" : "") + "。需要事实依据时优先用工具查看,不要凭空猜测。" : "",
1054
1522
  "\n# 发言要求",
@@ -1056,7 +1524,7 @@ function createConversation(core, deps) {
1056
1524
  "- 回应群内最新讨论(消息中「@你的名字」表示用户点名要求你回应,被点名时请优先回应);与其他成员自然对话;有不同观点可以提出并说明理由",
1057
1525
  "- 保持简洁,通常不超过 300 字"
1058
1526
  ].filter((s) => s !== "").join("\n");
1059
- const history = transcriptBlock(sess);
1527
+ const history = transcriptBlock(sess, skipId);
1060
1528
  const intro = history ? "以下是本会话的群聊记录(从旧到新):\n\n" + history : "本会话刚刚开始,请围绕主题做简短开场发言。";
1061
1529
  let wsRoot = null;
1062
1530
  if (ws.dir) try {
@@ -1230,6 +1698,7 @@ function createConversation(core, deps) {
1230
1698
  });
1231
1699
  for (const tc of round.tcs) {
1232
1700
  if (run.stopping) break;
1701
+ if (wsRoot === null) break;
1233
1702
  const res = await tools.executeTool(g, wsRoot, tc);
1234
1703
  const output = String(res.output || "");
1235
1704
  toolCalls.push({
@@ -1276,11 +1745,14 @@ function createConversation(core, deps) {
1276
1745
  toolCalls
1277
1746
  };
1278
1747
  };
1279
- const runLoop = async (sess) => {
1280
- const g = groups.get(sess.groupId);
1748
+ const runLoop = async (sess, opts) => {
1749
+ const g = groups.get(sess.groupId) ?? null;
1281
1750
  const startCount = sess.messageIds.length;
1282
1751
  let failed = false;
1752
+ let replaceId = opts && opts.replaceMessageId;
1753
+ run.replaceMessageId = replaceId || null;
1283
1754
  try {
1755
+ if (!g) return;
1284
1756
  while (run.queue.length > 0 && !run.stopping) {
1285
1757
  const roleId = run.queue.shift();
1286
1758
  const role = roles.get(roleId);
@@ -1289,16 +1761,27 @@ function createConversation(core, deps) {
1289
1761
  run.partialReasoning = "";
1290
1762
  touch();
1291
1763
  if (!role) continue;
1764
+ const targetId = replaceId;
1765
+ replaceId = void 0;
1292
1766
  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
- });
1767
+ const out = await speak(g, sess, role, targetId);
1768
+ if (!run.stopping && (out.text || Array.isArray(out.toolCalls) && out.toolCalls.length > 0)) writeSuccess(sess, role, out, targetId);
1769
+ else if (!run.stopping && targetId) {
1770
+ failed = true;
1771
+ run.currentRoleId = null;
1772
+ run.partial = "";
1773
+ run.partialReasoning = "";
1774
+ run.replaceMessageId = null;
1775
+ writeFailure(sess, role, "模型没有返回内容", targetId);
1776
+ break;
1777
+ }
1299
1778
  } catch (e) {
1300
1779
  failed = true;
1301
- appendMessage(sess, "system", "角色「" + role.name + "」发言失败:" + String(e && e.message || e), { error: true });
1780
+ run.currentRoleId = null;
1781
+ run.partial = "";
1782
+ run.partialReasoning = "";
1783
+ run.replaceMessageId = null;
1784
+ writeFailure(sess, role, e, targetId);
1302
1785
  break;
1303
1786
  }
1304
1787
  }
@@ -1318,9 +1801,13 @@ function createConversation(core, deps) {
1318
1801
  run.confirmSignal = null;
1319
1802
  run.childProc = null;
1320
1803
  run.stopping = false;
1804
+ run.replaceMessageId = null;
1321
1805
  touch();
1322
1806
  }
1323
- if (sess.messageIds.length > startCount) retitle(sess);
1807
+ if (sess.messageIds.length > startCount || opts && opts.replaceMessageId) {
1808
+ retitle(sess);
1809
+ fold(sess);
1810
+ }
1324
1811
  };
1325
1812
  return {
1326
1813
  appendMessage,
@@ -1591,42 +2078,6 @@ function createMaterials(core) {
1591
2078
  };
1592
2079
  }
1593
2080
  //#endregion
1594
- //#region src/core/json.ts
1595
- /** 消息 → 落盘 JSON(可选字段无则省略;兼容保留 error)。 */
1596
- function messageJson(m) {
1597
- if (!m || typeof m.id !== "string") return null;
1598
- const o = {
1599
- id: m.id,
1600
- speaker: m.speaker,
1601
- text: String(m.text || ""),
1602
- ts: typeof m.ts === "number" ? m.ts : Date.now(),
1603
- seq: typeof m.seq === "number" ? m.seq : 0
1604
- };
1605
- if (m.model !== void 0) o.model = m.model;
1606
- if (m.reasoning !== void 0) o.reasoning = m.reasoning;
1607
- if (m.reasoningFull !== void 0) o.reasoningFull = m.reasoningFull;
1608
- if (m.thinkingSummary !== void 0) o.thinkingSummary = m.thinkingSummary;
1609
- if (m.error !== void 0) o.error = m.error;
1610
- if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) o.toolCalls = m.toolCalls;
1611
- return o;
1612
- }
1613
- /** 角色 → 落盘 JSON(groupId 由目录归属,不再写入)。 */
1614
- function roleJson(r) {
1615
- const o = {
1616
- id: r.id,
1617
- name: String(r.name || "成员"),
1618
- persona: String(r.persona || ""),
1619
- provider: String(r.provider || ""),
1620
- model: String(r.model || ""),
1621
- enabled: r.enabled !== false,
1622
- thinking: r.thinking === true
1623
- };
1624
- if (r.color) o.color = r.color;
1625
- if (typeof r.temperature === "number" && !Number.isNaN(r.temperature)) o.temperature = r.temperature;
1626
- if (typeof r.reasoningEffort === "string" && r.reasoningEffort && r.reasoningEffort !== "default") o.reasoningEffort = r.reasoningEffort;
1627
- return o;
1628
- }
1629
- //#endregion
1630
2081
  //#region src/host/persistence/store.ts
1631
2082
  /**
1632
2083
  * 持久化(PERSISTENCE.md v2.1:会话级文件隔离)。
@@ -2021,6 +2472,8 @@ function createPersistence(core) {
2021
2472
  topic: s.topic,
2022
2473
  ...s.namePinned ? { namePinned: true } : {},
2023
2474
  ...s.topicPinned ? { topicPinned: true } : {},
2475
+ ...s.constraints && s.constraints.length ? { constraints: s.constraints } : {},
2476
+ ...typeof s.constraintsUpToSeq === "number" && s.constraintsUpToSeq > 0 ? { constraintsUpToSeq: s.constraintsUpToSeq } : {},
2024
2477
  createdAt: s.createdAt,
2025
2478
  messages: s.messageIds.map((mid) => core.messages.get(mid)).filter(Boolean).map((m) => messageJson(m)).filter(Boolean)
2026
2479
  });
@@ -2128,6 +2581,9 @@ function createPersistence(core) {
2128
2581
  if (typeof doc.topic === "string") sess.topic = doc.topic;
2129
2582
  if (doc.namePinned === true) sess.namePinned = true;
2130
2583
  if (doc.topicPinned === true) sess.topicPinned = true;
2584
+ const constraints = sanitizeConstraints(doc.constraints);
2585
+ if (constraints.length) sess.constraints = constraints;
2586
+ if (typeof doc.constraintsUpToSeq === "number" && doc.constraintsUpToSeq > 0) sess.constraintsUpToSeq = doc.constraintsUpToSeq;
2131
2587
  if (typeof doc.createdAt === "number") sess.createdAt = doc.createdAt;
2132
2588
  let fallbackSeq = 0;
2133
2589
  for (const m of Array.isArray(doc.messages) ? doc.messages : []) {
@@ -2144,11 +2600,19 @@ function createPersistence(core) {
2144
2600
  thinkingSummary: m.thinkingSummary !== void 0 ? String(m.thinkingSummary) : void 0,
2145
2601
  model: m.model,
2146
2602
  error: m.error,
2603
+ failedRoleId: typeof m.failedRoleId === "string" && m.failedRoleId ? m.failedRoleId : void 0,
2147
2604
  toolCalls: Array.isArray(m.toolCalls) ? m.toolCalls : void 0,
2148
2605
  ts: typeof m.ts === "number" ? m.ts : Date.now()
2149
2606
  });
2150
2607
  sess.messageIds.push(m.id);
2151
2608
  }
2609
+ const groupRoles = g.roleIds.map((rid) => core.roles.get(rid)).filter((r) => Boolean(r));
2610
+ let repaired = false;
2611
+ for (const mid of sess.messageIds) {
2612
+ const rec = core.messages.get(mid);
2613
+ if (rec && repairFailedMessage(rec, groupRoles)) repaired = true;
2614
+ }
2615
+ if (repaired) schedulePersist({ session: sid });
2152
2616
  }
2153
2617
  core.sessions.set(sid, sess);
2154
2618
  g.sessionIds.push(sid);
@@ -2223,7 +2687,7 @@ function createPersistence(core) {
2223
2687
  sessionIds: []
2224
2688
  };
2225
2689
  core.groups.set(g.id, g);
2226
- const sess = core.newSession(g.id, "会话 1");
2690
+ const sess = core.newSession(g.id);
2227
2691
  g.sessionIds.push(sess.id);
2228
2692
  autoSessions.push(sess.id);
2229
2693
  }