@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,385 @@
1
+ /**
2
+ * 主面板:三区工作台——左导航栏(搜索 + 群组→会话目录树)、中央会话流
3
+ * (@成员 点名 + markdown 渲染 + 思考折叠)、右上下文栏(群成员角色卡 +
4
+ * 工作区目录),右栏可收起,角色编辑走右侧滑出抽屉。
5
+ *
6
+ * 面板数据走 /api/group-chat/*(fetch + SSE),Host 半持有全部状态。
7
+ * @module dsh-group-chat/client/panel
8
+ */
9
+
10
+ import { useCallback, useEffect, useState, type ReactNode } from 'react'
11
+ import { Icon, P } from './lib/ui.ts'
12
+ import { api } from './lib/api.ts'
13
+ import { RoleDrawer } from './components/RoleDrawer.tsx'
14
+ import { NavPanel } from './components/NavPanel.tsx'
15
+ import { AsidePanel } from './components/AsidePanel.tsx'
16
+ import { ChatPanel } from './components/ChatPanel.tsx'
17
+ import { useGroupChatState, type ActionOk } from './hooks/useGroupChatState.ts'
18
+ import { useComposerEffects, useComposerInput, useMentionChip, useInputKeyboard } from './hooks/useComposer.ts'
19
+ import { useFileSearch } from './hooks/useFileSearch.ts'
20
+ import { useMentionCandidates, useMentionedRoles, useSafeMentionIndex } from './hooks/useMentionState.ts'
21
+ import { draftFromRole, blankDraft, groupById, roleById, sessById, type ClientSnapshot, type ModelsResponse, type SnapshotRole } from './lib/model.ts'
22
+
23
+ export function GroupChatPanel(): ReactNode {
24
+ const state = useGroupChatState()
25
+ const {
26
+ snap,
27
+ gid,
28
+ setGid,
29
+ sid,
30
+ setSid,
31
+ search,
32
+ setSearch,
33
+ collapsedGroups,
34
+ setCollapsedGroups,
35
+ renameDraft,
36
+ setRenameDraft,
37
+ confirmDel,
38
+ setConfirmDel,
39
+ confirmClear,
40
+ setConfirmClear,
41
+ roleDraft,
42
+ setRoleDraft,
43
+ roleFormError,
44
+ setRoleFormError,
45
+ models,
46
+ setModels,
47
+ modelsError,
48
+ setModelsError,
49
+ fileBrowser,
50
+ setFileBrowser,
51
+ wsDraft,
52
+ setWsDraft,
53
+ partsSel,
54
+ setPartsSel,
55
+ rounds,
56
+ setRounds,
57
+ input,
58
+ setInput,
59
+ err,
60
+ setErr,
61
+ topicDraft,
62
+ setTopicDraft,
63
+ mention,
64
+ setMention,
65
+ mentionIdx,
66
+ setMentionIdx,
67
+ asideOpen,
68
+ setAsideOpen,
69
+ navOpen,
70
+ setNavOpen,
71
+ atBottom,
72
+ setAtBottom,
73
+ inputRef,
74
+ sendingRef,
75
+ scrollRef,
76
+ action,
77
+ mutate,
78
+ } = state
79
+
80
+ // 瞬时通知(toast):key 为展示序号,同文案重复触发也会重启动画
81
+ const [toast, setToast] = useState<{ text: string, seq: number } | null>(null)
82
+
83
+ // ---- 所有 Hooks 必须在条件返回之前调用 ----
84
+ // Composer effects
85
+ const { onMsgsScroll } = useComposerEffects(inputRef, scrollRef, input, atBottom, snap)
86
+ const { syncFromDOM, onInputCE, onPasteCE, onDragOverCE, onDropCE } = useComposerInput(inputRef, setInput, setMention, setMentionIdx)
87
+
88
+ // 选中群组/会话解析(用于 hooks 依赖)
89
+ let group = snap && gid ? groupById(snap, gid) : null
90
+ if (!group && snap && snap.groups.length) group = snap.groups[0]
91
+ let sess = snap && sid ? sessById(snap, sid) : null
92
+ // 有效会话统一兜底(与渲染体一致):sid 未建立/未命中/跨群不匹配时取当前群组
93
+ // 最后一个会话——必须在 busyNow 等 run 派生值之前完成,否则流式行/停止按钮
94
+ // 在兜底视图(典型:host 重启后 lastCreated 为空、选中态从未建立)下永远失活
95
+ if ((!sess || sess.groupId !== group?.id) && group && snap) {
96
+ sess = group.sessionIds.length ? sessById(snap, group.sessionIds[group.sessionIds.length - 1]) : null
97
+ }
98
+
99
+ const enabledRoles = group ? group.roleIds.map((id) => roleById(snap!, id)).filter((r): r is SnapshotRole => !!r && r.enabled) : []
100
+ const participants = partsSel || enabledRoles.map((r) => r.id)
101
+ const busyNow = !!(sess && snap && snap.run.running && snap.run.sessionId === sess.id)
102
+
103
+ // 查看即清:当前选中会话即 run.finished 所指会话 → 发 ackFinish 确认已读
104
+ // (host 幂等清除 + 广播;覆盖点击/键盘/重挂恢复/lastCreated 定位全部选中路径)
105
+ const finishedRun = snap ? snap.run.finished : null
106
+ useEffect(() => {
107
+ if (finishedRun && sess && finishedRun.sessionId === sess.id) void mutate({ op: 'ackFinish' })
108
+ }, [finishedRun, sess])
109
+
110
+ // 提及状态:角色候选、文件搜索、索引安全化
111
+ const mentionCandidates = useMentionCandidates(mention, enabledRoles)
112
+ const { fileCandidates, fileSearchError, fileSearchLoading } = useFileSearch(
113
+ mention,
114
+ group?.id,
115
+ )
116
+ const mentionIdxC = useSafeMentionIndex(
117
+ mentionCandidates.length || fileCandidates.length,
118
+ mentionIdx
119
+ )
120
+
121
+ const mentionedRoles = useMentionedRoles(input, enabledRoles)
122
+
123
+ const { insertChip, insertFileChip } = useMentionChip(inputRef, setMention, setMentionIdx, syncFromDOM)
124
+
125
+ const sendMsg = useCallback(async (): Promise<void> => {
126
+ if (busyNow || !sess || sendingRef.current) return
127
+ sendingRef.current = true
128
+ try {
129
+ const parts = mentionedRoles.length ? mentionedRoles.map((r) => r.id) : participants
130
+ if (!parts.length) {
131
+ setErr(mentionedRoles.length ? '' : '请至少选择一个参与角色(或在消息中 @成员)')
132
+ if (!mentionedRoles.length) return
133
+ }
134
+ const res = await action({ kind: 'send', sessionId: sess.id, text: input, participantRoleIds: parts, rounds }) as ActionOk | null
135
+ if (res && !res.ok && res.error) setErr(res.error)
136
+ else if (res && res.ok) {
137
+ const el = inputRef.current
138
+ if (el) el.innerHTML = ''
139
+ setInput('')
140
+ setMention(null)
141
+ setErr('')
142
+ setAtBottom(true)
143
+ syncFromDOM()
144
+ }
145
+ } finally {
146
+ sendingRef.current = false
147
+ }
148
+ }, [busyNow, sess, sendingRef, mentionedRoles, participants, action, rounds, input, inputRef, setInput, setMention, setErr, setAtBottom, syncFromDOM])
149
+
150
+ const { onInputKeyDown } = useInputKeyboard(mention, mentionCandidates, mentionIdxC, setMentionIdx, insertChip, insertFileChip, fileCandidates, setMention, sendMsg)
151
+
152
+ // ---- 条件性早期返回 ----
153
+ if (!snap) {
154
+ return (
155
+ <div className="dsgc-root">
156
+ <div className="dsgc-loading">{Icon(P.IconLoadingOutline16, 16)}加载中…</div>
157
+ </div>
158
+ )
159
+ }
160
+
161
+ if (!group) {
162
+ return (
163
+ <div className="dsgc-root">
164
+ <div className="dsgc-loading">暂无群组</div>
165
+ </div>
166
+ )
167
+ }
168
+
169
+ const msgById: Record<string, ClientSnapshot['messages'][number]> = {}
170
+ for (const m of snap.messages) msgById[m.id] = m
171
+
172
+ const togglePart = (rid: string): void => {
173
+ const has = participants.includes(rid)
174
+ setPartsSel(has ? participants.filter((x) => x !== rid) : participants.concat([rid]))
175
+ }
176
+
177
+ // 清空确认:不可清空(对话进行中)走 toast 提示,不再落到输入框上方的红字
178
+ const doClear = async (): Promise<void> => {
179
+ if (!sess) return
180
+ if (busyNow) {
181
+ setToast({ text: '对话进行中,需先停止才能清空', seq: Date.now() })
182
+ return
183
+ }
184
+ const res = await mutate({ op: 'clearMessages', sessionId: sess.id })
185
+ if (res && res.ok && res.snapshot && !res.snapshot.error) setConfirmClear(false)
186
+ else if (res && res.snapshot && res.snapshot.error) {
187
+ setErr('')
188
+ setToast({ text: res.snapshot.error, seq: Date.now() })
189
+ }
190
+ }
191
+
192
+ // ---- 动作 ----
193
+ const fetchModels = async (): Promise<void> => {
194
+ try {
195
+ const m = await api.action({ kind: 'models' }) as ModelsResponse
196
+ if (m && m.ok && Array.isArray(m.providers)) {
197
+ setModels(m)
198
+ setModelsError(null)
199
+ return
200
+ }
201
+ setModelsError(m && m.error ? String(m.error) : '响应异常')
202
+ } catch (e) {
203
+ setModelsError(String((e && (e as Error).message) || e))
204
+ }
205
+ }
206
+
207
+ const openRoleEditor = async (role: SnapshotRole | null): Promise<void> => {
208
+ setErr('')
209
+ setRoleFormError('')
210
+ if (!models || modelsError) await fetchModels()
211
+ setRoleDraft(role ? draftFromRole(role) : blankDraft())
212
+ }
213
+
214
+ const saveRole = async (): Promise<void> => {
215
+ if (!roleDraft) return
216
+ if (!roleDraft.name.trim()) {
217
+ setRoleFormError('角色名称不能为空')
218
+ return
219
+ }
220
+ if (!roleDraft.provider || !roleDraft.model) {
221
+ setRoleFormError('请选择角色绑定的模型')
222
+ return
223
+ }
224
+ const res = await mutate({ op: 'upsertRole', groupId: group.id, role: roleDraft })
225
+ if (res && res.ok && res.snapshot && !res.snapshot.error) {
226
+ setRoleDraft(null)
227
+ setRoleFormError('')
228
+ }
229
+ }
230
+
231
+ const stopRun = async (): Promise<void> => {
232
+ await action({ kind: 'stop', sessionId: sess!.id })
233
+ }
234
+
235
+ const openBrowser = async (path: string | undefined): Promise<void> => {
236
+ setFileBrowser({ open: true, loading: true, list: null, error: '' })
237
+ try {
238
+ const res = await api.action({ kind: 'browse', path: path || '' }) as import('../core/types.ts').BrowseResult
239
+ if (res && res.ok) setFileBrowser({ open: true, loading: false, list: res, error: '' })
240
+ else setFileBrowser({ open: true, loading: false, list: null, error: (res && res.error) || '浏览失败' })
241
+ } catch (e) {
242
+ setFileBrowser({ open: true, loading: false, list: null, error: String((e && (e as Error).message) || e) })
243
+ }
244
+ }
245
+
246
+ const selectCurrentDir = (): void => {
247
+ if (!fileBrowser || !fileBrowser.list || !group) return
248
+ const path = fileBrowser.list.path!
249
+ void mutate({ op: 'setWorkspaceDir', groupId: group.id, path })
250
+ setWsDraft(null)
251
+ setFileBrowser(null)
252
+ }
253
+
254
+ return (
255
+ <div className="dsgc-root">
256
+ <NavPanel
257
+ snap={snap}
258
+ search={search}
259
+ setSearch={setSearch}
260
+ collapsedGroups={collapsedGroups}
261
+ setCollapsedGroups={setCollapsedGroups}
262
+ gid={group.id}
263
+ sid={sess ? sess.id : sid}
264
+ setGid={setGid}
265
+ setSid={setSid}
266
+ setPartsSel={setPartsSel}
267
+ renameDraft={renameDraft}
268
+ setRenameDraft={setRenameDraft}
269
+ confirmDel={confirmDel}
270
+ setConfirmDel={setConfirmDel}
271
+ mutate={mutate}
272
+ navOpen={navOpen}
273
+ />
274
+ <ChatPanel
275
+ snap={snap}
276
+ sess={sess}
277
+ group={group}
278
+ enabledRoles={enabledRoles}
279
+ participants={participants}
280
+ mentionedRoles={mentionedRoles}
281
+ busyNow={busyNow}
282
+ input={input}
283
+ mention={mention}
284
+ mentionCandidates={mentionCandidates}
285
+ mentionIdxC={mentionIdxC}
286
+ fileCandidates={fileCandidates}
287
+ fileSearchError={fileSearchError}
288
+ fileSearchLoading={fileSearchLoading}
289
+ rounds={rounds}
290
+ err={err}
291
+ atBottom={atBottom}
292
+ topicDraft={topicDraft}
293
+ msgById={msgById}
294
+ navOpen={navOpen}
295
+ asideOpen={asideOpen}
296
+ inputRef={inputRef}
297
+ scrollRef={scrollRef}
298
+ setTopicDraft={setTopicDraft}
299
+ setConfirmClear={setConfirmClear}
300
+ setMentionIdx={setMentionIdx}
301
+ setRounds={setRounds}
302
+ setNavOpen={setNavOpen}
303
+ setAsideOpen={setAsideOpen}
304
+ togglePart={togglePart}
305
+ onMsgsScroll={() => {
306
+ const isBottom = onMsgsScroll()
307
+ if (isBottom !== undefined) setAtBottom(isBottom)
308
+ }}
309
+ onInputCE={onInputCE}
310
+ onInputKeyDown={onInputKeyDown}
311
+ onPasteCE={onPasteCE}
312
+ onDropCE={onDropCE}
313
+ onDragOverCE={onDragOverCE}
314
+ insertChip={insertChip}
315
+ insertFileChip={insertFileChip}
316
+ sendMsg={sendMsg}
317
+ stopRun={stopRun}
318
+ action={action}
319
+ mutate={mutate}
320
+ setMention={setMention}
321
+ />
322
+ <AsidePanel
323
+ snap={snap}
324
+ group={group}
325
+ asideOpen={asideOpen}
326
+ wsDraft={wsDraft}
327
+ setWsDraft={setWsDraft}
328
+ fileBrowser={fileBrowser}
329
+ setFileBrowser={setFileBrowser}
330
+ mutate={mutate}
331
+ openRoleEditor={openRoleEditor}
332
+ openBrowser={openBrowser}
333
+ selectCurrentDir={selectCurrentDir}
334
+ />
335
+ {sess && confirmClear
336
+ ? (
337
+ <P.Modal
338
+ open
339
+ onClose={() => { setConfirmClear(false) }}
340
+ title="清空本会话的消息记录?"
341
+ closeLabel="关闭"
342
+ description={'会话「' + sess.name + '」的全部 ' + sess.messageIds.length + ' 条消息将被永久删除'}
343
+ footer={(
344
+ <>
345
+ <P.Button variant="outline" size="sm" onClick={() => { setConfirmClear(false) }}>取消</P.Button>
346
+ <P.Button variant="outline" size="sm" className="dsgc-stopbtn" onClick={() => { void doClear() }}>清空</P.Button>
347
+ </>
348
+ )}
349
+ >
350
+ <ul className="dsgc-clearnotes">
351
+ <li>删除内容:本会话的用户消息与角色发言(含思考、工具调用记录),确认后立即落盘</li>
352
+ <li>不可恢复:此操作没有回收站,也没有撤销</li>
353
+ <li>不受影响:会话本身与主题、群成员角色、工作区目录、权限档位</li>
354
+ </ul>
355
+ </P.Modal>
356
+ )
357
+ : null}
358
+ {toast
359
+ ? (
360
+ <P.Toast
361
+ key={toast.seq}
362
+ text={toast.text}
363
+ icon={Icon(P.IconWarningOutline16, 16)}
364
+ anchor={inputRef.current}
365
+ onDone={() => { setToast(null) }}
366
+ />
367
+ )
368
+ : null}
369
+ {roleDraft
370
+ ? (
371
+ <RoleDrawer
372
+ draft={roleDraft}
373
+ set={setRoleDraft}
374
+ models={models}
375
+ modelsError={modelsError}
376
+ onRetryModels={() => { void fetchModels() }}
377
+ onSave={() => { void saveRole() }}
378
+ onCancel={() => { setRoleDraft(null); setRoleFormError('') }}
379
+ formError={roleFormError}
380
+ />
381
+ )
382
+ : null}
383
+ </div>
384
+ )
385
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * 右侧成员与工作区栏组件
3
+ * @module dsh-group-chat/client/components
4
+ */
5
+
6
+ import type { ReactNode } from 'react'
7
+ import { Icon, P } from '../lib/ui.ts'
8
+ import { roleById, type ClientSnapshot, type ModelsResponse, type RoleDraft, type SnapshotRole } from '../lib/model.ts'
9
+
10
+ interface AsidePanelProps {
11
+ snap: ClientSnapshot
12
+ group: ClientSnapshot['groups'][number]
13
+ asideOpen: boolean
14
+ wsDraft: string | null
15
+ setWsDraft: (val: string | null) => void
16
+ fileBrowser: { open: boolean, loading: boolean, list: import('../../core/types.ts').BrowseResult | null, error: string } | null
17
+ setFileBrowser: (val: { open: boolean, loading: boolean, list: import('../../core/types.ts').BrowseResult | null, error: string } | null) => void
18
+ mutate: (args: Record<string, unknown>) => Promise<unknown>
19
+ openRoleEditor: (role: SnapshotRole | null) => Promise<void>
20
+ openBrowser: (path: string | undefined) => Promise<void>
21
+ selectCurrentDir: () => void
22
+ }
23
+
24
+ export function AsidePanel(props: AsidePanelProps): ReactNode {
25
+ const { snap, group, asideOpen, wsDraft, setWsDraft, fileBrowser, setFileBrowser, mutate, openRoleEditor, openBrowser, selectCurrentDir } = props
26
+
27
+ const commitWsDir = (): void => {
28
+ if (wsDraft !== null && group && wsDraft !== (group.workspaceDir || '')) {
29
+ void mutate({ op: 'setWorkspaceDir', groupId: group.id, path: wsDraft })
30
+ }
31
+ setWsDraft(null)
32
+ }
33
+
34
+ return (
35
+ <div className={'dsgc-aside' + (asideOpen ? '' : ' closed')} aria-hidden={asideOpen ? undefined : 'true'}>
36
+ <div className="dsgc-sec">
37
+ <div className="dsgc-sechead">
38
+ 群成员
39
+ <span className="dsgc-secspacer" />
40
+ <span className="dsgc-seccount">{group.roleIds.length ? group.roleIds.length + ' 个' : ''}</span>
41
+ <P.Button variant="ghost" size="sm" onClick={() => { void openRoleEditor(null) }} aria-label="添加角色">
42
+ {Icon(P.IconPlusOutline16, 14)}添加
43
+ </P.Button>
44
+ </div>
45
+ {group.roleIds.length
46
+ ? (
47
+ <div className="dsgc-roles">
48
+ {group.roleIds.map((rid) => {
49
+ const r = roleById(snap, rid)
50
+ if (!r) return null
51
+ return (
52
+ <div
53
+ key={r.id}
54
+ className={'dsgc-role' + (r.enabled ? '' : ' off')}
55
+ role="button"
56
+ tabIndex={0}
57
+ onClick={() => { void openRoleEditor(r) }}
58
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); void openRoleEditor(r) } }}
59
+ title="点击编辑角色"
60
+ >
61
+ <div className="dsgc-rolehead">
62
+ <span className="dsgc-roledot" style={{ background: r.color || '#888' }} />
63
+ <span className="dsgc-rolename">{r.name}</span>
64
+ <P.Tooltip label={r.enabled ? '停用该角色' : '启用该角色'} side="top" delayMs={300}>
65
+ <span
66
+ onClick={(e) => { e.stopPropagation() }}
67
+ onKeyDown={(e) => { e.stopPropagation() }}
68
+ style={{ display: 'inline-flex', flex: 'none' }}
69
+ >
70
+ <P.Switch
71
+ checked={r.enabled}
72
+ onChange={() => { void mutate({ op: 'setRoleEnabled', roleId: r.id, enabled: !r.enabled }) }}
73
+ label={r.enabled ? '停用该角色' : '启用该角色'}
74
+ aria-label={(r.enabled ? '停用' : '启用') + '角色 ' + r.name}
75
+ />
76
+ </span>
77
+ </P.Tooltip>
78
+ </div>
79
+ {r.persona ? <div className="dsgc-rolepersona" title={r.persona}>{r.persona}</div> : null}
80
+ <div className="dsgc-rolemenu">
81
+ <span
82
+ className="dsgc-rolemodel"
83
+ title={r.provider + ' / ' + r.model + (r.thinking ? ' · 深度思考' + (r.reasoningEffort && r.reasoningEffort !== 'default' ? '(' + r.reasoningEffort + ')' : '') : '')}
84
+ >
85
+ {r.provider} / {r.model}
86
+ </span>
87
+ {r.thinking
88
+ ? <span title="深度思考" style={{ display: 'inline-flex', alignItems: 'center', color: 'var(--dsw-alias-label-tertiary,inherit)' }}>{Icon(P.IconThinkOutline14, 14)}</span>
89
+ : null}
90
+ <span className="dsgc-roleops">
91
+ <button
92
+ className="dsgc-opbtn"
93
+ title="编辑角色"
94
+ aria-label="编辑角色"
95
+ onClick={(e) => { e.stopPropagation(); void openRoleEditor(r) }}
96
+ >
97
+ {Icon(P.IconEditOutline16, 14)}
98
+ </button>
99
+ <button
100
+ className="dsgc-opbtn danger"
101
+ title="移除角色"
102
+ aria-label="移除角色"
103
+ onClick={(e) => { e.stopPropagation(); void mutate({ op: 'deleteRole', roleId: r.id }) }}
104
+ >
105
+ {Icon(P.IconTrashOutline16, 14)}
106
+ </button>
107
+ </span>
108
+ </div>
109
+ </div>
110
+ )
111
+ })}
112
+ </div>
113
+ )
114
+ : <div className="dsgc-hint">还没有角色。每个角色可绑定不同的 provider/model,在群内以独立身份发言。</div>}
115
+ </div>
116
+ <div className="dsgc-sec">
117
+ <div className="dsgc-sechead">工作区目录</div>
118
+ <div className="dsgc-field">
119
+ <div className="dsgc-wsrow">
120
+ <input
121
+ className="dsgc-input"
122
+ value={wsDraft === null ? (group.workspaceDir || '') : wsDraft}
123
+ placeholder="~/docs 或 /abs/dir"
124
+ onChange={(e) => { setWsDraft(e.target.value) }}
125
+ onBlur={commitWsDir}
126
+ onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
127
+ />
128
+ <P.Button variant="outline" size="sm" onClick={() => { void openBrowser(wsDraft === null ? group.workspaceDir : wsDraft) }}>
129
+ {Icon(P.IconFolderOpenOutline16, 14)}浏览
130
+ </P.Button>
131
+ </div>
132
+ <div className="dsgc-hint">发送时读取目录内文本文件,注入本群全体角色上下文;角色也可用工具主动查看</div>
133
+ {group.workspaceDir === '~' || group.workspaceDir === '/'
134
+ ? <div className="dsgc-err">工作区指向整个主目录/根目录:角色的只读工具将可读取该范围下的所有文件,请谨慎</div>
135
+ : null}
136
+ </div>
137
+ {fileBrowser && fileBrowser.open
138
+ ? (
139
+ <div className="dsgc-fb">
140
+ <div className="dsgc-fbhead">
141
+ <span className="dsgc-fbpath" title={fileBrowser.list ? fileBrowser.list.path : ''}>
142
+ {fileBrowser.loading ? '读取中…' : (fileBrowser.list ? fileBrowser.list.path : '')}
143
+ </span>
144
+ <span style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
145
+ <P.Button variant="primary" size="sm" disabled={!fileBrowser.list} onClick={selectCurrentDir}>选定此目录</P.Button>
146
+ {fileBrowser.list
147
+ ? <P.Button variant="outline" size="sm" title="上一级" aria-label="上一级" onClick={() => { void openBrowser(fileBrowser.list!.parent) }}>{Icon(P.IconChevronUpOutline14, 14)}</P.Button>
148
+ : null}
149
+ {fileBrowser.list
150
+ ? <P.Button variant="outline" size="sm" title="主目录" aria-label="主目录" onClick={() => { void openBrowser(fileBrowser.list!.home) }}>{Icon(P.IconFolderClose16, 14)}</P.Button>
151
+ : null}
152
+ <P.Button variant="ghost" size="sm" onClick={() => { setFileBrowser(null) }} aria-label="关闭浏览器">{Icon(P.IconCloseOutline16, 14)}</P.Button>
153
+ </span>
154
+ </div>
155
+ {fileBrowser.error ? <div className="dsgc-err">{fileBrowser.error}</div> : null}
156
+ {fileBrowser.list
157
+ ? (
158
+ <div className="dsgc-fblist">
159
+ {(fileBrowser.list.entries || []).length
160
+ ? (fileBrowser.list.entries || []).map((e) => e.type === 'directory'
161
+ ? (
162
+ <button
163
+ key={e.path}
164
+ type="button"
165
+ className={'dsgc-fbrow' + (e.hidden ? ' dim' : '') + ' dir'}
166
+ onClick={() => { void openBrowser(e.path) }}
167
+ >
168
+ {Icon(e.hidden ? P.IconFolderClose16 : P.IconFolderOpenOutline16, 14)}
169
+ <span className="dsgc-fbname">{e.name}/</span>
170
+ </button>
171
+ )
172
+ : (
173
+ <div key={e.path} className="dsgc-fbrow dim file">
174
+ <span className="dsgc-fbname">{e.name}</span>
175
+ {e.size !== undefined ? <span className="dsgc-fbsize">{(P as unknown as { fileSizeText: (n: number) => string }).fileSizeText(e.size)}</span> : null}
176
+ </div>
177
+ ))
178
+ : <div className="dsgc-hint">空目录</div>}
179
+ </div>
180
+ )
181
+ : null}
182
+ </div>
183
+ )
184
+ : null}
185
+ </div>
186
+ </div>
187
+ )
188
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 聊天消息气泡(user / system / 角色发言)。
3
+ *
4
+ * 帧稳定 memo:SSE 全量快照(~65KB × 7fps)每帧重建全部消息对象 identity,
5
+ * 但已完成消息在 host 侧 append 后不可变——按渲染相关字段做值比较,流式
6
+ * 期间跳过已完成消息的重渲(每帧仅 live 行与派生列表变化),避免全量
7
+ * MarkdownText 重新解析打满主线程导致「卡死后一次性蹦出」。
8
+ * @module dsh-group-chat/client/Bubble
9
+ */
10
+
11
+ import { memo, type ReactNode } from 'react'
12
+ import { P } from '../lib/ui.ts'
13
+ import { fmtTime, MD_LABELS, type ClientSnapshot, type SnapshotRole } from '../lib/model.ts'
14
+ import { ThinkRow } from './ThinkRow.tsx'
15
+ import { ToolRow } from './ToolRow.tsx'
16
+
17
+ export interface BubbleProps {
18
+ m: ClientSnapshot['messages'][number]
19
+ /** 父级解析好的发言角色(user/system 消息为 null)——避免 Bubble 依赖 snap identity。 */
20
+ role: SnapshotRole | null
21
+ }
22
+
23
+ function BubbleInner({ m, role }: BubbleProps): ReactNode {
24
+ const isUser = m.speaker === 'user'
25
+ const isSys = m.speaker === 'system'
26
+ const name = isUser ? '我' : isSys ? '系统' : role ? role.name : '成员'
27
+ if (isSys) return <div className={'dsgc-sysmsg' + (m.error ? ' err' : '')}>{m.text}</div>
28
+ return (
29
+ <div className={'dsgc-msg' + (isUser ? ' mine' : '')}>
30
+ <div
31
+ className={'dsgc-avatar' + (isUser ? ' mine' : '')}
32
+ style={isUser || !role ? undefined : { border: '2px solid ' + (role.color || '#888') }}
33
+ >
34
+ {name.slice(0, 1)}
35
+ </div>
36
+ <div className="dsgc-msgbody">
37
+ <div className="dsgc-msghead">
38
+ <span className="dsgc-msgname">{name}</span>
39
+ {m.model ? <span className="dsgc-msgmodel" title={m.model}>{m.model}</span> : null}
40
+ {m.ts ? <span className="dsgc-msgtime">{fmtTime(m.ts)}</span> : null}
41
+ </div>
42
+ {isUser
43
+ ? <div className="dsgc-msgtext">{m.text}</div>
44
+ : (
45
+ <div className="dsgc-msgtext">
46
+ {m.reasoning ? <ThinkRow text={m.reasoning} /> : null}
47
+ {(Array.isArray(m.toolCalls) ? m.toolCalls : []).map((c, i) => <ToolRow key={'tc' + i} c={c} />)}
48
+ <P.MarkdownText text={m.text || '(无内容)'} labels={MD_LABELS} />
49
+ </div>
50
+ )}
51
+ </div>
52
+ </div>
53
+ )
54
+ }
55
+
56
+ /** 渲染相关字段的值比较(消息不可变;角色仅名/色参与渲染)。 */
57
+ function bubblePropsEqual(a: BubbleProps, b: BubbleProps): boolean {
58
+ const x = a.m
59
+ const y = b.m
60
+ if (x !== y) {
61
+ if (x.id !== y.id || x.speaker !== y.speaker || x.text !== y.text || x.reasoning !== y.reasoning || x.model !== y.model || x.error !== y.error || x.ts !== y.ts) return false
62
+ const ta = Array.isArray(x.toolCalls) ? x.toolCalls : []
63
+ const tb = Array.isArray(y.toolCalls) ? y.toolCalls : []
64
+ if (ta.length !== tb.length) return false
65
+ for (let i = 0; i < ta.length; i++) {
66
+ const ca = ta[i]
67
+ const cb = tb[i]
68
+ if (ca !== cb && (ca.tool !== cb.tool || ca.status !== cb.status || ca.output !== cb.output || ca.durationMs !== cb.durationMs || JSON.stringify(ca.args) !== JSON.stringify(cb.args))) return false
69
+ }
70
+ }
71
+ const ra = a.role
72
+ const rb = b.role
73
+ return (ra ? ra.name + '\u0000' + (ra.color || '') : '') === (rb ? rb.name + '\u0000' + (rb.color || '') : '')
74
+ }
75
+
76
+ export const Bubble = memo(BubbleInner, bubblePropsEqual)