@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,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)
|