@roaming-ai/dsh-group-chat 0.2.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 (136) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/client.js +3308 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.js +2583 -0
  7. package/lib/types/client/Bubble.d.ts +10 -0
  8. package/lib/types/client/Glyph.d.ts +12 -0
  9. package/lib/types/client/GroupChatPanel.d.ts +10 -0
  10. package/lib/types/client/GroupChatPanel.old.d.ts +10 -0
  11. package/lib/types/client/GroupChatSettingsSection.d.ts +15 -0
  12. package/lib/types/client/PermissionSelect.d.ts +13 -0
  13. package/lib/types/client/RoleDrawer.d.ts +17 -0
  14. package/lib/types/client/ThinkRow.d.ts +9 -0
  15. package/lib/types/client/ToolRow.d.ts +9 -0
  16. package/lib/types/client/api.d.ts +8 -0
  17. package/lib/types/client/components/AsidePanel.d.ts +31 -0
  18. package/lib/types/client/components/Bubble.d.ts +19 -0
  19. package/lib/types/client/components/ChatPanel.d.ts +57 -0
  20. package/lib/types/client/components/Composer.d.ts +48 -0
  21. package/lib/types/client/components/Glyph.d.ts +12 -0
  22. package/lib/types/client/components/GroupChatSettingsSection.d.ts +15 -0
  23. package/lib/types/client/components/MessageFlow.d.ts +15 -0
  24. package/lib/types/client/components/NavPanel.d.ts +40 -0
  25. package/lib/types/client/components/PermissionSelect.d.ts +13 -0
  26. package/lib/types/client/components/RoleDrawer.d.ts +17 -0
  27. package/lib/types/client/components/ThinkRow.d.ts +9 -0
  28. package/lib/types/client/components/ToolRow.d.ts +9 -0
  29. package/lib/types/client/components/index.d.ts +9 -0
  30. package/lib/types/client/components.d.ts +22 -0
  31. package/lib/types/client/drawer.d.ts +17 -0
  32. package/lib/types/client/glyph.d.ts +12 -0
  33. package/lib/types/client/hooks/index.d.ts +6 -0
  34. package/lib/types/client/hooks/useComposer.d.ts +27 -0
  35. package/lib/types/client/hooks/useFileSearch.d.ts +19 -0
  36. package/lib/types/client/hooks/useGroupChatState.d.ts +95 -0
  37. package/lib/types/client/hooks/useMentionState.d.ts +27 -0
  38. package/lib/types/client/index.d.ts +52 -0
  39. package/lib/types/client/lib/api.d.ts +8 -0
  40. package/lib/types/client/lib/model.d.ts +80 -0
  41. package/lib/types/client/lib/styles.d.ts +10 -0
  42. package/lib/types/client/lib/ui.d.ts +17 -0
  43. package/lib/types/client/model.d.ts +78 -0
  44. package/lib/types/client/panel.d.ts +10 -0
  45. package/lib/types/client/settings.d.ts +15 -0
  46. package/lib/types/client/styles.d.ts +10 -0
  47. package/lib/types/client/ui.d.ts +17 -0
  48. package/lib/types/client/utils/utils.d.ts +29 -0
  49. package/lib/types/client/utils.d.ts +22 -0
  50. package/lib/types/core/json.d.ts +9 -0
  51. package/lib/types/core/status.d.ts +18 -0
  52. package/lib/types/core/tools.d.ts +59 -0
  53. package/lib/types/core/types.d.ts +256 -0
  54. package/lib/types/host/actions.d.ts +34 -0
  55. package/lib/types/host/api/actions.d.ts +36 -0
  56. package/lib/types/host/api/http.d.ts +16 -0
  57. package/lib/types/host/api/index.d.ts +7 -0
  58. package/lib/types/host/api/routes.d.ts +11 -0
  59. package/lib/types/host/broadcast.d.ts +20 -0
  60. package/lib/types/host/conversation.d.ts +25 -0
  61. package/lib/types/host/engine/conversation.d.ts +25 -0
  62. package/lib/types/host/engine/index.d.ts +7 -0
  63. package/lib/types/host/engine/retitle.d.ts +20 -0
  64. package/lib/types/host/http.d.ts +16 -0
  65. package/lib/types/host/index.d.ts +8 -0
  66. package/lib/types/host/materials/index.d.ts +6 -0
  67. package/lib/types/host/materials/materials.d.ts +40 -0
  68. package/lib/types/host/materials.d.ts +32 -0
  69. package/lib/types/host/persistence/index.d.ts +6 -0
  70. package/lib/types/host/persistence/persistence.d.ts +31 -0
  71. package/lib/types/host/persistence/store.d.ts +65 -0
  72. package/lib/types/host/persistence.d.ts +31 -0
  73. package/lib/types/host/routes.d.ts +11 -0
  74. package/lib/types/host/service.d.ts +28 -0
  75. package/lib/types/host/state.d.ts +45 -0
  76. package/lib/types/host/store.d.ts +65 -0
  77. package/lib/types/host/tools/index.d.ts +6 -0
  78. package/lib/types/host/tools/tools.d.ts +28 -0
  79. package/lib/types/host/tools.d.ts +28 -0
  80. package/lib/types/index.d.ts +41 -0
  81. package/lib/types/mount-once.d.ts +9 -0
  82. package/lib/types/shared/file-mention-grammar.d.ts +37 -0
  83. package/lib/types/shared/file-mention-grammar.test.d.ts +5 -0
  84. package/package.json +98 -0
  85. package/src/client/GroupChatPanel.tsx +385 -0
  86. package/src/client/components/AsidePanel.tsx +188 -0
  87. package/src/client/components/Bubble.tsx +76 -0
  88. package/src/client/components/ChatPanel.tsx +214 -0
  89. package/src/client/components/Composer.tsx +238 -0
  90. package/src/client/components/Glyph.tsx +27 -0
  91. package/src/client/components/GroupChatSettingsSection.tsx +74 -0
  92. package/src/client/components/MessageFlow.tsx +97 -0
  93. package/src/client/components/NavPanel.tsx +249 -0
  94. package/src/client/components/PermissionSelect.tsx +159 -0
  95. package/src/client/components/RoleDrawer.tsx +186 -0
  96. package/src/client/components/ThinkRow.tsx +32 -0
  97. package/src/client/components/ToolRow.tsx +51 -0
  98. package/src/client/components/index.ts +10 -0
  99. package/src/client/hooks/index.ts +7 -0
  100. package/src/client/hooks/useComposer.ts +272 -0
  101. package/src/client/hooks/useFileSearch.ts +115 -0
  102. package/src/client/hooks/useGroupChatState.ts +214 -0
  103. package/src/client/hooks/useMentionState.ts +56 -0
  104. package/src/client/index.ts +110 -0
  105. package/src/client/lib/api.ts +23 -0
  106. package/src/client/lib/model.ts +119 -0
  107. package/src/client/lib/styles.ts +317 -0
  108. package/src/client/lib/ui.ts +27 -0
  109. package/src/client/react-dom-shim.d.ts +13 -0
  110. package/src/client/utils/utils.ts +104 -0
  111. package/src/core/json.ts +28 -0
  112. package/src/core/status.ts +33 -0
  113. package/src/core/tools.ts +41 -0
  114. package/src/core/types.ts +222 -0
  115. package/src/host/api/actions.ts +335 -0
  116. package/src/host/api/http.ts +80 -0
  117. package/src/host/api/index.ts +8 -0
  118. package/src/host/api/routes.ts +95 -0
  119. package/src/host/broadcast.ts +65 -0
  120. package/src/host/engine/conversation.ts +331 -0
  121. package/src/host/engine/index.ts +8 -0
  122. package/src/host/engine/retitle.ts +135 -0
  123. package/src/host/index.ts +9 -0
  124. package/src/host/materials/index.ts +7 -0
  125. package/src/host/materials/materials.ts +193 -0
  126. package/src/host/persistence/index.ts +7 -0
  127. package/src/host/persistence/persistence.ts +311 -0
  128. package/src/host/persistence/store.ts +375 -0
  129. package/src/host/service.ts +78 -0
  130. package/src/host/state.ts +85 -0
  131. package/src/host/tools/index.ts +7 -0
  132. package/src/host/tools/tools.ts +194 -0
  133. package/src/index.ts +99 -0
  134. package/src/mount-once.ts +49 -0
  135. package/src/shared/file-mention-grammar.test.ts +77 -0
  136. package/src/shared/file-mention-grammar.ts +82 -0
