@roaming-ai/dsh-group-chat 0.3.0 → 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 +89 -26
- package/lib/client.js +289 -321
- package/lib/client.js.map +1 -1
- package/lib/index.js +124 -132
- package/lib/types/client/components/AsidePanel.d.ts +3 -19
- 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/useGroupChatState.d.ts +0 -16
- package/lib/types/client/lib/model.d.ts +1 -7
- package/lib/types/client/utils/utils.d.ts +4 -4
- package/lib/types/core/json.d.ts +6 -0
- package/lib/types/core/types.d.ts +57 -63
- package/lib/types/host/engine/defaults.d.ts +13 -0
- package/lib/types/shared/file-mention-grammar.d.ts +3 -12
- package/package.json +1 -1
- package/src/client/GroupChatPanel.tsx +7 -56
- package/src/client/components/AsidePanel.tsx +40 -15
- package/src/client/components/ConstraintList.tsx +1 -6
- package/src/client/components/MessageFlow.tsx +2 -1
- package/src/client/components/NavPanel.tsx +96 -78
- package/src/client/components/RoleDrawer.tsx +29 -7
- package/src/client/components/ToolRow.tsx +2 -2
- package/src/client/hooks/useComposer.ts +14 -53
- package/src/client/hooks/useGroupChatState.ts +2 -13
- package/src/client/lib/model.ts +1 -14
- package/src/client/utils/utils.ts +6 -6
- package/src/core/constraints.ts +6 -12
- package/src/core/errors.ts +2 -12
- package/src/core/json.ts +17 -0
- package/src/core/types.ts +27 -19
- package/src/host/api/actions.ts +14 -13
- package/src/host/engine/conversation.ts +49 -50
- package/src/host/engine/defaults.ts +28 -0
- package/src/host/engine/fold.ts +2 -17
- package/src/host/engine/retitle.ts +9 -31
- package/src/host/materials/materials.ts +2 -0
- package/src/host/tools/tools.ts +2 -2
- 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,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 左侧导航栏组件(群组 → 会话目录树;群组行与会话行共用重命名/删除三件套)。
|
|
3
3
|
* @module dsh-group-chat/client/components
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -8,6 +8,9 @@ import { Icon, P } from '../lib/ui.ts'
|
|
|
8
8
|
import { HoverTip } from './HoverTip.tsx'
|
|
9
9
|
import { groupById, sessById, sessStatus, SESS_STATUS_LABEL, type ClientSnapshot } from '../lib/model.ts'
|
|
10
10
|
|
|
11
|
+
/** 目录树节点编辑态形状(群组/会话共用)。 */
|
|
12
|
+
type NodeEdit = { kind: 'group' | 'session', id: string, value: string }
|
|
13
|
+
|
|
11
14
|
interface NavPanelProps {
|
|
12
15
|
snap: ClientSnapshot
|
|
13
16
|
search: string
|
|
@@ -19,14 +22,55 @@ interface NavPanelProps {
|
|
|
19
22
|
setGid: (val: string) => void
|
|
20
23
|
setSid: (val: string) => void
|
|
21
24
|
setPartsSel: (val: string[] | null) => void
|
|
22
|
-
renameDraft:
|
|
23
|
-
setRenameDraft: (val:
|
|
25
|
+
renameDraft: NodeEdit | null
|
|
26
|
+
setRenameDraft: (val: NodeEdit | null) => void
|
|
24
27
|
confirmDel: { kind: 'group' | 'session', id: string } | null
|
|
25
28
|
setConfirmDel: (val: { kind: 'group' | 'session', id: string } | null) => void
|
|
26
29
|
mutate: (args: Record<string, unknown>) => Promise<unknown>
|
|
27
30
|
navOpen: boolean
|
|
28
31
|
}
|
|
29
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
|
+
|
|
30
74
|
export function NavPanel(props: NavPanelProps): ReactNode {
|
|
31
75
|
const { snap, search, setSearch, collapsedGroups, setCollapsedGroups, gid, sid, setGid, setSid, setPartsSel, renameDraft, setRenameDraft, confirmDel, setConfirmDel, mutate, navOpen } = props
|
|
32
76
|
|
|
@@ -58,6 +102,22 @@ export function NavPanel(props: NavPanelProps): ReactNode {
|
|
|
58
102
|
else await mutate({ op: 'deleteSession', sessionId: d.id })
|
|
59
103
|
}
|
|
60
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
|
+
|
|
61
121
|
const q = search.trim().toLowerCase()
|
|
62
122
|
const groupMatches = (g: ClientSnapshot['groups'][number]): { show: boolean, filterSessions: boolean } => {
|
|
63
123
|
if (!q) return { show: true, filterSessions: false }
|
|
@@ -67,6 +127,7 @@ export function NavPanel(props: NavPanelProps): ReactNode {
|
|
|
67
127
|
}
|
|
68
128
|
|
|
69
129
|
const group = gid ? groupById(snap, gid) : null
|
|
130
|
+
const selected = sid ? sessById(snap, sid) : null
|
|
70
131
|
|
|
71
132
|
const treeNodes: ReactNode[] = []
|
|
72
133
|
for (const g of snap.groups) {
|
|
@@ -76,14 +137,13 @@ export function NavPanel(props: NavPanelProps): ReactNode {
|
|
|
76
137
|
const isRenameGroup = renameDraft && renameDraft.kind === 'group' && renameDraft.id === g.id
|
|
77
138
|
const isConfirmGroup = confirmDel && confirmDel.kind === 'group' && confirmDel.id === g.id
|
|
78
139
|
const groupChildren: ReactNode[] = []
|
|
79
|
-
|
|
140
|
+
|
|
80
141
|
if (expanded) {
|
|
81
142
|
for (const sessionId of g.sessionIds) {
|
|
82
143
|
const s = sessById(snap, sessionId)
|
|
83
144
|
if (!s) continue
|
|
84
145
|
if (match.filterSessions && !s.name.toLowerCase().includes(q)) continue
|
|
85
|
-
const
|
|
86
|
-
const isActive = sess && s.id === sess.id && g.id === group?.id
|
|
146
|
+
const isActive = selected && s.id === selected.id && g.id === group?.id
|
|
87
147
|
const isRenameSess = renameDraft && renameDraft.kind === 'session' && renameDraft.id === s.id
|
|
88
148
|
const isConfirm = confirmDel && confirmDel.kind === 'session' && confirmDel.id === s.id
|
|
89
149
|
// 会话状态(对齐主 GUI StateDot:idle 不渲染点)
|
|
@@ -95,11 +155,11 @@ export function NavPanel(props: NavPanelProps): ReactNode {
|
|
|
95
155
|
className={'dsgc-sess-row' + (isActive ? ' on' : '')}
|
|
96
156
|
role="button"
|
|
97
157
|
tabIndex={0}
|
|
98
|
-
onClick={() => {
|
|
158
|
+
onClick={() => { pickSession(g.id, s.id) }}
|
|
99
159
|
onKeyDown={(e) => {
|
|
100
160
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
101
161
|
e.preventDefault()
|
|
102
|
-
|
|
162
|
+
pickSession(g.id, s.id)
|
|
103
163
|
}
|
|
104
164
|
}}
|
|
105
165
|
>
|
|
@@ -114,66 +174,45 @@ export function NavPanel(props: NavPanelProps): ReactNode {
|
|
|
114
174
|
: null}
|
|
115
175
|
</span>
|
|
116
176
|
{isRenameSess
|
|
117
|
-
? (
|
|
118
|
-
<input
|
|
119
|
-
className="dsgc-rename"
|
|
120
|
-
value={renameDraft!.value}
|
|
121
|
-
autoFocus
|
|
122
|
-
onChange={(e) => { setRenameDraft({ kind: 'session', id: s.id, value: e.target.value }) }}
|
|
123
|
-
onBlur={() => { void commitRename() }}
|
|
124
|
-
onKeyDown={renameKeyDown}
|
|
125
|
-
onClick={(e) => { e.stopPropagation() }}
|
|
126
|
-
/>
|
|
127
|
-
)
|
|
177
|
+
? <RenameField draft={renameDraft!} set={setRenameDraft} commit={() => { void commitRename() }} keyDown={renameKeyDown} />
|
|
128
178
|
: (
|
|
129
179
|
<HoverTip label={s.name} side="right" delayMs={500} className="dsgc-sess-name">
|
|
130
180
|
{s.name}
|
|
131
181
|
</HoverTip>
|
|
132
182
|
)}
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
title={isConfirm ? '再次点击确认删除' : '删除会话'}
|
|
145
|
-
aria-label="删除会话"
|
|
146
|
-
onClick={(e) => {
|
|
147
|
-
e.stopPropagation()
|
|
148
|
-
if (isConfirm) void doDelete()
|
|
149
|
-
else setConfirmDel({ kind: 'session', id: s.id })
|
|
150
|
-
}}
|
|
151
|
-
>
|
|
152
|
-
{Icon(P.IconTrashOutline16, 14)}
|
|
153
|
-
</button>
|
|
154
|
-
</span>
|
|
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
|
+
/>
|
|
155
194
|
</div>,
|
|
156
195
|
)
|
|
157
196
|
}
|
|
158
|
-
|
|
197
|
+
|
|
159
198
|
groupChildren.push(
|
|
160
199
|
<button key="__add" className="dsgc-addsess" onClick={() => { void mutate({ op: 'createSession', groupId: g.id }) }}>
|
|
161
200
|
{Icon(P.IconPlusOutline16, 14)}新会话
|
|
162
201
|
</button>,
|
|
163
202
|
)
|
|
164
203
|
}
|
|
165
|
-
|
|
204
|
+
|
|
166
205
|
treeNodes.push(
|
|
167
206
|
<div key={g.id} className="dsgc-gnode">
|
|
168
207
|
<div
|
|
169
208
|
className={'dsgc-grow-row' + (g.id === group?.id ? ' on' : '')}
|
|
170
209
|
role="button"
|
|
171
210
|
tabIndex={0}
|
|
172
|
-
onClick={() => {
|
|
211
|
+
onClick={() => { pickGroup(g.id) }}
|
|
173
212
|
onKeyDown={(e) => {
|
|
174
213
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
175
214
|
e.preventDefault()
|
|
176
|
-
|
|
215
|
+
pickGroup(g.id)
|
|
177
216
|
}
|
|
178
217
|
}}
|
|
179
218
|
>
|
|
@@ -194,40 +233,19 @@ export function NavPanel(props: NavPanelProps): ReactNode {
|
|
|
194
233
|
{Icon(P.IconChevronDownOutline14, 14)}
|
|
195
234
|
</button>
|
|
196
235
|
{isRenameGroup
|
|
197
|
-
? (
|
|
198
|
-
<input
|
|
199
|
-
className="dsgc-rename"
|
|
200
|
-
value={renameDraft!.value}
|
|
201
|
-
autoFocus
|
|
202
|
-
onChange={(e) => { setRenameDraft({ kind: 'group', id: g.id, value: e.target.value }) }}
|
|
203
|
-
onBlur={() => { void commitRename() }}
|
|
204
|
-
onKeyDown={renameKeyDown}
|
|
205
|
-
onClick={(e) => { e.stopPropagation() }}
|
|
206
|
-
/>
|
|
207
|
-
)
|
|
236
|
+
? <RenameField draft={renameDraft!} set={setRenameDraft} commit={() => { void commitRename() }} keyDown={renameKeyDown} />
|
|
208
237
|
: <span className="dsgc-gname">{g.name}</span>}
|
|
209
|
-
<
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
title={isConfirmGroup ? '再次点击确认删除' : '删除群组'}
|
|
221
|
-
aria-label="删除群组"
|
|
222
|
-
onClick={(e) => {
|
|
223
|
-
e.stopPropagation()
|
|
224
|
-
if (isConfirmGroup) void doDelete()
|
|
225
|
-
else setConfirmDel({ kind: 'group', id: g.id })
|
|
226
|
-
}}
|
|
227
|
-
>
|
|
228
|
-
{Icon(P.IconTrashOutline16, 14)}
|
|
229
|
-
</button>
|
|
230
|
-
</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
|
+
/>
|
|
231
249
|
</div>
|
|
232
250
|
{expanded ? <div className="dsgc-sess-list">{groupChildren}</div> : null}
|
|
233
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
|
)
|
|
@@ -5,12 +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
9
|
import { ClipWell, Fold } from './Fold.tsx'
|
|
10
10
|
|
|
11
11
|
const TOOL_ICONS: Record<string, string> = { read_file: 'IconBrowseOutline16', list_dir: 'IconFolderOpenOutline16', run_command: 'IconCodeOutline16' }
|
|
12
12
|
|
|
13
|
-
export function ToolRow(props: { c:
|
|
13
|
+
export function ToolRow(props: { c: ToolCallRecord }): ReactNode {
|
|
14
14
|
const c = props.c
|
|
15
15
|
const [expanded, setExpanded] = useState(false)
|
|
16
16
|
let brief = ''
|
|
@@ -127,14 +127,14 @@ export function useMentionChip(
|
|
|
127
127
|
setMentionIdx: (val: number) => void,
|
|
128
128
|
syncFromDOM: () => void,
|
|
129
129
|
) {
|
|
130
|
-
/** 弹层候选 → 删掉光标前的
|
|
131
|
-
const
|
|
130
|
+
/** 弹层候选 → 删掉光标前的 @词、插入带 data-new 标记的芯片 + 尾随空格。 */
|
|
131
|
+
const insertChipHtml = useCallback((html: string): void => {
|
|
132
132
|
const el = inputRef.current
|
|
133
133
|
const sel = window.getSelection()
|
|
134
134
|
if (!el || !sel) return
|
|
135
135
|
if (!sel.anchorNode || !el.contains(sel.anchorNode)) return
|
|
136
136
|
el.focus({ preventScroll: true })
|
|
137
|
-
|
|
137
|
+
|
|
138
138
|
// 1. 删除光标前的 @token(包括引号)
|
|
139
139
|
const node = sel.anchorNode
|
|
140
140
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
@@ -157,12 +157,13 @@ export function useMentionChip(
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
|
-
|
|
161
|
-
// 2.
|
|
162
|
-
|
|
160
|
+
|
|
161
|
+
// 2. 插入芯片(fresh 标记:部分浏览器把选区落进 contenteditable=false 芯片内部,
|
|
162
|
+
// 曾致空格丢失与光标不可见)
|
|
163
|
+
execCommand('insertHTML', html)
|
|
163
164
|
const chip = el.querySelector<HTMLElement>('.dsgc-chipin[data-new]')
|
|
164
165
|
if (!chip) return
|
|
165
|
-
|
|
166
|
+
|
|
166
167
|
// 3. 选区显式钉到芯片之后,再补尾随空格
|
|
167
168
|
const after = document.createRange()
|
|
168
169
|
after.setStartAfter(chip)
|
|
@@ -176,53 +177,13 @@ export function useMentionChip(
|
|
|
176
177
|
syncFromDOM()
|
|
177
178
|
}, [inputRef, setMention, setMentionIdx, syncFromDOM])
|
|
178
179
|
|
|
180
|
+
const insertChip = useCallback((role: SnapshotRole): void => {
|
|
181
|
+
insertChipHtml(chipHtml(role, true))
|
|
182
|
+
}, [insertChipHtml])
|
|
183
|
+
|
|
179
184
|
const insertFileChip = useCallback((path: string, kind: 'file' | 'directory'): void => {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (!el || !sel) return
|
|
183
|
-
if (!sel.anchorNode || !el.contains(sel.anchorNode)) return
|
|
184
|
-
el.focus({ preventScroll: true })
|
|
185
|
-
|
|
186
|
-
// 1. 删除光标前的 @token(包括引号)
|
|
187
|
-
const node = sel.anchorNode
|
|
188
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
189
|
-
const text = node.nodeValue || ''
|
|
190
|
-
const off = sel.anchorOffset
|
|
191
|
-
const before = text.slice(0, off)
|
|
192
|
-
// 匹配 @词 或 @"词
|
|
193
|
-
const m = /(?:^|\s)@("?)([^"\s@]*)$/.exec(before)
|
|
194
|
-
if (m) {
|
|
195
|
-
const prefixLen = m[1] ? 2 : 1 // @" 或 @
|
|
196
|
-
const queryLen = m[2].length
|
|
197
|
-
const start = off - prefixLen - queryLen
|
|
198
|
-
if (start >= 0) {
|
|
199
|
-
const range = document.createRange()
|
|
200
|
-
range.setStart(node, start)
|
|
201
|
-
range.setEnd(node, off)
|
|
202
|
-
sel.removeAllRanges()
|
|
203
|
-
sel.addRange(range)
|
|
204
|
-
execCommand('delete')
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// 2. 插入文件芯片
|
|
210
|
-
execCommand('insertHTML', fileChipHtml(path, kind).replace('class="dsgc-chipin dsgc-chipin-file"', 'class="dsgc-chipin dsgc-chipin-file" data-new=""'))
|
|
211
|
-
const chip = el.querySelector<HTMLElement>('.dsgc-chipin[data-new]')
|
|
212
|
-
if (!chip) return
|
|
213
|
-
|
|
214
|
-
// 3. 选区显式钉到芯片之后,再补尾随空格
|
|
215
|
-
const after = document.createRange()
|
|
216
|
-
after.setStartAfter(chip)
|
|
217
|
-
after.collapse(true)
|
|
218
|
-
sel.removeAllRanges()
|
|
219
|
-
sel.addRange(after)
|
|
220
|
-
execCommand('insertText', ' ')
|
|
221
|
-
chip.removeAttribute('data-new')
|
|
222
|
-
setMention(null)
|
|
223
|
-
setMentionIdx(0)
|
|
224
|
-
syncFromDOM()
|
|
225
|
-
}, [inputRef, setMention, setMentionIdx, syncFromDOM])
|
|
185
|
+
insertChipHtml(fileChipHtml(path, kind, true))
|
|
186
|
+
}, [insertChipHtml])
|
|
226
187
|
|
|
227
188
|
return { insertChip, insertFileChip }
|
|
228
189
|
}
|
|
@@ -70,17 +70,12 @@ export function useGroupChatState() {
|
|
|
70
70
|
const [confirmDel, setConfirmDel] = useState<{ kind: 'group' | 'session', id: string } | null>(null)
|
|
71
71
|
const [confirmClear, setConfirmClear] = useState(false)
|
|
72
72
|
const [topicDraft, setTopicDraft] = useState<string | null>(null)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// 角色编辑
|
|
73
|
+
|
|
74
|
+
// 角色编辑(models 目录与保存/校验态在 RoleDrawer 内自理)
|
|
76
75
|
const [roleDraft, setRoleDraft] = useState<RoleDraft | null>(null)
|
|
77
|
-
const [roleFormError, setRoleFormError] = useState('')
|
|
78
76
|
const [models, setModels] = useState<ModelsResponse | null>(null)
|
|
79
77
|
const [modelsError, setModelsError] = useState<string | null>(null)
|
|
80
78
|
|
|
81
|
-
// 文件浏览器
|
|
82
|
-
const [fileBrowser, setFileBrowser] = useState<{ open: boolean, loading: boolean, list: import('../../core/types.ts').BrowseResult | null, error: string } | null>(null)
|
|
83
|
-
|
|
84
79
|
// 参与角色选择
|
|
85
80
|
const [partsSel, setPartsSel] = useState<string[] | null>(null)
|
|
86
81
|
const [rounds, setRounds] = useState(1)
|
|
@@ -174,16 +169,10 @@ export function useGroupChatState() {
|
|
|
174
169
|
setConfirmClear,
|
|
175
170
|
roleDraft,
|
|
176
171
|
setRoleDraft,
|
|
177
|
-
roleFormError,
|
|
178
|
-
setRoleFormError,
|
|
179
172
|
models,
|
|
180
173
|
setModels,
|
|
181
174
|
modelsError,
|
|
182
175
|
setModelsError,
|
|
183
|
-
fileBrowser,
|
|
184
|
-
setFileBrowser,
|
|
185
|
-
wsDraft,
|
|
186
|
-
setWsDraft,
|
|
187
176
|
partsSel,
|
|
188
177
|
setPartsSel,
|
|
189
178
|
rounds,
|
package/src/client/lib/model.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @module dsh-group-chat/client/model
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type {
|
|
6
|
+
import type { Snapshot } from '../../core/types.ts'
|
|
7
7
|
|
|
8
8
|
/** 会话列表状态派生与文案(core 纯函数,供 NavPanel 渲染)。 */
|
|
9
9
|
export { sessStatus, SESS_STATUS_LABEL, type SessStatus } from '../../core/status.ts'
|
|
@@ -104,16 +104,3 @@ export function draftFromRole(role: SnapshotRole): RoleDraft {
|
|
|
104
104
|
export function blankDraft(): RoleDraft {
|
|
105
105
|
return { name: '', color: null, persona: '', provider: '', model: '', temperature: undefined, reasoningEffort: 'default', enabled: true, thinking: false }
|
|
106
106
|
}
|
|
107
|
-
|
|
108
|
-
/** 工具调用行的展示参数。 */
|
|
109
|
-
export type ToolCallView = ToolCallRecord
|
|
110
|
-
|
|
111
|
-
/** 草稿是否可作为角色落库(宿主侧再校验一次)。 */
|
|
112
|
-
export function draftMissing(draft: RoleDraft): string {
|
|
113
|
-
if (!draft.name.trim()) return '角色名称不能为空'
|
|
114
|
-
if (!draft.provider || !draft.model) return '请选择角色绑定的模型'
|
|
115
|
-
return ''
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** RoleRecord 兼容视图(快照角色行即其展示子集)。 */
|
|
119
|
-
export type RoleView = RoleRecord
|
|
@@ -57,10 +57,10 @@ export function serializeInput(root: HTMLElement): string {
|
|
|
57
57
|
return Array.from(root.childNodes).map(walk).join('')
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
/** @角色芯片的 HTML(原子元素:contenteditable=false + draggable
|
|
61
|
-
export function chipHtml(role: { id: string, name: string, color?: string }): string {
|
|
60
|
+
/** @角色芯片的 HTML(原子元素:contenteditable=false + draggable,退格整删;fresh=插入后需营救选区,加 data-new 标记)。 */
|
|
61
|
+
export function chipHtml(role: { id: string, name: string, color?: string }, fresh = false): string {
|
|
62
62
|
const c = escapeHtml(role.color || '#888')
|
|
63
|
-
return '<span class="dsgc-chipin" data-role-id="' + escapeHtml(role.id) + '" data-name="' + escapeHtml(role.name) + '" style="--role-color:' + c + '" contenteditable="false" draggable="true">' +
|
|
63
|
+
return '<span class="dsgc-chipin"' + (fresh ? ' data-new=""' : '') + ' data-role-id="' + escapeHtml(role.id) + '" data-name="' + escapeHtml(role.name) + '" style="--role-color:' + c + '" contenteditable="false" draggable="true">' +
|
|
64
64
|
'<span class="dsgc-chipdot" style="background:' + c + '"></span>' + escapeHtml(role.name) + '</span>'
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -81,11 +81,11 @@ function fileTypeIconMarkup(path: string, kind: 'file' | 'directory'): string {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
/** @文件芯片的 HTML(原子元素:contenteditable=false + draggable
|
|
85
|
-
export function fileChipHtml(path: string, kind: 'file' | 'directory'): string {
|
|
84
|
+
/** @文件芯片的 HTML(原子元素:contenteditable=false + draggable,退格整删;fresh 同 chipHtml)。 */
|
|
85
|
+
export function fileChipHtml(path: string, kind: 'file' | 'directory', fresh = false): string {
|
|
86
86
|
const basename = path.split('/').pop() || path
|
|
87
87
|
const dir = kind === 'directory'
|
|
88
|
-
return '<span class="dsgc-chipin dsgc-chipin-file" data-kind="file"' + (dir ? ' data-dir="1"' : '') + ' data-path="' + escapeHtml(path) + '" contenteditable="false" draggable="true">' +
|
|
88
|
+
return '<span class="dsgc-chipin dsgc-chipin-file"' + (fresh ? ' data-new=""' : '') + ' data-kind="file"' + (dir ? ' data-dir="1"' : '') + ' data-path="' + escapeHtml(path) + '" contenteditable="false" draggable="true">' +
|
|
89
89
|
'<span class="dsgc-chipglyph" aria-hidden="true">' + fileTypeIconMarkup(path, kind) + '</span>' + escapeHtml(basename) + '</span>'
|
|
90
90
|
}
|
|
91
91
|
|
package/src/core/constraints.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* @module dsh-group-chat/core/constraints
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { looseJson } from './json.ts'
|
|
6
7
|
import { TRANSCRIPT_TOOL_SUMMARY } from './tools.ts'
|
|
7
8
|
import { asConstraintKind, type MessageRecord, type SessionConstraint, type SessionRecord } from './types.ts'
|
|
8
9
|
|
|
@@ -182,18 +183,11 @@ export function sanitizeConstraints(raw: unknown): SessionConstraint[] {
|
|
|
182
183
|
* 解析折叠模型输出。null = 解析失败(水位不推);[] = 无新结论(水位推、备忘不动)。
|
|
183
184
|
*/
|
|
184
185
|
export function parseConstraints(raw: string): SessionConstraint[] | null {
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
const o = JSON.parse(body.slice(l, r + 1)) as { constraints?: unknown }
|
|
191
|
-
if (!Object.prototype.hasOwnProperty.call(o, 'constraints')) return null
|
|
192
|
-
if (!Array.isArray(o.constraints)) return null
|
|
193
|
-
return sanitizeConstraints(o.constraints)
|
|
194
|
-
} catch {
|
|
195
|
-
return null
|
|
196
|
-
}
|
|
186
|
+
const o = looseJson(raw.replace(/```(?:json)?/g, ''))
|
|
187
|
+
if (o === null) return null
|
|
188
|
+
if (!Object.prototype.hasOwnProperty.call(o, 'constraints')) return null
|
|
189
|
+
if (!Array.isArray(o.constraints)) return null
|
|
190
|
+
return sanitizeConstraints(o.constraints)
|
|
197
191
|
}
|
|
198
192
|
|
|
199
193
|
/** 本批无用户消息时,新的已定/否决降为未决。 */
|
package/src/core/errors.ts
CHANGED
|
@@ -111,17 +111,7 @@ export function repairFailedMessage(
|
|
|
111
111
|
return changed
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
const start = text.indexOf('{')
|
|
116
|
-
const end = text.lastIndexOf('}')
|
|
117
|
-
if (start < 0 || end <= start) return null
|
|
118
|
-
try {
|
|
119
|
-
const parsed = JSON.parse(text.slice(start, end + 1)) as unknown
|
|
120
|
-
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null
|
|
121
|
-
} catch {
|
|
122
|
-
return null
|
|
123
|
-
}
|
|
124
|
-
}
|
|
114
|
+
import { looseJson } from './json.ts'
|
|
125
115
|
|
|
126
116
|
function httpStatus(text: string): number | null {
|
|
127
117
|
const m = text.match(/\b([1-5]\d{2})\b/)
|
|
@@ -141,7 +131,7 @@ function resetHint(message: string): string | undefined {
|
|
|
141
131
|
*/
|
|
142
132
|
export function classifySpeakFailure(raw: string): SpeakFailureView {
|
|
143
133
|
const source = unwrapSpeakFailure(raw)
|
|
144
|
-
const blob =
|
|
134
|
+
const blob = looseJson(source)
|
|
145
135
|
const code = blob && typeof blob.code === 'string' ? blob.code : ''
|
|
146
136
|
const type = blob && typeof blob.type === 'string' ? blob.type : ''
|
|
147
137
|
const message = blob && typeof blob.message === 'string' ? blob.message : source
|
package/src/core/json.ts
CHANGED
|
@@ -27,3 +27,20 @@ export function roleJson(r: Partial<RoleRecord>): Record<string, unknown> {
|
|
|
27
27
|
if (typeof r.reasoningEffort === 'string' && r.reasoningEffort && r.reasoningEffort !== 'default') o.reasoningEffort = r.reasoningEffort
|
|
28
28
|
return o
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 宽容 JSON 提取(模型输出/供应商错误原文的统一解析入口):
|
|
33
|
+
* 取首个 { 至末个 } 的片段解析,仅接受普通对象;失败返回 null。
|
|
34
|
+
* 代码围栏剥离由调用方按需先行处理(供应商错误原文不剥)。
|
|
35
|
+
*/
|
|
36
|
+
export function looseJson(raw: string): Record<string, unknown> | null {
|
|
37
|
+
const start = raw.indexOf('{')
|
|
38
|
+
const end = raw.lastIndexOf('}')
|
|
39
|
+
if (start < 0 || end <= start) return null
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(raw.slice(start, end + 1)) as unknown
|
|
42
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|