@roaming-ai/dsh-group-chat 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +93 -28
  2. package/lib/client.js +295 -326
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +234 -221
  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 +59 -64
  14. package/lib/types/host/engine/defaults.d.ts +13 -0
  15. package/lib/types/host/persistence/persistence.d.ts +2 -4
  16. package/lib/types/host/persistence/store.d.ts +5 -1
  17. package/lib/types/host/service.d.ts +2 -2
  18. package/lib/types/host/state.d.ts +1 -0
  19. package/lib/types/host/tools/tools.d.ts +2 -1
  20. package/lib/types/index.d.ts +3 -0
  21. package/lib/types/shared/file-mention-grammar.d.ts +3 -12
  22. package/package.json +4 -1
  23. package/src/client/GroupChatPanel.tsx +9 -56
  24. package/src/client/components/AsidePanel.tsx +40 -15
  25. package/src/client/components/ChatPanel.tsx +1 -1
  26. package/src/client/components/Composer.tsx +2 -2
  27. package/src/client/components/ConstraintList.tsx +1 -6
  28. package/src/client/components/MessageFlow.tsx +2 -1
  29. package/src/client/components/NavPanel.tsx +96 -78
  30. package/src/client/components/RoleDrawer.tsx +29 -7
  31. package/src/client/components/ToolRow.tsx +2 -2
  32. package/src/client/hooks/useComposer.ts +14 -53
  33. package/src/client/hooks/useGroupChatState.ts +2 -13
  34. package/src/client/lib/model.ts +1 -14
  35. package/src/client/lib/styles.ts +6 -2
  36. package/src/client/utils/utils.ts +6 -6
  37. package/src/core/constraints.ts +6 -12
  38. package/src/core/errors.ts +2 -12
  39. package/src/core/json.ts +17 -0
  40. package/src/core/types.ts +29 -20
  41. package/src/host/api/actions.ts +14 -13
  42. package/src/host/engine/conversation.ts +50 -51
  43. package/src/host/engine/defaults.ts +28 -0
  44. package/src/host/engine/fold.ts +2 -17
  45. package/src/host/engine/retitle.ts +9 -31
  46. package/src/host/materials/materials.ts +2 -0
  47. package/src/host/persistence/persistence.ts +57 -34
  48. package/src/host/persistence/store.ts +5 -1
  49. package/src/host/service.ts +4 -4
  50. package/src/host/state.ts +5 -2
  51. package/src/host/tools/tools.ts +42 -48
  52. package/src/index.ts +7 -5
  53. package/src/shared/file-mention-grammar.test.ts +1 -38
  54. package/src/shared/file-mention-grammar.ts +7 -33
  55. package/lib/types/client/Bubble.d.ts +0 -10
  56. package/lib/types/client/Glyph.d.ts +0 -12
  57. package/lib/types/client/GroupChatPanel.old.d.ts +0 -10
  58. package/lib/types/client/GroupChatSettingsSection.d.ts +0 -15
  59. package/lib/types/client/PermissionSelect.d.ts +0 -13
  60. package/lib/types/client/RoleDrawer.d.ts +0 -17
  61. package/lib/types/client/ThinkRow.d.ts +0 -9
  62. package/lib/types/client/ToolRow.d.ts +0 -9
  63. package/lib/types/client/api.d.ts +0 -8
  64. package/lib/types/client/components.d.ts +0 -22
  65. package/lib/types/client/drawer.d.ts +0 -17
  66. package/lib/types/client/glyph.d.ts +0 -12
  67. package/lib/types/client/model.d.ts +0 -78
  68. package/lib/types/client/panel.d.ts +0 -10
  69. package/lib/types/client/settings.d.ts +0 -15
  70. package/lib/types/client/styles.d.ts +0 -10
  71. package/lib/types/client/ui.d.ts +0 -17
  72. package/lib/types/client/utils.d.ts +0 -22
  73. package/lib/types/host/actions.d.ts +0 -34
  74. package/lib/types/host/conversation.d.ts +0 -25
  75. package/lib/types/host/http.d.ts +0 -16
  76. package/lib/types/host/materials.d.ts +0 -32
  77. package/lib/types/host/persistence.d.ts +0 -31
  78. package/lib/types/host/routes.d.ts +0 -11
  79. package/lib/types/host/store.d.ts +0 -65
  80. package/lib/types/host/tools.d.ts +0 -28
