@dcrays/dcgchat-test 0.6.10 → 0.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -0
- package/index.ts +35 -0
- package/openclaw.plugin.json +12 -41
- package/package.json +35 -8
- package/src/agent.ts +568 -0
- package/src/bot.ts +646 -0
- package/src/channel/index.ts +329 -0
- package/src/channel/outboundMedia.ts +144 -0
- package/src/channel/outboundTarget.ts +62 -0
- package/src/channel/uploadMediaUrl.ts +76 -0
- package/src/cron/message.ts +114 -0
- package/src/cron/params.ts +164 -0
- package/src/cron/toolGuard.ts +466 -0
- package/src/cron/types.ts +15 -0
- package/src/gateway/index.ts +430 -0
- package/src/gateway/security.ts +95 -0
- package/src/gateway/socket.ts +256 -0
- package/src/libs/ali-oss-6.23.0.tgz +0 -0
- package/src/libs/axios-1.13.6.tgz +0 -0
- package/src/libs/md5-2.3.0.tgz +0 -0
- package/src/libs/mime-types-3.0.2.tgz +0 -0
- package/src/libs/unzipper-0.12.3.tgz +0 -0
- package/src/libs/ws-8.19.0.tgz +0 -0
- package/src/monitor.ts +269 -0
- package/src/request/api.ts +80 -0
- package/src/request/oss.ts +200 -0
- package/src/request/request.ts +191 -0
- package/src/request/userInfo.ts +44 -0
- package/src/session.ts +28 -0
- package/src/skill.ts +114 -0
- package/src/tool.ts +603 -0
- package/src/tools/messageTool.ts +334 -0
- package/src/tools/toolCallGuard.ts +327 -0
- package/src/transport.ts +217 -0
- package/src/types.ts +139 -0
- package/src/utils/agentErrors.ts +116 -0
- package/src/utils/constant.ts +60 -0
- package/src/utils/env-config.ts +19 -0
- package/src/utils/formatLlmInputEvent.ts +48 -0
- package/src/utils/gatewayMsgHandler.ts +147 -0
- package/src/utils/global.ts +309 -0
- package/src/utils/inboundTurnState.ts +66 -0
- package/src/utils/log.ts +77 -0
- package/src/utils/mediaAttached.ts +244 -0
- package/src/utils/mediaEmitter.ts +54 -0
- package/src/utils/outboundAssistantText.ts +117 -0
- package/src/utils/params.ts +104 -0
- package/src/utils/passTxt.ts +4 -0
- package/src/utils/resolveRegisterConfig.ts +33 -0
- package/src/utils/searchFile.ts +228 -0
- package/src/utils/sessionState.ts +137 -0
- package/src/utils/sessionTermination.ts +228 -0
- package/src/utils/streamMerge.ts +150 -0
- package/src/utils/subagentRunMap.ts +71 -0
- package/src/utils/undiciFetchInterceptor.ts +346 -0
- package/src/utils/workspaceFilePaths.ts +18 -0
- package/src/utils/wsMessageHandler.ts +124 -0
- package/src/utils/zipExtract.ts +97 -0
- package/src/utils/zipPath.ts +24 -0
- package/index.js +0 -304
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { dcgLogger } from './log.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 从文本中提取 /mobook 目录下的文件
|
|
5
|
+
* @param {string} text
|
|
6
|
+
* @returns {string[]}
|
|
7
|
+
*/
|
|
8
|
+
const EXT_LIST = [
|
|
9
|
+
// 文档类
|
|
10
|
+
'doc',
|
|
11
|
+
'docx',
|
|
12
|
+
'xls',
|
|
13
|
+
'xlsx',
|
|
14
|
+
'ppt',
|
|
15
|
+
'pptx',
|
|
16
|
+
'pdf',
|
|
17
|
+
'txt',
|
|
18
|
+
'rtf',
|
|
19
|
+
'odt',
|
|
20
|
+
|
|
21
|
+
// 数据/开发
|
|
22
|
+
'json',
|
|
23
|
+
'xml',
|
|
24
|
+
'csv',
|
|
25
|
+
'yaml',
|
|
26
|
+
'yml',
|
|
27
|
+
|
|
28
|
+
// 前端/文本
|
|
29
|
+
'html',
|
|
30
|
+
'htm',
|
|
31
|
+
'md',
|
|
32
|
+
'markdown',
|
|
33
|
+
'css',
|
|
34
|
+
'js',
|
|
35
|
+
'ts',
|
|
36
|
+
|
|
37
|
+
// 图片
|
|
38
|
+
'png',
|
|
39
|
+
'jpg',
|
|
40
|
+
'jpeg',
|
|
41
|
+
'gif',
|
|
42
|
+
'bmp',
|
|
43
|
+
'webp',
|
|
44
|
+
'svg',
|
|
45
|
+
'ico',
|
|
46
|
+
'tiff',
|
|
47
|
+
|
|
48
|
+
// 音频
|
|
49
|
+
'mp3',
|
|
50
|
+
'wav',
|
|
51
|
+
'ogg',
|
|
52
|
+
'aac',
|
|
53
|
+
'flac',
|
|
54
|
+
'm4a',
|
|
55
|
+
|
|
56
|
+
// 视频
|
|
57
|
+
'mp4',
|
|
58
|
+
'avi',
|
|
59
|
+
'mov',
|
|
60
|
+
'wmv',
|
|
61
|
+
'flv',
|
|
62
|
+
'mkv',
|
|
63
|
+
'webm',
|
|
64
|
+
|
|
65
|
+
// 压缩包
|
|
66
|
+
'zip',
|
|
67
|
+
'rar',
|
|
68
|
+
'7z',
|
|
69
|
+
'tar',
|
|
70
|
+
'gz',
|
|
71
|
+
'bz2',
|
|
72
|
+
'xz',
|
|
73
|
+
|
|
74
|
+
// 可执行/程序
|
|
75
|
+
'exe',
|
|
76
|
+
'dmg',
|
|
77
|
+
'pkg',
|
|
78
|
+
'apk',
|
|
79
|
+
'ipa',
|
|
80
|
+
|
|
81
|
+
// 其他常见
|
|
82
|
+
'log',
|
|
83
|
+
'dat',
|
|
84
|
+
'bin'
|
|
85
|
+
]
|
|
86
|
+
/**
|
|
87
|
+
* 扩展名按长度降序,用于正则交替,避免 xls 抢先匹配 xlsx、htm 抢先匹配 html 等
|
|
88
|
+
*/
|
|
89
|
+
const EXT_SORTED_FOR_REGEX = [...EXT_LIST].sort((a, b) => b.length - a.length)
|
|
90
|
+
|
|
91
|
+
/** 去除控制符、零宽字符等常见脏值 */
|
|
92
|
+
function stripMobookNoise(s: string) {
|
|
93
|
+
return s.replace(/[\u0000-\u001F\u007F\u200B-\u200D\u200E\u200F\uFEFF]/g, '')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 从文本中扫描 `.../mobook/...` 或 `...\mobook\...` 片段,按最长后缀匹配合法扩展名(兜底)
|
|
98
|
+
*/
|
|
99
|
+
function collectMobookPathsAfterNeedle(text: string, lower: string, needle: string, result: Set<string>): void {
|
|
100
|
+
let from = 0
|
|
101
|
+
while (from < text.length) {
|
|
102
|
+
const i = lower.indexOf(needle, from)
|
|
103
|
+
if (i < 0) break
|
|
104
|
+
const start = i + needle.length
|
|
105
|
+
const tail = text.slice(start)
|
|
106
|
+
const seg = tail.match(/^([^\s\]\)'"}\u3002,,]+)/)
|
|
107
|
+
if (!seg) {
|
|
108
|
+
from = start + 1
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
let raw = stripMobookNoise(seg[1]).trim()
|
|
112
|
+
if (!raw || raw.includes('\uFFFD')) {
|
|
113
|
+
from = start + 1
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
const low = raw.toLowerCase()
|
|
117
|
+
let matchedExt: string | undefined
|
|
118
|
+
for (const ext of EXT_SORTED_FOR_REGEX) {
|
|
119
|
+
if (low.endsWith(`.${ext}`)) {
|
|
120
|
+
matchedExt = ext
|
|
121
|
+
break
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!matchedExt) {
|
|
125
|
+
from = start + 1
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
const base = raw.slice(0, -(matchedExt.length + 1))
|
|
129
|
+
const fileName = `${base}.${matchedExt}`
|
|
130
|
+
if (isValidFileName(fileName)) {
|
|
131
|
+
result.add(normalizePath(`/mobook/${fileName}`))
|
|
132
|
+
}
|
|
133
|
+
from = start + 1
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function collectMobookPathsByScan(text: string, result: Set<string>): void {
|
|
138
|
+
const lower = text.toLowerCase()
|
|
139
|
+
collectMobookPathsAfterNeedle(text, lower, '/mobook/', result)
|
|
140
|
+
collectMobookPathsAfterNeedle(text, lower, '\\mobook\\', result)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function extractMobookFiles(text = '') {
|
|
144
|
+
if (typeof text !== 'string' || !text.trim()) return []
|
|
145
|
+
// 全角冒号(中文输入常见)→ 半角,便于匹配 c:\mobook\
|
|
146
|
+
text = text.replace(/\uFF1A/g, ':')
|
|
147
|
+
const result = new Set<string>()
|
|
148
|
+
// ✅ 扩展名(必须长扩展名优先,见 EXT_SORTED_FOR_REGEX)
|
|
149
|
+
const EXT = `(${EXT_SORTED_FOR_REGEX.join('|')})`
|
|
150
|
+
// ✅ 文件名字符(增强:支持中文、符号)
|
|
151
|
+
const FILE_NAME = `[\\w\\u4e00-\\u9fa5::《》()()\\-\\s]+?`
|
|
152
|
+
try {
|
|
153
|
+
// 1️⃣ `xxx.xxx`
|
|
154
|
+
const backtickReg = new RegExp(`\`([^\\\`]+?\\.${EXT})\``, 'gi')
|
|
155
|
+
;(text.match(backtickReg) || []).forEach((item) => {
|
|
156
|
+
const name = item.replace(/`/g, '').trim()
|
|
157
|
+
if (isValidFileName(name)) {
|
|
158
|
+
result.add(`/mobook/${name}`)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
// 2️⃣ /mobook/xxx.xxx
|
|
162
|
+
const fullPathReg = new RegExp(`/mobook/${FILE_NAME}\\.${EXT}`, 'gi')
|
|
163
|
+
;(text.match(fullPathReg) || []).forEach((p) => {
|
|
164
|
+
result.add(normalizePath(p))
|
|
165
|
+
})
|
|
166
|
+
// 2️⃣b Windows 实际保存路径:C:\mobook\xxx、c:/mobook/xxx、\mobook\xxx(模型常写反斜杠,原先无法识别)
|
|
167
|
+
const winMobookReg = new RegExp(`(?:[a-zA-Z]:)?[/\\\\]mobook[/\\\\]${FILE_NAME}\\.${EXT}`, 'gi')
|
|
168
|
+
;(text.match(winMobookReg) || []).forEach((full) => {
|
|
169
|
+
const name = full.replace(/^(?:[a-zA-Z]:)?[/\\\\]mobook[/\\\\]/i, '').trim()
|
|
170
|
+
if (isValidFileName(name)) {
|
|
171
|
+
result.add(normalizePath(`/mobook/${name}`))
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
// 3️⃣ mobook下的 xxx.xxx
|
|
175
|
+
const inlineReg = new RegExp(`mobook下的\\s*(${FILE_NAME}\\.${EXT})`, 'gi')
|
|
176
|
+
;(text.match(inlineReg) || []).forEach((item) => {
|
|
177
|
+
const match = item.match(new RegExp(`${FILE_NAME}\\.${EXT}`, 'i'))
|
|
178
|
+
if (match && isValidFileName(match[0])) {
|
|
179
|
+
result.add(`/mobook/${match[0].trim()}`)
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
// 🆕 4️⃣ **xxx.xxx**
|
|
183
|
+
const boldReg = new RegExp(`\\*\\*(${FILE_NAME}\\.${EXT})\\*\\*`, 'gi')
|
|
184
|
+
;(text.match(boldReg) || []).forEach((item) => {
|
|
185
|
+
const name = item.replace(/\*\*/g, '').trim()
|
|
186
|
+
if (isValidFileName(name)) {
|
|
187
|
+
result.add(`/mobook/${name}`)
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
// 🆕 5️⃣ xxx.xxx (123字节)
|
|
191
|
+
const looseReg = new RegExp(`(${FILE_NAME}\\.${EXT})\\s*\\(`, 'gi')
|
|
192
|
+
;(text.match(looseReg) || []).forEach((item) => {
|
|
193
|
+
const name = item.replace(/\s*\(.+$/, '').trim()
|
|
194
|
+
if (isValidFileName(name)) {
|
|
195
|
+
result.add(`/mobook/${name}`)
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
// 6️⃣ 兜底:绝对路径等 `.../mobook/<文件名>.<扩展名>` + 最长后缀匹配 + 去脏字符
|
|
199
|
+
collectMobookPathsByScan(text, result)
|
|
200
|
+
} catch (e) {
|
|
201
|
+
dcgLogger(`extractMobookFiles error:${e}`, 'error')
|
|
202
|
+
}
|
|
203
|
+
return [...result]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 校验文件名是否合法(避免脏数据)
|
|
208
|
+
*/
|
|
209
|
+
function isValidFileName(name: string) {
|
|
210
|
+
if (!name) return false
|
|
211
|
+
const cleaned = stripMobookNoise(name).trim()
|
|
212
|
+
if (!cleaned) return false
|
|
213
|
+
if (cleaned.includes('\uFFFD')) return false
|
|
214
|
+
// 过滤异常字符
|
|
215
|
+
if (/[\/\\<>:"|?*]/.test(cleaned)) return false
|
|
216
|
+
// 长度限制(防止异常长字符串)
|
|
217
|
+
if (cleaned.length > 200) return false
|
|
218
|
+
return true
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 规范路径(去重用)
|
|
223
|
+
*/
|
|
224
|
+
function normalizePath(path: string) {
|
|
225
|
+
return path
|
|
226
|
+
.replace(/\/+/g, '/') // 多斜杠 → 单斜杠
|
|
227
|
+
.replace(/\/$/, '') // 去掉结尾 /
|
|
228
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { getWorkspaceDir, snapshotGlobalSessionState, restoreGlobalSessionState, type GlobalSessionStateSnapshot } from './global.js'
|
|
4
|
+
import { snapshotParamsState, restoreParamsState, type ParamsStateSnapshot } from './params.js'
|
|
5
|
+
import { dcgLogger } from './log.js'
|
|
6
|
+
|
|
7
|
+
/** 子 agent 跟踪状态(由 tool.ts 侧维护)的可序列化快照 */
|
|
8
|
+
export type SubagentTrackingSnapshot = {
|
|
9
|
+
activeSubagentRunIdsByRequester: Array<[string, string[]]>
|
|
10
|
+
requesterByChildSessionKey: Array<[string, string]>
|
|
11
|
+
runIdByChildSessionKey: Array<[string, string]>
|
|
12
|
+
requesterSessionKeyByRunId?: Array<[string, string]>
|
|
13
|
+
childSessionKeyByRunId?: Array<[string, string]>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 完整的会话相关内存状态快照 */
|
|
17
|
+
export type SessionStateFile = {
|
|
18
|
+
version: number
|
|
19
|
+
savedAt: number
|
|
20
|
+
params: ParamsStateSnapshot
|
|
21
|
+
global: GlobalSessionStateSnapshot
|
|
22
|
+
subagent: SubagentTrackingSnapshot
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SESSION_STATE_FILE_VERSION = 1
|
|
26
|
+
const SESSION_STATE_FILE_NAME = 'session-state.json'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 将快照落地到 `${workspace}/../session-state.json`,与 cron 走同级约定,
|
|
30
|
+
* 避免运行时污染 workspace 文件树。workspace 未初始化时直接跳过。
|
|
31
|
+
*/
|
|
32
|
+
function getSessionStateFilePath(): string | null {
|
|
33
|
+
const ws = getWorkspaceDir()
|
|
34
|
+
if (!ws) return null
|
|
35
|
+
return path.join(path.dirname(ws), SESSION_STATE_FILE_NAME)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function writeSessionStateFile(subagent: SubagentTrackingSnapshot): void {
|
|
39
|
+
const filePath = getSessionStateFilePath()
|
|
40
|
+
if (!filePath) {
|
|
41
|
+
dcgLogger('writeSessionStateFile: workspace dir not initialized, skip', 'error')
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
const global = snapshotGlobalSessionState()
|
|
45
|
+
const runningSessionKeys = new Set(global.msgStatusBySessionKey.map(([k]) => k))
|
|
46
|
+
if (runningSessionKeys.size === 0) {
|
|
47
|
+
removeSessionStateFile()
|
|
48
|
+
dcgLogger('session state skipped: no running sessions')
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
const payload: SessionStateFile = {
|
|
52
|
+
version: SESSION_STATE_FILE_VERSION,
|
|
53
|
+
savedAt: Date.now(),
|
|
54
|
+
params: snapshotParamsState(runningSessionKeys),
|
|
55
|
+
global,
|
|
56
|
+
subagent
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const dir = path.dirname(filePath)
|
|
60
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
61
|
+
const tmp = `${filePath}.tmp`
|
|
62
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
|
|
63
|
+
fs.renameSync(tmp, filePath)
|
|
64
|
+
dcgLogger(`session state persisted: ${filePath}`)
|
|
65
|
+
} catch (e) {
|
|
66
|
+
dcgLogger(`writeSessionStateFile failed: ${String(e)}`, 'error')
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readSessionStateFile(): SessionStateFile | null {
|
|
71
|
+
const filePath = getSessionStateFilePath()
|
|
72
|
+
if (!filePath || !fs.existsSync(filePath)) return null
|
|
73
|
+
try {
|
|
74
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
75
|
+
const data = JSON.parse(raw) as SessionStateFile
|
|
76
|
+
if (!data || typeof data !== 'object') return null
|
|
77
|
+
if (data.version !== SESSION_STATE_FILE_VERSION) {
|
|
78
|
+
dcgLogger(`readSessionStateFile: version mismatch ${data.version} vs ${SESSION_STATE_FILE_VERSION}`, 'error')
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
return data
|
|
82
|
+
} catch (e) {
|
|
83
|
+
dcgLogger(`readSessionStateFile failed: ${String(e)}`, 'error')
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 恢复 params/global 两个模块的内存状态。subagent 跟踪由 tool.ts 侧自行 restore(避免循环依赖)。
|
|
90
|
+
*/
|
|
91
|
+
export function restoreParamsAndGlobal(snap: SessionStateFile | null): void {
|
|
92
|
+
if (!snap) return
|
|
93
|
+
restoreParamsState(snap.params)
|
|
94
|
+
restoreGlobalSessionState(snap.global)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function removeSessionStateFile(): void {
|
|
98
|
+
const filePath = getSessionStateFilePath()
|
|
99
|
+
if (!filePath) return
|
|
100
|
+
try {
|
|
101
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
|
|
102
|
+
} catch (e) {
|
|
103
|
+
dcgLogger(`removeSessionStateFile failed: ${String(e)}`, 'error')
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function addSessionKeyToSet(out: Set<string>, k: string | undefined) {
|
|
108
|
+
const t = k?.trim()
|
|
109
|
+
if (t) out.add(t)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 从持久化快照中收集所有与「会话」相关的 dcg sessionKey,供网关恢复后 `chat.send` 等用。
|
|
114
|
+
* (不含 `sentMediaKeys` 外层 key:那是 messageId,不是 sessionKey。)
|
|
115
|
+
*/
|
|
116
|
+
export function collectSessionKeysFromSnapshot(snap: SessionStateFile | null | undefined): string[] {
|
|
117
|
+
if (!snap) return []
|
|
118
|
+
const set = new Set<string>()
|
|
119
|
+
for (const [k, status] of snap.global?.msgStatusBySessionKey ?? []) {
|
|
120
|
+
if (status === 'running') addSessionKeyToSet(set, k)
|
|
121
|
+
}
|
|
122
|
+
return [...set]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 网关恢复时只恢复顶层会话,避免对旧子会话发送「继续执行」但本地不恢复 runId 映射,
|
|
127
|
+
* 导致后续子 agent 流式输出无法回到主消息。
|
|
128
|
+
*/
|
|
129
|
+
export function collectRootSessionKeysFromSnapshot(snap: SessionStateFile | null | undefined): string[] {
|
|
130
|
+
if (!snap) return []
|
|
131
|
+
const childKeys = new Set<string>()
|
|
132
|
+
for (const [child] of snap.subagent?.requesterByChildSessionKey ?? []) {
|
|
133
|
+
const k = child?.trim()
|
|
134
|
+
if (k) childKeys.add(k)
|
|
135
|
+
}
|
|
136
|
+
return collectSessionKeysFromSnapshot(snap).filter((k) => !childKeys.has(k))
|
|
137
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话终止 / 抢占 / 网关 abort 的集中实现,便于单独调整策略与观测。
|
|
3
|
+
* 与 inboundTurnState(generation / streamChunkIdx)、入站业务上下文分离,仅负责:本地 AbortController、流抑制标记、
|
|
4
|
+
* 入站串行队尾、activeRunId、以及 /stop control barrier 时的 chat.abort(子会话→主会话)。
|
|
5
|
+
*/
|
|
6
|
+
import { sendGatewayRpc } from '../gateway/socket.js'
|
|
7
|
+
import { dcgLogger } from './log.js'
|
|
8
|
+
import { bumpInboundGeneration, bumpStopSeq, clearInboundGenerationForSession, getStopSeq } from './inboundTurnState.js'
|
|
9
|
+
import { getDescendantSessionKeysForRequester, resetSubagentStateForRequesterSession, cleanupSubagentStateForSession } from '../tool.js'
|
|
10
|
+
import { clearParamsMessage } from './params.js'
|
|
11
|
+
import { setMsgStatus } from './global.js'
|
|
12
|
+
import { clearOutboundTextState } from './outboundAssistantText.js'
|
|
13
|
+
|
|
14
|
+
// 缩短超时时间,避免长时间阻塞
|
|
15
|
+
const GATEWAY_ABORT_TIMEOUT_MS = 5_000
|
|
16
|
+
|
|
17
|
+
// --- 状态(仅本模块内修改,供 bot 通过下方 API 使用)---
|
|
18
|
+
|
|
19
|
+
/** 当前会话最近一次 agent run 的 runId(网关 chat.abort 主会话时携带) */
|
|
20
|
+
const activeRunIdBySessionKey = new Map<string, string>()
|
|
21
|
+
|
|
22
|
+
/** dispatchReplyFromConfig 使用的 AbortSignal,用于真正掐断工具与模型 */
|
|
23
|
+
const dispatchAbortBySessionKey = new Map<string, AbortController>()
|
|
24
|
+
|
|
25
|
+
/** 打断后抑制 deliver / onPartialReply 继续下发 */
|
|
26
|
+
const sessionStreamSuppressed = new Set<string>()
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 同 sessionKey 入站串行队尾;与 Core session lane 对齐,避免并发 dispatch 过早返回。
|
|
30
|
+
*/
|
|
31
|
+
const inboundTurnTailBySessionKey = new Map<string, Promise<void>>()
|
|
32
|
+
|
|
33
|
+
/** 当前 session 正在执行的 /stop 终止屏障;普通消息必须等它结束后再进入新 turn。 */
|
|
34
|
+
const stopBarrierBySessionKey = new Map<string, Promise<void>>()
|
|
35
|
+
|
|
36
|
+
// --- activeRunId(供 bot 在 onAgentRunStart / 错误收尾时同步)---
|
|
37
|
+
|
|
38
|
+
export function setActiveRunIdForSession(sessionKey: string, runId: string): void {
|
|
39
|
+
activeRunIdBySessionKey.set(sessionKey, runId)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function clearActiveRunIdForSession(sessionKey: string): void {
|
|
43
|
+
activeRunIdBySessionKey.delete(sessionKey)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- 流抑制 ---
|
|
47
|
+
|
|
48
|
+
export function isSessionStreamSuppressed(sessionKey: string): boolean {
|
|
49
|
+
return sessionStreamSuppressed.has(sessionKey)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function clearSessionStreamSuppression(sessionKey: string): void {
|
|
53
|
+
sessionStreamSuppressed.delete(sessionKey)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function markSessionStreamSuppressed(sessionKey: string): void {
|
|
57
|
+
sessionStreamSuppressed.add(sessionKey)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- 入站串行队列 ---
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* /stop control path:掐断当前 in-process,等待网关 chat.abort(子会话→主会话)完成后再释放普通消息。
|
|
64
|
+
*
|
|
65
|
+
* 返回 started=false 表示已有同 session 的 stop barrier 在跑;调用方仍应给当前 /stop 消息立即 ack。
|
|
66
|
+
*/
|
|
67
|
+
export function requestStopInterruption(sessionKey: string): { started: boolean; barrier: Promise<void> } {
|
|
68
|
+
const key = sessionKey.trim()
|
|
69
|
+
if (!key) return { started: false, barrier: Promise.resolve() }
|
|
70
|
+
|
|
71
|
+
const existing = stopBarrierBySessionKey.get(key)
|
|
72
|
+
if (existing) {
|
|
73
|
+
dcgLogger(`stop barrier already running sessionKey=${key}`)
|
|
74
|
+
return { started: false, barrier: existing }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let barrier!: Promise<void>
|
|
78
|
+
barrier = Promise.resolve().then(async () => {
|
|
79
|
+
try {
|
|
80
|
+
const c = dispatchAbortBySessionKey.get(key)
|
|
81
|
+
if (c) {
|
|
82
|
+
c.abort()
|
|
83
|
+
dispatchAbortBySessionKey.delete(key)
|
|
84
|
+
dcgLogger(`stop barrier: aborted local controller sessionKey=${key}`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
markSessionStreamSuppressed(key)
|
|
88
|
+
const inboundGenAfterBump = bumpInboundGeneration(key)
|
|
89
|
+
const stopSeqAfterBump = bumpStopSeq(key)
|
|
90
|
+
dcgLogger(`stop barrier: bump inboundGen=${inboundGenAfterBump}, stopSeq=${stopSeqAfterBump}, sessionKey=${key}`)
|
|
91
|
+
|
|
92
|
+
const oldTail = inboundTurnTailBySessionKey.get(key)
|
|
93
|
+
if (oldTail) {
|
|
94
|
+
await Promise.race([oldTail.catch(() => {}), new Promise<void>((r) => setTimeout(r, 100))])
|
|
95
|
+
dcgLogger(`stop barrier: old tail done/timeout sessionKey=${key}`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await abortGatewayRunsForSession(key)
|
|
100
|
+
} catch (e) {
|
|
101
|
+
dcgLogger(`stop barrier: gateway abort: ${String(e)}`, 'error')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
inboundTurnTailBySessionKey.set(key, Promise.resolve())
|
|
105
|
+
dcgLogger(`stop barrier: released inbound queue sessionKey=${key}`)
|
|
106
|
+
} finally {
|
|
107
|
+
if (stopBarrierBySessionKey.get(key) === barrier) {
|
|
108
|
+
stopBarrierBySessionKey.delete(key)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
stopBarrierBySessionKey.set(key, barrier)
|
|
114
|
+
return { started: true, barrier }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 普通入站消息进入队列前调用,保证 /stop 的 gateway abort 已完成或超时。 */
|
|
118
|
+
export async function waitForStopInterruption(sessionKey: string): Promise<void> {
|
|
119
|
+
const key = sessionKey.trim()
|
|
120
|
+
if (!key) return
|
|
121
|
+
for (;;) {
|
|
122
|
+
const barrier = stopBarrierBySessionKey.get(key)
|
|
123
|
+
if (!barrier) return
|
|
124
|
+
dcgLogger(`waiting stop barrier before inbound turn sessionKey=${key}`)
|
|
125
|
+
await barrier.catch((e) => dcgLogger(`stop barrier wait failed: ${String(e)}`, 'error'))
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 将本轮入站处理挂到 sessionKey 队尾,保证同会话顺序执行 */
|
|
130
|
+
export async function runInboundTurnSequenced(sessionKey: string, run: () => Promise<void>): Promise<void> {
|
|
131
|
+
const prev = inboundTurnTailBySessionKey.get(sessionKey) ?? Promise.resolve()
|
|
132
|
+
|
|
133
|
+
const next = prev.catch(() => {}).then(() => run())
|
|
134
|
+
|
|
135
|
+
inboundTurnTailBySessionKey.set(
|
|
136
|
+
sessionKey,
|
|
137
|
+
next.catch((e) => dcgLogger(`session turn failed: ${String(e)}`, 'error'))
|
|
138
|
+
)
|
|
139
|
+
await next
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- 网关 abort ---
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 终止网关上仍可能活跃的 run(子会话自深到浅,再主会话)。
|
|
146
|
+
* 主会话始终 chat.abort;有 mainRunId 则按 run 掐断,无则仅用 sessionKey 扫尾。
|
|
147
|
+
*/
|
|
148
|
+
export async function abortGatewayRunsForSession(sessionKey: string): Promise<void> {
|
|
149
|
+
const prefix = 'interrupt'
|
|
150
|
+
const descendantKeys = getDescendantSessionKeysForRequester(sessionKey)
|
|
151
|
+
const abortSubKeys = [...descendantKeys].reverse()
|
|
152
|
+
const mainRunId = activeRunIdBySessionKey.get(sessionKey)
|
|
153
|
+
|
|
154
|
+
if (abortSubKeys.length > 0) {
|
|
155
|
+
dcgLogger(`${prefix}: chat.abort ${abortSubKeys.length} subagent session(s) (nested incl.)`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const abortAllSequentially = async () => {
|
|
159
|
+
// 按“先子后主”顺序终止,降低主会话先结束导致子会话残留事件的概率。
|
|
160
|
+
for (const subKey of abortSubKeys) {
|
|
161
|
+
try {
|
|
162
|
+
await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: subKey } })
|
|
163
|
+
} catch (e: unknown) {
|
|
164
|
+
dcgLogger(`${prefix}: chat.abort subagent ${subKey}: ${String(e)}`, 'error')
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
await sendGatewayRpc({
|
|
169
|
+
method: 'chat.abort',
|
|
170
|
+
params: mainRunId ? { sessionKey, runId: mainRunId } : { sessionKey }
|
|
171
|
+
})
|
|
172
|
+
} catch (e: unknown) {
|
|
173
|
+
dcgLogger(`${prefix}: chat.abort main ${sessionKey}: ${String(e)}`, 'error')
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await Promise.race([
|
|
179
|
+
abortAllSequentially(),
|
|
180
|
+
new Promise<void>((_, reject) => setTimeout(() => reject(new Error('batch abort timeout')), GATEWAY_ABORT_TIMEOUT_MS))
|
|
181
|
+
])
|
|
182
|
+
} catch (e) {
|
|
183
|
+
dcgLogger(`${prefix}: batch abort timeout: ${String(e)}`, 'error')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
activeRunIdBySessionKey.delete(sessionKey)
|
|
187
|
+
resetSubagentStateForRequesterSession(sessionKey)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// --- 本地 dispatch AbortController ---
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 新一轮非 /stop 用户消息:本地中止上一轮 AbortController(若有)、清除流抑制,并安装本轮 AbortController。
|
|
194
|
+
* 不在此处发往网关 chat.abort;每轮流式由入站串行保证先后,网关 run 收尾由上一轮正常完结或用户 /stop 处理。
|
|
195
|
+
* 调用方须在之前或之后自行 `resetStreamChunkIdxForSession(sessionKey, 0)`(见 inboundTurnState)。
|
|
196
|
+
*/
|
|
197
|
+
export function beginDispatchAbortForInboundTurn(sessionKey: string): AbortController {
|
|
198
|
+
dispatchAbortBySessionKey.get(sessionKey)?.abort()
|
|
199
|
+
sessionStreamSuppressed.delete(sessionKey)
|
|
200
|
+
const ac = new AbortController()
|
|
201
|
+
dispatchAbortBySessionKey.set(sessionKey, ac)
|
|
202
|
+
return ac
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** try/finally 中:仅当仍是当前 controller 时从 map 移除 */
|
|
206
|
+
export function releaseDispatchAbortIfCurrent(sessionKey: string, controller: AbortController | undefined): void {
|
|
207
|
+
if (controller && dispatchAbortBySessionKey.get(sessionKey) === controller) {
|
|
208
|
+
dispatchAbortBySessionKey.delete(sessionKey)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 会话完全终止时的资源清理:移除本模块及 tool 模块中与该会话相关的所有状态,
|
|
214
|
+
* 防止 Map/Set 内存泄漏。
|
|
215
|
+
*/
|
|
216
|
+
export function cleanupTerminatedSession(sessionKey: string): void {
|
|
217
|
+
if (!sessionKey) return
|
|
218
|
+
activeRunIdBySessionKey.delete(sessionKey)
|
|
219
|
+
dispatchAbortBySessionKey.delete(sessionKey)
|
|
220
|
+
sessionStreamSuppressed.delete(sessionKey)
|
|
221
|
+
inboundTurnTailBySessionKey.delete(sessionKey)
|
|
222
|
+
stopBarrierBySessionKey.delete(sessionKey)
|
|
223
|
+
clearInboundGenerationForSession(sessionKey)
|
|
224
|
+
clearParamsMessage(sessionKey)
|
|
225
|
+
setMsgStatus(sessionKey, '')
|
|
226
|
+
clearOutboundTextState(sessionKey)
|
|
227
|
+
cleanupSubagentStateForSession(sessionKey)
|
|
228
|
+
}
|