@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
package/src/core/types.ts CHANGED
@@ -202,31 +202,39 @@ export interface FileSearchResult {
202
202
  error?: string
203
203
  }
204
204
 
205
- /** 发到客户端的全量快照(wire 形态)。 */
205
+ /** 快照内的会话行(wire 形态:不含 hydrate 用的固定标记与折入水位)。 */
206
+ export type SnapshotSession = Omit<SessionRecord, 'namePinned' | 'topicPinned' | 'constraintsUpToSeq'>
207
+ /** 快照内的消息行(wire 形态:思考全文与摘要不外发)。 */
208
+ export type SnapshotMessage = Omit<MessageRecord, 'reasoningFull' | 'thinkingSummary'>
209
+
210
+ /** 发到客户端的全量快照(wire 形态;各表行由领域记录派生,字段增删由编译器同步)。 */
206
211
  export interface Snapshot {
207
212
  revision: number
208
- run: { running: boolean, sessionId: string | null, currentRoleId: string | null, partial: string, partialReasoning: string, pendingConfirm: PendingConfirm | null, finished: RunFinished | null, replaceMessageId: string | null }
213
+ run: Omit<RunState, 'stopping' | 'queue' | 'confirmSignal' | 'childProc'>
209
214
  lastCreated: LastCreated | null
210
- groups: { id: string, name: string, workspaceDir: string, permissionTier: PermissionTier, roleIds: string[], sessionIds: string[] }[]
211
- sessions: { id: string, groupId: string, name: string, topic: string, constraints?: SessionConstraint[], messageIds: string[], createdAt: number }[]
212
- roles: { id: string, groupId: string, name: string, color?: string, persona: string, provider: string, model: string, temperature?: number, reasoningEffort?: string, enabled: boolean, thinking: boolean }[]
213
- messages: { id: string, sessionId: string, seq: number, speaker: string, text: string, reasoning?: string, model?: string, error?: boolean, failedRoleId?: string, toolCalls?: ToolCallRecord[], ts: number }[]
215
+ groups: GroupRecord[]
216
+ sessions: SnapshotSession[]
217
+ roles: RoleRecord[]
218
+ messages: SnapshotMessage[]
214
219
  error?: string
215
220
  }
216
221
 
217
- /** mutate 动作的参数形态(op 分发见 host/service)。 */
218
- export interface MutateArgs {
219
- op: string
220
- groupId?: string
221
- sessionId?: string
222
- roleId?: string
223
- role?: Partial<RoleRecord>
224
- name?: string
225
- topic?: string
226
- path?: string
227
- tier?: string
228
- enabled?: boolean
229
- }
222
+ /** mutate 动作的参数形态(判别联合:按 op 收窄各分支字段;wire 上字段可缺,消费方各自守卫)。 */
223
+ export type MutateArgs =
224
+ | { op: 'createGroup', name?: string }
225
+ | { op: 'renameGroup', groupId: string, name?: string }
226
+ | { op: 'deleteGroup', groupId: string }
227
+ | { op: 'createSession', groupId: string, name?: string }
228
+ | { op: 'renameSession', sessionId: string, name?: string }
229
+ | { op: 'deleteSession', sessionId: string }
230
+ | { op: 'setTopic', sessionId: string, topic?: string }
231
+ | { op: 'upsertRole', groupId: string, role?: Partial<RoleRecord> }
232
+ | { op: 'deleteRole', roleId: string }
233
+ | { op: 'setRoleEnabled', roleId: string, enabled?: boolean }
234
+ | { op: 'setWorkspaceDir', groupId: string, path?: string }
235
+ | { op: 'setPermissionTier', groupId: string, tier?: string }
236
+ | { op: 'ackFinish' }
237
+ | { op: 'clearMessages', sessionId: string }
230
238
 
231
239
  /** send 动作的参数形态。 */
