@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,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 消息输入相关 hooks
|
|
3
|
+
* @module dsh-group-chat/client/hooks
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useCallback, useEffect, type ClipboardEvent as ReactClipboardEvent, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react'
|
|
7
|
+
import { execCommand, queryAtCaret, serializeInput, chipHtml, fileChipHtml } from '../utils/utils.ts'
|
|
8
|
+
import type { AtToken } from '../../shared/file-mention-grammar.ts'
|
|
9
|
+
import type { SnapshotRole } from '../lib/model.ts'
|
|
10
|
+
|
|
11
|
+
export function useComposerEffects(
|
|
12
|
+
inputRef: React.RefObject<HTMLDivElement>,
|
|
13
|
+
scrollRef: React.RefObject<HTMLDivElement>,
|
|
14
|
+
input: string,
|
|
15
|
+
atBottom: boolean,
|
|
16
|
+
snap: unknown,
|
|
17
|
+
) {
|
|
18
|
+
// 贴底时新内容自动跟随滚动
|
|
19
|
+
const onMsgsScroll = useCallback(() => {
|
|
20
|
+
const el = scrollRef.current
|
|
21
|
+
if (!el) return
|
|
22
|
+
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60
|
|
23
|
+
return isAtBottom
|
|
24
|
+
}, [scrollRef])
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const el = scrollRef.current
|
|
28
|
+
if (el && atBottom) el.scrollTop = el.scrollHeight
|
|
29
|
+
}, [snap, atBottom, scrollRef])
|
|
30
|
+
|
|
31
|
+
// composer 输入框随内容自适应高度
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const el = inputRef.current
|
|
34
|
+
if (!el) return
|
|
35
|
+
el.style.height = 'auto'
|
|
36
|
+
el.style.height = Math.min(180, Math.max(36, el.scrollHeight)) + 'px'
|
|
37
|
+
}, [input, inputRef])
|
|
38
|
+
|
|
39
|
+
return { onMsgsScroll }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useComposerInput(
|
|
43
|
+
inputRef: React.RefObject<HTMLDivElement>,
|
|
44
|
+
setInput: (val: string) => void,
|
|
45
|
+
setMention: (val: AtToken | null) => void,
|
|
46
|
+
setMentionIdx: (val: number) => void,
|
|
47
|
+
) {
|
|
48
|
+
/** 从 DOM 同步 input 状态(序列化) */
|
|
49
|
+
const syncFromDOM = useCallback((): void => {
|
|
50
|
+
const el = inputRef.current
|
|
51
|
+
if (!el) return
|
|
52
|
+
setInput(serializeInput(el))
|
|
53
|
+
}, [inputRef, setInput])
|
|
54
|
+
|
|
55
|
+
const onInputCE = useCallback((): void => {
|
|
56
|
+
syncFromDOM()
|
|
57
|
+
setMention(queryAtCaret())
|
|
58
|
+
setMentionIdx(0)
|
|
59
|
+
}, [syncFromDOM, setMention, setMentionIdx])
|
|
60
|
+
|
|
61
|
+
/** 粘贴/拖放强制纯文本 */
|
|
62
|
+
const onPasteCE = useCallback((e: ReactClipboardEvent<HTMLDivElement>): void => {
|
|
63
|
+
e.preventDefault()
|
|
64
|
+
const text = e.clipboardData.getData('text/plain')
|
|
65
|
+
if (text) execCommand('insertText', text)
|
|
66
|
+
}, [])
|
|
67
|
+
|
|
68
|
+
const onDragOverCE = useCallback((e: ReactDragEvent<HTMLDivElement>): void => {
|
|
69
|
+
e.preventDefault()
|
|
70
|
+
}, [])
|
|
71
|
+
|
|
72
|
+
const onDropCE = useCallback((e: ReactDragEvent<HTMLDivElement>): void => {
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
const text = e.dataTransfer.getData('text/plain')
|
|
75
|
+
if (text) execCommand('insertText', text)
|
|
76
|
+
}, [])
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
syncFromDOM,
|
|
80
|
+
onInputCE,
|
|
81
|
+
onPasteCE,
|
|
82
|
+
onDragOverCE,
|
|
83
|
+
onDropCE,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function useMentionChip(
|
|
88
|
+
inputRef: React.RefObject<HTMLDivElement>,
|
|
89
|
+
setMention: (val: AtToken | null) => void,
|
|
90
|
+
setMentionIdx: (val: number) => void,
|
|
91
|
+
syncFromDOM: () => void,
|
|
92
|
+
) {
|
|
93
|
+
/** 弹层候选 → 删掉光标前的 @词、插入原子芯片 + 尾随空格 */
|
|
94
|
+
const insertChip = useCallback((role: SnapshotRole): void => {
|
|
95
|
+
const el = inputRef.current
|
|
96
|
+
const sel = window.getSelection()
|
|
97
|
+
if (!el || !sel) return
|
|
98
|
+
if (!sel.anchorNode || !el.contains(sel.anchorNode)) return
|
|
99
|
+
el.focus({ preventScroll: true })
|
|
100
|
+
|
|
101
|
+
// 1. 删除光标前的 @token(包括引号)
|
|
102
|
+
const node = sel.anchorNode
|
|
103
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
104
|
+
const text = node.nodeValue || ''
|
|
105
|
+
const off = sel.anchorOffset
|
|
106
|
+
const before = text.slice(0, off)
|
|
107
|
+
// 匹配 @词 或 @"词
|
|
108
|
+
const m = /(?:^|\s)@("?)([^"\s@]*)$/.exec(before)
|
|
109
|
+
if (m) {
|
|
110
|
+
const prefixLen = m[1] ? 2 : 1 // @" 或 @
|
|
111
|
+
const queryLen = m[2].length
|
|
112
|
+
const start = off - prefixLen - queryLen
|
|
113
|
+
if (start >= 0) {
|
|
114
|
+
const range = document.createRange()
|
|
115
|
+
range.setStart(node, start)
|
|
116
|
+
range.setEnd(node, off)
|
|
117
|
+
sel.removeAllRanges()
|
|
118
|
+
sel.addRange(range)
|
|
119
|
+
execCommand('delete')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 2. 插入芯片
|
|
125
|
+
execCommand('insertHTML', chipHtml(role).replace('class="dsgc-chipin"', 'class="dsgc-chipin" data-new=""'))
|
|
126
|
+
const chip = el.querySelector<HTMLElement>('.dsgc-chipin[data-new]')
|
|
127
|
+
if (!chip) return
|
|
128
|
+
|
|
129
|
+
// 3. 选区显式钉到芯片之后,再补尾随空格
|
|
130
|
+
const after = document.createRange()
|
|
131
|
+
after.setStartAfter(chip)
|
|
132
|
+
after.collapse(true)
|
|
133
|
+
sel.removeAllRanges()
|
|
134
|
+
sel.addRange(after)
|
|
135
|
+
execCommand('insertText', ' ')
|
|
136
|
+
chip.removeAttribute('data-new')
|
|
137
|
+
setMention(null)
|
|
138
|
+
setMentionIdx(0)
|
|
139
|
+
syncFromDOM()
|
|
140
|
+
}, [inputRef, setMention, setMentionIdx, syncFromDOM])
|
|
141
|
+
|
|
142
|
+
const insertFileChip = useCallback((path: string, kind: 'file' | 'directory'): void => {
|
|
143
|
+
const el = inputRef.current
|
|
144
|
+
const sel = window.getSelection()
|
|
145
|
+
if (!el || !sel) return
|
|
146
|
+
if (!sel.anchorNode || !el.contains(sel.anchorNode)) return
|
|
147
|
+
el.focus({ preventScroll: true })
|
|
148
|
+
|
|
149
|
+
// 1. 删除光标前的 @token(包括引号)
|
|
150
|
+
const node = sel.anchorNode
|
|
151
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
152
|
+
const text = node.nodeValue || ''
|
|
153
|
+
const off = sel.anchorOffset
|
|
154
|
+
const before = text.slice(0, off)
|
|
155
|
+
// 匹配 @词 或 @"词
|
|
156
|
+
const m = /(?:^|\s)@("?)([^"\s@]*)$/.exec(before)
|
|
157
|
+
if (m) {
|
|
158
|
+
const prefixLen = m[1] ? 2 : 1 // @" 或 @
|
|
159
|
+
const queryLen = m[2].length
|
|
160
|
+
const start = off - prefixLen - queryLen
|
|
161
|
+
if (start >= 0) {
|
|
162
|
+
const range = document.createRange()
|
|
163
|
+
range.setStart(node, start)
|
|
164
|
+
range.setEnd(node, off)
|
|
165
|
+
sel.removeAllRanges()
|
|
166
|
+
sel.addRange(range)
|
|
167
|
+
execCommand('delete')
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 2. 插入文件芯片
|
|
173
|
+
execCommand('insertHTML', fileChipHtml(path, kind).replace('class="dsgc-chipin dsgc-chipin-file"', 'class="dsgc-chipin dsgc-chipin-file" data-new=""'))
|
|
174
|
+
const chip = el.querySelector<HTMLElement>('.dsgc-chipin[data-new]')
|
|
175
|
+
if (!chip) return
|
|
176
|
+
|
|
177
|
+
// 3. 选区显式钉到芯片之后,再补尾随空格
|
|
178
|
+
const after = document.createRange()
|
|
179
|
+
after.setStartAfter(chip)
|
|
180
|
+
after.collapse(true)
|
|
181
|
+
sel.removeAllRanges()
|
|
182
|
+
sel.addRange(after)
|
|
183
|
+
execCommand('insertText', ' ')
|
|
184
|
+
chip.removeAttribute('data-new')
|
|
185
|
+
setMention(null)
|
|
186
|
+
setMentionIdx(0)
|
|
187
|
+
syncFromDOM()
|
|
188
|
+
}, [inputRef, setMention, setMentionIdx, syncFromDOM])
|
|
189
|
+
|
|
190
|
+
return { insertChip, insertFileChip }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function useInputKeyboard(
|
|
194
|
+
mention: AtToken | null,
|
|
195
|
+
mentionCandidates: SnapshotRole[],
|
|
196
|
+
mentionIdxC: number,
|
|
197
|
+
setMentionIdx: (val: number | ((prev: number) => number)) => void,
|
|
198
|
+
insertChip: (role: SnapshotRole) => void,
|
|
199
|
+
insertFileChip: (path: string, kind: 'file' | 'directory') => void,
|
|
200
|
+
fileCandidates: Array<{ path: string; isDir: boolean }>,
|
|
201
|
+
setMention: (val: AtToken | null) => void,
|
|
202
|
+
sendMsg: () => Promise<void>,
|
|
203
|
+
) {
|
|
204
|
+
const onInputKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>): void => {
|
|
205
|
+
// IME 组合中的按键不参与任何快捷逻辑
|
|
206
|
+
if (e.nativeEvent.isComposing || e.keyCode === 229) return
|
|
207
|
+
|
|
208
|
+
// 角色模式(裸 @):Enter/Tab 插入角色芯片
|
|
209
|
+
if (mention !== null && mention.query === '' && mentionCandidates.length) {
|
|
210
|
+
if (e.key === 'ArrowDown') {
|
|
211
|
+
e.preventDefault()
|
|
212
|
+
setMentionIdx((mentionIdxC + 1) % mentionCandidates.length)
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
if (e.key === 'ArrowUp') {
|
|
216
|
+
e.preventDefault()
|
|
217
|
+
setMentionIdx((mentionIdxC - 1 + mentionCandidates.length) % mentionCandidates.length)
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') {
|
|
221
|
+
e.preventDefault()
|
|
222
|
+
insertChip(mentionCandidates[mentionIdxC])
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 文件模式(@ + query):Enter 插入文件/Tab 钻入目录/Esc 关闭
|
|
228
|
+
if (mention !== null && mention.query !== '' && fileCandidates.length) {
|
|
229
|
+
if (e.key === 'ArrowDown') {
|
|
230
|
+
e.preventDefault()
|
|
231
|
+
setMentionIdx((mentionIdxC + 1) % fileCandidates.length)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
if (e.key === 'ArrowUp') {
|
|
235
|
+
e.preventDefault()
|
|
236
|
+
setMentionIdx((mentionIdxC - 1 + fileCandidates.length) % fileCandidates.length)
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
const selectedItem = fileCandidates[mentionIdxC]
|
|
240
|
+
if (selectedItem) {
|
|
241
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
242
|
+
e.preventDefault()
|
|
243
|
+
insertFileChip(selectedItem.path, selectedItem.isDir ? 'directory' : 'file')
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
if (e.key === 'Tab' && selectedItem.isDir) {
|
|
247
|
+
e.preventDefault()
|
|
248
|
+
insertFileChip(selectedItem.path, 'directory')
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (e.key === 'Escape') {
|
|
253
|
+
e.preventDefault()
|
|
254
|
+
setMention(null)
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (e.key === 'Enter' && e.shiftKey) {
|
|
260
|
+
e.preventDefault()
|
|
261
|
+
execCommand('insertLineBreak')
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
266
|
+
e.preventDefault()
|
|
267
|
+
void sendMsg()
|
|
268
|
+
}
|
|
269
|
+
}, [mention, mentionCandidates, mentionIdxC, fileCandidates, setMentionIdx, insertChip, insertFileChip, setMention, sendMsg])
|
|
270
|
+
|
|
271
|
+
return { onInputKeyDown }
|
|
272
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件搜索 hook:防抖 + 取消过期请求 + 短缓存。
|
|
3
|
+
* @module dsh-group-chat/client/hooks
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect, useState } from 'react'
|
|
7
|
+
import { api } from '../lib/api.ts'
|
|
8
|
+
import type { AtToken } from '../../shared/file-mention-grammar.ts'
|
|
9
|
+
|
|
10
|
+
export interface FileCandidate {
|
|
11
|
+
path: string
|
|
12
|
+
isDir: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FileSearchResult {
|
|
16
|
+
fileCandidates: FileCandidate[]
|
|
17
|
+
fileSearchError: string | null
|
|
18
|
+
fileSearchLoading: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEBOUNCE_MS = 180
|
|
22
|
+
const CACHE_LIMIT = 20
|
|
23
|
+
const cache = new Map<string, FileCandidate[]>()
|
|
24
|
+
|
|
25
|
+
function cacheGet(key: string): FileCandidate[] | undefined {
|
|
26
|
+
const hit = cache.get(key)
|
|
27
|
+
if (!hit) return undefined
|
|
28
|
+
cache.delete(key)
|
|
29
|
+
cache.set(key, hit)
|
|
30
|
+
return hit
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cacheSet(key: string, value: FileCandidate[]): void {
|
|
34
|
+
cache.delete(key)
|
|
35
|
+
cache.set(key, value)
|
|
36
|
+
if (cache.size > CACHE_LIMIT) {
|
|
37
|
+
const oldest = cache.keys().next().value
|
|
38
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isAbort(err: unknown): boolean {
|
|
43
|
+
return !!err && typeof err === 'object' && 'name' in err && (err as { name: string }).name === 'AbortError'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 文件搜索:query 变化 180ms 内合并;过期 fetch 真正 abort;
|
|
48
|
+
* 命中短缓存立刻出结果。离开文件模式才清空列表,避免每个按键闪「检索中」。
|
|
49
|
+
*/
|
|
50
|
+
export function useFileSearch(
|
|
51
|
+
mention: AtToken | null,
|
|
52
|
+
groupId: string | undefined,
|
|
53
|
+
): FileSearchResult {
|
|
54
|
+
const [fileCandidates, setFileCandidates] = useState<FileCandidate[]>([])
|
|
55
|
+
const [fileSearchError, setFileSearchError] = useState<string | null>(null)
|
|
56
|
+
const [fileSearchLoading, setFileSearchLoading] = useState(false)
|
|
57
|
+
|
|
58
|
+
const query = mention && mention.query !== '' ? mention.query : null
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (query === null || !groupId) {
|
|
62
|
+
setFileCandidates([])
|
|
63
|
+
setFileSearchError(null)
|
|
64
|
+
setFileSearchLoading(false)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const key = groupId + '\0' + query
|
|
69
|
+
const hit = cacheGet(key)
|
|
70
|
+
if (hit) {
|
|
71
|
+
setFileCandidates(hit)
|
|
72
|
+
setFileSearchError(null)
|
|
73
|
+
setFileSearchLoading(false)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const controller = new AbortController()
|
|
78
|
+
setFileSearchLoading(true)
|
|
79
|
+
const timer = window.setTimeout(() => {
|
|
80
|
+
void (async () => {
|
|
81
|
+
try {
|
|
82
|
+
const res = await api.action({
|
|
83
|
+
kind: 'fileSearch',
|
|
84
|
+
groupId,
|
|
85
|
+
query,
|
|
86
|
+
}, controller.signal) as { ok: true, candidates?: FileCandidate[] } | { ok: false, error?: string }
|
|
87
|
+
|
|
88
|
+
if (controller.signal.aborted) return
|
|
89
|
+
if (res.ok) {
|
|
90
|
+
const next = res.candidates || []
|
|
91
|
+
cacheSet(key, next)
|
|
92
|
+
setFileCandidates(next)
|
|
93
|
+
setFileSearchError(null)
|
|
94
|
+
} else {
|
|
95
|
+
setFileCandidates([])
|
|
96
|
+
setFileSearchError(res.error || '文件检索失败')
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (controller.signal.aborted || isAbort(err)) return
|
|
100
|
+
setFileCandidates([])
|
|
101
|
+
setFileSearchError(String(err))
|
|
102
|
+
} finally {
|
|
103
|
+
if (!controller.signal.aborted) setFileSearchLoading(false)
|
|
104
|
+
}
|
|
105
|
+
})()
|
|
106
|
+
}, DEBOUNCE_MS)
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
window.clearTimeout(timer)
|
|
110
|
+
controller.abort()
|
|
111
|
+
}
|
|
112
|
+
}, [query, groupId])
|
|
113
|
+
|
|
114
|
+
return { fileCandidates, fileSearchError, fileSearchLoading }
|
|
115
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 群聊面板核心状态管理 hook
|
|
3
|
+
* @module dsh-group-chat/client/hooks
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
7
|
+
import { api } from '../lib/api.ts'
|
|
8
|
+
import type { ClientSnapshot, ModelsResponse, RoleDraft } from '../lib/model.ts'
|
|
9
|
+
import type { AtToken } from '../../shared/file-mention-grammar.ts'
|
|
10
|
+
|
|
11
|
+
export interface MutateResponse {
|
|
12
|
+
ok: boolean
|
|
13
|
+
snapshot?: ClientSnapshot
|
|
14
|
+
lastCreated?: ClientSnapshot['lastCreated']
|
|
15
|
+
error?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ActionOk {
|
|
19
|
+
ok: boolean
|
|
20
|
+
error?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 面板切换缓存(模块级,跨挂载存活):main 为 keyed 槽,主会话⇄群聊切换会
|
|
25
|
+
* 整体卸载重挂面板组件;缓存最近快照与选中项让重挂载瞬时恢复上次视图,
|
|
26
|
+
* 挂载后的 state 拉取 / SSE 首帧再行校正(本机回路往返仅数毫秒)。
|
|
27
|
+
*/
|
|
28
|
+
let cachedSnap: ClientSnapshot | null = null
|
|
29
|
+
let cachedGid: string | null = null
|
|
30
|
+
let cachedSid: string | null = null
|
|
31
|
+
|
|
32
|
+
export function useGroupChatState() {
|
|
33
|
+
// 核心数据快照(重挂载时从缓存瞬时恢复,消除切换闪现的加载态)
|
|
34
|
+
const [snap, setSnap] = useState<ClientSnapshot | null>(() => cachedSnap)
|
|
35
|
+
|
|
36
|
+
// 选中状态
|
|
37
|
+
const [gid, setGid] = useState<string | null>(() => cachedGid)
|
|
38
|
+
const [sid, setSid] = useState<string | null>(() => cachedSid)
|
|
39
|
+
|
|
40
|
+
/** 已应用过的 lastCreated 会话标记:只在标记变化(真正的新建)时跟随选中,
|
|
41
|
+
而不是每帧都把视图拽回「最近创建的会话」(流式期间每 120ms 一帧)。 */
|
|
42
|
+
const appliedCreatedRef = useRef<string | null>(cachedSnap?.lastCreated?.sessionId ?? null)
|
|
43
|
+
|
|
44
|
+
/** 快照落地统一口:写状态 + 写切换缓存。 */
|
|
45
|
+
const applySnap = useCallback((s: ClientSnapshot): void => {
|
|
46
|
+
cachedSnap = s
|
|
47
|
+
setSnap(s)
|
|
48
|
+
}, [])
|
|
49
|
+
|
|
50
|
+
/** 选中项统一口:写状态 + 写切换缓存(重挂载恢复到用户离开时的位置)。 */
|
|
51
|
+
const selectGroup = useCallback((v: string): void => {
|
|
52
|
+
cachedGid = v
|
|
53
|
+
setGid(v)
|
|
54
|
+
}, [])
|
|
55
|
+
const selectSession = useCallback((v: string): void => {
|
|
56
|
+
cachedSid = v
|
|
57
|
+
setSid(v)
|
|
58
|
+
}, [])
|
|
59
|
+
|
|
60
|
+
// UI 状态
|
|
61
|
+
const [search, setSearch] = useState('')
|
|
62
|
+
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set())
|
|
63
|
+
const [asideOpen, setAsideOpen] = useState(true)
|
|
64
|
+
const [navOpen, setNavOpen] = useState(true)
|
|
65
|
+
const [atBottom, setAtBottom] = useState(true)
|
|
66
|
+
|
|
67
|
+
// 编辑态
|
|
68
|
+
const [renameDraft, setRenameDraft] = useState<{ kind: 'group' | 'session', id: string, value: string } | null>(null)
|
|
69
|
+
const [confirmDel, setConfirmDel] = useState<{ kind: 'group' | 'session', id: string } | null>(null)
|
|
70
|
+
const [confirmClear, setConfirmClear] = useState(false)
|
|
71
|
+
const [topicDraft, setTopicDraft] = useState<string | null>(null)
|
|
72
|
+
const [wsDraft, setWsDraft] = useState<string | null>(null)
|
|
73
|
+
|
|
74
|
+
// 角色编辑
|
|
75
|
+
const [roleDraft, setRoleDraft] = useState<RoleDraft | null>(null)
|
|
76
|
+
const [roleFormError, setRoleFormError] = useState('')
|
|
77
|
+
const [models, setModels] = useState<ModelsResponse | null>(null)
|
|
78
|
+
const [modelsError, setModelsError] = useState<string | null>(null)
|
|
79
|
+
|
|
80
|
+
// 文件浏览器
|
|
81
|
+
const [fileBrowser, setFileBrowser] = useState<{ open: boolean, loading: boolean, list: import('../../core/types.ts').BrowseResult | null, error: string } | null>(null)
|
|
82
|
+
|
|
83
|
+
// 参与角色选择
|
|
84
|
+
const [partsSel, setPartsSel] = useState<string[] | null>(null)
|
|
85
|
+
const [rounds, setRounds] = useState(1)
|
|
86
|
+
|
|
87
|
+
// 输入与 @提及
|
|
88
|
+
const [input, setInput] = useState('')
|
|
89
|
+
const [mention, setMention] = useState<AtToken | null>(null)
|
|
90
|
+
const [mentionIdx, setMentionIdx] = useState(0)
|
|
91
|
+
|
|
92
|
+
// 错误提示
|
|
93
|
+
const [err, setErr] = useState('')
|
|
94
|
+
|
|
95
|
+
// Refs
|
|
96
|
+
const inputRef = useRef<HTMLDivElement | null>(null)
|
|
97
|
+
const sendingRef = useRef(false)
|
|
98
|
+
const scrollRef = useRef<HTMLDivElement | null>(null)
|
|
99
|
+
|
|
100
|
+
// 初始加载 + SSE 订阅
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
let live = true
|
|
103
|
+
api.state().then((s) => {
|
|
104
|
+
if (live && s && (s as ClientSnapshot).ok) applySnap(s as ClientSnapshot)
|
|
105
|
+
}).catch(() => { /* SSE 会重试 */ })
|
|
106
|
+
const events = new EventSource('/api/group-chat/events')
|
|
107
|
+
events.onmessage = (message) => {
|
|
108
|
+
try {
|
|
109
|
+
const s = JSON.parse(message.data) as ClientSnapshot
|
|
110
|
+
if (!live || !s || !s.ok) return
|
|
111
|
+
// lastCreated 仅在变化时跟随选中(新建群组/会话的定位信号);
|
|
112
|
+
// 帧里重复携带的旧标记不再反复重置视图
|
|
113
|
+
if (s.lastCreated && s.lastCreated.sessionId && s.lastCreated.sessionId !== appliedCreatedRef.current) {
|
|
114
|
+
appliedCreatedRef.current = s.lastCreated.sessionId
|
|
115
|
+
selectGroup(s.lastCreated.groupId)
|
|
116
|
+
selectSession(s.lastCreated.sessionId)
|
|
117
|
+
setPartsSel(null)
|
|
118
|
+
}
|
|
119
|
+
applySnap(s)
|
|
120
|
+
} catch { /* ignore malformed frame */ }
|
|
121
|
+
}
|
|
122
|
+
return () => {
|
|
123
|
+
live = false
|
|
124
|
+
events.close()
|
|
125
|
+
}
|
|
126
|
+
}, [applySnap, selectGroup, selectSession])
|
|
127
|
+
|
|
128
|
+
// API 操作封装
|
|
129
|
+
const action = useCallback(async (payload: Record<string, unknown>): Promise<unknown> => {
|
|
130
|
+
try {
|
|
131
|
+
return await api.action(payload)
|
|
132
|
+
} catch (e) {
|
|
133
|
+
setErr(String((e && (e as Error).message) || e))
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
}, [])
|
|
137
|
+
|
|
138
|
+
const mutate = async (args: Record<string, unknown>): Promise<MutateResponse | null> => {
|
|
139
|
+
setErr('')
|
|
140
|
+
const res = await action(Object.assign({ kind: 'mutate' }, args)) as MutateResponse | null
|
|
141
|
+
if (res && res.ok && res.snapshot) {
|
|
142
|
+
// 响应里的 lastCreated 是服务端当前值(未必由本次调用产生)——同样只在
|
|
143
|
+
// 变化时跟随选中,避免改名/改配置等普通 mutate 把视图拽走
|
|
144
|
+
if (res.lastCreated && res.lastCreated.sessionId && res.lastCreated.sessionId !== appliedCreatedRef.current) {
|
|
145
|
+
appliedCreatedRef.current = res.lastCreated.sessionId
|
|
146
|
+
selectGroup(res.lastCreated.groupId)
|
|
147
|
+
selectSession(res.lastCreated.sessionId)
|
|
148
|
+
setPartsSel(null)
|
|
149
|
+
}
|
|
150
|
+
applySnap(res.snapshot)
|
|
151
|
+
if (res.snapshot.error) setErr(res.snapshot.error)
|
|
152
|
+
}
|
|
153
|
+
return res
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
// 状态
|
|
158
|
+
snap,
|
|
159
|
+
setSnap: applySnap,
|
|
160
|
+
gid,
|
|
161
|
+
setGid: selectGroup,
|
|
162
|
+
sid,
|
|
163
|
+
setSid: selectSession,
|
|
164
|
+
search,
|
|
165
|
+
setSearch,
|
|
166
|
+
collapsedGroups,
|
|
167
|
+
setCollapsedGroups,
|
|
168
|
+
renameDraft,
|
|
169
|
+
setRenameDraft,
|
|
170
|
+
confirmDel,
|
|
171
|
+
setConfirmDel,
|
|
172
|
+
confirmClear,
|
|
173
|
+
setConfirmClear,
|
|
174
|
+
roleDraft,
|
|
175
|
+
setRoleDraft,
|
|
176
|
+
roleFormError,
|
|
177
|
+
setRoleFormError,
|
|
178
|
+
models,
|
|
179
|
+
setModels,
|
|
180
|
+
modelsError,
|
|
181
|
+
setModelsError,
|
|
182
|
+
fileBrowser,
|
|
183
|
+
setFileBrowser,
|
|
184
|
+
wsDraft,
|
|
185
|
+
setWsDraft,
|
|
186
|
+
partsSel,
|
|
187
|
+
setPartsSel,
|
|
188
|
+
rounds,
|
|
189
|
+
setRounds,
|
|
190
|
+
input,
|
|
191
|
+
setInput,
|
|
192
|
+
err,
|
|
193
|
+
setErr,
|
|
194
|
+
topicDraft,
|
|
195
|
+
setTopicDraft,
|
|
196
|
+
mention,
|
|
197
|
+
setMention,
|
|
198
|
+
mentionIdx,
|
|
199
|
+
setMentionIdx,
|
|
200
|
+
asideOpen,
|
|
201
|
+
setAsideOpen,
|
|
202
|
+
navOpen,
|
|
203
|
+
setNavOpen,
|
|
204
|
+
atBottom,
|
|
205
|
+
setAtBottom,
|
|
206
|
+
// Refs
|
|
207
|
+
inputRef,
|
|
208
|
+
sendingRef,
|
|
209
|
+
scrollRef,
|
|
210
|
+
// 方法
|
|
211
|
+
action,
|
|
212
|
+
mutate,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 提及状态 hook:封装角色/文件提及相关的派生状态计算
|
|
3
|
+
* @module dsh-group-chat/client/hooks
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useMemo } from 'react'
|
|
7
|
+
import { escapeRegExp, type SnapshotRole } from '../lib/model.ts'
|
|
8
|
+
import type { AtToken } from '../../shared/file-mention-grammar.ts'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 计算角色提及候选列表
|
|
12
|
+
* @param mention - 当前 @ token 状态
|
|
13
|
+
* @param enabledRoles - 启用的角色列表
|
|
14
|
+
* @returns 角色候选列表(仅在角色模式且 query 为空时返回)
|
|
15
|
+
*/
|
|
16
|
+
export function useMentionCandidates(
|
|
17
|
+
mention: AtToken | null,
|
|
18
|
+
enabledRoles: SnapshotRole[],
|
|
19
|
+
): SnapshotRole[] {
|
|
20
|
+
return useMemo(() => {
|
|
21
|
+
// 角色模式:仅当 mention 存在且 query 为空时显示全部启用角色
|
|
22
|
+
return mention !== null && mention.query === '' ? enabledRoles : []
|
|
23
|
+
}, [mention, enabledRoles])
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 检测输入文本中被 @ 的角色
|
|
28
|
+
* @param input - 输入文本
|
|
29
|
+
* @param enabledRoles - 启用的角色列表
|
|
30
|
+
* @returns 被提及的角色列表
|
|
31
|
+
*/
|
|
32
|
+
export function useMentionedRoles(
|
|
33
|
+
input: string,
|
|
34
|
+
enabledRoles: SnapshotRole[],
|
|
35
|
+
): SnapshotRole[] {
|
|
36
|
+
return useMemo(() => {
|
|
37
|
+
return enabledRoles.filter((r) =>
|
|
38
|
+
new RegExp('(^|\\s)@' + escapeRegExp(r.name) + '(?=\\s|$)').test(input)
|
|
39
|
+
)
|
|
40
|
+
}, [input, enabledRoles])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 计算安全的候选索引(确保不越界)
|
|
45
|
+
* @param candidatesLength - 候选列表长度
|
|
46
|
+
* @param currentIndex - 当前索引
|
|
47
|
+
* @returns 安全的索引值
|
|
48
|
+
*/
|
|
49
|
+
export function useSafeMentionIndex(
|
|
50
|
+
candidatesLength: number,
|
|
51
|
+
currentIndex: number,
|
|
52
|
+
): number {
|
|
53
|
+
return useMemo(() => {
|
|
54
|
+
return candidatesLength ? Math.min(currentIndex, candidatesLength - 1) : 0
|
|
55
|
+
}, [candidatesLength, currentIndex])
|
|
56
|
+
}
|