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

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 (59) hide show
  1. package/README.md +3 -2
  2. package/lib/client.js +984 -296
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +524 -52
  5. package/lib/types/client/components/Bubble.d.ts +6 -3
  6. package/lib/types/client/components/ChatPanel.d.ts +1 -0
  7. package/lib/types/client/components/ConstraintList.d.ts +11 -0
  8. package/lib/types/client/components/FailCard.d.ts +9 -0
  9. package/lib/types/client/components/Fold.d.ts +19 -0
  10. package/lib/types/client/components/HoverTip.d.ts +22 -0
  11. package/lib/types/client/components/MessageFlow.d.ts +1 -0
  12. package/lib/types/client/components/MsgActions.d.ts +14 -0
  13. package/lib/types/client/hooks/useComposer.d.ts +6 -1
  14. package/lib/types/client/lib/composer-draft.d.ts +17 -0
  15. package/lib/types/core/constraints.d.ts +61 -0
  16. package/lib/types/core/errors.d.ts +54 -0
  17. package/lib/types/core/types.d.ts +22 -0
  18. package/lib/types/host/api/actions.d.ts +1 -1
  19. package/lib/types/host/engine/conversation.d.ts +5 -3
  20. package/lib/types/host/engine/fold.d.ts +17 -0
  21. package/lib/types/host/engine/index.d.ts +1 -0
  22. package/lib/types/host/engine/retitle.d.ts +5 -5
  23. package/lib/types/host/service.d.ts +1 -1
  24. package/lib/types/host/state.d.ts +2 -0
  25. package/lib/types/index.d.ts +2 -0
  26. package/package.json +1 -1
  27. package/src/client/GroupChatPanel.tsx +18 -3
  28. package/src/client/components/AsidePanel.tsx +10 -8
  29. package/src/client/components/Bubble.tsx +45 -18
  30. package/src/client/components/ChatPanel.tsx +17 -12
  31. package/src/client/components/Composer.tsx +26 -20
  32. package/src/client/components/ConstraintList.tsx +69 -0
  33. package/src/client/components/FailCard.tsx +49 -0
  34. package/src/client/components/Fold.tsx +72 -0
  35. package/src/client/components/HoverTip.tsx +134 -0
  36. package/src/client/components/MessageFlow.tsx +77 -54
  37. package/src/client/components/MsgActions.tsx +85 -0
  38. package/src/client/components/NavPanel.tsx +6 -1
  39. package/src/client/components/ThinkRow.tsx +7 -3
  40. package/src/client/components/ToolRow.tsx +7 -3
  41. package/src/client/hooks/useComposer.ts +40 -3
  42. package/src/client/hooks/useGroupChatState.ts +5 -4
  43. package/src/client/lib/composer-draft.ts +47 -0
  44. package/src/client/lib/styles.ts +73 -12
  45. package/src/client/react-dom-shim.d.ts +2 -0
  46. package/src/core/constraints.ts +210 -0
  47. package/src/core/errors.ts +184 -0
  48. package/src/core/json.ts +1 -0
  49. package/src/core/types.ts +28 -3
  50. package/src/host/api/actions.ts +35 -1
  51. package/src/host/broadcast.ts +3 -3
  52. package/src/host/engine/conversation.ts +103 -38
  53. package/src/host/engine/fold.ts +114 -0
  54. package/src/host/engine/index.ts +1 -0
  55. package/src/host/engine/retitle.ts +16 -11
  56. package/src/host/persistence/persistence.ts +18 -1
  57. package/src/host/service.ts +1 -1
  58. package/src/host/state.ts +5 -4
  59. package/src/index.ts +2 -0
@@ -1,6 +1,8 @@
1
1
  /** Platform modules (react-dom / client) have no @types in this plugin. */
2
2
  declare module 'react-dom' {
3
+ import type { ReactNode } from 'react'
3
4
  export function flushSync(fn: () => void): void
5
+ export function createPortal(children: ReactNode, container: Element | DocumentFragment): ReactNode
4
6
  }
5
7
 
