@roaming-ai/dsh-group-chat 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +91 -27
  2. package/lib/client.js +1254 -598
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +573 -109
  5. package/lib/types/client/components/AsidePanel.d.ts +3 -19
  6. package/lib/types/client/components/Bubble.d.ts +6 -3
  7. package/lib/types/client/components/ChatPanel.d.ts +1 -0
  8. package/lib/types/client/components/ConstraintList.d.ts +11 -0
  9. package/lib/types/client/components/FailCard.d.ts +9 -0
  10. package/lib/types/client/components/Fold.d.ts +19 -0
  11. package/lib/types/client/components/HoverTip.d.ts +22 -0
  12. package/lib/types/client/components/MessageFlow.d.ts +1 -0
  13. package/lib/types/client/components/MsgActions.d.ts +14 -0
  14. package/lib/types/client/components/NavPanel.d.ts +9 -11
  15. package/lib/types/client/components/RoleDrawer.d.ts +3 -2
  16. package/lib/types/client/components/ToolRow.d.ts +2 -2
  17. package/lib/types/client/hooks/useComposer.d.ts +6 -1
  18. package/lib/types/client/hooks/useGroupChatState.d.ts +0 -16
  19. package/lib/types/client/lib/composer-draft.d.ts +17 -0
  20. package/lib/types/client/lib/model.d.ts +1 -7
  21. package/lib/types/client/utils/utils.d.ts +4 -4
  22. package/lib/types/core/constraints.d.ts +61 -0
  23. package/lib/types/core/errors.d.ts +54 -0
  24. package/lib/types/core/json.d.ts +6 -0
  25. package/lib/types/core/types.d.ts +76 -60
  26. package/lib/types/host/api/actions.d.ts +1 -1
  27. package/lib/types/host/engine/conversation.d.ts +5 -3
  28. package/lib/types/host/engine/defaults.d.ts +13 -0
  29. package/lib/types/host/engine/fold.d.ts +17 -0
  30. package/lib/types/host/engine/index.d.ts +1 -0
  31. package/lib/types/host/engine/retitle.d.ts +5 -5
  32. package/lib/types/host/service.d.ts +1 -1
  33. package/lib/types/host/state.d.ts +2 -0
  34. package/lib/types/index.d.ts +2 -0
  35. package/lib/types/shared/file-mention-grammar.d.ts +3 -12
  36. package/package.json +1 -1
  37. package/src/client/GroupChatPanel.tsx +25 -59
  38. package/src/client/components/AsidePanel.tsx +50 -23
  39. package/src/client/components/Bubble.tsx +45 -18
  40. package/src/client/components/ChatPanel.tsx +17 -12
  41. package/src/client/components/Composer.tsx +26 -20
  42. package/src/client/components/ConstraintList.tsx +64 -0
  43. package/src/client/components/FailCard.tsx +49 -0
  44. package/src/client/components/Fold.tsx +72 -0
  45. package/src/client/components/HoverTip.tsx +134 -0
  46. package/src/client/components/MessageFlow.tsx +78 -54
  47. package/src/client/components/MsgActions.tsx +85 -0
  48. package/src/client/components/NavPanel.tsx +102 -79
  49. package/src/client/components/RoleDrawer.tsx +29 -7
  50. package/src/client/components/ThinkRow.tsx +7 -3
  51. package/src/client/components/ToolRow.tsx +9 -5
  52. package/src/client/hooks/useComposer.ts +54 -56
  53. package/src/client/hooks/useGroupChatState.ts +7 -17
  54. package/src/client/lib/composer-draft.ts +47 -0
  55. package/src/client/lib/model.ts +1 -14
  56. package/src/client/lib/styles.ts +73 -12
  57. package/src/client/react-dom-shim.d.ts +2 -0
  58. package/src/client/utils/utils.ts +6 -6
  59. package/src/core/constraints.ts +204 -0
  60. package/src/core/errors.ts +174 -0
  61. package/src/core/json.ts +18 -0
  62. package/src/core/types.ts +52 -19
  63. package/src/host/api/actions.ts +49 -14
  64. package/src/host/broadcast.ts +3 -3
  65. package/src/host/engine/conversation.ts +104 -40
  66. package/src/host/engine/defaults.ts +28 -0
  67. package/src/host/engine/fold.ts +99 -0
  68. package/src/host/engine/index.ts +1 -0
  69. package/src/host/engine/retitle.ts +24 -41
  70. package/src/host/materials/materials.ts +2 -0
  71. package/src/host/persistence/persistence.ts +18 -1
  72. package/src/host/service.ts +1 -1
  73. package/src/host/state.ts +5 -4
  74. package/src/host/tools/tools.ts +2 -2
  75. package/src/index.ts +2 -0
  76. package/src/shared/file-mention-grammar.test.ts +1 -38
  77. package/src/shared/file-mention-grammar.ts +7 -33
  78. package/lib/types/client/Bubble.d.ts +0 -10
  79. package/lib/types/client/Glyph.d.ts +0 -12
  80. package/lib/types/client/GroupChatPanel.old.d.ts +0 -10
  81. package/lib/types/client/GroupChatSettingsSection.d.ts +0 -15
  82. package/lib/types/client/PermissionSelect.d.ts +0 -13
  83. package/lib/types/client/RoleDrawer.d.ts +0 -17
  84. package/lib/types/client/ThinkRow.d.ts +0 -9
  85. package/lib/types/client/ToolRow.d.ts +0 -9
  86. package/lib/types/client/api.d.ts +0 -8
  87. package/lib/types/client/components.d.ts +0 -22
  88. package/lib/types/client/drawer.d.ts +0 -17
  89. package/lib/types/client/glyph.d.ts +0 -12
  90. package/lib/types/client/model.d.ts +0 -78
  91. package/lib/types/client/panel.d.ts +0 -10
  92. package/lib/types/client/settings.d.ts +0 -15
  93. package/lib/types/client/styles.d.ts +0 -10
  94. package/lib/types/client/ui.d.ts +0 -17
  95. package/lib/types/client/utils.d.ts +0 -22
  96. package/lib/types/host/actions.d.ts +0 -34
  97. package/lib/types/host/conversation.d.ts +0 -25
  98. package/lib/types/host/http.d.ts +0 -16
  99. package/lib/types/host/materials.d.ts +0 -32
  100. package/lib/types/host/persistence.d.ts +0 -31
  101. package/lib/types/host/routes.d.ts +0 -11
  102. package/lib/types/host/store.d.ts +0 -65
  103. package/lib/types/host/tools.d.ts +0 -28
