@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.
Files changed (66) hide show
  1. package/README.md +89 -26
  2. package/lib/client.js +289 -321
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +124 -132
  5. package/lib/types/client/components/AsidePanel.d.ts +3 -19
  6. package/lib/types/client/components/NavPanel.d.ts +9 -11
  7. package/lib/types/client/components/RoleDrawer.d.ts +3 -2
  8. package/lib/types/client/components/ToolRow.d.ts +2 -2
  9. package/lib/types/client/hooks/useGroupChatState.d.ts +0 -16
  10. package/lib/types/client/lib/model.d.ts +1 -7
  11. package/lib/types/client/utils/utils.d.ts +4 -4
  12. package/lib/types/core/json.d.ts +6 -0
  13. package/lib/types/core/types.d.ts +57 -63
  14. package/lib/types/host/engine/defaults.d.ts +13 -0
  15. package/lib/types/shared/file-mention-grammar.d.ts +3 -12
  16. package/package.json +1 -1
  17. package/src/client/GroupChatPanel.tsx +7 -56
  18. package/src/client/components/AsidePanel.tsx +40 -15
  19. package/src/client/components/ConstraintList.tsx +1 -6
  20. package/src/client/components/MessageFlow.tsx +2 -1
  21. package/src/client/components/NavPanel.tsx +96 -78
  22. package/src/client/components/RoleDrawer.tsx +29 -7
  23. package/src/client/components/ToolRow.tsx +2 -2
  24. package/src/client/hooks/useComposer.ts +14 -53
  25. package/src/client/hooks/useGroupChatState.ts +2 -13
  26. package/src/client/lib/model.ts +1 -14
  27. package/src/client/utils/utils.ts +6 -6
  28. package/src/core/constraints.ts +6 -12
  29. package/src/core/errors.ts +2 -12
  30. package/src/core/json.ts +17 -0
  31. package/src/core/types.ts +27 -19
  32. package/src/host/api/actions.ts +14 -13
  33. package/src/host/engine/conversation.ts +49 -50
  34. package/src/host/engine/defaults.ts +28 -0
  35. package/src/host/engine/fold.ts +2 -17
  36. package/src/host/engine/retitle.ts +9 -31
  37. package/src/host/materials/materials.ts +2 -0
  38. package/src/host/tools/tools.ts +2 -2
  39. package/src/shared/file-mention-grammar.test.ts +1 -38
  40. package/src/shared/file-mention-grammar.ts +7 -33
  41. package/lib/types/client/Bubble.d.ts +0 -10
  42. package/lib/types/client/Glyph.d.ts +0 -12
  43. package/lib/types/client/GroupChatPanel.old.d.ts +0 -10
  44. package/lib/types/client/GroupChatSettingsSection.d.ts +0 -15
  45. package/lib/types/client/PermissionSelect.d.ts +0 -13
  46. package/lib/types/client/RoleDrawer.d.ts +0 -17
  47. package/lib/types/client/ThinkRow.d.ts +0 -9
  48. package/lib/types/client/ToolRow.d.ts +0 -9
  49. package/lib/types/client/api.d.ts +0 -8
  50. package/lib/types/client/components.d.ts +0 -22
  51. package/lib/types/client/drawer.d.ts +0 -17
  52. package/lib/types/client/glyph.d.ts +0 -12
  53. package/lib/types/client/model.d.ts +0 -78
  54. package/lib/types/client/panel.d.ts +0 -10
  55. package/lib/types/client/settings.d.ts +0 -15
  56. package/lib/types/client/styles.d.ts +0 -10
  57. package/lib/types/client/ui.d.ts +0 -17
  58. package/lib/types/client/utils.d.ts +0 -22
  59. package/lib/types/host/actions.d.ts +0 -34
  60. package/lib/types/host/conversation.d.ts +0 -25
  61. package/lib/types/host/http.d.ts +0 -16
  62. package/lib/types/host/materials.d.ts +0 -32
  63. package/lib/types/host/persistence.d.ts +0 -31
  64. package/lib/types/host/routes.d.ts +0 -11
  65. package/lib/types/host/store.d.ts +0 -65
  66. 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: { kind: 'group' | 'session', id: string, value: string } | null
23
- setRenameDraft: (val: { kind: 'group' | 'session', id: string, value: string } | null) => void
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 sess = sid ? sessById(snap, sid) : null
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={() => { setGid(g.id); setSid(s.id); setPartsSel(null); setConfirmDel(null); setRenameDraft(null) }}
158
+ onClick={() => { pickSession(g.id, s.id) }}
99
159
  onKeyDown={(e) => {
100
160
  if (e.key === 'Enter' || e.key === ' ') {
101
161
  e.preventDefault()
102
- setGid(g.id); setSid(s.id); setPartsSel(null); setConfirmDel(null); setRenameDraft(null)
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
- <span className="dsgc-nodeops">
134
- <button
135
- className="dsgc-opbtn"
136
- title="重命名会话"
137
- aria-label="重命名会话"
138
- onClick={(e) => { e.stopPropagation(); setRenameDraft({ kind: 'session', id: s.id, value: s.name }) }}
139
- >
140
- {Icon(P.IconEditOutline16, 14)}
141
- </button>
142
- <button
143
- className={'dsgc-opbtn' + (isConfirm ? ' danger' : '')}
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={() => { setGid(g.id); setPartsSel(null); setConfirmDel(null); setRenameDraft(null) }}
211
+ onClick={() => { pickGroup(g.id) }}
173
212
  onKeyDown={(e) => {
174
213
  if (e.key === 'Enter' || e.key === ' ') {
175
214
  e.preventDefault()
176
- setGid(g.id); setPartsSel(null); setConfirmDel(null); setRenameDraft(null)
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
- <span className="dsgc-nodeops">
210
- <button
211
- className="dsgc-opbtn"
212
- title="重命名群组"
213
- aria-label="重命名群组"
214
- onClick={(e) => { e.stopPropagation(); setRenameDraft({ kind: 'group', id: g.id, value: g.name }) }}
215
- >
216
- {Icon(P.IconEditOutline16, 14)}
217
- </button>
218
- <button
219
- className={'dsgc-opbtn' + (isConfirmGroup ? ' danger' : '')}
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
- 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
  )
@@ -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 { ToolCallView } from '../lib/model.ts'
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: ToolCallView }): ReactNode {
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 insertChip = useCallback((role: SnapshotRole): void => {
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
- execCommand('insertHTML', chipHtml(role).replace('class="dsgc-chipin"', 'class="dsgc-chipin" data-new=""'))
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
- const el = inputRef.current
181
- const sel = window.getSelection()
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
- const [wsDraft, setWsDraft] = useState<string | null>(null)
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,
@@ -3,7 +3,7 @@
3
3
  * @module dsh-group-chat/client/model
4
4
  */
5
5
 
6
- import type { RoleRecord, Snapshot, ToolCallRecord } from '../../core/types.ts'
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
 
@@ -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 body = raw.replace(/```(?:json)?/g, '')
186
- const l = body.indexOf('{')
187
- const r = body.lastIndexOf('}')
188
- if (l < 0 || r <= l) return null
189
- try {
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
  /** 本批无用户消息时,新的已定/否决降为未决。 */
@@ -111,17 +111,7 @@ export function repairFailedMessage(
111
111
  return changed
112
112
  }
113
113
 
114
- function pickJsonBlob(text: string): Record<string, unknown> | null {
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 = pickJsonBlob(source)
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
+ }