@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.
- package/README.md +91 -27
- package/lib/client.js +1254 -598
- package/lib/client.js.map +1 -1
- package/lib/index.js +573 -109
- package/lib/types/client/components/AsidePanel.d.ts +3 -19
- package/lib/types/client/components/Bubble.d.ts +6 -3
- package/lib/types/client/components/ChatPanel.d.ts +1 -0
- package/lib/types/client/components/ConstraintList.d.ts +11 -0
- package/lib/types/client/components/FailCard.d.ts +9 -0
- package/lib/types/client/components/Fold.d.ts +19 -0
- package/lib/types/client/components/HoverTip.d.ts +22 -0
- package/lib/types/client/components/MessageFlow.d.ts +1 -0
- package/lib/types/client/components/MsgActions.d.ts +14 -0
- package/lib/types/client/components/NavPanel.d.ts +9 -11
- package/lib/types/client/components/RoleDrawer.d.ts +3 -2
- package/lib/types/client/components/ToolRow.d.ts +2 -2
- package/lib/types/client/hooks/useComposer.d.ts +6 -1
- package/lib/types/client/hooks/useGroupChatState.d.ts +0 -16
- package/lib/types/client/lib/composer-draft.d.ts +17 -0
- package/lib/types/client/lib/model.d.ts +1 -7
- package/lib/types/client/utils/utils.d.ts +4 -4
- package/lib/types/core/constraints.d.ts +61 -0
- package/lib/types/core/errors.d.ts +54 -0
- package/lib/types/core/json.d.ts +6 -0
- package/lib/types/core/types.d.ts +76 -60
- package/lib/types/host/api/actions.d.ts +1 -1
- package/lib/types/host/engine/conversation.d.ts +5 -3
- package/lib/types/host/engine/defaults.d.ts +13 -0
- package/lib/types/host/engine/fold.d.ts +17 -0
- package/lib/types/host/engine/index.d.ts +1 -0
- package/lib/types/host/engine/retitle.d.ts +5 -5
- package/lib/types/host/service.d.ts +1 -1
- package/lib/types/host/state.d.ts +2 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/shared/file-mention-grammar.d.ts +3 -12
- package/package.json +1 -1
- package/src/client/GroupChatPanel.tsx +25 -59
- package/src/client/components/AsidePanel.tsx +50 -23
- package/src/client/components/Bubble.tsx +45 -18
- package/src/client/components/ChatPanel.tsx +17 -12
- package/src/client/components/Composer.tsx +26 -20
- package/src/client/components/ConstraintList.tsx +64 -0
- package/src/client/components/FailCard.tsx +49 -0
- package/src/client/components/Fold.tsx +72 -0
- package/src/client/components/HoverTip.tsx +134 -0
- package/src/client/components/MessageFlow.tsx +78 -54
- package/src/client/components/MsgActions.tsx +85 -0
- package/src/client/components/NavPanel.tsx +102 -79
- package/src/client/components/RoleDrawer.tsx +29 -7
- package/src/client/components/ThinkRow.tsx +7 -3
- package/src/client/components/ToolRow.tsx +9 -5
- package/src/client/hooks/useComposer.ts +54 -56
- package/src/client/hooks/useGroupChatState.ts +7 -17
- package/src/client/lib/composer-draft.ts +47 -0
- package/src/client/lib/model.ts +1 -14
- package/src/client/lib/styles.ts +73 -12
- package/src/client/react-dom-shim.d.ts +2 -0
- package/src/client/utils/utils.ts +6 -6
- package/src/core/constraints.ts +204 -0
- package/src/core/errors.ts +174 -0
- package/src/core/json.ts +18 -0
- package/src/core/types.ts +52 -19
- package/src/host/api/actions.ts +49 -14
- package/src/host/broadcast.ts +3 -3
- package/src/host/engine/conversation.ts +104 -40
- package/src/host/engine/defaults.ts +28 -0
- package/src/host/engine/fold.ts +99 -0
- package/src/host/engine/index.ts +1 -0
- package/src/host/engine/retitle.ts +24 -41
- package/src/host/materials/materials.ts +2 -0
- package/src/host/persistence/persistence.ts +18 -1
- package/src/host/service.ts +1 -1
- package/src/host/state.ts +5 -4
- package/src/host/tools/tools.ts +2 -2
- package/src/index.ts +2 -0
- package/src/shared/file-mention-grammar.test.ts +1 -38
- package/src/shared/file-mention-grammar.ts +7 -33
- package/lib/types/client/Bubble.d.ts +0 -10
- package/lib/types/client/Glyph.d.ts +0 -12
- package/lib/types/client/GroupChatPanel.old.d.ts +0 -10
- package/lib/types/client/GroupChatSettingsSection.d.ts +0 -15
- package/lib/types/client/PermissionSelect.d.ts +0 -13
- package/lib/types/client/RoleDrawer.d.ts +0 -17
- package/lib/types/client/ThinkRow.d.ts +0 -9
- package/lib/types/client/ToolRow.d.ts +0 -9
- package/lib/types/client/api.d.ts +0 -8
- package/lib/types/client/components.d.ts +0 -22
- package/lib/types/client/drawer.d.ts +0 -17
- package/lib/types/client/glyph.d.ts +0 -12
- package/lib/types/client/model.d.ts +0 -78
- package/lib/types/client/panel.d.ts +0 -10
- package/lib/types/client/settings.d.ts +0 -15
- package/lib/types/client/styles.d.ts +0 -10
- package/lib/types/client/ui.d.ts +0 -17
- package/lib/types/client/utils.d.ts +0 -22
- package/lib/types/host/actions.d.ts +0 -34
- package/lib/types/host/conversation.d.ts +0 -25
- package/lib/types/host/http.d.ts +0 -16
- package/lib/types/host/materials.d.ts +0 -32
- package/lib/types/host/persistence.d.ts +0 -31
- package/lib/types/host/routes.d.ts +0 -11
- package/lib/types/host/store.d.ts +0 -65
- 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 || "
|
|
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
|
|
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/
|
|
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
|
-
|
|
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
|
-
/** 宽容解析模型输出:剥代码围栏 →
|
|
1306
|
+
/** 宽容解析模型输出:剥代码围栏 → looseJson → 校验并封顶字段。 */
|
|
876
1307
|
const parseRetitle = (raw) => {
|
|
877
|
-
const
|
|
878
|
-
|
|
879
|
-
const
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
*
|
|
896
|
-
*
|
|
897
|
-
*
|
|
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
|
-
|
|
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
|
-
"当前名称:" + (
|
|
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
|
|
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
|
-
/**
|
|
1015
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
2690
|
+
const sess = core.newSession(g.id);
|
|
2227
2691
|
g.sessionIds.push(sess.id);
|
|
2228
2692
|
autoSessions.push(sess.id);
|
|
2229
2693
|
}
|