@roaming-ai/dsh-group-chat 0.2.1 → 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 +986 -298
  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 +74 -13
  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
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 {
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * 对话引擎:消息追加、群聊记录转写、角色发言(speak:prompt 构建 + 流式
3
- * 轮次 + 工具回注循环)、多轮 runLoop(每轮结束触发 retitle 标题整理)。
3
+ * 轮次 + 工具回注循环)、多轮 runLoop(结束时后台 retitle + 窗口外约束折叠)。
4
4
  * @module dsh-group-chat/host/engine/conversation
5
5
  */
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'
10
+ import { unwrapSpeakFailure } from '../../core/errors.ts'
9
11
  import { TOOL_FLOOR_COST_CHARS, TOOL_REPEAT_LIMIT, TOOL_RESULTS_TOTAL_MAX, TRANSCRIPT_TOOL_SUMMARY } from '../../core/tools.ts'
10
12
  import type { GroupRecord, MessageRecord, RoleRecord, SessionRecord, SpeakResult, ToolCallRecord } from '../../core/types.ts'
11
13
  import { asEffort } from '../../core/types.ts'
12
14
  import type { HostState } from '../state.ts'
15
+ import { createFold } from './fold.ts'
13
16
  import { createRetitle } from './retitle.ts'
14
17
  import type { Materials } from '../materials/index.ts'
15
18
  import type { Tools } from '../tools/index.ts'
@@ -30,16 +33,18 @@ interface StreamRound {
30
33
  export interface Conversation {
31
34
  /** 追加一条消息到会话(touch + 落盘调度)。 */
32
35
  appendMessage: (sess: SessionRecord, speaker: string, text: string, extra?: Partial<MessageRecord>) => MessageRecord
33
- /** 多轮 round-robin 主循环(send 触发;finally 复位 run)。 */
34
- runLoop: (sess: SessionRecord) => Promise<void>
36
+ /** 多轮 round-robin 主循环(send / retrySpeak 触发;finally 复位 run)。 */
37
+ runLoop: (sess: SessionRecord, opts?: { replaceMessageId?: string }) => Promise<void>
35
38
  }
36
39
 
37
40
  /** 创建对话引擎。 */
38
41
  export function createConversation(core: HostState, deps: { touch: () => void, schedulePersist: (targets?: { session?: string | null }) => void, materials: Materials, tools: Tools }): Conversation {
39
42
  const { llm, groups, roles, messages, run } = core
40
43
  const { touch, schedulePersist, materials, tools } = deps
41
- // 标题/主题自动整理(独立关注点,见 retitle.ts;runLoop 每轮结束触发)
44
+ // 标题/主题自动整理 + 窗口外约束折叠(finally 之后并列后台跑,不占用 run)
42
45
  const retitle = createRetitle(core, { touch, schedulePersist })
46
+ const fold = createFold(core, { touch, schedulePersist })
47
+ const nameOf = (speaker: string): string => speakerLabel(speaker, (roles.get(speaker) || { name: undefined }).name)
43
48
 
44
49
  const appendMessage = (sess: SessionRecord, speaker: string, text: string, extra?: Partial<MessageRecord>): MessageRecord => {
45
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() }
@@ -49,6 +54,7 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
49
54
  if (extra.thinkingSummary !== undefined) msg.thinkingSummary = extra.thinkingSummary
50
55
  if (extra.model !== undefined) msg.model = extra.model
51
56
  if (extra.error !== undefined) msg.error = extra.error
57
+ if (extra.failedRoleId !== undefined) msg.failedRoleId = extra.failedRoleId
52
58
  if (Array.isArray(extra.toolCalls) && extra.toolCalls.length > 0) msg.toolCalls = extra.toolCalls
53
59
  }
54
60
  messages.set(msg.id, msg)
@@ -58,38 +64,75 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
58
64
  return msg
59
65
  }
60
66
 
