@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,10 @@
1
+ /**
2
+ * 组件统一导出
3
+ * @module dsh-group-chat/client/components
4
+ */
5
+
6
+ export * from './NavPanel.tsx'
7
+ export * from './AsidePanel.tsx'
8
+ export * from './ChatPanel.tsx'
9
+ export * from './Composer.tsx'
10
+ export * from './MessageFlow.tsx'
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Hooks 统一导出
3
+ * @module dsh-group-chat/client/hooks
4
+ */
5
+
6
+ export * from './useGroupChatState.ts'
7
+ export * from './useComposer.ts'
@@ -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
+ }