@@ -0,0 +1,174 @@
1
+ /**
2
+ * 角色发言失败:供应商原文 → 人话标题/原因(core 纯函数,无 React)。
3
+ * 原始 JSON 仍落盘在消息 text 里,UI 默认只展示短因,展开才看原文。
4
+ * @module dsh-group-chat/core/errors
5
+ */
6
+
7
+ export interface SpeakFailureView {
8
+ /** 一行标题,例如「额度已用尽」。 */
9
+ title: string
10
+ /** 可选短因:重置时间、HTTP 状态等。 */
11
+ detail?: string
12
+ /** 复制/排障用的供应商原文(已剥「模型输出异常终止: 」前缀)。 */
13
+ raw: string
14
+ }
15
+
16
+ const PREFIX = '模型输出异常终止: '
17
+ const LEGACY_ROLE = /^角色「([^」]+)」发言失败[::]\s*/
18
+
19
+ /** 剥旧系统胶囊与引擎包装前缀,保留供应商原文。 */
20
+ export function unwrapSpeakFailure(raw: string): string {
21
+ const text = String(raw || '').trim()
22
+ const legacy = parseLegacyRoleFailure(text)
23
+ const body = legacy ? legacy.rest : text
24
+ return body.startsWith(PREFIX) ? body.slice(PREFIX.length).trim() : body
25
+ }
26
+
27
+ /** 旧系统胶囊文案:角色「名」发言失败:原文。对不上则 null。 */
28
+ export function parseLegacyRoleFailure(text: string): { roleName: string, rest: string } | null {
29
+ const m = LEGACY_ROLE.exec(String(text || ''))
30
+ if (!m) return null
31
+ return { roleName: m[1], rest: String(text).slice(m[0].length) }
32
+ }
33
+
34
+ export interface RoleRef {
35
+ id: string
36
+ name: string
37
+ provider?: string
38
+ model?: string
39
+ }
40
+
41
+ export interface FailedMessageRef {
42
+ speaker: string
43
+ text: string
44
+ error?: boolean
45
+ failedRoleId?: string
46
+ }
47
+
48
+ /** 发言失败卡:error 标记,或旧系统胶囊文案。 */
49
+ export function isSpeakFailure(m: FailedMessageRef): boolean {
50
+ return !!m.error || !!parseLegacyRoleFailure(m.text)
51
+ }
52
+
53
+ /** 失败回合对应角色:failedRoleId / speaker / 旧文案里的角色名(恰好一名才命中)。 */
54
+ export function resolveFailedRole<T extends RoleRef>(m: FailedMessageRef, roles: T[]): T | null {
55
+ const byId = (id: string | undefined): T | null => {
56
+ if (!id) return null
57
+ for (const r of roles) if (r.id === id) return r
58
+ return null
59
+ }
60
+ const direct = byId(m.failedRoleId) || (m.speaker !== 'user' && m.speaker !== 'system' ? byId(m.speaker) : null)
61
+ if (direct) return direct
62
+ const parsed = parseLegacyRoleFailure(m.text)
63
+ if (!parsed) return null
64
+ const hits = roles.filter((r) => r.name === parsed.roleName)
65
+ return hits.length === 1 ? hits[0] : null
66
+ }
67
+
68
+ /**
69
+ * 把旧系统失败行挂到对应角色(恰好一名命中才迁)。
70
+ * 返回是否改写了记录。
71
+ */
72
+ export function repairFailedMessage(
73
+ m: { speaker: string, text: string, error?: boolean, failedRoleId?: string, model?: string },
74
+ roles: RoleRef[],
75
+ ): boolean {
76
+ if (!isSpeakFailure(m)) return false
77
+ let changed = false
78
+ if (!m.error) {
79
+ m.error = true
80
+ changed = true
81
+ }
82
+ if (!m.failedRoleId && m.speaker !== 'user' && m.speaker !== 'system') {
83
+ m.failedRoleId = m.speaker
84
+ changed = true
85
+ }
86
+ if (!m.failedRoleId && m.speaker === 'system') {
87
+ const parsed = parseLegacyRoleFailure(m.text)
88
+ if (parsed) {
89
+ const hits = roles.filter((r) => r.name === parsed.roleName)
90
+ if (hits.length === 1) {
91
+ const role = hits[0]
92
+ m.speaker = role.id
93
+ m.failedRoleId = role.id
94
+ m.text = unwrapSpeakFailure(parsed.rest)
95
+ if (!m.model && (role.provider || role.model)) m.model = (role.provider || '') + ' / ' + (role.model || '')
96
+ changed = true
97
+ }
98
+ }
99
+ } else if (m.failedRoleId && m.speaker === 'system') {
100
+ m.speaker = m.failedRoleId
101
+ changed = true
102
+ }
103
+ // 对不上角色时保留「角色「名」发言失败」原文,客户端才能再解析。
104
+ if (m.failedRoleId || (m.speaker !== 'user' && m.speaker !== 'system')) {
105
+ const unwrapped = unwrapSpeakFailure(m.text)
106
+ if (unwrapped !== m.text) {
107
+ m.text = unwrapped
108
+ changed = true
109
+ }
110
+ }
111
+ return changed
112
+ }
113
+
114
+ import { looseJson } from './json.ts'
115
+
116
+ function httpStatus(text: string): number | null {
117
+ const m = text.match(/\b([1-5]\d{2})\b/)
118
+ if (!m) return null
119
+ const n = Number(m[1])
120
+ return n >= 100 && n <= 599 ? n : null
121
+ }
122
+
123
+ function resetHint(message: string): string | undefined {
124
+ const m = message.match(/reset at ([^.]+\S)/i) || message.match(/将在\s*([^\s。]+)\s*重置/)
125
+ return m ? '将在 ' + m[1] + ' 重置' : undefined
126
+ }
127
+
128
+ /**
129
+ * 把供应商错误压成可扫描的标题 + 短因。
130
+ * 未知形态回退为「发言失败」,原文仍可展开。
131
+ */
132
+ export function classifySpeakFailure(raw: string): SpeakFailureView {
133
+ const source = unwrapSpeakFailure(raw)
134
+ const blob = looseJson(source)
135
+ const code = blob && typeof blob.code === 'string' ? blob.code : ''
136
+ const type = blob && typeof blob.type === 'string' ? blob.type : ''
137
+ const message = blob && typeof blob.message === 'string' ? blob.message : source
138
+ const status = httpStatus(source)
139
+ const joined = (code + ' ' + type + ' ' + message + ' ' + source).toLowerCase()
140
+
141
+ if (/quota|accountquotaexceeded|exceeded the .*quota|额度|配额/.test(joined) || code === 'AccountQuotaExceeded') {
142
+ return { title: '额度已用尽', detail: resetHint(message), raw: source }
143
+ }
144
+ if (status === 429 || /too.?many.?requests|rate.?limit|限流/.test(joined) || type === 'TooManyRequests') {
145
+ return { title: '请求过于频繁', detail: '稍后再试,或降低并发', raw: source }
146
+ }
147
+ if (status === 401 || status === 403 || /unauthorized|forbidden|invalid.?api.?key|鉴权|未授权/.test(joined)) {
148
+ return { title: '模型鉴权失败', detail: '检查该角色绑定的提供方密钥', raw: source }
149
+ }
150
+ if (status === 404 || /model.?not.?found|unknown.?model|模型不存在/.test(joined)) {
151
+ return { title: '模型不可用', detail: '该角色绑定的模型可能已下线', raw: source }
152
+ }
153
+ if (/timeout|timed out|etimedout|超时/.test(joined)) {
154
+ return { title: '模型响应超时', raw: source }
155
+ }
156
+ if (/network|econnreset|econnrefused|enotfound|fetch failed|网络/.test(joined)) {
157
+ return { title: '网络异常', detail: '检查网络后重试', raw: source }
158
+ }
159
+ if (status !== null && status >= 500) {
160
+ return { title: '模型服务暂时不可用', detail: 'HTTP ' + status, raw: source }
161
+ }
162
+ if (status !== null) {
163
+ return { title: '发言失败', detail: 'HTTP ' + status, raw: source }
164
+ }
165
+ return { title: '发言失败', raw: source }
166
+ }
167
+
168
+ /** 失败卡默认复制内容:标题 + 短因 + 原文。 */
169
+ export function formatSpeakFailureCopy(view: SpeakFailureView): string {
170
+ const lines = [view.title]
171
+ if (view.detail) lines.push(view.detail)
172
+ if (view.raw && view.raw !== view.title) lines.push(view.raw)
173
+ return lines.join('\n')
174
+ }
package/src/core/json.ts CHANGED
@@ -14,6 +14,7 @@ export function messageJson(m: Partial<MessageRecord> | undefined): Record<strin
14
14
  if (m.reasoningFull !== undefined) o.reasoningFull = m.reasoningFull
