@wenbin_wb/dsh-bridge 2.10.8 → 2.10.9
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/CHANGELOG.md +12 -0
- package/README.en.md +27 -0
- package/README.md +27 -0
- package/docs/telegram-usage.md +1 -1
- package/lib/auth/dsh-native-cookie.js +148 -0
- package/lib/bridge-rpc.js +9 -2
- package/lib/compat.js +129 -129
- package/lib/connection-compat.js +115 -0
- package/lib/feishu/index.js +225 -225
- package/lib/feishu/node.js +439 -439
- package/lib/index.js +5 -26
- package/lib/platform/base.js +147 -147
- package/lib/platform/commands.js +221 -221
- package/lib/platform/conversation-bridge.js +821 -821
- package/lib/platform/dsh-storage.js +117 -117
- package/lib/platform/index.js +10 -10
- package/lib/platform/message-split.js +229 -229
- package/lib/platform/session-catalog.js +372 -372
- package/lib/platform/stream-slices.js +21 -21
- package/lib/qq/index.js +312 -312
- package/lib/telegram/index.js +216 -216
- package/lib/telegram/node.js +322 -322
- package/lib/wechat/gateway.js +986 -986
- package/lib/wechat/index.js +244 -244
- package/lib/wechat/media.js +285 -285
- package/lib/wechat/node.js +352 -352
- package/package.json +2 -2
|
@@ -1,229 +1,229 @@
|
|
|
1
|
-
// 出站消息分块与 SEND_FILE 指令解析(平台无关纯函数)
|
|
2
|
-
// 自 conversation-bridge.js 拆出:按平台 maxMessageChars 分块、保留 fenced code block、
|
|
3
|
-
// [SEND_FILE: ...] 显式指令提取与路径解析。
|
|
4
|
-
import { statSync } from 'node:fs'
|
|
5
|
-
import { isAbsolute, normalize, relative, resolve } from 'node:path'
|
|
6
|
-
|
|
7
|
-
const FENCE_RE = /^```([^\n`]*)\s*$/
|
|
8
|
-
|
|
9
|
-
function normalizeMarkdownBlocks(content) {
|
|
10
|
-
const lines = content.split('\n')
|
|
11
|
-
const out = []
|
|
12
|
-
let blankRun = 0
|
|
13
|
-
let inCode = false
|
|
14
|
-
for (const raw of lines) {
|
|
15
|
-
const line = raw.replace(/\s+$/, '')
|
|
16
|
-
if (FENCE_RE.test(line.trim())) {
|
|
17
|
-
inCode = !inCode
|
|
18
|
-
out.push(line)
|
|
19
|
-
blankRun = 0
|
|
20
|
-
continue
|
|
21
|
-
}
|
|
22
|
-
if (inCode) {
|
|
23
|
-
out.push(line)
|
|
24
|
-
continue
|
|
25
|
-
}
|
|
26
|
-
if (!line.trim()) {
|
|
27
|
-
blankRun += 1
|
|
28
|
-
if (blankRun <= 1) out.push('')
|
|
29
|
-
continue
|
|
30
|
-
}
|
|
31
|
-
blankRun = 0
|
|
32
|
-
out.push(line)
|
|
33
|
-
}
|
|
34
|
-
return out.join('\n').trim()
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function splitMarkdownBlocks(content) {
|
|
38
|
-
const blocks = []
|
|
39
|
-
let current = []
|
|
40
|
-
let inCode = false
|
|
41
|
-
const flush = () => {
|
|
42
|
-
const block = current.join('\n').trim()
|
|
43
|
-
if (block) blocks.push(block)
|
|
44
|
-
current = []
|
|
45
|
-
}
|
|
46
|
-
for (const raw of content.split('\n')) {
|
|
47
|
-
const line = raw.replace(/\s+$/, '')
|
|
48
|
-
if (FENCE_RE.test(line.trim())) {
|
|
49
|
-
if (!inCode && current.length) flush()
|
|
50
|
-
current.push(line)
|
|
51
|
-
inCode = !inCode
|
|
52
|
-
if (!inCode) flush()
|
|
53
|
-
continue
|
|
54
|
-
}
|
|
55
|
-
if (inCode) {
|
|
56
|
-
current.push(line)
|
|
57
|
-
continue
|
|
58
|
-
}
|
|
59
|
-
if (!line.trim()) {
|
|
60
|
-
flush()
|
|
61
|
-
continue
|
|
62
|
-
}
|
|
63
|
-
current.push(line)
|
|
64
|
-
}
|
|
65
|
-
flush()
|
|
66
|
-
return blocks
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function hardSplit(text, max) {
|
|
70
|
-
const chunks = []
|
|
71
|
-
let rest = text
|
|
72
|
-
while (rest.length > max) {
|
|
73
|
-
chunks.push(rest.slice(0, max))
|
|
74
|
-
rest = rest.slice(max)
|
|
75
|
-
}
|
|
76
|
-
if (rest) chunks.push(rest)
|
|
77
|
-
return chunks
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function packBlocks(blocks, max) {
|
|
81
|
-
const units = []
|
|
82
|
-
let current = ''
|
|
83
|
-
for (const block of blocks) {
|
|
84
|
-
const candidate = current ? `${current}\n\n${block}` : block
|
|
85
|
-
if (candidate.length <= max) {
|
|
86
|
-
current = candidate
|
|
87
|
-
continue
|
|
88
|
-
}
|
|
89
|
-
if (current) units.push(current)
|
|
90
|
-
if (block.length <= max) {
|
|
91
|
-
current = block
|
|
92
|
-
} else {
|
|
93
|
-
units.push(...hardSplit(block, max))
|
|
94
|
-
current = ''
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
if (current) units.push(current)
|
|
98
|
-
return units
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function splitForIM(content, max = 2000) {
|
|
102
|
-
// 安全检查:防止畸形输入导致无限循环或崩溃
|
|
103
|
-
if (typeof content !== 'string' || content.length === 0) return []
|
|
104
|
-
if (content.length > 1_000_000) {
|
|
105
|
-
content = content.slice(0, 1_000_000) + '\n\n[已截断:内容过长]'
|
|
106
|
-
}
|
|
107
|
-
const normalized = normalizeMarkdownBlocks(content)
|
|
108
|
-
if (!normalized) return []
|
|
109
|
-
if (normalized.length <= max) return [normalized]
|
|
110
|
-
return packBlocks(splitMarkdownBlocks(normalized), max)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// 协议标记过滤:模型偶尔会把工具调用语法(如 <||DSML||tool_calls>…)当正文
|
|
114
|
-
// 输出,这些是协议内容,转发到 IM 只会是乱码噪音。整块移除 tool_calls 段落,
|
|
115
|
-
// 再剥掉残余的 DSML 标签;剩余正文照常发送。
|
|
116
|
-
export function stripProtocolMarkup(text) {
|
|
117
|
-
if (!text || typeof text !== 'string' || !text.includes('DSML')) return text
|
|
118
|
-
let out = text.replace(/<[^<>]*DSML[^<>]*tool_calls[^<>]*>[\s\S]*?<[^<>]*DSML[^<>]*tool_calls[^<>]*>/g, '')
|
|
119
|
-
out = out.replace(/<[^<>]*DSML[^<>]*>/g, '')
|
|
120
|
-
return out
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export function textOfAssistantMessage(message) {
|
|
124
|
-
const raw = (message.content ?? [])
|
|
125
|
-
.filter((block) => block?.type === 'text')
|
|
126
|
-
.map((block) => block.text)
|
|
127
|
-
.join('\n')
|
|
128
|
-
return stripProtocolMarkup(raw)
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* 尝试将任意路径(绝对或相对当前工作区)解析为真实存在的本地文件绝对路径
|
|
133
|
-
*/
|
|
134
|
-
export function resolveFilePath(rawPath, cwd = process.cwd()) {
|
|
135
|
-
if (typeof rawPath !== 'string') return null
|
|
136
|
-
let p = rawPath.trim()
|
|
137
|
-
.replace(/^["'`]|["'`]$/g, '')
|
|
138
|
-
.replace(/^file:\/\/\/?/, '')
|
|
139
|
-
.replace(/^[📁📄📦\s]+/, '')
|
|
140
|
-
if (!p) return null
|
|
141
|
-
// 排除 HTTP/HTTPS 网址
|
|
142
|
-
if (/^https?:\/\//i.test(p)) return null
|
|
143
|
-
const resolved = isAbsolute(p) ? normalize(p) : resolve(cwd, p)
|
|
144
|
-
try {
|
|
145
|
-
if (statSync(resolved).isFile()) {
|
|
146
|
-
return resolved
|
|
147
|
-
}
|
|
148
|
-
} catch {}
|
|
149
|
-
return null
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* 判断解析后的文件路径是否允许经 [SEND_FILE] 发送给 IM。
|
|
154
|
-
* 安全约束(防止模型被诱导后外发任意本地文件):
|
|
155
|
-
* 1. 必须位于 allowedRoots 中的某个根目录(含子目录)内 —— 默认仅会话 cwd;
|
|
156
|
-
* 2. 路径任何一段不得命中敏感名单(.ssh/.gnupg/.aws/.git/.env/.credentials 等)。
|
|
157
|
-
* @param {string} resolvedPath 已解析的绝对路径
|
|
158
|
-
* @param {string|string[]} allowedRoots 允许的根目录(绝对路径);默认 process.cwd()
|
|
159
|
-
* @returns {boolean}
|
|
160
|
-
*/
|
|
161
|
-
export function isPathAllowedForSend(resolvedPath, allowedRoots = process.cwd()) {
|
|
162
|
-
if (typeof resolvedPath !== 'string' || !resolvedPath) return false
|
|
163
|
-
const roots = Array.isArray(allowedRoots) ? allowedRoots : [allowedRoots]
|
|
164
|
-
if (roots.length === 0) return false
|
|
165
|
-
|
|
166
|
-
const normalized = resolve(resolvedPath)
|
|
167
|
-
const pathParts = normalized.split(/[\\/]/).filter(Boolean)
|
|
168
|
-
const SENSITIVE_PARTS = new Set([
|
|
169
|
-
'.ssh', '.gnupg', '.aws', '.azure', '.kube', '.git', '.svn', '.hg',
|
|
170
|
-
'.bash_history', '.zsh_history', '.profile', '.bash_profile', '.bashrc',
|
|
171
|
-
'.zshrc', '.netrc', '.env', '.npmrc', '.credentials', 'id_rsa', 'id_ed25519',
|
|
172
|
-
'id_ecdsa', 'id_dsa', 'shadow', 'passwd',
|
|
173
|
-
])
|
|
174
|
-
for (const part of pathParts) {
|
|
175
|
-
if (SENSITIVE_PARTS.has(part.toLowerCase())) return false
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
for (const root of roots) {
|
|
179
|
-
if (typeof root !== 'string' || !root) continue
|
|
180
|
-
const normRoot = resolve(root)
|
|
181
|
-
if (normalized === normRoot) return true
|
|
182
|
-
// 用 relative 判断是否位于根内:越界时 rel 为 '..' 或以 '../' 开头
|
|
183
|
-
// (Windows 跨盘则 rel 是绝对路径,isAbsolute 拦截)。跨平台正确处理 \ 与 / 差异。
|
|
184
|
-
const rel = relative(normRoot, normalized)
|
|
185
|
-
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return true
|
|
186
|
-
}
|
|
187
|
-
return false
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* 提取并过滤文本中的 [SEND_FILE: <path>] 显式发送指令
|
|
192
|
-
* 由 AI 根据用户意图显式决定何时向用户发送文件附件,杜绝底层盲目扫描与误发。
|
|
193
|
-
* @param {string} text - 原始助手回复文本
|
|
194
|
-
* @param {string} cwd - 会话当前工作目录
|
|
195
|
-
* @returns {{ cleanText: string, files: string[] }}
|
|
196
|
-
*/
|
|
197
|
-
export function extractAndStripSendFileDirectives(text, cwd = process.cwd()) {
|
|
198
|
-
if (typeof text !== 'string' || !text.trim()) {
|
|
199
|
-
return { cleanText: text || '', files: [] }
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const files = []
|
|
203
|
-
const directiveRegex = /\[(?:SEND_FILE|SEND-FILE|send_file|send-file|SEND_MEDIA|send_media):\s*[`"']?([^\]`"'\r\n]+?)[`"']?\s*\]/gi
|
|
204
|
-
|
|
205
|
-
let m
|
|
206
|
-
const re = new RegExp(directiveRegex)
|
|
207
|
-
while ((m = re.exec(text)) !== null) {
|
|
208
|
-
const rawPath = m[1].trim()
|
|
209
|
-
const resolved = resolveFilePath(rawPath, cwd)
|
|
210
|
-
if (resolved && !files.includes(resolved)) {
|
|
211
|
-
files.push(resolved)
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// 从聊天正文中彻底剔除控制指令(保持 IM 聊天气泡的干净整洁)
|
|
216
|
-
const cleanText = text.replace(directiveRegex, '').replace(/\n{3,}/g, '\n\n').trim()
|
|
217
|
-
|
|
218
|
-
return { cleanText, files }
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* 提取文本中的产物文件路径(基于显式指令)
|
|
223
|
-
*/
|
|
224
|
-
export function extractFilePathsFromText(text, cwd = process.cwd()) {
|
|
225
|
-
return extractAndStripSendFileDirectives(text, cwd).files
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ---------------------------------------------------------------------------
|
|
229
|
-
// digest 摘要
|
|
1
|
+
// 出站消息分块与 SEND_FILE 指令解析(平台无关纯函数)
|
|
2
|
+
// 自 conversation-bridge.js 拆出:按平台 maxMessageChars 分块、保留 fenced code block、
|
|
3
|
+
// [SEND_FILE: ...] 显式指令提取与路径解析。
|
|
4
|
+
import { statSync } from 'node:fs'
|
|
5
|
+
import { isAbsolute, normalize, relative, resolve } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const FENCE_RE = /^```([^\n`]*)\s*$/
|
|
8
|
+
|
|
9
|
+
function normalizeMarkdownBlocks(content) {
|
|
10
|
+
const lines = content.split('\n')
|
|
11
|
+
const out = []
|
|
12
|
+
let blankRun = 0
|
|
13
|
+
let inCode = false
|
|
14
|
+
for (const raw of lines) {
|
|
15
|
+
const line = raw.replace(/\s+$/, '')
|
|
16
|
+
if (FENCE_RE.test(line.trim())) {
|
|
17
|
+
inCode = !inCode
|
|
18
|
+
out.push(line)
|
|
19
|
+
blankRun = 0
|
|
20
|
+
continue
|
|
21
|
+
}
|
|
22
|
+
if (inCode) {
|
|
23
|
+
out.push(line)
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
if (!line.trim()) {
|
|
27
|
+
blankRun += 1
|
|
28
|
+
if (blankRun <= 1) out.push('')
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
blankRun = 0
|
|
32
|
+
out.push(line)
|
|
33
|
+
}
|
|
34
|
+
return out.join('\n').trim()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function splitMarkdownBlocks(content) {
|
|
38
|
+
const blocks = []
|
|
39
|
+
let current = []
|
|
40
|
+
let inCode = false
|
|
41
|
+
const flush = () => {
|
|
42
|
+
const block = current.join('\n').trim()
|
|
43
|
+
if (block) blocks.push(block)
|
|
44
|
+
current = []
|
|
45
|
+
}
|
|
46
|
+
for (const raw of content.split('\n')) {
|
|
47
|
+
const line = raw.replace(/\s+$/, '')
|
|
48
|
+
if (FENCE_RE.test(line.trim())) {
|
|
49
|
+
if (!inCode && current.length) flush()
|
|
50
|
+
current.push(line)
|
|
51
|
+
inCode = !inCode
|
|
52
|
+
if (!inCode) flush()
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
if (inCode) {
|
|
56
|
+
current.push(line)
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
if (!line.trim()) {
|
|
60
|
+
flush()
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
current.push(line)
|
|
64
|
+
}
|
|
65
|
+
flush()
|
|
66
|
+
return blocks
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function hardSplit(text, max) {
|
|
70
|
+
const chunks = []
|
|
71
|
+
let rest = text
|
|
72
|
+
while (rest.length > max) {
|
|
73
|
+
chunks.push(rest.slice(0, max))
|
|
74
|
+
rest = rest.slice(max)
|
|
75
|
+
}
|
|
76
|
+
if (rest) chunks.push(rest)
|
|
77
|
+
return chunks
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function packBlocks(blocks, max) {
|
|
81
|
+
const units = []
|
|
82
|
+
let current = ''
|
|
83
|
+
for (const block of blocks) {
|
|
84
|
+
const candidate = current ? `${current}\n\n${block}` : block
|
|
85
|
+
if (candidate.length <= max) {
|
|
86
|
+
current = candidate
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (current) units.push(current)
|
|
90
|
+
if (block.length <= max) {
|
|
91
|
+
current = block
|
|
92
|
+
} else {
|
|
93
|
+
units.push(...hardSplit(block, max))
|
|
94
|
+
current = ''
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (current) units.push(current)
|
|
98
|
+
return units
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function splitForIM(content, max = 2000) {
|
|
102
|
+
// 安全检查:防止畸形输入导致无限循环或崩溃
|
|
103
|
+
if (typeof content !== 'string' || content.length === 0) return []
|
|
104
|
+
if (content.length > 1_000_000) {
|
|
105
|
+
content = content.slice(0, 1_000_000) + '\n\n[已截断:内容过长]'
|
|
106
|
+
}
|
|
107
|
+
const normalized = normalizeMarkdownBlocks(content)
|
|
108
|
+
if (!normalized) return []
|
|
109
|
+
if (normalized.length <= max) return [normalized]
|
|
110
|
+
return packBlocks(splitMarkdownBlocks(normalized), max)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 协议标记过滤:模型偶尔会把工具调用语法(如 <||DSML||tool_calls>…)当正文
|
|
114
|
+
// 输出,这些是协议内容,转发到 IM 只会是乱码噪音。整块移除 tool_calls 段落,
|
|
115
|
+
// 再剥掉残余的 DSML 标签;剩余正文照常发送。
|
|
116
|
+
export function stripProtocolMarkup(text) {
|
|
117
|
+
if (!text || typeof text !== 'string' || !text.includes('DSML')) return text
|
|
118
|
+
let out = text.replace(/<[^<>]*DSML[^<>]*tool_calls[^<>]*>[\s\S]*?<[^<>]*DSML[^<>]*tool_calls[^<>]*>/g, '')
|
|
119
|
+
out = out.replace(/<[^<>]*DSML[^<>]*>/g, '')
|
|
120
|
+
return out
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function textOfAssistantMessage(message) {
|
|
124
|
+
const raw = (message.content ?? [])
|
|
125
|
+
.filter((block) => block?.type === 'text')
|
|
126
|
+
.map((block) => block.text)
|
|
127
|
+
.join('\n')
|
|
128
|
+
return stripProtocolMarkup(raw)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 尝试将任意路径(绝对或相对当前工作区)解析为真实存在的本地文件绝对路径
|
|
133
|
+
*/
|
|
134
|
+
export function resolveFilePath(rawPath, cwd = process.cwd()) {
|
|
135
|
+
if (typeof rawPath !== 'string') return null
|
|
136
|
+
let p = rawPath.trim()
|
|
137
|
+
.replace(/^["'`]|["'`]$/g, '')
|
|
138
|
+
.replace(/^file:\/\/\/?/, '')
|
|
139
|
+
.replace(/^[📁📄📦\s]+/, '')
|
|
140
|
+
if (!p) return null
|
|
141
|
+
// 排除 HTTP/HTTPS 网址
|
|
142
|
+
if (/^https?:\/\//i.test(p)) return null
|
|
143
|
+
const resolved = isAbsolute(p) ? normalize(p) : resolve(cwd, p)
|
|
144
|
+
try {
|
|
145
|
+
if (statSync(resolved).isFile()) {
|
|
146
|
+
return resolved
|
|
147
|
+
}
|
|
148
|
+
} catch {}
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 判断解析后的文件路径是否允许经 [SEND_FILE] 发送给 IM。
|
|
154
|
+
* 安全约束(防止模型被诱导后外发任意本地文件):
|
|
155
|
+
* 1. 必须位于 allowedRoots 中的某个根目录(含子目录)内 —— 默认仅会话 cwd;
|
|
156
|
+
* 2. 路径任何一段不得命中敏感名单(.ssh/.gnupg/.aws/.git/.env/.credentials 等)。
|
|
157
|
+
* @param {string} resolvedPath 已解析的绝对路径
|
|
158
|
+
* @param {string|string[]} allowedRoots 允许的根目录(绝对路径);默认 process.cwd()
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
export function isPathAllowedForSend(resolvedPath, allowedRoots = process.cwd()) {
|
|
162
|
+
if (typeof resolvedPath !== 'string' || !resolvedPath) return false
|
|
163
|
+
const roots = Array.isArray(allowedRoots) ? allowedRoots : [allowedRoots]
|
|
164
|
+
if (roots.length === 0) return false
|
|
165
|
+
|
|
166
|
+
const normalized = resolve(resolvedPath)
|
|
167
|
+
const pathParts = normalized.split(/[\\/]/).filter(Boolean)
|
|
168
|
+
const SENSITIVE_PARTS = new Set([
|
|
169
|
+
'.ssh', '.gnupg', '.aws', '.azure', '.kube', '.git', '.svn', '.hg',
|
|
170
|
+
'.bash_history', '.zsh_history', '.profile', '.bash_profile', '.bashrc',
|
|
171
|
+
'.zshrc', '.netrc', '.env', '.npmrc', '.credentials', 'id_rsa', 'id_ed25519',
|
|
172
|
+
'id_ecdsa', 'id_dsa', 'shadow', 'passwd',
|
|
173
|
+
])
|
|
174
|
+
for (const part of pathParts) {
|
|
175
|
+
if (SENSITIVE_PARTS.has(part.toLowerCase())) return false
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const root of roots) {
|
|
179
|
+
if (typeof root !== 'string' || !root) continue
|
|
180
|
+
const normRoot = resolve(root)
|
|
181
|
+
if (normalized === normRoot) return true
|
|
182
|
+
// 用 relative 判断是否位于根内:越界时 rel 为 '..' 或以 '../' 开头
|
|
183
|
+
// (Windows 跨盘则 rel 是绝对路径,isAbsolute 拦截)。跨平台正确处理 \ 与 / 差异。
|
|
184
|
+
const rel = relative(normRoot, normalized)
|
|
185
|
+
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return true
|
|
186
|
+
}
|
|
187
|
+
return false
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 提取并过滤文本中的 [SEND_FILE: <path>] 显式发送指令
|
|
192
|
+
* 由 AI 根据用户意图显式决定何时向用户发送文件附件,杜绝底层盲目扫描与误发。
|
|
193
|
+
* @param {string} text - 原始助手回复文本
|
|
194
|
+
* @param {string} cwd - 会话当前工作目录
|
|
195
|
+
* @returns {{ cleanText: string, files: string[] }}
|
|
196
|
+
*/
|
|
197
|
+
export function extractAndStripSendFileDirectives(text, cwd = process.cwd()) {
|
|
198
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
199
|
+
return { cleanText: text || '', files: [] }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const files = []
|
|
203
|
+
const directiveRegex = /\[(?:SEND_FILE|SEND-FILE|send_file|send-file|SEND_MEDIA|send_media):\s*[`"']?([^\]`"'\r\n]+?)[`"']?\s*\]/gi
|
|
204
|
+
|
|
205
|
+
let m
|
|
206
|
+
const re = new RegExp(directiveRegex)
|
|
207
|
+
while ((m = re.exec(text)) !== null) {
|
|
208
|
+
const rawPath = m[1].trim()
|
|
209
|
+
const resolved = resolveFilePath(rawPath, cwd)
|
|
210
|
+
if (resolved && !files.includes(resolved)) {
|
|
211
|
+
files.push(resolved)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 从聊天正文中彻底剔除控制指令(保持 IM 聊天气泡的干净整洁)
|
|
216
|
+
const cleanText = text.replace(directiveRegex, '').replace(/\n{3,}/g, '\n\n').trim()
|
|
217
|
+
|
|
218
|
+
return { cleanText, files }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 提取文本中的产物文件路径(基于显式指令)
|
|
223
|
+
*/
|
|
224
|
+
export function extractFilePathsFromText(text, cwd = process.cwd()) {
|
|
225
|
+
return extractAndStripSendFileDirectives(text, cwd).files
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// digest 摘要
|