@roaming-ai/dsh-group-chat 0.2.2 → 0.3.1
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/README.md +91 -27
- package/lib/client.js +1254 -598
- package/lib/client.js.map +1 -1
- package/lib/index.js +573 -109
- package/lib/types/client/components/AsidePanel.d.ts +3 -19
- package/lib/types/client/components/Bubble.d.ts +6 -3
- package/lib/types/client/components/ChatPanel.d.ts +1 -0
- package/lib/types/client/components/ConstraintList.d.ts +11 -0
- package/lib/types/client/components/FailCard.d.ts +9 -0
- package/lib/types/client/components/Fold.d.ts +19 -0
- package/lib/types/client/components/HoverTip.d.ts +22 -0
- package/lib/types/client/components/MessageFlow.d.ts +1 -0
- package/lib/types/client/components/MsgActions.d.ts +14 -0
- package/lib/types/client/components/NavPanel.d.ts +9 -11
- package/lib/types/client/components/RoleDrawer.d.ts +3 -2
- package/lib/types/client/components/ToolRow.d.ts +2 -2
- package/lib/types/client/hooks/useComposer.d.ts +6 -1
- package/lib/types/client/hooks/useGroupChatState.d.ts +0 -16
- package/lib/types/client/lib/composer-draft.d.ts +17 -0
- package/lib/types/client/lib/model.d.ts +1 -7
- package/lib/types/client/utils/utils.d.ts +4 -4
- package/lib/types/core/constraints.d.ts +61 -0
- package/lib/types/core/errors.d.ts +54 -0
- package/lib/types/core/json.d.ts +6 -0
- package/lib/types/core/types.d.ts +76 -60
- package/lib/types/host/api/actions.d.ts +1 -1
- package/lib/types/host/engine/conversation.d.ts +5 -3
- package/lib/types/host/engine/defaults.d.ts +13 -0
- package/lib/types/host/engine/fold.d.ts +17 -0
- package/lib/types/host/engine/index.d.ts +1 -0
- package/lib/types/host/engine/retitle.d.ts +5 -5
- package/lib/types/host/service.d.ts +1 -1
- package/lib/types/host/state.d.ts +2 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/shared/file-mention-grammar.d.ts +3 -12
- package/package.json +1 -1
- package/src/client/GroupChatPanel.tsx +25 -59
- package/src/client/components/AsidePanel.tsx +50 -23
- package/src/client/components/Bubble.tsx +45 -18
- package/src/client/components/ChatPanel.tsx +17 -12
- package/src/client/components/Composer.tsx +26 -20
- package/src/client/components/ConstraintList.tsx +64 -0
- package/src/client/components/FailCard.tsx +49 -0
- package/src/client/components/Fold.tsx +72 -0
- package/src/client/components/HoverTip.tsx +134 -0
- package/src/client/components/MessageFlow.tsx +78 -54
- package/src/client/components/MsgActions.tsx +85 -0
- package/src/client/components/NavPanel.tsx +102 -79
- package/src/client/components/RoleDrawer.tsx +29 -7
- package/src/client/components/ThinkRow.tsx +7 -3
- package/src/client/components/ToolRow.tsx +9 -5
- package/src/client/hooks/useComposer.ts +54 -56
- package/src/client/hooks/useGroupChatState.ts +7 -17
- package/src/client/lib/composer-draft.ts +47 -0
- package/src/client/lib/model.ts +1 -14
- package/src/client/lib/styles.ts +73 -12
- package/src/client/react-dom-shim.d.ts +2 -0
- package/src/client/utils/utils.ts +6 -6
- package/src/core/constraints.ts +204 -0
- package/src/core/errors.ts +174 -0
- package/src/core/json.ts +18 -0
- package/src/core/types.ts +52 -19
- package/src/host/api/actions.ts +49 -14
- package/src/host/broadcast.ts +3 -3
- package/src/host/engine/conversation.ts +104 -40
- package/src/host/engine/defaults.ts +28 -0
- package/src/host/engine/fold.ts +99 -0
- package/src/host/engine/index.ts +1 -0
- package/src/host/engine/retitle.ts +24 -41
- package/src/host/materials/materials.ts +2 -0
- package/src/host/persistence/persistence.ts +18 -1
- package/src/host/service.ts +1 -1
- package/src/host/state.ts +5 -4
- package/src/host/tools/tools.ts +2 -2
- package/src/index.ts +2 -0
- package/src/shared/file-mention-grammar.test.ts +1 -38
- package/src/shared/file-mention-grammar.ts +7 -33
- package/lib/types/client/Bubble.d.ts +0 -10
- package/lib/types/client/Glyph.d.ts +0 -12
- package/lib/types/client/GroupChatPanel.old.d.ts +0 -10
- package/lib/types/client/GroupChatSettingsSection.d.ts +0 -15
- package/lib/types/client/PermissionSelect.d.ts +0 -13
- package/lib/types/client/RoleDrawer.d.ts +0 -17
- package/lib/types/client/ThinkRow.d.ts +0 -9
- package/lib/types/client/ToolRow.d.ts +0 -9
- package/lib/types/client/api.d.ts +0 -8
- package/lib/types/client/components.d.ts +0 -22
- package/lib/types/client/drawer.d.ts +0 -17
- package/lib/types/client/glyph.d.ts +0 -12
- package/lib/types/client/model.d.ts +0 -78
- package/lib/types/client/panel.d.ts +0 -10
- package/lib/types/client/settings.d.ts +0 -15
- package/lib/types/client/styles.d.ts +0 -10
- package/lib/types/client/ui.d.ts +0 -17
- package/lib/types/client/utils.d.ts +0 -22
- package/lib/types/host/actions.d.ts +0 -34
- package/lib/types/host/conversation.d.ts +0 -25
- package/lib/types/host/http.d.ts +0 -16
- package/lib/types/host/materials.d.ts +0 -32
- package/lib/types/host/persistence.d.ts +0 -31
- package/lib/types/host/routes.d.ts +0 -11
- package/lib/types/host/store.d.ts +0 -65
- package/lib/types/host/tools.d.ts +0 -28
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 左侧导航栏组件(群组 → 会话目录树;群组行与会话行共用重命名/删除三件套)。
|
|
3
3
|
* @module dsh-group-chat/client/components
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { ReactNode, KeyboardEvent as ReactKeyboardEvent } from 'react'
|
|
7
7
|
import { Icon, P } from '../lib/ui.ts'
|
|
8
|
+
import { HoverTip } from './HoverTip.tsx'
|
|
8
9
|
import { groupById, sessById, sessStatus, SESS_STATUS_LABEL, type ClientSnapshot } from '../lib/model.ts'
|
|
9
10
|
|
|
11
|
+
/** 目录树节点编辑态形状(群组/会话共用)。 */
|
|
12
|
+
type NodeEdit = { kind: 'group' | 'session', id: string, value: string }
|
|
13
|
+
|
|
10
14
|
interface NavPanelProps {
|
|
11
15
|
snap: ClientSnapshot
|
|
12
16
|
search: string
|
|
@@ -18,14 +22,55 @@ interface NavPanelProps {
|
|
|
18
22
|
setGid: (val: string) => void
|
|
19
23
|
setSid: (val: string) => void
|
|
20
24
|
setPartsSel: (val: string[] | null) => void
|
|
21
|
-
renameDraft:
|
|
22
|
-
setRenameDraft: (val:
|
|
25
|
+
renameDraft: NodeEdit | null
|
|
26
|
+
setRenameDraft: (val: NodeEdit | null) => void
|
|
23
27
|
confirmDel: { kind: 'group' | 'session', id: string } | null
|
|
24
28
|
setConfirmDel: (val: { kind: 'group' | 'session', id: string } | null) => void
|
|
25
29
|
mutate: (args: Record<string, unknown>) => Promise<unknown>
|
|
26
30
|
navOpen: boolean
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
/** 内联重命名输入(群组行/会话行同款)。 */
|
|
34
|
+
function RenameField(props: { draft: NodeEdit, set: (d: NodeEdit | null) => void, commit: () => void, keyDown: (e: ReactKeyboardEvent<HTMLInputElement>) => void }): ReactNode {
|
|
35
|
+
const { draft, set, commit, keyDown } = props
|
|
36
|
+
return (
|
|
37
|
+
<input
|
|
38
|
+
className="dsgc-rename"
|
|
39
|
+
value={draft.value}
|
|
40
|
+
autoFocus
|
|
41
|
+
onChange={(e) => { set({ kind: draft.kind, id: draft.id, value: e.target.value }) }}
|
|
42
|
+
onBlur={() => { commit() }}
|
|
43
|
+
onKeyDown={keyDown}
|
|
44
|
+
onClick={(e) => { e.stopPropagation() }}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 行尾操作钮:重命名 + 两次点击确认删除(群组行/会话行同款)。 */
|
|
50
|
+
function NodeOps(props: { renameLabel: string, deleteLabel: string, deleteTitle: string, danger: boolean, onRename: () => void, onDelete: () => void }): ReactNode {
|
|
51
|
+
const { renameLabel, deleteLabel, deleteTitle, danger, onRename, onDelete } = props
|
|
52
|
+
return (
|
|
53
|
+
<span className="dsgc-nodeops">
|
|
54
|
+
<button
|
|
55
|
+
className="dsgc-opbtn"
|
|
56
|
+
title={renameLabel}
|
|
57
|
+
aria-label={renameLabel}
|
|
58
|
+
onClick={(e) => { e.stopPropagation(); onRename() }}
|
|
59
|
+
>
|
|
60
|
+
{Icon(P.IconEditOutline16, 14)}
|
|
61
|
+
</button>
|
|
62
|
+
<button
|
|
63
|
+
className={'dsgc-opbtn' + (danger ? ' danger' : '')}
|
|
64
|
+
title={deleteTitle}
|
|
65
|
+
aria-label={deleteLabel}
|
|
66
|
+
onClick={(e) => { e.stopPropagation(); onDelete() }}
|
|
67
|
+
>
|
|
68
|
+
{Icon(P.IconTrashOutline16, 14)}
|
|
69
|
+
</button>
|
|
70
|
+
</span>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
29
74
|
export function NavPanel(props: NavPanelProps): ReactNode {
|
|
30
75
|
const { snap, search, setSearch, collapsedGroups, setCollapsedGroups, gid, sid, setGid, setSid, setPartsSel, renameDraft, setRenameDraft, confirmDel, setConfirmDel, mutate, navOpen } = props
|
|
31
76
|
|
|
@@ -57,6 +102,22 @@ export function NavPanel(props: NavPanelProps): ReactNode {
|
|
|
57
102
|
else await mutate({ op: 'deleteSession', sessionId: d.id })
|
|
58
103
|
}
|
|
59
104
|
|
|
105
|
+
// 选中即清编辑态(四连调用收敛为一处)
|
|
106
|
+
const clearEdits = (): void => {
|
|
107
|
+
setPartsSel(null)
|
|
108
|
+
setConfirmDel(null)
|
|
109
|
+
setRenameDraft(null)
|
|
110
|
+
}
|
|
111
|
+
const pickGroup = (id: string): void => {
|
|
112
|
+
setGid(id)
|
|
113
|
+
clearEdits()
|
|
114
|
+
}
|
|
115
|
+
const pickSession = (gidToSet: string, sidToSet: string): void => {
|
|
116
|
+
setGid(gidToSet)
|
|
117
|
+
setSid(sidToSet)
|
|
118
|
+
clearEdits()
|
|
119
|
+
}
|
|
120
|
+
|
|
60
121
|
const q = search.trim().toLowerCase()
|
|
61
122
|
const groupMatches = (g: ClientSnapshot['groups'][number]): { show: boolean, filterSessions: boolean } => {
|
|
62
123
|
if (!q) return { show: true, filterSessions: false }
|
|
@@ -66,6 +127,7 @@ export function NavPanel(props: NavPanelProps): ReactNode {
|
|
|
66
127
|
}
|
|
67
128
|
|
|
68
129
|
const group = gid ? groupById(snap, gid) : null
|
|
130
|
+
const selected = sid ? sessById(snap, sid) : null
|
|
69
131
|
|
|
70
132
|
const treeNodes: ReactNode[] = []
|
|
71
133
|
for (const g of snap.groups) {
|
|
@@ -75,14 +137,13 @@ export function NavPanel(props: NavPanelProps): ReactNode {
|
|
|
75
137
|
const isRenameGroup = renameDraft && renameDraft.kind === 'group' && renameDraft.id === g.id
|
|
76
138
|
const isConfirmGroup = confirmDel && confirmDel.kind === 'group' && confirmDel.id === g.id
|
|
77
139
|
const groupChildren: ReactNode[] = []
|
|
78
|
-
|
|
140
|
+
|
|
79
141
|
if (expanded) {
|
|
80
142
|
for (const sessionId of g.sessionIds) {
|
|
81
143
|
const s = sessById(snap, sessionId)
|
|
82
144
|
if (!s) continue
|
|
83
145
|
if (match.filterSessions && !s.name.toLowerCase().includes(q)) continue
|
|
84
|
-
const
|
|
85
|
-
const isActive = sess && s.id === sess.id && g.id === group?.id
|
|
146
|
+
const isActive = selected && s.id === selected.id && g.id === group?.id
|
|
86
147
|
const isRenameSess = renameDraft && renameDraft.kind === 'session' && renameDraft.id === s.id
|
|
87
148
|
const isConfirm = confirmDel && confirmDel.kind === 'session' && confirmDel.id === s.id
|
|
88
149
|
// 会话状态(对齐主 GUI StateDot:idle 不渲染点)
|
|
@@ -94,11 +155,11 @@ export function NavPanel(props: NavPanelProps): ReactNode {
|
|
|
94
155
|
className={'dsgc-sess-row' + (isActive ? ' on' : '')}
|
|
95
156
|
role="button"
|
|
96
157
|
tabIndex={0}
|
|
97
|
-
onClick={() => {
|
|
158
|
+
onClick={() => { pickSession(g.id, s.id) }}
|
|
98
159
|
onKeyDown={(e) => {
|
|
99
160
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
100
161
|
e.preventDefault()
|
|
101
|
-
|
|
162
|
+
pickSession(g.id, s.id)
|
|
102
163
|
}
|
|
103
164
|
}}
|
|
104
165
|
>
|
|
@@ -113,62 +174,45 @@ export function NavPanel(props: NavPanelProps): ReactNode {
|
|
|
113
174
|
: null}
|
|
114
175
|
</span>
|
|
115
176
|
{isRenameSess
|
|
116
|
-
? (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
onClick={(e) => { e.stopPropagation(); setRenameDraft({ kind: 'session', id: s.id, value: s.name }) }}
|
|
134
|
-
>
|
|
135
|
-
{Icon(P.IconEditOutline16, 14)}
|
|
136
|
-
</button>
|
|
137
|
-
<button
|
|
138
|
-
className={'dsgc-opbtn' + (isConfirm ? ' danger' : '')}
|
|
139
|
-
title={isConfirm ? '再次点击确认删除' : '删除会话'}
|
|
140
|
-
aria-label="删除会话"
|
|
141
|
-
onClick={(e) => {
|
|
142
|
-
e.stopPropagation()
|
|
143
|
-
if (isConfirm) void doDelete()
|
|
144
|
-
else setConfirmDel({ kind: 'session', id: s.id })
|
|
145
|
-
}}
|
|
146
|
-
>
|
|
147
|
-
{Icon(P.IconTrashOutline16, 14)}
|
|
148
|
-
</button>
|
|
149
|
-
</span>
|
|
177
|
+
? <RenameField draft={renameDraft!} set={setRenameDraft} commit={() => { void commitRename() }} keyDown={renameKeyDown} />
|
|
178
|
+
: (
|
|
179
|
+
<HoverTip label={s.name} side="right" delayMs={500} className="dsgc-sess-name">
|
|
180
|
+
{s.name}
|
|
181
|
+
</HoverTip>
|
|
182
|
+
)}
|
|
183
|
+
<NodeOps
|
|
184
|
+
renameLabel="重命名会话"
|
|
185
|
+
deleteLabel="删除会话"
|
|
186
|
+
deleteTitle={isConfirm ? '再次点击确认删除' : '删除会话'}
|
|
187
|
+
danger={!!isConfirm}
|
|
188
|
+
onRename={() => { setRenameDraft({ kind: 'session', id: s.id, value: s.name }) }}
|
|
189
|
+
onDelete={() => {
|
|
190
|
+
if (isConfirm) void doDelete()
|
|
191
|
+
else setConfirmDel({ kind: 'session', id: s.id })
|
|
192
|
+
}}
|
|
193
|
+
/>
|
|
150
194
|
</div>,
|
|
151
195
|
)
|
|
152
196
|
}
|
|
153
|
-
|
|
197
|
+
|
|
154
198
|
groupChildren.push(
|
|
155
199
|
<button key="__add" className="dsgc-addsess" onClick={() => { void mutate({ op: 'createSession', groupId: g.id }) }}>
|
|
156
200
|
{Icon(P.IconPlusOutline16, 14)}新会话
|
|
157
201
|
</button>,
|
|
158
202
|
)
|
|
159
203
|
}
|
|
160
|
-
|
|
204
|
+
|
|
161
205
|
treeNodes.push(
|
|
162
206
|
<div key={g.id} className="dsgc-gnode">
|
|
163
207
|
<div
|
|
164
208
|
className={'dsgc-grow-row' + (g.id === group?.id ? ' on' : '')}
|
|
165
209
|
role="button"
|
|
166
210
|
tabIndex={0}
|
|
167
|
-
onClick={() => {
|
|
211
|
+
onClick={() => { pickGroup(g.id) }}
|
|
168
212
|
onKeyDown={(e) => {
|
|
169
213
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
170
214
|
e.preventDefault()
|
|
171
|
-
|
|
215
|
+
pickGroup(g.id)
|
|
172
216
|
}
|
|
173
217
|
}}
|
|
174
218
|
>
|
|
@@ -189,40 +233,19 @@ export function NavPanel(props: NavPanelProps): ReactNode {
|
|
|
189
233
|
{Icon(P.IconChevronDownOutline14, 14)}
|
|
190
234
|
</button>
|
|
191
235
|
{isRenameGroup
|
|
192
|
-
? (
|
|
193
|
-
<input
|
|
194
|
-
className="dsgc-rename"
|
|
195
|
-
value={renameDraft!.value}
|
|
196
|
-
autoFocus
|
|
197
|
-
onChange={(e) => { setRenameDraft({ kind: 'group', id: g.id, value: e.target.value }) }}
|
|
198
|
-
onBlur={() => { void commitRename() }}
|
|
199
|
-
onKeyDown={renameKeyDown}
|
|
200
|
-
onClick={(e) => { e.stopPropagation() }}
|
|
201
|
-
/>
|
|
202
|
-
)
|
|
236
|
+
? <RenameField draft={renameDraft!} set={setRenameDraft} commit={() => { void commitRename() }} keyDown={renameKeyDown} />
|
|
203
237
|
: <span className="dsgc-gname">{g.name}</span>}
|
|
204
|
-
<
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
title={isConfirmGroup ? '再次点击确认删除' : '删除群组'}
|
|
216
|
-
aria-label="删除群组"
|
|
217
|
-
onClick={(e) => {
|
|
218
|
-
e.stopPropagation()
|
|
219
|
-
if (isConfirmGroup) void doDelete()
|
|
220
|
-
else setConfirmDel({ kind: 'group', id: g.id })
|
|
221
|
-
}}
|
|
222
|
-
>
|
|
223
|
-
{Icon(P.IconTrashOutline16, 14)}
|
|
224
|
-
</button>
|
|
225
|
-
</span>
|
|
238
|
+
<NodeOps
|
|
239
|
+
renameLabel="重命名群组"
|
|
240
|
+
deleteLabel="删除群组"
|
|
241
|
+
deleteTitle={isConfirmGroup ? '再次点击确认删除' : '删除群组'}
|
|
242
|
+
danger={!!isConfirmGroup}
|
|
243
|
+
onRename={() => { setRenameDraft({ kind: 'group', id: g.id, value: g.name }) }}
|
|
244
|
+
onDelete={() => {
|
|
245
|
+
if (isConfirmGroup) void doDelete()
|
|
246
|
+
else setConfirmDel({ kind: 'group', id: g.id })
|
|
247
|
+
}}
|
|
248
|
+
/>
|
|
226
249
|
</div>
|
|
227
250
|
{expanded ? <div className="dsgc-sess-list">{groupChildren}</div> : null}
|
|
228
251
|
</div>,
|
|
@@ -3,31 +3,38 @@
|
|
|
3
3
|
* @module dsh-group-chat/client/drawer
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { useEffect, useState, type ReactNode } from 'react'
|
|
6
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
7
7
|
import { Icon, P } from '../lib/ui.ts'
|
|
8
8
|
import { api } from '../lib/api.ts'
|
|
9
9
|
import { PALETTE, type EffortsResponse, type ModelsResponse, type RoleDraft } from '../lib/model.ts'
|
|
10
|
+
import type { MutateResponse } from '../hooks/useGroupChatState.ts'
|
|
10
11
|
|
|
11
12
|
export interface RoleDrawerProps {
|
|
12
13
|
draft: RoleDraft
|
|
13
14
|
set: (draft: RoleDraft) => void
|
|
15
|
+
/** 保存目标群组(upsertRole 落库)。 */
|
|
16
|
+
groupId: string
|
|
14
17
|
models: ModelsResponse | null
|
|
15
18
|
modelsError: string | null
|
|
16
19
|
onRetryModels: () => void
|
|
17
|
-
|
|
20
|
+
mutate: (args: Record<string, unknown>) => Promise<unknown>
|
|
18
21
|
onCancel: () => void
|
|
19
|
-
formError: string
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export function RoleDrawer(props: RoleDrawerProps): ReactNode {
|
|
23
|
-
const { draft, set } = props
|
|
25
|
+
const { draft, set, groupId } = props
|
|
24
26
|
const models = props.models
|
|
25
27
|
const providers = (models && models.providers) || []
|
|
26
28
|
const modelsOf = (models && models.modelsByProvider && models.modelsByProvider[draft.provider]) || []
|
|
27
29
|
const [closing, setClosing] = useState(false)
|
|
30
|
+
// Esc 监听挂在空依赖上,闭包看的是首帧 closing(恒 false)——用 ref 挡住
|
|
31
|
+
// 退场动画窗口内的重复触发
|
|
32
|
+
const closingRef = useRef(false)
|
|
33
|
+
const [formError, setFormError] = useState('')
|
|
28
34
|
const [effortsInfo, setEffortsInfo] = useState<EffortsResponse | null>(null)
|
|
29
35
|
const close = (): void => {
|
|
30
|
-
if (
|
|
36
|
+
if (closingRef.current) return
|
|
37
|
+
closingRef.current = true
|
|
31
38
|
setClosing(true)
|
|
32
39
|
window.setTimeout(() => props.onCancel(), 140)
|
|
33
40
|
}
|
|
@@ -37,6 +44,21 @@ export function RoleDrawer(props: RoleDrawerProps): ReactNode {
|
|
|
37
44
|
// 换模型路线后旧推理级别失效,重置为默认
|
|
38
45
|
set(Object.assign({}, draft, { provider: p, model: list.length ? list[0].id : '', reasoningEffort: 'default' }))
|
|
39
46
|
}
|
|
47
|
+
/** 保存(校验在本地,落库经 mutate;失败文案与原实现逐字一致)。 */
|
|
48
|
+
const save = async (): Promise<void> => {
|
|
49
|
+
if (!draft.name.trim()) {
|
|
50
|
+
setFormError('角色名称不能为空')
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (!draft.provider || !draft.model) {
|
|
54
|
+
setFormError('请选择角色绑定的模型')
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
const res = await props.mutate({ op: 'upsertRole', groupId, role: draft }) as MutateResponse | null
|
|
58
|
+
if (res && res.ok && res.snapshot && !res.snapshot.error) {
|
|
59
|
+
props.onCancel()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
40
62
|
useEffect(() => {
|
|
41
63
|
const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') close() }
|
|
42
64
|
document.addEventListener('keydown', onKey)
|
|
@@ -174,12 +196,12 @@ export function RoleDrawer(props: RoleDrawerProps): ReactNode {
|
|
|
174
196
|
</div>
|
|
175
197
|
)
|
|
176
198
|
: null}
|
|
177
|
-
{
|
|
199
|
+
{formError ? <div className="dsgc-formerr">{formError}</div> : null}
|
|
178
200
|
</div>
|
|
179
201
|
</div>
|
|
180
202
|
<div className="dsgc-drawerfoot">
|
|
181
203
|
<P.Button variant="outline" onClick={close}>取消</P.Button>
|
|
182
|
-
<P.Button variant="primary" onClick={
|
|
204
|
+
<P.Button variant="primary" onClick={() => { void save() }}>{draft.id ? '保存' : '添加'}</P.Button>
|
|
183
205
|
</div>
|
|
184
206
|
</div>
|
|
185
207
|
)
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { useState, type ReactNode } from 'react'
|
|
7
7
|
import { P } from '../lib/ui.ts'
|
|
8
8
|
import { firstLine, latestLine } from '../lib/model.ts'
|
|
9
|
+
import { ClipWell, Fold } from './Fold.tsx'
|
|
9
10
|
|
|
10
11
|
export function ThinkRow(props: { text: string, running?: boolean }): ReactNode {
|
|
11
12
|
const text = props.text || ''
|
|
@@ -24,9 +25,12 @@ export function ThinkRow(props: { text: string, running?: boolean }): ReactNode
|
|
|
24
25
|
expandOnRowClick
|
|
25
26
|
onToggle={() => { setExpanded((v) => !v) }}
|
|
26
27
|
collapsedContent={text ? <span className="dsgc-thinksummary">{summary}</span> : null}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
/>
|
|
29
|
+
<Fold open={expanded}>
|
|
30
|
+
<ClipWell maxHeight={320} watch={text}>
|
|
31
|
+
<div className="dsgc-thinkbody">{text}</div>
|
|
32
|
+
</ClipWell>
|
|
33
|
+
</Fold>
|
|
30
34
|
</div>
|
|
31
35
|
)
|
|
32
36
|
}
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
import { useState, type ReactNode } from 'react'
|
|
7
7
|
import { Icon, P, pickPrimitive } from '../lib/ui.ts'
|
|
8
|
-
import type {
|
|
8
|
+
import type { ToolCallRecord } from '../../core/types.ts'
|
|
9
|
+
import { ClipWell, Fold } from './Fold.tsx'
|
|
9
10
|
|
|
10
11
|
const TOOL_ICONS: Record<string, string> = { read_file: 'IconBrowseOutline16', list_dir: 'IconFolderOpenOutline16', run_command: 'IconCodeOutline16' }
|
|
11
12
|
|
|
12
|
-
export function ToolRow(props: { c:
|
|
13
|
+
export function ToolRow(props: { c: ToolCallRecord }): ReactNode {
|
|
13
14
|
const c = props.c
|
|
14
15
|
const [expanded, setExpanded] = useState(false)
|
|
15
16
|
let brief = ''
|
|
@@ -43,9 +44,12 @@ export function ToolRow(props: { c: ToolCallView }): ReactNode {
|
|
|
43
44
|
{statusText + (dur ? ' · ' + dur : '')}
|
|
44
45
|
</span>
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
/>
|
|
48
|
+
<Fold open={expanded}>
|
|
49
|
+
<ClipWell maxHeight={260} watch={c.output}>
|
|
50
|
+
<div className="dsgc-toolbody">{c.output || '(无输出)'}</div>
|
|
51
|
+
</ClipWell>
|
|
52
|
+
</Fold>
|
|
49
53
|
</div>
|
|
50
54
|
)
|
|
51
55
|
}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* @module dsh-group-chat/client/hooks
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { useCallback, useEffect, type ClipboardEvent as ReactClipboardEvent, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react'
|
|
6
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, type ClipboardEvent as ReactClipboardEvent, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react'
|
|
7
7
|
import { execCommand, queryAtCaret, serializeInput, chipHtml, fileChipHtml } from '../utils/utils.ts'
|
|
8
|
+
import { readComposerDraft, writeComposerDraft } from '../lib/composer-draft.ts'
|
|
8
9
|
import type { AtToken } from '../../shared/file-mention-grammar.ts'
|
|
9
10
|
import type { SnapshotRole } from '../lib/model.ts'
|
|
10
11
|
|
|
@@ -44,12 +45,18 @@ export function useComposerInput(
|
|
|
44
45
|
setInput: (val: string) => void,
|
|
45
46
|
setMention: (val: AtToken | null) => void,
|
|
46
47
|
setMentionIdx: (val: number) => void,
|
|
48
|
+
sessionId?: string | null,
|
|
47
49
|
) {
|
|
48
|
-
|
|
50
|
+
const sessionIdRef = useRef(sessionId)
|
|
51
|
+
sessionIdRef.current = sessionId
|
|
52
|
+
|
|
53
|
+
/** 从 DOM 同步 input 状态(序列化),并写入当前会话草稿槽。 */
|
|
49
54
|
const syncFromDOM = useCallback((): void => {
|
|
50
55
|
const el = inputRef.current
|
|
51
56
|
if (!el) return
|
|
52
|
-
|
|
57
|
+
const text = serializeInput(el)
|
|
58
|
+
setInput(text)
|
|
59
|
+
writeComposerDraft(sessionIdRef.current, el.innerHTML, text)
|
|
53
60
|
}, [inputRef, setInput])
|
|
54
61
|
|
|
55
62
|
const onInputCE = useCallback((): void => {
|
|
@@ -84,20 +91,50 @@ export function useComposerInput(
|
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
|
|
94
|
+
/**
|
|
95
|
+
* 按会话恢复草稿:切会话时先把当前 HTML 写入旧槽,再灌入新槽;
|
|
96
|
+
* 面板重挂载(主会话⇄群聊)时 editor 是新节点,从模块缓存灌回。
|
|
97
|
+
*/
|
|
98
|
+
export function useComposerDraft(
|
|
99
|
+
sessionId: string | null | undefined,
|
|
100
|
+
inputRef: React.RefObject<HTMLDivElement>,
|
|
101
|
+
setInput: (val: string) => void,
|
|
102
|
+
setMention: (val: AtToken | null) => void,
|
|
103
|
+
): void {
|
|
104
|
+
const prevIdRef = useRef<string | null>(null)
|
|
105
|
+
useLayoutEffect(() => {
|
|
106
|
+
const el = inputRef.current
|
|
107
|
+
const prev = prevIdRef.current
|
|
108
|
+
const next = sessionId || null
|
|
109
|
+
if (el && prev && prev !== next) writeComposerDraft(prev, el.innerHTML, serializeInput(el))
|
|
110
|
+
prevIdRef.current = next
|
|
111
|
+
if (!next) return
|
|
112
|
+
const draft = readComposerDraft(next)
|
|
113
|
+
if (el) el.innerHTML = draft.html
|
|
114
|
+
setInput(draft.text)
|
|
115
|
+
setMention(null)
|
|
116
|
+
return () => {
|
|
117
|
+
const node = inputRef.current
|
|
118
|
+
const id = prevIdRef.current
|
|
119
|
+
if (node && id) writeComposerDraft(id, node.innerHTML, serializeInput(node))
|
|
120
|
+
}
|
|
121
|
+
}, [sessionId, inputRef, setInput, setMention])
|
|
122
|
+
}
|
|
123
|
+
|
|
87
124
|
export function useMentionChip(
|
|
88
125
|
inputRef: React.RefObject<HTMLDivElement>,
|
|
89
126
|
setMention: (val: AtToken | null) => void,
|
|
90
127
|
setMentionIdx: (val: number) => void,
|
|
91
128
|
syncFromDOM: () => void,
|
|
92
129
|
) {
|
|
93
|
-
/** 弹层候选 → 删掉光标前的
|
|
94
|
-
const
|
|
130
|
+
/** 弹层候选 → 删掉光标前的 @词、插入带 data-new 标记的芯片 + 尾随空格。 */
|
|
131
|
+
const insertChipHtml = useCallback((html: string): void => {
|
|
95
132
|
const el = inputRef.current
|
|
96
133
|
const sel = window.getSelection()
|
|
97
134
|
if (!el || !sel) return
|
|
98
135
|
if (!sel.anchorNode || !el.contains(sel.anchorNode)) return
|
|
99
136
|
el.focus({ preventScroll: true })
|
|
100
|
-
|
|
137
|
+
|
|
101
138
|
// 1. 删除光标前的 @token(包括引号)
|
|
102
139
|
const node = sel.anchorNode
|
|
103
140
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
@@ -120,12 +157,13 @@ export function useMentionChip(
|
|
|
120
157
|
}
|
|
121
158
|
}
|
|
122
159
|
}
|
|
123
|
-
|
|
124
|
-
// 2.
|
|
125
|
-
|
|
160
|
+
|
|
161
|
+
// 2. 插入芯片(fresh 标记:部分浏览器把选区落进 contenteditable=false 芯片内部,
|
|
162
|
+
// 曾致空格丢失与光标不可见)
|
|
163
|
+
execCommand('insertHTML', html)
|
|
126
164
|
const chip = el.querySelector<HTMLElement>('.dsgc-chipin[data-new]')
|
|
127
165
|
if (!chip) return
|
|
128
|
-
|
|
166
|
+
|
|
129
167
|
// 3. 选区显式钉到芯片之后,再补尾随空格
|
|
130
168
|
const after = document.createRange()
|
|
131
169
|
after.setStartAfter(chip)
|
|
@@ -139,53 +177,13 @@ export function useMentionChip(
|
|
|
139
177
|
syncFromDOM()
|
|
140
178
|
}, [inputRef, setMention, setMentionIdx, syncFromDOM])
|
|
141
179
|
|
|
180
|
+
const insertChip = useCallback((role: SnapshotRole): void => {
|
|
181
|
+
insertChipHtml(chipHtml(role, true))
|
|
182
|
+
}, [insertChipHtml])
|
|
183
|
+
|
|
142
184
|
const insertFileChip = useCallback((path: string, kind: 'file' | 'directory'): void => {
|
|
143
|
-
|
|
144
|
-
|
|
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])
|
|
185
|
+
insertChipHtml(fileChipHtml(path, kind, true))
|
|
186
|
+
}, [insertChipHtml])
|
|
189
187
|
|
|
190
188
|
return { insertChip, insertFileChip }
|
|
191
189
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
7
7
|
import { api } from '../lib/api.ts'
|
|
8
|
+
import { readComposerDraft } from '../lib/composer-draft.ts'
|
|
8
9
|
import type { ClientSnapshot, ModelsResponse, RoleDraft } from '../lib/model.ts'
|
|
9
10
|
import type { AtToken } from '../../shared/file-mention-grammar.ts'
|
|
10
11
|
|
|
@@ -22,8 +23,8 @@ export interface ActionOk {
|
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* 面板切换缓存(模块级,跨挂载存活):main 为 keyed 槽,主会话⇄群聊切换会
|
|
25
|
-
*
|
|
26
|
-
*
|
|
26
|
+
* 整体卸载重挂面板组件;缓存最近快照、选中项与按会话分槽的未发送草稿,
|
|
27
|
+
* 让重挂载瞬时恢复上次视图。挂载后的 state 拉取 / SSE 首帧再行校正。
|
|
27
28
|
*/
|
|
28
29
|
let cachedSnap: ClientSnapshot | null = null
|
|
29
30
|
let cachedGid: string | null = null
|
|
@@ -69,23 +70,18 @@ export function useGroupChatState() {
|
|
|
69
70
|
const [confirmDel, setConfirmDel] = useState<{ kind: 'group' | 'session', id: string } | null>(null)
|
|
70
71
|
const [confirmClear, setConfirmClear] = useState(false)
|
|
71
72
|
const [topicDraft, setTopicDraft] = useState<string | null>(null)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
// 角色编辑
|
|
73
|
+
|
|
74
|
+
// 角色编辑(models 目录与保存/校验态在 RoleDrawer 内自理)
|
|
75
75
|
const [roleDraft, setRoleDraft] = useState<RoleDraft | null>(null)
|
|
76
|
-
const [roleFormError, setRoleFormError] = useState('')
|
|
77
76
|
const [models, setModels] = useState<ModelsResponse | null>(null)
|
|
78
77
|
const [modelsError, setModelsError] = useState<string | null>(null)
|
|
79
78
|
|
|
80
|
-
// 文件浏览器
|
|
81
|
-
const [fileBrowser, setFileBrowser] = useState<{ open: boolean, loading: boolean, list: import('../../core/types.ts').BrowseResult | null, error: string } | null>(null)
|
|
82
|
-
|
|
83
79
|
// 参与角色选择
|
|
84
80
|
const [partsSel, setPartsSel] = useState<string[] | null>(null)
|
|
85
81
|
const [rounds, setRounds] = useState(1)
|
|
86
82
|
|
|
87
|
-
// 输入与
|
|
88
|
-
const [input, setInput] = useState(
|
|
83
|
+
// 输入与 @提及(文本初值取当前选中会话草稿,HTML 由 useComposerDraft 灌回)
|
|
84
|
+
const [input, setInput] = useState(() => readComposerDraft(cachedSid).text)
|
|
89
85
|
const [mention, setMention] = useState<AtToken | null>(null)
|
|
90
86
|
const [mentionIdx, setMentionIdx] = useState(0)
|
|
91
87
|
|
|
@@ -173,16 +169,10 @@ export function useGroupChatState() {
|
|
|
173
169
|
setConfirmClear,
|
|
174
170
|
roleDraft,
|
|
175
171
|
setRoleDraft,
|
|
176
|
-
roleFormError,
|
|
177
|
-
setRoleFormError,
|
|
178
172
|
models,
|
|
179
173
|
setModels,
|
|
180
174
|
modelsError,
|
|
181
175
|
setModelsError,
|
|
182
|
-
fileBrowser,
|
|
183
|
-
setFileBrowser,
|
|
184
|
-
wsDraft,
|
|
185
|
-
setWsDraft,
|
|
186
176
|
partsSel,
|
|
187
177
|
setPartsSel,
|
|
188
178
|
rounds,
|