@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.
Files changed (103) hide show
  1. package/README.md +91 -27
  2. package/lib/client.js +1254 -598
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +573 -109
  5. package/lib/types/client/components/AsidePanel.d.ts +3 -19
  6. package/lib/types/client/components/Bubble.d.ts +6 -3
  7. package/lib/types/client/components/ChatPanel.d.ts +1 -0
  8. package/lib/types/client/components/ConstraintList.d.ts +11 -0
  9. package/lib/types/client/components/FailCard.d.ts +9 -0
  10. package/lib/types/client/components/Fold.d.ts +19 -0
  11. package/lib/types/client/components/HoverTip.d.ts +22 -0
  12. package/lib/types/client/components/MessageFlow.d.ts +1 -0
  13. package/lib/types/client/components/MsgActions.d.ts +14 -0
  14. package/lib/types/client/components/NavPanel.d.ts +9 -11
  15. package/lib/types/client/components/RoleDrawer.d.ts +3 -2
  16. package/lib/types/client/components/ToolRow.d.ts +2 -2
  17. package/lib/types/client/hooks/useComposer.d.ts +6 -1
  18. package/lib/types/client/hooks/useGroupChatState.d.ts +0 -16
  19. package/lib/types/client/lib/composer-draft.d.ts +17 -0
  20. package/lib/types/client/lib/model.d.ts +1 -7
  21. package/lib/types/client/utils/utils.d.ts +4 -4
  22. package/lib/types/core/constraints.d.ts +61 -0
  23. package/lib/types/core/errors.d.ts +54 -0
  24. package/lib/types/core/json.d.ts +6 -0
  25. package/lib/types/core/types.d.ts +76 -60
  26. package/lib/types/host/api/actions.d.ts +1 -1
  27. package/lib/types/host/engine/conversation.d.ts +5 -3
  28. package/lib/types/host/engine/defaults.d.ts +13 -0
  29. package/lib/types/host/engine/fold.d.ts +17 -0
  30. package/lib/types/host/engine/index.d.ts +1 -0
  31. package/lib/types/host/engine/retitle.d.ts +5 -5
  32. package/lib/types/host/service.d.ts +1 -1
  33. package/lib/types/host/state.d.ts +2 -0
  34. package/lib/types/index.d.ts +2 -0
  35. package/lib/types/shared/file-mention-grammar.d.ts +3 -12
  36. package/package.json +1 -1
  37. package/src/client/GroupChatPanel.tsx +25 -59
  38. package/src/client/components/AsidePanel.tsx +50 -23
  39. package/src/client/components/Bubble.tsx +45 -18
  40. package/src/client/components/ChatPanel.tsx +17 -12
  41. package/src/client/components/Composer.tsx +26 -20
  42. package/src/client/components/ConstraintList.tsx +64 -0
  43. package/src/client/components/FailCard.tsx +49 -0
  44. package/src/client/components/Fold.tsx +72 -0
  45. package/src/client/components/HoverTip.tsx +134 -0
  46. package/src/client/components/MessageFlow.tsx +78 -54
  47. package/src/client/components/MsgActions.tsx +85 -0
  48. package/src/client/components/NavPanel.tsx +102 -79
  49. package/src/client/components/RoleDrawer.tsx +29 -7
  50. package/src/client/components/ThinkRow.tsx +7 -3
  51. package/src/client/components/ToolRow.tsx +9 -5
  52. package/src/client/hooks/useComposer.ts +54 -56
  53. package/src/client/hooks/useGroupChatState.ts +7 -17
  54. package/src/client/lib/composer-draft.ts +47 -0
  55. package/src/client/lib/model.ts +1 -14
  56. package/src/client/lib/styles.ts +73 -12
  57. package/src/client/react-dom-shim.d.ts +2 -0
  58. package/src/client/utils/utils.ts +6 -6
  59. package/src/core/constraints.ts +204 -0
  60. package/src/core/errors.ts +174 -0
  61. package/src/core/json.ts +18 -0
  62. package/src/core/types.ts +52 -19
  63. package/src/host/api/actions.ts +49 -14
  64. package/src/host/broadcast.ts +3 -3
  65. package/src/host/engine/conversation.ts +104 -40
  66. package/src/host/engine/defaults.ts +28 -0
  67. package/src/host/engine/fold.ts +99 -0
  68. package/src/host/engine/index.ts +1 -0
  69. package/src/host/engine/retitle.ts +24 -41
  70. package/src/host/materials/materials.ts +2 -0
  71. package/src/host/persistence/persistence.ts +18 -1
  72. package/src/host/service.ts +1 -1
  73. package/src/host/state.ts +5 -4
  74. package/src/host/tools/tools.ts +2 -2
  75. package/src/index.ts +2 -0
  76. package/src/shared/file-mention-grammar.test.ts +1 -38
  77. package/src/shared/file-mention-grammar.ts +7 -33
  78. package/lib/types/client/Bubble.d.ts +0 -10
  79. package/lib/types/client/Glyph.d.ts +0 -12
  80. package/lib/types/client/GroupChatPanel.old.d.ts +0 -10
  81. package/lib/types/client/GroupChatSettingsSection.d.ts +0 -15
  82. package/lib/types/client/PermissionSelect.d.ts +0 -13
  83. package/lib/types/client/RoleDrawer.d.ts +0 -17
  84. package/lib/types/client/ThinkRow.d.ts +0 -9
  85. package/lib/types/client/ToolRow.d.ts +0 -9
  86. package/lib/types/client/api.d.ts +0 -8
  87. package/lib/types/client/components.d.ts +0 -22
  88. package/lib/types/client/drawer.d.ts +0 -17
  89. package/lib/types/client/glyph.d.ts +0 -12
  90. package/lib/types/client/model.d.ts +0 -78
  91. package/lib/types/client/panel.d.ts +0 -10
  92. package/lib/types/client/settings.d.ts +0 -15
  93. package/lib/types/client/styles.d.ts +0 -10
  94. package/lib/types/client/ui.d.ts +0 -17
  95. package/lib/types/client/utils.d.ts +0 -22
  96. package/lib/types/host/actions.d.ts +0 -34
  97. package/lib/types/host/conversation.d.ts +0 -25
  98. package/lib/types/host/http.d.ts +0 -16
  99. package/lib/types/host/materials.d.ts +0 -32
  100. package/lib/types/host/persistence.d.ts +0 -31
  101. package/lib/types/host/routes.d.ts +0 -11
  102. package/lib/types/host/store.d.ts +0 -65
  103. 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: { kind: 'group' | 'session', id: string, value: string } | null
