@roaming-ai/dsh-group-chat 0.3.0 → 0.3.2
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 +93 -28
- package/lib/client.js +295 -326
- package/lib/client.js.map +1 -1
- package/lib/index.js +234 -221
- 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 +59 -64
- package/lib/types/host/engine/defaults.d.ts +13 -0
- package/lib/types/host/persistence/persistence.d.ts +2 -4
- package/lib/types/host/persistence/store.d.ts +5 -1
- package/lib/types/host/service.d.ts +2 -2
- package/lib/types/host/state.d.ts +1 -0
- package/lib/types/host/tools/tools.d.ts +2 -1
- package/lib/types/index.d.ts +3 -0
- package/lib/types/shared/file-mention-grammar.d.ts +3 -12
- package/package.json +4 -1
- package/src/client/GroupChatPanel.tsx +9 -56
- package/src/client/components/AsidePanel.tsx +40 -15
- package/src/client/components/ChatPanel.tsx +1 -1
- package/src/client/components/Composer.tsx +2 -2
- 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/lib/styles.ts +6 -2
- 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 +29 -20
- package/src/host/api/actions.ts +14 -13
- package/src/host/engine/conversation.ts +50 -51
- 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/persistence/persistence.ts +57 -34
- package/src/host/persistence/store.ts +5 -1
- package/src/host/service.ts +4 -4
- package/src/host/state.ts +5 -2
- package/src/host/tools/tools.ts +42 -48
- package/src/index.ts +7 -5
- 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,28 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 右侧成员与工作区栏组件。工作区目录编辑态(草稿、文件浏览器)为栏内自有状态。
|
|
3
3
|
* @module dsh-group-chat/client/components
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type
|
|
6
|
+
import { useState, type ReactNode } from 'react'
|
|
7
7
|
import { Icon, P } from '../lib/ui.ts'
|
|
8
|
-
import {
|
|
8
|
+
import { api } from '../lib/api.ts'
|
|
9
|
+
import { roleById, type ClientSnapshot, type SnapshotRole } from '../lib/model.ts'
|
|
10
|
+
import type { BrowseResult } from '../../core/types.ts'
|
|
9
11
|
|
|
10
12
|
interface AsidePanelProps {
|
|
11
13
|
snap: ClientSnapshot
|
|
12
14
|
group: ClientSnapshot['groups'][number]
|
|
13
15
|
asideOpen: boolean
|
|
14
|
-
wsDraft: string | null
|
|
15
|
-
setWsDraft: (val: string | null) => void
|
|
16
|
-
fileBrowser: { open: boolean, loading: boolean, list: import('../../core/types.ts').BrowseResult | null, error: string } | null
|
|
17
|
-
setFileBrowser: (val: { open: boolean, loading: boolean, list: import('../../core/types.ts').BrowseResult | null, error: string } | null) => void
|
|
18
16
|
mutate: (args: Record<string, unknown>) => Promise<unknown>
|
|
19
|
-
openRoleEditor: (role: SnapshotRole | null) =>
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
openRoleEditor: (role: SnapshotRole | null) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 文件浏览器态(栏内自有)。 */
|
|
21
|
+
interface FileBrowserState {
|
|
22
|
+
open: boolean
|
|
23
|
+
loading: boolean
|
|
24
|
+
list: BrowseResult | null
|
|
25
|
+
error: string
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export function AsidePanel(props: AsidePanelProps): ReactNode {
|
|
25
|
-
const { snap, group, asideOpen,
|
|
29
|
+
const { snap, group, asideOpen, mutate, openRoleEditor } = props
|
|
30
|
+
const [wsDraft, setWsDraft] = useState<string | null>(null)
|
|
31
|
+
const [fileBrowser, setFileBrowser] = useState<FileBrowserState | null>(null)
|
|
26
32
|
|
|
27
33
|
const commitWsDir = (): void => {
|
|
28
34
|
if (wsDraft !== null && group && wsDraft !== (group.workspaceDir || '')) {
|
|
@@ -31,6 +37,25 @@ export function AsidePanel(props: AsidePanelProps): ReactNode {
|
|
|
31
37
|
setWsDraft(null)
|
|
32
38
|
}
|
|
33
39
|
|
|
40
|
+
const openBrowser = async (path: string | undefined): Promise<void> => {
|
|
41
|
+
setFileBrowser({ open: true, loading: true, list: null, error: '' })
|
|
42
|
+
try {
|
|
43
|
+
const res = await api.action({ kind: 'browse', path: path || '' }) as BrowseResult
|
|
44
|
+
if (res && res.ok) setFileBrowser({ open: true, loading: false, list: res, error: '' })
|
|
45
|
+
else setFileBrowser({ open: true, loading: false, list: null, error: (res && res.error) || '浏览失败' })
|
|
46
|
+
} catch (e) {
|
|
47
|
+
setFileBrowser({ open: true, loading: false, list: null, error: String((e && (e as Error).message) || e) })
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const selectCurrentDir = (): void => {
|
|
52
|
+
if (!fileBrowser || !fileBrowser.list || !group) return
|
|
53
|
+
const path = fileBrowser.list.path!
|
|
54
|
+
void mutate({ op: 'setWorkspaceDir', groupId: group.id, path })
|
|
55
|
+
setWsDraft(null)
|
|
56
|
+
setFileBrowser(null)
|
|
57
|
+
}
|
|
58
|
+
|
|
34
59
|
return (
|
|
35
60
|
<div className={'dsgc-aside' + (asideOpen ? '' : ' closed')} aria-hidden={asideOpen ? undefined : 'true'}>
|
|
36
61
|
<div className="dsgc-sec">
|
|
@@ -38,7 +63,7 @@ export function AsidePanel(props: AsidePanelProps): ReactNode {
|
|
|
38
63
|
群成员
|
|
39
64
|
<span className="dsgc-secspacer" />
|
|
40
65
|
<span className="dsgc-seccount">{group.roleIds.length ? group.roleIds.length + ' 个' : ''}</span>
|
|
41
|
-
<P.Button variant="ghost" size="sm" onClick={() => {
|
|
66
|
+
<P.Button variant="ghost" size="sm" onClick={() => { openRoleEditor(null) }} aria-label="添加角色">
|
|
42
67
|
{Icon(P.IconPlusOutline16, 14)}添加
|
|
43
68
|
</P.Button>
|
|
44
69
|
</div>
|
|
@@ -54,8 +79,8 @@ export function AsidePanel(props: AsidePanelProps): ReactNode {
|
|
|
54
79
|
className={'dsgc-role' + (r.enabled ? '' : ' off')}
|
|
55
80
|
role="button"
|
|
56
81
|
tabIndex={0}
|
|
57
|
-
onClick={() => {
|
|
58
|
-
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault();
|
|
82
|
+
onClick={() => { openRoleEditor(r) }}
|
|
83
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); openRoleEditor(r) } }}
|
|
59
84
|
title="点击编辑角色"
|
|
60
85
|
>
|
|
61
86
|
<div className="dsgc-rolehead">
|
|
@@ -94,7 +119,7 @@ export function AsidePanel(props: AsidePanelProps): ReactNode {
|
|
|
94
119
|
className="dsgc-opbtn"
|
|
95
120
|
title="编辑角色"
|
|
96
121
|
aria-label="编辑角色"
|
|
97
|
-
onClick={(e) => { e.stopPropagation();
|
|
122
|
+
onClick={(e) => { e.stopPropagation(); openRoleEditor(r) }}
|
|
98
123
|
>
|
|
99
124
|
{Icon(P.IconEditOutline16, 14)}
|
|
100
125
|
</button>
|
|
@@ -171,7 +171,7 @@ export function ChatPanel(props: ChatPanelProps): ReactNode {
|
|
|
171
171
|
<div className="dsgc-emptytitle">{sess && sess.topic ? '「' + sess.topic + '」' : '会话已就绪'}</div>
|
|
172
172
|
<div className="dsgc-hint">
|
|
173
173
|
{enabledRoles.length
|
|
174
|
-
? '发送消息开始讨论;@成员
|
|
174
|
+
? '发送消息开始讨论;@成员 点名让其优先回应——每轮全体参与角色按顺序各发言一次,可用右下角轮数控制(1–10 轮)'
|
|
175
175
|
: '先在右侧添加角色(每个角色可绑定不同模型),再回到这里发起讨论。'}
|
|
176
176
|
</div>
|
|
177
177
|
</div>
|
|
@@ -192,7 +192,7 @@ export function Composer(props: ComposerProps): ReactNode {
|
|
|
192
192
|
)
|
|
193
193
|
: null}
|
|
194
194
|
{!input.trim()
|
|
195
|
-
? <div className="dsgc-ph" aria-hidden="true">发消息给全群,@成员
|
|
195
|
+
? <div className="dsgc-ph" aria-hidden="true">发消息给全群,@成员 点名让其回应…</div>
|
|
196
196
|
: null}
|
|
197
197
|
<div
|
|
198
198
|
className="dsgc-edit"
|
|
@@ -233,7 +233,7 @@ export function Composer(props: ComposerProps): ReactNode {
|
|
|
233
233
|
</P.Button>
|
|
234
234
|
)
|
|
235
235
|
: (
|
|
236
|
-
<P.Button variant="primary" onClick={() => { void sendMsg() }} disabled={(!participants.length && !mentionedRoles.length) || !sess}>
|
|
236
|
+
<P.Button variant="primary" onClick={() => { void sendMsg() }} disabled={!input.trim() || (!participants.length && !mentionedRoles.length) || !sess}>
|
|
237
237
|
{Icon(P.IconSendOutline16, 16)}发送
|
|
238
238
|
</P.Button>
|
|
239
239
|
)}
|
|
@@ -5,14 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { useState, type ReactNode } from 'react'
|
|
7
7
|
import type { SessionConstraint } from '../../core/types.ts'
|
|
8
|
+
import { KIND_LABEL } from '../../core/constraints.ts'
|
|
8
9
|
import { ClipWell, Fold } from './Fold.tsx'
|
|
9
10
|
|
|
10
|
-
const KIND_LABEL: Record<SessionConstraint['kind'], string> = {
|
|
11
|
-
decided: '已定',
|
|
12
|
-
rejected: '否决',
|
|
13
|
-
open: '未决',
|
|
14
|
-
}
|
|
15
|
-
|
|
16
11
|
interface ConstraintListProps {
|
|
17
12
|
items: SessionConstraint[]
|
|
18
13
|
}
|
|
@@ -87,6 +87,8 @@ export function MessageFlow(props: MessageFlowProps): ReactNode {
|
|
|
87
87
|
let livePlaced = false
|
|
88
88
|
|
|
89
89
|
if (sess) {
|
|
90
|
+
// 循环不变量:本群角色表只过滤一次(流式期间每帧执行的热路径)
|
|
91
|
+
const groupRoles = snap.roles.filter((r) => r.groupId === sess.groupId)
|
|
90
92
|
const ids = sess.messageIds
|
|
91
93
|
const memo = sess.constraints && sess.constraints.length
|
|
92
94
|
? <ConstraintList key={sess.id + '-constraints'} items={sess.constraints} />
|
|
@@ -100,7 +102,6 @@ export function MessageFlow(props: MessageFlowProps): ReactNode {
|
|
|
100
102
|
if (!m) continue
|
|
101
103
|
// 角色在父级解析:Bubble 按值 memo(不依赖 snap identity),流式帧
|
|
102
104
|
// 不再触发已完成消息的全量重渲(每帧仅 live 行与派生列表变化)
|
|
103
|
-
const groupRoles = snap.roles.filter((r) => r.groupId === sess.groupId)
|
|
104
105
|
const role = resolveFailedRole(m, groupRoles) || (m.speaker !== 'user' && m.speaker !== 'system' ? roleById(snap, m.speaker) : null)
|
|
105
106
|
// 原地重试:该槽位换成 live(确认卡紧随其后),不要钉在列表末尾
|
|
106
107
|
if (replaceId && m.id === replaceId) {
|
|
@@ -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,
|