@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.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +3308 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +2583 -0
- package/lib/types/client/Bubble.d.ts +10 -0
- package/lib/types/client/Glyph.d.ts +12 -0
- package/lib/types/client/GroupChatPanel.d.ts +10 -0
- package/lib/types/client/GroupChatPanel.old.d.ts +10 -0
- package/lib/types/client/GroupChatSettingsSection.d.ts +15 -0
- package/lib/types/client/PermissionSelect.d.ts +13 -0
- package/lib/types/client/RoleDrawer.d.ts +17 -0
- package/lib/types/client/ThinkRow.d.ts +9 -0
- package/lib/types/client/ToolRow.d.ts +9 -0
- package/lib/types/client/api.d.ts +8 -0
- package/lib/types/client/components/AsidePanel.d.ts +31 -0
- package/lib/types/client/components/Bubble.d.ts +19 -0
- package/lib/types/client/components/ChatPanel.d.ts +57 -0
- package/lib/types/client/components/Composer.d.ts +48 -0
- package/lib/types/client/components/Glyph.d.ts +12 -0
- package/lib/types/client/components/GroupChatSettingsSection.d.ts +15 -0
- package/lib/types/client/components/MessageFlow.d.ts +15 -0
- package/lib/types/client/components/NavPanel.d.ts +40 -0
- package/lib/types/client/components/PermissionSelect.d.ts +13 -0
- package/lib/types/client/components/RoleDrawer.d.ts +17 -0
- package/lib/types/client/components/ThinkRow.d.ts +9 -0
- package/lib/types/client/components/ToolRow.d.ts +9 -0
- package/lib/types/client/components/index.d.ts +9 -0
- package/lib/types/client/components.d.ts +22 -0
- package/lib/types/client/drawer.d.ts +17 -0
- package/lib/types/client/glyph.d.ts +12 -0
- package/lib/types/client/hooks/index.d.ts +6 -0
- package/lib/types/client/hooks/useComposer.d.ts +27 -0
- package/lib/types/client/hooks/useFileSearch.d.ts +19 -0
- package/lib/types/client/hooks/useGroupChatState.d.ts +95 -0
- package/lib/types/client/hooks/useMentionState.d.ts +27 -0
- package/lib/types/client/index.d.ts +52 -0
- package/lib/types/client/lib/api.d.ts +8 -0
- package/lib/types/client/lib/model.d.ts +80 -0
- package/lib/types/client/lib/styles.d.ts +10 -0
- package/lib/types/client/lib/ui.d.ts +17 -0
- package/lib/types/client/model.d.ts +78 -0
- package/lib/types/client/panel.d.ts +10 -0
- package/lib/types/client/settings.d.ts +15 -0
- package/lib/types/client/styles.d.ts +10 -0
- package/lib/types/client/ui.d.ts +17 -0
- package/lib/types/client/utils/utils.d.ts +29 -0
- package/lib/types/client/utils.d.ts +22 -0
- package/lib/types/core/json.d.ts +9 -0
- package/lib/types/core/status.d.ts +18 -0
- package/lib/types/core/tools.d.ts +59 -0
- package/lib/types/core/types.d.ts +256 -0
- package/lib/types/host/actions.d.ts +34 -0
- package/lib/types/host/api/actions.d.ts +36 -0
- package/lib/types/host/api/http.d.ts +16 -0
- package/lib/types/host/api/index.d.ts +7 -0
- package/lib/types/host/api/routes.d.ts +11 -0
- package/lib/types/host/broadcast.d.ts +20 -0
- package/lib/types/host/conversation.d.ts +25 -0
- package/lib/types/host/engine/conversation.d.ts +25 -0
- package/lib/types/host/engine/index.d.ts +7 -0
- package/lib/types/host/engine/retitle.d.ts +20 -0
- package/lib/types/host/http.d.ts +16 -0
- package/lib/types/host/index.d.ts +8 -0
- package/lib/types/host/materials/index.d.ts +6 -0
- package/lib/types/host/materials/materials.d.ts +40 -0
- package/lib/types/host/materials.d.ts +32 -0
- package/lib/types/host/persistence/index.d.ts +6 -0
- package/lib/types/host/persistence/persistence.d.ts +31 -0
- package/lib/types/host/persistence/store.d.ts +65 -0
- package/lib/types/host/persistence.d.ts +31 -0
- package/lib/types/host/routes.d.ts +11 -0
- package/lib/types/host/service.d.ts +28 -0
- package/lib/types/host/state.d.ts +45 -0
- package/lib/types/host/store.d.ts +65 -0
- package/lib/types/host/tools/index.d.ts +6 -0
- package/lib/types/host/tools/tools.d.ts +28 -0
- package/lib/types/host/tools.d.ts +28 -0
- package/lib/types/index.d.ts +41 -0
- package/lib/types/mount-once.d.ts +9 -0
- package/lib/types/shared/file-mention-grammar.d.ts +37 -0
- package/lib/types/shared/file-mention-grammar.test.d.ts +5 -0
- package/package.json +98 -0
- package/src/client/GroupChatPanel.tsx +385 -0
- package/src/client/components/AsidePanel.tsx +188 -0
- package/src/client/components/Bubble.tsx +76 -0
- package/src/client/components/ChatPanel.tsx +214 -0
- package/src/client/components/Composer.tsx +238 -0
- package/src/client/components/Glyph.tsx +27 -0
- package/src/client/components/GroupChatSettingsSection.tsx +74 -0
- package/src/client/components/MessageFlow.tsx +97 -0
- package/src/client/components/NavPanel.tsx +249 -0
- package/src/client/components/PermissionSelect.tsx +159 -0
- package/src/client/components/RoleDrawer.tsx +186 -0
- package/src/client/components/ThinkRow.tsx +32 -0
- package/src/client/components/ToolRow.tsx +51 -0
- package/src/client/components/index.ts +10 -0
- package/src/client/hooks/index.ts +7 -0
- package/src/client/hooks/useComposer.ts +272 -0
- package/src/client/hooks/useFileSearch.ts +115 -0
- package/src/client/hooks/useGroupChatState.ts +214 -0
- package/src/client/hooks/useMentionState.ts +56 -0
- package/src/client/index.ts +110 -0
- package/src/client/lib/api.ts +23 -0
- package/src/client/lib/model.ts +119 -0
- package/src/client/lib/styles.ts +317 -0
- package/src/client/lib/ui.ts +27 -0
- package/src/client/react-dom-shim.d.ts +13 -0
- package/src/client/utils/utils.ts +104 -0
- package/src/core/json.ts +28 -0
- package/src/core/status.ts +33 -0
- package/src/core/tools.ts +41 -0
- package/src/core/types.ts +222 -0
- package/src/host/api/actions.ts +335 -0
- package/src/host/api/http.ts +80 -0
- package/src/host/api/index.ts +8 -0
- package/src/host/api/routes.ts +95 -0
- package/src/host/broadcast.ts +65 -0
- package/src/host/engine/conversation.ts +331 -0
- package/src/host/engine/index.ts +8 -0
- package/src/host/engine/retitle.ts +135 -0
- package/src/host/index.ts +9 -0
- package/src/host/materials/index.ts +7 -0
- package/src/host/materials/materials.ts +193 -0
- package/src/host/persistence/index.ts +7 -0
- package/src/host/persistence/persistence.ts +311 -0
- package/src/host/persistence/store.ts +375 -0
- package/src/host/service.ts +78 -0
- package/src/host/state.ts +85 -0
- package/src/host/tools/index.ts +7 -0
- package/src/host/tools/tools.ts +194 -0
- package/src/index.ts +99 -0
- package/src/mount-once.ts +49 -0
- package/src/shared/file-mention-grammar.test.ts +77 -0
- 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,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
|
+
}
|