15
15
  if (m.thinkingSummary !== undefined) o.thinkingSummary = m.thinkingSummary
16
16
  if (m.error !== undefined) o.error = m.error
17
+ if (typeof m.failedRoleId === 'string' && m.failedRoleId) o.failedRoleId = m.failedRoleId
17
18
  if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) o.toolCalls = m.toolCalls
18
19
  return o
19
20
  }
@@ -26,3 +27,20 @@ export function roleJson(r: Partial<RoleRecord>): Record<string, unknown> {
26
27
  if (typeof r.reasoningEffort === 'string' && r.reasoningEffort && r.reasoningEffort !== 'default') o.reasoningEffort = r.reasoningEffort
27
28
  return o
28
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
@@ -41,6 +41,23 @@ export interface GroupRecord {
41
41
  sessionIds: string[]
42
42
  }
43
43
 
44
+ /** 会话约束条目类型(窗口外结论/约束备忘)。 */
45
+ export type ConstraintKind = 'decided' | 'rejected' | 'open'
46
+
47
+ /** 全部合法约束类型。 */
48
+ export const CONSTRAINT_KINDS: readonly ConstraintKind[] = ['decided', 'rejected', 'open']
49
+
50
+ /** 合法 kind 原样,其余 undefined。 */
51
+ export function asConstraintKind(value: unknown): ConstraintKind | undefined {
52
+ return typeof value === 'string' && (CONSTRAINT_KINDS as readonly string[]).includes(value) ? value as ConstraintKind : undefined
53
+ }
54
+
55
+ /** 一条无主结论/约束(已定 / 否决 / 未决)。 */
56
+ export interface SessionConstraint {
57
+ kind: ConstraintKind
58
+ text: string
59
+ }
60
+
44
61
  /** 会话:消息挂在会话上。 */
45
62
  export interface SessionRecord {
46
63
  id: string
@@ -51,6 +68,10 @@ export interface SessionRecord {
51
68
  namePinned?: boolean
52
69
  /** 主题已被手动编辑:自动整理永久跳过(隐式固定)。 */
53
70
  topicPinned?: boolean
71
+ /** 窗口外结论/约束备忘;空则省略。 */
72
+ constraints?: SessionConstraint[]
73
+ /** 已折入备忘的最大消息 seq;0/缺省 = 尚未折过。 */
74
+ constraintsUpToSeq?: number
54
75
  messageIds: string[]
55
76
  createdAt: number
56
77
  }
@@ -91,6 +112,8 @@ export interface MessageRecord {
91
112
  thinkingSummary?: string
92
113
  model?: string
93
114
  error?: boolean
115
+ /** 发言失败时的角色 id;刷新后仍可对该条点重试。角色消息 speaker 即角色 id,此字段冗余兼容旧系统错误行。 */
116
+ failedRoleId?: string
94
117
  toolCalls?: ToolCallRecord[]
95
118
  ts: number
96
119
  }
@@ -129,6 +152,8 @@ export interface RunState {
129
152
  childProc: import('node:child_process').ChildProcess | null
130
153
  /** 最近一次 run 的结束标记:会话列表「已完成/已出错」状态的数据源。 */
131
154
  finished: RunFinished | null
155
+ /** 原地重试时被覆盖的失败消息 id;普通 send 为 null。 */
156
+ replaceMessageId: string | null
132
157
  }
133
158
 
134
159
  /** 角色发言的引擎产物。 */
@@ -177,31 +202,39 @@ export interface FileSearchResult {
177
202
  error?: string
178
203
  }
179
204
 
180
- /** 发到客户端的全量快照(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 形态;各表行由领域记录派生,字段增删由编译器同步)。 */
181
211
  export interface Snapshot {
182
212
  revision: number
183
- run: { running: boolean, sessionId: string | null, currentRoleId: string | null, partial: string, partialReasoning: string, pendingConfirm: PendingConfirm | null, finished: RunFinished | null }
213
+ run: Omit<RunState, 'stopping' | 'queue' | 'confirmSignal' | 'childProc'>
184
214
  lastCreated: LastCreated | null
185
- groups: { id: string, name: string, workspaceDir: string, permissionTier: PermissionTier, roleIds: string[], sessionIds: string[] }[]
186
- sessions: { id: string, groupId: string, name: string, topic: string, messageIds: string[], createdAt: number }[]
187
- roles: { id: string, groupId: string, name: string, color?: string, persona: string, provider: string, model: string, temperature?: number, reasoningEffort?: string, enabled: boolean, thinking: boolean }[]
188
- messages: { id: string, sessionId: string, seq: number, speaker: string, text: string, reasoning?: string, model?: string, error?: boolean, toolCalls?: ToolCallRecord[], ts: number }[]
215
+ groups: GroupRecord[]
216
+ sessions: SnapshotSession[]
217
+ roles: RoleRecord[]
218
+ messages: SnapshotMessage[]
189
219
  error?: string
190
220
  }
191
221
 
192
- /** mutate 动作的参数形态(op 分发见 host/service)。 */
193
- export interface MutateArgs {
194
- op: string
195
- groupId?: string
196
- sessionId?: string
197
- roleId?: string
198
- role?: Partial<RoleRecord>
199
- name?: string
200
- topic?: string
201
- path?: string
202
- tier?: string
203
- enabled?: boolean
204
- }
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 }
205
238
 
206
239
  /** send 动作的参数形态。 */
207
240
  export interface SendArgs {
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * 动作分发(handleAction,POST /api/group-chat/action 的载荷):
3
- * mutate(12 种 CRUD/配置操作)| send | stop | confirmCommand | models | efforts。
3
+ * mutate(12 种 CRUD/配置操作)| send | retrySpeak | stop | confirmCommand | models | efforts。
4
4
  * @module dsh-group-chat/host/api/actions
5
5
  */
6
6
 
7
7
  import { rmSync, unlinkSync } from 'node:fs'
8
+ import { isSpeakFailure, repairFailedMessage } from '../../core/errors.ts'
8
9
  import type { BrowseResult, EffortOptions, GroupRecord, ModelCatalog, MutateArgs, RoleRecord, SendArgs, Snapshot } from '../../core/types.ts'
9
10
  import { asEffort, asNumber, asPermissionTier } from '../../core/types.ts'
10
11
  import { PALETTE } from '../state.ts'
@@ -35,7 +36,8 @@ export function createActions(core: HostState, deps: {
35
36
  const { touch, snapshot, schedulePersist, dropDirty, appendMessage, runLoop, wakeConfirm, killChild, browse, fileSearch, disposeFileSearch } = deps
36
37
 
37
38
  const mutate = (args: MutateArgs): Snapshot => {
38
- const op = args && args.op
39
+ // 直接属性别名保持判别联合收窄(调用方 handleAction 已保证 body 为对象)
40
+ const op = args.op
39
41
  if (op === 'createGroup') {
40
42
  const g: GroupRecord = { id: core.nid('grp'), name: String(args.name || '').trim() || '群组 ' + (groups.size + 1), workspaceDir: '', permissionTier: 'view_only', roleIds: [], sessionIds: [] }
41
43
  groups.set(g.id, g)
@@ -45,14 +47,14 @@ export function createActions(core: HostState, deps: {
45
47
  schedulePersist({ ledger: true, session: sess.id })
46
48
  touch()
47
49
  } else if (op === 'renameGroup') {
48
- const g = groups.get(args.groupId!)
50
+ const g = groups.get(args.groupId)
49
51
  if (g && String(args.name || '').trim()) {
50
52
  g.name = String(args.name).trim()
51
53
  schedulePersist({ ledger: true })
52
54
  touch()
53
55
  }
54
56
  } else if (op === 'deleteGroup') {
55
- const g = groups.get(args.groupId!)
57
+ const g = groups.get(args.groupId)
56
58
  if (!g) return { ...snapshot(), error: '群组不存在' }
57
59
  if (groups.size <= 1) return { ...snapshot(), error: '至少保留一个群组' }
58
60
  if (run.running) {
@@ -79,7 +81,7 @@ export function createActions(core: HostState, deps: {
79
81
  schedulePersist({ ledger: true })
80
82
  touch()
81
83
  } else if (op === 'createSession') {
82
- const g = groups.get(args.groupId!)
84
+ const g = groups.get(args.groupId)
83
85
  if (!g) return { ...snapshot(), error: '群组不存在' }
84
86
  const sess = core.newSession(g.id, String(args.name || '').trim() || undefined)
85
87
  g.sessionIds.push(sess.id)
@@ -87,7 +89,7 @@ export function createActions(core: HostState, deps: {
87
89
  schedulePersist({ ledger: true, session: sess.id })
88
90
  touch()
89
91
  } else if (op === 'renameSession') {
90
- const sess = sessions.get(args.sessionId!)
92
+ const sess = sessions.get(args.sessionId)
91
93
  if (sess && String(args.name || '').trim()) {
92
94
  sess.name = String(args.name).trim()
93
95
  sess.namePinned = true // 手动编辑 = 隐式固定:自动命名此后跳过名称
@@ -95,7 +97,7 @@ export function createActions(core: HostState, deps: {
95
97
  touch()
96
98
  }
97
99
  } else if (op === 'deleteSession') {
98
- const sess = sessions.get(args.sessionId!)
100
+ const sess = sessions.get(args.sessionId)
99
101
  if (!sess) return { ...snapshot(), error: '会话不存在' }
100
102
  const g = groups.get(sess.groupId)
101
103
  if (g && g.sessionIds.length <= 1) return { ...snapshot(), error: '每个群组至少保留一个会话' }
@@ -114,7 +116,7 @@ export function createActions(core: HostState, deps: {
114
116
  schedulePersist({ ledger: true })
115
117
  touch()
116
118
  } else if (op === 'setTopic') {
117
- const sess = sessions.get(args.sessionId!)
119
+ const sess = sessions.get(args.sessionId)
118
120
  if (sess) {
119
121
  sess.topic = String(args.topic || '')
120
122
  sess.topicPinned = true // 手动编辑 = 隐式固定:自动整理此后跳过主题
@@ -122,7 +124,7 @@ export function createActions(core: HostState, deps: {
122
124
  touch()
123
125
  }
124
126
  } else if (op === 'upsertRole') {
125
- const g = groups.get(args.groupId!)
127
+ const g = groups.get(args.groupId)
126
128
  const r = args.role || {}
127
129
  if (!g) return { ...snapshot(), error: '群组不存在' }
128
130
  if (!r.name || !String(r.name).trim()) return { ...snapshot(), error: '角色名称不能为空' }
@@ -160,7 +162,7 @@ export function createActions(core: HostState, deps: {
160
162
  touch()
161
163
  }
162
164
  } else if (op === 'deleteRole') {
163
- const r = roles.get(args.roleId!)
165
+ const r = roles.get(args.roleId)
164
166
  if (r) {
165
167
  const gid = r.groupId
166
168
  const g = groups.get(gid)
@@ -170,14 +172,14 @@ export function createActions(core: HostState, deps: {
170
172
  touch()
171
173
  }
172
174
  } else if (op === 'setRoleEnabled') {
173
- const r = roles.get(args.roleId!)
175
+ const r = roles.get(args.roleId)
174
176
  if (r) {
175
177
  r.enabled = !!args.enabled
176
178
  schedulePersist({ roles: r.groupId })
177
179
  touch()
178
180
  }
179
181
  } else if (op === 'setWorkspaceDir') {
180
- const g = groups.get(args.groupId!)
182
+ const g = groups.get(args.groupId)
181
183
  if (g) {
182
184
  g.workspaceDir = String(args.path || '').trim()
183
185
  schedulePersist({ workspace: g.id })
@@ -186,7 +188,7 @@ export function createActions(core: HostState, deps: {
186
188
  } else if (op === 'setPermissionTier') {
187
189
  const tier = asPermissionTier(args.tier)
188
190
  if (!tier) return { ...snapshot(), error: '未知权限档位' }
189
- const g = groups.get(args.groupId!)
191
+ const g = groups.get(args.groupId)
190
192
  if (g) {
191
193
  g.permissionTier = tier
192
194
  // 降到仅可查看时若该群挂着待确认命令,自动拒绝(安全侧倾斜);
@@ -205,11 +207,13 @@ export function createActions(core: HostState, deps: {
205
207
  touch()
206
208
  }
207
209
  } else if (op === 'clearMessages') {
208
- const sess = sessions.get(args.sessionId!)
210
+ const sess = sessions.get(args.sessionId)
209
211
  if (sess) {
210
212
  if (run.running && run.sessionId === sess.id) return { ...snapshot(), error: '对话进行中,无法清空' }
211
213
  for (const mid of sess.messageIds) messages.delete(mid)
212
214
  sess.messageIds = []
215
+ sess.constraints = undefined
216
+ sess.constraintsUpToSeq = undefined
213
217
  schedulePersist({ session: sess.id })
214
218
  touch()
215
219
  }
@@ -243,11 +247,41 @@ export function createActions(core: HostState, deps: {
243
247
  run.queue = queue
244
248
  run.stopping = false
245
249
  run.finished = null // 新 run 覆盖旧的「输出完毕」未读标记
250
+ run.replaceMessageId = null
246
251
  touch()
247
252
  void runLoop(sess).catch((e) => console.error('group-chat run failed', e))
248
253
  return { ok: true }
249
254
  }
250
255
 
256
+ /** 对失败卡原地重试:只让该角色再讲一次,成功后覆盖同一条消息。 */
257
+ const retrySpeak = (args: { sessionId?: string, messageId?: string }): { ok: boolean, error?: string } => {
258
+ const sess = sessions.get(String(args && args.sessionId || ''))
259
+ if (!sess) return { ok: false, error: '会话不存在' }
260
+ if (run.running) return { ok: false, error: '已有对话进行中,请先停止' }
261
+ const msg = messages.get(String(args && args.messageId || ''))
262
+ if (!msg || msg.sessionId !== sess.id || !isSpeakFailure(msg)) return { ok: false, error: '没有可重试的失败发言' }
263
+ const g = groups.get(sess.groupId)
264
+ if (!g) return { ok: false, error: '群组不存在' }
265
+ const groupRoles = g.roleIds.map((id) => roles.get(id)).filter((r): r is RoleRecord => Boolean(r))
266
+ if (repairFailedMessage(msg, groupRoles)) {
267
+ schedulePersist({ session: sess.id })
268
+ touch()
269
+ }
270
+ const roleId = msg.failedRoleId || (msg.speaker !== 'user' && msg.speaker !== 'system' ? msg.speaker : '')
271
+ const role = roleId ? roles.get(roleId) : undefined
272
+ if (!role || role.groupId !== sess.groupId) return { ok: false, error: '失败角色已不存在,无法重试' }
273
+ if (!role.enabled) return { ok: false, error: '该角色已停用,无法重试' }
274
+ run.running = true
275
+ run.sessionId = sess.id
276
+ run.queue = [role.id]
277
+ run.stopping = false
278
+ run.finished = null
279
+ run.replaceMessageId = msg.id
280
+ touch()
281
+ void runLoop(sess, { replaceMessageId: msg.id }).catch((e) => console.error('group-chat retry failed', e))
282
+ return { ok: true }
283
+ }
284
+
251
285
  const stop = (args: { sessionId?: string }): { ok: boolean } => {
252
286
  if (run.running && (!args || !args.sessionId || run.sessionId === args.sessionId)) {
253
287
  run.stopping = true
@@ -322,6 +356,7 @@ export function createActions(core: HostState, deps: {
322
356
  const kind = body && body.kind
323
357
  if (kind === 'mutate') return { ok: true, snapshot: mutate(body as unknown as MutateArgs), lastCreated: core.lastCreated }
324
358
  if (kind === 'send') return send(body as unknown as SendArgs)
359
+ if (kind === 'retrySpeak') return retrySpeak(body as { sessionId?: string, messageId?: string })
325
360
  if (kind === 'stop') return stop(body as { sessionId?: string })
326
361
  if (kind === 'confirmCommand') return confirmCommand(body as { toolCallId?: string, allow?: boolean })
327
362
  if (kind === 'models') return { ok: true, ...(await models()) }
@@ -41,12 +41,12 @@ export function createBroadcast(core: HostState): Broadcast {
41
41
 
42
42
  const snapshot = (): Snapshot => ({
43
43
  revision: core.revision,
44
- run: { running: core.run.running, sessionId: core.run.sessionId, currentRoleId: core.run.currentRoleId, partial: core.run.partial, partialReasoning: core.run.partialReasoning, pendingConfirm: core.run.pendingConfirm, finished: core.run.finished },
44
+ run: { running: core.run.running, sessionId: core.run.sessionId, currentRoleId: core.run.currentRoleId, partial: core.run.partial, partialReasoning: core.run.partialReasoning, pendingConfirm: core.run.pendingConfirm, finished: core.run.finished, replaceMessageId: core.run.replaceMessageId },
45
45
  lastCreated: core.lastCreated,
46
46
  groups: [...core.groups.values()].map((g) => ({ id: g.id, name: g.name, workspaceDir: g.workspaceDir, permissionTier: g.permissionTier, roleIds: g.roleIds.slice(), sessionIds: g.sessionIds.slice() })),
47
- sessions: [...core.sessions.values()].map((s) => ({ id: s.id, groupId: s.groupId, name: s.name, topic: s.topic, messageIds: s.messageIds.slice(), createdAt: s.createdAt })),
47
+ sessions: [...core.sessions.values()].map((s) => ({ id: s.id, groupId: s.groupId, name: s.name, topic: s.topic, ...(s.constraints && s.constraints.length ? { constraints: s.constraints } : {}), messageIds: s.messageIds.slice(), createdAt: s.createdAt })),
48
48
  roles: [...core.roles.values()].map((r) => ({ id: r.id, groupId: r.groupId, name: r.name, color: r.color, persona: r.persona, provider: r.provider, model: r.model, temperature: r.temperature, reasoningEffort: r.reasoningEffort, enabled: r.enabled, thinking: r.thinking === true })),
49
- messages: [...core.messages.values()].map((m) => ({ id: m.id, sessionId: m.sessionId, seq: m.seq, speaker: m.speaker, text: m.text, reasoning: m.reasoning, model: m.model, error: m.error, toolCalls: m.toolCalls, ts: m.ts })),
49
+ messages: [...core.messages.values()].map((m) => ({ id: m.id, sessionId: m.sessionId, seq: m.seq, speaker: m.speaker, text: m.text, reasoning: m.reasoning, model: m.model, error: m.error, failedRoleId: m.failedRoleId, toolCalls: m.toolCalls, ts: m.ts })),
50
50
  })
51
51
 
52
52
  return {