@@ -0,0 +1,331 @@
1
+ /**
2
+ * 对话引擎:消息追加、群聊记录转写、角色发言(speak:prompt 构建 + 流式
3
+ * 轮次 + 工具回注循环)、多轮 runLoop(每轮结束触发 retitle 标题整理)。
4
+ * @module dsh-group-chat/host/engine/conversation
5
+ */
6
+
7
+ import { realpathSync } from 'node:fs'
8
+ import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
9
+ import { TOOL_FLOOR_COST_CHARS, TOOL_REPEAT_LIMIT, TOOL_RESULTS_TOTAL_MAX, TRANSCRIPT_TOOL_SUMMARY } from '../../core/tools.ts'
10
+ import type { GroupRecord, MessageRecord, RoleRecord, SessionRecord, SpeakResult, ToolCallRecord } from '../../core/types.ts'
11
+ import { asEffort } from '../../core/types.ts'
12
+ import type { HostState } from '../state.ts'
13
+ import { createRetitle } from './retitle.ts'
14
+ import type { Materials } from '../materials/index.ts'
15
+ import type { Tools } from '../tools/index.ts'
16
+
17
+ /** llm wire 的工具调用 id(branded)。 */
18
+ type WireToolCallId = Extract<Message['content'][number], { type: 'tool-call' }>['id']
19
+
20
+ /** llm.stream 的单轮流式产物。 */
21
+ interface StreamRound {
22
+ acc: string
23
+ rAcc: string
24
+ errorFinish: { kind: string, failure?: { message?: string } } | null
25
+ maxTokens: boolean
26
+ tcs: { id: string, name: string, args: string }[]
27
+ }
28
+
29
+ /** 对话引擎面。 */
30
+ export interface Conversation {
31
+ /** 追加一条消息到会话(touch + 落盘调度)。 */
32
+ appendMessage: (sess: SessionRecord, speaker: string, text: string, extra?: Partial<MessageRecord>) => MessageRecord
33
+ /** 多轮 round-robin 主循环(send 触发;finally 复位 run)。 */
34
+ runLoop: (sess: SessionRecord) => Promise<void>
35
+ }
36
+
37
+ /** 创建对话引擎。 */
38
+ export function createConversation(core: HostState, deps: { touch: () => void, schedulePersist: (targets?: { session?: string | null }) => void, materials: Materials, tools: Tools }): Conversation {
39
+ const { llm, groups, roles, messages, run } = core
40
+ const { touch, schedulePersist, materials, tools } = deps
41
+ // 标题/主题自动整理(独立关注点,见 retitle.ts;runLoop 每轮结束触发)
42
+ const retitle = createRetitle(core, { touch, schedulePersist })
43
+
44
+ const appendMessage = (sess: SessionRecord, speaker: string, text: string, extra?: Partial<MessageRecord>): MessageRecord => {
45
+ 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() }
46
+ if (extra) {
47
+ if (extra.reasoning !== undefined) msg.reasoning = extra.reasoning
48
+ if (extra.reasoningFull !== undefined) msg.reasoningFull = extra.reasoningFull
49
+ if (extra.thinkingSummary !== undefined) msg.thinkingSummary = extra.thinkingSummary
50
+ if (extra.model !== undefined) msg.model = extra.model
51
+ if (extra.error !== undefined) msg.error = extra.error
52
+ if (Array.isArray(extra.toolCalls) && extra.toolCalls.length > 0) msg.toolCalls = extra.toolCalls
53
+ }
54
+ messages.set(msg.id, msg)
55
+ sess.messageIds.push(msg.id)
56
+ touch()
57
+ schedulePersist({ session: sess.id })
58
+ return msg
59
+ }
60
+
61
+ /** 群聊记录 → 角色上下文块(最近 40 条、逐条 8k 截断、工具行压缩)。 */
62
+ const transcriptBlock = (sess: SessionRecord): string => {
63
+ const out: string[] = []
64
+ for (const mid of sess.messageIds) {
65
+ 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)
88
+ }
89
+ return out.slice(-40).join('\n\n')
90
+ }
91
+
92
+ const speak = async (g: GroupRecord, sess: SessionRecord, role: RoleRecord): Promise<SpeakResult> => {
93
+ const ws = await materials.loadWorkspaceFiles(g)
94
+ const parts = ws.parts
95
+ const sys = [
96
+ '你正在参与一个多角色群聊。你在群中的身份如下,请始终以该身份发言。',
97
+ '',
98
+ '# 你的角色',
99
+ '- 名称:' + role.name,
100
+ '- 人设:' + (role.persona ? role.persona : '(未填写,请以积极协作者的身份参与讨论)'),
101
+ sess.topic ? '\n# 本会话主题\n' + sess.topic : '',
102
+ materials.materialBlock(parts, ws.dir),
103
+ ws.dir
104
+ ? '\n# 可用工具\n你可以调用工具在群组工作区目录(' + ws.dir + ')内查看文件与目录' +
105
+ (g.permissionTier === 'workspace_write'
106
+ ? '、执行 shell 命令(命令需用户逐条确认,请优先用于运行测试)'
107
+ : g.permissionTier === 'full_access'
108
+ ? '、执行 shell 命令(命令将直接执行、无需确认,请谨慎并优先用于运行测试)'
109
+ : '') +
110
+ '。需要事实依据时优先用工具查看,不要凭空猜测。'
111
+ : '',
112
+ '\n# 发言要求',
113
+ '- 直接输出「' + role.name + '」本轮的发言内容本身:不要输出名字前缀、引号、动作旁白或代码围栏',
114
+ '- 回应群内最新讨论(消息中「@你的名字」表示用户点名要求你回应,被点名时请优先回应);与其他成员自然对话;有不同观点可以提出并说明理由',
115
+ '- 保持简洁,通常不超过 300 字',
116
+ ].filter((s) => s !== '').join('\n')
117
+
118
+ const history = transcriptBlock(sess)
119
+ const intro = history
120
+ ? '以下是本会话的群聊记录(从旧到新):\n\n' + history
121
+ : '本会话刚刚开始,请围绕主题做简短开场发言。'
122
+
123
+ // 工具沙箱根:工作区目录的 realpath(loadWorkspaceFiles 成功即目录存在)
124
+ let wsRoot: string | null = null
125
+ if (ws.dir) {
126
+ try {
127
+ wsRoot = realpathSync(ws.dir)
128
+ } catch {
129
+ wsRoot = null
130
+ }
131
+ }
132
+ const toolSchemas = wsRoot !== null ? tools.buildToolSchemas(g) : []
133
+
134
+ const baseOpts: { provider: string, model: string, system: string, temperature?: number, reasoningEffort?: GenerateOptions['reasoningEffort'] } = { provider: role.provider, model: role.model, system: sys }
135
+ if (typeof role.temperature === 'number' && !Number.isNaN(role.temperature)) baseOpts.temperature = role.temperature
136
+ // 深度思考:角色级开关 + 推理级别(取自 DSH 模型设置的 effort 选项);
137
+ // 未配置或 'default' = 不传 reasoningEffort(用 provider 默认);
138
+ // 模型不支持 reasoning effort 时本地解析失败,catch 后删参降级重发
139
+ if (role.thinking === true) {
140
+ const effort = asEffort(role.reasoningEffort) as GenerateOptions['reasoningEffort'] | undefined
141
+ if (effort !== undefined) baseOpts.reasoningEffort = effort
142
+ }
143
+
144
+ const msgs: Message[] = [{ id: ('g' + core.revision + '-m0') as Message['id'], role: 'user', content: [{ type: 'text', text: intro }], source: { kind: 'user' } }]
145
+ const toolCalls: ToolCallRecord[] = []
146
+ const texts: string[] = []
147
+ const reasonings: string[] = []
148
+ let budgetUsed = 0
149
+ let streak = 0
150
+ let lastSig: string | null = null
151
+ let toolsOff = false // 降级(不支持 tools)或强制收尾后不再带 tools
152
+
153
+ // 一轮流式生成:收集 text/reasoning/tool-call(delta 累积 + block-end 闭合;post-block-end delta 忽略)
154
+ const streamRound = async (): Promise<StreamRound> => {
155
+ let acc = ''
156
+ let rAcc = ''
157
+ let errorFinish: StreamRound['errorFinish'] = null
158
+ let maxTokens = false
159
+ const byIndex = new Map<number, { id: string, name: string, args: string, closed: boolean }>()
160
+ const roundTools = toolSchemas.length === 0 || toolsOff ? undefined : toolSchemas
161
+ for await (const chunk of llm.stream({ ...baseOpts, tools: roundTools, messages: msgs })) {
162
+ if (run.stopping) break
163
+ if (chunk.type === 'text-delta') {
164
+ acc += chunk.text
165
+ run.partial = acc
166
+ touch()
167
+ } else if (chunk.type === 'reasoning-delta') {
168
+ rAcc += chunk.text
169
+ run.partialReasoning = rAcc
170
+ touch()
171
+ } else if (chunk.type === 'tool-call-delta') {
172
+ const cur = byIndex.get(chunk.index)
173
+ if (cur && cur.closed) continue
174
+ const entry = cur || { id: 'call-' + chunk.index, name: '', args: '', closed: false }
175
+ if (chunk.id !== undefined) entry.id = chunk.id
176
+ if (chunk.name !== undefined) entry.name = chunk.name
177
+ entry.args += chunk.argumentsDelta
178
+ byIndex.set(chunk.index, entry)
179
+ } else if (chunk.type === 'block-end' && chunk.block && chunk.block.type === 'tool-call') {
180
+ const entry = byIndex.get(chunk.index) || { id: 'call-' + chunk.index, name: '', args: '', closed: false }
181
+ entry.id = chunk.block.id
182
+ entry.name = chunk.block.name
183
+ entry.args = chunk.block.arguments
184
+ entry.closed = true
185
+ byIndex.set(chunk.index, entry)
186
+ } else if (chunk.type === 'finish') {
187
+ const reason = chunk.reason || {}
188
+ if (reason.kind === 'error' || reason.kind === 'aborted') errorFinish = reason
189
+ else if (reason.kind === 'max-tokens') maxTokens = true
190
+ }
191
+ }
192
+ const tcs = [...byIndex.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => ({ id: v.id, name: v.name, args: v.args }))
193
+ return { acc, rAcc, errorFinish, maxTokens, tcs }
194
+ }
195
+
196
+ // 强制收尾:工具历史折叠为纯文本摘要(不带 tools、不带工具块),规避无 tools 参数携带工具历史被拒
197
+ const wrapUp = (note: string): void => {
198
+ const lines = toolCalls.map((c) => {
199
+ let brief = ''
200
+ try {
201
+ brief = JSON.stringify(c.args) || ''
202
+ } catch {
203
+ brief = ''
204
+ }
205
+ if (brief.length > 80) brief = brief.slice(0, 80) + '…'
206
+ const ob = String(c.output || '')
207
+ return '[工具] ' + c.tool + ' ' + brief + ' → ' + c.status + (ob ? ':' + (ob.length > TRANSCRIPT_TOOL_SUMMARY ? ob.slice(0, TRANSCRIPT_TOOL_SUMMARY) + '…' : ob) : '')
208
+ })
209
+ msgs.length = 1
210
+ msgs.push({
211
+ id: ('g' + core.revision + '-w' + msgs.length) as Message['id'],
212
+ role: 'user',
213
+ content: [{ type: 'text', text: (texts.length ? '你此前的发言草稿:\n' + texts.join('\n\n') + '\n\n' : '') + '你的工具执行记录(摘要):\n' + lines.join('\n') + '\n\n' + note + ',请基于以上记录输出最终发言。' }],
214
+ source: { kind: 'user' },
215
+ })
216
+ toolsOff = true
217
+ }
218
+
219
+ for (;;) {
220
+ if (run.stopping) break
221
+ let round: StreamRound
222
+ try {
223
+ round = await streamRound()
224
+ } catch (e) {
225
+ const m = String((e && (e as Error).message) || e)
226
+ if (baseOpts.reasoningEffort !== undefined && m.includes('UNSUPPORTED_REASONING_EFFORT')) {
227
+ delete baseOpts.reasoningEffort
228
+ continue
229
+ }
230
+ throw e
231
+ }
232
+ if (round.errorFinish) {
233
+ const f = round.errorFinish.failure || {}
234
+ const failureText = String(f.message || round.errorFinish.kind)
235
+ // 降级(B2):「不支持 tools」以 error finish 到达;保留 failure 全文判定,去 tools 重试一次
236
+ if (!toolsOff && toolSchemas.length > 0 && /tool/i.test(failureText)) {
237
+ toolsOff = true
238
+ continue
239
+ }
240
+ throw new Error('模型输出异常终止: ' + failureText)
241
+ }
242
+ if (round.acc.trim()) texts.push(round.acc.trim())
243
+ if (round.rAcc.trim()) reasonings.push(round.rAcc.trim())
244
+ // max-tokens:丢弃全部工具块(截断的工具调用不可安全执行),以已有文本收尾
245
+ if (round.maxTokens) break
246
+ if (run.stopping) break
247
+ // 中断(stop break)时半成品工具调用随循环退出整体丢弃
248
+ if (round.tcs.length === 0) break
249
+ // 回注契约(TOOLS.md §3.3):assistant 消息(text + tool-call 块)在前,每工具一条 user-role 结果消息在后
250
+ const assistantContent: Message['content'] = []
251
+ if (round.acc.trim()) assistantContent.push({ type: 'text', text: round.acc })
252
+ for (const tc of round.tcs) assistantContent.push({ type: 'tool-call', id: tc.id as WireToolCallId, name: tc.name, arguments: tc.args })
253
+ msgs.push({ id: ('g' + core.revision + '-a' + msgs.length) as Message['id'], role: 'assistant', content: assistantContent, source: { kind: 'model', provider: role.provider, model: role.model } })
254
+ for (const tc of round.tcs) {
255
+ if (run.stopping) break
256
+ const res = await tools.executeTool(g, wsRoot!, tc)
257
+ const output = String(res.output || '')
258
+ toolCalls.push({ tool: tc.name, args: res.args, status: res.status, output, durationMs: res.durationMs })
259
+ budgetUsed += Math.max(output.length, TOOL_FLOOR_COST_CHARS)
260
+ msgs.push({
261
+ id: ('g' + core.revision + '-t' + msgs.length) as Message['id'],
262
+ role: 'user',
263
+ content: [{ type: 'tool-result', toolCallId: tc.id as WireToolCallId, content: [{ type: 'text', text: output || '(无输出)' }], isError: res.status === 'error' }],
264
+ source: { kind: 'tool', callId: tc.id as WireToolCallId },
265
+ })
266
+ // 死循环检测(B1):连续空输出或与上一次完全相同的调用
267
+ const sig = tc.name + '|' + tc.args + '|' + output
268
+ const isRepeat = output === '' || (lastSig !== null && sig === lastSig)
269
+ streak = isRepeat ? streak + 1 : 1
270
+ lastSig = sig
271
+ }
272
+ if (budgetUsed >= TOOL_RESULTS_TOTAL_MAX) {
273
+ wrapUp('已达工具结果累计上限')
274
+ continue
275
+ }
276
+ if (streak >= TOOL_REPEAT_LIMIT) {
277
+ wrapUp('检测到重复或空输出的工具调用')
278
+ continue
279
+ }
280
+ }
281
+ return { text: texts.join('\n\n').trim(), reasoning: reasonings.join('\n\n').trim() || undefined, toolCalls }
282
+ }
283
+
284
+ const runLoop = async (sess: SessionRecord): Promise<void> => {
285
+ const g = groups.get(sess.groupId)
286
+ const startCount = sess.messageIds.length
287
+ let failed = false // 发言失败 → 会话列表「已出错」标记
288
+ try {
289
+ while (run.queue.length > 0 && !run.stopping) {
290
+ const roleId = run.queue.shift()!
291
+ const role = roles.get(roleId)
292
+ run.currentRoleId = roleId
293
+ run.partial = ''
294
+ run.partialReasoning = ''
295
+ touch()
296
+ if (!role) continue
297
+ try {
298
+ const out = await speak(g!, sess, role)
299
+ // 停止后不落部分消息(已有「已停止本次对话」系统消息承接)
300
+ 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 })
302
+ }
303
+ } catch (e) {
304
+ failed = true
305
+ appendMessage(sess, 'system', '角色「' + role.name + '」发言失败:' + String((e && (e as Error).message) || e), { error: true })
306
+ break
307
+ }
308
+ }
309
+ if (run.stopping) appendMessage(sess, 'system', '已停止本次对话', {})
310
+ } finally {
311
+ // 「输出完毕」标记:正常跑完/用户停止 → ok,发言失败 → error;
312
+ // 内存态(不持久化),新 run 启动或客户端查看(ackFinish)时清除
313
+ run.finished = { sessionId: sess.id, reason: failed ? 'error' : 'ok' }
314
+ run.running = false
315
+ run.sessionId = null
316
+ run.currentRoleId = null
317
+ run.partial = ''
318
+ run.partialReasoning = ''
319
+ run.queue = []
320
+ run.pendingConfirm = null
321
+ run.confirmSignal = null
322
+ run.childProc = null
323
+ run.stopping = false
324
+ touch()
325
+ }
326
+ // 本轮有新消息落盘 → 后台整理标题/主题(fire-and-forget,不产生消息、静默失败)
327
+ if (sess.messageIds.length > startCount) void retitle(sess)
328
+ }
329
+
330
+ return { appendMessage, runLoop }
331
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * 对话引擎层统一导出
3
+ * @module dsh-group-chat/host/engine
4
+ */
5
+
6
+ export { createConversation } from './conversation.ts'
7
+ export type { Conversation } from './conversation.ts'
8
+ export { createRetitle } from './retitle.ts'
@@ -0,0 +1,135 @@
1
+ /**
2
+ * 会话标题/主题自动整理(参照 oil-codex-title):每轮结束后用 DSH 默认模型
3
+ * 后台生成「类别 emoji + 对象|目标」名称与演进式主题;手动编辑过的字段
4
+ * 永久跳过(隐式固定)。
5
+ * @module dsh-group-chat/host/engine/retitle
6
+ */
7
+
8
+ import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
9
+ import type { SessionRecord } from '../../core/types.ts'
10
+ import type { HostState } from '../state.ts'
11
+
12
+ /** DSH 默认模型(agentDefaultModel 服务缺位或未配置时返回 null,调用方静默跳过)。 */
13
+ const defaultModel = (core: HostState): { provider: string, model: string } | null => {
14
+ try {
15
+ // cordis 语义:未 inject 的 ctx 属性访问会抛错("cannot get property without
16
+ // inject"),`?.` 接不住——可选消费必须走 reflect.get(读全局注册表,缺位返回
17
+ // undefined)。直接属性访问曾致 retitle 被静默跳过(标题/主题从不生成)。
18
+ const svc = core.ctx.reflect.get('agentDefaultModel')
19
+ const sel = svc ? svc.currentSelection() : null
20
+ return sel && typeof sel.provider === 'string' && typeof sel.model === 'string' && sel.provider && sel.model
21
+ ? { provider: sel.provider, model: sel.model }
22
+ : null
23
+ } catch {
24
+ return null
25
+ }
26
+ }
27
+
28
+ /** 命名输入:最近 40 条非系统消息的紧凑转写(每条 500 字符封顶,命名不需要全文)。 */
29
+ const titleTranscript = (core: HostState, sess: SessionRecord): string => {
30
+ const out: string[] = []
31
+ for (const mid of sess.messageIds.slice(-40)) {
32
+ const m = core.messages.get(mid)
33
+ if (!m || m.speaker === 'system' || !m.text) continue
34
+ const name = m.speaker === 'user' ? '用户' : (core.roles.get(m.speaker) || { name: undefined }).name || '成员'
35
+ out.push('【' + name + '】' + m.text.replace(/\s+/g, ' ').slice(0, 500))
36
+ }
37
+ return out.join('\n')
38
+ }
39
+
40
+ /** 宽容解析模型输出:剥代码围栏 → 取首个 { 至末个 } 的 JSON → 校验并封顶字段。 */
41
+ const parseRetitle = (raw: string): { name?: string, topic?: string } => {
42
+ const body = raw.replace(/```(?:json)?/g, '')
43
+ const l = body.indexOf('{')
44
+ const r = body.lastIndexOf('}')
45
+ if (l < 0 || r <= l) return {}
46
+ try {
47
+ const o = JSON.parse(body.slice(l, r + 1)) as { name?: unknown, topic?: unknown }
48
+ const name = typeof o.name === 'string' ? o.name.trim() : ''
49
+ const topic = typeof o.topic === 'string' ? o.topic.trim() : ''
50
+ return { name: name ? name.slice(0, 24) : undefined, topic: topic ? topic.slice(0, 120) : undefined }
51
+ } catch {
52
+ return {}
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 每轮结束后根据聊天内容整理会话名称与主题:后台 fire-and-forget、不产生
58
+ * 消息、静默失败。名称 =「类别 emoji + 对象|目标」(类别固定、对象稳定、
59
+ * 目标实质变化才改);主题 = 演进式一句话摘要(对象+目标+当前焦点),注入
60
+ * 后续轮次的角色上下文。手动编辑过的字段永久跳过(隐式固定,apply 时复查)。
61
+ */
62
+ export function createRetitle(core: HostState, deps: { touch: () => void, schedulePersist: (targets?: { session?: string | null }) => void }): (sess: SessionRecord) => Promise<void> {
63
+ const { llm } = core
64
+ const { touch, schedulePersist } = deps
65
+
66
+ /** 进行中的命名会话集合(同会话并发去重)。 */
67
+ const retitling = new Set<string>()
68
+
69
+ return async (sess: SessionRecord): Promise<void> => {
70
+ if (retitling.has(sess.id)) return
71
+ const dm = defaultModel(core)
72
+ if (!dm || (sess.namePinned && sess.topicPinned)) return
73
+ const transcript = titleTranscript(core, sess)
74
+ if (!transcript) return
75
+ retitling.add(sess.id)
76
+ try {
77
+ const sys = [
78
+ '你是群聊会话的命名助手。根据群聊记录为这个会话生成名称与主题。',
79
+ '',
80
+ '# 名称规则',
81
+ '- 格式:「类别 emoji + 对象|目标」,例如「🔎 缓存选型|Redis 与本地 KV 对比」',
82
+ '- 类别固定六选一:🔎 调研对比(多方案/多观点比较)、💡 头脑风暴(创意发散)、⚖️ 方案评审(评审已有方案或产物)、🛠️ 排查修复(定位与解决问题)、📝 方法整理(总结沉淀方法与知识)、🗣️ 通用讨论(其余兜底)',
83
+ '- 对象在前且稳定:把辨识度最高的讨论对象放最前;省略群组名(外层已展示);除非讨论对象实质变化,沿用当前名称里的对象名;「继续」「追问」等不改变主线',
84
+ '- 目标 = 当前正在做的事,动宾短语,保持简洁',
85
+ '- 名称总长不超过 16 个字',
86
+ '',
87
+ '# 主题规则',
88
+ '- 一句话演进式摘要:讨论对象 + 当前目标 + 当前焦点/分歧点',
89
+ '- 不超过 60 个字;供后续讨论作为上下文锚,跟随最新进展更新',
90
+ '',
91
+ '# 其他',
92
+ '- 语言跟随用户消息的主要语言;保留产品名与技术名词',
93
+ '- 只输出一行 JSON:{"name": "…", "topic": "…"},不要输出其他内容',
94
+ '',
95
+ '当前名称:' + (sess.namePinned ? '(已手动固定,本次不要输出 name 字段)' : sess.name),
96
+ '当前主题:' + (sess.topicPinned ? '(已手动固定,本次不要输出 topic 字段)' : (sess.topic || '(空)')),
97
+ ].join('\n')
98
+ let acc = ''
99
+ for await (const chunk of llm.stream({
100
+ provider: dm.provider,
101
+ model: dm.model,
102
+ system: sys,
103
+ // 命名任务关闭思考、直接生成:purpose 'session-title' 是 DSH 为辅助
104
+ // 命名调用定义的专用语义(主会话标题生成同款)——deepseek 协议
105
+ // adapter 据此 thinking: disabled,无 reasoning、秒级返回;不识别
106
+ // 该字段的 adapter 安全忽略(可选字段,无错误面,退回模型默认行为)。
107
+ // 不设 maxTokens:思考模型的 reasoning 与正文共享输出预算,任何小
108
+ // 上限都可能被思考耗尽(finish=max-tokens、正文空、静默无变更);
109
+ // 跟随 provider 默认输出上限(与角色发言 speak 同一先例),正文侧
110
+ // 由 acc 2000 字符截断兜底
111
+ purpose: 'session-title',
112
+ messages: [{ id: ('g' + core.revision + '-t0') as Message['id'], role: 'user', content: [{ type: 'text', text: '群聊记录(从旧到新):\n\n' + transcript }], source: { kind: 'user' } }],
113
+ } as GenerateOptions)) {
114
+ if (chunk.type === 'text-delta') {
115
+ acc += chunk.text
116
+ if (acc.length > 2000) break
117
+ } else if (chunk.type === 'finish') {
118
+ break
119
+ }
120
+ }
121
+ const parsed = parseRetitle(acc)
122
+ let changed = false
123
+ if (parsed.name && !sess.namePinned) { sess.name = parsed.name; changed = true }
124
+ if (parsed.topic && !sess.topicPinned) { sess.topic = parsed.topic; changed = true }
125
+ if (changed) {
126
+ schedulePersist({ session: sess.id })
127
+ touch()
128
+ }
129
+ } catch (e) {
130
+ console.error('[dsh-group-chat] 会话标题整理失败(跳过,不影响对话):', e)
131
+ } finally {
132
+ retitling.delete(sess.id)
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 群聊宿主半对外门面(对齐浏览器半 src/client/index.ts 的入口角色):
3
+ * 插件入口(src/index.ts)只从这里取宿主面的装配件。
4
+ * @module dsh-group-chat/host
5
+ */
6
+
7
+ export { createGroupChatService } from './service.ts'
8
+ export type { GroupChatService } from './service.ts'
9
+ export { makeGroupChatRoutes } from './api/routes.ts'
@@ -0,0 +1,7 @@
1
+ /**
2
+ * 资料层统一导出
3
+ * @module dsh-group-chat/host/materials
4
+ */
5
+
6
+ export { createMaterials } from './materials.ts'
7
+ export type { Materials } from './materials.ts'