6
8
  declare module 'react-dom/client' {
@@ -0,0 +1,210 @@
1
+ /**
2
+ * 会话约束备忘纯逻辑:滑动 40 条窗口、挤出集、压行、临时原文、解析与截断。
3
+ * @module dsh-group-chat/core/constraints
4
+ */
5
+
6
+ import { TRANSCRIPT_TOOL_SUMMARY } from './tools.ts'
7
+ import { asConstraintKind, type MessageRecord, type SessionConstraint, type SessionRecord } from './types.ts'
8
+
9
+ /** 当场原文窗口(含系统行)。 */
10
+ export const WINDOW_SIZE = 40
11
+ /** 折叠失败时临时原文条数上限。 */
12
+ export const TEMP_MAX_MESSAGES = 20
13
+ /** 折叠失败时临时原文总长上限。 */
14
+ export const TEMP_MAX_CHARS = 16000
15
+ /** 单轮折叠最多消耗的挤出条数(含系统行;超出留待下一轮)。 */
16
+ export const FOLD_MAX_MESSAGES = 40
17
+ /** 单轮折叠输入总长上限(格式化后)。 */
18
+ export const FOLD_MAX_CHARS = 16000
19
+ /** 备忘条数上限。 */
20
+ export const CONSTRAINT_MAX_ITEMS = 12
21
+ /** 备忘总长上限(条目前缀+正文)。 */
22
+ export const CONSTRAINT_MAX_CHARS = 1200
23
+ /** 单条备忘正文上限。 */
24
+ export const CONSTRAINT_ITEM_MAX_CHARS = 160
25
+
26
+ export const KIND_LABEL: Record<SessionConstraint['kind'], string> = {
27
+ decided: '已定',
28
+ rejected: '否决',
29
+ open: '未决',
30
+ }
31
+
32
+ /** 已折入水位(缺省 0)。 */
33
+ export function constraintsWatermark(sess: SessionRecord): number {
34
+ return typeof sess.constraintsUpToSeq === 'number' && sess.constraintsUpToSeq > 0 ? sess.constraintsUpToSeq : 0
35
+ }
36
+
37
+ /** 重试:只取失败卡之前的时间线;untilId 不在列表则原样。 */
38
+ export function prefixIds(ids: string[], untilId?: string): string[] {
39
+ if (!untilId) return ids
40
+ const i = ids.indexOf(untilId)
41
+ return i >= 0 ? ids.slice(0, i) : ids
42
+ }
43
+
44
+ /**
45
+ * 新挤出:seq > 水位 且不在最近 40 条。只扫窗口外前缀(旧→新)。
46
+ * untilId:重试时把窗口截到该消息之前,不带上后面已经发生的发言。
47
+ */
48
+ export function squeezedMessages(
49
+ messages: Map<string, MessageRecord>,
50
+ sess: SessionRecord,
51
+ untilId?: string,
52
+ ): MessageRecord[] {
53
+ const ids = prefixIds(sess.messageIds, untilId)
54
+ if (ids.length <= WINDOW_SIZE) return []
55
+ const end = ids.length - WINDOW_SIZE
56
+ const upTo = constraintsWatermark(sess)
57
+ const last = messages.get(ids[end - 1])
58
+ if (last && last.seq <= upTo) return []
59
+ const out: MessageRecord[] = []
60
+ for (let i = 0; i < end; i++) {
61
+ const m = messages.get(ids[i])
62
+ if (!m || m.seq <= upTo) continue
63
+ out.push(m)
64
+ }
65
+ return out
66
+ }
67
+
68
+ /** 说话人展示名(折叠输入 / transcript 共用)。 */
69
+ export function speakerLabel(speaker: string, roleName?: string): string {
70
+ if (speaker === 'user') return '用户'
71
+ if (speaker === 'system') return '系统'
72
+ return roleName || '成员'
73
+ }
74
+
75
+ /** 单条消息压成 transcript 行(正文 8k + 工具一行摘要)。 */
76
+ export function formatTranscriptLine(m: MessageRecord, name: string): string {
77
+ let text = m.text || ''
78
+ if (text.length > 8000) text = text.slice(0, 8000) + '…(已截断)'
79
+ let line = '【' + name + '】' + text
80
+ if (Array.isArray(m.toolCalls)) {
81
+ for (const c of m.toolCalls) {
82
+ if (!c || typeof c.tool !== 'string') continue
83
+ let brief = ''
84
+ try {
85
+ brief = JSON.stringify(c.args) || ''
86
+ } catch {
87
+ brief = ''
88
+ }
89
+ if (brief.length > 60) brief = brief.slice(0, 60) + '…'
90
+ const st = c.status === 'ok' ? '成功' : c.status === 'denied' ? '用户拒绝' : '失败'
91
+ let ob = String(c.output || '')
92
+ if (ob.length > TRANSCRIPT_TOOL_SUMMARY) ob = ob.slice(0, TRANSCRIPT_TOOL_SUMMARY) + '…'
93
+ line += '\n [工具] ' + c.tool + ' ' + brief + ' → ' + st + (ob ? '(' + ob.replace(/\s+/g, ' ') + ')' : '')
94
+ }
95
+ }
96
+ return line
97
+ }
98
+
99
+ /**
100
+ * 单轮折叠消耗前缀:从最旧挤出起,最多 40 条 / 16k;系统行与失败卡计入消耗但不进模型。
101
+ * 水位只能推到 consumed 的 max seq,剩余留待下一轮。
102
+ */
103
+ export function takeFoldBatch(squeezed: MessageRecord[], nameOf: (m: MessageRecord) => string): {
104
+ consumed: MessageRecord[]
105
+ lines: string[]
106
+ hasUser: boolean
107
+ allSystem: boolean
108
+ } {
109
+ const consumed: MessageRecord[] = []
110
+ const lines: string[] = []
111
+ let hasUser = false
112
+ let chars = 0
113
+ for (const m of squeezed) {
114
+ if (m.speaker === 'system' || m.error) {
115
+ consumed.push(m)
116
+ if (consumed.length >= FOLD_MAX_MESSAGES) break
117
+ continue
118
+ }
119
+ const line = formatTranscriptLine(m, nameOf(m))
120
+ const extra = line.length + (lines.length ? 2 : 0)
121
+ if (lines.length > 0 && chars + extra > FOLD_MAX_CHARS) break
122
+ if (m.speaker === 'user') hasUser = true
123
+ lines.push(line)
124
+ chars += extra
125
+ consumed.push(m)
126
+ if (consumed.length >= FOLD_MAX_MESSAGES) break
127
+ }
128
+ return { consumed, lines, hasUser, allSystem: lines.length === 0 }
129
+ }
130
+
131
+ /**
132
+ * 未折入的挤出原文(失败缓冲):只格式化最近 20 条,再按 16k 从最旧往下丢。
133
+ */
134
+ export function tempTranscript(squeezed: MessageRecord[], nameOf: (m: MessageRecord) => string): string {
135
+ if (!squeezed.length) return ''
136
+ const usable = squeezed.filter((m) => m.speaker !== 'system' && !m.error)
137
+ if (!usable.length) return ''
138
+ const batch = usable.length > TEMP_MAX_MESSAGES ? usable.slice(-TEMP_MAX_MESSAGES) : usable
139
+ const lines = batch.map((m) => formatTranscriptLine(m, nameOf(m)))
140
+ let start = 0
141
+ let total = lines[0] ? lines[0].length : 0
142
+ for (let i = 1; i < lines.length; i++) {
143
+ total += 2 + lines[i].length
144
+ }
145
+ while (start < lines.length - 1 && total > TEMP_MAX_CHARS) {
146
+ total -= lines[start].length + 2
147
+ start++
148
+ }
149
+ let block = lines.slice(start).join('\n\n')
150
+ if (block.length > TEMP_MAX_CHARS) block = block.slice(0, TEMP_MAX_CHARS) + '…(已截断)'
151
+ return block
152
+ }
153
+
154
+ /** 水位 = 本批挤出的 max(seq);空批为 0。 */
155
+ export function squeezedMaxSeq(squeezed: MessageRecord[]): number {
156
+ let max = 0
157
+ for (const m of squeezed) if (m.seq > max) max = m.seq
158
+ return max
159
+ }
160
+
161
+ /** hydrate / 模型输出:非法 kind 丢条目;空 text 丢;条数与总长截断。 */
162
+ export function sanitizeConstraints(raw: unknown): SessionConstraint[] {
163
+ if (!Array.isArray(raw)) return []
164
+ const out: SessionConstraint[] = []
165
+ let total = 0
166
+ for (const item of raw) {
167
+ if (out.length >= CONSTRAINT_MAX_ITEMS) break
168
+ if (!item || typeof item !== 'object') continue
169
+ const kind = asConstraintKind((item as { kind?: unknown }).kind)
170
+ const text = typeof (item as { text?: unknown }).text === 'string' ? (item as { text: string }).text.trim() : ''
171
+ if (!kind || !text) continue
172
+ const clipped = text.length > CONSTRAINT_ITEM_MAX_CHARS ? text.slice(0, CONSTRAINT_ITEM_MAX_CHARS) + '…' : text
173
+ const cost = KIND_LABEL[kind].length + clipped.length
174
+ if (total + cost > CONSTRAINT_MAX_CHARS) break
175
+ out.push({ kind, text: clipped })
176
+ total += cost
177
+ }
178
+ return out
179
+ }
180
+
181
+ /**
182
+ * 解析折叠模型输出。null = 解析失败(水位不推);[] = 无新结论(水位推、备忘不动)。
183
+ */
184
+ 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
+ }
197
+ }
198
+
199
+ /** 本批无用户消息时,新的已定/否决降为未决。 */
200
+ export function downgradeWithoutUser(list: SessionConstraint[], hasUser: boolean): SessionConstraint[] {
201
+ if (hasUser) return list
202
+ return list.map((c) => c.kind === 'open' ? c : { kind: 'open', text: c.text })
203
+ }
204
+
205
+ /** system 提示词「已确认约束」块;空则空串。 */
206
+ export function constraintBlock(list: SessionConstraint[] | undefined): string {
207
+ if (!list || !list.length) return ''
208
+ const lines = list.map((c) => '- ' + KIND_LABEL[c.kind] + ':' + c.text)
209
+ return '\n# 已确认约束\n' + lines.join('\n')
210
+ }
@@ -0,0 +1,184 @@
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
+ 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
+ }
125
+
126
+ function httpStatus(text: string): number | null {
127
+ const m = text.match(/\b([1-5]\d{2})\b/)
128
+ if (!m) return null
129
+ const n = Number(m[1])
130
+ return n >= 100 && n <= 599 ? n : null
131
+ }
132
+
133
+ function resetHint(message: string): string | undefined {
134
+ const m = message.match(/reset at ([^.]+\S)/i) || message.match(/将在\s*([^\s。]+)\s*重置/)
135
+ return m ? '将在 ' + m[1] + ' 重置' : undefined
136
+ }
137
+
138
+ /**
139
+ * 把供应商错误压成可扫描的标题 + 短因。
140
+ * 未知形态回退为「发言失败」,原文仍可展开。
141
+ */
142
+ export function classifySpeakFailure(raw: string): SpeakFailureView {
143
+ const source = unwrapSpeakFailure(raw)
144
+ const blob = pickJsonBlob(source)
145
+ const code = blob && typeof blob.code === 'string' ? blob.code : ''
146
+ const type = blob && typeof blob.type === 'string' ? blob.type : ''
147
+ const message = blob && typeof blob.message === 'string' ? blob.message : source
148
+ const status = httpStatus(source)
149
+ const joined = (code + ' ' + type + ' ' + message + ' ' + source).toLowerCase()
150
+
151
+ if (/quota|accountquotaexceeded|exceeded the .*quota|额度|配额/.test(joined) || code === 'AccountQuotaExceeded') {
152
+ return { title: '额度已用尽', detail: resetHint(message), raw: source }
153
+ }
154
+ if (status === 429 || /too.?many.?requests|rate.?limit|限流/.test(joined) || type === 'TooManyRequests') {
155
+ return { title: '请求过于频繁', detail: '稍后再试,或降低并发', raw: source }
156
+ }
157
+ if (status === 401 || status === 403 || /unauthorized|forbidden|invalid.?api.?key|鉴权|未授权/.test(joined)) {
158
+ return { title: '模型鉴权失败', detail: '检查该角色绑定的提供方密钥', raw: source }
159
+ }
160
+ if (status === 404 || /model.?not.?found|unknown.?model|模型不存在/.test(joined)) {
161
+ return { title: '模型不可用', detail: '该角色绑定的模型可能已下线', raw: source }
162
+ }
163
+ if (/timeout|timed out|etimedout|超时/.test(joined)) {
164
+ return { title: '模型响应超时', raw: source }
165
+ }
166
+ if (/network|econnreset|econnrefused|enotfound|fetch failed|网络/.test(joined)) {
167
+ return { title: '网络异常', detail: '检查网络后重试', raw: source }
168
+ }
169
+ if (status !== null && status >= 500) {
170
+ return { title: '模型服务暂时不可用', detail: 'HTTP ' + status, raw: source }
171
+ }
172
+ if (status !== null) {
173
+ return { title: '发言失败', detail: 'HTTP ' + status, raw: source }
174
+ }
175
+ return { title: '发言失败', raw: source }
176
+ }
177
+
178
+ /** 失败卡默认复制内容:标题 + 短因 + 原文。 */
179
+ export function formatSpeakFailureCopy(view: SpeakFailureView): string {
180
+ const lines = [view.title]
181
+ if (view.detail) lines.push(view.detail)
182
+ if (view.raw && view.raw !== view.title) lines.push(view.raw)
183
+ return lines.join('\n')
184
+ }
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
  }
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
  /** 角色发言的引擎产物。 */
