@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,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 })
@@ -5,7 +5,9 @@
5
5
  */
6
6
 
7
7
  import { existsSync } from 'node:fs'
8
+ import { repairFailedMessage } from '../../core/errors.ts'
8
9
  import { messageJson, roleJson } from '../../core/json.ts'
10
+ import { sanitizeConstraints } from '../../core/constraints.ts'
9
11
  import { asNumber, migrateTier } from '../../core/types.ts'
10
12
  import type { GroupRecord, MessageRecord, RoleRecord, SessionRecord } from '../../core/types.ts'
11
13
  import { emptyGroup, LedgerDocument, scanGroupIds, scanSessionIds, STORE_DIR, Store } from './store.ts'
@@ -17,6 +19,8 @@ interface SessionDocument {
17
19
  topic?: string
18
20
  namePinned?: boolean
19
21
  topicPinned?: boolean
22
+ constraints?: unknown
23
+ constraintsUpToSeq?: number
20
24
  createdAt?: number
21
25
  messages?: Partial<MessageRecord>[]
22
26
  }
@@ -74,6 +78,8 @@ export function createPersistence(core: HostState): Persistence {
74
78
  topic: s.topic,
75
79
  ...(s.namePinned ? { namePinned: true } : {}),
76
80
  ...(s.topicPinned ? { topicPinned: true } : {}),
81
+ ...(s.constraints && s.constraints.length ? { constraints: s.constraints } : {}),
82
+ ...(typeof s.constraintsUpToSeq === 'number' && s.constraintsUpToSeq > 0 ? { constraintsUpToSeq: s.constraintsUpToSeq } : {}),
77
83
  createdAt: s.createdAt,
78
84
  messages: s.messageIds.map((mid) => core.messages.get(mid)).filter(Boolean).map((m) => messageJson(m)).filter(Boolean),
79
85
  })
@@ -178,6 +184,9 @@ export function createPersistence(core: HostState): Persistence {
178
184
  if (typeof doc.topic === 'string') sess.topic = doc.topic
179
185
  if (doc.namePinned === true) sess.namePinned = true
180
186
  if (doc.topicPinned === true) sess.topicPinned = true
187
+ const constraints = sanitizeConstraints(doc.constraints)
188
+ if (constraints.length) sess.constraints = constraints
189
+ if (typeof doc.constraintsUpToSeq === 'number' && doc.constraintsUpToSeq > 0) sess.constraintsUpToSeq = doc.constraintsUpToSeq
181
190
  if (typeof doc.createdAt === 'number') sess.createdAt = doc.createdAt
182
191
  let fallbackSeq = 0
183
192
  for (const m of (Array.isArray(doc.messages) ? doc.messages : [])) {
@@ -194,11 +203,19 @@ export function createPersistence(core: HostState): Persistence {
194
203
  thinkingSummary: m.thinkingSummary !== undefined ? String(m.thinkingSummary) : undefined,
195
204
  model: m.model,
196
205
  error: m.error,
206
+ failedRoleId: typeof m.failedRoleId === 'string' && m.failedRoleId ? m.failedRoleId : undefined,
197
207
  toolCalls: Array.isArray(m.toolCalls) ? m.toolCalls : undefined,
198
208
  ts: typeof m.ts === 'number' ? m.ts : Date.now(),
199
209
  })
200
210
  sess.messageIds.push(m.id)
201
211
  }
212
+ const groupRoles = g.roleIds.map((rid) => core.roles.get(rid)).filter((r): r is RoleRecord => Boolean(r))
213
+ let repaired = false
214
+ for (const mid of sess.messageIds) {
215
+ const rec = core.messages.get(mid)
216
+ if (rec && repairFailedMessage(rec, groupRoles)) repaired = true
217
+ }
218
+ if (repaired) schedulePersist({ session: sid })
202
219
  }
203
220
  core.sessions.set(sid, sess)
204
221
  g.sessionIds.push(sid)