232
240
  export interface SendArgs {
@@ -36,7 +36,8 @@ export function createActions(core: HostState, deps: {
36
36
  const { touch, snapshot, schedulePersist, dropDirty, appendMessage, runLoop, wakeConfirm, killChild, browse, fileSearch, disposeFileSearch } = deps
37
37
 
38
38
  const mutate = (args: MutateArgs): Snapshot => {
39
- const op = args && args.op
39
+ // 直接属性别名保持判别联合收窄(调用方 handleAction 已保证 body 为对象)
40
+ const op = args.op
40
41
  if (op === 'createGroup') {
41
42
  const g: GroupRecord = { id: core.nid('grp'), name: String(args.name || '').trim() || '群组 ' + (groups.size + 1), workspaceDir: '', permissionTier: 'view_only', roleIds: [], sessionIds: [] }
42
43
  groups.set(g.id, g)
@@ -46,14 +47,14 @@ export function createActions(core: HostState, deps: {
46
47
  schedulePersist({ ledger: true, session: sess.id })
47
48
  touch()
48
49
  } else if (op === 'renameGroup') {
49
- const g = groups.get(args.groupId!)
50
+ const g = groups.get(args.groupId)
50
51
  if (g && String(args.name || '').trim()) {
51
52
  g.name = String(args.name).trim()
52
53
  schedulePersist({ ledger: true })
53
54
  touch()
54
55
  }
55
56
  } else if (op === 'deleteGroup') {
56
- const g = groups.get(args.groupId!)
57
+ const g = groups.get(args.groupId)
57
58
  if (!g) return { ...snapshot(), error: '群组不存在' }
58
59
  if (groups.size <= 1) return { ...snapshot(), error: '至少保留一个群组' }
59
60
  if (run.running) {
@@ -80,7 +81,7 @@ export function createActions(core: HostState, deps: {
80
81
  schedulePersist({ ledger: true })
81
82
  touch()
82
83
  } else if (op === 'createSession') {
83
- const g = groups.get(args.groupId!)
84
+ const g = groups.get(args.groupId)
84
85
  if (!g) return { ...snapshot(), error: '群组不存在' }
85
86
  const sess = core.newSession(g.id, String(args.name || '').trim() || undefined)
86
87
  g.sessionIds.push(sess.id)
@@ -88,7 +89,7 @@ export function createActions(core: HostState, deps: {
88
89
  schedulePersist({ ledger: true, session: sess.id })
89
90
  touch()
90
91
  } else if (op === 'renameSession') {
91
- const sess = sessions.get(args.sessionId!)
92
+ const sess = sessions.get(args.sessionId)
92
93
  if (sess && String(args.name || '').trim()) {
93
94
  sess.name = String(args.name).trim()
94
95
  sess.namePinned = true // 手动编辑 = 隐式固定:自动命名此后跳过名称
@@ -96,7 +97,7 @@ export function createActions(core: HostState, deps: {
96
97
  touch()
97
98
  }
98
99
  } else if (op === 'deleteSession') {
99
- const sess = sessions.get(args.sessionId!)
100
+ const sess = sessions.get(args.sessionId)
100
101
  if (!sess) return { ...snapshot(), error: '会话不存在' }
101
102
  const g = groups.get(sess.groupId)
102
103
  if (g && g.sessionIds.length <= 1) return { ...snapshot(), error: '每个群组至少保留一个会话' }
@@ -115,7 +116,7 @@ export function createActions(core: HostState, deps: {
115
116
  schedulePersist({ ledger: true })
116
117
  touch()
117
118
  } else if (op === 'setTopic') {
118
- const sess = sessions.get(args.sessionId!)
119
+ const sess = sessions.get(args.sessionId)
119
120
  if (sess) {
120
121
  sess.topic = String(args.topic || '')
121
122
  sess.topicPinned = true // 手动编辑 = 隐式固定:自动整理此后跳过主题
@@ -123,7 +124,7 @@ export function createActions(core: HostState, deps: {
123
124
  touch()
124
125
  }
125
126
  } else if (op === 'upsertRole') {
126
- const g = groups.get(args.groupId!)
127
+ const g = groups.get(args.groupId)
127
128
  const r = args.role || {}
128
129
  if (!g) return { ...snapshot(), error: '群组不存在' }
129
130
  if (!r.name || !String(r.name).trim()) return { ...snapshot(), error: '角色名称不能为空' }
@@ -161,7 +162,7 @@ export function createActions(core: HostState, deps: {
161
162
  touch()
162
163
  }
163
164
  } else if (op === 'deleteRole') {
164
- const r = roles.get(args.roleId!)
165
+ const r = roles.get(args.roleId)
165
166
  if (r) {
166
167
  const gid = r.groupId
167
168
  const g = groups.get(gid)
@@ -171,14 +172,14 @@ export function createActions(core: HostState, deps: {
171
172
  touch()
172
173
  }
173
174
  } else if (op === 'setRoleEnabled') {
174
- const r = roles.get(args.roleId!)
175
+ const r = roles.get(args.roleId)
175
176
  if (r) {
176
177
  r.enabled = !!args.enabled
177
178
  schedulePersist({ roles: r.groupId })
178
179
  touch()
179
180
  }
180
181
  } else if (op === 'setWorkspaceDir') {
181
- const g = groups.get(args.groupId!)
182
+ const g = groups.get(args.groupId)
182
183
  if (g) {
183
184
  g.workspaceDir = String(args.path || '').trim()
184
185
  schedulePersist({ workspace: g.id })
@@ -187,7 +188,7 @@ export function createActions(core: HostState, deps: {
187
188
  } else if (op === 'setPermissionTier') {
188
189
  const tier = asPermissionTier(args.tier)
189
190
  if (!tier) return { ...snapshot(), error: '未知权限档位' }
190
- const g = groups.get(args.groupId!)
191
+ const g = groups.get(args.groupId)
191
192
  if (g) {
192
193
  g.permissionTier = tier
193
194
  // 降到仅可查看时若该群挂着待确认命令,自动拒绝(安全侧倾斜);
@@ -206,7 +207,7 @@ export function createActions(core: HostState, deps: {
206
207
  touch()
207
208
  }
208
209
  } else if (op === 'clearMessages') {
209
- const sess = sessions.get(args.sessionId!)
210
+ const sess = sessions.get(args.sessionId)
210
211
  if (sess) {
211
212
  if (run.running && run.sessionId === sess.id) return { ...snapshot(), error: '对话进行中,无法清空' }
212
213
  for (const mid of sess.messageIds) messages.delete(mid)
@@ -6,12 +6,13 @@
6
6
 
7
7
  import { realpathSync } from 'node:fs'
8
8
  import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
9
- import { constraintBlock, formatTranscriptLine, prefixIds, speakerLabel, squeezedMessages, tempTranscript, WINDOW_SIZE } from '../../core/constraints.ts'
9
+ import { constraintBlock, formatTranscriptLine, prefixIds, squeezedMessages, tempTranscript, WINDOW_SIZE } from '../../core/constraints.ts'
10
10
  import { unwrapSpeakFailure } from '../../core/errors.ts'
11
11
  import { TOOL_FLOOR_COST_CHARS, TOOL_REPEAT_LIMIT, TOOL_RESULTS_TOTAL_MAX, TRANSCRIPT_TOOL_SUMMARY } from '../../core/tools.ts'
12
12
  import type { GroupRecord, MessageRecord, RoleRecord, SessionRecord, SpeakResult, ToolCallRecord } from '../../core/types.ts'
13
13
  import { asEffort } from '../../core/types.ts'
14
14
  import type { HostState } from '../state.ts'
15
+ import { speakerNameOf } from './defaults.ts'
15
16
  import { createFold } from './fold.ts'
16
17
  import { createRetitle } from './retitle.ts'
17
18
  import type { Materials } from '../materials/index.ts'
@@ -44,7 +45,6 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
44
45
  // 标题/主题自动整理 + 窗口外约束折叠(finally 之后并列后台跑,不占用 run)
45
46
  const retitle = createRetitle(core, { touch, schedulePersist })
46
47
  const fold = createFold(core, { touch, schedulePersist })
47
- const nameOf = (speaker: string): string => speakerLabel(speaker, (roles.get(speaker) || { name: undefined }).name)
48
48
 
49
49
  const appendMessage = (sess: SessionRecord, speaker: string, text: string, extra?: Partial<MessageRecord>): MessageRecord => {
50
50
  const msg: MessageRecord = { id: core.nid('msg'), sessionId: sess.id, seq: sess.messageIds.length + 1, speaker, text, reasoning: undefined, model: undefined, error: undefined, ts: Date.now() }
@@ -64,57 +64,50 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
64
64
  return msg
65
65
  }
66
66
 
67
+ /** 原地覆盖既有消息(重试槽位);目标不存在或不属于本会话时返回 false,由调用方走追加。 */
68
+ const overwriteMessage = (sess: SessionRecord, replaceId: string | undefined, patch: Partial<MessageRecord>): boolean => {
69
+ if (!replaceId) return false
70
+ const existing = messages.get(replaceId)
71
+ if (!existing || existing.sessionId !== sess.id) return false
72
+ Object.assign(existing, patch, { ts: Date.now() })
73
+ touch()
74
+ schedulePersist({ session: sess.id })
75
+ return true
76
+ }
77
+
67
78
  /** 把失败回合写成该角色的消息(speaker = 角色 id),不再用系统胶囊顶替。 */
68
79
  const writeFailure = (sess: SessionRecord, role: RoleRecord, err: unknown, replaceId?: string): void => {
69
80
  const raw = unwrapSpeakFailure(String((err && (err as Error).message) || err))
70
- const extra: Partial<MessageRecord> = {
71
- error: true,
72
- failedRoleId: role.id,
73
- model: role.provider + ' / ' + role.model,
74
- }
75
- if (replaceId) {
76
- const existing = messages.get(replaceId)
77
- if (existing && existing.sessionId === sess.id) {
78
- existing.speaker = role.id
79
- existing.text = raw
80
- existing.error = true
81
- existing.failedRoleId = role.id
82
- existing.model = extra.model
83
- existing.reasoning = undefined
84
- existing.reasoningFull = undefined
85
- existing.thinkingSummary = undefined
86
- existing.toolCalls = undefined
87
- existing.ts = Date.now()
88
- touch()
89
- schedulePersist({ session: sess.id })
90
- return
91
- }
92
- }
81
+ const model = role.provider + ' / ' + role.model
82
+ const extra: Partial<MessageRecord> = { error: true, failedRoleId: role.id, model }
83
+ if (overwriteMessage(sess, replaceId, {
84
+ speaker: role.id,
85
+ text: raw,
86
+ ...extra,
87
+ reasoning: undefined,
88
+ reasoningFull: undefined,
89
+ thinkingSummary: undefined,
90
+ toolCalls: undefined,
91
+ })) return
93
92
  appendMessage(sess, role.id, raw, extra)
94
93
  }
95
94
 
96
95
  /** 成功发言写入:replaceId 存在则原地覆盖失败卡。 */
97
96
  const writeSuccess = (sess: SessionRecord, role: RoleRecord, out: SpeakResult, replaceId?: string): void => {
98
- const extra: Partial<MessageRecord> = { model: role.provider + ' / ' + role.model, reasoning: out.reasoning, toolCalls: out.toolCalls }
99
- if (replaceId) {
100
- const existing = messages.get(replaceId)
101
- if (existing && existing.sessionId === sess.id) {
102
- existing.speaker = role.id
103
- existing.text = out.text
104
- existing.error = undefined
105
- existing.failedRoleId = undefined
106
- existing.model = extra.model
107
- existing.reasoning = out.reasoning
108
- existing.reasoningFull = undefined
109
- existing.thinkingSummary = undefined
110
- existing.toolCalls = Array.isArray(out.toolCalls) && out.toolCalls.length > 0 ? out.toolCalls : undefined
111
- existing.ts = Date.now()
112
- touch()
113
- schedulePersist({ session: sess.id })
114
- return
115
- }
116
- }
117
- appendMessage(sess, role.id, out.text, extra)
97
+ const model = role.provider + ' / ' + role.model
98
+ const toolCalls = Array.isArray(out.toolCalls) && out.toolCalls.length > 0 ? out.toolCalls : undefined
99
+ if (overwriteMessage(sess, replaceId, {
100
+ speaker: role.id,
101
+ text: out.text,
102
+ error: undefined,
103
+ failedRoleId: undefined,
104
+ model,
105
+ reasoning: out.reasoning,
106
+ reasoningFull: undefined,
107
+ thinkingSummary: undefined,
108
+ toolCalls,
109
+ })) return
110
+ appendMessage(sess, role.id, out.text, { model, reasoning: out.reasoning, toolCalls: out.toolCalls })
118
111
  }
119
112
 
120
113
  /** 群聊记录 → 角色上下文块(最近 40 条 + 未折入临时原文)。失败卡不进上下文。重试截到该条之前。 */
@@ -123,10 +116,10 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
123
116
  for (const mid of prefixIds(sess.messageIds, skipId).slice(-WINDOW_SIZE)) {
124
117
  const m = messages.get(mid)
125
118
  if (!m || m.id === skipId || m.error) continue
126
- out.push(formatTranscriptLine(m, nameOf(m.speaker)))
119
+ out.push(formatTranscriptLine(m, speakerNameOf(roles, m.speaker)))
127
120
  }
128
121
  const live = out.join('\n\n')
129
- const temp = tempTranscript(squeezedMessages(messages, sess, skipId), (m) => nameOf(m.speaker))
122
+ const temp = tempTranscript(squeezedMessages(messages, sess, skipId), (m) => speakerNameOf(roles, m.speaker))
130
123
  if (!temp) return live
131
124
  if (!live) return temp
132
125
  return temp + '\n\n' + live
@@ -297,7 +290,10 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
297
290
  msgs.push({ id: ('g' + core.revision + '-a' + msgs.length) as Message['id'], role: 'assistant', content: assistantContent, source: { kind: 'model', provider: role.provider, model: role.model } })
298
291
  for (const tc of round.tcs) {
299
292
  if (run.stopping) break
300
- const res = await tools.executeTool(g, wsRoot!, tc)
293
+ // wsRoot null toolSchemas 必为空、不会产生 tool-call;防御性跳出,
294
+ // 不把空根传进沙箱
295
+ if (wsRoot === null) break
296
+ const res = await tools.executeTool(g, wsRoot, tc)
301
297
  const output = String(res.output || '')
302
298
  toolCalls.push({ tool: tc.name, args: res.args, status: res.status, output, durationMs: res.durationMs })
303
299
  budgetUsed += Math.max(output.length, TOOL_FLOOR_COST_CHARS)
@@ -326,12 +322,15 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
326
322
  }
327
323
 
328
324
  const runLoop = async (sess: SessionRecord, opts?: { replaceMessageId?: string }): Promise<void> => {
329
- const g = groups.get(sess.groupId)
325
+ const g = groups.get(sess.groupId) ?? null
330
326
  const startCount = sess.messageIds.length
331
327
  let failed = false // 发言失败 → 会话列表「已出错」标记
332
328
  let replaceId = opts && opts.replaceMessageId
333
329
  run.replaceMessageId = replaceId || null
334
330
  try {
331
+ // 群组在 run 期间不可删(send 已校验 + deleteGroup 拦截运行中群组);
332
+ // 防御性早退——置空让 finally 走统一复位路径
333
+ if (!g) return
335
334
  while (run.queue.length > 0 && !run.stopping) {
336
335
  const roleId = run.queue.shift()!
337
336
  const role = roles.get(roleId)
@@ -343,7 +342,7 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
343
342
  const targetId = replaceId
344
343
  replaceId = undefined
345
344
  try {
346
- const out = await speak(g!, sess, role, targetId)
345
+ const out = await speak(g, sess, role, targetId)
347
346
  // 停止后不落部分消息(已有「已停止本次对话」系统消息承接)
348
347
  if (!run.stopping && (out.text || (Array.isArray(out.toolCalls) && out.toolCalls.length > 0))) {
349
348
  writeSuccess(sess, role, out, targetId)
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 引擎共享小件:DSH 默认模型读取 + 发言人展示名(retitle / fold / conversation 共用)。
3
+ * @module dsh-group-chat/host/engine/defaults
4
+ */
5
+
6
+ import { speakerLabel } from '../../core/constraints.ts'
7
+ import type { RoleRecord } from '../../core/types.ts'
8
+ import type { HostState } from '../state.ts'
9
+
10
+ /** DSH 默认模型(agentDefaultModel 服务缺位或未配置时返回 null,调用方静默跳过)。 */
11
+ export const defaultModel = (core: HostState): { provider: string, model: string } | null => {
12
+ try {
13
+ // cordis 语义:未 inject 的 ctx 属性访问会抛错("cannot get property without
14
+ // inject"),`?.` 接不住——可选消费必须走 reflect.get(读全局注册表,缺位返回
15
+ // undefined)。直接属性访问曾致 retitle 被静默跳过(标题/主题从不生成)。
16
+ const svc = core.ctx.reflect.get('agentDefaultModel')
17
+ const sel = svc ? svc.currentSelection() : null
18
+ return sel && typeof sel.provider === 'string' && typeof sel.model === 'string' && sel.provider && sel.model
19
+ ? { provider: sel.provider, model: sel.model }
20
+ : null
21
+ } catch {
22
+ return null
23
+ }
24
+ }
25
+
26
+ /** 发言人展示名(roles 表内查角色名;user/system 固定文案,见 speakerLabel)。 */
27
+ export const speakerNameOf = (roles: Map<string, RoleRecord>, speaker: string): string =>
28
+ speakerLabel(speaker, roles.get(speaker)?.name)
@@ -13,24 +13,11 @@ import {
13
13
  parseConstraints,
14
14
  squeezedMaxSeq,
15
15
  squeezedMessages,
16
- speakerLabel,
17
16
  takeFoldBatch,
18
17
  } from '../../core/constraints.ts'
19
18
  import type { SessionRecord } from '../../core/types.ts'
20
19
  import type { HostState } from '../state.ts'
21
-
22
- /** DSH 默认模型(与 retitle 同一读取面;缺位返回 null)。 */
23
- const defaultModel = (core: HostState): { provider: string, model: string } | null => {
24
- try {
25
- const svc = core.ctx.reflect.get('agentDefaultModel')
26
- const sel = svc ? svc.currentSelection() : null
27
- return sel && typeof sel.provider === 'string' && typeof sel.model === 'string' && sel.provider && sel.model
28
- ? { provider: sel.provider, model: sel.model }
29
- : null
30
- } catch {
31
- return null
32
- }
33
- }
20
+ import { defaultModel, speakerNameOf } from './defaults.ts'
34
21
 
35
22
  /**
36
23
  * 每轮 send 结束后折叠窗口外约束:fire-and-forget、不产生消息、静默失败。
@@ -41,13 +28,11 @@ export function createFold(core: HostState, deps: { touch: () => void, scheduleP
41
28
  const { touch, schedulePersist } = deps
42
29
  const folding = new Set<string>()
43
30
 
44
- const nameOf = (speaker: string): string => speakerLabel(speaker, (roles.get(speaker) || { name: undefined }).name)
45
-
46
31
  return async (sess: SessionRecord): Promise<void> => {
47
32
  if (folding.has(sess.id)) return
48
33
  const squeezed = squeezedMessages(messages, sess)
49
34
  if (!squeezed.length) return
50
- const input = takeFoldBatch(squeezed, (m) => nameOf(m.speaker))
35
+ const input = takeFoldBatch(squeezed, (m) => speakerNameOf(roles, m.speaker))
51
36
  const watermark = squeezedMaxSeq(input.consumed)
52
37
  if (watermark <= 0) return
53
38
 
@@ -6,55 +6,33 @@
6
6
  */
7
7
 
8
8
  import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
9
+ import { looseJson } from '../../core/json.ts'
9
10
  import type { SessionRecord } from '../../core/types.ts'
10
11
  import { DEFAULT_SESSION_NAME, type HostState } from '../state.ts'
12
+ import { defaultModel, speakerNameOf } from './defaults.ts'
11
13
 
12
14
  /** 仍是新建占位名(含改名之前的「会话 N」存量),自动标题尚未落地。 */
13
15
  const isPlaceholderName = (name: string): boolean =>
14
16
  name === DEFAULT_SESSION_NAME || /^会话 \d+$/.test(name)
15
17
 
16
- /** DSH 默认模型(agentDefaultModel 服务缺位或未配置时返回 null,调用方静默跳过)。 */
17
- const defaultModel = (core: HostState): { provider: string, model: string } | null => {
18
- try {
19
- // cordis 语义:未 inject 的 ctx 属性访问会抛错("cannot get property without
20
- // inject"),`?.` 接不住——可选消费必须走 reflect.get(读全局注册表,缺位返回
21
- // undefined)。直接属性访问曾致 retitle 被静默跳过(标题/主题从不生成)。
22
- const svc = core.ctx.reflect.get('agentDefaultModel')
23
- const sel = svc ? svc.currentSelection() : null
24
- return sel && typeof sel.provider === 'string' && typeof sel.model === 'string' && sel.provider && sel.model
25
- ? { provider: sel.provider, model: sel.model }
26
- : null
27
- } catch {
28
- return null
29
- }
30
- }
31
-
32
18
  /** 命名输入:最近 40 条非系统消息的紧凑转写(每条 500 字符封顶,命名不需要全文)。 */
33
19
  const titleTranscript = (core: HostState, sess: SessionRecord): string => {
34
20
  const out: string[] = []
35
21
  for (const mid of sess.messageIds.slice(-40)) {
36
22
  const m = core.messages.get(mid)
37
23
  if (!m || m.speaker === 'system' || m.error || !m.text) continue
38
- const name = m.speaker === 'user' ? '用户' : (core.roles.get(m.speaker) || { name: undefined }).name || '成员'
39
- out.push('【' + name + '】' + m.text.replace(/\s+/g, ' ').slice(0, 500))
24
+ out.push('' + speakerNameOf(core.roles, m.speaker) + '】' + m.text.replace(/\s+/g, ' ').slice(0, 500))
40
25
  }
41
26
  return out.join('\n')
42
27
  }
43
28
 
44
- /** 宽容解析模型输出:剥代码围栏 → 取首个 { 至末个 } 的 JSON → 校验并封顶字段。 */
29
+ /** 宽容解析模型输出:剥代码围栏 → looseJson → 校验并封顶字段。 */
45
30
  const parseRetitle = (raw: string): { name?: string, topic?: string } => {
46
- const body = raw.replace(/```(?:json)?/g, '')
47
- const l = body.indexOf('{')
48
- const r = body.lastIndexOf('}')
49
- if (l < 0 || r <= l) return {}
50
- try {
51
- const o = JSON.parse(body.slice(l, r + 1)) as { name?: unknown, topic?: unknown }
52
- const name = typeof o.name === 'string' ? o.name.trim() : ''
53
- const topic = typeof o.topic === 'string' ? o.topic.trim() : ''
54
- return { name: name ? name.slice(0, 24) : undefined, topic: topic ? topic.slice(0, 120) : undefined }
55
- } catch {
56
- return {}
57
- }
31
+ const o = looseJson(raw.replace(/```(?:json)?/g, ''))
32
+ if (!o) return {}
33
+ const name = typeof o.name === 'string' ? o.name.trim() : ''
34
+ const topic = typeof o.topic === 'string' ? o.topic.trim() : ''
35
+ return { name: name ? name.slice(0, 24) : undefined, topic: topic ? topic.slice(0, 120) : undefined }
58
36
  }
59
37
 
60
38
  /**
@@ -64,6 +64,8 @@ export function createMaterials(core: HostState): Materials {
64
64
 
65
65
  /** 逐候选 stat,返回第一个存在的目标;都不存在时返回首候选与全部尝试。 */
66
66
  const resolveMaterialTarget = async (raw: unknown) => {
67
+ // candidatePaths 构造上恒返回非空候选数组('' → [HOME],其余分支均单值或
68
+ // 非空 map),first 必在;! 仅为取首候选,无空指针风险
67
69
  const candidates = await candidatePaths(raw)
68
70
  let first: { target: import('@deepseek-ai/dsh-fs').FsTarget, path: string } | undefined
69
71
  for (const c of candidates) {
@@ -9,7 +9,7 @@ import { spawn, type ChildProcess } from 'node:child_process'
9
9
  import { closeSync, openSync, readSync, readdirSync, realpathSync, statSync } from 'node:fs'
10
10
  import { isAbsolute, join, sep } from 'node:path'
11
11
  import { CMD_CAPTURE_MAX_BYTES, CMD_OUTPUT_MAX_CHARS, READ_FILE_MAX_BYTES, RUN_CMD_TIMEOUT_MS, TOOL_SCHEMAS } from '../../core/tools.ts'
12
- import type { GroupRecord, RunState, ToolExecution } from '../../core/types.ts'
12
+ import type { GroupRecord, PendingConfirm, ToolExecution } from '../../core/types.ts'
13
13
  import type { HostState } from '../state.ts'
14
14
 
15
15
  /** 工具面。 */
@@ -134,7 +134,7 @@ export function createTools(core: HostState, touch: () => void): Tools {
134
134
 
135
135
  /** run_command 确认闸门:置 pendingConfirm 后无限等待,confirmCommand/stop/dispose 唤醒。 */
136
136
  const requestConfirmation = (toolCallId: string, args: Record<string, unknown>): Promise<boolean> => new Promise((resolve) => {
137
- run.pendingConfirm = { toolCallId, tool: 'run_command', args: args as RunState['pendingConfirm'] extends null ? never : NonNullable<RunState['pendingConfirm']>['args'] }
137
+ run.pendingConfirm = { toolCallId, tool: 'run_command', args: args as PendingConfirm['args'] }
138
138
  run.confirmSignal = { resolve }
139
139
  touch()
140
140
  })
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { describe, expect, it } from 'vitest'
7
- import { activeAtToken, formatFileMention } from './file-mention-grammar.ts'
7
+ import { activeAtToken } from './file-mention-grammar.ts'
8
8
 
9
9
  describe('activeAtToken', () => {
10
10
  it('extracts plain @path at cursor', () => {
@@ -38,40 +38,3 @@ describe('activeAtToken', () => {
38
38
  expect(activeAtToken('\t@"test', 7)).toEqual({ prefix: '@"test', query: 'test', quoted: true })
39
39
  })
40
40
  })
41
-
42
- describe('formatFileMention', () => {
43
- it('formats plain file without whitespace', () => {
44
- expect(formatFileMention({ kind: 'file', path: 'README.md' }, false)).toBe('@README.md')
45
- expect(formatFileMention({ kind: 'file', path: 'src/index.ts' }, false)).toBe('@src/index.ts')
46
- })
47
-
48
- it('formats plain directory with trailing slash', () => {
49
- expect(formatFileMention({ kind: 'directory', path: 'src' }, false)).toBe('@src/')
50
- expect(formatFileMention({ kind: 'directory', path: 'lib/utils' }, false)).toBe('@lib/utils/')
51
- })
52
-
53
- it('quotes file with whitespace and closes quote', () => {
54
- expect(formatFileMention({ kind: 'file', path: 'My Document.txt' }, false)).toBe('@"My Document.txt"')
55
- expect(formatFileMention({ kind: 'file', path: 'src/my file.ts' }, false)).toBe('@"src/my file.ts"')
56
- })
57
-
58
- it('quotes directory with whitespace but keeps quote open', () => {
59
- expect(formatFileMention({ kind: 'directory', path: 'My Folder' }, false)).toBe('@"My Folder/')
60
- expect(formatFileMention({ kind: 'directory', path: 'src/my lib' }, false)).toBe('@"src/my lib/')
61
- })
62
-
63
- it('preserves quote even when unnecessary', () => {
64
- expect(formatFileMention({ kind: 'file', path: 'plain.txt' }, true)).toBe('@"plain.txt"')
65
- expect(formatFileMention({ kind: 'directory', path: 'lib' }, true)).toBe('@"lib/')
66
- })
67
-
68
- it('rejects paths with control characters', () => {
69
- expect(formatFileMention({ kind: 'file', path: 'file\x00.txt' }, false)).toBeUndefined()
70
- expect(formatFileMention({ kind: 'file', path: 'file\n.txt' }, false)).toBeUndefined()
71
- })
72
-
73
- it('rejects paths with embedded quotes', () => {
74
- expect(formatFileMention({ kind: 'file', path: 'file"name.txt' }, false)).toBeUndefined()
75
- expect(formatFileMention({ kind: 'directory', path: 'dir"name' }, false)).toBeUndefined()
76
- })
77
- })
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * Vendored @file token grammar from dsh-file-reference (browser-safe, zero Node API).
3
- *
3
+ *
4
4
  * Sourced from @deepseek-ai/dsh-file-reference/lib/types/grammar.js
5
5
  * Cannot import directly due to client bundle purity gate (dsh-client-bundle-purity).
6
- *
6
+ *
7
+ * 只保留输入侧的 @token 识别(activeAtToken);候选的文本化(formatFileMention)
8
+ * 未被本插件采用——目录钻入走「插入整颗芯片」语义,见 docs/DESIGN.md Composer 契约。
9
+ *
7
10
  * @module dsh-group-chat/shared
8
11
  */
9
12
 
@@ -16,22 +19,17 @@ export interface AtToken {
16
19
  quoted: boolean
17
20
  }
18
21
 
19
- export interface FileCandidate {
20
- kind: 'file' | 'directory'
21
- path: string
22
- }
23
-
24
22
  /**
25
23
  * Extract active @token at cursor position.
26
24
  * Matches @ at word boundary (line start or after whitespace).
27
- *
25
+ *
28
26
  * @param line - Current line text
29
27
  * @param cursor - Cursor offset in line
30
28
  * @returns Token or undefined if no active @token at cursor
31
29
  */
32
30
  export function activeAtToken(line: string, cursor: number): AtToken | undefined {
33
31
  const before = line.slice(0, cursor)
34
-
32
+
35
33
  // Try quoted @"... first (more specific)
36
34
  const quotedMatch = /(?:^|\s)@"([^"]*)$/.exec(before)
37
35
  if (quotedMatch) {
@@ -56,27 +54,3 @@ export function activeAtToken(line: string, cursor: number): AtToken | undefined
56
54
 
57
55
  return undefined
58
56
  }
59
-
60
- /**
61
- * Format file/directory candidate as @mention text.
62
- *
63
- * @param candidate - File or directory to format
64
- * @param preserveQuote - Keep quotes even when unnecessary (for drill-down continuity)
65
- * @returns Formatted @path or undefined for invalid paths
66
- */
67
- export function formatFileMention(candidate: FileCandidate, preserveQuote: boolean): string | undefined {
68
- const { path } = candidate
69
-
70
- // Reject control characters and embedded quotes
71
- if (/[\x00-\x1F"]/.test(path)) return undefined
72
-
73
- const needsQuote = /\s/.test(path)
74
- const useQuote = needsQuote || preserveQuote
75
-
76
- if (candidate.kind === 'file') {
77
- return useQuote ? `@"${path}"` : `@${path}`
78
- } else {
79
- // Directory: trailing slash, quote stays open for drill-down
80
- return useQuote ? `@"${path}/` : `@${path}/`
81
- }
82
- }
@@ -1,10 +0,0 @@
1
- /**
2
- * 聊天消息气泡(user / system / 角色发言)。
3
- * @module dsh-group-chat/client/Bubble
4
- */
5
- import type { ReactNode } from 'react';
6
- import { type ClientSnapshot } from './model.ts';
7
- export declare function Bubble(props: {
8
- snap: ClientSnapshot;
9
- m: ClientSnapshot['messages'][number];
10
- }): ReactNode;
@@ -1,12 +0,0 @@
1
- /**
2
- * 侧边栏入口图标。
3
- *
4
- * 整行命中层携带 “newSession” 类名:任务看板等 DOM 注入式面板用
5
- * [class*="newSession"] 识别“侧边栏导航点击”并自动收起自己,与点击
6
- * “新建会话”行为一致——类名不可哈希化。
7
- * @module dsh-group-chat/client/glyph
8
- */
9
- import type { ReactNode } from 'react';
10
- export declare function Glyph(props: {
11
- size?: number;
12
- }): ReactNode;