@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.
- package/README.md +3 -2
- package/lib/client.js +984 -296
- package/lib/client.js.map +1 -1
- package/lib/index.js +524 -52
- package/lib/types/client/components/Bubble.d.ts +6 -3
- package/lib/types/client/components/ChatPanel.d.ts +1 -0
- package/lib/types/client/components/ConstraintList.d.ts +11 -0
- package/lib/types/client/components/FailCard.d.ts +9 -0
- package/lib/types/client/components/Fold.d.ts +19 -0
- package/lib/types/client/components/HoverTip.d.ts +22 -0
- package/lib/types/client/components/MessageFlow.d.ts +1 -0
- package/lib/types/client/components/MsgActions.d.ts +14 -0
- package/lib/types/client/hooks/useComposer.d.ts +6 -1
- package/lib/types/client/lib/composer-draft.d.ts +17 -0
- package/lib/types/core/constraints.d.ts +61 -0
- package/lib/types/core/errors.d.ts +54 -0
- package/lib/types/core/types.d.ts +22 -0
- package/lib/types/host/api/actions.d.ts +1 -1
- package/lib/types/host/engine/conversation.d.ts +5 -3
- package/lib/types/host/engine/fold.d.ts +17 -0
- package/lib/types/host/engine/index.d.ts +1 -0
- package/lib/types/host/engine/retitle.d.ts +5 -5
- package/lib/types/host/service.d.ts +1 -1
- package/lib/types/host/state.d.ts +2 -0
- package/lib/types/index.d.ts +2 -0
- package/package.json +1 -1
- package/src/client/GroupChatPanel.tsx +18 -3
- package/src/client/components/AsidePanel.tsx +10 -8
- package/src/client/components/Bubble.tsx +45 -18
- package/src/client/components/ChatPanel.tsx +17 -12
- package/src/client/components/Composer.tsx +26 -20
- package/src/client/components/ConstraintList.tsx +69 -0
- package/src/client/components/FailCard.tsx +49 -0
- package/src/client/components/Fold.tsx +72 -0
- package/src/client/components/HoverTip.tsx +134 -0
- package/src/client/components/MessageFlow.tsx +77 -54
- package/src/client/components/MsgActions.tsx +85 -0
- package/src/client/components/NavPanel.tsx +6 -1
- package/src/client/components/ThinkRow.tsx +7 -3
- package/src/client/components/ToolRow.tsx +7 -3
- package/src/client/hooks/useComposer.ts +40 -3
- package/src/client/hooks/useGroupChatState.ts +5 -4
- package/src/client/lib/composer-draft.ts +47 -0
- package/src/client/lib/styles.ts +73 -12
- package/src/client/react-dom-shim.d.ts +2 -0
- package/src/core/constraints.ts +210 -0
- package/src/core/errors.ts +184 -0
- package/src/core/json.ts +1 -0
- package/src/core/types.ts +28 -3
- package/src/host/api/actions.ts +35 -1
- package/src/host/broadcast.ts +3 -3
- package/src/host/engine/conversation.ts +103 -38
- package/src/host/engine/fold.ts +114 -0
- package/src/host/engine/index.ts +1 -0
- package/src/host/engine/retitle.ts +16 -11
- package/src/host/persistence/persistence.ts +18 -1
- package/src/host/service.ts +1 -1
- package/src/host/state.ts +5 -4
- 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
|
|
package/src/host/api/actions.ts
CHANGED
|
@@ -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()) }
|
package/src/host/broadcast.ts
CHANGED
|
@@ -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 {
|