61
- /** 群聊记录 角色上下文块(最近 40 条、逐条 8k 截断、工具行压缩)。 */
62
- const transcriptBlock = (sess: SessionRecord): string => {
67
+ /** 把失败回合写成该角色的消息(speaker = 角色 id),不再用系统胶囊顶替。 */
68
+ const writeFailure = (sess: SessionRecord, role: RoleRecord, err: unknown, replaceId?: string): void => {
69
+ 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
+ }
93
+ appendMessage(sess, role.id, raw, extra)
94
+ }
95
+
96
+ /** 成功发言写入:replaceId 存在则原地覆盖失败卡。 */
97
+ 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)
118
+ }
119
+
120
+ /** 群聊记录 → 角色上下文块(最近 40 条 + 未折入临时原文)。失败卡不进上下文。重试截到该条之前。 */
121
+ const transcriptBlock = (sess: SessionRecord, skipId?: string): string => {
63
122
  const out: string[] = []
64
- for (const mid of sess.messageIds) {
123
+ for (const mid of prefixIds(sess.messageIds, skipId).slice(-WINDOW_SIZE)) {
65
124
  const m = messages.get(mid)
66
- if (!m) continue
67
- const name = m.speaker === 'user' ? '用户' : m.speaker === 'system' ? '系统' : (roles.get(m.speaker) || { name: undefined }).name || '成员'
68
- let text = m.text || ''
69
- if (text.length > 8000) text = text.slice(0, 8000) + '…(已截断)'
70
- let line = '【' + name + '】' + text
71
- if (Array.isArray(m.toolCalls)) {
72
- for (const c of m.toolCalls) {
73
- if (!c || typeof c.tool !== 'string') continue
74
- let brief = ''
75
- try {
76
- brief = JSON.stringify(c.args) || ''
77
- } catch {
78
- brief = ''
79
- }
80
- if (brief.length > 60) brief = brief.slice(0, 60) + '…'
81
- const st = c.status === 'ok' ? '成功' : c.status === 'denied' ? '用户拒绝' : '失败'
82
- let ob = String(c.output || '')
83
- if (ob.length > TRANSCRIPT_TOOL_SUMMARY) ob = ob.slice(0, TRANSCRIPT_TOOL_SUMMARY) + '…'
84
- line += '\n [工具] ' + c.tool + ' ' + brief + ' → ' + st + (ob ? '(' + ob.replace(/\s+/g, ' ') + ')' : '')
85
- }
86
- }
87
- out.push(line)
125
+ if (!m || m.id === skipId || m.error) continue
126
+ out.push(formatTranscriptLine(m, nameOf(m.speaker)))
88
127
  }
89
- return out.slice(-40).join('\n\n')
128
+ const live = out.join('\n\n')
129
+ const temp = tempTranscript(squeezedMessages(messages, sess, skipId), (m) => nameOf(m.speaker))
130
+ if (!temp) return live
131
+ if (!live) return temp
132
+ return temp + '\n\n' + live
90
133
  }
91
134
 
92
- const speak = async (g: GroupRecord, sess: SessionRecord, role: RoleRecord): Promise<SpeakResult> => {
135
+ const speak = async (g: GroupRecord, sess: SessionRecord, role: RoleRecord, skipId?: string): Promise<SpeakResult> => {
93
136
  const ws = await materials.loadWorkspaceFiles(g)
94
137
  const parts = ws.parts
95
138
  const sys = [
@@ -99,6 +142,7 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
99
142
  '- 名称:' + role.name,
100
143
  '- 人设:' + (role.persona ? role.persona : '(未填写,请以积极协作者的身份参与讨论)'),
101
144
  sess.topic ? '\n# 本会话主题\n' + sess.topic : '',
145
+ constraintBlock(sess.constraints),
102
146
  materials.materialBlock(parts, ws.dir),
103
147
  ws.dir
104
148
  ? '\n# 可用工具\n你可以调用工具在群组工作区目录(' + ws.dir + ')内查看文件与目录' +
@@ -115,7 +159,7 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
115
159
  '- 保持简洁,通常不超过 300 字',
116
160
  ].filter((s) => s !== '').join('\n')
117
161
 
118
- const history = transcriptBlock(sess)
162
+ const history = transcriptBlock(sess, skipId)
119
163
  const intro = history
120
164
  ? '以下是本会话的群聊记录(从旧到新):\n\n' + history
121
165
  : '本会话刚刚开始,请围绕主题做简短开场发言。'
@@ -281,10 +325,12 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
281
325
  return { text: texts.join('\n\n').trim(), reasoning: reasonings.join('\n\n').trim() || undefined, toolCalls }
282
326
  }
283
327
 
284
- const runLoop = async (sess: SessionRecord): Promise<void> => {
328
+ const runLoop = async (sess: SessionRecord, opts?: { replaceMessageId?: string }): Promise<void> => {
285
329
  const g = groups.get(sess.groupId)
286
330
  const startCount = sess.messageIds.length
287
331
  let failed = false // 发言失败 → 会话列表「已出错」标记
332
+ let replaceId = opts && opts.replaceMessageId
333
+ run.replaceMessageId = replaceId || null
288
334
  try {
289
335
  while (run.queue.length > 0 && !run.stopping) {
290
336
  const roleId = run.queue.shift()!
@@ -294,15 +340,30 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
294
340
  run.partialReasoning = ''
295
341
  touch()
296
342
  if (!role) continue
343
+ const targetId = replaceId
344
+ replaceId = undefined
297
345
  try {
298
- const out = await speak(g!, sess, role)
346
+ const out = await speak(g!, sess, role, targetId)
299
347
  // 停止后不落部分消息(已有「已停止本次对话」系统消息承接)
300
348
  if (!run.stopping && (out.text || (Array.isArray(out.toolCalls) && out.toolCalls.length > 0))) {
301
- appendMessage(sess, role.id, out.text, { model: role.provider + ' / ' + role.model, reasoning: out.reasoning, toolCalls: out.toolCalls })
349
+ writeSuccess(sess, role, out, targetId)
350
+ } else if (!run.stopping && targetId) {
351
+ // 重试得到空输出:保留失败卡,避免成功覆盖成空白气泡
352
+ failed = true
353
+ run.currentRoleId = null
354
+ run.partial = ''
355
+ run.partialReasoning = ''
356
+ run.replaceMessageId = null
357
+ writeFailure(sess, role, '模型没有返回内容', targetId)
358
+ break
302
359
  }
303
360
  } catch (e) {
304
361
  failed = true
305
- appendMessage(sess, 'system', '角色「' + role.name + '」发言失败:' + String((e && (e as Error).message) || e), { error: true })
362
+ run.currentRoleId = null
363
+ run.partial = ''
364
+ run.partialReasoning = ''
365
+ run.replaceMessageId = null
366
+ writeFailure(sess, role, e, targetId)
306
367
  break
307
368
  }
308
369
  }
@@ -321,10 +382,14 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
321
382
  run.confirmSignal = null
322
383
  run.childProc = null
323
384
  run.stopping = false
385
+ run.replaceMessageId = null
324
386
  touch()
325
387
  }
326
- // 本轮有新消息落盘后台整理标题/主题(fire-and-forget,不产生消息、静默失败)
327
- if (sess.messageIds.length > startCount) void retitle(sess)
388
+ // 本轮有新消息落盘,或原地重试覆盖了失败卡后台整理标题/主题 + 窗口外约束
389
+ if (sess.messageIds.length > startCount || (opts && opts.replaceMessageId)) {
390
+ void retitle(sess)
391
+ void fold(sess)
392
+ }
328
393
  }
329
394
 
330
395
  return { appendMessage, runLoop }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * 窗口外结论/约束备忘:整次 runLoop 结束后后台折叠(仿 retitle)。
3
+ * 立刻 idle;未折完用水位 + 临时原文表达。v1 复用 purpose session-title 关思考。
4
+ * @module dsh-group-chat/host/engine/fold
5
+ */
6
+
7
+ import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
8
+ import {
9
+ CONSTRAINT_MAX_CHARS,
10
+ CONSTRAINT_MAX_ITEMS,
11
+ KIND_LABEL,
12
+ downgradeWithoutUser,
13
+ parseConstraints,
14
+ squeezedMaxSeq,
15
+ squeezedMessages,
16
+ speakerLabel,
17
+ takeFoldBatch,
18
+ } from '../../core/constraints.ts'
19
+ import type { SessionRecord } from '../../core/types.ts'
20
+ 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
+ }
34
+
35
+ /**
36
+ * 每轮 send 结束后折叠窗口外约束:fire-and-forget、不产生消息、静默失败。
37
+ * 同会话去重;会话已清空则 abort。内存 {constraints, constraintsUpToSeq} 一次挂上。
38
+ */
39
+ export function createFold(core: HostState, deps: { touch: () => void, schedulePersist: (targets?: { session?: string | null }) => void }): (sess: SessionRecord) => Promise<void> {
40
+ const { llm, messages, roles } = core
41
+ const { touch, schedulePersist } = deps
42
+ const folding = new Set<string>()
43
+
44
+ const nameOf = (speaker: string): string => speakerLabel(speaker, (roles.get(speaker) || { name: undefined }).name)
45
+
46
+ return async (sess: SessionRecord): Promise<void> => {
47
+ if (folding.has(sess.id)) return
48
+ const squeezed = squeezedMessages(messages, sess)
49
+ if (!squeezed.length) return
50
+ const input = takeFoldBatch(squeezed, (m) => nameOf(m.speaker))
51
+ const watermark = squeezedMaxSeq(input.consumed)
52
+ if (watermark <= 0) return
53
+
54
+ const commit = (next: SessionRecord['constraints'] | undefined): void => {
55
+ const live = core.sessions.get(sess.id)
56
+ if (!live || live.messageIds.length === 0) return
57
+ if (next && next.length) live.constraints = next
58
+ live.constraintsUpToSeq = watermark
59
+ schedulePersist({ session: live.id })
60
+ touch()
61
+ }
62
+
63
+ if (input.allSystem) {
64
+ commit(undefined)
65
+ return
66
+ }
67
+
68
+ const dm = defaultModel(core)
69
+ if (!dm) return
70
+
71
+ folding.add(sess.id)
72
+ try {
73
+ const existing = (sess.constraints || []).map((c) => '- ' + KIND_LABEL[c.kind] + ':' + c.text)
74
+ const sys = [
75
+ '你是群聊会话的约束整理助手。把已经离开最近对话窗口的旧消息压成无主结论/约束备忘。',
76
+ '',
77
+ '# 规则',
78
+ '- 只输出一行 JSON:{"constraints":[{"kind":"decided|rejected|open","text":"…"}, ...]}',
79
+ '- kind 只能是 decided(已定)/ rejected(否决)/ open(未决)',
80
+ '- 无主:条目不写说话人。8–12 条、合计不超过 ' + CONSTRAINT_MAX_CHARS + ' 字,最多 ' + CONSTRAINT_MAX_ITEMS + ' 条',
81
+ '- 已定/否决只能依据【用户】原文;角色对打一律标 open(未决)',
82
+ '- 同主题:新已定覆盖旧未决;旧已定不能因角色反对改写,除非【用户】改口',
83
+ '- 没有新结论时输出 {"constraints":[]}(保留旧备忘)',
84
+ '- 超预算时按 已定 > 未决 > 过程叙述 取舍;语言跟随记录',
85
+ '',
86
+ '当前备忘:',
87
+ existing.length ? existing.join('\n') : '(空)',
88
+ ].join('\n')
89
+ const user = '旧消息(从旧到新,含工具一行摘要):\n\n' + input.lines.join('\n\n')
90
+ let acc = ''
91
+ for await (const chunk of llm.stream({
92
+ provider: dm.provider,
93
+ model: dm.model,
94
+ system: sys,
95
+ purpose: 'session-title',
96
+ messages: [{ id: ('g' + core.revision + '-c0') as Message['id'], role: 'user', content: [{ type: 'text', text: user }], source: { kind: 'user' } }],
97
+ } as GenerateOptions)) {
98
+ if (chunk.type === 'text-delta') {
99
+ acc += chunk.text
100
+ if (acc.length > 4000) break
101
+ } else if (chunk.type === 'finish') {
102
+ break
103
+ }
104
+ }
105
+ const parsed = parseConstraints(acc)
106
+ if (parsed === null) return
107
+ commit(parsed.length ? downgradeWithoutUser(parsed, input.hasUser) : undefined)
108
+ } catch (e) {
109
+ console.error('[dsh-group-chat] 会话约束折叠失败(跳过,不影响对话):', e)
110
+ } finally {
111
+ folding.delete(sess.id)
112
+ }
113
+ }
114
+ }
@@ -5,4 +5,5 @@
5
5
 
6
6
  export { createConversation } from './conversation.ts'
7
7
  export type { Conversation } from './conversation.ts'
8
+ export { createFold } from './fold.ts'
8
9
  export { createRetitle } from './retitle.ts'
@@ -1,13 +1,17 @@
1
1
  /**
2
2
  * 会话标题/主题自动整理(参照 oil-codex-title):每轮结束后用 DSH 默认模型
3
- * 后台生成「类别 emoji + 对象|目标」名称与演进式主题;手动编辑过的字段
3
+ * 后台生成。名称只在仍为默认占位时写一次;主题每轮演进。手动编辑过的字段
4
4
  * 永久跳过(隐式固定)。
5
5
  * @module dsh-group-chat/host/engine/retitle
6
6
  */
7
7
 
8
8
  import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
9
9
  import type { SessionRecord } from '../../core/types.ts'
10
- import type { HostState } from '../state.ts'
10
+ import { DEFAULT_SESSION_NAME, type HostState } from '../state.ts'
11
+
12
+ /** 仍是新建占位名(含改名之前的「会话 N」存量),自动标题尚未落地。 */
13
+ const isPlaceholderName = (name: string): boolean =>
14
+ name === DEFAULT_SESSION_NAME || /^会话 \d+$/.test(name)
11
15
 
12
16
  /** DSH 默认模型(agentDefaultModel 服务缺位或未配置时返回 null,调用方静默跳过)。 */
13
17
  const defaultModel = (core: HostState): { provider: string, model: string } | null => {
@@ -30,7 +34,7 @@ const titleTranscript = (core: HostState, sess: SessionRecord): string => {
30
34
  const out: string[] = []
31
35
  for (const mid of sess.messageIds.slice(-40)) {
32
36
  const m = core.messages.get(mid)
33
- if (!m || m.speaker === 'system' || !m.text) continue
37
+ if (!m || m.speaker === 'system' || m.error || !m.text) continue
34
38
  const name = m.speaker === 'user' ? '用户' : (core.roles.get(m.speaker) || { name: undefined }).name || '成员'
35
39
  out.push('【' + name + '】' + m.text.replace(/\s+/g, ' ').slice(0, 500))
36
40
  }
@@ -55,9 +59,9 @@ const parseRetitle = (raw: string): { name?: string, topic?: string } => {
55
59
 
56
60
  /**
57
61
  * 每轮结束后根据聊天内容整理会话名称与主题:后台 fire-and-forget、不产生
58
- * 消息、静默失败。名称 =「类别 emoji + 对象|目标」(类别固定、对象稳定、
59
- * 目标实质变化才改);主题 = 演进式一句话摘要(对象+目标+当前焦点),注入
60
- * 后续轮次的角色上下文。手动编辑过的字段永久跳过(隐式固定,apply 时复查)。
62
+ * 消息、静默失败。名称只在仍为默认占位时生成一次(「类别 emoji + 对象|目标」);
63
+ * 主题 = 演进式一句话摘要(对象+目标+当前焦点),每轮更新,注入后续角色上下文。
64
+ * 手动编辑过的字段永久跳过(隐式固定,apply 时复查)。
61
65
  */
62
66
  export function createRetitle(core: HostState, deps: { touch: () => void, schedulePersist: (targets?: { session?: string | null }) => void }): (sess: SessionRecord) => Promise<void> {
63
67
  const { llm } = core
@@ -69,7 +73,8 @@ export function createRetitle(core: HostState, deps: { touch: () => void, schedu
69
73
  return async (sess: SessionRecord): Promise<void> => {
70
74
  if (retitling.has(sess.id)) return
71
75
  const dm = defaultModel(core)
72
- if (!dm || (sess.namePinned && sess.topicPinned)) return
76
+ const nameFrozen = !!sess.namePinned || !isPlaceholderName(sess.name)
77
+ if (!dm || (nameFrozen && sess.topicPinned)) return
73
78
  const transcript = titleTranscript(core, sess)
74
79
  if (!transcript) return
75
80
  retitling.add(sess.id)
@@ -80,9 +85,9 @@ export function createRetitle(core: HostState, deps: { touch: () => void, schedu
80
85
  '# 名称规则',
81
86
  '- 格式:「类别 emoji + 对象|目标」,例如「🔎 缓存选型|Redis 与本地 KV 对比」',
82
87
  '- 类别固定六选一:🔎 调研对比(多方案/多观点比较)、💡 头脑风暴(创意发散)、⚖️ 方案评审(评审已有方案或产物)、🛠️ 排查修复(定位与解决问题)、📝 方法整理(总结沉淀方法与知识)、🗣️ 通用讨论(其余兜底)',
83
- '- 对象在前且稳定:把辨识度最高的讨论对象放最前;省略群组名(外层已展示);除非讨论对象实质变化,沿用当前名称里的对象名;「继续」「追问」等不改变主线',
88
+ '- 对象在前:把辨识度最高的讨论对象放最前;省略群组名(外层已展示)',
84
89
  '- 目标 = 当前正在做的事,动宾短语,保持简洁',
85
- '- 名称总长不超过 16 个字',
90
+ '- 名称总长不超过 16 个字;名称只生成一次,不要为了追问/继续而改名',
86
91
  '',
87
92
  '# 主题规则',
88
93
  '- 一句话演进式摘要:讨论对象 + 当前目标 + 当前焦点/分歧点',
@@ -92,7 +97,7 @@ export function createRetitle(core: HostState, deps: { touch: () => void, schedu
92
97
  '- 语言跟随用户消息的主要语言;保留产品名与技术名词',
93
98
  '- 只输出一行 JSON:{"name": "…", "topic": "…"},不要输出其他内容',
94
99
  '',
95
- '当前名称:' + (sess.namePinned ? '(已手动固定,本次不要输出 name 字段)' : sess.name),
100
+ '当前名称:' + (nameFrozen ? '(已固定,本次不要输出 name 字段)' : sess.name),
96
101
  '当前主题:' + (sess.topicPinned ? '(已手动固定,本次不要输出 topic 字段)' : (sess.topic || '(空)')),
97
102
  ].join('\n')
98
103
  let acc = ''
@@ -120,7 +125,7 @@ export function createRetitle(core: HostState, deps: { touch: () => void, schedu
120
125
  }
121
126
  const parsed = parseRetitle(acc)
122
127
  let changed = false
123
- if (parsed.name && !sess.namePinned) { sess.name = parsed.name; changed = true }
128
+ if (parsed.name && !sess.namePinned && isPlaceholderName(sess.name)) { sess.name = parsed.name; changed = true }
124
129
  if (parsed.topic && !sess.topicPinned) { sess.topic = parsed.topic; changed = true }
125
130
  if (changed) {
126
131
  schedulePersist({ session: sess.id })