@@ -180,12 +205,12 @@ export interface FileSearchResult {
180
205
  /** 发到客户端的全量快照(wire 形态)。 */
181
206
  export interface Snapshot {
182
207
  revision: number
183
- run: { running: boolean, sessionId: string | null, currentRoleId: string | null, partial: string, partialReasoning: string, pendingConfirm: PendingConfirm | null, finished: RunFinished | null }
208
+ run: { running: boolean, sessionId: string | null, currentRoleId: string | null, partial: string, partialReasoning: string, pendingConfirm: PendingConfirm | null, finished: RunFinished | null, replaceMessageId: string | null }
184
209
  lastCreated: LastCreated | null
185
210
  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 }[]
211
+ sessions: { id: string, groupId: string, name: string, topic: string, constraints?: SessionConstraint[], messageIds: string[], createdAt: number }[]
187
212
  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 }[]
213
+ messages: { id: string, sessionId: string, seq: number, speaker: string, text: string, reasoning?: string, model?: string, error?: boolean, failedRoleId?: string, toolCalls?: ToolCallRecord[], ts: number }[]
189
214
  error?: string
190
215
  }
191
216
 
@@ -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'
@@ -210,6 +211,8 @@ export function createActions(core: HostState, deps: {
210
211
  if (run.running && run.sessionId === sess.id) return { ...snapshot(), error: '对话进行中,无法清空' }
211
212
  for (const mid of sess.messageIds) messages.delete(mid)
212
213
  sess.messageIds = []
214
+ sess.constraints = undefined
215
+ sess.constraintsUpToSeq = undefined
213
216
  schedulePersist({ session: sess.id })
214
217
  touch()
215
218
  }
@@ -243,11 +246,41 @@ export function createActions(core: HostState, deps: {
243
246
  run.queue = queue
244
247
  run.stopping = false
245
248
  run.finished = null // 新 run 覆盖旧的「输出完毕」未读标记
249
+ run.replaceMessageId = null
246
250
  touch()
247
251
  void runLoop(sess).catch((e) => console.error('group-chat run failed', e))
248
252
  return { ok: true }
249
253
  }
250
254
 
255
+ /** 对失败卡原地重试:只让该角色再讲一次,成功后覆盖同一条消息。 */
256
+ const retrySpeak = (args: { sessionId?: string, messageId?: string }): { ok: boolean, error?: string } => {
257
+ const sess = sessions.get(String(args && args.sessionId || ''))
258
+ if (!sess) return { ok: false, error: '会话不存在' }
259
+ if (run.running) return { ok: false, error: '已有对话进行中,请先停止' }
260
+ const msg = messages.get(String(args && args.messageId || ''))
261
+ if (!msg || msg.sessionId !== sess.id || !isSpeakFailure(msg)) return { ok: false, error: '没有可重试的失败发言' }
262
+ const g = groups.get(sess.groupId)
263
+ if (!g) return { ok: false, error: '群组不存在' }
264
+ const groupRoles = g.roleIds.map((id) => roles.get(id)).filter((r): r is RoleRecord => Boolean(r))
265
+ if (repairFailedMessage(msg, groupRoles)) {
266
+ schedulePersist({ session: sess.id })
267
+ touch()
268
+ }
269
+ const roleId = msg.failedRoleId || (msg.speaker !== 'user' && msg.speaker !== 'system' ? msg.speaker : '')
270
+ const role = roleId ? roles.get(roleId) : undefined
271
+ if (!role || role.groupId !== sess.groupId) return { ok: false, error: '失败角色已不存在,无法重试' }
272
+ if (!role.enabled) return { ok: false, error: '该角色已停用,无法重试' }
273
+ run.running = true
274
+ run.sessionId = sess.id
275
+ run.queue = [role.id]
276
+ run.stopping = false
277
+ run.finished = null
278
+ run.replaceMessageId = msg.id
279
+ touch()
280
+ void runLoop(sess, { replaceMessageId: msg.id }).catch((e) => console.error('group-chat retry failed', e))
281
+ return { ok: true }
282
+ }
283
+
251
284
  const stop = (args: { sessionId?: string }): { ok: boolean } => {
252
285
  if (run.running && (!args || !args.sessionId || run.sessionId === args.sessionId)) {
253
286
  run.stopping = true
@@ -322,6 +355,7 @@ export function createActions(core: HostState, deps: {
322
355
  const kind = body && body.kind
323
356
  if (kind === 'mutate') return { ok: true, snapshot: mutate(body as unknown as MutateArgs), lastCreated: core.lastCreated }
324
357
  if (kind === 'send') return send(body as unknown as SendArgs)
358
+ if (kind === 'retrySpeak') return retrySpeak(body as { sessionId?: string, messageId?: string })
325
359
  if (kind === 'stop') return stop(body as { sessionId?: string })
326
360
  if (kind === 'confirmCommand') return confirmCommand(body as { toolCallId?: string, allow?: boolean })
327
361
  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 {