22
- setRenameDraft: (val: { kind: 'group' | 'session', id: string, value: string } | null) => void
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 sess = sid ? sessById(snap, sid) : null
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={() => { setGid(g.id); setSid(s.id); setPartsSel(null); setConfirmDel(null); setRenameDraft(null) }}
158
+ onClick={() => { pickSession(g.id, s.id) }}
98
159
  onKeyDown={(e) => {
99
160
  if (e.key === 'Enter' || e.key === ' ') {
100
161
  e.preventDefault()
101
- setGid(g.id); setSid(s.id); setPartsSel(null); setConfirmDel(null); setRenameDraft(null)
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
- <input
118
- className="dsgc-rename"
119
- value={renameDraft!.value}
120
- autoFocus
121
- onChange={(e) => { setRenameDraft({ kind: 'session', id: s.id, value: e.target.value }) }}
122
- onBlur={() => { void commitRename() }}
123
- onKeyDown={renameKeyDown}
124
- onClick={(e) => { e.stopPropagation() }}
125
- />
126
- )
127
- : <span className="dsgc-sess-name">{s.name}</span>}
128
- <span className="dsgc-nodeops">
129
- <button
130
- className="dsgc-opbtn"
131
- title="重命名会话"
132
- aria-label="重命名会话"
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={() => { setGid(g.id); setPartsSel(null); setConfirmDel(null); setRenameDraft(null) }}
211
+ onClick={() => { pickGroup(g.id) }}
168
212
  onKeyDown={(e) => {
169
213
  if (e.key === 'Enter' || e.key === ' ') {
170
214
  e.preventDefault()
171
- setGid(g.id); setPartsSel(null); setConfirmDel(null); setRenameDraft(null)
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
- <span className="dsgc-nodeops">
205
- <button
206
- className="dsgc-opbtn"
207
- title="重命名群组"
208
- aria-label="重命名群组"
209
- onClick={(e) => { e.stopPropagation(); setRenameDraft({ kind: 'group', id: g.id, value: g.name }) }}
210
- >
211
- {Icon(P.IconEditOutline16, 14)}
212
- </button>
213
- <button
214
- className={'dsgc-opbtn' + (isConfirmGroup ? ' danger' : '')}
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
- onSave: () => void
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 (closing) return
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
- {props.formError ? <div className="dsgc-formerr">{props.formError}</div> : null}
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={props.onSave}>{draft.id ? '保存' : '添加'}</P.Button>
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
- <div className="dsgc-thinkbody">{text}</div>
29
- </P.DisclosureRow>
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 { ToolCallView } from '../lib/model.ts'
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: ToolCallView }): ReactNode {
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
- <div className="dsgc-toolbody">{c.output || '(无输出)'}</div>
48
- </P.DisclosureRow>
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
- /** DOM 同步 input 状态(序列化) */
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
- setInput(serializeInput(el))
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 insertChip = useCallback((role: SnapshotRole): void => {
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
- execCommand('insertHTML', chipHtml(role).replace('class="dsgc-chipin"', 'class="dsgc-chipin" data-new=""'))
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
- const el = inputRef.current
144
- const sel = window.getSelection()
145
- if (!el || !sel) return
146
- if (!sel.anchorNode || !el.contains(sel.anchorNode)) return
147
- el.focus({ preventScroll: true })
148
-
149
- // 1. 删除光标前的 @token(包括引号)
150
- const node = sel.anchorNode
151
- if (node.nodeType === Node.TEXT_NODE) {
152
- const text = node.nodeValue || ''
153
- const off = sel.anchorOffset
154
- const before = text.slice(0, off)
155
- // 匹配 @词 或 @"词
156
- const m = /(?:^|\s)@("?)([^"\s@]*)$/.exec(before)
157
- if (m) {
158
- const prefixLen = m[1] ? 2 : 1 // @" 或 @
159
- const queryLen = m[2].length
160
- const start = off - prefixLen - queryLen
161
- if (start >= 0) {
162
- const range = document.createRange()
163
- range.setStart(node, start)
164
- range.setEnd(node, off)
165
- sel.removeAllRanges()
166
- sel.addRange(range)
167
- execCommand('delete')
168
- }
169
- }
170
- }
171
-
172
- // 2. 插入文件芯片
173
- execCommand('insertHTML', fileChipHtml(path, kind).replace('class="dsgc-chipin dsgc-chipin-file"', 'class="dsgc-chipin dsgc-chipin-file" data-new=""'))
174
- const chip = el.querySelector<HTMLElement>('.dsgc-chipin[data-new]')
175
- if (!chip) return
176
-
177
- // 3. 选区显式钉到芯片之后,再补尾随空格
178
- const after = document.createRange()
179
- after.setStartAfter(chip)
180
- after.collapse(true)
181
- sel.removeAllRanges()
182
- sel.addRange(after)
183
- execCommand('insertText', ' ')
184
- chip.removeAttribute('data-new')
185
- setMention(null)
186
- setMentionIdx(0)
187
- syncFromDOM()
188
- }, [inputRef, setMention, setMentionIdx, syncFromDOM])
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
- * 挂载后的 state 拉取 / SSE 首帧再行校正(本机回路往返仅数毫秒)。
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
- const [wsDraft, setWsDraft] = useState<string | null>(null)
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,