@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.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +3308 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +2583 -0
- package/lib/types/client/Bubble.d.ts +10 -0
- package/lib/types/client/Glyph.d.ts +12 -0
- package/lib/types/client/GroupChatPanel.d.ts +10 -0
- package/lib/types/client/GroupChatPanel.old.d.ts +10 -0
- package/lib/types/client/GroupChatSettingsSection.d.ts +15 -0
- package/lib/types/client/PermissionSelect.d.ts +13 -0
- package/lib/types/client/RoleDrawer.d.ts +17 -0
- package/lib/types/client/ThinkRow.d.ts +9 -0
- package/lib/types/client/ToolRow.d.ts +9 -0
- package/lib/types/client/api.d.ts +8 -0
- package/lib/types/client/components/AsidePanel.d.ts +31 -0
- package/lib/types/client/components/Bubble.d.ts +19 -0
- package/lib/types/client/components/ChatPanel.d.ts +57 -0
- package/lib/types/client/components/Composer.d.ts +48 -0
- package/lib/types/client/components/Glyph.d.ts +12 -0
- package/lib/types/client/components/GroupChatSettingsSection.d.ts +15 -0
- package/lib/types/client/components/MessageFlow.d.ts +15 -0
- package/lib/types/client/components/NavPanel.d.ts +40 -0
- package/lib/types/client/components/PermissionSelect.d.ts +13 -0
- package/lib/types/client/components/RoleDrawer.d.ts +17 -0
- package/lib/types/client/components/ThinkRow.d.ts +9 -0
- package/lib/types/client/components/ToolRow.d.ts +9 -0
- package/lib/types/client/components/index.d.ts +9 -0
- package/lib/types/client/components.d.ts +22 -0
- package/lib/types/client/drawer.d.ts +17 -0
- package/lib/types/client/glyph.d.ts +12 -0
- package/lib/types/client/hooks/index.d.ts +6 -0
- package/lib/types/client/hooks/useComposer.d.ts +27 -0
- package/lib/types/client/hooks/useFileSearch.d.ts +19 -0
- package/lib/types/client/hooks/useGroupChatState.d.ts +95 -0
- package/lib/types/client/hooks/useMentionState.d.ts +27 -0
- package/lib/types/client/index.d.ts +52 -0
- package/lib/types/client/lib/api.d.ts +8 -0
- package/lib/types/client/lib/model.d.ts +80 -0
- package/lib/types/client/lib/styles.d.ts +10 -0
- package/lib/types/client/lib/ui.d.ts +17 -0
- package/lib/types/client/model.d.ts +78 -0
- package/lib/types/client/panel.d.ts +10 -0
- package/lib/types/client/settings.d.ts +15 -0
- package/lib/types/client/styles.d.ts +10 -0
- package/lib/types/client/ui.d.ts +17 -0
- package/lib/types/client/utils/utils.d.ts +29 -0
- package/lib/types/client/utils.d.ts +22 -0
- package/lib/types/core/json.d.ts +9 -0
- package/lib/types/core/status.d.ts +18 -0
- package/lib/types/core/tools.d.ts +59 -0
- package/lib/types/core/types.d.ts +256 -0
- package/lib/types/host/actions.d.ts +34 -0
- package/lib/types/host/api/actions.d.ts +36 -0
- package/lib/types/host/api/http.d.ts +16 -0
- package/lib/types/host/api/index.d.ts +7 -0
- package/lib/types/host/api/routes.d.ts +11 -0
- package/lib/types/host/broadcast.d.ts +20 -0
- package/lib/types/host/conversation.d.ts +25 -0
- package/lib/types/host/engine/conversation.d.ts +25 -0
- package/lib/types/host/engine/index.d.ts +7 -0
- package/lib/types/host/engine/retitle.d.ts +20 -0
- package/lib/types/host/http.d.ts +16 -0
- package/lib/types/host/index.d.ts +8 -0
- package/lib/types/host/materials/index.d.ts +6 -0
- package/lib/types/host/materials/materials.d.ts +40 -0
- package/lib/types/host/materials.d.ts +32 -0
- package/lib/types/host/persistence/index.d.ts +6 -0
- package/lib/types/host/persistence/persistence.d.ts +31 -0
- package/lib/types/host/persistence/store.d.ts +65 -0
- package/lib/types/host/persistence.d.ts +31 -0
- package/lib/types/host/routes.d.ts +11 -0
- package/lib/types/host/service.d.ts +28 -0
- package/lib/types/host/state.d.ts +45 -0
- package/lib/types/host/store.d.ts +65 -0
- package/lib/types/host/tools/index.d.ts +6 -0
- package/lib/types/host/tools/tools.d.ts +28 -0
- package/lib/types/host/tools.d.ts +28 -0
- package/lib/types/index.d.ts +41 -0
- package/lib/types/mount-once.d.ts +9 -0
- package/lib/types/shared/file-mention-grammar.d.ts +37 -0
- package/lib/types/shared/file-mention-grammar.test.d.ts +5 -0
- package/package.json +98 -0
- package/src/client/GroupChatPanel.tsx +385 -0
- package/src/client/components/AsidePanel.tsx +188 -0
- package/src/client/components/Bubble.tsx +76 -0
- package/src/client/components/ChatPanel.tsx +214 -0
- package/src/client/components/Composer.tsx +238 -0
- package/src/client/components/Glyph.tsx +27 -0
- package/src/client/components/GroupChatSettingsSection.tsx +74 -0
- package/src/client/components/MessageFlow.tsx +97 -0
- package/src/client/components/NavPanel.tsx +249 -0
- package/src/client/components/PermissionSelect.tsx +159 -0
- package/src/client/components/RoleDrawer.tsx +186 -0
- package/src/client/components/ThinkRow.tsx +32 -0
- package/src/client/components/ToolRow.tsx +51 -0
- package/src/client/components/index.ts +10 -0
- package/src/client/hooks/index.ts +7 -0
- package/src/client/hooks/useComposer.ts +272 -0
- package/src/client/hooks/useFileSearch.ts +115 -0
- package/src/client/hooks/useGroupChatState.ts +214 -0
- package/src/client/hooks/useMentionState.ts +56 -0
- package/src/client/index.ts +110 -0
- package/src/client/lib/api.ts +23 -0
- package/src/client/lib/model.ts +119 -0
- package/src/client/lib/styles.ts +317 -0
- package/src/client/lib/ui.ts +27 -0
- package/src/client/react-dom-shim.d.ts +13 -0
- package/src/client/utils/utils.ts +104 -0
- package/src/core/json.ts +28 -0
- package/src/core/status.ts +33 -0
- package/src/core/tools.ts +41 -0
- package/src/core/types.ts +222 -0
- package/src/host/api/actions.ts +335 -0
- package/src/host/api/http.ts +80 -0
- package/src/host/api/index.ts +8 -0
- package/src/host/api/routes.ts +95 -0
- package/src/host/broadcast.ts +65 -0
- package/src/host/engine/conversation.ts +331 -0
- package/src/host/engine/index.ts +8 -0
- package/src/host/engine/retitle.ts +135 -0
- package/src/host/index.ts +9 -0
- package/src/host/materials/index.ts +7 -0
- package/src/host/materials/materials.ts +193 -0
- package/src/host/persistence/index.ts +7 -0
- package/src/host/persistence/persistence.ts +311 -0
- package/src/host/persistence/store.ts +375 -0
- package/src/host/service.ts +78 -0
- package/src/host/state.ts +85 -0
- package/src/host/tools/index.ts +7 -0
- package/src/host/tools/tools.ts +194 -0
- package/src/index.ts +99 -0
- package/src/mount-once.ts +49 -0
- package/src/shared/file-mention-grammar.test.ts +77 -0
- 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,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'
|