@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
@@ -0,0 +1,335 @@
1
+ /**
2
+ * 动作分发(handleAction,POST /api/group-chat/action 的载荷):
3
+ * mutate(12 种 CRUD/配置操作)| send | stop | confirmCommand | models | efforts。
4
+ * @module dsh-group-chat/host/api/actions
5
+ */
6
+
7
+ import { rmSync, unlinkSync } from 'node:fs'
8
+ import type { BrowseResult, EffortOptions, GroupRecord, ModelCatalog, MutateArgs, RoleRecord, SendArgs, Snapshot } from '../../core/types.ts'
9
+ import { asEffort, asNumber, asPermissionTier } from '../../core/types.ts'
10
+ import { PALETTE } from '../state.ts'
11
+ import type { HostState } from '../state.ts'
12
+ import type { Conversation } from '../engine/index.ts'
13
+ import type { Materials } from '../materials/index.ts'
14
+
15
+ /** 动作面。 */
16
+ export interface Actions {
17
+ handleAction: (body: Record<string, unknown>) => Promise<unknown>
18
+ }
19
+
20
+ /** 创建动作分发面。 */
21
+ export function createActions(core: HostState, deps: {
22
+ touch: () => void
23
+ snapshot: () => Snapshot
24
+ schedulePersist: (targets?: { ledger?: boolean, session?: string | null, roles?: string | null, workspace?: string | null }) => void
25
+ dropDirty: (targets: { session?: string | null, roles?: string | null, workspace?: string | null }) => void
26
+ appendMessage: Conversation['appendMessage']
27
+ runLoop: Conversation['runLoop']
28
+ wakeConfirm: () => void
29
+ killChild: () => void
30
+ browse: Materials['browse']
31
+ fileSearch: Materials['fileSearch']
32
+ disposeFileSearch: Materials['disposeFileSearch']
33
+ }): Actions {
34
+ const { llm, groups, sessions, roles, messages, run } = core
35
+ const { touch, snapshot, schedulePersist, dropDirty, appendMessage, runLoop, wakeConfirm, killChild, browse, fileSearch, disposeFileSearch } = deps
36
+
37
+ const mutate = (args: MutateArgs): Snapshot => {
38
+ const op = args && args.op
39
+ if (op === 'createGroup') {
40
+ const g: GroupRecord = { id: core.nid('grp'), name: String(args.name || '').trim() || '群组 ' + (groups.size + 1), workspaceDir: '', permissionTier: 'view_only', roleIds: [], sessionIds: [] }
41
+ groups.set(g.id, g)
42
+ const sess = core.newSession(g.id)
43
+ g.sessionIds.push(sess.id)
44
+ core.lastCreated = { kind: 'group', groupId: g.id, sessionId: sess.id }
45
+ schedulePersist({ ledger: true, session: sess.id })
46
+ touch()
47
+ } else if (op === 'renameGroup') {
48
+ const g = groups.get(args.groupId!)
49
+ if (g && String(args.name || '').trim()) {
50
+ g.name = String(args.name).trim()
51
+ schedulePersist({ ledger: true })
52
+ touch()
53
+ }
54
+ } else if (op === 'deleteGroup') {
55
+ const g = groups.get(args.groupId!)
56
+ if (!g) return { ...snapshot(), error: '群组不存在' }
57
+ if (groups.size <= 1) return { ...snapshot(), error: '至少保留一个群组' }
58
+ if (run.running) {
59
+ const runSession = sessions.get(run.sessionId!)
60
+ if (runSession && runSession.groupId === g.id) return { ...snapshot(), error: '对话进行中,无法删除群组' }
61
+ }
62
+ for (const sid of g.sessionIds) {
63
+ const sess = sessions.get(sid)
64
+ if (sess) for (const mid of sess.messageIds) messages.delete(mid)
65
+ sessions.delete(sid)
66
+ dropDirty({ session: sid })
67
+ }
68
+ for (const rid of g.roleIds) roles.delete(rid)
69
+ groups.delete(g.id)
70
+ disposeFileSearch(g.id)
71
+ dropDirty({ roles: g.id, workspace: g.id })
72
+ if (core.store !== null) {
73
+ try {
74
+ rmSync(core.store.groupDir(g.id), { recursive: true, force: true })
75
+ } catch (e) {
76
+ console.error('[dsh-group-chat] 群组目录清理失败:', e)
77
+ }
78
+ }
79
+ schedulePersist({ ledger: true })
80
+ touch()
81
+ } else if (op === 'createSession') {
82
+ const g = groups.get(args.groupId!)
83
+ if (!g) return { ...snapshot(), error: '群组不存在' }
84
+ const sess = core.newSession(g.id, String(args.name || '').trim() || undefined)
85
+ g.sessionIds.push(sess.id)
86
+ core.lastCreated = { kind: 'session', groupId: g.id, sessionId: sess.id }
87
+ schedulePersist({ ledger: true, session: sess.id })
88
+ touch()
89
+ } else if (op === 'renameSession') {
90
+ const sess = sessions.get(args.sessionId!)
91
+ if (sess && String(args.name || '').trim()) {
92
+ sess.name = String(args.name).trim()
93
+ sess.namePinned = true // 手动编辑 = 隐式固定:自动命名此后跳过名称
94
+ schedulePersist({ session: sess.id })
95
+ touch()
96
+ }
97
+ } else if (op === 'deleteSession') {
98
+ const sess = sessions.get(args.sessionId!)
99
+ if (!sess) return { ...snapshot(), error: '会话不存在' }
100
+ const g = groups.get(sess.groupId)
101
+ if (g && g.sessionIds.length <= 1) return { ...snapshot(), error: '每个群组至少保留一个会话' }
102
+ if (run.running && run.sessionId === sess.id) return { ...snapshot(), error: '对话进行中,无法删除会话' }
103
+ // 「输出完毕」标记随会话一并清理(悬空指向已删除会话无消费方)
104
+ if (run.finished && run.finished.sessionId === sess.id) run.finished = null
105
+ for (const mid of sess.messageIds) messages.delete(mid)
106
+ sessions.delete(sess.id)
107
+ dropDirty({ session: sess.id })
108
+ if (g) g.sessionIds = g.sessionIds.filter((x) => x !== sess.id)
109
+ if (core.store !== null) {
110
+ try {
111
+ unlinkSync(core.store.sessionFile(sess.groupId, sess.id))
112
+ } catch {}
113
+ }
114
+ schedulePersist({ ledger: true })
115
+ touch()
116
+ } else if (op === 'setTopic') {
117
+ const sess = sessions.get(args.sessionId!)
118
+ if (sess) {
119
+ sess.topic = String(args.topic || '')
120
+ sess.topicPinned = true // 手动编辑 = 隐式固定:自动整理此后跳过主题
121
+ schedulePersist({ session: sess.id })
122
+ touch()
123
+ }
124
+ } else if (op === 'upsertRole') {
125
+ const g = groups.get(args.groupId!)
126
+ const r = args.role || {}
127
+ if (!g) return { ...snapshot(), error: '群组不存在' }
128
+ if (!r.name || !String(r.name).trim()) return { ...snapshot(), error: '角色名称不能为空' }
129
+ if (!r.provider || !r.model) return { ...snapshot(), error: '请选择角色绑定的模型' }
130
+ if (r.id && roles.get(r.id)) {
131
+ const ex = roles.get(r.id)!
132
+ ex.name = String(r.name).trim()
133
+ ex.color = r.color || ex.color
134
+ ex.persona = r.persona || ''
135
+ ex.provider = r.provider
136
+ ex.model = r.model
137
+ ex.temperature = asNumber(r.temperature)
138
+ ex.reasoningEffort = asEffort(r.reasoningEffort)
139
+ ex.enabled = r.enabled !== false
140
+ ex.thinking = r.thinking === true
141
+ schedulePersist({ roles: g.id })
142
+ touch()
143
+ } else {
144
+ const nr: RoleRecord = {
145
+ id: core.nid('role'),
146
+ groupId: g.id,
147
+ name: String(r.name).trim(),
148
+ color: r.color || PALETTE[g.roleIds.length % PALETTE.length],
149
+ persona: r.persona || '',
150
+ provider: r.provider,
151
+ model: r.model,
152
+ temperature: asNumber(r.temperature),
153
+ reasoningEffort: asEffort(r.reasoningEffort),
154
+ enabled: true,
155
+ thinking: r.thinking === true,
156
+ }
157
+ roles.set(nr.id, nr)
158
+ g.roleIds.push(nr.id)
159
+ schedulePersist({ roles: g.id })
160
+ touch()
161
+ }
162
+ } else if (op === 'deleteRole') {
163
+ const r = roles.get(args.roleId!)
164
+ if (r) {
165
+ const gid = r.groupId
166
+ const g = groups.get(gid)
167
+ if (g) g.roleIds = g.roleIds.filter((x) => x !== r.id)
168
+ roles.delete(r.id)
169
+ schedulePersist({ roles: gid })
170
+ touch()
171
+ }
172
+ } else if (op === 'setRoleEnabled') {
173
+ const r = roles.get(args.roleId!)
174
+ if (r) {
175
+ r.enabled = !!args.enabled
176
+ schedulePersist({ roles: r.groupId })
177
+ touch()
178
+ }
179
+ } else if (op === 'setWorkspaceDir') {
180
+ const g = groups.get(args.groupId!)
181
+ if (g) {
182
+ g.workspaceDir = String(args.path || '').trim()
183
+ schedulePersist({ workspace: g.id })
184
+ touch()
185
+ }
186
+ } else if (op === 'setPermissionTier') {
187
+ const tier = asPermissionTier(args.tier)
188
+ if (!tier) return { ...snapshot(), error: '未知权限档位' }
189
+ const g = groups.get(args.groupId!)
190
+ if (g) {
191
+ g.permissionTier = tier
192
+ // 降到仅可查看时若该群挂着待确认命令,自动拒绝(安全侧倾斜);
193
+ // 升档不自动放行已排队的待确认命令,仍需用户手动确认
194
+ if (tier === 'view_only' && run.pendingConfirm) {
195
+ const runSession = run.sessionId ? sessions.get(run.sessionId) : null
196
+ if (runSession && runSession.groupId === g.id) wakeConfirm()
197
+ }
198
+ schedulePersist({ ledger: true })
199
+ touch()
200
+ }
201
+ } else if (op === 'ackFinish') {
202
+ // 查看即清:客户端选中 lastFinished 会话时确认已读(幂等;无标记不广播)
203
+ if (run.finished) {
204
+ run.finished = null
205
+ touch()
206
+ }
207
+ } else if (op === 'clearMessages') {
208
+ const sess = sessions.get(args.sessionId!)
209
+ if (sess) {
210
+ if (run.running && run.sessionId === sess.id) return { ...snapshot(), error: '对话进行中,无法清空' }
211
+ for (const mid of sess.messageIds) messages.delete(mid)
212
+ sess.messageIds = []
213
+ schedulePersist({ session: sess.id })
214
+ touch()
215
+ }
216
+ }
217
+ return snapshot()
218
+ }
219
+
220
+ const send = (args: SendArgs): { ok: boolean, error?: string } => {
221
+ const sess = sessions.get(args.sessionId)
222
+ if (!sess) return { ok: false, error: '会话不存在' }
223
+ const g = groups.get(sess.groupId)
224
+ if (!g) return { ok: false, error: '群组不存在' }
225
+ if (run.running) return { ok: false, error: '已有对话进行中,请先停止' }
226
+ let parts = Array.isArray(args.participantRoleIds) ? args.participantRoleIds.slice() : []
227
+ parts = parts.filter((id) => {
228
+ const r = roles.get(id)
229
+ return r && r.groupId === g.id && r.enabled
230
+ })
231
+ if (!parts.length) parts = g.roleIds.filter((id) => {
232
+ const r = roles.get(id)
233
+ return r && r.enabled
234
+ })
235
+ if (!parts.length) return { ok: false, error: '群内还没有启用的角色,请先添加角色' }
236
+ const rounds = Math.max(1, Math.min(10, Math.floor(Number(args.rounds) || 1)))
237
+ const text = String(args.text || '').trim().slice(0, 32000)
238
+ if (text) appendMessage(sess, 'user', text)
239
+ const queue: string[] = []
240
+ for (let i = 0; i < rounds; i++) for (const rid of parts) queue.push(rid)
241
+ run.running = true
242
+ run.sessionId = sess.id
243
+ run.queue = queue
244
+ run.stopping = false
245
+ run.finished = null // 新 run 覆盖旧的「输出完毕」未读标记
246
+ touch()
247
+ void runLoop(sess).catch((e) => console.error('group-chat run failed', e))
248
+ return { ok: true }
249
+ }
250
+
251
+ const stop = (args: { sessionId?: string }): { ok: boolean } => {
252
+ if (run.running && (!args || !args.sessionId || run.sessionId === args.sessionId)) {
253
+ run.stopping = true
254
+ if (run.pendingConfirm) wakeConfirm()
255
+ killChild()
256
+ touch()
257
+ }
258
+ return { ok: true }
259
+ }
260
+
261
+ /** run_command 确认(TOOLS.md §4):同步三查全过才置空放行,杜绝并发/伪造/stale 确认。 */
262
+ const confirmCommand = (args: { toolCallId?: string, allow?: boolean }): { ok: boolean, error?: string } => {
263
+ if (!run.running || !run.pendingConfirm || run.pendingConfirm.toolCallId !== (args && args.toolCallId)) {
264
+ return { ok: false, error: '确认请求不存在或已处理' }
265
+ }
266
+ const signal = run.confirmSignal
267
+ run.pendingConfirm = null
268
+ run.confirmSignal = null
269
+ touch()
270
+ if (signal) {
271
+ try {
272
+ signal.resolve(!!(args && args.allow))
273
+ } catch {}
274
+ }
275
+ return { ok: true }
276
+ }
277
+
278
+ let modelCache: ModelCatalog | null = null
279
+ const models = async (): Promise<ModelCatalog> => {
280
+ const now = Date.now()
281
+ if (modelCache && now - modelCache.at < 60000) return modelCache
282
+ const providers = await llm.listProviders()
283
+ const modelsByProvider: ModelCatalog['modelsByProvider'] = {}
284
+ for (const p of providers) {
285
+ try {
286
+ modelsByProvider[p.id] = await llm.listModels(p.id)
287
+ } catch {
288
+ modelsByProvider[p.id] = []
289
+ }
290
+ }
291
+ modelCache = { at: now, providers: providers.map((p) => ({ id: p.id, name: p.name || p.id })), modelsByProvider }
292
+ return modelCache
293
+ }
294
+
295
+ // 推理级别选项:取自 DSH 模型设置(llm.resolveModelInfo 的 reasoning.efforts),
296
+ // 60s 缓存;模型未暴露或查询失败返回空列表(客户端只显示「默认」)
297
+ const effortCache = new Map<string, { at: number, data: EffortOptions }>()
298
+ const efforts = async (args: { provider?: string, model?: string }): Promise<{ ok: boolean } & EffortOptions> => {
299
+ const provider = String((args && args.provider) || '')
300
+ const model = String((args && args.model) || '')
301
+ if (!provider || !model) return { ok: false, efforts: [], defaultEffort: undefined, error: '缺少 provider 或 model' }
302
+ const key = provider + '|' + model
303
+ const hit = effortCache.get(key)
304
+ if (hit && Date.now() - hit.at < 60000) return { ok: true, ...hit.data }
305
+ let data: EffortOptions
306
+ try {
307
+ const info = await llm.resolveModelInfo(provider, model)
308
+ const reasoning = info && info.reasoning
309
+ const list = reasoning && Array.isArray(reasoning.efforts) ? reasoning.efforts : []
310
+ data = {
311
+ efforts: list.map((e) => ({ id: e.id, name: e.name || e.id, description: e.description })),
312
+ defaultEffort: reasoning && typeof reasoning.defaultEffort === 'string' ? reasoning.defaultEffort : undefined,
313
+ }
314
+ } catch (e) {
315
+ data = { efforts: [], defaultEffort: undefined, error: String((e && (e as Error).message) || e) }
316
+ }
317
+ effortCache.set(key, { at: Date.now(), data })
318
+ return { ok: true, ...data }
319
+ }
320
+
321
+ const handleAction = async (body: Record<string, unknown>): Promise<unknown> => {
322
+ const kind = body && body.kind
323
+ if (kind === 'mutate') return { ok: true, snapshot: mutate(body as unknown as MutateArgs), lastCreated: core.lastCreated }
324
+ if (kind === 'send') return send(body as unknown as SendArgs)
325
+ if (kind === 'stop') return stop(body as { sessionId?: string })
326
+ if (kind === 'confirmCommand') return confirmCommand(body as { toolCallId?: string, allow?: boolean })
327
+ if (kind === 'models') return { ok: true, ...(await models()) }
328
+ if (kind === 'efforts') return efforts(body as { provider?: string, model?: string })
329
+ if (kind === 'browse') return browse(body as { path?: string })
330
+ if (kind === 'fileSearch') return fileSearch(body as { groupId?: string, query?: string })
331
+ return { ok: false, error: 'unknown-action' }
332
+ }
333
+
334
+ return { handleAction }
335
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Host HTTP 工具:回环信任栏(对齐 dsh-web shared/host/loopback.ts 语义 +
3
+ * 浏览器同源标记栏)、有界 JSON 请求体读取、JSON 响应写出。
4
+ * @module dsh-group-chat/host/api/http
5
+ */
6
+
7
+ import type { IncomingMessage, ServerResponse } from 'node:http'
8
+
9
+ /** 请求体上限 1MB。 */
10
+ const BODY_LIMIT = 1024 * 1024
11
+
12
+ const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8' }
13
+
14
+ /** IPv4 127/8 谓词(四段十进制,首段 == 127)。 */
15
+ function isIPv4Loopback(v4: string): boolean {
16
+ const parts = v4.split('.')
17
+ return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
18
+ }
19
+
20
+ /** socket 远端地址是否落在回环段(127/8、::1、IPv4-mapped)。 */
21
+ function isLoopbackAddress(address: string | undefined): boolean {
22
+ if (address === undefined) return false
23
+ const normalized = address.toLowerCase()
24
+ if (normalized === '::1') return true
25
+ if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice(7))
26
+ return isIPv4Loopback(normalized)
27
+ }
28
+
29
+ /** 归一化 URL 主机名是否为回环权威(localhost、[::1]、127/8)。 */
30
+ function isLoopbackHostname(hostname: string): boolean {
31
+ if (hostname === 'localhost' || hostname === '[::1]') return true
32
+ return isIPv4Loopback(hostname)
33
+ }
34
+
35
+ /**
36
+ * 请求级信任栏:浏览器同源标记(sec-fetch-site / Origin)必须存在且回环
37
+ * socket + 回环 Host 全过;X-Forwarded-For 一律不信任。无任何同源标记的
38
+ * 非浏览器请求(如裸 curl)一律拒绝。
39
+ */
40
+ export function isTrustedRequest(req: IncomingMessage): boolean {
41
+ if (!(req.headers['sec-fetch-site'] === 'same-origin' || typeof req.headers.origin === 'string')) return false
42
+ if (!isLoopbackAddress(req.socket.remoteAddress)) return false
43
+ const host = req.headers.host
44
+ if (typeof host !== 'string') return false
45
+ let hostUrl: URL
46
+ try {
47
+ hostUrl = new URL('http://' + host)
48
+ } catch {
49
+ return false
50
+ }
51
+ if (!isLoopbackHostname(hostUrl.hostname)) return false
52
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
53
+ const origin = req.headers.origin
54
+ if (origin === undefined) return true
55
+ try {
56
+ return new URL(origin).host === hostUrl.host
57
+ } catch {
58
+ return false
59
+ }
60
+ }
61
+
62
+ /** 读有界 JSON 请求体;超限抛 'body-too-large'(路由映射 413)。 */
63
+ export async function readBody(req: IncomingMessage): Promise<unknown> {
64
+ const chunks: Buffer[] = []
65
+ let size = 0
66
+ for await (const chunk of req) {
67
+ const buffer = chunk as Buffer
68
+ size += buffer.length
69
+ if (size > BODY_LIMIT) throw new Error('body-too-large')
70
+ chunks.push(buffer)
71
+ }
72
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
73
+ }
74
+
75
+ /** 写 JSON 响应;调用方可追加/覆盖默认头。 */
76
+ export function writeJson(res: ServerResponse, status: number, body: unknown, headers: Record<string, string | number> = {}): void {
77
+ const payload = JSON.stringify(body)
78
+ res.writeHead(status, { ...JSON_HEADERS, ...headers })
79
+ res.end(payload)
80
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * API 层统一导出:HTTP 传输(护栏 + 路由)与动作分发。
3
+ * @module dsh-group-chat/host/api
4
+ */
5
+
6
+ export { createActions } from './actions.ts'
7
+ export type { Actions } from './actions.ts'
8
+ export { makeGroupChatRoutes } from './routes.ts'
@@ -0,0 +1,95 @@
1
+ /**
2
+ * HTTP 路由(经 webServer 暴露):
3
+ * GET /api/group-chat/state 全量快照
4
+ * POST /api/group-chat/action { kind: mutate|send|stop|confirmCommand|models|efforts|browse|fileSearch, ... }
5
+ * GET /api/group-chat/events SSE,状态变化时推送节流后的全量快照
6
+ * @module dsh-group-chat/host/api/routes
7
+ */
8
+
9
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
10
+ import { isTrustedRequest, readBody, writeJson } from './http.ts'
11
+ import type { GroupChatService } from '../service.ts'
12
+
13
+ const API_PREFIX = '/api/group-chat'
14
+
15
+ /** SSE 心跳周期。 */
16
+ const HEARTBEAT_MS = 15e3
17
+
18
+ /** 逐路由的信任栏 + 403。 */
19
+ function guard(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse): boolean {
20
+ if (isTrustedRequest(req)) return true
21
+ writeJson(res, 403, { ok: false, error: 'forbidden' }, { 'cache-control': 'no-store' })
22
+ return false
23
+ }
24
+
25
+ /** 构建群聊的三条 HTTP 路由。 */
26
+ export function makeGroupChatRoutes(service: GroupChatService): WebRoute[] {
27
+ return [
28
+ {
29
+ kind: 'exact',
30
+ path: API_PREFIX + '/state',
31
+ handler: (req, res) => {
32
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'method-not-allowed' })
33
+ if (!guard(req, res)) return
34
+ writeJson(res, 200, { ok: true, ...service.snapshot() }, { 'cache-control': 'no-store' })
35
+ },
36
+ },
37
+ {
38
+ kind: 'exact',
39
+ path: API_PREFIX + '/action',
40
+ handler: async (req, res) => {
41
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'method-not-allowed' })
42
+ if (!guard(req, res)) return
43
+ if (!String(req.headers['content-type'] || '').toLowerCase().startsWith('application/json')) {
44
+ return writeJson(res, 415, { ok: false, error: 'json-required' })
45
+ }
46
+ try {
47
+ const body = await readBody(req)
48
+ writeJson(res, 200, await service.handleAction(body), { 'cache-control': 'no-store' })
49
+ } catch (e) {
50
+ const message = e instanceof Error ? e.message : String(e)
51
+ writeJson(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message })
52
+ }
53
+ },
54
+ },
55
+ {
56
+ kind: 'exact',
57
+ path: API_PREFIX + '/events',
58
+ handler: (req, res) => {
59
+ if (req.method !== 'GET') {
60
+ res.writeHead(405)
61
+ res.end()
62
+ return
63
+ }
64
+ if (!guard(req, res)) return
65
+ res.writeHead(200, {
66
+ 'content-type': 'text/event-stream; charset=utf-8',
67
+ 'cache-control': 'no-cache',
68
+ connection: 'keep-alive',
69
+ })
70
+ const push = () => {
71
+ try {
72
+ res.write('data: ' + JSON.stringify({ ok: true, ...service.snapshot() }) + '\n\n')
73
+ } catch {
74
+ /* connection gone; close handler cleans up */
75
+ }
76
+ }
77
+ const unsubscribe = service.subscribePush(push)
78
+ const heartbeat = setInterval(() => {
79
+ try {
80
+ res.write(': ping\n\n')
81
+ } catch {
82
+ /* ignore */
83
+ }
84
+ }, HEARTBEAT_MS)
85
+ const close = () => {
86
+ clearInterval(heartbeat)
87
+ unsubscribe()
88
+ }
89
+ req.once('close', close)
90
+ res.once('close', close)
91
+ push()
92
+ },
93
+ },
94
+ ]
95
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * SSE 广播(节流)+ touch + 全量快照:变更经 touch()(revision++ + 节流广播)
3
+ * 通知 /api/group-chat/events 订阅者;快照读容器当前状态。
4
+ * @module dsh-group-chat/host/broadcast
5
+ */
6
+
7
+ import type { HostState } from './state.ts'
8
+ import type { Snapshot } from '../core/types.ts'
9
+
10
+ /** SSE 推送节流。 */
11
+ const PUSH_THROTTLE_MS = 120
12
+
13
+ /** 广播面。 */
14
+ export interface Broadcast {
15
+ /** revision++ 并节流广播。 */
16
+ touch: () => void
17
+ /** 全量快照(客户端唯一读取面)。 */
18
+ snapshot: () => Snapshot
19
+ /** 订阅推送;返回退订函数。 */
20
+ subscribePush: (push: () => void) => () => void
21
+ /** dispose:清掉挂起的节流定时器。 */
22
+ dispose: () => void
23
+ }
24
+
25
+ /** 创建广播面。 */
26
+ export function createBroadcast(core: HostState): Broadcast {
27
+ const subscribers = new Set<() => void>()
28
+ let pushTimer: ReturnType<typeof setTimeout> | null = null
29
+ const broadcast = (): void => {
30
+ if (pushTimer !== null) return
31
+ pushTimer = setTimeout(() => {
32
+ pushTimer = null
33
+ for (const push of subscribers) push()
34
+ }, PUSH_THROTTLE_MS)
35
+ }
36
+
37
+ const touch = (): void => {
38
+ core.revision++
39
+ broadcast()
40
+ }
41
+
42
+ const snapshot = (): Snapshot => ({
43
+ revision: core.revision,
44
+ run: { running: core.run.running, sessionId: core.run.sessionId, currentRoleId: core.run.currentRoleId, partial: core.run.partial, partialReasoning: core.run.partialReasoning, pendingConfirm: core.run.pendingConfirm, finished: core.run.finished },
45
+ lastCreated: core.lastCreated,
46
+ groups: [...core.groups.values()].map((g) => ({ id: g.id, name: g.name, workspaceDir: g.workspaceDir, permissionTier: g.permissionTier, roleIds: g.roleIds.slice(), sessionIds: g.sessionIds.slice() })),
47
+ sessions: [...core.sessions.values()].map((s) => ({ id: s.id, groupId: s.groupId, name: s.name, topic: s.topic, messageIds: s.messageIds.slice(), createdAt: s.createdAt })),
48
+ roles: [...core.roles.values()].map((r) => ({ id: r.id, groupId: r.groupId, name: r.name, color: r.color, persona: r.persona, provider: r.provider, model: r.model, temperature: r.temperature, reasoningEffort: r.reasoningEffort, enabled: r.enabled, thinking: r.thinking === true })),
49
+ messages: [...core.messages.values()].map((m) => ({ id: m.id, sessionId: m.sessionId, seq: m.seq, speaker: m.speaker, text: m.text, reasoning: m.reasoning, model: m.model, error: m.error, toolCalls: m.toolCalls, ts: m.ts })),
50
+ })
51
+
52
+ return {
53
+ touch,
54
+ snapshot,
55
+ subscribePush(push: () => void): () => void {
56
+ subscribers.add(push)
57
+ return () => {
58
+ subscribers.delete(push)
59
+ }
60
+ },
61
+ dispose(): void {
62
+ if (pushTimer !== null) clearTimeout(pushTimer)
63
+ },
64
+ }
65
+ }