@ww_nero/mini-cli 1.0.56
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/bin/mini.js +3 -0
- package/package.json +38 -0
- package/src/chat.js +1008 -0
- package/src/config.js +371 -0
- package/src/index.js +38 -0
- package/src/llm.js +147 -0
- package/src/prompt/tool.js +18 -0
- package/src/request.js +328 -0
- package/src/tools/bash.js +241 -0
- package/src/tools/convert.js +297 -0
- package/src/tools/index.js +66 -0
- package/src/tools/mcp.js +478 -0
- package/src/tools/python/html_to_png.py +100 -0
- package/src/tools/python/html_to_pptx.py +163 -0
- package/src/tools/python/pdf_to_png.py +58 -0
- package/src/tools/python/pptx_to_pdf.py +107 -0
- package/src/tools/read.js +44 -0
- package/src/tools/replace.js +135 -0
- package/src/tools/todos.js +90 -0
- package/src/tools/write.js +52 -0
- package/src/utils/cliOptions.js +8 -0
- package/src/utils/commands.js +89 -0
- package/src/utils/git.js +89 -0
- package/src/utils/helpers.js +93 -0
- package/src/utils/history.js +181 -0
- package/src/utils/model.js +127 -0
- package/src/utils/output.js +76 -0
- package/src/utils/renderer.js +92 -0
- package/src/utils/settings.js +90 -0
- package/src/utils/think.js +211 -0
package/src/request.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
const fetch = require('node-fetch');
|
|
2
|
+
const { createParser } = require('eventsource-parser');
|
|
3
|
+
const { COMPACT_SUMMARY_PROMPT } = require('./config');
|
|
4
|
+
|
|
5
|
+
const joinUrl = (base, pathname) => {
|
|
6
|
+
const normalizedBase = String(base || '').replace(/\/$/, '');
|
|
7
|
+
const normalizedPath = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
|
8
|
+
return `${normalizedBase}${normalizedPath}`;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const createAbortError = () => {
|
|
12
|
+
if (typeof DOMException === 'function') {
|
|
13
|
+
return new DOMException('Aborted', 'AbortError');
|
|
14
|
+
}
|
|
15
|
+
const error = new Error('Aborted');
|
|
16
|
+
error.name = 'AbortError';
|
|
17
|
+
return error;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const createFetchOptions = (endpoint, body, abortController) => ({
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: {
|
|
23
|
+
Authorization: `Bearer ${endpoint.key}`,
|
|
24
|
+
'Content-Type': 'application/json'
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
signal: abortController.signal
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const makeRequestWithRetry = async (endpoint, requestBody, abortController, options = {}) => {
|
|
31
|
+
const { maxRetries = 5, retryDelay = 3000, onRetry = null } = options;
|
|
32
|
+
const url = joinUrl(endpoint.baseUrl, '/chat/completions');
|
|
33
|
+
|
|
34
|
+
const attemptRequest = async () => {
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch(url, createFetchOptions(endpoint, requestBody, abortController));
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
throw new Error(`HTTP ${response.status}`);
|
|
39
|
+
}
|
|
40
|
+
return { response, error: null };
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (abortController.signal.aborted) {
|
|
43
|
+
throw createAbortError();
|
|
44
|
+
}
|
|
45
|
+
return { response: null, error };
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
let attempt = 0;
|
|
50
|
+
let result = await attemptRequest();
|
|
51
|
+
|
|
52
|
+
while (!result.response && attempt < maxRetries) {
|
|
53
|
+
attempt += 1;
|
|
54
|
+
if (typeof onRetry === 'function') {
|
|
55
|
+
onRetry(attempt, result.error);
|
|
56
|
+
}
|
|
57
|
+
await new Promise(resolve => {
|
|
58
|
+
const handleAbort = () => {
|
|
59
|
+
abortController.signal.removeEventListener('abort', handleAbort);
|
|
60
|
+
clearTimeout(timeout);
|
|
61
|
+
resolve();
|
|
62
|
+
};
|
|
63
|
+
const timeout = setTimeout(() => {
|
|
64
|
+
abortController.signal.removeEventListener('abort', handleAbort);
|
|
65
|
+
resolve();
|
|
66
|
+
}, retryDelay);
|
|
67
|
+
abortController.signal.addEventListener('abort', handleAbort);
|
|
68
|
+
});
|
|
69
|
+
if (abortController.signal.aborted) {
|
|
70
|
+
throw createAbortError();
|
|
71
|
+
}
|
|
72
|
+
result = await attemptRequest();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!result.response) {
|
|
76
|
+
throw result.error;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return result.response;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const processStreamResponse = (response, options = {}) => {
|
|
83
|
+
const {
|
|
84
|
+
onContent = null,
|
|
85
|
+
onReasoningContent = null,
|
|
86
|
+
onComplete = null,
|
|
87
|
+
abortController = null,
|
|
88
|
+
includeToolCalls = false
|
|
89
|
+
} = options;
|
|
90
|
+
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
let abortHandler = null;
|
|
93
|
+
const cleanupAbortListener = () => {
|
|
94
|
+
if (abortController && abortHandler) {
|
|
95
|
+
abortController.signal.removeEventListener('abort', abortHandler);
|
|
96
|
+
abortHandler = null;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const rejectWithAbort = () => {
|
|
101
|
+
cleanupAbortListener();
|
|
102
|
+
if (response.body && typeof response.body.destroy === 'function') {
|
|
103
|
+
response.body.destroy();
|
|
104
|
+
}
|
|
105
|
+
reject(createAbortError());
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const checkAbort = () => {
|
|
109
|
+
if (abortController && abortController.signal.aborted) {
|
|
110
|
+
rejectWithAbort();
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
let fullContent = '';
|
|
118
|
+
let toolCalls = [];
|
|
119
|
+
let currentMessage = null;
|
|
120
|
+
let fullReasoningContent = '';
|
|
121
|
+
let usage = null;
|
|
122
|
+
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
123
|
+
|
|
124
|
+
const handleEvent = (event) => {
|
|
125
|
+
if (checkAbort()) return;
|
|
126
|
+
if (!event || event.type !== 'event') {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!event.data || event.data === '[DONE]') {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const payload = JSON.parse(event.data);
|
|
134
|
+
if (payload.usage && typeof payload.usage === 'object') {
|
|
135
|
+
usage = payload.usage;
|
|
136
|
+
}
|
|
137
|
+
const choice = payload.choices && payload.choices[0];
|
|
138
|
+
if (!choice || !choice.delta) return;
|
|
139
|
+
const delta = choice.delta;
|
|
140
|
+
const contentChunk = delta.content || '';
|
|
141
|
+
if (contentChunk) {
|
|
142
|
+
fullContent += contentChunk;
|
|
143
|
+
if (onContent) {
|
|
144
|
+
onContent(contentChunk, fullContent);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const reasoningChunk = delta.reasoning_content || '';
|
|
149
|
+
if (reasoningChunk) {
|
|
150
|
+
fullReasoningContent += reasoningChunk;
|
|
151
|
+
if (onReasoningContent) {
|
|
152
|
+
onReasoningContent(reasoningChunk, fullReasoningContent);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (includeToolCalls && Array.isArray(delta.tool_calls)) {
|
|
157
|
+
delta.tool_calls.forEach((call, index) => {
|
|
158
|
+
if (!toolCalls[index]) {
|
|
159
|
+
toolCalls[index] = {
|
|
160
|
+
id: call.id || '',
|
|
161
|
+
type: call.type || 'function',
|
|
162
|
+
function: {
|
|
163
|
+
name: (call.function && call.function.name) || '',
|
|
164
|
+
arguments: ''
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (call.function && typeof call.function.arguments === 'string') {
|
|
170
|
+
toolCalls[index].function.arguments += call.function.arguments;
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (includeToolCalls) {
|
|
176
|
+
currentMessage = {
|
|
177
|
+
role: 'assistant',
|
|
178
|
+
content: fullContent || null,
|
|
179
|
+
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
|
180
|
+
...(fullReasoningContent ? { reasoning_content: fullReasoningContent } : {})
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
console.warn('SSE parse error:', error.message);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const parser = createParser(handleEvent);
|
|
189
|
+
|
|
190
|
+
const safeFeed = (text) => {
|
|
191
|
+
try {
|
|
192
|
+
parser.feed(text);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.warn('SSE error:', error.message);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
response.body.on('data', chunk => {
|
|
199
|
+
if (checkAbort()) return;
|
|
200
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
201
|
+
safeFeed(text);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
response.body.on('end', () => {
|
|
205
|
+
if (checkAbort()) return;
|
|
206
|
+
cleanupAbortListener();
|
|
207
|
+
const rest = decoder.decode();
|
|
208
|
+
if (rest) {
|
|
209
|
+
safeFeed(rest);
|
|
210
|
+
}
|
|
211
|
+
onComplete && onComplete();
|
|
212
|
+
if (includeToolCalls) {
|
|
213
|
+
const filteredToolCalls = toolCalls.filter(Boolean);
|
|
214
|
+
const assistantMessage = currentMessage || {
|
|
215
|
+
role: 'assistant',
|
|
216
|
+
content: fullContent || null,
|
|
217
|
+
...(filteredToolCalls.length > 0 ? { tool_calls: filteredToolCalls } : {})
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
if (fullReasoningContent && !assistantMessage.reasoning_content) {
|
|
221
|
+
assistantMessage.reasoning_content = fullReasoningContent;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
resolve({
|
|
225
|
+
content: fullContent,
|
|
226
|
+
toolCalls: filteredToolCalls,
|
|
227
|
+
message: assistantMessage,
|
|
228
|
+
reasoningContent: fullReasoningContent,
|
|
229
|
+
usage
|
|
230
|
+
});
|
|
231
|
+
} else {
|
|
232
|
+
resolve({
|
|
233
|
+
content: fullContent,
|
|
234
|
+
reasoningContent: fullReasoningContent,
|
|
235
|
+
usage
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
response.body.on('error', (error) => {
|
|
241
|
+
cleanupAbortListener();
|
|
242
|
+
reject(error);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (abortController) {
|
|
246
|
+
abortHandler = () => {
|
|
247
|
+
rejectWithAbort();
|
|
248
|
+
};
|
|
249
|
+
abortController.signal.addEventListener('abort', abortHandler);
|
|
250
|
+
}
|
|
251
|
+
} catch (error) {
|
|
252
|
+
reject(error);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const buildConversationForSummary = (messages) => {
|
|
258
|
+
const parts = [];
|
|
259
|
+
for (const msg of messages) {
|
|
260
|
+
if (msg.role === 'system') continue;
|
|
261
|
+
|
|
262
|
+
const role = msg.role === 'user' ? '用户' : msg.role === 'assistant' ? '助手' : '工具';
|
|
263
|
+
let content = msg.content || '';
|
|
264
|
+
|
|
265
|
+
if (msg.tool_call_id) {
|
|
266
|
+
parts.push(`[工具返回 ${msg.tool_call_id}]: ${content}`);
|
|
267
|
+
} else if (msg.tool_calls && msg.tool_calls.length > 0) {
|
|
268
|
+
const toolNames = msg.tool_calls.map(tc => tc.function?.name || '未知工具').join(', ');
|
|
269
|
+
parts.push(`[${role}]: ${content || ''}\n[调用工具: ${toolNames}]`);
|
|
270
|
+
} else {
|
|
271
|
+
parts.push(`[${role}]: ${content}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return parts.join('\n\n');
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const performCompactSummary = async (endpoint, messages, abortController) => {
|
|
278
|
+
const conversationText = buildConversationForSummary(messages);
|
|
279
|
+
|
|
280
|
+
const summaryMessages = [
|
|
281
|
+
{
|
|
282
|
+
role: 'system',
|
|
283
|
+
content: COMPACT_SUMMARY_PROMPT
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
role: 'user',
|
|
287
|
+
content: `以下是需要总结的对话内容:\n\n${conversationText}`
|
|
288
|
+
}
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
const requestBody = {
|
|
292
|
+
stream: false,
|
|
293
|
+
messages: summaryMessages,
|
|
294
|
+
model: endpoint.model
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const url = joinUrl(endpoint.baseUrl, '/chat/completions');
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const response = await fetch(url, createFetchOptions(endpoint, requestBody, abortController));
|
|
301
|
+
|
|
302
|
+
if (!response.ok) {
|
|
303
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const data = await response.json();
|
|
307
|
+
const choice = data.choices && data.choices[0];
|
|
308
|
+
|
|
309
|
+
if (choice && choice.message && choice.message.content) {
|
|
310
|
+
return choice.message.content;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
throw new Error('无法获取总结内容');
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (error.name === 'AbortError') {
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
console.error('Compact 总结请求失败:', error);
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
module.exports = {
|
|
324
|
+
makeRequestWithRetry,
|
|
325
|
+
processStreamResponse,
|
|
326
|
+
buildConversationForSummary,
|
|
327
|
+
performCompactSummary
|
|
328
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const { resolveWorkspacePath } = require('../utils/helpers');
|
|
3
|
+
const { DEFAULT_ALLOWED_COMMANDS } = require('../config');
|
|
4
|
+
|
|
5
|
+
const OUTPUT_MAX_LENGTH = 12000;
|
|
6
|
+
const EXECUTION_TIMEOUT = 300000;
|
|
7
|
+
const SERVICE_RETURN_DELAY = 5000;
|
|
8
|
+
|
|
9
|
+
// Git 只读命令白名单
|
|
10
|
+
const GIT_READONLY_COMMANDS = ['show', 'diff', 'log', 'status', 'branch', 'tag', 'ls-files', 'ls-tree', 'rev-parse', 'reflog', 'blame', 'shortlog', 'describe', 'config --get', 'config --list', 'remote', 'ls-remote', 'fetch --dry-run', 'grep'];
|
|
11
|
+
|
|
12
|
+
// Git 禁止的命令(会修改状态的命令)
|
|
13
|
+
const GIT_FORBIDDEN_COMMANDS = ['add', 'commit', 'push', 'pull', 'merge', 'rebase', 'reset', 'revert', 'cherry-pick', 'apply', 'stash', 'clean', 'rm', 'mv', 'checkout', 'switch', 'restore', 'tag -a', 'tag -d', 'branch -d', 'branch -D', 'config --add', 'config --unset', 'submodule', 'clone', 'init'];
|
|
14
|
+
|
|
15
|
+
const splitShellCommands = (commandString = '') => {
|
|
16
|
+
const operators = ['&&', '||'];
|
|
17
|
+
const pattern = '(' + operators.map(op => op.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')';
|
|
18
|
+
return commandString
|
|
19
|
+
.split(new RegExp(pattern))
|
|
20
|
+
.map(part => part.trim())
|
|
21
|
+
.filter(part => part && !operators.includes(part));
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const validateSingleCommand = (commandString = '', allowedCommands = []) => {
|
|
25
|
+
const parts = commandString.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
|
|
26
|
+
if (parts.length === 0) {
|
|
27
|
+
return { isValid: false, reason: '命令为空' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const command = parts[0];
|
|
31
|
+
if (!allowedCommands.includes(command)) {
|
|
32
|
+
return { isValid: false, reason: `命令 ${command} 不在允许名单内` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if ((command === 'rm' || command === 'rmdir') && parts.slice(1).some(arg => !arg.startsWith('-') && arg.includes('.git'))) {
|
|
36
|
+
return { isValid: false, reason: '禁止对 .git 目录执行删除操作' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Git 命令额外验证
|
|
40
|
+
if (command === 'git') {
|
|
41
|
+
if (parts.length < 2) {
|
|
42
|
+
return { isValid: false, reason: 'git 命令缺少子命令' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const gitSubCommand = parts[1];
|
|
46
|
+
const gitArgs = parts.slice(2).join(' ');
|
|
47
|
+
const fullGitCommand = gitSubCommand + (gitArgs ? ' ' + gitArgs : '');
|
|
48
|
+
|
|
49
|
+
// 检查是否是禁止的命令
|
|
50
|
+
for (const forbidden of GIT_FORBIDDEN_COMMANDS) {
|
|
51
|
+
if (gitSubCommand === forbidden || fullGitCommand.startsWith(forbidden)) {
|
|
52
|
+
return {
|
|
53
|
+
isValid: false,
|
|
54
|
+
reason: `禁止执行会修改仓库状态的 git 命令: ${forbidden}。只允许执行只读命令如: ${GIT_READONLY_COMMANDS.join(', ')}`
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 检查是否是允许的只读命令
|
|
60
|
+
let isReadOnly = false;
|
|
61
|
+
for (const readonly of GIT_READONLY_COMMANDS) {
|
|
62
|
+
if (gitSubCommand === readonly || fullGitCommand.startsWith(readonly)) {
|
|
63
|
+
isReadOnly = true;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!isReadOnly) {
|
|
69
|
+
return {
|
|
70
|
+
isValid: false,
|
|
71
|
+
reason: `git 子命令 ${gitSubCommand} 不在只读命令白名单内。允许的命令: ${GIT_READONLY_COMMANDS.join(', ')}`
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { isValid: true, reason: '' };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const executeCommand = async ({ command, workingDirectory = '.', isService = false } = {}, context = {}) => {
|
|
80
|
+
if (!command || typeof command !== 'string' || !command.trim()) {
|
|
81
|
+
return 'command 参数不能为空';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const normalizedCommand = command.replace(/\bpython3\b/g, 'python');
|
|
85
|
+
|
|
86
|
+
const allowedCommands = Array.isArray(context.allowedCommands) && context.allowedCommands.length > 0
|
|
87
|
+
? context.allowedCommands
|
|
88
|
+
: DEFAULT_ALLOWED_COMMANDS;
|
|
89
|
+
const commands = splitShellCommands(normalizedCommand);
|
|
90
|
+
if (commands.length === 0) {
|
|
91
|
+
return '未找到有效的命令';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < commands.length; i += 1) {
|
|
95
|
+
const validation = validateSingleCommand(commands[i], allowedCommands);
|
|
96
|
+
if (!validation.isValid) {
|
|
97
|
+
return [
|
|
98
|
+
'命令未通过安全校验',
|
|
99
|
+
`第 ${i + 1} 个指令: ${commands[i]}`,
|
|
100
|
+
`原因: ${validation.reason}`,
|
|
101
|
+
'允许的命令: ' + allowedCommands.join(', '),
|
|
102
|
+
'附加限制: rm/rmdir 禁止修改`.git`目录'
|
|
103
|
+
].join('\n');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let cwd;
|
|
108
|
+
try {
|
|
109
|
+
cwd = resolveWorkspacePath(context.workspaceRoot, workingDirectory || '.');
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return `工作目录无效: ${error.message}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
const child = spawn('bash', ['-lc', normalizedCommand], {
|
|
116
|
+
cwd,
|
|
117
|
+
env: process.env,
|
|
118
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
let stdout = '';
|
|
122
|
+
let stderr = '';
|
|
123
|
+
let settled = false;
|
|
124
|
+
|
|
125
|
+
const cleanup = () => {
|
|
126
|
+
child.stdout.removeAllListeners();
|
|
127
|
+
child.stderr.removeAllListeners();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
child.stdout.on('data', (data) => {
|
|
131
|
+
stdout += data.toString();
|
|
132
|
+
if (stdout.length > OUTPUT_MAX_LENGTH) {
|
|
133
|
+
stdout = stdout.slice(0, OUTPUT_MAX_LENGTH);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
child.stderr.on('data', (data) => {
|
|
138
|
+
stderr += data.toString();
|
|
139
|
+
if (stderr.length > OUTPUT_MAX_LENGTH) {
|
|
140
|
+
stderr = stderr.slice(0, OUTPUT_MAX_LENGTH);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const handleProcessError = (error) => {
|
|
145
|
+
if (settled) return;
|
|
146
|
+
settled = true;
|
|
147
|
+
cleanup();
|
|
148
|
+
resolve(`命令执行失败: ${error.message}`);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const handleProcessClose = (code) => {
|
|
152
|
+
cleanup();
|
|
153
|
+
if (settled) return;
|
|
154
|
+
settled = true;
|
|
155
|
+
if (code === 0) {
|
|
156
|
+
const output = stdout.trim();
|
|
157
|
+
resolve(output || '命令执行成功,无额外输出');
|
|
158
|
+
} else {
|
|
159
|
+
const errOutput = stderr.trim();
|
|
160
|
+
const stdOutput = stdout.trim();
|
|
161
|
+
resolve(`命令执行失败,退出码 ${code}${errOutput ? `\n错误: ${errOutput}` : ''}${stdOutput ? `\n输出: ${stdOutput}` : ''}`);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (isService) {
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
if (settled) return;
|
|
168
|
+
settled = true;
|
|
169
|
+
const stdOutput = stdout.trim();
|
|
170
|
+
const errOutput = stderr.trim();
|
|
171
|
+
const captured = stdOutput || errOutput ? `\n当前输出:\n${stdOutput}${errOutput ? `\n错误:\n${errOutput}` : ''}` : '\n暂无输出';
|
|
172
|
+
resolve(`已等待 ${SERVICE_RETURN_DELAY / 1000}s,命令已在后台持续运行(PID: ${child.pid})。${captured}`);
|
|
173
|
+
}, SERVICE_RETURN_DELAY);
|
|
174
|
+
|
|
175
|
+
const serviceErrorHandler = (error) => {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
handleProcessError(error);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
child.on('error', serviceErrorHandler);
|
|
181
|
+
child.on('close', (code) => {
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
handleProcessClose(code);
|
|
184
|
+
});
|
|
185
|
+
} else {
|
|
186
|
+
child.on('error', handleProcessError);
|
|
187
|
+
child.on('close', handleProcessClose);
|
|
188
|
+
|
|
189
|
+
setTimeout(() => {
|
|
190
|
+
if (settled) return;
|
|
191
|
+
child.kill('SIGTERM');
|
|
192
|
+
cleanup();
|
|
193
|
+
resolve('命令执行超时 (超过 300s)');
|
|
194
|
+
}, EXECUTION_TIMEOUT);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const createBashToolSchema = (context = {}) => {
|
|
200
|
+
const allowedCommands = Array.isArray(context.allowedCommands) && context.allowedCommands.length > 0
|
|
201
|
+
? context.allowedCommands
|
|
202
|
+
: DEFAULT_ALLOWED_COMMANDS;
|
|
203
|
+
const descriptionParts = ['在指定目录运行 bash 命令,支持使用 && / || 连接多个命令。'];
|
|
204
|
+
descriptionParts.push(`支持的命令: ${allowedCommands.join(', ')}。`);
|
|
205
|
+
descriptionParts.push('git 命令仅支持只读操作(show/diff/log/status 等),严禁执行 add/commit/push/reset 等会修改仓库状态的命令。');
|
|
206
|
+
descriptionParts.push('isService=true 时在后台运行服务,等待 5 秒返回初始输出,进程继续运行并持续捕获输出;默认等待命令执行完成,超时为 300 秒。');
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
type: 'function',
|
|
210
|
+
function: {
|
|
211
|
+
name: 'execute_bash',
|
|
212
|
+
description: descriptionParts.join(' '),
|
|
213
|
+
parameters: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
command: {
|
|
217
|
+
type: 'string',
|
|
218
|
+
description: '要执行的 bash 命令'
|
|
219
|
+
},
|
|
220
|
+
workingDirectory: {
|
|
221
|
+
type: 'string',
|
|
222
|
+
description: '相对工作目录,默认为项目根目录',
|
|
223
|
+
default: '.'
|
|
224
|
+
},
|
|
225
|
+
isService: {
|
|
226
|
+
type: 'boolean',
|
|
227
|
+
description: '是否为启动长时间运行服务的命令。为 true 时后台运行并在 5 秒后返回捕获的输出。',
|
|
228
|
+
default: false
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
required: ['command']
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
module.exports = {
|
|
238
|
+
name: 'execute_bash',
|
|
239
|
+
schema: createBashToolSchema,
|
|
240
|
+
handler: executeCommand
|
|
241
|
+
};
|