@@ -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
@@ -201,8 +201,12 @@ export const CSS = [
201
201
  /* @提及芯片(输入区内原子元素):色点 + 角色色淡底胶囊——弹层候选行同语言 */
202
202
  '.dsgc-chipin{display:inline-flex;align-items:center;gap:4px;background:color-mix(in srgb,var(--role-color,#888) 15%,transparent);border-radius:999px;padding:1px 7px 1px 5px;margin:0 1px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,inherit);white-space:nowrap;user-select:all}',
203
203
  '.dsgc-card .dsgc-sendrow{padding:0 12px 10px}',
204
- '.dsgc-stopbtn{background:var(--dsw-alias-state-error-primary,#e5484d);border-color:transparent;color:#fff;font-weight:600}',
205
- '.dsgc-stopbtn:hover:not(:disabled){filter:brightness(1.08);color:#fff}',
204
+ /* 停止/清空按钮:实心红(Button primitive outline 变体的 hover 规则
205
+ background:interactive-bg-hover 特异度 (0,3,0) 会盖掉 (0,1,0) 的红底,
206
+ 悬停时按钮变透明;加 button 类型选择器提为 (0,1,1)/(0,3,1),顺序无关
207
+ 地压过变体,hover 补回红底并经 brightness 提亮) */
208
+ 'button.dsgc-stopbtn{background:var(--dsw-alias-state-error-primary,#e5484d);border-color:transparent;color:#fff;font-weight:600}',
209
+ 'button.dsgc-stopbtn:hover:not(:disabled){background:var(--dsw-alias-state-error-primary,#e5484d);filter:brightness(1.08);color:#fff}',
206
210
  /* 标签固定在左;芯片在右侧独立换行,后续行与首行芯片左缘对齐(不折到「参与角色」下面)。
207
211
  单行 24px;角色过多时随内容增高,避免固定高度把后续行溢出叠到下方输入卡上 */
208
212
  '.dsgc-parts{display:flex;align-items:flex-start;gap:6px;min-height:24px}',
@@ -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
+ }
package/src/core/types.ts CHANGED
@@ -149,7 +149,8 @@ export interface RunState {
149
149
  queue: string[]
150
150
  pendingConfirm: PendingConfirm | null
151
151
  confirmSignal: { resolve: (allowed: boolean) => void } | null
152
- childProc: import('node:child_process').ChildProcess | null
152
+ /** 正在执行的 run_command 的中止句柄(stop/dispose 时 abort,执行器 kill 进程)。 */
153
+ commandAbort: AbortController | null
153
154
  /** 最近一次 run 的结束标记:会话列表「已完成/已出错」状态的数据源。 */
154
155
  finished: RunFinished | null
155
156
  /** 原地重试时被覆盖的失败消息 id;普通 send 为 null。 */
@@ -202,31 +203,39 @@ export interface FileSearchResult {
202
203
  error?: string
203
204
  }
204
205
 
205
- /** 发到客户端的全量快照(wire 形态)。 */
206
+ /** 快照内的会话行(wire 形态:不含 hydrate 用的固定标记与折入水位)。 */
207
+ export type SnapshotSession = Omit<SessionRecord, 'namePinned' | 'topicPinned' | 'constraintsUpToSeq'>
208
+ /** 快照内的消息行(wire 形态:思考全文与摘要不外发)。 */
209
+ export type SnapshotMessage = Omit<MessageRecord, 'reasoningFull' | 'thinkingSummary'>
210
+
211
+ /** 发到客户端的全量快照(wire 形态;各表行由领域记录派生,字段增删由编译器同步)。 */
206
212
  export interface Snapshot {
207
213
  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 }
214
+ run: Omit<RunState, 'stopping' | 'queue' | 'confirmSignal' | 'commandAbort'>
209
215
  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 }[]
216
+ groups: GroupRecord[]
217
+ sessions: SnapshotSession[]
218
+ roles: RoleRecord[]
219
+ messages: SnapshotMessage[]
214
220
  error?: string
215
221
  }
216
222
 
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
- }
223
+ /** mutate 动作的参数形态(判别联合:按 op 收窄各分支字段;wire 上字段可缺,消费方各自守卫)。 */
224
+ export type MutateArgs =
225
+ | { op: 'createGroup', name?: string }
226
+ | { op: 'renameGroup', groupId: string, name?: string }
227
+ | { op: 'deleteGroup', groupId: string }
228
+ | { op: 'createSession', groupId: string, name?: string }
229
+ | { op: 'renameSession', sessionId: string, name?: string }
230
+ | { op: 'deleteSession', sessionId: string }
231
+ | { op: 'setTopic', sessionId: string, topic?: string }
232
+ | { op: 'upsertRole', groupId: string, role?: Partial<RoleRecord> }
233
+ | { op: 'deleteRole', roleId: string }
234
+ | { op: 'setRoleEnabled', roleId: string, enabled?: boolean }
235
+ | { op: 'setWorkspaceDir', groupId: string, path?: string }
236
+ | { op: 'setPermissionTier', groupId: string, tier?: string }
237
+ | { op: 'ackFinish' }
238
+ | { op: 'clearMessages', sessionId: string }
230
239
 
231
240
  /** send 动作的参数形态。 */
232
241
  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)
@@ -380,7 +379,7 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
380
379
  run.queue = []
381
380
  run.pendingConfirm = null
382
381
  run.confirmSignal = null
383
- run.childProc = null
382
+ run.commandAbort = null
384
383
  run.stopping = false
385
384
  run.replaceMessageId = null
386
385
  touch()
@@ -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