Project Files
src / toolsProvider.ts
import { tool, Tool, ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import { existsSync, readdirSync } from "fs";
import { mkdir, readFile, writeFile } from "fs/promises";
import { exec } from "child_process";
import * as path from "path";
import * as os from "os";
import { configSchematics } from "./config";
import { getSecureFilePath } from "./utils";
export async function toolsProvider(ctl: ToolsProviderController): Promise<Tool[]> {
const config = ctl.getPluginConfig(configSchematics);
const enableFileSystem = config.get("enableFileSystem");
const enableConsole = config.get("enableConsole");
const enableInternet = config.get("enableInternet");
const commandTimeoutSec = config.get("commandTimeoutSec");
const maxWebpageLength = config.get("maxWebpageLength");
const browserProfilePath = config.get("browserProfilePath")?.trim();
const tools: Tool[] = [];
// ------------------------------------------------------------------
// 1. ФАЙЛОВАЯ СИСТЕМА
// ------------------------------------------------------------------
if (enableFileSystem) {
// --- list_files ---
tools.push(
tool({
name: "list_files",
description: `
<role>Вы — AI-агент, исследующий файловую систему.</role>
Используйте этот инструмент, чтобы получить список всех файлов (не папок) в базовой директории.
<usage_rules>
1. Возвращает массив имён файлов. Папки игнорируются.
2. Если базовая директория не существует или пуста, возвращается пустой массив.
3. Используйте этот инструмент перед чтением файлов, чтобы узнать, что доступно.
</usage_rules>
`,
parameters: {},
implementation: async (_, { signal }) => {
signal.throwIfAborted();
try {
const baseDir = config.get("baseDirectory")?.trim();
let rootDir: string;
if (baseDir) {
rootDir = path.resolve(baseDir);
} else {
rootDir = ctl.getWorkingDirectory();
}
if (!existsSync(rootDir)) {
return `❌ Базовая директория не существует: ${rootDir}`;
}
const entries = readdirSync(rootDir, { withFileTypes: true });
const files = entries
.filter(entry => entry.isFile())
.map(entry => entry.name);
if (files.length === 0) {
return "В базовой директории нет файлов.";
}
return `📁 Файлы в директории (${files.length}):\n` + files.map(f => `- ${f}`).join("\n");
} catch (error: any) {
return `❌ Ошибка при получении списка файлов: ${error.message}`;
}
},
})
);
// --- create_file ---
tools.push(
tool({
name: "create_file",
description: `
<role>Вы — AI-агент, пишущий код и документы.</role>
Используйте этот инструмент для создания или перезаписи файлов в базовой директории.
<usage_rules>
1. Указывайте полное имя файла с расширением (например, 'script.ts').
2. Содержимое должно быть корректным текстом или кодом.
3. Если файл существует, он будет ПЕРЕЗАПИСАН.
4. Файл будет создан внутри базовой директории, заданной в настройках плагина.
5. Чтобы узнать, какие файлы уже существуют, сначала используйте list_files.
</usage_rules>
`,
parameters: {
file_name: z.string().describe("Имя файла (например, 'main.ts')"),
content: z.string().describe("Содержимое файла"),
},
implementation: async ({ file_name, content }, { status, signal }) => {
signal.throwIfAborted();
status(`Подготовка к созданию файла: ${file_name}`);
try {
const filePath = getSecureFilePath(ctl, file_name);
const dir = path.dirname(filePath);
await mkdir(dir, { recursive: true });
status(`Запись ${content.length} символов...`);
await writeFile(filePath, content, "utf-8");
return `✅ Файл "${file_name}" успешно создан в ${path.dirname(filePath)}`;
} catch (error: any) {
return `❌ Ошибка при создании файла: ${error.message}`;
}
},
})
);
// --- read_file ---
tools.push(
tool({
name: "read_file",
description: `
<role>Вы — AI-агент, анализирующий существующий код и данные.</role>
Используйте этот инструмент для чтения содержимого файлов из базовой директории.
<usage_rules>
1. Указывайте точное имя файла (с расширением).
2. Не используйте для бинарных файлов.
3. Сначала вызовите list_files, чтобы узнать имена доступных файлов.
</usage_rules>
`,
parameters: {
file_name: z.string().describe("Имя файла для чтения"),
},
implementation: async ({ file_name }, { signal }) => {
signal.throwIfAborted();
try {
const filePath = getSecureFilePath(ctl, file_name);
if (!existsSync(filePath)) {
return `❌ Файл "${file_name}" не найден в базовой директории. Используйте list_files, чтобы увидеть список доступных файлов.`;
}
const content = await readFile(filePath, "utf-8");
return content;
} catch (error: any) {
return `❌ Ошибка чтения файла: ${error.message}`;
}
},
})
);
}
// ------------------------------------------------------------------
// 2. КОНСОЛЬНЫЕ КОМАНДЫ
// ------------------------------------------------------------------
if (enableConsole) {
tools.push(
tool({
name: "run_command",
description: `
<role>Вы — AI-агент с доступом к терминалу.</role>
Используйте этот инструмент для выполнения консольных команд в рабочей директории чата.
<usage_rules>
1. Команды выполняются в подоболочке (bash/sh или cmd.exe).
2. Максимальное время выполнения задано в настройках (по умолчанию 30 сек).
3. Не используйте интерактивные команды (vim, top).
4. Для получения списка файлов предпочтительнее использовать list_files.
</usage_rules>
`,
parameters: {
command: z.string().describe("Команда для запуска (например, 'ls -la')"),
},
implementation: async ({ command }, { status, signal }) => {
signal.throwIfAborted();
status(`Запуск команды: ${command}`);
const maxBuffer = 100 * 1024; // 100 КБ
const execPromiseWithCancel = () => {
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
const child = exec(
command,
{
cwd: ctl.getWorkingDirectory(),
timeout: commandTimeoutSec * 1000,
maxBuffer,
},
(error, stdout, stderr) => {
if (error) {
reject(new Error(stderr || error.message));
} else {
resolve({ stdout, stderr });
}
}
);
const abortHandler = () => {
child.kill("SIGTERM");
reject(new Error("Операция отменена пользователем."));
};
signal.addEventListener("abort", abortHandler, { once: true });
child.on("close", () => {
signal.removeEventListener("abort", abortHandler);
});
});
};
try {
const { stdout, stderr } = await execPromiseWithCancel();
status("Команда выполнена");
if (stderr && !stdout) {
return `⚠️ Команда выполнена с предупреждениями:\n${stderr}`;
}
return stdout.trim() || "Команда выполнена успешно (нет вывода).";
} catch (error: any) {
return `❌ Ошибка выполнения команды: ${error.message}`;
}
},
})
);
}
// ------------------------------------------------------------------
// 3. ИНТЕРНЕТ (fetch_webpage на базе Playwright с профилем Chrome)
// ------------------------------------------------------------------
if (enableInternet) {
tools.push(
tool({
name: "fetch_webpage",
description: `
<role>Вы — AI-агент с доступом к браузеру Playwright (Chrome).</role>
Используйте этот инструмент для загрузки любой веб-страницы, включая поиск Google.
<usage_rules>
1. Указывайте полный URL (включая https://).
2. Для поиска в Google сформируйте URL самостоятельно: https://www.google.com/search?q=запрос (пробелы → +).
3. Возвращает заголовок, очищенный текст (до ${maxWebpageLength} символов), первые 20 ссылок и 20 изображений.
4. Страница загружается в браузере Chrome. Если указан путь к профилю в настройках, используются ваши куки (рекомендуется для обхода капчи).
</usage_rules>
`,
parameters: {
url: z.string().url().describe("Полный URL-адрес для загрузки"),
},
implementation: async ({ url }, { status, signal }) => {
signal.throwIfAborted();
status(`Запуск браузера для ${url}...`);
const { chromium } = await import('playwright');
const fs = await import('fs/promises');
let userDataDir: string;
let tempDir: string | null = null;
if (browserProfilePath && browserProfilePath.length > 0) {
userDataDir = browserProfilePath;
} else {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'playwright-chrome-'));
userDataDir = tempDir;
}
let context;
try {
context = await chromium.launchPersistentContext(userDataDir, {
headless: false,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-blink-features=AutomationControlled',
'--disable-features=IsolateOrigins,site-per-process',
],
viewport: { width: 1280, height: 720 },
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
const page = await context.newPage();
await page.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
});
status(`Загрузка страницы...`);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
status(`Извлечение данных...`);
const title = await page.title();
const bodyText = await page.evaluate(() => document.body.innerText || '');
let text = bodyText.replace(/\s+/g, ' ').trim();
if (text.length > maxWebpageLength) {
text = text.substring(0, maxWebpageLength) + '... (текст обрезан)';
}
const links = await page.$$eval('a[href]', (elements: Element[]) => {
return elements.slice(0, 20).map(el => (el as HTMLAnchorElement).href).filter(href => href && !href.startsWith('javascript:'));
});
const imageUrls = await page.$$eval('img[src]', (elements: Element[]) => {
return elements.slice(0, 20).map(el => (el as HTMLImageElement).src).filter(src => src);
});
await context.close();
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
let result = `🌐 **${title}**\nURL: ${url}\n\n`;
result += `📄 **Основной текст** (${text.length} символов):\n${text}\n\n`;
if (links.length > 0) {
result += `🔗 **Ссылки** (первые ${links.length}):\n${links.map(l => `- ${l}`).join('\n')}\n\n`;
}
if (imageUrls.length > 0) {
result += `🖼️ **Изображения** (первые ${imageUrls.length}):\n${imageUrls.map(i => `- ${i}`).join('\n')}`;
}
return result;
} catch (error: any) {
if (context) await context.close().catch(() => {});
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
if (error.name === 'AbortError' || signal.aborted) {
return '⏹️ Загрузка страницы отменена пользователем.';
}
return `❌ Ошибка при загрузке страницы: ${error.message}`;
}
},
})
);
}
return tools;
}