@myassis/gateway 1.0.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/README.md +194 -0
- package/dist/.env +6 -0
- package/dist/api/index.js +182 -0
- package/dist/config/index.js +41 -0
- package/dist/index.js +183 -0
- package/dist/middleware/auth.js +53 -0
- package/dist/middleware/errorHandler.js +20 -0
- package/dist/routes/agent.js +513 -0
- package/dist/routes/auth.js +172 -0
- package/dist/routes/chat.js +45 -0
- package/dist/routes/config.js +21 -0
- package/dist/routes/models.js +123 -0
- package/dist/routes/service.js +240 -0
- package/dist/routes/settings.js +101 -0
- package/dist/routes/skillHub.js +126 -0
- package/dist/routes/skills.js +159 -0
- package/dist/routes/tasks.js +149 -0
- package/dist/routes/upload.js +129 -0
- package/dist/routes/version.js +66 -0
- package/dist/services/HMSPushService.js +24 -0
- package/dist/services/LocalTaskService.js +223 -0
- package/dist/services/NotificationService.js +242 -0
- package/dist/services/ServiceManager.js +348 -0
- package/dist/services/TaskSchedulerService.js +195 -0
- package/dist/services/TaskService.js +240 -0
- package/dist/services/WebSocketService.js +236 -0
- package/dist/services/agent/Agent.js +120 -0
- package/dist/services/agent/AgentManager.js +265 -0
- package/dist/services/agent/AgentStore.js +73 -0
- package/dist/services/dataService.js +293 -0
- package/dist/services/index.js +15 -0
- package/dist/services/llm/LLMClient.js +724 -0
- package/dist/services/memory/MemoryManager.js +117 -0
- package/dist/services/model/ModelCapabilities.js +141 -0
- package/dist/services/model/index.js +4 -0
- package/dist/services/models.js +16 -0
- package/dist/services/session/MigrationManager.js +176 -0
- package/dist/services/session/Session.js +733 -0
- package/dist/services/session/SessionManager.js +255 -0
- package/dist/services/session/SessionStore.js +186 -0
- package/dist/services/session/index.js +3 -0
- package/dist/services/skills.js +34 -0
- package/dist/services/systemPrompt.js +150 -0
- package/dist/services/task/PushTokenStore.js +124 -0
- package/dist/services/task/TaskStore.js +143 -0
- package/dist/services/tools/calculator.js +27 -0
- package/dist/services/tools/edit.js +318 -0
- package/dist/services/tools/exec.js +119 -0
- package/dist/services/tools/fetch.js +155 -0
- package/dist/services/tools/file.js +315 -0
- package/dist/services/tools/index.js +48 -0
- package/dist/services/tools/keyboard.js +145 -0
- package/dist/services/tools/model.js +86 -0
- package/dist/services/tools/mouse.js +55 -0
- package/dist/services/tools/screenshot.js +19 -0
- package/dist/services/tools/search.js +53 -0
- package/dist/services/tools/skill.js +108 -0
- package/dist/services/tools/task.js +110 -0
- package/dist/services/tools/types.js +1 -0
- package/dist/services/tools/webFetch.js +34 -0
- package/dist/stores/authStore.js +178 -0
- package/dist/stores/index.js +6 -0
- package/dist/stores/memoryStore.js +191 -0
- package/dist/stores/persistStore.js +317 -0
- package/package.json +94 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { getLogger } from '@pocketclaw/shared';
|
|
6
|
+
const logger = getLogger('exec');
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
// 会话级工作目录状态:sessionId -> cwd
|
|
9
|
+
// 让 cd 命令能跨多次 exec 调用持久化工作目录
|
|
10
|
+
const sessionCwdMap = new Map();
|
|
11
|
+
/** 解析 cd 参数,返回目标绝对路径。支持 ~、相对路径、绝对路径 */
|
|
12
|
+
function resolveCdTarget(cdArg, currentCwd) {
|
|
13
|
+
const arg = cdArg.trim();
|
|
14
|
+
if (!arg)
|
|
15
|
+
return os.homedir(); // cd 无参数 → 回家目录
|
|
16
|
+
if (arg === '~' || arg.startsWith('~/')) {
|
|
17
|
+
return path.join(os.homedir(), arg.slice(1));
|
|
18
|
+
}
|
|
19
|
+
// Windows 盘符路径或 Unix 绝对路径
|
|
20
|
+
if (path.isAbsolute(arg))
|
|
21
|
+
return path.resolve(arg);
|
|
22
|
+
// 相对路径:基于当前 cwd 解析
|
|
23
|
+
return path.resolve(currentCwd, arg);
|
|
24
|
+
}
|
|
25
|
+
/** 检查命令是否以 cd 开头,若是则提取目标路径(支持 cd /d D:\path 这种 Windows 写法)*/
|
|
26
|
+
function parseCdCommand(command) {
|
|
27
|
+
const trimmed = command.trim();
|
|
28
|
+
// 匹配:cd arg 或 cd /d arg(Windows)或 cd "path with spaces"
|
|
29
|
+
const match = trimmed.match(/^cd(?:\s+\/d)?\s+(.+)$/is);
|
|
30
|
+
if (!match) {
|
|
31
|
+
// 裸 cd(无参数)→ 返回 home
|
|
32
|
+
if (/^cd\s*$/i.test(trimmed))
|
|
33
|
+
return '';
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
// 去掉末尾可能存在的 && 其他命令,只取 cd 的目标
|
|
37
|
+
let target = match[1].trim();
|
|
38
|
+
// 去掉引号
|
|
39
|
+
target = target.replace(/^['"]|['"]$/g, '');
|
|
40
|
+
// 如果 target 里包含 && 或 ; ,只取第一部分
|
|
41
|
+
const cut = target.split(/&&|\|/)[0].trim();
|
|
42
|
+
return cut || '';
|
|
43
|
+
}
|
|
44
|
+
/** 获取某个 session 的当前工作目录 */
|
|
45
|
+
export function getSessionCwd(sessionId) {
|
|
46
|
+
return sessionCwdMap.get(sessionId);
|
|
47
|
+
}
|
|
48
|
+
/** 设置某个 session 的工作目录 */
|
|
49
|
+
export function setSessionCwd(sessionId, cwd) {
|
|
50
|
+
sessionCwdMap.set(sessionId, cwd);
|
|
51
|
+
}
|
|
52
|
+
/** 清除某个 session 的工作目录(可选,用于会话结束时清理) */
|
|
53
|
+
export function clearSessionCwd(sessionId) {
|
|
54
|
+
sessionCwdMap.delete(sessionId);
|
|
55
|
+
}
|
|
56
|
+
export const execTool = {
|
|
57
|
+
name: 'exec',
|
|
58
|
+
description: '在本地计算机上执行命令行命令。适用于运行系统命令、脚本、查看文件、执行程序等场景。',
|
|
59
|
+
parameters: {
|
|
60
|
+
type: 'object',
|
|
61
|
+
properties: {
|
|
62
|
+
command: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
description: '要执行的命令行命令。',
|
|
65
|
+
},
|
|
66
|
+
cwd: {
|
|
67
|
+
type: 'string',
|
|
68
|
+
description: '命令执行的工作目录(可选)。',
|
|
69
|
+
},
|
|
70
|
+
timeout: {
|
|
71
|
+
type: 'number',
|
|
72
|
+
description: '命令超时时间(毫秒),默认 60000',
|
|
73
|
+
default: 60000,
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
required: ['command'],
|
|
77
|
+
},
|
|
78
|
+
handler: async (args, sessionId) => {
|
|
79
|
+
return new Promise(async (resolve) => {
|
|
80
|
+
const { cwd, timeout = 60000 } = args;
|
|
81
|
+
let command = args.command;
|
|
82
|
+
const dangerousPatterns = [
|
|
83
|
+
/rm\s+-rf\s+\//,
|
|
84
|
+
/format\s+[a-z]:/i,
|
|
85
|
+
/del\s+\/[sfq]\s+\*/i,
|
|
86
|
+
/shutdown/i,
|
|
87
|
+
/reboot/i,
|
|
88
|
+
/mkfs/i,
|
|
89
|
+
/dd\s+if=.*of=\/dev\//i,
|
|
90
|
+
];
|
|
91
|
+
for (const reg of dangerousPatterns) {
|
|
92
|
+
if (reg.test(command)) {
|
|
93
|
+
resolve({ success: false, errorMessage: '危险命令已拦截' });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const options = {
|
|
98
|
+
cwd,
|
|
99
|
+
timeout,
|
|
100
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
101
|
+
windowsHide: true,
|
|
102
|
+
};
|
|
103
|
+
try {
|
|
104
|
+
const { stdout, stderr } = await execAsync(command, options);
|
|
105
|
+
resolve({
|
|
106
|
+
success: true,
|
|
107
|
+
output: stdout.toLocaleString().substring(0, 100000),
|
|
108
|
+
errorMessage: stderr.toLocaleString().substring(0, 100000)
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
const error = e;
|
|
114
|
+
resolve({ success: false, errorMessage: error.stdout.toLocaleString().substring(0, 100) + error.stderr.toLocaleString() + error.message });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { getLogger } from '@pocketclaw/shared';
|
|
2
|
+
const logger = getLogger('FetchTool');
|
|
3
|
+
let _undiciCache = null;
|
|
4
|
+
async function loadUndici() {
|
|
5
|
+
if (_undiciCache)
|
|
6
|
+
return _undiciCache;
|
|
7
|
+
try {
|
|
8
|
+
const undici = await import('undici');
|
|
9
|
+
_undiciCache = { ProxyAgent: undici.ProxyAgent, fetch: undici.fetch };
|
|
10
|
+
return _undiciCache;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
logger.debug('Failed to load undici:', error?.message);
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function getProxyUrl() {
|
|
18
|
+
return process.env.HTTPS_PROXY || process.env.https_proxy ||
|
|
19
|
+
process.env.HTTP_PROXY || process.env.http_proxy || 'http://127.0.0.1:7890';
|
|
20
|
+
}
|
|
21
|
+
const DEFAULT_PROXY_DOMAINS = [
|
|
22
|
+
'googleapis.com', 'google.com', 'gstatic.com', 'youtube.com', 'yt3.ggpht.com',
|
|
23
|
+
'openai.com', 'api.openai.com', 'chat.openai.com',
|
|
24
|
+
'anthropic.com', 'api.anthropic.com',
|
|
25
|
+
'github.com', 'githubusercontent.com',
|
|
26
|
+
'twitter.com', 'x.com', 'twimg.com',
|
|
27
|
+
'wikipedia.org', 'reddit.com', 'discord.com', 'discordapp.com',
|
|
28
|
+
'facebook.com', 'instagram.com', 'whatsapp.com', 'cloudflare.com',
|
|
29
|
+
];
|
|
30
|
+
const DEFAULT_NO_PROXY_DOMAINS = [
|
|
31
|
+
'.cn', '.com.cn', '.org.cn', '.net.cn',
|
|
32
|
+
'baidu.com', 'bilibili.com', 'qq.com', 'weixin.qq.com',
|
|
33
|
+
'taobao.com', 'tmall.com', 'jd.com', 'alipay.com',
|
|
34
|
+
'aliyun.com', 'tencentcloudapi.com', 'volcengineapi.com',
|
|
35
|
+
'douban.com', 'zhihu.com', 'weibo.com', 'douyin.com',
|
|
36
|
+
'163.com', '126.com', 'netease.com', 'sina.com.cn',
|
|
37
|
+
'localhost', '127.0.0.1', '::1',
|
|
38
|
+
];
|
|
39
|
+
function parseNoProxyEnv() {
|
|
40
|
+
return (process.env.NO_PROXY || process.env.no_proxy || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
function shouldUseProxy(targetUrl) {
|
|
43
|
+
let hostname;
|
|
44
|
+
try {
|
|
45
|
+
hostname = new URL(targetUrl).hostname.toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
for (const pattern of parseNoProxyEnv()) {
|
|
51
|
+
if (hostname === pattern || hostname.endsWith('.' + pattern))
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
for (const pattern of DEFAULT_NO_PROXY_DOMAINS) {
|
|
55
|
+
if (hostname === pattern || hostname.endsWith('.' + pattern))
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
for (const pattern of DEFAULT_PROXY_DOMAINS) {
|
|
59
|
+
if (hostname === pattern || hostname.endsWith('.' + pattern))
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
let _proxyDispatcherCache = null;
|
|
65
|
+
async function getProxyDispatcher() {
|
|
66
|
+
const proxyUrl = getProxyUrl();
|
|
67
|
+
if (_proxyDispatcherCache && _proxyDispatcherCache.url === proxyUrl)
|
|
68
|
+
return _proxyDispatcherCache.dispatcher;
|
|
69
|
+
const undici = await loadUndici();
|
|
70
|
+
if (!undici)
|
|
71
|
+
return undefined;
|
|
72
|
+
try {
|
|
73
|
+
const dispatcher = new undici.ProxyAgent(proxyUrl);
|
|
74
|
+
_proxyDispatcherCache = { url: proxyUrl, dispatcher };
|
|
75
|
+
logger.info(`[Fetch] ProxyAgent created: ${proxyUrl}`);
|
|
76
|
+
return dispatcher;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
logger.debug('Failed to create ProxyAgent:', error?.message);
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function toTimeoutSignal(ms) {
|
|
84
|
+
const validMs = Number.isFinite(ms) && ms > 0 ? ms : 30000;
|
|
85
|
+
return AbortSignal.timeout(validMs);
|
|
86
|
+
}
|
|
87
|
+
export const fetchTool = {
|
|
88
|
+
name: 'fetch',
|
|
89
|
+
description: '发送 HTTP 请求',
|
|
90
|
+
parameters: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
url: { type: 'string', description: '请求 URL' },
|
|
94
|
+
method: { type: 'string', description: 'HTTP 方法', enum: ['GET', 'POST', 'PUT', 'DELETE'], default: 'GET' },
|
|
95
|
+
headers: { type: 'object', description: '请求头' },
|
|
96
|
+
body: { type: 'string', description: '请求体(POST/PUT)' },
|
|
97
|
+
timeout: { type: 'number', description: '超时(毫秒)', default: 30000 },
|
|
98
|
+
},
|
|
99
|
+
required: ['url'],
|
|
100
|
+
},
|
|
101
|
+
handler: async (args) => {
|
|
102
|
+
const { url, method = 'GET', headers = {}, body, timeout = 30000 } = args;
|
|
103
|
+
try {
|
|
104
|
+
new URL(url);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return { success: false, errorMessage: '无效的 URL 格式' };
|
|
108
|
+
}
|
|
109
|
+
const reqHeaders = { 'User-Agent': 'Mozilla/5.0', ...headers };
|
|
110
|
+
const fetchOptions = { method, headers: reqHeaders, signal: toTimeoutSignal(timeout) };
|
|
111
|
+
if (body && ['POST', 'PUT'].includes(method)) {
|
|
112
|
+
fetchOptions.body = body;
|
|
113
|
+
if (!headers['Content-Type']) {
|
|
114
|
+
reqHeaders['Content-Type'] = 'application/json';
|
|
115
|
+
fetchOptions.headers = reqHeaders;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const useProxy = shouldUseProxy(url);
|
|
119
|
+
const undici = await loadUndici();
|
|
120
|
+
if (useProxy) {
|
|
121
|
+
const dispatcher = await getProxyDispatcher();
|
|
122
|
+
if (dispatcher)
|
|
123
|
+
fetchOptions.dispatcher = dispatcher;
|
|
124
|
+
}
|
|
125
|
+
const fetchFn = (useProxy && undici?.fetch) ? undici.fetch : globalThis.fetch;
|
|
126
|
+
logger.debug('fetch options', { method, url, useProxy });
|
|
127
|
+
try {
|
|
128
|
+
const response = await fetchFn(url, fetchOptions);
|
|
129
|
+
let responseBody = '';
|
|
130
|
+
try {
|
|
131
|
+
responseBody = await response.text();
|
|
132
|
+
}
|
|
133
|
+
catch { /* empty */ }
|
|
134
|
+
const contentType = response.headers.get('content-type') || '';
|
|
135
|
+
let data = responseBody;
|
|
136
|
+
if (contentType.includes('application/json')) {
|
|
137
|
+
try {
|
|
138
|
+
data = JSON.parse(responseBody);
|
|
139
|
+
}
|
|
140
|
+
catch { /* keep as string */ }
|
|
141
|
+
}
|
|
142
|
+
return { success: true, output: JSON.stringify({ status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers.entries()), body: typeof data === 'string' ? data.substring(0, 5000) : data }) };
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
146
|
+
logger.error('fetch error:', error, 'cause:', cause);
|
|
147
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
148
|
+
const causeMsg = cause instanceof Error ? cause.message : (cause ? String(cause) : '');
|
|
149
|
+
if (error instanceof DOMException && error.name === 'TimeoutError') {
|
|
150
|
+
return { success: false, errorMessage: `请求超时(${timeout}ms)` };
|
|
151
|
+
}
|
|
152
|
+
return { success: false, errorMessage: `请求失败: ${message}${causeMsg ? ' | cause: ' + causeMsg : ''}` };
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
};
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件操作工具
|
|
3
|
+
* 提供文件和目录的基础操作:读取、写入、创建、删除、搜索等
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import { exec } from 'child_process';
|
|
9
|
+
import { promisify } from 'util';
|
|
10
|
+
import { getLogger } from '@pocketclaw/shared';
|
|
11
|
+
const logger = getLogger('FileTool');
|
|
12
|
+
const execAsync = promisify(exec);
|
|
13
|
+
const DEFAULT_EXCLUDE_DIRS = [
|
|
14
|
+
'node_modules', '.git', '.svn', '.hg', 'dist', 'build', 'out', '.next', '.nuxt',
|
|
15
|
+
'.cache', '__pycache__', '.venv', '.env', '.idea', '.vscode', '.DS_Store',
|
|
16
|
+
'target', 'Pods', '.gradle', '.dart_tool',
|
|
17
|
+
];
|
|
18
|
+
const ALLOWED_DIRS = [
|
|
19
|
+
os.homedir(), '/tmp', process.env.GATEWAY_DIR || '/workspace/projects/gateway',
|
|
20
|
+
process.env.USER_HOME || '',
|
|
21
|
+
].filter(Boolean);
|
|
22
|
+
function isPathAllowed(_filePath) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
async function listDir(dirPath) {
|
|
26
|
+
const items = await fs.readdir(dirPath, { withFileTypes: true });
|
|
27
|
+
return items.map(item => ({ name: item.name, type: item.isDirectory() ? 'directory' : 'file', path: path.join(dirPath, item.name) }));
|
|
28
|
+
}
|
|
29
|
+
function isDirExcluded(dirName, excludeDirs) {
|
|
30
|
+
const lower = dirName.toLowerCase();
|
|
31
|
+
return excludeDirs.some(excl => lower === excl.toLowerCase() || lower.startsWith(excl.toLowerCase()));
|
|
32
|
+
}
|
|
33
|
+
function isWildcardPattern(query) {
|
|
34
|
+
return (query.includes('*') || query.includes('?')) && !query.startsWith('/');
|
|
35
|
+
}
|
|
36
|
+
function wildcardToRegex(pattern, matchWhole = true) {
|
|
37
|
+
let regexStr = '';
|
|
38
|
+
for (const char of pattern) {
|
|
39
|
+
if (char === '*')
|
|
40
|
+
regexStr += '.*';
|
|
41
|
+
else if (char === '?')
|
|
42
|
+
regexStr += '.';
|
|
43
|
+
else if ('.\\^$|+()[]{}'.includes(char))
|
|
44
|
+
regexStr += '\\' + char;
|
|
45
|
+
else
|
|
46
|
+
regexStr += char;
|
|
47
|
+
}
|
|
48
|
+
return new RegExp(matchWhole ? `^${regexStr}$` : regexStr, 'g');
|
|
49
|
+
}
|
|
50
|
+
function matchText(text, query, caseSensitive, wholeWord) {
|
|
51
|
+
if (wholeWord) {
|
|
52
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
53
|
+
const regex = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
|
|
54
|
+
return regex.test(text);
|
|
55
|
+
}
|
|
56
|
+
return caseSensitive ? text.includes(query) : text.toLowerCase().includes(query.toLowerCase());
|
|
57
|
+
}
|
|
58
|
+
function matchFileName(fileName, query, caseSensitive, wholeWord) {
|
|
59
|
+
if (wholeWord) {
|
|
60
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
61
|
+
const regex = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
|
|
62
|
+
return regex.test(fileName);
|
|
63
|
+
}
|
|
64
|
+
return caseSensitive ? fileName.includes(query) : fileName.toLowerCase().includes(query.toLowerCase());
|
|
65
|
+
}
|
|
66
|
+
function parseSearchQuery(query) {
|
|
67
|
+
const match = /^\/(.+)\/([gimsuy]*)$/.exec(query);
|
|
68
|
+
if (match) {
|
|
69
|
+
try {
|
|
70
|
+
return { regex: new RegExp(match[1], match[2]), plainText: null };
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return { regex: null, plainText: query };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { regex: null, plainText: query };
|
|
77
|
+
}
|
|
78
|
+
async function searchFiles(dirPath, query, maxResults = 50, excludeDirs = DEFAULT_EXCLUDE_DIRS, caseSensitive = false, wholeWord = false) {
|
|
79
|
+
const results = [];
|
|
80
|
+
const searchMode = parseSearchQuery(query);
|
|
81
|
+
const wildcardRegex = isWildcardPattern(query) ? wildcardToRegex(query, true) : null;
|
|
82
|
+
async function searchDir(dir) {
|
|
83
|
+
if (results.length >= maxResults)
|
|
84
|
+
return;
|
|
85
|
+
try {
|
|
86
|
+
const items = await fs.readdir(dir, { withFileTypes: true });
|
|
87
|
+
for (const item of items) {
|
|
88
|
+
if (results.length >= maxResults)
|
|
89
|
+
break;
|
|
90
|
+
const fullPath = path.join(dir, item.name);
|
|
91
|
+
if (item.isDirectory() && !item.name.startsWith('.') && !isDirExcluded(item.name, excludeDirs)) {
|
|
92
|
+
await searchDir(fullPath);
|
|
93
|
+
}
|
|
94
|
+
else if (item.isFile()) {
|
|
95
|
+
const matched = wildcardRegex ? wildcardRegex.test(item.name) :
|
|
96
|
+
matchFileName(item.name, searchMode.plainText || query, caseSensitive, wholeWord);
|
|
97
|
+
if (matched)
|
|
98
|
+
results.push(fullPath);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch { /* ignore */ }
|
|
103
|
+
}
|
|
104
|
+
await searchDir(dirPath);
|
|
105
|
+
return results;
|
|
106
|
+
}
|
|
107
|
+
export const fileTool = {
|
|
108
|
+
name: 'file',
|
|
109
|
+
description: '文件和目录操作工具',
|
|
110
|
+
parameters: {
|
|
111
|
+
type: 'object',
|
|
112
|
+
properties: {
|
|
113
|
+
action: {
|
|
114
|
+
type: 'string',
|
|
115
|
+
description: '操作类型:read、write、mkdir、delete、rmdir、list、open、search、searchContent',
|
|
116
|
+
enum: ['read', 'write', 'mkdir', 'delete', 'rmdir', 'list', 'open', 'search', 'searchContent'],
|
|
117
|
+
},
|
|
118
|
+
path: { type: 'string', description: '文件或目录路径' },
|
|
119
|
+
content: { type: 'string', description: '写入内容(write)' },
|
|
120
|
+
searchQuery: { type: 'string', description: '搜索关键词(search/searchContent)' },
|
|
121
|
+
maxResults: { type: 'number', description: '最大结果数', default: 50 },
|
|
122
|
+
excludeDirs: { type: 'string', description: '排除的目录(逗号分隔)' },
|
|
123
|
+
caseSensitive: { type: 'boolean', description: '区分大小写', default: false },
|
|
124
|
+
wholeWord: { type: 'boolean', description: '全字匹配', default: false },
|
|
125
|
+
offset: { type: 'number', description: '起始行号(从1开始)' },
|
|
126
|
+
limit: { type: 'number', description: '读取行数' },
|
|
127
|
+
maxContentResults: { type: 'number', description: '内容搜索最大结果数', default: 20 },
|
|
128
|
+
},
|
|
129
|
+
required: ['action'],
|
|
130
|
+
},
|
|
131
|
+
handler: async (args) => {
|
|
132
|
+
try {
|
|
133
|
+
const { action, path: filePath, content, searchQuery, maxResults = 50, excludeDirs, caseSensitive = false, wholeWord = false } = args;
|
|
134
|
+
const effectiveExcludeDirs = typeof excludeDirs === 'string' && excludeDirs.trim()
|
|
135
|
+
? [...new Set([...DEFAULT_EXCLUDE_DIRS, ...excludeDirs.split(',').map((d) => d.trim()).filter(Boolean)])]
|
|
136
|
+
: DEFAULT_EXCLUDE_DIRS;
|
|
137
|
+
if (!isPathAllowed(filePath))
|
|
138
|
+
return { success: false, errorMessage: `路径不在允许范围内` };
|
|
139
|
+
switch (action) {
|
|
140
|
+
case 'read': {
|
|
141
|
+
const stats = await fs.stat(filePath);
|
|
142
|
+
if (stats.isDirectory()) {
|
|
143
|
+
const items = await listDir(filePath);
|
|
144
|
+
return { success: true, output: JSON.stringify({ type: 'directory', items }) };
|
|
145
|
+
}
|
|
146
|
+
const fileContent = await fs.readFile(filePath, 'utf-8');
|
|
147
|
+
// split('\n') 会保留末尾空行,用 filter(Boolean) 去掉末尾空元素
|
|
148
|
+
// 保持一致性:无论文件是否以 \n 结尾,行号都从 1 开始,终点为总行数
|
|
149
|
+
const allLines = fileContent.split('\n').filter((_, i, arr) => !(i === arr.length - 1 && _ === ''));
|
|
150
|
+
const totalLines = allLines.length;
|
|
151
|
+
const offset = args.offset !== undefined ? Math.max(1, args.offset) : 1;
|
|
152
|
+
const limit = args.limit !== undefined ? args.limit : 0;
|
|
153
|
+
// limit=0 时读取到文件末尾
|
|
154
|
+
const endLine = limit === 0 ? totalLines : Math.min(offset + limit - 1, totalLines);
|
|
155
|
+
if (offset > totalLines) {
|
|
156
|
+
return { success: true, output: JSON.stringify({ totalLines, message: `offset (${offset}) 超出文件总行数 (${totalLines}),文件已读完` }) };
|
|
157
|
+
}
|
|
158
|
+
// 显示真实行号(而非 1,2,3...)让 LLM 准确知道位置
|
|
159
|
+
const contentLines = allLines.slice(offset - 1, endLine).map((item, index) => `${offset + index} ${item}`);
|
|
160
|
+
// 原始内容(无行号前缀),供 LLM 用作 edit 的 oldContent 比对
|
|
161
|
+
const rawContent = allLines.slice(offset - 1, endLine).join('\n');
|
|
162
|
+
return {
|
|
163
|
+
success: true,
|
|
164
|
+
output: JSON.stringify({
|
|
165
|
+
totalLines,
|
|
166
|
+
readRange: { startLine: offset, endLine },
|
|
167
|
+
// 带行号前缀的显示内容,LLM 可参考行号
|
|
168
|
+
content: contentLines.join('\n'),
|
|
169
|
+
// 原始内容(无行号),供 oldContent 比对使用
|
|
170
|
+
rawContent,
|
|
171
|
+
}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
case 'write': {
|
|
175
|
+
if (content === undefined)
|
|
176
|
+
return { success: false, errorMessage: 'write 操作需要提供 content' };
|
|
177
|
+
const dir = path.dirname(filePath);
|
|
178
|
+
await fs.mkdir(dir, { recursive: true });
|
|
179
|
+
await fs.writeFile(filePath, content, 'utf-8');
|
|
180
|
+
return { success: true, output: JSON.stringify({ path: filePath, size: content.length }) };
|
|
181
|
+
}
|
|
182
|
+
case 'mkdir': {
|
|
183
|
+
await fs.mkdir(filePath, { recursive: true });
|
|
184
|
+
return { success: true, output: JSON.stringify({ path: filePath }) };
|
|
185
|
+
}
|
|
186
|
+
case 'delete': {
|
|
187
|
+
const stats = await fs.stat(filePath);
|
|
188
|
+
if (stats.isDirectory())
|
|
189
|
+
return { success: false, errorMessage: 'delete 用于删除文件,删除目录请使用 rmdir' };
|
|
190
|
+
await fs.unlink(filePath);
|
|
191
|
+
return { success: true, output: JSON.stringify({ path: filePath }) };
|
|
192
|
+
}
|
|
193
|
+
case 'rmdir': {
|
|
194
|
+
const rmdirStat = await fs.stat(filePath);
|
|
195
|
+
if (!rmdirStat.isDirectory())
|
|
196
|
+
return { success: false, errorMessage: 'rmdir 用于删除目录' };
|
|
197
|
+
await fs.rm(filePath, { recursive: true });
|
|
198
|
+
return { success: true, output: JSON.stringify({ path: filePath }) };
|
|
199
|
+
}
|
|
200
|
+
case 'list': {
|
|
201
|
+
const items = await listDir(filePath);
|
|
202
|
+
return { success: true, output: JSON.stringify({ total: items.length, items }) };
|
|
203
|
+
}
|
|
204
|
+
case 'open': {
|
|
205
|
+
const stats = await fs.stat(filePath);
|
|
206
|
+
const targetPath = stats.isFile() ? path.dirname(filePath) : filePath;
|
|
207
|
+
let command, ignoreExitCode = false;
|
|
208
|
+
switch (os.platform()) {
|
|
209
|
+
case 'darwin':
|
|
210
|
+
command = `open "${targetPath}"`;
|
|
211
|
+
break;
|
|
212
|
+
case 'win32':
|
|
213
|
+
command = `explorer "${targetPath}"`;
|
|
214
|
+
ignoreExitCode = true;
|
|
215
|
+
break;
|
|
216
|
+
default:
|
|
217
|
+
command = `xdg-open "${targetPath}"`;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
await execAsync(command);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (!ignoreExitCode || error.code !== 1)
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
return { success: true, output: JSON.stringify({ path: targetPath, platform: os.platform() }) };
|
|
228
|
+
}
|
|
229
|
+
case 'search': {
|
|
230
|
+
if (!searchQuery)
|
|
231
|
+
return { success: false, errorMessage: 'search 操作需要提供 searchQuery' };
|
|
232
|
+
let searchRoot = filePath || os.homedir();
|
|
233
|
+
try {
|
|
234
|
+
const rootStat = await fs.stat(searchRoot);
|
|
235
|
+
if (!rootStat.isDirectory())
|
|
236
|
+
return { success: false, errorMessage: `path 必须是目录` };
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return { success: false, errorMessage: `搜索目录不存在或无法访问: ${searchRoot}` };
|
|
240
|
+
}
|
|
241
|
+
const results = await searchFiles(searchRoot, searchQuery, maxResults, effectiveExcludeDirs, caseSensitive, wholeWord);
|
|
242
|
+
return { success: true, output: JSON.stringify({ query: searchQuery, total: results.length, results }) };
|
|
243
|
+
}
|
|
244
|
+
case 'searchContent': {
|
|
245
|
+
if (!searchQuery)
|
|
246
|
+
return { success: false, errorMessage: 'searchContent 操作需要提供 searchQuery' };
|
|
247
|
+
const stats = await fs.stat(filePath);
|
|
248
|
+
const isDir = stats.isDirectory();
|
|
249
|
+
const results = [];
|
|
250
|
+
const maxContent = args.maxContentResults || 20;
|
|
251
|
+
const searchMode = parseSearchQuery(searchQuery);
|
|
252
|
+
const wildcardRegex = isWildcardPattern(searchQuery) ? wildcardToRegex(searchQuery, false) : null;
|
|
253
|
+
const BINARY_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.svg', '.mp3', '.mp4', '.avi', '.mov', '.wav', '.flac', '.zip', '.tar', '.gz', '.rar', '.7z', '.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.sqlite', '.db', '.woff', '.woff2', '.ttf', '.eot', '.class', '.jar', '.pyc', '.o', '.obj']);
|
|
254
|
+
async function searchInFile(targetPath) {
|
|
255
|
+
try {
|
|
256
|
+
const fileContent = await fs.readFile(targetPath, 'utf-8');
|
|
257
|
+
const lines = fileContent.split('\n');
|
|
258
|
+
for (let i = 0; i < lines.length; i++) {
|
|
259
|
+
if (results.length >= maxContent)
|
|
260
|
+
return;
|
|
261
|
+
const line = lines[i];
|
|
262
|
+
let matched = false;
|
|
263
|
+
if (searchMode.regex) {
|
|
264
|
+
matched = searchMode.regex.test(line);
|
|
265
|
+
searchMode.regex.lastIndex = 0;
|
|
266
|
+
}
|
|
267
|
+
else if (wildcardRegex)
|
|
268
|
+
matched = wildcardRegex.test(line);
|
|
269
|
+
else if (searchMode.plainText)
|
|
270
|
+
matched = matchText(line, searchMode.plainText, caseSensitive, wholeWord);
|
|
271
|
+
if (matched)
|
|
272
|
+
results.push({ file: targetPath, line: i + 1, content: line.substring(0, 200) });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch { /* ignore */ }
|
|
276
|
+
}
|
|
277
|
+
async function searchDirRecursive(dirPath) {
|
|
278
|
+
if (results.length >= maxContent)
|
|
279
|
+
return;
|
|
280
|
+
try {
|
|
281
|
+
const items = await fs.readdir(dirPath, { withFileTypes: true });
|
|
282
|
+
for (const item of items) {
|
|
283
|
+
if (results.length >= maxContent)
|
|
284
|
+
break;
|
|
285
|
+
const fullPath = path.join(dirPath, item.name);
|
|
286
|
+
if (item.isDirectory() && !item.name.startsWith('.') && !isDirExcluded(item.name, effectiveExcludeDirs)) {
|
|
287
|
+
await searchDirRecursive(fullPath);
|
|
288
|
+
}
|
|
289
|
+
else if (item.isFile()) {
|
|
290
|
+
const ext = path.extname(item.name).toLowerCase();
|
|
291
|
+
if (!BINARY_EXTS.has(ext))
|
|
292
|
+
await searchInFile(fullPath);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch { /* ignore */ }
|
|
297
|
+
}
|
|
298
|
+
if (!isDir) {
|
|
299
|
+
await searchInFile(filePath);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
await searchDirRecursive(filePath);
|
|
303
|
+
}
|
|
304
|
+
return { success: true, output: JSON.stringify({ query: searchQuery, path: filePath, total: results.length, results }) };
|
|
305
|
+
}
|
|
306
|
+
default:
|
|
307
|
+
return { success: false, errorMessage: `未知的文件操作: ${action}` };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
logger.error(`文件操作失败: ${error.message}`, { error, action: args?.action });
|
|
312
|
+
return { success: false, errorMessage: `文件操作失败: ${error.message}` };
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具定义集合
|
|
3
|
+
*/
|
|
4
|
+
export * from './types';
|
|
5
|
+
import { searchTool } from './search';
|
|
6
|
+
import { calculatorTool } from './calculator';
|
|
7
|
+
import { screenshotTool } from './screenshot';
|
|
8
|
+
import { keyboardTool } from './keyboard';
|
|
9
|
+
import { mouseTool } from './mouse';
|
|
10
|
+
import { fetchTool } from './fetch';
|
|
11
|
+
import { skillTool } from './skill';
|
|
12
|
+
import { execTool } from './exec';
|
|
13
|
+
import { fileTool } from './file';
|
|
14
|
+
import { taskTool } from './task';
|
|
15
|
+
import { modelTool } from './model';
|
|
16
|
+
import { editTool } from './edit';
|
|
17
|
+
import { webFetchTool } from './webFetch';
|
|
18
|
+
export const tools = [
|
|
19
|
+
searchTool, calculatorTool, screenshotTool, keyboardTool, mouseTool,
|
|
20
|
+
skillTool, execTool, taskTool, modelTool, fetchTool,
|
|
21
|
+
editTool, webFetchTool, fileTool
|
|
22
|
+
];
|
|
23
|
+
export function getToolByName(name) {
|
|
24
|
+
return tools.find(tool => tool.name === name);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 执行工具调用,返回 ToolResult(AI 可见格式)
|
|
28
|
+
*/
|
|
29
|
+
export async function executeTool(name, args, sessionId, messageId) {
|
|
30
|
+
const tool = getToolByName(name);
|
|
31
|
+
if (!tool)
|
|
32
|
+
throw new Error(`Tool not found: ${name}`);
|
|
33
|
+
return await tool.handler(args, sessionId, messageId);
|
|
34
|
+
}
|
|
35
|
+
export function getToolDefinitions() {
|
|
36
|
+
return tools.map(tool => ({
|
|
37
|
+
type: 'function',
|
|
38
|
+
function: {
|
|
39
|
+
name: tool.name,
|
|
40
|
+
description: tool.description,
|
|
41
|
+
parameters: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: tool.parameters.properties,
|
|
44
|
+
required: tool.parameters.required || [],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
48
|
+
}
|