diff --git a/package-lock.json b/package-lock.json index 76e2739..f48523d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,8 @@ "inquirer": "^12.0.0", "ora": "^8.1.0", "tree-sitter-bash": "^0.25.1", + "vscode-jsonrpc": "^8.2.1", + "vscode-languageserver-protocol": "^3.17.5", "web-tree-sitter": "^0.25.10", "zod": "^4.1.13" }, @@ -1500,6 +1502,40 @@ "devOptional": true, "license": "MIT" }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-protocol/node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, "node_modules/web-tree-sitter": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", diff --git a/package.json b/package.json index 67644ba..9379985 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "inquirer": "^12.0.0", "ora": "^8.1.0", "tree-sitter-bash": "^0.25.1", + "vscode-jsonrpc": "^8.2.1", + "vscode-languageserver-protocol": "^3.17.5", "web-tree-sitter": "^0.25.10", "zod": "^4.1.13" }, diff --git a/src/index.ts b/src/index.ts index 1072419..d16f7aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,13 @@ import { loadConfig, initConfig } from './utils/config.js'; import { toolRegistry, todoManager } from './tools/index.js'; import { getPermissionManager, promptPermission } from './permission/index.js'; import { SessionManager } from './session/index.js'; +import { initLSP, shutdownLSP } from './lsp/index.js'; +import { + printServerList, + installServer, + installAllServers, + showServerInfo, +} from './lsp/cli.js'; const program = new Command(); @@ -23,6 +30,43 @@ program await initConfig(); }); +// LSP 命令组 +const lspCommand = program + .command('lsp') + .description('语言服务器管理'); + +lspCommand + .command('list') + .description('列出所有语言服务器及其安装状态') + .action(() => { + printServerList(); + }); + +lspCommand + .command('install [servers...]') + .description('安装指定的语言服务器') + .option('-a, --all', '安装所有语言服务器') + .action(async (servers: string[], options: { all?: boolean }) => { + if (options.all) { + await installAllServers(); + } else if (servers.length === 0) { + console.log('用法: ai-assist lsp install [server2] ...'); + console.log(' ai-assist lsp install --all'); + console.log('\n运行 "ai-assist lsp list" 查看可用的服务器'); + } else { + for (const server of servers) { + await installServer(server); + } + } + }); + +lspCommand + .command('info ') + .description('显示语言服务器详细信息') + .action((server: string) => { + showServerInfo(server); + }); + // 初始化权限系统 function setupPermissions(): void { const permissionManager = getPermissionManager(); @@ -61,6 +105,9 @@ program.action(async () => { const config = loadConfig(); const agent = new Agent(config); + // 初始化 LSP 系统 + initLSP(process.cwd()); + // 设置工具注册表(支持动态工具发现) agent.setRegistry(toolRegistry); @@ -84,6 +131,7 @@ program.action(async () => { // 优雅退出 process.on('SIGINT', async () => { console.log('\n\n👋 再见!'); + await shutdownLSP(); await sessionManager.close(); ui.close(); process.exit(0); diff --git a/src/lsp/cli.ts b/src/lsp/cli.ts new file mode 100644 index 0000000..af80fd7 --- /dev/null +++ b/src/lsp/cli.ts @@ -0,0 +1,284 @@ +/** + * LSP CLI 命令 + * 提供语言服务器的查询和安装功能 + */ + +import { execSync, spawnSync } from 'child_process'; +import { getUniqueServers, type ServerConfig, type InstallConfig } from './server.js'; +import type { LanguageId } from './language.js'; + +// 服务器状态 +export interface ServerStatus { + id: string; + displayName: string; + description: string; + command: string; + installed: boolean; + languages: LanguageId[]; + install: InstallConfig; +} + +/** + * 检查命令是否存在 + */ +function commandExists(command: string): boolean { + try { + execSync(`which ${command}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** + * 检查包管理器是否可用 + */ +function hasPackageManager(manager: string): boolean { + return commandExists(manager); +} + +/** + * 获取所有服务器状态 + */ +export function listServers(): ServerStatus[] { + const servers = getUniqueServers(); + + return servers.map((server) => ({ + id: server.id, + displayName: server.config.displayName, + description: server.config.description, + command: server.config.command, + installed: commandExists(server.config.command), + languages: server.languages, + install: server.config.install, + })); +} + +/** + * 打印服务器列表 + */ +export function printServerList(): void { + const servers = listServers(); + + console.log('\n语言服务器状态:\n'); + console.log(' 状态 | 服务器 | 支持语言'); + console.log(' ------+--------------------------------+------------------'); + + for (const server of servers) { + const status = server.installed ? ' ✓ ' : ' ✗ '; + const statusColor = server.installed ? '\x1b[32m' : '\x1b[31m'; + const reset = '\x1b[0m'; + const name = server.displayName.padEnd(30); + const langs = server.languages.slice(0, 3).join(', ') + (server.languages.length > 3 ? '...' : ''); + + console.log(` ${statusColor}${status}${reset} | ${name} | ${langs}`); + } + + const installed = servers.filter((s) => s.installed).length; + console.log(`\n 已安装: ${installed}/${servers.length}\n`); +} + +/** + * 获取安装命令 + */ +function getInstallCommand(install: InstallConfig): { command: string; description: string } | null { + // 优先使用 npm + if (install.npm && hasPackageManager('npm')) { + return { + command: `npm install -g ${install.npm}`, + description: 'npm', + }; + } + + // pip + if (install.pip && hasPackageManager('pip3')) { + return { + command: `pip3 install ${install.pip}`, + description: 'pip3', + }; + } + if (install.pip && hasPackageManager('pip')) { + return { + command: `pip install ${install.pip}`, + description: 'pip', + }; + } + + // go + if (install.go && hasPackageManager('go')) { + return { + command: `go install ${install.go}`, + description: 'go install', + }; + } + + // rustup + if (install.rustup && hasPackageManager('rustup')) { + return { + command: `rustup component add ${install.rustup}`, + description: 'rustup', + }; + } + + // cargo + if (install.cargo && hasPackageManager('cargo')) { + return { + command: `cargo install ${install.cargo}`, + description: 'cargo', + }; + } + + // brew + if (install.brew && hasPackageManager('brew')) { + return { + command: `brew install ${install.brew}`, + description: 'Homebrew', + }; + } + + // gem + if (install.gem && hasPackageManager('gem')) { + return { + command: `gem install ${install.gem}`, + description: 'RubyGems', + }; + } + + // custom + if (install.custom) { + return { + command: install.custom, + description: '自定义命令', + }; + } + + return null; +} + +/** + * 安装服务器 + */ +export async function installServer(serverId: string): Promise { + const servers = listServers(); + const server = servers.find( + (s) => s.id === serverId || s.displayName.toLowerCase() === serverId.toLowerCase() + ); + + if (!server) { + console.error(`\x1b[31m错误: 未找到服务器 "${serverId}"\x1b[0m`); + console.log('\n可用的服务器:'); + servers.forEach((s) => console.log(` - ${s.displayName} (${s.id})`)); + return false; + } + + if (server.installed) { + console.log(`\x1b[32m✓ ${server.displayName} 已安装\x1b[0m`); + return true; + } + + const installCmd = getInstallCommand(server.install); + + if (!installCmd) { + console.error(`\x1b[31m无法自动安装 ${server.displayName}\x1b[0m`); + if (server.install.manual) { + console.log('\n手动安装说明:'); + console.log(server.install.manual); + } + return false; + } + + console.log(`\n正在安装 ${server.displayName}...`); + console.log(`使用 ${installCmd.description}: ${installCmd.command}\n`); + + try { + const result = spawnSync(installCmd.command, { + shell: true, + stdio: 'inherit', + }); + + if (result.status === 0) { + console.log(`\n\x1b[32m✓ ${server.displayName} 安装成功\x1b[0m`); + return true; + } else { + console.error(`\n\x1b[31m✗ ${server.displayName} 安装失败\x1b[0m`); + if (server.install.manual) { + console.log('\n手动安装说明:'); + console.log(server.install.manual); + } + return false; + } + } catch (error) { + console.error(`\n\x1b[31m安装出错: ${error}\x1b[0m`); + return false; + } +} + +/** + * 安装所有服务器 + */ +export async function installAllServers(): Promise { + const servers = listServers(); + const notInstalled = servers.filter((s) => !s.installed); + + if (notInstalled.length === 0) { + console.log('\x1b[32m所有语言服务器都已安装\x1b[0m'); + return; + } + + console.log(`\n将安装 ${notInstalled.length} 个语言服务器:\n`); + notInstalled.forEach((s) => console.log(` - ${s.displayName}`)); + console.log(''); + + let success = 0; + let failed = 0; + + for (const server of notInstalled) { + const result = await installServer(server.id); + if (result) { + success++; + } else { + failed++; + } + console.log(''); + } + + console.log(`\n安装完成: ${success} 成功, ${failed} 失败`); +} + +/** + * 显示服务器详细信息 + */ +export function showServerInfo(serverId: string): void { + const servers = listServers(); + const server = servers.find( + (s) => s.id === serverId || s.displayName.toLowerCase() === serverId.toLowerCase() + ); + + if (!server) { + console.error(`\x1b[31m错误: 未找到服务器 "${serverId}"\x1b[0m`); + return; + } + + const status = server.installed ? '\x1b[32m已安装\x1b[0m' : '\x1b[31m未安装\x1b[0m'; + + console.log(`\n${server.displayName}`); + console.log('='.repeat(40)); + console.log(`状态: ${status}`); + console.log(`命令: ${server.command}`); + console.log(`描述: ${server.description}`); + console.log(`支持语言: ${server.languages.join(', ')}`); + + if (!server.installed) { + const installCmd = getInstallCommand(server.install); + if (installCmd) { + console.log(`\n安装命令 (${installCmd.description}):`); + console.log(` ${installCmd.command}`); + } + if (server.install.manual) { + console.log('\n手动安装说明:'); + console.log(server.install.manual); + } + } + + console.log(''); +} diff --git a/src/lsp/client.ts b/src/lsp/client.ts new file mode 100644 index 0000000..e1d2fb9 --- /dev/null +++ b/src/lsp/client.ts @@ -0,0 +1,409 @@ +/** + * LSP 客户端 + * 负责与语言服务器通信 + */ + +import { spawn, ChildProcess } from 'child_process'; +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, + MessageConnection, +} from 'vscode-jsonrpc/node.js'; +import { + InitializeParams, + Diagnostic, + DiagnosticSeverity, + PublishDiagnosticsParams, +} from 'vscode-languageserver-protocol'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { getLanguageId, type LanguageId } from './language.js'; +import { getServerConfig, type ServerConfig } from './server.js'; + +// 诊断信息接口 +export interface FileDiagnostic { + file: string; + line: number; + column: number; + endLine?: number; + endColumn?: number; + severity: 'error' | 'warning' | 'info' | 'hint'; + message: string; + source?: string; + code?: string | number; +} + +// 客户端状态 +interface ClientState { + process: ChildProcess; + connection: MessageConnection; + languageId: LanguageId; + config: ServerConfig; + initialized: boolean; + openDocuments: Set; + documentVersions: Map; + diagnostics: Map; + rootUri: string; +} + +/** + * LSP 客户端管理器 + * 管理多个语言服务器的生命周期 + */ +export class LSPClientManager { + private clients: Map = new Map(); + private rootPath: string; + + constructor(rootPath?: string) { + this.rootPath = rootPath || process.cwd(); + } + + /** + * 设置工作区根目录 + */ + setRootPath(rootPath: string): void { + this.rootPath = rootPath; + } + + /** + * 获取或启动语言服务器 + */ + async getClient(languageId: LanguageId): Promise { + // 如果已有客户端,直接返回 + if (this.clients.has(languageId)) { + return this.clients.get(languageId); + } + + // 获取服务器配置 + const config = getServerConfig(languageId); + if (!config) { + return undefined; + } + + // 启动语言服务器 + try { + const client = await this.startServer(languageId, config); + this.clients.set(languageId, client); + return client; + } catch (error) { + console.error(`启动语言服务器失败 (${languageId}):`, error); + return undefined; + } + } + + /** + * 启动语言服务器 + */ + private async startServer(languageId: LanguageId, config: ServerConfig): Promise { + // 检查命令是否可用 + const commandExists = await this.checkCommand(config.command); + if (!commandExists) { + throw new Error(`语言服务器命令不存在: ${config.command}`); + } + + // 启动进程 + const serverProcess = spawn(config.command, config.args, { + env: { ...process.env, ...config.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + if (!serverProcess.stdin || !serverProcess.stdout) { + throw new Error('无法创建进程管道'); + } + + // 创建 JSON-RPC 连接 + const connection = createMessageConnection( + new StreamMessageReader(serverProcess.stdout), + new StreamMessageWriter(serverProcess.stdin) + ); + + const rootUri = `file://${this.rootPath}`; + + const state: ClientState = { + process: serverProcess, + connection, + languageId, + config, + initialized: false, + openDocuments: new Set(), + documentVersions: new Map(), + diagnostics: new Map(), + rootUri, + }; + + // 监听诊断通知(使用字符串方法名) + connection.onNotification('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => { + const filePath = params.uri.replace('file://', ''); + state.diagnostics.set(filePath, params.diagnostics); + }); + + // 监听进程错误 + serverProcess.on('error', (error) => { + console.error(`语言服务器错误 (${languageId}):`, error); + }); + + serverProcess.on('exit', () => { + this.clients.delete(languageId); + }); + + // 启动连接 + connection.listen(); + + // 初始化服务器 + await this.initializeServer(state, config); + + return state; + } + + /** + * 初始化语言服务器 + */ + private async initializeServer(state: ClientState, config: ServerConfig): Promise { + const initParams: InitializeParams = { + processId: process.pid, + rootUri: state.rootUri, + capabilities: { + textDocument: { + synchronization: { + dynamicRegistration: false, + willSave: false, + willSaveWaitUntil: false, + didSave: true, + }, + publishDiagnostics: { + relatedInformation: true, + tagSupport: { valueSet: [1, 2] }, + }, + }, + workspace: { + workspaceFolders: true, + }, + }, + workspaceFolders: [ + { + uri: state.rootUri, + name: path.basename(this.rootPath), + }, + ], + initializationOptions: config.initializationOptions, + }; + + // 使用字符串方法名发送请求 + await state.connection.sendRequest('initialize', initParams); + await state.connection.sendNotification('initialized', {}); + state.initialized = true; + } + + /** + * 通知文件变更(打开或更新) + * @returns 是否是首次启动服务器(需要更长等待时间) + */ + async touchFile(filePath: string, isNew: boolean = false): Promise { + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.join(this.rootPath, filePath); + + const languageId = getLanguageId(absolutePath); + if (!languageId) { + return false; + } + + // 检查是否是首次启动 + const wasRunning = this.clients.has(languageId); + + const client = await this.getClient(languageId); + if (!client || !client.initialized) { + return false; + } + + const isFirstStart = !wasRunning; + + const uri = `file://${absolutePath}`; + const content = await fs.readFile(absolutePath, 'utf-8'); + + if (!client.openDocuments.has(absolutePath)) { + // 打开文档(使用字符串方法名) + await client.connection.sendNotification('textDocument/didOpen', { + textDocument: { + uri, + languageId, + version: 1, + text: content, + }, + }); + client.openDocuments.add(absolutePath); + client.documentVersions.set(absolutePath, 1); + } else { + // 更新文档 + const version = (client.documentVersions.get(absolutePath) || 0) + 1; + client.documentVersions.set(absolutePath, version); + + await client.connection.sendNotification('textDocument/didChange', { + textDocument: { + uri, + version, + }, + contentChanges: [{ text: content }], + }); + } + + // 等待诊断结果(给服务器一些时间处理) + await this.waitForDiagnostics(100); + + return isFirstStart; + } + + /** + * 关闭文档 + */ + async closeFile(filePath: string): Promise { + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.join(this.rootPath, filePath); + + const languageId = getLanguageId(absolutePath); + if (!languageId) { + return; + } + + const client = this.clients.get(languageId); + if (!client || !client.openDocuments.has(absolutePath)) { + return; + } + + const uri = `file://${absolutePath}`; + await client.connection.sendNotification('textDocument/didClose', { + textDocument: { uri }, + }); + + client.openDocuments.delete(absolutePath); + client.documentVersions.delete(absolutePath); + client.diagnostics.delete(absolutePath); + } + + /** + * 获取文件诊断信息 + */ + getDiagnostics(filePath?: string): Map { + const result = new Map(); + + for (const client of this.clients.values()) { + for (const [file, diagnostics] of client.diagnostics) { + if (filePath && file !== filePath) { + continue; + } + + const fileDiagnostics = diagnostics.map((d) => this.convertDiagnostic(file, d)); + if (fileDiagnostics.length > 0) { + const existing = result.get(file) || []; + result.set(file, [...existing, ...fileDiagnostics]); + } + } + } + + return result; + } + + /** + * 获取单个文件的诊断信息 + */ + getFileDiagnostics(filePath: string): FileDiagnostic[] { + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.join(this.rootPath, filePath); + + for (const client of this.clients.values()) { + const diagnostics = client.diagnostics.get(absolutePath); + if (diagnostics) { + return diagnostics.map((d) => this.convertDiagnostic(absolutePath, d)); + } + } + + return []; + } + + /** + * 转换诊断信息格式 + */ + private convertDiagnostic(file: string, diagnostic: Diagnostic): FileDiagnostic { + return { + file, + line: diagnostic.range.start.line + 1, + column: diagnostic.range.start.character + 1, + endLine: diagnostic.range.end.line + 1, + endColumn: diagnostic.range.end.character + 1, + severity: this.convertSeverity(diagnostic.severity), + message: diagnostic.message, + source: diagnostic.source, + code: diagnostic.code as string | number | undefined, + }; + } + + /** + * 转换严重性 + */ + private convertSeverity(severity?: DiagnosticSeverity): FileDiagnostic['severity'] { + switch (severity) { + case DiagnosticSeverity.Error: + return 'error'; + case DiagnosticSeverity.Warning: + return 'warning'; + case DiagnosticSeverity.Information: + return 'info'; + case DiagnosticSeverity.Hint: + return 'hint'; + default: + return 'error'; + } + } + + /** + * 检查命令是否存在 + */ + private async checkCommand(command: string): Promise { + try { + const { execSync } = await import('child_process'); + execSync(`which ${command}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } + } + + /** + * 等待诊断结果 + */ + private waitForDiagnostics(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * 关闭所有客户端 + */ + async shutdown(): Promise { + for (const [languageId, client] of this.clients) { + try { + client.connection.dispose(); + client.process.kill(); + } catch (error) { + console.error(`关闭语言服务器失败 (${languageId}):`, error); + } + } + this.clients.clear(); + } + + /** + * 检查服务器是否运行中 + */ + isServerRunning(languageId: LanguageId): boolean { + return this.clients.has(languageId); + } + + /** + * 获取运行中的服务器列表 + */ + getRunningServers(): LanguageId[] { + return Array.from(this.clients.keys()); + } +} diff --git a/src/lsp/index.ts b/src/lsp/index.ts new file mode 100644 index 0000000..e120971 --- /dev/null +++ b/src/lsp/index.ts @@ -0,0 +1,128 @@ +/** + * LSP 模块入口 + * 提供简化的 API 供其他模块使用 + */ + +import { LSPClientManager, type FileDiagnostic } from './client.js'; +import { getLanguageId, isLanguageSupported } from './language.js'; + +// 全局 LSP 管理器实例 +let lspManager: LSPClientManager | null = null; + +/** + * 初始化 LSP 系统 + */ +export function initLSP(rootPath?: string): void { + if (lspManager) { + return; + } + lspManager = new LSPClientManager(rootPath); +} + +/** + * 获取 LSP 管理器实例 + */ +export function getLSPManager(): LSPClientManager | null { + return lspManager; +} + +/** + * 通知 LSP 文件已变更 + * 用于编辑/写入文件后通知语言服务器 + * @returns 是否是首次启动服务器(需要更长等待时间) + */ +export async function touchFile(filePath: string, isNew: boolean = false): Promise { + if (!lspManager) { + return false; + } + + if (!isLanguageSupported(filePath)) { + return false; + } + + try { + return await lspManager.touchFile(filePath, isNew); + } catch (error) { + // 静默失败,LSP 是增强功能 + console.error('LSP touchFile 错误:', error); + return false; + } +} + +/** + * 获取所有文件的诊断信息 + */ +export function getDiagnostics(): Map { + if (!lspManager) { + return new Map(); + } + return lspManager.getDiagnostics(); +} + +/** + * 获取单个文件的诊断信息 + */ +export function getFileDiagnostics(filePath: string): FileDiagnostic[] { + if (!lspManager) { + return []; + } + return lspManager.getFileDiagnostics(filePath); +} + +/** + * 格式化诊断信息为字符串 + * 用于返回给 AI + */ +export function formatDiagnostics(diagnostics: FileDiagnostic[]): string { + if (diagnostics.length === 0) { + return ''; + } + + const lines: string[] = []; + for (const d of diagnostics) { + const location = `${d.line}:${d.column}`; + const severity = d.severity.toUpperCase(); + const code = d.code ? ` [${d.code}]` : ''; + const source = d.source ? ` (${d.source})` : ''; + lines.push(` ${location} ${severity}${code}: ${d.message}${source}`); + } + + return lines.join('\n'); +} + +/** + * 获取文件诊断并格式化为 AI 可读的格式 + */ +export async function getFormattedFileDiagnostics(filePath: string): Promise { + const diagnostics = getFileDiagnostics(filePath); + + if (diagnostics.length === 0) { + return ''; + } + + const errors = diagnostics.filter(d => d.severity === 'error'); + const warnings = diagnostics.filter(d => d.severity === 'warning'); + + let result = `\n\n`; + result += `发现 ${errors.length} 个错误, ${warnings.length} 个警告:\n`; + result += formatDiagnostics(diagnostics); + result += '\n'; + + return result; +} + +/** + * 关闭 LSP 系统 + */ +export async function shutdownLSP(): Promise { + if (lspManager) { + await lspManager.shutdown(); + lspManager = null; + } +} + +// 导出类型 +export type { FileDiagnostic } from './client.js'; +export { getLanguageId, isLanguageSupported, getSupportedExtensions } from './language.js'; +export { getServerConfig, hasServerConfig, getSupportedLanguages } from './server.js'; +export { LSPClientManager } from './client.js'; diff --git a/src/lsp/language.ts b/src/lsp/language.ts new file mode 100644 index 0000000..a2ad6e0 --- /dev/null +++ b/src/lsp/language.ts @@ -0,0 +1,138 @@ +/** + * 文件扩展名到 LSP languageId 的映射 + */ + +// 语言 ID 定义 +export type LanguageId = + | 'typescript' + | 'javascript' + | 'typescriptreact' + | 'javascriptreact' + | 'python' + | 'go' + | 'rust' + | 'java' + | 'c' + | 'cpp' + | 'csharp' + | 'php' + | 'ruby' + | 'swift' + | 'kotlin' + | 'scala' + | 'html' + | 'css' + | 'scss' + | 'less' + | 'json' + | 'yaml' + | 'markdown' + | 'vue' + | 'svelte'; + +// 扩展名到语言 ID 的映射 +const extensionToLanguageId: Record = { + // TypeScript/JavaScript + '.ts': 'typescript', + '.tsx': 'typescriptreact', + '.js': 'javascript', + '.jsx': 'javascriptreact', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.mts': 'typescript', + '.cts': 'typescript', + + // Python + '.py': 'python', + '.pyi': 'python', + '.pyw': 'python', + + // Go + '.go': 'go', + + // Rust + '.rs': 'rust', + + // Java + '.java': 'java', + + // C/C++ + '.c': 'c', + '.h': 'c', + '.cpp': 'cpp', + '.cc': 'cpp', + '.cxx': 'cpp', + '.hpp': 'cpp', + '.hh': 'cpp', + '.hxx': 'cpp', + + // C# + '.cs': 'csharp', + + // PHP + '.php': 'php', + + // Ruby + '.rb': 'ruby', + '.rake': 'ruby', + + // Swift + '.swift': 'swift', + + // Kotlin + '.kt': 'kotlin', + '.kts': 'kotlin', + + // Scala + '.scala': 'scala', + '.sc': 'scala', + + // Web + '.html': 'html', + '.htm': 'html', + '.css': 'css', + '.scss': 'scss', + '.less': 'less', + '.vue': 'vue', + '.svelte': 'svelte', + + // Data formats + '.json': 'json', + '.yaml': 'yaml', + '.yml': 'yaml', + + // Markdown + '.md': 'markdown', + '.markdown': 'markdown', +}; + +/** + * 根据文件路径获取语言 ID + */ +export function getLanguageId(filePath: string): LanguageId | undefined { + const ext = getExtension(filePath); + return extensionToLanguageId[ext]; +} + +/** + * 获取文件扩展名(小写) + */ +function getExtension(filePath: string): string { + const lastDot = filePath.lastIndexOf('.'); + if (lastDot === -1) return ''; + return filePath.slice(lastDot).toLowerCase(); +} + +/** + * 检查文件是否支持 LSP + */ +export function isLanguageSupported(filePath: string): boolean { + return getLanguageId(filePath) !== undefined; +} + +/** + * 获取所有支持的扩展名 + */ +export function getSupportedExtensions(): string[] { + return Object.keys(extensionToLanguageId); +} diff --git a/src/lsp/server.ts b/src/lsp/server.ts new file mode 100644 index 0000000..4bdfb44 --- /dev/null +++ b/src/lsp/server.ts @@ -0,0 +1,336 @@ +/** + * 语言服务器定义 + * 定义各种语言的 LSP 服务器配置 + */ + +import type { LanguageId } from './language.js'; + +// 安装命令配置 +export interface InstallConfig { + /** npm 全局安装包名 */ + npm?: string; + /** pip 安装包名 */ + pip?: string; + /** go install 路径 */ + go?: string; + /** cargo install 包名 */ + cargo?: string; + /** brew 安装包名 */ + brew?: string; + /** gem 安装包名 */ + gem?: string; + /** rustup component */ + rustup?: string; + /** 自定义安装命令 */ + custom?: string; + /** 安装说明(当无法自动安装时) */ + manual?: string; +} + +// 服务器配置接口 +export interface ServerConfig { + /** 服务器命令 */ + command: string; + /** 命令参数 */ + args: string[]; + /** 环境变量 */ + env?: Record; + /** 初始化选项 */ + initializationOptions?: Record; + /** 安装配置 */ + install: InstallConfig; + /** 显示名称 */ + displayName: string; + /** 描述 */ + description: string; +} + +// 语言服务器定义 +const serverConfigs: Partial> = { + // TypeScript/JavaScript - 使用 typescript-language-server + typescript: { + command: 'typescript-language-server', + args: ['--stdio'], + initializationOptions: { + preferences: { + includeInlayParameterNameHints: 'all', + includeInlayPropertyDeclarationTypeHints: true, + includeInlayFunctionLikeReturnTypeHints: true, + }, + }, + install: { + npm: 'typescript-language-server typescript', + }, + displayName: 'TypeScript', + description: 'TypeScript/JavaScript 语言服务器', + }, + javascript: { + command: 'typescript-language-server', + args: ['--stdio'], + install: { + npm: 'typescript-language-server typescript', + }, + displayName: 'JavaScript', + description: 'JavaScript 语言服务器(共用 TypeScript)', + }, + typescriptreact: { + command: 'typescript-language-server', + args: ['--stdio'], + install: { + npm: 'typescript-language-server typescript', + }, + displayName: 'TypeScript React', + description: 'TSX 语言服务器(共用 TypeScript)', + }, + javascriptreact: { + command: 'typescript-language-server', + args: ['--stdio'], + install: { + npm: 'typescript-language-server typescript', + }, + displayName: 'JavaScript React', + description: 'JSX 语言服务器(共用 TypeScript)', + }, + + // Python - 使用 pyright + python: { + command: 'pyright-langserver', + args: ['--stdio'], + install: { + npm: 'pyright', + pip: 'pyright', + }, + displayName: 'Python', + description: 'Python 语言服务器 (Pyright)', + }, + + // Go - 使用 gopls + go: { + command: 'gopls', + args: ['serve'], + install: { + go: 'golang.org/x/tools/gopls@latest', + }, + displayName: 'Go', + description: 'Go 语言服务器 (gopls)', + }, + + // Rust - 使用 rust-analyzer + rust: { + command: 'rust-analyzer', + args: [], + install: { + rustup: 'rust-analyzer', + brew: 'rust-analyzer', + }, + displayName: 'Rust', + description: 'Rust 语言服务器 (rust-analyzer)', + }, + + // C/C++ - 使用 clangd + c: { + command: 'clangd', + args: ['--background-index'], + install: { + brew: 'llvm', + manual: 'Ubuntu: sudo apt install clangd\nmacOS: brew install llvm', + }, + displayName: 'C', + description: 'C 语言服务器 (clangd)', + }, + cpp: { + command: 'clangd', + args: ['--background-index'], + install: { + brew: 'llvm', + manual: 'Ubuntu: sudo apt install clangd\nmacOS: brew install llvm', + }, + displayName: 'C++', + description: 'C++ 语言服务器 (clangd)', + }, + + // Java - 使用 jdtls + java: { + command: 'jdtls', + args: [], + install: { + brew: 'jdtls', + manual: '请访问 https://github.com/eclipse/eclipse.jdt.ls 获取安装说明', + }, + displayName: 'Java', + description: 'Java 语言服务器 (Eclipse JDT.LS)', + }, + + // C# - 使用 OmniSharp + csharp: { + command: 'omnisharp', + args: ['-lsp'], + install: { + brew: 'omnisharp/omnisharp-roslyn/omnisharp-mono', + manual: '请访问 https://github.com/OmniSharp/omnisharp-roslyn 获取安装说明', + }, + displayName: 'C#', + description: 'C# 语言服务器 (OmniSharp)', + }, + + // PHP - 使用 intelephense + php: { + command: 'intelephense', + args: ['--stdio'], + install: { + npm: 'intelephense', + }, + displayName: 'PHP', + description: 'PHP 语言服务器 (Intelephense)', + }, + + // Ruby - 使用 solargraph + ruby: { + command: 'solargraph', + args: ['stdio'], + install: { + gem: 'solargraph', + }, + displayName: 'Ruby', + description: 'Ruby 语言服务器 (Solargraph)', + }, + + // Vue - 使用 vue-language-server + vue: { + command: 'vue-language-server', + args: ['--stdio'], + install: { + npm: '@vue/language-server', + }, + displayName: 'Vue', + description: 'Vue 语言服务器', + }, + + // Svelte - 使用 svelte-language-server + svelte: { + command: 'svelteserver', + args: ['--stdio'], + install: { + npm: 'svelte-language-server', + }, + displayName: 'Svelte', + description: 'Svelte 语言服务器', + }, + + // HTML - 使用 vscode-html-language-server + html: { + command: 'vscode-html-language-server', + args: ['--stdio'], + install: { + npm: 'vscode-langservers-extracted', + }, + displayName: 'HTML', + description: 'HTML 语言服务器', + }, + + // CSS/SCSS/Less - 使用 vscode-css-language-server + css: { + command: 'vscode-css-language-server', + args: ['--stdio'], + install: { + npm: 'vscode-langservers-extracted', + }, + displayName: 'CSS', + description: 'CSS 语言服务器', + }, + scss: { + command: 'vscode-css-language-server', + args: ['--stdio'], + install: { + npm: 'vscode-langservers-extracted', + }, + displayName: 'SCSS', + description: 'SCSS 语言服务器(共用 CSS)', + }, + less: { + command: 'vscode-css-language-server', + args: ['--stdio'], + install: { + npm: 'vscode-langservers-extracted', + }, + displayName: 'Less', + description: 'Less 语言服务器(共用 CSS)', + }, + + // JSON - 使用 vscode-json-language-server + json: { + command: 'vscode-json-language-server', + args: ['--stdio'], + install: { + npm: 'vscode-langservers-extracted', + }, + displayName: 'JSON', + description: 'JSON 语言服务器', + }, + + // YAML - 使用 yaml-language-server + yaml: { + command: 'yaml-language-server', + args: ['--stdio'], + install: { + npm: 'yaml-language-server', + }, + displayName: 'YAML', + description: 'YAML 语言服务器', + }, +}; + +/** + * 获取语言服务器配置 + */ +export function getServerConfig(languageId: LanguageId): ServerConfig | undefined { + return serverConfigs[languageId]; +} + +/** + * 检查语言是否有可用的服务器配置 + */ +export function hasServerConfig(languageId: LanguageId): boolean { + return languageId in serverConfigs; +} + +/** + * 获取所有支持的语言 ID + */ +export function getSupportedLanguages(): LanguageId[] { + return Object.keys(serverConfigs) as LanguageId[]; +} + +/** + * 获取所有服务器配置(用于 CLI) + */ +export function getAllServerConfigs(): Partial> { + return serverConfigs; +} + +/** + * 获取唯一的服务器列表(去重相同命令的服务器) + */ +export function getUniqueServers(): Array<{ id: string; config: ServerConfig; languages: LanguageId[] }> { + const commandMap = new Map(); + + for (const [langId, config] of Object.entries(serverConfigs)) { + if (!config) continue; + + const existing = commandMap.get(config.command); + if (existing) { + existing.languages.push(langId as LanguageId); + } else { + commandMap.set(config.command, { + config, + languages: [langId as LanguageId], + }); + } + } + + return Array.from(commandMap.entries()).map(([command, data]) => ({ + id: command, + config: data.config, + languages: data.languages, + })); +} diff --git a/src/tools/filesystem/edit_file.ts b/src/tools/filesystem/edit_file.ts index 9a2d07a..b290d33 100644 --- a/src/tools/filesystem/edit_file.ts +++ b/src/tools/filesystem/edit_file.ts @@ -4,6 +4,7 @@ import type { ToolResult } from '../../types/index.js'; import type { ToolWithMetadata } from '../types.js'; import { loadDescription } from '../load_description.js'; import { getPermissionManager } from '../../permission/index.js'; +import { touchFile, getFormattedFileDiagnostics, isLanguageSupported } from '../../lsp/index.js'; export const editFileTool: ToolWithMetadata = { name: 'edit_file', @@ -13,7 +14,7 @@ export const editFileTool: ToolWithMetadata = { category: 'filesystem', description: '编辑文件内容(查找替换)', keywords: ['edit', 'file', 'replace', 'modify', 'change', 'update', '编辑', '文件', '替换', '修改', '更新'], - deferLoading: true, + deferLoading: false, // 核心工具,始终可用 }, parameters: { path: { @@ -87,9 +88,27 @@ export const editFileTool: ToolWithMetadata = { const newContent = content.replace(oldString, newString); await fs.writeFile(absolutePath, newContent, 'utf-8'); + let output = `文件已编辑: ${absolutePath}`; + + // 如果支持 LSP,通知语言服务器并获取诊断 + if (isLanguageSupported(absolutePath)) { + try { + const isFirstStart = await touchFile(absolutePath, false); + // 首次启动需要更长时间,后续只需短暂等待 + const waitTime = isFirstStart ? 2000 : 300; + await new Promise(resolve => setTimeout(resolve, waitTime)); + const diagnostics = await getFormattedFileDiagnostics(absolutePath); + if (diagnostics) { + output += `\n\n⚠️ 代码检查发现问题,请修复:${diagnostics}`; + } + } catch { + // LSP 错误不影响主流程 + } + } + return { success: true, - output: `文件已编辑: ${absolutePath}`, + output, }; } catch (error) { return { diff --git a/src/tools/filesystem/read_file.ts b/src/tools/filesystem/read_file.ts index 106eae4..12a9291 100644 --- a/src/tools/filesystem/read_file.ts +++ b/src/tools/filesystem/read_file.ts @@ -13,7 +13,7 @@ export const readFileTool: ToolWithMetadata = { category: 'filesystem', description: '读取文件内容', keywords: ['read', 'file', 'content', 'cat', 'view', 'open', '读取', '文件', '内容', '查看', '打开'], - deferLoading: true, + deferLoading: false, // 核心工具,始终可用 }, parameters: { path: { diff --git a/src/tools/filesystem/write_file.ts b/src/tools/filesystem/write_file.ts index 78e3177..a60a558 100644 --- a/src/tools/filesystem/write_file.ts +++ b/src/tools/filesystem/write_file.ts @@ -4,6 +4,7 @@ import type { ToolResult } from '../../types/index.js'; import type { ToolWithMetadata } from '../types.js'; import { loadDescription } from '../load_description.js'; import { getPermissionManager } from '../../permission/index.js'; +import { touchFile, getFormattedFileDiagnostics, isLanguageSupported } from '../../lsp/index.js'; export const writeFileTool: ToolWithMetadata = { name: 'write_file', @@ -13,7 +14,7 @@ export const writeFileTool: ToolWithMetadata = { category: 'filesystem', description: '写入文件内容', keywords: ['write', 'file', 'save', 'create', '写入', '文件', '保存', '创建', '新建'], - deferLoading: true, + deferLoading: false, // 核心工具,始终可用 }, parameters: { path: { @@ -61,9 +62,28 @@ export const writeFileTool: ToolWithMetadata = { try { await fs.mkdir(path.dirname(absolutePath), { recursive: true }); await fs.writeFile(absolutePath, content, 'utf-8'); + + let output = `文件已写入: ${absolutePath}`; + + // 如果支持 LSP,通知语言服务器并获取诊断 + if (isLanguageSupported(absolutePath)) { + try { + const isFirstStart = await touchFile(absolutePath, true); + // 首次启动需要更长时间,后续只需短暂等待 + const waitTime = isFirstStart ? 2000 : 300; + await new Promise(resolve => setTimeout(resolve, waitTime)); + const diagnostics = await getFormattedFileDiagnostics(absolutePath); + if (diagnostics) { + output += `\n\n⚠️ 代码检查发现问题,请修复:${diagnostics}`; + } + } catch { + // LSP 错误不影响主流程 + } + } + return { success: true, - output: `文件已写入: ${absolutePath}`, + output, }; } catch (error) { return { diff --git a/src/utils/config.ts b/src/utils/config.ts index a02d0b8..ea129ab 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -32,6 +32,11 @@ const DEFAULT_SYSTEM_PROMPT = `你是一个运行在终端中的 AI 编程助手 2. 执行可能有风险的命令前,先向用户确认 3. 给出清晰、简洁的回答 +重要的工具使用规则: +- 创建或修改文件时,必须使用 write_file 或 edit_file 工具,不要使用 bash 命令(如 cat、echo 等) +- write_file 和 edit_file 工具集成了代码诊断功能,可以在写入后自动检查代码错误 +- bash 工具仅用于运行命令、安装依赖、执行脚本等操作,不要用于文件内容的创建和修改 + 当前工作目录: ${process.cwd()} 操作系统: ${process.platform}`;