@@ -274,7 +291,7 @@ export function createPersistence(core: HostState): Persistence {
274
291
  if (core.groups.size === 0) {
275
292
  const g: GroupRecord = { id: core.nid('grp'), name: '默认群组', workspaceDir: '', permissionTier: 'view_only', roleIds: [], sessionIds: [] }
276
293
  core.groups.set(g.id, g)
277
- const sess = core.newSession(g.id, '会话 1')
294
+ const sess = core.newSession(g.id)
278
295
  g.sessionIds.push(sess.id)
279
296
  autoSessions.push(sess.id)
280
297
  }
@@ -6,7 +6,7 @@
6
6
  * persistence/ 持久化(store 文件原语 + 脏标记合并落盘 + 启动恢复)
7
7
  * materials/ 资料读取与路径解析 + 目录浏览器
8
8
  * tools/ 工具执行(沙箱 + 确认闸门)
9
- * engine/ 对话引擎(conversation:speak/runLoop;retitle:标题整理)
9
+ * engine/ 对话引擎(conversation:speak/runLoop;retitle:标题整理;fold:窗口外约束)
10
10
  * api/ HTTP 传输(http 护栏 + routes 路由)与动作分发(actions)
11
11
  * @module dsh-group-chat/host/service
12
12
  */
package/src/host/state.ts CHANGED
@@ -28,6 +28,9 @@ declare module '@deepseek-ai/cordis' {
28
28
  /** 角色标识色板(新增角色依序取色)。 */
29
29
  export const PALETTE = ['#5b8def', '#22a06b', '#e8912d', '#c678dd', '#e05661', '#56b6c2', '#98c379', '#d19a66']
30
30
 
31
+ /** 新建会话默认名称(自动标题只在仍为此占位名时生成一次)。 */
32
+ export const DEFAULT_SESSION_NAME = '新会话'
33
+
31
34
  /** 宿主服务共享状态容器(见模块注释;可变原始值一律经 core.* 访问)。 */
32
35
  export interface HostState {
33
36
  ctx: Context
@@ -60,19 +63,17 @@ export function createHostState(ctx: Context): HostState {
60
63
  sessions: new Map(),
61
64
  roles: new Map(),
62
65
  messages: new Map(),
63
- run: { running: false, sessionId: null, currentRoleId: null, partial: '', partialReasoning: '', stopping: false, queue: [], pendingConfirm: null, confirmSignal: null, childProc: null, finished: null },
66
+ run: { running: false, sessionId: null, currentRoleId: null, partial: '', partialReasoning: '', stopping: false, queue: [], pendingConfirm: null, confirmSignal: null, childProc: null, finished: null, replaceMessageId: null },
64
67
  store: null,
65
68
  revision: 1,
66
69
  idSeq: 1,
67
70
  nid: (p: string): string => p + '-' + (core.idSeq++),
68
71
  lastCreated: null,
69
72
  newSession: (groupId: string, name?: string): SessionRecord => {
70
- let n = 0
71
- for (const s of core.sessions.values()) if (s.groupId === groupId) n++
72
73
  const s: SessionRecord = {
73
74
  id: randomUUID(),
74
75
  groupId,
75
- name: name || '会话 ' + (n + 1),
76
+ name: name || DEFAULT_SESSION_NAME,
76
77
  topic: '',
77
78
  messageIds: [],
78
79
  createdAt: Date.now(),
package/src/index.ts CHANGED
@@ -10,6 +10,8 @@
10
10
  * 数据模型:Group 1..N Session,消息挂在会话上;角色与工作区目录挂在群组上
11
11
  * - 群组与会话的增删改;群组/会话检索由客户端在快照上过滤
12
12
  * - 角色发言经 `llm` 服务流式生成,按角色绑定的 provider/model 路由
13
+ * - 整次 runLoop 结束后后台折叠被 40 条窗口挤出的消息为会话级约束备忘
14
+ * (engine/fold.ts;立刻 idle、fire-and-forget,不产生消息)
13
15
  * - 群组工作区目录经 `fs` 服务读取(根下一层文本文件,最多 20 个),
14
16
  * 以「共享资料」块注入每个角色的 system 提示词;无独立笔记/文件清单
15
17
  * - 经 `webServer` 暴露 HTTP API: