@roaming-ai/dsh-group-chat 0.2.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 (136) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/client.js +3308 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.js +2583 -0
  7. package/lib/types/client/Bubble.d.ts +10 -0
  8. package/lib/types/client/Glyph.d.ts +12 -0
  9. package/lib/types/client/GroupChatPanel.d.ts +10 -0
  10. package/lib/types/client/GroupChatPanel.old.d.ts +10 -0
  11. package/lib/types/client/GroupChatSettingsSection.d.ts +15 -0
  12. package/lib/types/client/PermissionSelect.d.ts +13 -0
  13. package/lib/types/client/RoleDrawer.d.ts +17 -0
  14. package/lib/types/client/ThinkRow.d.ts +9 -0
  15. package/lib/types/client/ToolRow.d.ts +9 -0
  16. package/lib/types/client/api.d.ts +8 -0
  17. package/lib/types/client/components/AsidePanel.d.ts +31 -0
  18. package/lib/types/client/components/Bubble.d.ts +19 -0
  19. package/lib/types/client/components/ChatPanel.d.ts +57 -0
  20. package/lib/types/client/components/Composer.d.ts +48 -0
  21. package/lib/types/client/components/Glyph.d.ts +12 -0
  22. package/lib/types/client/components/GroupChatSettingsSection.d.ts +15 -0
  23. package/lib/types/client/components/MessageFlow.d.ts +15 -0
  24. package/lib/types/client/components/NavPanel.d.ts +40 -0
  25. package/lib/types/client/components/PermissionSelect.d.ts +13 -0
  26. package/lib/types/client/components/RoleDrawer.d.ts +17 -0
  27. package/lib/types/client/components/ThinkRow.d.ts +9 -0
  28. package/lib/types/client/components/ToolRow.d.ts +9 -0
  29. package/lib/types/client/components/index.d.ts +9 -0
  30. package/lib/types/client/components.d.ts +22 -0
  31. package/lib/types/client/drawer.d.ts +17 -0
  32. package/lib/types/client/glyph.d.ts +12 -0
  33. package/lib/types/client/hooks/index.d.ts +6 -0
  34. package/lib/types/client/hooks/useComposer.d.ts +27 -0
  35. package/lib/types/client/hooks/useFileSearch.d.ts +19 -0
  36. package/lib/types/client/hooks/useGroupChatState.d.ts +95 -0
  37. package/lib/types/client/hooks/useMentionState.d.ts +27 -0
  38. package/lib/types/client/index.d.ts +52 -0
  39. package/lib/types/client/lib/api.d.ts +8 -0
  40. package/lib/types/client/lib/model.d.ts +80 -0
  41. package/lib/types/client/lib/styles.d.ts +10 -0
  42. package/lib/types/client/lib/ui.d.ts +17 -0
  43. package/lib/types/client/model.d.ts +78 -0
  44. package/lib/types/client/panel.d.ts +10 -0
  45. package/lib/types/client/settings.d.ts +15 -0
  46. package/lib/types/client/styles.d.ts +10 -0
  47. package/lib/types/client/ui.d.ts +17 -0
  48. package/lib/types/client/utils/utils.d.ts +29 -0
  49. package/lib/types/client/utils.d.ts +22 -0
  50. package/lib/types/core/json.d.ts +9 -0
  51. package/lib/types/core/status.d.ts +18 -0
  52. package/lib/types/core/tools.d.ts +59 -0
  53. package/lib/types/core/types.d.ts +256 -0
  54. package/lib/types/host/actions.d.ts +34 -0
  55. package/lib/types/host/api/actions.d.ts +36 -0
  56. package/lib/types/host/api/http.d.ts +16 -0
  57. package/lib/types/host/api/index.d.ts +7 -0
  58. package/lib/types/host/api/routes.d.ts +11 -0
  59. package/lib/types/host/broadcast.d.ts +20 -0
  60. package/lib/types/host/conversation.d.ts +25 -0
  61. package/lib/types/host/engine/conversation.d.ts +25 -0
  62. package/lib/types/host/engine/index.d.ts +7 -0
  63. package/lib/types/host/engine/retitle.d.ts +20 -0
  64. package/lib/types/host/http.d.ts +16 -0
  65. package/lib/types/host/index.d.ts +8 -0
  66. package/lib/types/host/materials/index.d.ts +6 -0
  67. package/lib/types/host/materials/materials.d.ts +40 -0
  68. package/lib/types/host/materials.d.ts +32 -0
  69. package/lib/types/host/persistence/index.d.ts +6 -0
  70. package/lib/types/host/persistence/persistence.d.ts +31 -0
  71. package/lib/types/host/persistence/store.d.ts +65 -0
  72. package/lib/types/host/persistence.d.ts +31 -0
  73. package/lib/types/host/routes.d.ts +11 -0
  74. package/lib/types/host/service.d.ts +28 -0
  75. package/lib/types/host/state.d.ts +45 -0
  76. package/lib/types/host/store.d.ts +65 -0
  77. package/lib/types/host/tools/index.d.ts +6 -0
  78. package/lib/types/host/tools/tools.d.ts +28 -0
  79. package/lib/types/host/tools.d.ts +28 -0
  80. package/lib/types/index.d.ts +41 -0
  81. package/lib/types/mount-once.d.ts +9 -0
  82. package/lib/types/shared/file-mention-grammar.d.ts +37 -0
  83. package/lib/types/shared/file-mention-grammar.test.d.ts +5 -0
  84. package/package.json +98 -0
  85. package/src/client/GroupChatPanel.tsx +385 -0
  86. package/src/client/components/AsidePanel.tsx +188 -0
  87. package/src/client/components/Bubble.tsx +76 -0
  88. package/src/client/components/ChatPanel.tsx +214 -0
  89. package/src/client/components/Composer.tsx +238 -0
  90. package/src/client/components/Glyph.tsx +27 -0
  91. package/src/client/components/GroupChatSettingsSection.tsx +74 -0
  92. package/src/client/components/MessageFlow.tsx +97 -0
  93. package/src/client/components/NavPanel.tsx +249 -0
  94. package/src/client/components/PermissionSelect.tsx +159 -0
  95. package/src/client/components/RoleDrawer.tsx +186 -0
  96. package/src/client/components/ThinkRow.tsx +32 -0
  97. package/src/client/components/ToolRow.tsx +51 -0
  98. package/src/client/components/index.ts +10 -0
  99. package/src/client/hooks/index.ts +7 -0
  100. package/src/client/hooks/useComposer.ts +272 -0
  101. package/src/client/hooks/useFileSearch.ts +115 -0
  102. package/src/client/hooks/useGroupChatState.ts +214 -0
  103. package/src/client/hooks/useMentionState.ts +56 -0
  104. package/src/client/index.ts +110 -0
  105. package/src/client/lib/api.ts +23 -0
  106. package/src/client/lib/model.ts +119 -0
  107. package/src/client/lib/styles.ts +317 -0
  108. package/src/client/lib/ui.ts +27 -0
  109. package/src/client/react-dom-shim.d.ts +13 -0
  110. package/src/client/utils/utils.ts +104 -0
  111. package/src/core/json.ts +28 -0
  112. package/src/core/status.ts +33 -0
  113. package/src/core/tools.ts +41 -0
  114. package/src/core/types.ts +222 -0
  115. package/src/host/api/actions.ts +335 -0
  116. package/src/host/api/http.ts +80 -0
  117. package/src/host/api/index.ts +8 -0
  118. package/src/host/api/routes.ts +95 -0
  119. package/src/host/broadcast.ts +65 -0
  120. package/src/host/engine/conversation.ts +331 -0
  121. package/src/host/engine/index.ts +8 -0
  122. package/src/host/engine/retitle.ts +135 -0
  123. package/src/host/index.ts +9 -0
  124. package/src/host/materials/index.ts +7 -0
  125. package/src/host/materials/materials.ts +193 -0
  126. package/src/host/persistence/index.ts +7 -0
  127. package/src/host/persistence/persistence.ts +311 -0
  128. package/src/host/persistence/store.ts +375 -0
  129. package/src/host/service.ts +78 -0
  130. package/src/host/state.ts +85 -0
  131. package/src/host/tools/index.ts +7 -0
  132. package/src/host/tools/tools.ts +194 -0
  133. package/src/index.ts +99 -0
  134. package/src/mount-once.ts +49 -0
  135. package/src/shared/file-mention-grammar.test.ts +77 -0
  136. package/src/shared/file-mention-grammar.ts +82 -0
package/lib/index.js ADDED
@@ -0,0 +1,2583 @@
1
+ import z from "schemastery";
2
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
4
+ import { homedir } from "node:os";
5
+ import { basename, dirname, isAbsolute, join, sep } from "node:path";
6
+ import { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, WorkspaceFileSearch } from "@deepseek-ai/dsh-file-reference-local";
7
+ import { spawn } from "node:child_process";
8
+ //#region src/mount-once.ts
9
+ /**
10
+ * Host single-instance guard shared by the plugin family. The family bundle
11
+ * (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so
12
+ * the loader accepts a standalone install of the same package side by side;
13
+ * without this guard the second instance would still re-register the same
14
+ * webserver routes, tools, settings namespaces, and system-prompt sections
15
+ * and fail the boot. mountOnce makes the second host apply a no-op for the
16
+ * lifetime of the first instance (the browser half is already deduped by
17
+ * package name in the client module host).
18
+ *
19
+ * The registry rides a global symbol so two module instances of the same
20
+ * package (npm copy vs repository link) still share one verdict. cordis
21
+ * `ctx.effect` runs its callback immediately and treats the callback's
22
+ * return value as the fiber disposer, so the unmarker is returned, not run.
23
+ */
24
+ const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
25
+ function mountedSet() {
26
+ const registry = globalThis;
27
+ return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
28
+ }
29
+ /**
30
+ * Wrap a cordis plugin apply so the package runs at most once per process.
31
+ * The first mount registers normally and unmarks when its fiber disposes;
32
+ * any later mount of the same package name is a no-op.
33
+ * @param packageName - npm package identity shared by every install source.
34
+ * @param fn - the original plugin apply.
35
+ * @returns an apply of the same shape.
36
+ */
37
+ function mountOnce(packageName, fn) {
38
+ return ((...args) => {
39
+ const mounted = mountedSet();
40
+ if (mounted.has(packageName)) return;
41
+ mounted.add(packageName);
42
+ args[0]?.effect?.(() => () => {
43
+ mounted.delete(packageName);
44
+ });
45
+ return fn(...args);
46
+ });
47
+ }
48
+ //#endregion
49
+ //#region src/core/types.ts
50
+ /** 全部合法档位(展示顺序)。 */
51
+ const PERMISSION_TIERS = [
52
+ "view_only",
53
+ "workspace_write",
54
+ "full_access"
55
+ ];
56
+ /** 档位安全化:合法字符串原样,其余 undefined。 */
57
+ function asPermissionTier(value) {
58
+ return typeof value === "string" && PERMISSION_TIERS.includes(value) ? value : void 0;
59
+ }
60
+ /**
61
+ * ledger 行档位归一(v2→v3 兼容):显式 permissionTier 优先;
62
+ * 缺失时按旧布尔迁移——allowCommands=true → workspace_write(今日语义即
63
+ * 「可执行命令但逐条确认」),false/无字段 → view_only。
64
+ */
65
+ function migrateTier(permissionTier, allowCommands) {
66
+ const tier = asPermissionTier(permissionTier);
67
+ if (tier !== void 0) return tier;
68
+ return allowCommands === true ? "workspace_write" : "view_only";
69
+ }
70
+ /** 可选数值参数安全化:数字则原样,否则 undefined。 */
71
+ function asNumber(value) {
72
+ return typeof value === "number" && !Number.isNaN(value) ? value : void 0;
73
+ }
74
+ /** 可选字符串参数安全化:非 default 的非空字符串则原样,否则 undefined。 */
75
+ function asEffort(value) {
76
+ return typeof value === "string" && value && value !== "default" ? value : void 0;
77
+ }
78
+ //#endregion
79
+ //#region src/host/state.ts
80
+ /**
81
+ * 群聊宿主服务共享状态容器:四张表 + run(对话进行时状态)+ 可变槽位
82
+ * (store / revision / idSeq / lastCreated)。各功能模块(persistence /
83
+ * broadcast / materials / tools / conversation / actions)以工厂装配到同一
84
+ * 容器上,模块间只经显式依赖传递;容器本身只含数据与稳定引用,不含行为。
85
+ * @module dsh-group-chat/host/state
86
+ */
87
+ /** 角色标识色板(新增角色依序取色)。 */
88
+ const PALETTE = [
89
+ "#5b8def",
90
+ "#22a06b",
91
+ "#e8912d",
92
+ "#c678dd",
93
+ "#e05661",
94
+ "#56b6c2",
95
+ "#98c379",
96
+ "#d19a66"
97
+ ];
98
+ /** 创建共享状态容器(仅数据与稳定引用,不含行为)。 */
99
+ function createHostState(ctx) {
100
+ const core = {
101
+ ctx,
102
+ llm: ctx.llm,
103
+ fs: ctx.fs,
104
+ groups: /* @__PURE__ */ new Map(),
105
+ sessions: /* @__PURE__ */ new Map(),
106
+ roles: /* @__PURE__ */ new Map(),
107
+ messages: /* @__PURE__ */ new Map(),
108
+ run: {
109
+ running: false,
110
+ sessionId: null,
111
+ currentRoleId: null,
112
+ partial: "",
113
+ partialReasoning: "",
114
+ stopping: false,
115
+ queue: [],
116
+ pendingConfirm: null,
117
+ confirmSignal: null,
118
+ childProc: null,
119
+ finished: null
120
+ },
121
+ store: null,
122
+ revision: 1,
123
+ idSeq: 1,
124
+ nid: (p) => p + "-" + core.idSeq++,
125
+ lastCreated: null,
126
+ newSession: (groupId, name) => {
127
+ let n = 0;
128
+ for (const s of core.sessions.values()) if (s.groupId === groupId) n++;
129
+ const s = {
130
+ id: randomUUID(),
131
+ groupId,
132
+ name: name || "会话 " + (n + 1),
133
+ topic: "",
134
+ messageIds: [],
135
+ createdAt: Date.now()
136
+ };
137
+ core.sessions.set(s.id, s);
138
+ return s;
139
+ }
140
+ };
141
+ return core;
142
+ }
143
+ //#endregion
144
+ //#region src/host/api/actions.ts
145
+ /**
146
+ * 动作分发(handleAction,POST /api/group-chat/action 的载荷):
147
+ * mutate(12 种 CRUD/配置操作)| send | stop | confirmCommand | models | efforts。
148
+ * @module dsh-group-chat/host/api/actions
149
+ */
150
+ /** 创建动作分发面。 */
151
+ function createActions(core, deps) {
152
+ const { llm, groups, sessions, roles, messages, run } = core;
153
+ const { touch, snapshot, schedulePersist, dropDirty, appendMessage, runLoop, wakeConfirm, killChild, browse, fileSearch, disposeFileSearch } = deps;
154
+ const mutate = (args) => {
155
+ const op = args && args.op;
156
+ if (op === "createGroup") {
157
+ const g = {
158
+ id: core.nid("grp"),
159
+ name: String(args.name || "").trim() || "群组 " + (groups.size + 1),
160
+ workspaceDir: "",
161
+ permissionTier: "view_only",
162
+ roleIds: [],
163
+ sessionIds: []
164
+ };
165
+ groups.set(g.id, g);
166
+ const sess = core.newSession(g.id);
167
+ g.sessionIds.push(sess.id);
168
+ core.lastCreated = {
169
+ kind: "group",
170
+ groupId: g.id,
171
+ sessionId: sess.id
172
+ };
173
+ schedulePersist({
174
+ ledger: true,
175
+ session: sess.id
176
+ });
177
+ touch();
178
+ } else if (op === "renameGroup") {
179
+ const g = groups.get(args.groupId);
180
+ if (g && String(args.name || "").trim()) {
181
+ g.name = String(args.name).trim();
182
+ schedulePersist({ ledger: true });
183
+ touch();
184
+ }
185
+ } else if (op === "deleteGroup") {
186
+ const g = groups.get(args.groupId);
187
+ if (!g) return {
188
+ ...snapshot(),
189
+ error: "群组不存在"
190
+ };
191
+ if (groups.size <= 1) return {
192
+ ...snapshot(),
193
+ error: "至少保留一个群组"
194
+ };
195
+ if (run.running) {
196
+ const runSession = sessions.get(run.sessionId);
197
+ if (runSession && runSession.groupId === g.id) return {
198
+ ...snapshot(),
199
+ error: "对话进行中,无法删除群组"
200
+ };
201
+ }
202
+ for (const sid of g.sessionIds) {
203
+ const sess = sessions.get(sid);
204
+ if (sess) for (const mid of sess.messageIds) messages.delete(mid);
205
+ sessions.delete(sid);
206
+ dropDirty({ session: sid });
207
+ }
208
+ for (const rid of g.roleIds) roles.delete(rid);
209
+ groups.delete(g.id);
210
+ disposeFileSearch(g.id);
211
+ dropDirty({
212
+ roles: g.id,
213
+ workspace: g.id
214
+ });
215
+ if (core.store !== null) try {
216
+ rmSync(core.store.groupDir(g.id), {
217
+ recursive: true,
218
+ force: true
219
+ });
220
+ } catch (e) {
221
+ console.error("[dsh-group-chat] 群组目录清理失败:", e);
222
+ }
223
+ schedulePersist({ ledger: true });
224
+ touch();
225
+ } else if (op === "createSession") {
226
+ const g = groups.get(args.groupId);
227
+ if (!g) return {
228
+ ...snapshot(),
229
+ error: "群组不存在"
230
+ };
231
+ const sess = core.newSession(g.id, String(args.name || "").trim() || void 0);
232
+ g.sessionIds.push(sess.id);
233
+ core.lastCreated = {
234
+ kind: "session",
235
+ groupId: g.id,
236
+ sessionId: sess.id
237
+ };
238
+ schedulePersist({
239
+ ledger: true,
240
+ session: sess.id
241
+ });
242
+ touch();
243
+ } else if (op === "renameSession") {
244
+ const sess = sessions.get(args.sessionId);
245
+ if (sess && String(args.name || "").trim()) {
246
+ sess.name = String(args.name).trim();
247
+ sess.namePinned = true;
248
+ schedulePersist({ session: sess.id });
249
+ touch();
250
+ }
251
+ } else if (op === "deleteSession") {
252
+ const sess = sessions.get(args.sessionId);
253
+ if (!sess) return {
254
+ ...snapshot(),
255
+ error: "会话不存在"
256
+ };
257
+ const g = groups.get(sess.groupId);
258
+ if (g && g.sessionIds.length <= 1) return {
259
+ ...snapshot(),
260
+ error: "每个群组至少保留一个会话"
261
+ };
262
+ if (run.running && run.sessionId === sess.id) return {
263
+ ...snapshot(),
264
+ error: "对话进行中,无法删除会话"
265
+ };
266
+ if (run.finished && run.finished.sessionId === sess.id) run.finished = null;
267
+ for (const mid of sess.messageIds) messages.delete(mid);
268
+ sessions.delete(sess.id);
269
+ dropDirty({ session: sess.id });
270
+ if (g) g.sessionIds = g.sessionIds.filter((x) => x !== sess.id);
271
+ if (core.store !== null) try {
272
+ unlinkSync(core.store.sessionFile(sess.groupId, sess.id));
273
+ } catch {}
274
+ schedulePersist({ ledger: true });
275
+ touch();
276
+ } else if (op === "setTopic") {
277
+ const sess = sessions.get(args.sessionId);
278
+ if (sess) {
279
+ sess.topic = String(args.topic || "");
280
+ sess.topicPinned = true;
281
+ schedulePersist({ session: sess.id });
282
+ touch();
283
+ }
284
+ } else if (op === "upsertRole") {
285
+ const g = groups.get(args.groupId);
286
+ const r = args.role || {};
287
+ if (!g) return {
288
+ ...snapshot(),
289
+ error: "群组不存在"
290
+ };
291
+ if (!r.name || !String(r.name).trim()) return {
292
+ ...snapshot(),
293
+ error: "角色名称不能为空"
294
+ };
295
+ if (!r.provider || !r.model) return {
296
+ ...snapshot(),
297
+ error: "请选择角色绑定的模型"
298
+ };
299
+ if (r.id && roles.get(r.id)) {
300
+ const ex = roles.get(r.id);
301
+ ex.name = String(r.name).trim();
302
+ ex.color = r.color || ex.color;
303
+ ex.persona = r.persona || "";
304
+ ex.provider = r.provider;
305
+ ex.model = r.model;
306
+ ex.temperature = asNumber(r.temperature);
307
+ ex.reasoningEffort = asEffort(r.reasoningEffort);
308
+ ex.enabled = r.enabled !== false;
309
+ ex.thinking = r.thinking === true;
310
+ schedulePersist({ roles: g.id });
311
+ touch();
312
+ } else {
313
+ const nr = {
314
+ id: core.nid("role"),
315
+ groupId: g.id,
316
+ name: String(r.name).trim(),
317
+ color: r.color || PALETTE[g.roleIds.length % PALETTE.length],
318
+ persona: r.persona || "",
319
+ provider: r.provider,
320
+ model: r.model,
321
+ temperature: asNumber(r.temperature),
322
+ reasoningEffort: asEffort(r.reasoningEffort),
323
+ enabled: true,
324
+ thinking: r.thinking === true
325
+ };
326
+ roles.set(nr.id, nr);
327
+ g.roleIds.push(nr.id);
328
+ schedulePersist({ roles: g.id });
329
+ touch();
330
+ }
331
+ } else if (op === "deleteRole") {
332
+ const r = roles.get(args.roleId);
333
+ if (r) {
334
+ const gid = r.groupId;
335
+ const g = groups.get(gid);
336
+ if (g) g.roleIds = g.roleIds.filter((x) => x !== r.id);
337
+ roles.delete(r.id);
338
+ schedulePersist({ roles: gid });
339
+ touch();
340
+ }
341
+ } else if (op === "setRoleEnabled") {
342
+ const r = roles.get(args.roleId);
343
+ if (r) {
344
+ r.enabled = !!args.enabled;
345
+ schedulePersist({ roles: r.groupId });
346
+ touch();
347
+ }
348
+ } else if (op === "setWorkspaceDir") {
349
+ const g = groups.get(args.groupId);
350
+ if (g) {
351
+ g.workspaceDir = String(args.path || "").trim();
352
+ schedulePersist({ workspace: g.id });
353
+ touch();
354
+ }
355
+ } else if (op === "setPermissionTier") {
356
+ const tier = asPermissionTier(args.tier);
357
+ if (!tier) return {
358
+ ...snapshot(),
359
+ error: "未知权限档位"
360
+ };
361
+ const g = groups.get(args.groupId);
362
+ if (g) {
363
+ g.permissionTier = tier;
364
+ if (tier === "view_only" && run.pendingConfirm) {
365
+ const runSession = run.sessionId ? sessions.get(run.sessionId) : null;
366
+ if (runSession && runSession.groupId === g.id) wakeConfirm();
367
+ }
368
+ schedulePersist({ ledger: true });
369
+ touch();
370
+ }
371
+ } else if (op === "ackFinish") {
372
+ if (run.finished) {
373
+ run.finished = null;
374
+ touch();
375
+ }
376
+ } else if (op === "clearMessages") {
377
+ const sess = sessions.get(args.sessionId);
378
+ if (sess) {
379
+ if (run.running && run.sessionId === sess.id) return {
380
+ ...snapshot(),
381
+ error: "对话进行中,无法清空"
382
+ };
383
+ for (const mid of sess.messageIds) messages.delete(mid);
384
+ sess.messageIds = [];
385
+ schedulePersist({ session: sess.id });
386
+ touch();
387
+ }
388
+ }
389
+ return snapshot();
390
+ };
391
+ const send = (args) => {
392
+ const sess = sessions.get(args.sessionId);
393
+ if (!sess) return {
394
+ ok: false,
395
+ error: "会话不存在"
396
+ };
397
+ const g = groups.get(sess.groupId);
398
+ if (!g) return {
399
+ ok: false,
400
+ error: "群组不存在"
401
+ };
402
+ if (run.running) return {
403
+ ok: false,
404
+ error: "已有对话进行中,请先停止"
405
+ };
406
+ let parts = Array.isArray(args.participantRoleIds) ? args.participantRoleIds.slice() : [];
407
+ parts = parts.filter((id) => {
408
+ const r = roles.get(id);
409
+ return r && r.groupId === g.id && r.enabled;
410
+ });
411
+ if (!parts.length) parts = g.roleIds.filter((id) => {
412
+ const r = roles.get(id);
413
+ return r && r.enabled;
414
+ });
415
+ if (!parts.length) return {
416
+ ok: false,
417
+ error: "群内还没有启用的角色,请先添加角色"
418
+ };
419
+ const rounds = Math.max(1, Math.min(10, Math.floor(Number(args.rounds) || 1)));
420
+ const text = String(args.text || "").trim().slice(0, 32e3);
421
+ if (text) appendMessage(sess, "user", text);
422
+ const queue = [];
423
+ for (let i = 0; i < rounds; i++) for (const rid of parts) queue.push(rid);
424
+ run.running = true;
425
+ run.sessionId = sess.id;
426
+ run.queue = queue;
427
+ run.stopping = false;
428
+ run.finished = null;
429
+ touch();
430
+ runLoop(sess).catch((e) => console.error("group-chat run failed", e));
431
+ return { ok: true };
432
+ };
433
+ const stop = (args) => {
434
+ if (run.running && (!args || !args.sessionId || run.sessionId === args.sessionId)) {
435
+ run.stopping = true;
436
+ if (run.pendingConfirm) wakeConfirm();
437
+ killChild();
438
+ touch();
439
+ }
440
+ return { ok: true };
441
+ };
442
+ /** run_command 确认(TOOLS.md §4):同步三查全过才置空放行,杜绝并发/伪造/stale 确认。 */
443
+ const confirmCommand = (args) => {
444
+ if (!run.running || !run.pendingConfirm || run.pendingConfirm.toolCallId !== (args && args.toolCallId)) return {
445
+ ok: false,
446
+ error: "确认请求不存在或已处理"
447
+ };
448
+ const signal = run.confirmSignal;
449
+ run.pendingConfirm = null;
450
+ run.confirmSignal = null;
451
+ touch();
452
+ if (signal) try {
453
+ signal.resolve(!!(args && args.allow));
454
+ } catch {}
455
+ return { ok: true };
456
+ };
457
+ let modelCache = null;
458
+ const models = async () => {
459
+ const now = Date.now();
460
+ if (modelCache && now - modelCache.at < 6e4) return modelCache;
461
+ const providers = await llm.listProviders();
462
+ const modelsByProvider = {};
463
+ for (const p of providers) try {
464
+ modelsByProvider[p.id] = await llm.listModels(p.id);
465
+ } catch {
466
+ modelsByProvider[p.id] = [];
467
+ }
468
+ modelCache = {
469
+ at: now,
470
+ providers: providers.map((p) => ({
471
+ id: p.id,
472
+ name: p.name || p.id
473
+ })),
474
+ modelsByProvider
475
+ };
476
+ return modelCache;
477
+ };
478
+ const effortCache = /* @__PURE__ */ new Map();
479
+ const efforts = async (args) => {
480
+ const provider = String(args && args.provider || "");
481
+ const model = String(args && args.model || "");
482
+ if (!provider || !model) return {
483
+ ok: false,
484
+ efforts: [],
485
+ defaultEffort: void 0,
486
+ error: "缺少 provider 或 model"
487
+ };
488
+ const key = provider + "|" + model;
489
+ const hit = effortCache.get(key);
490
+ if (hit && Date.now() - hit.at < 6e4) return {
491
+ ok: true,
492
+ ...hit.data
493
+ };
494
+ let data;
495
+ try {
496
+ const info = await llm.resolveModelInfo(provider, model);
497
+ const reasoning = info && info.reasoning;
498
+ data = {
499
+ efforts: (reasoning && Array.isArray(reasoning.efforts) ? reasoning.efforts : []).map((e) => ({
500
+ id: e.id,
501
+ name: e.name || e.id,
502
+ description: e.description
503
+ })),
504
+ defaultEffort: reasoning && typeof reasoning.defaultEffort === "string" ? reasoning.defaultEffort : void 0
505
+ };
506
+ } catch (e) {
507
+ data = {
508
+ efforts: [],
509
+ defaultEffort: void 0,
510
+ error: String(e && e.message || e)
511
+ };
512
+ }
513
+ effortCache.set(key, {
514
+ at: Date.now(),
515
+ data
516
+ });
517
+ return {
518
+ ok: true,
519
+ ...data
520
+ };
521
+ };
522
+ const handleAction = async (body) => {
523
+ const kind = body && body.kind;
524
+ if (kind === "mutate") return {
525
+ ok: true,
526
+ snapshot: mutate(body),
527
+ lastCreated: core.lastCreated
528
+ };
529
+ if (kind === "send") return send(body);
530
+ if (kind === "stop") return stop(body);
531
+ if (kind === "confirmCommand") return confirmCommand(body);
532
+ if (kind === "models") return {
533
+ ok: true,
534
+ ...await models()
535
+ };
536
+ if (kind === "efforts") return efforts(body);
537
+ if (kind === "browse") return browse(body);
538
+ if (kind === "fileSearch") return fileSearch(body);
539
+ return {
540
+ ok: false,
541
+ error: "unknown-action"
542
+ };
543
+ };
544
+ return { handleAction };
545
+ }
546
+ //#endregion
547
+ //#region src/host/api/http.ts
548
+ /** 请求体上限 1MB。 */
549
+ const BODY_LIMIT = 1048576;
550
+ const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
551
+ /** IPv4 127/8 谓词(四段十进制,首段 == 127)。 */
552
+ function isIPv4Loopback(v4) {
553
+ const parts = v4.split(".");
554
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
555
+ }
556
+ /** socket 远端地址是否落在回环段(127/8、::1、IPv4-mapped)。 */
557
+ function isLoopbackAddress(address) {
558
+ if (address === void 0) return false;
559
+ const normalized = address.toLowerCase();
560
+ if (normalized === "::1") return true;
561
+ if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
562
+ return isIPv4Loopback(normalized);
563
+ }
564
+ /** 归一化 URL 主机名是否为回环权威(localhost、[::1]、127/8)。 */
565
+ function isLoopbackHostname(hostname) {
566
+ if (hostname === "localhost" || hostname === "[::1]") return true;
567
+ return isIPv4Loopback(hostname);
568
+ }
569
+ /**
570
+ * 请求级信任栏:浏览器同源标记(sec-fetch-site / Origin)必须存在且回环
571
+ * socket + 回环 Host 全过;X-Forwarded-For 一律不信任。无任何同源标记的
572
+ * 非浏览器请求(如裸 curl)一律拒绝。
573
+ */
574
+ function isTrustedRequest(req) {
575
+ if (!(req.headers["sec-fetch-site"] === "same-origin" || typeof req.headers.origin === "string")) return false;
576
+ if (!isLoopbackAddress(req.socket.remoteAddress)) return false;
577
+ const host = req.headers.host;
578
+ if (typeof host !== "string") return false;
579
+ let hostUrl;
580
+ try {
581
+ hostUrl = new URL("http://" + host);
582
+ } catch {
583
+ return false;
584
+ }
585
+ if (!isLoopbackHostname(hostUrl.hostname)) return false;
586
+ if (req.headers["sec-fetch-site"] === "cross-site") return false;
587
+ const origin = req.headers.origin;
588
+ if (origin === void 0) return true;
589
+ try {
590
+ return new URL(origin).host === hostUrl.host;
591
+ } catch {
592
+ return false;
593
+ }
594
+ }
595
+ /** 读有界 JSON 请求体;超限抛 'body-too-large'(路由映射 413)。 */
596
+ async function readBody(req) {
597
+ const chunks = [];
598
+ let size = 0;
599
+ for await (const chunk of req) {
600
+ const buffer = chunk;
601
+ size += buffer.length;
602
+ if (size > BODY_LIMIT) throw new Error("body-too-large");
603
+ chunks.push(buffer);
604
+ }
605
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
606
+ }
607
+ /** 写 JSON 响应;调用方可追加/覆盖默认头。 */
608
+ function writeJson(res, status, body, headers = {}) {
609
+ const payload = JSON.stringify(body);
610
+ res.writeHead(status, {
611
+ ...JSON_HEADERS,
612
+ ...headers
613
+ });
614
+ res.end(payload);
615
+ }
616
+ //#endregion
617
+ //#region src/host/api/routes.ts
618
+ const API_PREFIX = "/api/group-chat";
619
+ /** SSE 心跳周期。 */
620
+ const HEARTBEAT_MS = 15e3;
621
+ /** 逐路由的信任栏 + 403。 */
622
+ function guard(req, res) {
623
+ if (isTrustedRequest(req)) return true;
624
+ writeJson(res, 403, {
625
+ ok: false,
626
+ error: "forbidden"
627
+ }, { "cache-control": "no-store" });
628
+ return false;
629
+ }
630
+ /** 构建群聊的三条 HTTP 路由。 */
631
+ function makeGroupChatRoutes(service) {
632
+ return [
633
+ {
634
+ kind: "exact",
635
+ path: API_PREFIX + "/state",
636
+ handler: (req, res) => {
637
+ if (req.method !== "GET") return writeJson(res, 405, {
638
+ ok: false,
639
+ error: "method-not-allowed"
640
+ });
641
+ if (!guard(req, res)) return;
642
+ writeJson(res, 200, {
643
+ ok: true,
644
+ ...service.snapshot()
645
+ }, { "cache-control": "no-store" });
646
+ }
647
+ },
648
+ {
649
+ kind: "exact",
650
+ path: API_PREFIX + "/action",
651
+ handler: async (req, res) => {
652
+ if (req.method !== "POST") return writeJson(res, 405, {
653
+ ok: false,
654
+ error: "method-not-allowed"
655
+ });
656
+ if (!guard(req, res)) return;
657
+ if (!String(req.headers["content-type"] || "").toLowerCase().startsWith("application/json")) return writeJson(res, 415, {
658
+ ok: false,
659
+ error: "json-required"
660
+ });
661
+ try {
662
+ const body = await readBody(req);
663
+ writeJson(res, 200, await service.handleAction(body), { "cache-control": "no-store" });
664
+ } catch (e) {
665
+ const message = e instanceof Error ? e.message : String(e);
666
+ writeJson(res, message === "body-too-large" ? 413 : 400, {
667
+ ok: false,
668
+ error: message
669
+ });
670
+ }
671
+ }
672
+ },
673
+ {
674
+ kind: "exact",
675
+ path: API_PREFIX + "/events",
676
+ handler: (req, res) => {
677
+ if (req.method !== "GET") {
678
+ res.writeHead(405);
679
+ res.end();
680
+ return;
681
+ }
682
+ if (!guard(req, res)) return;
683
+ res.writeHead(200, {
684
+ "content-type": "text/event-stream; charset=utf-8",
685
+ "cache-control": "no-cache",
686
+ connection: "keep-alive"
687
+ });
688
+ const push = () => {
689
+ try {
690
+ res.write("data: " + JSON.stringify({
691
+ ok: true,
692
+ ...service.snapshot()
693
+ }) + "\n\n");
694
+ } catch {}
695
+ };
696
+ const unsubscribe = service.subscribePush(push);
697
+ const heartbeat = setInterval(() => {
698
+ try {
699
+ res.write(": ping\n\n");
700
+ } catch {}
701
+ }, HEARTBEAT_MS);
702
+ const close = () => {
703
+ clearInterval(heartbeat);
704
+ unsubscribe();
705
+ };
706
+ req.once("close", close);
707
+ res.once("close", close);
708
+ push();
709
+ }
710
+ }
711
+ ];
712
+ }
713
+ //#endregion
714
+ //#region src/host/broadcast.ts
715
+ /** SSE 推送节流。 */
716
+ const PUSH_THROTTLE_MS = 120;
717
+ /** 创建广播面。 */
718
+ function createBroadcast(core) {
719
+ const subscribers = /* @__PURE__ */ new Set();
720
+ let pushTimer = null;
721
+ const broadcast = () => {
722
+ if (pushTimer !== null) return;
723
+ pushTimer = setTimeout(() => {
724
+ pushTimer = null;
725
+ for (const push of subscribers) push();
726
+ }, PUSH_THROTTLE_MS);
727
+ };
728
+ const touch = () => {
729
+ core.revision++;
730
+ broadcast();
731
+ };
732
+ const snapshot = () => ({
733
+ revision: core.revision,
734
+ run: {
735
+ running: core.run.running,
736
+ sessionId: core.run.sessionId,
737
+ currentRoleId: core.run.currentRoleId,
738
+ partial: core.run.partial,
739
+ partialReasoning: core.run.partialReasoning,
740
+ pendingConfirm: core.run.pendingConfirm,
741
+ finished: core.run.finished
742
+ },
743
+ lastCreated: core.lastCreated,
744
+ groups: [...core.groups.values()].map((g) => ({
745
+ id: g.id,
746
+ name: g.name,
747
+ workspaceDir: g.workspaceDir,
748
+ permissionTier: g.permissionTier,
749
+ roleIds: g.roleIds.slice(),
750
+ sessionIds: g.sessionIds.slice()
751
+ })),
752
+ sessions: [...core.sessions.values()].map((s) => ({
753
+ id: s.id,
754
+ groupId: s.groupId,
755
+ name: s.name,
756
+ topic: s.topic,
757
+ messageIds: s.messageIds.slice(),
758
+ createdAt: s.createdAt
759
+ })),
760
+ roles: [...core.roles.values()].map((r) => ({
761
+ id: r.id,
762
+ groupId: r.groupId,
763
+ name: r.name,
764
+ color: r.color,
765
+ persona: r.persona,
766
+ provider: r.provider,
767
+ model: r.model,
768
+ temperature: r.temperature,
769
+ reasoningEffort: r.reasoningEffort,
770
+ enabled: r.enabled,
771
+ thinking: r.thinking === true
772
+ })),
773
+ messages: [...core.messages.values()].map((m) => ({
774
+ id: m.id,
775
+ sessionId: m.sessionId,
776
+ seq: m.seq,
777
+ speaker: m.speaker,
778
+ text: m.text,
779
+ reasoning: m.reasoning,
780
+ model: m.model,
781
+ error: m.error,
782
+ toolCalls: m.toolCalls,
783
+ ts: m.ts
784
+ }))
785
+ });
786
+ return {
787
+ touch,
788
+ snapshot,
789
+ subscribePush(push) {
790
+ subscribers.add(push);
791
+ return () => {
792
+ subscribers.delete(push);
793
+ };
794
+ },
795
+ dispose() {
796
+ if (pushTimer !== null) clearTimeout(pushTimer);
797
+ }
798
+ };
799
+ }
800
+ //#endregion
801
+ //#region src/core/tools.ts
802
+ /**
803
+ * 工具执行护栏常量与 schema(TOOLS.md §1/§5;core 层纯数据)。
804
+ * @module dsh-group-chat/core/tools
805
+ */
806
+ /** run_command 超时。 */
807
+ const RUN_CMD_TIMEOUT_MS = 12e4;
808
+ /** read_file 单文件上限(超出截断)。 */
809
+ const READ_FILE_MAX_BYTES = 102400;
810
+ /** 单条工具输出回注截断。 */
811
+ const CMD_OUTPUT_MAX_CHARS = 8e3;
812
+ /** 命令输出采集内存上限(1MB,超出弃置仅计数)。 */
813
+ const CMD_CAPTURE_MAX_BYTES = 1 << 20;
814
+ /** 工具 schema(TOOLS.md §1)。 */
815
+ const TOOL_SCHEMAS = [
816
+ {
817
+ name: "read_file",
818
+ description: "读取群组工作区目录内的一个文本文件内容(≤100KB,超出截断)",
819
+ parameters: {
820
+ type: "object",
821
+ properties: { path: {
822
+ type: "string",
823
+ description: "相对工作区根的路径"
824
+ } },
825
+ required: ["path"]
826
+ }
827
+ },
828
+ {
829
+ name: "list_dir",
830
+ description: "列出群组工作区目录内一个子目录的条目(名称/类型/大小)",
831
+ parameters: {
832
+ type: "object",
833
+ properties: { path: {
834
+ type: "string",
835
+ description: "相对工作区根的路径,默认 \".\""
836
+ } }
837
+ }
838
+ },
839
+ {
840
+ name: "run_command",
841
+ description: "在群组工作区目录内执行 shell 命令(如运行测试、git 操作;超时 120 秒;是否需要确认取决于群组权限档位)",
842
+ parameters: {
843
+ type: "object",
844
+ properties: { command: { type: "string" } },
845
+ required: ["command"]
846
+ }
847
+ }
848
+ ];
849
+ //#endregion
850
+ //#region src/host/engine/retitle.ts
851
+ /** DSH 默认模型(agentDefaultModel 服务缺位或未配置时返回 null,调用方静默跳过)。 */
852
+ const defaultModel = (core) => {
853
+ try {
854
+ const svc = core.ctx.reflect.get("agentDefaultModel");
855
+ const sel = svc ? svc.currentSelection() : null;
856
+ return sel && typeof sel.provider === "string" && typeof sel.model === "string" && sel.provider && sel.model ? {
857
+ provider: sel.provider,
858
+ model: sel.model
859
+ } : null;
860
+ } catch {
861
+ return null;
862
+ }
863
+ };
864
+ /** 命名输入:最近 40 条非系统消息的紧凑转写(每条 500 字符封顶,命名不需要全文)。 */
865
+ const titleTranscript = (core, sess) => {
866
+ const out = [];
867
+ for (const mid of sess.messageIds.slice(-40)) {
868
+ 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));
872
+ }
873
+ return out.join("\n");
874
+ };
875
+ /** 宽容解析模型输出:剥代码围栏 → 取首个 { 至末个 } 的 JSON → 校验并封顶字段。 */
876
+ 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
+ }
892
+ };
893
+ /**
894
+ * 每轮结束后根据聊天内容整理会话名称与主题:后台 fire-and-forget、不产生
895
+ * 消息、静默失败。名称 =「类别 emoji + 对象|目标」(类别固定、对象稳定、
896
+ * 目标实质变化才改);主题 = 演进式一句话摘要(对象+目标+当前焦点),注入
897
+ * 后续轮次的角色上下文。手动编辑过的字段永久跳过(隐式固定,apply 时复查)。
898
+ */
899
+ function createRetitle(core, deps) {
900
+ const { llm } = core;
901
+ const { touch, schedulePersist } = deps;
902
+ /** 进行中的命名会话集合(同会话并发去重)。 */
903
+ const retitling = /* @__PURE__ */ new Set();
904
+ return async (sess) => {
905
+ if (retitling.has(sess.id)) return;
906
+ const dm = defaultModel(core);
907
+ if (!dm || sess.namePinned && sess.topicPinned) return;
908
+ const transcript = titleTranscript(core, sess);
909
+ if (!transcript) return;
910
+ retitling.add(sess.id);
911
+ try {
912
+ const sys = [
913
+ "你是群聊会话的命名助手。根据群聊记录为这个会话生成名称与主题。",
914
+ "",
915
+ "# 名称规则",
916
+ "- 格式:「类别 emoji + 对象|目标」,例如「🔎 缓存选型|Redis 与本地 KV 对比」",
917
+ "- 类别固定六选一:🔎 调研对比(多方案/多观点比较)、💡 头脑风暴(创意发散)、⚖️ 方案评审(评审已有方案或产物)、🛠️ 排查修复(定位与解决问题)、📝 方法整理(总结沉淀方法与知识)、🗣️ 通用讨论(其余兜底)",
918
+ "- 对象在前且稳定:把辨识度最高的讨论对象放最前;省略群组名(外层已展示);除非讨论对象实质变化,沿用当前名称里的对象名;「继续」「追问」等不改变主线",
919
+ "- 目标 = 当前正在做的事,动宾短语,保持简洁",
920
+ "- 名称总长不超过 16 个字",
921
+ "",
922
+ "# 主题规则",
923
+ "- 一句话演进式摘要:讨论对象 + 当前目标 + 当前焦点/分歧点",
924
+ "- 不超过 60 个字;供后续讨论作为上下文锚,跟随最新进展更新",
925
+ "",
926
+ "# 其他",
927
+ "- 语言跟随用户消息的主要语言;保留产品名与技术名词",
928
+ "- 只输出一行 JSON:{\"name\": \"…\", \"topic\": \"…\"},不要输出其他内容",
929
+ "",
930
+ "当前名称:" + (sess.namePinned ? "(已手动固定,本次不要输出 name 字段)" : sess.name),
931
+ "当前主题:" + (sess.topicPinned ? "(已手动固定,本次不要输出 topic 字段)" : sess.topic || "(空)")
932
+ ].join("\n");
933
+ let acc = "";
934
+ for await (const chunk of llm.stream({
935
+ provider: dm.provider,
936
+ model: dm.model,
937
+ system: sys,
938
+ purpose: "session-title",
939
+ messages: [{
940
+ id: "g" + core.revision + "-t0",
941
+ role: "user",
942
+ content: [{
943
+ type: "text",
944
+ text: "群聊记录(从旧到新):\n\n" + transcript
945
+ }],
946
+ source: { kind: "user" }
947
+ }]
948
+ })) if (chunk.type === "text-delta") {
949
+ acc += chunk.text;
950
+ if (acc.length > 2e3) break;
951
+ } else if (chunk.type === "finish") break;
952
+ const parsed = parseRetitle(acc);
953
+ let changed = false;
954
+ if (parsed.name && !sess.namePinned) {
955
+ sess.name = parsed.name;
956
+ changed = true;
957
+ }
958
+ if (parsed.topic && !sess.topicPinned) {
959
+ sess.topic = parsed.topic;
960
+ changed = true;
961
+ }
962
+ if (changed) {
963
+ schedulePersist({ session: sess.id });
964
+ touch();
965
+ }
966
+ } catch (e) {
967
+ console.error("[dsh-group-chat] 会话标题整理失败(跳过,不影响对话):", e);
968
+ } finally {
969
+ retitling.delete(sess.id);
970
+ }
971
+ };
972
+ }
973
+ //#endregion
974
+ //#region src/host/engine/conversation.ts
975
+ /**
976
+ * 对话引擎:消息追加、群聊记录转写、角色发言(speak:prompt 构建 + 流式
977
+ * 轮次 + 工具回注循环)、多轮 runLoop(每轮结束触发 retitle 标题整理)。
978
+ * @module dsh-group-chat/host/engine/conversation
979
+ */
980
+ /** 创建对话引擎。 */
981
+ function createConversation(core, deps) {
982
+ const { llm, groups, roles, messages, run } = core;
983
+ const { touch, schedulePersist, materials, tools } = deps;
984
+ const retitle = createRetitle(core, {
985
+ touch,
986
+ schedulePersist
987
+ });
988
+ const appendMessage = (sess, speaker, text, extra) => {
989
+ const msg = {
990
+ id: core.nid("msg"),
991
+ sessionId: sess.id,
992
+ seq: sess.messageIds.length + 1,
993
+ speaker,
994
+ text,
995
+ reasoning: void 0,
996
+ model: void 0,
997
+ error: void 0,
998
+ ts: Date.now()
999
+ };
1000
+ if (extra) {
1001
+ if (extra.reasoning !== void 0) msg.reasoning = extra.reasoning;
1002
+ if (extra.reasoningFull !== void 0) msg.reasoningFull = extra.reasoningFull;
1003
+ if (extra.thinkingSummary !== void 0) msg.thinkingSummary = extra.thinkingSummary;
1004
+ if (extra.model !== void 0) msg.model = extra.model;
1005
+ if (extra.error !== void 0) msg.error = extra.error;
1006
+ if (Array.isArray(extra.toolCalls) && extra.toolCalls.length > 0) msg.toolCalls = extra.toolCalls;
1007
+ }
1008
+ messages.set(msg.id, msg);
1009
+ sess.messageIds.push(msg.id);
1010
+ touch();
1011
+ schedulePersist({ session: sess.id });
1012
+ return msg;
1013
+ };
1014
+ /** 群聊记录 → 角色上下文块(最近 40 条、逐条 8k 截断、工具行压缩)。 */
1015
+ const transcriptBlock = (sess) => {
1016
+ const out = [];
1017
+ for (const mid of sess.messageIds) {
1018
+ 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);
1039
+ }
1040
+ return out.slice(-40).join("\n\n");
1041
+ };
1042
+ const speak = async (g, sess, role) => {
1043
+ const ws = await materials.loadWorkspaceFiles(g);
1044
+ const parts = ws.parts;
1045
+ const sys = [
1046
+ "你正在参与一个多角色群聊。你在群中的身份如下,请始终以该身份发言。",
1047
+ "",
1048
+ "# 你的角色",
1049
+ "- 名称:" + role.name,
1050
+ "- 人设:" + (role.persona ? role.persona : "(未填写,请以积极协作者的身份参与讨论)"),
1051
+ sess.topic ? "\n# 本会话主题\n" + sess.topic : "",
1052
+ materials.materialBlock(parts, ws.dir),
1053
+ ws.dir ? "\n# 可用工具\n你可以调用工具在群组工作区目录(" + ws.dir + ")内查看文件与目录" + (g.permissionTier === "workspace_write" ? "、执行 shell 命令(命令需用户逐条确认,请优先用于运行测试)" : g.permissionTier === "full_access" ? "、执行 shell 命令(命令将直接执行、无需确认,请谨慎并优先用于运行测试)" : "") + "。需要事实依据时优先用工具查看,不要凭空猜测。" : "",
1054
+ "\n# 发言要求",
1055
+ "- 直接输出「" + role.name + "」本轮的发言内容本身:不要输出名字前缀、引号、动作旁白或代码围栏",
1056
+ "- 回应群内最新讨论(消息中「@你的名字」表示用户点名要求你回应,被点名时请优先回应);与其他成员自然对话;有不同观点可以提出并说明理由",
1057
+ "- 保持简洁,通常不超过 300 字"
1058
+ ].filter((s) => s !== "").join("\n");
1059
+ const history = transcriptBlock(sess);
1060
+ const intro = history ? "以下是本会话的群聊记录(从旧到新):\n\n" + history : "本会话刚刚开始,请围绕主题做简短开场发言。";
1061
+ let wsRoot = null;
1062
+ if (ws.dir) try {
1063
+ wsRoot = realpathSync(ws.dir);
1064
+ } catch {
1065
+ wsRoot = null;
1066
+ }
1067
+ const toolSchemas = wsRoot !== null ? tools.buildToolSchemas(g) : [];
1068
+ const baseOpts = {
1069
+ provider: role.provider,
1070
+ model: role.model,
1071
+ system: sys
1072
+ };
1073
+ if (typeof role.temperature === "number" && !Number.isNaN(role.temperature)) baseOpts.temperature = role.temperature;
1074
+ if (role.thinking === true) {
1075
+ const effort = asEffort(role.reasoningEffort);
1076
+ if (effort !== void 0) baseOpts.reasoningEffort = effort;
1077
+ }
1078
+ const msgs = [{
1079
+ id: "g" + core.revision + "-m0",
1080
+ role: "user",
1081
+ content: [{
1082
+ type: "text",
1083
+ text: intro
1084
+ }],
1085
+ source: { kind: "user" }
1086
+ }];
1087
+ const toolCalls = [];
1088
+ const texts = [];
1089
+ const reasonings = [];
1090
+ let budgetUsed = 0;
1091
+ let streak = 0;
1092
+ let lastSig = null;
1093
+ let toolsOff = false;
1094
+ const streamRound = async () => {
1095
+ let acc = "";
1096
+ let rAcc = "";
1097
+ let errorFinish = null;
1098
+ let maxTokens = false;
1099
+ const byIndex = /* @__PURE__ */ new Map();
1100
+ const roundTools = toolSchemas.length === 0 || toolsOff ? void 0 : toolSchemas;
1101
+ for await (const chunk of llm.stream({
1102
+ ...baseOpts,
1103
+ tools: roundTools,
1104
+ messages: msgs
1105
+ })) {
1106
+ if (run.stopping) break;
1107
+ if (chunk.type === "text-delta") {
1108
+ acc += chunk.text;
1109
+ run.partial = acc;
1110
+ touch();
1111
+ } else if (chunk.type === "reasoning-delta") {
1112
+ rAcc += chunk.text;
1113
+ run.partialReasoning = rAcc;
1114
+ touch();
1115
+ } else if (chunk.type === "tool-call-delta") {
1116
+ const cur = byIndex.get(chunk.index);
1117
+ if (cur && cur.closed) continue;
1118
+ const entry = cur || {
1119
+ id: "call-" + chunk.index,
1120
+ name: "",
1121
+ args: "",
1122
+ closed: false
1123
+ };
1124
+ if (chunk.id !== void 0) entry.id = chunk.id;
1125
+ if (chunk.name !== void 0) entry.name = chunk.name;
1126
+ entry.args += chunk.argumentsDelta;
1127
+ byIndex.set(chunk.index, entry);
1128
+ } else if (chunk.type === "block-end" && chunk.block && chunk.block.type === "tool-call") {
1129
+ const entry = byIndex.get(chunk.index) || {
1130
+ id: "call-" + chunk.index,
1131
+ name: "",
1132
+ args: "",
1133
+ closed: false
1134
+ };
1135
+ entry.id = chunk.block.id;
1136
+ entry.name = chunk.block.name;
1137
+ entry.args = chunk.block.arguments;
1138
+ entry.closed = true;
1139
+ byIndex.set(chunk.index, entry);
1140
+ } else if (chunk.type === "finish") {
1141
+ const reason = chunk.reason || {};
1142
+ if (reason.kind === "error" || reason.kind === "aborted") errorFinish = reason;
1143
+ else if (reason.kind === "max-tokens") maxTokens = true;
1144
+ }
1145
+ }
1146
+ const tcs = [...byIndex.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => ({
1147
+ id: v.id,
1148
+ name: v.name,
1149
+ args: v.args
1150
+ }));
1151
+ return {
1152
+ acc,
1153
+ rAcc,
1154
+ errorFinish,
1155
+ maxTokens,
1156
+ tcs
1157
+ };
1158
+ };
1159
+ const wrapUp = (note) => {
1160
+ const lines = toolCalls.map((c) => {
1161
+ let brief = "";
1162
+ try {
1163
+ brief = JSON.stringify(c.args) || "";
1164
+ } catch {
1165
+ brief = "";
1166
+ }
1167
+ if (brief.length > 80) brief = brief.slice(0, 80) + "…";
1168
+ const ob = String(c.output || "");
1169
+ return "[工具] " + c.tool + " " + brief + " → " + c.status + (ob ? ":" + (ob.length > 200 ? ob.slice(0, 200) + "…" : ob) : "");
1170
+ });
1171
+ msgs.length = 1;
1172
+ msgs.push({
1173
+ id: "g" + core.revision + "-w" + msgs.length,
1174
+ role: "user",
1175
+ content: [{
1176
+ type: "text",
1177
+ text: (texts.length ? "你此前的发言草稿:\n" + texts.join("\n\n") + "\n\n" : "") + "你的工具执行记录(摘要):\n" + lines.join("\n") + "\n\n" + note + ",请基于以上记录输出最终发言。"
1178
+ }],
1179
+ source: { kind: "user" }
1180
+ });
1181
+ toolsOff = true;
1182
+ };
1183
+ for (;;) {
1184
+ if (run.stopping) break;
1185
+ let round;
1186
+ try {
1187
+ round = await streamRound();
1188
+ } catch (e) {
1189
+ const m = String(e && e.message || e);
1190
+ if (baseOpts.reasoningEffort !== void 0 && m.includes("UNSUPPORTED_REASONING_EFFORT")) {
1191
+ delete baseOpts.reasoningEffort;
1192
+ continue;
1193
+ }
1194
+ throw e;
1195
+ }
1196
+ if (round.errorFinish) {
1197
+ const f = round.errorFinish.failure || {};
1198
+ const failureText = String(f.message || round.errorFinish.kind);
1199
+ if (!toolsOff && toolSchemas.length > 0 && /tool/i.test(failureText)) {
1200
+ toolsOff = true;
1201
+ continue;
1202
+ }
1203
+ throw new Error("模型输出异常终止: " + failureText);
1204
+ }
1205
+ if (round.acc.trim()) texts.push(round.acc.trim());
1206
+ if (round.rAcc.trim()) reasonings.push(round.rAcc.trim());
1207
+ if (round.maxTokens) break;
1208
+ if (run.stopping) break;
1209
+ if (round.tcs.length === 0) break;
1210
+ const assistantContent = [];
1211
+ if (round.acc.trim()) assistantContent.push({
1212
+ type: "text",
1213
+ text: round.acc
1214
+ });
1215
+ for (const tc of round.tcs) assistantContent.push({
1216
+ type: "tool-call",
1217
+ id: tc.id,
1218
+ name: tc.name,
1219
+ arguments: tc.args
1220
+ });
1221
+ msgs.push({
1222
+ id: "g" + core.revision + "-a" + msgs.length,
1223
+ role: "assistant",
1224
+ content: assistantContent,
1225
+ source: {
1226
+ kind: "model",
1227
+ provider: role.provider,
1228
+ model: role.model
1229
+ }
1230
+ });
1231
+ for (const tc of round.tcs) {
1232
+ if (run.stopping) break;
1233
+ const res = await tools.executeTool(g, wsRoot, tc);
1234
+ const output = String(res.output || "");
1235
+ toolCalls.push({
1236
+ tool: tc.name,
1237
+ args: res.args,
1238
+ status: res.status,
1239
+ output,
1240
+ durationMs: res.durationMs
1241
+ });
1242
+ budgetUsed += Math.max(output.length, 100);
1243
+ msgs.push({
1244
+ id: "g" + core.revision + "-t" + msgs.length,
1245
+ role: "user",
1246
+ content: [{
1247
+ type: "tool-result",
1248
+ toolCallId: tc.id,
1249
+ content: [{
1250
+ type: "text",
1251
+ text: output || "(无输出)"
1252
+ }],
1253
+ isError: res.status === "error"
1254
+ }],
1255
+ source: {
1256
+ kind: "tool",
1257
+ callId: tc.id
1258
+ }
1259
+ });
1260
+ const sig = tc.name + "|" + tc.args + "|" + output;
1261
+ streak = output === "" || lastSig !== null && sig === lastSig ? streak + 1 : 1;
1262
+ lastSig = sig;
1263
+ }
1264
+ if (budgetUsed >= 32e3) {
1265
+ wrapUp("已达工具结果累计上限");
1266
+ continue;
1267
+ }
1268
+ if (streak >= 6) {
1269
+ wrapUp("检测到重复或空输出的工具调用");
1270
+ continue;
1271
+ }
1272
+ }
1273
+ return {
1274
+ text: texts.join("\n\n").trim(),
1275
+ reasoning: reasonings.join("\n\n").trim() || void 0,
1276
+ toolCalls
1277
+ };
1278
+ };
1279
+ const runLoop = async (sess) => {
1280
+ const g = groups.get(sess.groupId);
1281
+ const startCount = sess.messageIds.length;
1282
+ let failed = false;
1283
+ try {
1284
+ while (run.queue.length > 0 && !run.stopping) {
1285
+ const roleId = run.queue.shift();
1286
+ const role = roles.get(roleId);
1287
+ run.currentRoleId = roleId;
1288
+ run.partial = "";
1289
+ run.partialReasoning = "";
1290
+ touch();
1291
+ if (!role) continue;
1292
+ 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
+ });
1299
+ } catch (e) {
1300
+ failed = true;
1301
+ appendMessage(sess, "system", "角色「" + role.name + "」发言失败:" + String(e && e.message || e), { error: true });
1302
+ break;
1303
+ }
1304
+ }
1305
+ if (run.stopping) appendMessage(sess, "system", "已停止本次对话", {});
1306
+ } finally {
1307
+ run.finished = {
1308
+ sessionId: sess.id,
1309
+ reason: failed ? "error" : "ok"
1310
+ };
1311
+ run.running = false;
1312
+ run.sessionId = null;
1313
+ run.currentRoleId = null;
1314
+ run.partial = "";
1315
+ run.partialReasoning = "";
1316
+ run.queue = [];
1317
+ run.pendingConfirm = null;
1318
+ run.confirmSignal = null;
1319
+ run.childProc = null;
1320
+ run.stopping = false;
1321
+ touch();
1322
+ }
1323
+ if (sess.messageIds.length > startCount) retitle(sess);
1324
+ };
1325
+ return {
1326
+ appendMessage,
1327
+ runLoop
1328
+ };
1329
+ }
1330
+ //#endregion
1331
+ //#region src/host/materials/materials.ts
1332
+ /**
1333
+ * 资料读取与路径解析:群组工作区目录 → 注入文件清单 + 目录浏览器 + @ 文件检索。
1334
+ * 路径解析顺序:~ 展开到 home;绝对路径直用;相对路径先试各工作区根,
1335
+ * 再试 dsh web 进程 cwd。目录浏览器、资料读取与文件检索共用同一套解析。
1336
+ * 无独立笔记/文件清单:共享资料 = workspaceDir 根下一层文本文件。
1337
+ * @module dsh-group-chat/host/materials
1338
+ */
1339
+ const TEXT_EXTS = /* @__PURE__ */ new Set([
1340
+ ".md",
1341
+ ".markdown",
1342
+ ".txt",
1343
+ ".json",
1344
+ ".yml",
1345
+ ".yaml",
1346
+ ".csv",
1347
+ ".tsv",
1348
+ ".toml",
1349
+ ".ini",
1350
+ ".conf",
1351
+ ".env",
1352
+ ".properties",
1353
+ ".log",
1354
+ ".xml",
1355
+ ".html",
1356
+ ".css",
1357
+ ".ts",
1358
+ ".tsx",
1359
+ ".js",
1360
+ ".jsx",
1361
+ ".py",
1362
+ ".java",
1363
+ ".go",
1364
+ ".rs",
1365
+ ".c",
1366
+ ".h",
1367
+ ".cpp",
1368
+ ".sql",
1369
+ ".sh"
1370
+ ]);
1371
+ const MAX_WS_FILES = 20;
1372
+ /** 创建资料面。 */
1373
+ function createMaterials(core) {
1374
+ const { ctx, fs } = core;
1375
+ const HOME = homedir();
1376
+ let workspacePathsCache = {
1377
+ at: 0,
1378
+ paths: []
1379
+ };
1380
+ const workspacePaths = async () => {
1381
+ const now = Date.now();
1382
+ if (now - workspacePathsCache.at < 5e3) return workspacePathsCache.paths;
1383
+ try {
1384
+ workspacePathsCache = {
1385
+ at: now,
1386
+ paths: (await ctx.workspaceRegistry.list()).map((w) => w.path).filter(Boolean)
1387
+ };
1388
+ } catch {
1389
+ workspacePathsCache = {
1390
+ at: now,
1391
+ paths: workspacePathsCache.paths
1392
+ };
1393
+ }
1394
+ return workspacePathsCache.paths;
1395
+ };
1396
+ const candidatePaths = async (raw) => {
1397
+ const p = String(raw || "").trim();
1398
+ if (p === "" || p === "~") return [HOME];
1399
+ if (p.startsWith("~/")) return [join(HOME, p.slice(2))];
1400
+ if (isAbsolute(p)) return [p];
1401
+ return [...await workspacePaths(), process.cwd()].map((root) => join(root, p));
1402
+ };
1403
+ /** 逐候选 stat,返回第一个存在的目标;都不存在时返回首候选与全部尝试。 */
1404
+ const resolveMaterialTarget = async (raw) => {
1405
+ const candidates = await candidatePaths(raw);
1406
+ let first;
1407
+ for (const c of candidates) {
1408
+ const target = await fs.resolve(c);
1409
+ if (first === void 0) first = {
1410
+ target,
1411
+ path: c
1412
+ };
1413
+ const info = await fs.stat(target);
1414
+ if (info !== void 0) return {
1415
+ target,
1416
+ path: c,
1417
+ info,
1418
+ tried: candidates
1419
+ };
1420
+ }
1421
+ return {
1422
+ target: first.target,
1423
+ path: first.path,
1424
+ info: void 0,
1425
+ tried: candidates
1426
+ };
1427
+ };
1428
+ const loadWorkspaceFiles = async (g) => {
1429
+ if (!g.workspaceDir) return {
1430
+ dir: "",
1431
+ parts: []
1432
+ };
1433
+ const res = await resolveMaterialTarget(g.workspaceDir);
1434
+ if (res.info === void 0) throw new Error("工作区目录不存在(尝试过:" + res.tried.join(";") + ")");
1435
+ if (res.info.type !== "directory") throw new Error("工作区目录不是目录:" + res.path);
1436
+ const entries = await fs.listDir(res.target);
1437
+ const parts = [];
1438
+ for (const e of entries) {
1439
+ if (parts.length >= MAX_WS_FILES) break;
1440
+ if (e.type !== "file" || e.name.startsWith(".")) continue;
1441
+ const dot = e.name.lastIndexOf(".");
1442
+ const ext = dot === -1 ? "" : e.name.slice(dot).toLowerCase();
1443
+ if (!TEXT_EXTS.has(ext)) continue;
1444
+ try {
1445
+ const text = await fs.readText(e.target);
1446
+ parts.push({
1447
+ name: e.name,
1448
+ content: text
1449
+ });
1450
+ } catch (err) {
1451
+ parts.push({
1452
+ name: e.name,
1453
+ content: "",
1454
+ error: String(err && err.message || err)
1455
+ });
1456
+ }
1457
+ }
1458
+ return {
1459
+ dir: res.path,
1460
+ parts
1461
+ };
1462
+ };
1463
+ const materialBlock = (parts, dir) => {
1464
+ if (!parts.length) return "";
1465
+ let total = 0;
1466
+ const lines = [];
1467
+ for (const p of parts) {
1468
+ if (p.error) {
1469
+ lines.push("### " + p.name + "\n[读取失败] " + p.error);
1470
+ continue;
1471
+ }
1472
+ let c = p.content || "";
1473
+ if (c.length > 16e3) c = c.slice(0, 16e3) + "\n…(已截断)";
1474
+ if (total + c.length > 48e3) c = c.slice(0, Math.max(0, 48e3 - total)) + "\n…(总量超限截断)";
1475
+ total += c.length;
1476
+ lines.push("### " + p.name + "\n" + c);
1477
+ }
1478
+ return "\n# 共享资料(来自群组工作区目录" + (dir ? " " + dir : "") + ",群内所有成员可见)\n" + lines.join("\n\n");
1479
+ };
1480
+ const browse = async (args) => {
1481
+ try {
1482
+ const raw = String(args && args.path || "").trim();
1483
+ const res = await resolveMaterialTarget(raw === "" ? HOME : raw);
1484
+ if (res.info === void 0) return {
1485
+ ok: false,
1486
+ error: "路径不存在,尝试过:" + res.tried.join(";")
1487
+ };
1488
+ let dirPath = res.path;
1489
+ let target = res.target;
1490
+ if (res.info.type !== "directory") {
1491
+ dirPath = dirname(dirPath);
1492
+ target = await fs.resolve(dirPath);
1493
+ }
1494
+ const sorted = [...await fs.listDir(target)].sort((a, b) => (a.type === "directory" ? 0 : 1) - (b.type === "directory" ? 0 : 1) || a.name.localeCompare(b.name));
1495
+ return {
1496
+ ok: true,
1497
+ path: dirPath,
1498
+ home: HOME,
1499
+ parent: dirname(dirPath),
1500
+ entries: sorted.map((e) => ({
1501
+ name: e.name,
1502
+ type: e.type,
1503
+ path: fs.processPath(e.target),
1504
+ size: e.size,
1505
+ hidden: e.name.startsWith(".")
1506
+ }))
1507
+ };
1508
+ } catch (e) {
1509
+ return {
1510
+ ok: false,
1511
+ error: String(e && e.message || e)
1512
+ };
1513
+ }
1514
+ };
1515
+ const FILE_SEARCH_CONFIG = {
1516
+ maxResults: DEFAULT_FILE_SEARCH_MAX_RESULTS,
1517
+ maxEntries: DEFAULT_FILE_SEARCH_MAX_ENTRIES,
1518
+ excludedDirectories: [...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]
1519
+ };
1520
+ const searches = /* @__PURE__ */ new Map();
1521
+ const disposeFileSearch = (groupId) => {
1522
+ if (groupId) {
1523
+ searches.get(groupId)?.search.dispose();
1524
+ searches.delete(groupId);
1525
+ return;
1526
+ }
1527
+ for (const hit of searches.values()) hit.search.dispose();
1528
+ searches.clear();
1529
+ };
1530
+ const searchFor = (groupId, root) => {
1531
+ const hit = searches.get(groupId);
1532
+ if (hit && hit.root === root) return hit.search;
1533
+ hit?.search.dispose();
1534
+ const search = new WorkspaceFileSearch(root, FILE_SEARCH_CONFIG);
1535
+ searches.set(groupId, {
1536
+ root,
1537
+ search
1538
+ });
1539
+ return search;
1540
+ };
1541
+ const fileSearch = async (args) => {
1542
+ const groupId = String(args && args.groupId || "");
1543
+ const g = core.groups.get(groupId);
1544
+ if (!g) return {
1545
+ ok: false,
1546
+ error: "群组不存在"
1547
+ };
1548
+ const raw = String(g.workspaceDir || "").trim();
1549
+ if (!raw) return {
1550
+ ok: false,
1551
+ error: "未设置群工作区,无法检索文件"
1552
+ };
1553
+ if (raw === "~" || raw === "/") return {
1554
+ ok: false,
1555
+ error: "工作区不能是 ~ 或 /,请设置具体目录"
1556
+ };
1557
+ try {
1558
+ const res = await resolveMaterialTarget(raw);
1559
+ if (res.info === void 0) return {
1560
+ ok: false,
1561
+ error: "工作区目录不存在(尝试过:" + res.tried.join(";") + ")"
1562
+ };
1563
+ if (res.info.type !== "directory") return {
1564
+ ok: false,
1565
+ error: "工作区不是目录:" + res.path
1566
+ };
1567
+ return {
1568
+ ok: true,
1569
+ candidates: (await searchFor(g.id, res.path).list(String(args && args.query || ""), AbortSignal.timeout(8e3))).map((c) => ({
1570
+ path: c.path,
1571
+ isDir: c.kind === "directory"
1572
+ }))
1573
+ };
1574
+ } catch (e) {
1575
+ if (e instanceof Error && e.name === "TimeoutError") return {
1576
+ ok: false,
1577
+ error: "文件检索超时"
1578
+ };
1579
+ return {
1580
+ ok: false,
1581
+ error: String(e && e.message || e)
1582
+ };
1583
+ }
1584
+ };
1585
+ return {
1586
+ loadWorkspaceFiles,
1587
+ materialBlock,
1588
+ browse,
1589
+ fileSearch,
1590
+ disposeFileSearch
1591
+ };
1592
+ }
1593
+ //#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
+ //#region src/host/persistence/store.ts
1631
+ /**
1632
+ * 持久化(PERSISTENCE.md v2.1:会话级文件隔离)。
1633
+ *
1634
+ * 目录布局(每群组一目录,一会话一文件):
1635
+ * <dir>/ledger.json 群组/会话清单(schema 3,纯清单,pretty;群组含 permissionTier)
1636
+ * <dir>/<group-id>/workspaceDir 工作区目录设置(纯文本一行)
1637
+ * <dir>/<group-id>/roles.json 群组角色(schema 1,紧凑)
1638
+ * <dir>/<group-id>/sessions/session-<uuid>.json 会话(schema 1,自包含,紧凑)
1639
+ *
1640
+ * 原子写 tmp+fsync+rename(每目录一次 fsync);单实例 .lock;损坏隔离重建;
1641
+ * 写失败保留脏标记;v1 自动迁移。
1642
+ * @module dsh-group-chat/host/persistence/store
1643
+ */
1644
+ /** DSH 主目录;DSH_GROUP_CHAT_STORE 供测试覆盖存储位置。 */
1645
+ const DSH_HOME = process.env.DSH_HOME || join(homedir(), ".dsh");
1646
+ /** 存储根目录。 */
1647
+ const STORE_DIR = process.env.DSH_GROUP_CHAT_STORE || join(DSH_HOME, "storages", "group-chat");
1648
+ const processIsAlive = (pid) => {
1649
+ try {
1650
+ process.kill(pid, 0);
1651
+ return true;
1652
+ } catch (e) {
1653
+ return e.code === "EPERM";
1654
+ }
1655
+ };
1656
+ const SESSION_FILE_RE = /^session-(.+)\.json$/;
1657
+ /** 会话文件名 → 会话 id。 */
1658
+ function sessionFileMatch(name) {
1659
+ const m = SESSION_FILE_RE.exec(name);
1660
+ return m ? m[1] : null;
1661
+ }
1662
+ var Store = class {
1663
+ dir;
1664
+ ledgerFile;
1665
+ lockFile;
1666
+ lockFd;
1667
+ constructor(dir) {
1668
+ this.dir = dir;
1669
+ this.ledgerFile = join(dir, "ledger.json");
1670
+ this.lockFile = join(dir, ".lock");
1671
+ mkdirSync(dir, { recursive: true });
1672
+ this.lockFd = this.acquireLock();
1673
+ }
1674
+ groupDir(groupId) {
1675
+ return join(this.dir, groupId);
1676
+ }
1677
+ sessionsDir(groupId) {
1678
+ return join(this.groupDir(groupId), "sessions");
1679
+ }
1680
+ sessionFile(groupId, sessionId) {
1681
+ return join(this.sessionsDir(groupId), "session-" + sessionId + ".json");
1682
+ }
1683
+ rolesFile(groupId) {
1684
+ return join(this.groupDir(groupId), "roles.json");
1685
+ }
1686
+ workspaceFile(groupId) {
1687
+ return join(this.groupDir(groupId), "workspaceDir");
1688
+ }
1689
+ acquireLock() {
1690
+ for (let attempt = 0; attempt < 2; attempt++) try {
1691
+ const fd = openSync(this.lockFile, "wx", 384);
1692
+ writeFileSync(fd, JSON.stringify({
1693
+ pid: process.pid,
1694
+ token: randomUUID(),
1695
+ at: Date.now()
1696
+ }), "utf8");
1697
+ try {
1698
+ fsyncSync(fd);
1699
+ } catch {}
1700
+ try {
1701
+ chmodSync(this.lockFile, 384);
1702
+ } catch {}
1703
+ return fd;
1704
+ } catch (e) {
1705
+ if (e.code !== "EEXIST") throw e;
1706
+ let pid;
1707
+ try {
1708
+ pid = JSON.parse(readFileSync(this.lockFile, "utf8")).pid;
1709
+ } catch {
1710
+ throw new Error(`持久化锁不可读,如确认无其他 dsh web 在运行可手动删除 ${this.lockFile}`);
1711
+ }
1712
+ if (typeof pid === "number" && processIsAlive(pid)) throw new Error(`持久化锁被进程 ${pid} 持有`);
1713
+ try {
1714
+ unlinkSync(this.lockFile);
1715
+ } catch (ue) {
1716
+ if (ue.code !== "ENOENT") throw ue;
1717
+ }
1718
+ }
1719
+ throw new Error("持久化锁获取失败");
1720
+ }
1721
+ release() {
1722
+ if (this.lockFd !== void 0) {
1723
+ try {
1724
+ closeSync(this.lockFd);
1725
+ } catch {}
1726
+ try {
1727
+ unlinkSync(this.lockFile);
1728
+ } catch {}
1729
+ this.lockFd = void 0;
1730
+ }
1731
+ }
1732
+ fsyncDir(dir) {
1733
+ try {
1734
+ const fd = openSync(dir, "r");
1735
+ try {
1736
+ fsyncSync(fd);
1737
+ } finally {
1738
+ closeSync(fd);
1739
+ }
1740
+ } catch {}
1741
+ }
1742
+ /** 原子写:tmp+fsync+rename;目录 fsync 由调用方按 flush 批量执行(每目录一次)。 */
1743
+ atomicWrite(file, text) {
1744
+ mkdirSync(dirname(file), { recursive: true });
1745
+ const tmp = `${file}.tmp-${process.pid}`;
1746
+ let fd;
1747
+ try {
1748
+ fd = openSync(tmp, "w", 384);
1749
+ writeFileSync(fd, text, "utf8");
1750
+ fsyncSync(fd);
1751
+ closeSync(fd);
1752
+ fd = void 0;
1753
+ try {
1754
+ chmodSync(tmp, 384);
1755
+ } catch {}
1756
+ renameSync(tmp, file);
1757
+ } catch (e) {
1758
+ if (fd !== void 0) try {
1759
+ closeSync(fd);
1760
+ } catch {}
1761
+ try {
1762
+ unlinkSync(tmp);
1763
+ } catch {}
1764
+ throw e;
1765
+ }
1766
+ }
1767
+ quarantine(file) {
1768
+ try {
1769
+ renameSync(file, `${file}.corrupt-${Date.now()}-${process.pid}`);
1770
+ } catch {}
1771
+ }
1772
+ /** 读 JSON;缺失返回 null;损坏则隔离后返回 null。 */
1773
+ loadJson(file) {
1774
+ let raw;
1775
+ try {
1776
+ raw = readFileSync(file, "utf8");
1777
+ } catch (e) {
1778
+ if (e.code === "ENOENT") return null;
1779
+ throw e;
1780
+ }
1781
+ try {
1782
+ return JSON.parse(raw);
1783
+ } catch (e) {
1784
+ console.error(`[dsh-group-chat] ${basename(file)} 解析失败,已隔离并以空数据启动:`, e);
1785
+ this.quarantine(file);
1786
+ return null;
1787
+ }
1788
+ }
1789
+ readWorkspace(groupId) {
1790
+ try {
1791
+ return readFileSync(this.workspaceFile(groupId), "utf8").trim();
1792
+ } catch {
1793
+ return "";
1794
+ }
1795
+ }
1796
+ /** hydrate 残留清理:.tmp-* 删除;同前缀 .corrupt-* 只保留最近 1 份。 */
1797
+ cleanup() {
1798
+ const sweep = (dir) => {
1799
+ let entries;
1800
+ try {
1801
+ entries = readdirSync(dir, { withFileTypes: true });
1802
+ } catch {
1803
+ return;
1804
+ }
1805
+ const corrupt = /* @__PURE__ */ new Map();
1806
+ for (const e of entries) {
1807
+ if (!e.isFile()) continue;
1808
+ if (/\.tmp-\d+$/.test(e.name)) {
1809
+ try {
1810
+ unlinkSync(join(dir, e.name));
1811
+ } catch {}
1812
+ continue;
1813
+ }
1814
+ const m = /^(.+)\.corrupt-\d+-\d+$/.exec(e.name);
1815
+ if (m) {
1816
+ let mtime = 0;
1817
+ try {
1818
+ mtime = statSync(join(dir, e.name)).mtimeMs;
1819
+ } catch {}
1820
+ if (!corrupt.has(m[1])) corrupt.set(m[1], []);
1821
+ corrupt.get(m[1]).push({
1822
+ name: e.name,
1823
+ mtime
1824
+ });
1825
+ }
1826
+ }
1827
+ for (const list of corrupt.values()) {
1828
+ list.sort((a, b) => b.mtime - a.mtime);
1829
+ for (const item of list.slice(1)) try {
1830
+ unlinkSync(join(dir, item.name));
1831
+ } catch {}
1832
+ }
1833
+ };
1834
+ sweep(this.dir);
1835
+ let entries;
1836
+ try {
1837
+ entries = readdirSync(this.dir, { withFileTypes: true });
1838
+ } catch {
1839
+ return;
1840
+ }
1841
+ for (const e of entries) {
1842
+ if (!e.isDirectory()) continue;
1843
+ const gdir = join(this.dir, e.name);
1844
+ sweep(gdir);
1845
+ sweep(join(gdir, "sessions"));
1846
+ }
1847
+ }
1848
+ /** v1(ledger+messages 双文件)→ v2 一次性迁移;幂等,messages.json 只归档从不删除。 */
1849
+ migrateV1() {
1850
+ const v1MessagesFile = join(this.dir, "messages.json");
1851
+ if (!existsSync(v1MessagesFile)) return;
1852
+ const v1Ledger = this.loadJson(this.ledgerFile);
1853
+ const archive = () => {
1854
+ try {
1855
+ renameSync(v1MessagesFile, `${v1MessagesFile}.migrated-${Date.now()}`);
1856
+ } catch {}
1857
+ this.fsyncDir(this.dir);
1858
+ };
1859
+ if (!v1Ledger || v1Ledger.schema === 2 || !Array.isArray(v1Ledger.groups)) {
1860
+ archive();
1861
+ return;
1862
+ }
1863
+ let dirs;
1864
+ try {
1865
+ dirs = readdirSync(this.dir, { withFileTypes: true });
1866
+ } catch {
1867
+ dirs = [];
1868
+ }
1869
+ for (const e of dirs) {
1870
+ if (!e.isDirectory()) continue;
1871
+ const gdir = join(this.dir, e.name);
1872
+ try {
1873
+ rmSync(join(gdir, "sessions"), {
1874
+ recursive: true,
1875
+ force: true
1876
+ });
1877
+ } catch {}
1878
+ for (const name of ["roles.json", "workspaceDir"]) try {
1879
+ unlinkSync(join(gdir, name));
1880
+ } catch {}
1881
+ }
1882
+ const groupsById = /* @__PURE__ */ new Map();
1883
+ for (const g of v1Ledger.groups) if (g && typeof g.id === "string") groupsById.set(g.id, g);
1884
+ const rolesByGroup = /* @__PURE__ */ new Map();
1885
+ for (const r of Array.isArray(v1Ledger.roles) ? v1Ledger.roles : []) if (r && typeof r.id === "string" && groupsById.has(r.groupId)) {
1886
+ if (!rolesByGroup.has(r.groupId)) rolesByGroup.set(r.groupId, []);
1887
+ rolesByGroup.get(r.groupId).push(r);
1888
+ }
1889
+ const v1Sessions = (Array.isArray(v1Ledger.sessions) ? v1Ledger.sessions : []).filter((s) => s && typeof s.id === "string" && groupsById.has(s.groupId));
1890
+ const sessionIds = new Set(v1Sessions.map((s) => s.id));
1891
+ const messagesDoc = this.loadJson(v1MessagesFile);
1892
+ const messagesBySession = /* @__PURE__ */ new Map();
1893
+ for (const m of messagesDoc && Array.isArray(messagesDoc.messages) ? messagesDoc.messages : []) if (m && typeof m.id === "string" && sessionIds.has(m.sessionId)) {
1894
+ if (!messagesBySession.has(m.sessionId)) messagesBySession.set(m.sessionId, []);
1895
+ messagesBySession.get(m.sessionId).push(m);
1896
+ }
1897
+ const fsyncDirs = /* @__PURE__ */ new Set();
1898
+ const uuidById = /* @__PURE__ */ new Map();
1899
+ for (const s of v1Sessions) {
1900
+ const uuid = randomUUID();
1901
+ uuidById.set(s.id, uuid);
1902
+ const doc = {
1903
+ schema: 1,
1904
+ savedAt: Date.now(),
1905
+ id: uuid,
1906
+ groupId: s.groupId,
1907
+ name: String(s.name || "会话"),
1908
+ topic: String(s.topic || ""),
1909
+ createdAt: typeof s.createdAt === "number" ? s.createdAt : Date.now(),
1910
+ messages: (messagesBySession.get(s.id) || []).map((m) => messageJson(m)).filter(Boolean)
1911
+ };
1912
+ this.atomicWrite(this.sessionFile(s.groupId, uuid), JSON.stringify(doc));
1913
+ fsyncDirs.add(this.sessionsDir(s.groupId));
1914
+ }
1915
+ for (const g of groupsById.values()) {
1916
+ this.atomicWrite(this.rolesFile(g.id), JSON.stringify({
1917
+ schema: 1,
1918
+ savedAt: Date.now(),
1919
+ roles: (rolesByGroup.get(g.id) || []).map((r) => roleJson(r))
1920
+ }));
1921
+ this.atomicWrite(this.workspaceFile(g.id), String(g.workspaceDir || "").trim() + "\n");
1922
+ fsyncDirs.add(this.groupDir(g.id));
1923
+ }
1924
+ this.atomicWrite(this.ledgerFile, JSON.stringify({
1925
+ schema: 2,
1926
+ savedAt: Date.now(),
1927
+ groups: [...groupsById.values()].map((g) => ({
1928
+ id: g.id,
1929
+ name: String(g.name || "群组")
1930
+ })),
1931
+ sessions: v1Sessions.map((s) => ({
1932
+ id: uuidById.get(s.id),
1933
+ groupId: s.groupId
1934
+ }))
1935
+ }, null, 2));
1936
+ fsyncDirs.add(this.dir);
1937
+ for (const d of fsyncDirs) this.fsyncDir(d);
1938
+ archive();
1939
+ }
1940
+ };
1941
+ /** 从磁盘目录回收群组 id 列表(ledger 缺失/损坏时的兜底路径)。 */
1942
+ function scanGroupIds(dir) {
1943
+ let entries;
1944
+ try {
1945
+ entries = readdirSync(dir, { withFileTypes: true });
1946
+ } catch {
1947
+ return [];
1948
+ }
1949
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
1950
+ }
1951
+ /** 扫描群组目录下的会话文件(ledger 缺失/损坏时的兜底路径)。 */
1952
+ function scanSessionIds(store, groupId) {
1953
+ let entries;
1954
+ try {
1955
+ entries = readdirSync(store.sessionsDir(groupId), { withFileTypes: true });
1956
+ } catch {
1957
+ return [];
1958
+ }
1959
+ const ids = [];
1960
+ for (const se of entries) {
1961
+ if (!se.isFile()) continue;
1962
+ const m = sessionFileMatch(se.name);
1963
+ if (m) ids.push(m);
1964
+ }
1965
+ return ids;
1966
+ }
1967
+ /** 空群组记录构造(hydrate 兜底路径用)。 */
1968
+ function emptyGroup(id, name) {
1969
+ return {
1970
+ id,
1971
+ name,
1972
+ workspaceDir: "",
1973
+ permissionTier: "view_only",
1974
+ roleIds: [],
1975
+ sessionIds: []
1976
+ };
1977
+ }
1978
+ //#endregion
1979
+ //#region src/host/persistence/persistence.ts
1980
+ /**
1981
+ * 持久化(PERSISTENCE.md v2.1):事件驱动脏标记合并落盘 + 启动恢复。
1982
+ * 锁失败/写失败时降级为内存态运行(console 告警)。
1983
+ * @module dsh-group-chat/host/persistence
1984
+ */
1985
+ /**
1986
+ * 创建持久化面:构造即完成 store 初始化 + hydrate(v1 迁移 → 残留清理 →
1987
+ * 清单 → 群组数据 → 会话文件;缺文件空重建;补建默认会话/群组立即落盘)。
1988
+ */
1989
+ function createPersistence(core) {
1990
+ try {
1991
+ core.store = new Store(STORE_DIR);
1992
+ } catch (e) {
1993
+ console.error("[dsh-group-chat] 持久化不可用,本实例以内存态运行(重启后数据不保留):", e);
1994
+ core.store = null;
1995
+ }
1996
+ const store = () => core.store;
1997
+ const dirtySessions = /* @__PURE__ */ new Set();
1998
+ const dirtyRoles = /* @__PURE__ */ new Set();
1999
+ const dirtyWorkspace = /* @__PURE__ */ new Set();
2000
+ let ledgerDirty = false;
2001
+ let flushScheduled = false;
2002
+ const ledgerDocument = () => ({
2003
+ schema: 3,
2004
+ savedAt: Date.now(),
2005
+ groups: [...core.groups.values()].map((g) => ({
2006
+ id: g.id,
2007
+ name: g.name,
2008
+ permissionTier: g.permissionTier
2009
+ })),
2010
+ sessions: [...core.groups.values()].flatMap((g) => g.sessionIds.map((sid) => ({
2011
+ id: sid,
2012
+ groupId: g.id
2013
+ })))
2014
+ });
2015
+ const sessionDocument = (s) => ({
2016
+ schema: 1,
2017
+ savedAt: Date.now(),
2018
+ id: s.id,
2019
+ name: s.name,
2020
+ groupId: s.groupId,
2021
+ topic: s.topic,
2022
+ ...s.namePinned ? { namePinned: true } : {},
2023
+ ...s.topicPinned ? { topicPinned: true } : {},
2024
+ createdAt: s.createdAt,
2025
+ messages: s.messageIds.map((mid) => core.messages.get(mid)).filter(Boolean).map((m) => messageJson(m)).filter(Boolean)
2026
+ });
2027
+ const rolesDocument = (g) => ({
2028
+ schema: 1,
2029
+ savedAt: Date.now(),
2030
+ roles: g.roleIds.map((rid) => core.roles.get(rid)).filter((r) => Boolean(r)).map((r) => roleJson(r))
2031
+ });
2032
+ /** 同步 flush:写全部脏文件;成功才清脏标记;每个实际发生 rename 的目录一次 fsync。 */
2033
+ const flushNow = () => {
2034
+ const s = store();
2035
+ if (s === null) return;
2036
+ const fsyncDirs = /* @__PURE__ */ new Set();
2037
+ for (const sid of [...dirtySessions]) {
2038
+ const sess = core.sessions.get(sid);
2039
+ if (!sess) {
2040
+ dirtySessions.delete(sid);
2041
+ continue;
2042
+ }
2043
+ try {
2044
+ s.atomicWrite(s.sessionFile(sess.groupId, sess.id), JSON.stringify(sessionDocument(sess)));
2045
+ dirtySessions.delete(sid);
2046
+ fsyncDirs.add(s.sessionsDir(sess.groupId));
2047
+ } catch (e) {
2048
+ console.error(`[dsh-group-chat] 会话 ${sid} 落盘失败(保留脏标记待重试):`, e);
2049
+ }
2050
+ }
2051
+ for (const gid of [...dirtyRoles]) {
2052
+ const g = core.groups.get(gid);
2053
+ if (!g) {
2054
+ dirtyRoles.delete(gid);
2055
+ continue;
2056
+ }
2057
+ try {
2058
+ s.atomicWrite(s.rolesFile(gid), JSON.stringify(rolesDocument(g)));
2059
+ dirtyRoles.delete(gid);
2060
+ fsyncDirs.add(s.groupDir(gid));
2061
+ } catch (e) {
2062
+ console.error(`[dsh-group-chat] 群组 ${gid} roles.json 落盘失败(保留脏标记待重试):`, e);
2063
+ }
2064
+ }
2065
+ for (const gid of [...dirtyWorkspace]) {
2066
+ const g = core.groups.get(gid);
2067
+ if (!g) {
2068
+ dirtyWorkspace.delete(gid);
2069
+ continue;
2070
+ }
2071
+ try {
2072
+ s.atomicWrite(s.workspaceFile(gid), (g.workspaceDir || "") + "\n");
2073
+ dirtyWorkspace.delete(gid);
2074
+ fsyncDirs.add(s.groupDir(gid));
2075
+ } catch (e) {
2076
+ console.error(`[dsh-group-chat] 群组 ${gid} workspaceDir 落盘失败(保留脏标记待重试):`, e);
2077
+ }
2078
+ }
2079
+ if (ledgerDirty) try {
2080
+ s.atomicWrite(s.ledgerFile, JSON.stringify(ledgerDocument(), null, 2));
2081
+ ledgerDirty = false;
2082
+ fsyncDirs.add(s.dir);
2083
+ } catch (e) {
2084
+ console.error("[dsh-group-chat] ledger.json 落盘失败(保留脏标记待重试):", e);
2085
+ }
2086
+ for (const d of fsyncDirs) s.fsyncDir(d);
2087
+ };
2088
+ /** 事件驱动落盘;同一 tick 内多次变更合并为一次写。 */
2089
+ const schedulePersist = ({ ledger = false, session = null, roles: roleGroup = null, workspace = null } = {}) => {
2090
+ if (store() === null) return;
2091
+ if (ledger) ledgerDirty = true;
2092
+ if (session) dirtySessions.add(session);
2093
+ if (roleGroup) dirtyRoles.add(roleGroup);
2094
+ if (workspace) dirtyWorkspace.add(workspace);
2095
+ if (flushScheduled) return;
2096
+ flushScheduled = true;
2097
+ Promise.resolve().then(() => {
2098
+ flushScheduled = false;
2099
+ flushNow();
2100
+ });
2101
+ };
2102
+ const dropDirty = ({ session = null, roles: roleGroup = null, workspace = null }) => {
2103
+ if (session) dirtySessions.delete(session);
2104
+ if (roleGroup) dirtyRoles.delete(roleGroup);
2105
+ if (workspace) dirtyWorkspace.delete(workspace);
2106
+ };
2107
+ const loadSession = (groupId, sid, noteMissing) => {
2108
+ if (core.sessions.has(sid)) return;
2109
+ const g = core.groups.get(groupId);
2110
+ if (!g) return;
2111
+ const sess = {
2112
+ id: sid,
2113
+ groupId,
2114
+ name: "会话",
2115
+ topic: "",
2116
+ messageIds: [],
2117
+ createdAt: Date.now()
2118
+ };
2119
+ let doc = null;
2120
+ const s = store();
2121
+ if (s) {
2122
+ const file = s.sessionFile(groupId, sid);
2123
+ if (noteMissing && !existsSync(file)) console.error(`[dsh-group-chat] 会话 ${sid} 文件缺失,按空消息重建`);
2124
+ doc = s.loadJson(file);
2125
+ }
2126
+ if (doc) {
2127
+ if (typeof doc.name === "string" && doc.name) sess.name = doc.name;
2128
+ if (typeof doc.topic === "string") sess.topic = doc.topic;
2129
+ if (doc.namePinned === true) sess.namePinned = true;
2130
+ if (doc.topicPinned === true) sess.topicPinned = true;
2131
+ if (typeof doc.createdAt === "number") sess.createdAt = doc.createdAt;
2132
+ let fallbackSeq = 0;
2133
+ for (const m of Array.isArray(doc.messages) ? doc.messages : []) {
2134
+ if (!m || typeof m.id !== "string" || core.messages.has(m.id)) continue;
2135
+ fallbackSeq++;
2136
+ core.messages.set(m.id, {
2137
+ id: m.id,
2138
+ sessionId: sid,
2139
+ seq: typeof m.seq === "number" ? m.seq : fallbackSeq,
2140
+ speaker: m.speaker ?? "",
2141
+ text: String(m.text || ""),
2142
+ reasoning: m.reasoning !== void 0 ? String(m.reasoning) : void 0,
2143
+ reasoningFull: m.reasoningFull !== void 0 ? String(m.reasoningFull) : void 0,
2144
+ thinkingSummary: m.thinkingSummary !== void 0 ? String(m.thinkingSummary) : void 0,
2145
+ model: m.model,
2146
+ error: m.error,
2147
+ toolCalls: Array.isArray(m.toolCalls) ? m.toolCalls : void 0,
2148
+ ts: typeof m.ts === "number" ? m.ts : Date.now()
2149
+ });
2150
+ sess.messageIds.push(m.id);
2151
+ }
2152
+ }
2153
+ core.sessions.set(sid, sess);
2154
+ g.sessionIds.push(sid);
2155
+ };
2156
+ const loadGroupData = (g) => {
2157
+ const s = store();
2158
+ if (!s) return;
2159
+ g.workspaceDir = s.readWorkspace(g.id);
2160
+ const doc = s.loadJson(s.rolesFile(g.id));
2161
+ if (doc && Array.isArray(doc.roles)) {
2162
+ for (const r of doc.roles) if (r && typeof r.id === "string" && !core.roles.has(r.id)) {
2163
+ core.roles.set(r.id, {
2164
+ id: r.id,
2165
+ groupId: g.id,
2166
+ name: String(r.name || "成员"),
2167
+ color: r.color || void 0,
2168
+ persona: String(r.persona || ""),
2169
+ provider: String(r.provider || ""),
2170
+ model: String(r.model || ""),
2171
+ temperature: asNumber(r.temperature),
2172
+ reasoningEffort: typeof r.reasoningEffort === "string" && r.reasoningEffort ? r.reasoningEffort : void 0,
2173
+ enabled: r.enabled !== false,
2174
+ thinking: r.thinking === true
2175
+ });
2176
+ g.roleIds.push(r.id);
2177
+ }
2178
+ }
2179
+ };
2180
+ const hydrate = () => {
2181
+ const s = store();
2182
+ if (s) {
2183
+ try {
2184
+ s.migrateV1();
2185
+ } catch (e) {
2186
+ console.error("[dsh-group-chat] v1 数据迁移失败,下次启动重试:", e);
2187
+ }
2188
+ try {
2189
+ s.cleanup();
2190
+ } catch {}
2191
+ }
2192
+ const ledger = s ? s.loadJson(s.ledgerFile) : null;
2193
+ if (ledger && Array.isArray(ledger.groups)) {
2194
+ for (const g of ledger.groups) if (g && typeof g.id === "string" && !core.groups.has(g.id)) core.groups.set(g.id, {
2195
+ id: g.id,
2196
+ name: String(g.name || "群组"),
2197
+ workspaceDir: "",
2198
+ permissionTier: migrateTier(g.permissionTier, g.allowCommands),
2199
+ roleIds: [],
2200
+ sessionIds: []
2201
+ });
2202
+ for (const g of core.groups.values()) loadGroupData(g);
2203
+ for (const item of Array.isArray(ledger.sessions) ? ledger.sessions : []) if (item && typeof item.id === "string" && core.groups.has(item.groupId)) loadSession(item.groupId, item.id, true);
2204
+ } else if (s) for (const gid of scanGroupIds(STORE_DIR)) {
2205
+ const g = emptyGroup(gid, "群组");
2206
+ core.groups.set(g.id, g);
2207
+ loadGroupData(g);
2208
+ for (const sid of scanSessionIds(s, gid)) loadSession(gid, sid, false);
2209
+ }
2210
+ const autoSessions = [];
2211
+ for (const g of core.groups.values()) if (g.sessionIds.length === 0) {
2212
+ const sess = core.newSession(g.id);
2213
+ g.sessionIds.push(sess.id);
2214
+ autoSessions.push(sess.id);
2215
+ }
2216
+ if (core.groups.size === 0) {
2217
+ const g = {
2218
+ id: core.nid("grp"),
2219
+ name: "默认群组",
2220
+ workspaceDir: "",
2221
+ permissionTier: "view_only",
2222
+ roleIds: [],
2223
+ sessionIds: []
2224
+ };
2225
+ core.groups.set(g.id, g);
2226
+ const sess = core.newSession(g.id, "会话 1");
2227
+ g.sessionIds.push(sess.id);
2228
+ autoSessions.push(sess.id);
2229
+ }
2230
+ let maxSeq = 0;
2231
+ for (const id of [
2232
+ ...core.groups.keys(),
2233
+ ...core.roles.keys(),
2234
+ ...core.messages.keys()
2235
+ ]) {
2236
+ const m = /-(\d+)$/.exec(id);
2237
+ if (m) maxSeq = Math.max(maxSeq, Number(m[1]));
2238
+ }
2239
+ core.idSeq = maxSeq + 1;
2240
+ if (autoSessions.length > 0) {
2241
+ schedulePersist({ ledger: true });
2242
+ for (const sid of autoSessions) schedulePersist({ session: sid });
2243
+ }
2244
+ };
2245
+ hydrate();
2246
+ return {
2247
+ schedulePersist,
2248
+ flushNow,
2249
+ dropDirty,
2250
+ release() {
2251
+ const s = store();
2252
+ if (s === null) return;
2253
+ try {
2254
+ flushNow();
2255
+ } catch {}
2256
+ try {
2257
+ s.release();
2258
+ } catch {}
2259
+ core.store = null;
2260
+ }
2261
+ };
2262
+ }
2263
+ //#endregion
2264
+ //#region src/host/tools/tools.ts
2265
+ /**
2266
+ * 工具执行(TOOLS.md §2:沙箱 / §3:确认闸门):read_file / list_dir /
2267
+ * run_command 三件套;realpath 硬边界 + 分隔符比较;run_command 按群组
2268
+ * 权限档位走逐条确认或直接执行。
2269
+ * @module dsh-group-chat/host/tools
2270
+ */
2271
+ /** 创建工具面。 */
2272
+ function createTools(core, touch) {
2273
+ const { run } = core;
2274
+ /** realpath 硬边界:目标必须在群组工作区内(带分隔符比较,防 /ws/foo 放行 /ws/foobar)。 */
2275
+ const resolveInWorkspace = (root, rawPath) => {
2276
+ const p = rawPath === void 0 || rawPath === null || String(rawPath).trim() === "" ? "." : String(rawPath);
2277
+ let target;
2278
+ try {
2279
+ target = realpathSync(isAbsolute(p) ? p : join(root, p));
2280
+ } catch {
2281
+ return {
2282
+ ok: false,
2283
+ error: "路径不存在:" + p
2284
+ };
2285
+ }
2286
+ if (target !== root && !target.startsWith(root + sep)) return {
2287
+ ok: false,
2288
+ error: "路径超出群组工作区范围:" + p
2289
+ };
2290
+ return {
2291
+ ok: true,
2292
+ target
2293
+ };
2294
+ };
2295
+ const toolReadFile = (root, args) => {
2296
+ const r = resolveInWorkspace(root, args.path);
2297
+ if (!r.ok) return {
2298
+ status: "error",
2299
+ output: r.error
2300
+ };
2301
+ let fd;
2302
+ try {
2303
+ if (!statSync(r.target).isFile()) return {
2304
+ status: "error",
2305
+ output: "不是文件:" + String(args.path)
2306
+ };
2307
+ fd = openSync(r.target, "r");
2308
+ const buf = Buffer.alloc(102401);
2309
+ const n = readSync(fd, buf, 0, buf.length, 0);
2310
+ let text = buf.subarray(0, Math.min(n, READ_FILE_MAX_BYTES)).toString("utf8");
2311
+ if (n > 102400) text += "\n…(文件超过 100KB,已截断)";
2312
+ return {
2313
+ status: "ok",
2314
+ output: text || "(空文件)"
2315
+ };
2316
+ } catch (e) {
2317
+ return {
2318
+ status: "error",
2319
+ output: "读取失败:" + String(e && e.message || e)
2320
+ };
2321
+ } finally {
2322
+ if (fd !== void 0) try {
2323
+ closeSync(fd);
2324
+ } catch {}
2325
+ }
2326
+ };
2327
+ const toolListDir = (root, args) => {
2328
+ const r = resolveInWorkspace(root, args.path);
2329
+ if (!r.ok) return {
2330
+ status: "error",
2331
+ output: r.error
2332
+ };
2333
+ try {
2334
+ if (!statSync(r.target).isDirectory()) return {
2335
+ status: "error",
2336
+ output: "不是目录:" + String(args.path)
2337
+ };
2338
+ const entries = readdirSync(r.target, { withFileTypes: true }).sort((a, b) => (a.isDirectory() ? 0 : 1) - (b.isDirectory() ? 0 : 1) || a.name.localeCompare(b.name));
2339
+ const lines = entries.slice(0, 500).map((e) => {
2340
+ if (e.isDirectory()) return e.name + "/";
2341
+ try {
2342
+ return e.name + " (" + statSync(join(r.target, e.name)).size + " B)";
2343
+ } catch {
2344
+ return e.name;
2345
+ }
2346
+ });
2347
+ if (entries.length > 500) lines.push("…(共 " + entries.length + " 项,仅显示前 500)");
2348
+ const out = lines.join("\n") || "(空目录)";
2349
+ return {
2350
+ status: "ok",
2351
+ output: out.length > 8e3 ? out.slice(0, CMD_OUTPUT_MAX_CHARS) + "\n…(已截断)" : out
2352
+ };
2353
+ } catch (e) {
2354
+ return {
2355
+ status: "error",
2356
+ output: "列出失败:" + String(e && e.message || e)
2357
+ };
2358
+ }
2359
+ };
2360
+ const runCommandTool = (root, command) => new Promise((resolve) => {
2361
+ let child;
2362
+ try {
2363
+ child = spawn("bash", ["-c", command], { cwd: root });
2364
+ } catch (e) {
2365
+ resolve({
2366
+ status: "error",
2367
+ output: "无法启动命令:" + String(e && e.message || e)
2368
+ });
2369
+ return;
2370
+ }
2371
+ run.childProc = child;
2372
+ let out = "";
2373
+ let dropped = 0;
2374
+ let timedOut = false;
2375
+ const onChunk = (chunk) => {
2376
+ if (out.length < 1048576) out += chunk.toString("utf8");
2377
+ else dropped += chunk.length;
2378
+ };
2379
+ child.stdout?.on("data", onChunk);
2380
+ child.stderr?.on("data", onChunk);
2381
+ const timer = setTimeout(() => {
2382
+ timedOut = true;
2383
+ try {
2384
+ child.kill("SIGKILL");
2385
+ } catch {}
2386
+ }, RUN_CMD_TIMEOUT_MS);
2387
+ const finish = (result) => {
2388
+ clearTimeout(timer);
2389
+ if (run.childProc === child) run.childProc = null;
2390
+ resolve(result);
2391
+ };
2392
+ child.on("error", (e) => finish({
2393
+ status: "error",
2394
+ output: "执行失败:" + String(e && e.message || e)
2395
+ }));
2396
+ child.on("close", (code) => {
2397
+ let text = out.length > 1048576 ? out.slice(0, CMD_CAPTURE_MAX_BYTES) : out;
2398
+ if (dropped > 0) text += "\n[输出采集超限,已丢弃 " + dropped + " 字节]";
2399
+ if (timedOut) text += "\n[执行超时(" + Math.round(RUN_CMD_TIMEOUT_MS / 1e3) + "s),已强制终止]";
2400
+ if (code !== 0 && code !== null && !timedOut) text += "\n[退出码 " + code + "]";
2401
+ if (text.length > 8e3) text = text.slice(0, CMD_OUTPUT_MAX_CHARS) + "\n…(输出超长,已截断)";
2402
+ finish({
2403
+ status: code === 0 ? "ok" : "error",
2404
+ output: text || "(无输出)"
2405
+ });
2406
+ });
2407
+ });
2408
+ /** run_command 确认闸门:置 pendingConfirm 后无限等待,confirmCommand/stop/dispose 唤醒。 */
2409
+ const requestConfirmation = (toolCallId, args) => new Promise((resolve) => {
2410
+ run.pendingConfirm = {
2411
+ toolCallId,
2412
+ tool: "run_command",
2413
+ args
2414
+ };
2415
+ run.confirmSignal = { resolve };
2416
+ touch();
2417
+ });
2418
+ const wakeConfirm = () => {
2419
+ const signal = run.confirmSignal;
2420
+ run.pendingConfirm = null;
2421
+ run.confirmSignal = null;
2422
+ if (signal) try {
2423
+ signal.resolve(false);
2424
+ } catch {}
2425
+ };
2426
+ const killChild = () => {
2427
+ if (run.childProc) try {
2428
+ run.childProc.kill("SIGKILL");
2429
+ } catch {}
2430
+ };
2431
+ const executeTool = async (g, root, tc) => {
2432
+ let args = {};
2433
+ try {
2434
+ const parsed = JSON.parse(tc.args);
2435
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) args = parsed;
2436
+ } catch {}
2437
+ const started = Date.now();
2438
+ let res;
2439
+ if (tc.name === "read_file") res = toolReadFile(root, args);
2440
+ else if (tc.name === "list_dir") res = toolListDir(root, args);
2441
+ else if (tc.name === "run_command") {
2442
+ if (g.permissionTier === "view_only") res = {
2443
+ status: "error",
2444
+ output: "群组权限为「仅可查看」,该命令未被运行"
2445
+ };
2446
+ else if (g.permissionTier === "workspace_write") {
2447
+ const allowed = await requestConfirmation(tc.id, args);
2448
+ if (run.stopping) res = {
2449
+ status: "error",
2450
+ output: "对话已被用户停止,命令未执行"
2451
+ };
2452
+ else if (!allowed) res = {
2453
+ status: "denied",
2454
+ output: "用户拒绝了这次命令执行"
2455
+ };
2456
+ else res = await runCommandTool(root, String(args.command || ""));
2457
+ } else res = await runCommandTool(root, String(args.command || ""));
2458
+ } else res = {
2459
+ status: "error",
2460
+ output: "未知工具:" + tc.name
2461
+ };
2462
+ return {
2463
+ status: res.status,
2464
+ output: res.output,
2465
+ args,
2466
+ durationMs: Date.now() - started
2467
+ };
2468
+ };
2469
+ const buildToolSchemas = (g) => {
2470
+ if (!g.workspaceDir) return [];
2471
+ return TOOL_SCHEMAS.filter((t) => t.name !== "run_command" || g.permissionTier !== "view_only");
2472
+ };
2473
+ return {
2474
+ executeTool,
2475
+ buildToolSchemas,
2476
+ requestConfirmation,
2477
+ wakeConfirm,
2478
+ killChild
2479
+ };
2480
+ }
2481
+ //#endregion
2482
+ //#region src/host/service.ts
2483
+ /**
2484
+ * 创建群聊宿主服务(装配各模块;持久化锁失败时降级内存态运行)。
2485
+ */
2486
+ function createGroupChatService(ctx) {
2487
+ const core = createHostState(ctx);
2488
+ const persist = createPersistence(core);
2489
+ const bus = createBroadcast(core);
2490
+ const materials = createMaterials(core);
2491
+ const tools = createTools(core, bus.touch);
2492
+ const conversation = createConversation(core, {
2493
+ touch: bus.touch,
2494
+ schedulePersist: persist.schedulePersist,
2495
+ materials,
2496
+ tools
2497
+ });
2498
+ const actions = createActions(core, {
2499
+ touch: bus.touch,
2500
+ snapshot: bus.snapshot,
2501
+ schedulePersist: persist.schedulePersist,
2502
+ dropDirty: persist.dropDirty,
2503
+ appendMessage: conversation.appendMessage,
2504
+ runLoop: conversation.runLoop,
2505
+ wakeConfirm: tools.wakeConfirm,
2506
+ killChild: tools.killChild,
2507
+ browse: materials.browse,
2508
+ fileSearch: materials.fileSearch,
2509
+ disposeFileSearch: materials.disposeFileSearch
2510
+ });
2511
+ return {
2512
+ snapshot: bus.snapshot,
2513
+ handleAction: actions.handleAction,
2514
+ subscribePush: bus.subscribePush,
2515
+ stopAll() {
2516
+ if (core.run.running) {
2517
+ core.run.stopping = true;
2518
+ bus.touch();
2519
+ }
2520
+ },
2521
+ dispose() {
2522
+ core.run.stopping = true;
2523
+ if (core.run.pendingConfirm) tools.wakeConfirm();
2524
+ tools.killChild();
2525
+ materials.disposeFileSearch();
2526
+ bus.dispose();
2527
+ persist.release();
2528
+ }
2529
+ };
2530
+ }
2531
+ //#endregion
2532
+ //#region src/index.ts
2533
+ const name = "group-chat";
2534
+ const inject = [
2535
+ "llm",
2536
+ "fs",
2537
+ "webServer",
2538
+ "workspaceRegistry"
2539
+ ];
2540
+ /** 设置命名空间;浏览器半拼写同一值,两边不共享代码。 */
2541
+ const SETTINGS_NAMESPACE = "group-chat";
2542
+ /** 设置 schema:目前只有启停一个开关。 */
2543
+ const Config = z.object({ enabled: z.boolean().default(true) });
2544
+ /**
2545
+ * Host 半插件体:状态机服务 + HTTP 路由 + 设置接入。
2546
+ * mountOnce 保证同名包每进程至多挂载一次(bundle 聚合与独立安装并存场景)。
2547
+ */
2548
+ const apply = mountOnce("dsh-group-chat", (ctx, config) => {
2549
+ const service = createGroupChatService(ctx);
2550
+ ctx.effect(() => {
2551
+ const disposers = makeGroupChatRoutes(service).map((route) => ctx.webServer.register(route));
2552
+ return () => {
2553
+ for (const dispose of disposers) dispose();
2554
+ };
2555
+ }, "group-chat: host API routes");
2556
+ ctx.effect(() => () => {
2557
+ service.dispose();
2558
+ });
2559
+ let current = () => config ?? {};
2560
+ const sync = () => {
2561
+ if (!(current().enabled ?? true)) service.stopAll();
2562
+ };
2563
+ ctx.inject(["settings"], (settingsCtx) => {
2564
+ try {
2565
+ if (typeof settingsCtx.settings?.installSection === "function") settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config ?? {}, {
2566
+ setSource: (source) => {
2567
+ current = source;
2568
+ },
2569
+ onChange: sync
2570
+ });
2571
+ else if (typeof settingsCtx.settings?.register === "function") {
2572
+ const scope = settingsCtx.settings.register(SETTINGS_NAMESPACE, Config, { base: config ?? {} });
2573
+ current = () => scope?.get?.() ?? config ?? {};
2574
+ scope?.watch?.(() => {
2575
+ sync();
2576
+ });
2577
+ }
2578
+ } catch {}
2579
+ });
2580
+ sync();
2581
+ });
2582
+ //#endregion
2583
+ export { Config, SETTINGS_NAMESPACE, apply, inject, name };