diff --git a/.gitignore b/.gitignore index fad568c715..26c7cf18ce 100644 --- a/.gitignore +++ b/.gitignore @@ -98,4 +98,5 @@ tools/workspace # jupyter .ipynb_checkpoints -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo +.env \ No newline at end of file diff --git a/configs/ts/tsconfig.build.json b/configs/ts/tsconfig.build.json index d231ac6cce..17303cfe72 100644 --- a/configs/ts/tsconfig.build.json +++ b/configs/ts/tsconfig.build.json @@ -159,6 +159,9 @@ { "path": "./references/tsconfig.design.json" }, + { + "path": "./references/tsconfig.addons.json" + }, { "path": "./references/tsconfig.ai-native.json" }, @@ -174,9 +177,6 @@ { "path": "./references/tsconfig.outline.json" }, - { - "path": "./references/tsconfig.addons.json" - }, { "path": "./references/tsconfig.startup.json" }, diff --git a/packages/addons/src/browser/file-search.contribution.ts b/packages/addons/src/browser/file-search.contribution.ts index 968cf8a15c..102e7c5497 100644 --- a/packages/addons/src/browser/file-search.contribution.ts +++ b/packages/addons/src/browser/file-search.contribution.ts @@ -354,7 +354,7 @@ export class FileSearchQuickCommandHandler { return results; } - protected async getQueryFiles(fileQuery: string, alreadyCollected: Set, token: CancellationToken) { + async getQueryFiles(fileQuery: string, alreadyCollected: Set, token: CancellationToken) { const roots = await this.workspaceService.roots; const rootUris: string[] = roots.map((stat) => new URI(stat.uri).codeUri.fsPath); const files = await this.fileSearchService.find( diff --git a/packages/ai-native/MCP.md b/packages/ai-native/MCP.md new file mode 100644 index 0000000000..b649c5cc23 --- /dev/null +++ b/packages/ai-native/MCP.md @@ -0,0 +1,225 @@ +# Model Control Protocol (MCP) Documentation + +## Overview + +The Model Control Protocol (MCP) is an integration layer that enables IDE capabilities to be exposed to AI models through a standardized interface. It provides a set of tools that allow AI models to interact with the IDE environment, manipulate files, and perform various operations. + +## Architecture + +Component Relationships: + +``` + ┌─────────────────────┐ + │ MCPServerManager │ + │ (Per Browser Tab) │ + └─────────┬───────────┘ + │ + │ manages + ▼ +┌─────────────────────┐ ┌───────────────────┐ +│ MCPServerRegistry │◄──────────┤ Builtin/External │ +│ (Frontend Proxy) │ register │ MCP Servers │ +└─────────┬───────────┘ tools └───────────────────┘ + │ + │ forwards + ▼ +┌─────────────────────┐ ┌─────────────────────────────┐ +│ SumiMCPServerBackend│◄──────────┤ ToolInvocationRegistryManager│ +│ (Browser<->Node.js)│ uses │ (Registry per Client) │ +└─────────┬───────────┘ └─────────────┬───────────────┘ + │ │ + │ executes │ manages + ▼ ▼ +┌─────────────────────┐ ┌─────────────────────────┐ +│ Tool Handlers │ │ ToolInvocationRegistry │ +│ (Implementation) │ │ (Available Tools) │ +└─────────────────────┘ └─────────────────────────┘ +``` + +### Core Components + +1. **MCPServerManager** + + - Manages multiple MCP servers + - Handles tool registration and invocation + - Maintains server lifecycle (start/stop) + - Each browser tab has its own MCPServerManager instance + +2. **MCPServerRegistry** + + - Frontend proxy service for MCP + - Registers and manages MCP tools + - Handles tool invocations + +3. **SumiMCPServerBackend** + + - Backend service that bridges browser and Node.js layers + - Manages tool registration and invocation + - Handles communication between frontend and backend + +4. **ToolInvocationRegistry** + + - Registry for all available function calls for agents + - Manages tool registration and lookup + - Maintains a map of tool IDs to their implementations + - Supports tool registration, retrieval, and unregistration + +5. **ToolInvocationRegistryManager** + - Manages multiple ToolInvocationRegistry instances + - Each instance is associated with a specific clientId + - Provides registry creation, retrieval, and removal + - Ensures isolation between different client contexts + +### Server Types + +1. **Builtin MCP Server** + + - Provides core IDE capabilities + - Integrated directly into the IDE + +2. **External MCP Servers** + - Can be added dynamically + - Configured with name, command, args, and environment variables + +## Available Tools + +The MCP system provides several built-in tools for file and IDE operations: + +### File Operations + +- `readFile`: Read contents of a file with line range support +- `listDir`: List contents of a directory +- `createNewFileWithText`: Create a new file with specified content +- `findFilesByNameSubstring`: Search for files by name +- `getFileTextByPath`: Get the content of a file by path +- `replaceOpenEditorFile`: Replace content in the current editor +- `replaceOpenEditorFileByDiffPreviewer`: Replace content with diff preview + +### Editor Operations + +- `getCurrentFilePath`: Get path of current open file +- `getSelectedText`: Get currently selected text +- `getOpenEditorFileText`: Get text from open editor + +### Diagnostics + +- `getDiagnosticsByPath`: Get diagnostics for a specific file +- `getOpenEditorFileDiagnostics`: Get diagnostics for open editor + +## Tool Structure + +Each MCP tool follows a standard structure: + +```typescript +interface MCPTool { + name: string; + description: string; + inputSchema: any; + providerName: string; +} + +interface MCPToolDefinition { + name: string; + description: string; + inputSchema: any; + handler: ( + args: any, + logger: MCPLogger, + ) => Promise<{ + content: { type: string; text: string }[]; + isError?: boolean; + }>; +} +``` + +## Usage Examples + +### Registering a New Tool + +```typescript +@Domain(MCPServerContribution) +export class MyCustomTool implements MCPServerContribution { + registerMCPServer(registry: IMCPServerRegistry): void { + registry.registerMCPTool({ + name: 'my_custom_tool', + description: 'Description of what the tool does', + inputSchema: zodToJsonSchema(myInputSchema), + handler: async (args, logger) => { + // Tool implementation + return { + content: [{ type: 'text', text: 'Result' }], + }; + }, + }); + } +} +``` + +### Adding External MCP Server - Configuration + +You can add external MCP servers through the `ai.native.mcp.servers` configuration in IDE settings. The configuration format is as follows: + +```json +{ + "ai.native.mcp.servers": [ + { + "name": "server-name", + "command": "command-to-execute", + "args": ["command-arguments"], + "env": { + "ENV_VAR_NAME": "env-var-value" + } + } + ] +} +``` + +Example configuration: + +```json +{ + "ai.native.mcp.servers": [ + { + "name": "filesystem", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/workspace"], + "env": {} + } + ] +} +``` + +## Best Practices + +1. **Tool Implementation** + + - Always validate input using schemas (e.g., Zod) + - Provide clear error messages + - Use the logger for debugging and tracking + - Handle errors gracefully + +2. **Server Management** + + - Initialize servers only when needed + - Clean up resources when servers are stopped + - Handle server lifecycle events properly + +3. **Tool Usage** + - Check tool availability before use + - Handle tool invocation errors + - Use appropriate tools for specific tasks + - Consider performance implications (e.g., reading entire files vs. line ranges) + +## Error Handling + +Tools should return errors in a standardized format: + +```typescript +{ + content: [{ + type: 'text', + text: 'Error: ' + }], + isError: true +} +``` diff --git a/packages/ai-native/MCP.zh-CN.md b/packages/ai-native/MCP.zh-CN.md new file mode 100644 index 0000000000..4fe79c1e40 --- /dev/null +++ b/packages/ai-native/MCP.zh-CN.md @@ -0,0 +1,232 @@ +# 模型控制协议(MCP)文档 + +## 概述 + +模型控制协议(Model Control Protocol,简称 MCP)是一个集成层,它使 IDE 的功能能够通过标准化接口暴露给 AI 模型。它提供了一组工具,允许 AI 模型与 IDE 环境交互,操作文件,并执行各种操作。 + +## 架构 + +组件关系图: + +``` + ┌──────────────────────┐ + │ MCP服务器管理器 │ + │ (每个浏览器标签页) │ + └─────────┬────────────┘ + │ + │ 管理 + ▼ +┌─────────────────────┐ ┌───────────────────┐ +│ MCP服务器注册表 │◄──────────┤ 内置/外部 │ +│ (前端代理) │ 注册工具 │ MCP服务器 │ +└─────────┬───────────┘ └───────────────────┘ + │ + │ 转发 + ▼ +┌─────────────────────┐ ┌─────────────────────────┐ +│ Sumi MCP后端 │◄──────────┤ 工具调用注册表管理器 │ +│ (浏览器<->Node.js) │ 使用 │ (每个浏览器 tab一个注册表) │ +└─────────┬───────────┘ └─────────────┬───────────┘ + │ │ + │ 执行 │ 管理 + ▼ ▼ +┌─────────────────────┐ ┌─────────────────────────┐ +│ 工具处理器 │ │ 工具调用注册表 │ +│ (具体实现) │ │ (可用工具集合) │ +└─────────────────────┘ └─────────────────────────┘ +``` + +### 核心组件 + +1. **MCP 服务器管理器(MCPServerManager)** + + - 管理多个 MCP 服务器 + - 处理工具注册和调用 + - 维护服务器生命周期(启动/停止) + - 每个浏览器标签页都有自己的 MCPServerManager 实例 + +2. **MCP 服务器注册表(MCPServerRegistry)** + + - MCP 的前端代理服务 + - 注册和管理 MCP 工具 + - 处理工具调用 + +3. **Sumi MCP 服务器后端(SumiMCPServerBackend)** + + - 连接浏览器和 Node.js 层的后端服务 + - 管理工具注册和调用 + - 处理前端和后端之间的通信 + +4. **工具调用注册表(ToolInvocationRegistry)** + + - 为 Agent 提供的所有可用函数调用的注册表 + - 管理工具的注册和查找 + - 维护工具 ID 到实现的映射 + - 支持工具的注册、获取和注销 + +5. **工具调用注册表管理器(ToolInvocationRegistryManager)** + - 管理多个 ToolInvocationRegistry 实例 + - 每个实例与特定的 clientId 关联 + - 提供注册表的创建、获取和移除功能 + - 确保不同客户端上下文之间的隔离 + +### 服务器类型 + +1. **内置 MCP 服务器** + + - 提供核心 IDE 功能 + - 直接集成到 IDE 中 + +2. **外部 MCP 服务器** + - 可以动态添加 + - 通过名称、命令、参数和环境变量进行配置 + +## 可用工具 + +MCP 系统为文件和 IDE 操作提供了几个内置工具: + +### 文件操作 + +- `readFile`:读取文件内容,支持行范围 +- `listDir`:列出目录内容 +- `createNewFileWithText`:创建带有指定内容的新文件 +- `findFilesByNameSubstring`:按名称搜索文件 +- `getFileTextByPath`:通过路径获取文件内容 +- `replaceOpenEditorFile`:替换当前编辑器中的内容 +- `replaceOpenEditorFileByDiffPreviewer`:使用差异预览替换内容 + +### 编辑器操作 + +- `getCurrentFilePath`:获取当前打开文件的路径 +- `getSelectedText`:获取当前选中的文本 +- `getOpenEditorFileText`:获取打开编辑器中的文本 + +### 诊断 + +- `getDiagnosticsByPath`:获取特定文件的诊断信息 +- `getOpenEditorFileDiagnostics`:获取打开编辑器的诊断信息 + +## 工具结构 + +每个 MCP 工具都遵循标准结构: + +```typescript +interface MCPTool { + name: string; // 工具名称 + description: string; // 工具描述 + inputSchema: any; // 输入模式 + providerName: string; // 提供者名称 +} + +interface MCPToolDefinition { + name: string; // 工具名称 + description: string; // 工具描述 + inputSchema: any; // 输入模式 + handler: ( + args: any, + logger: MCPLogger, + ) => Promise<{ + content: { type: string; text: string }[]; + isError?: boolean; + }>; +} +``` + +## 使用示例 + +### 注册新工具 + +```typescript +@Domain(MCPServerContribution) +export class MyCustomTool implements MCPServerContribution { + registerMCPServer(registry: IMCPServerRegistry): void { + registry.registerMCPTool({ + name: 'my_custom_tool', + description: '工具功能描述', + inputSchema: zodToJsonSchema(myInputSchema), + handler: async (args, logger) => { + // 工具实现 + return { + content: [{ type: 'text', text: '结果' }], + }; + }, + }); + } +} +``` + +### 添加外部 MCP 服务器 - 配置 + +在 IDE 的设置中,你可以通过 `ai.native.mcp.servers` 配置项添加外部 MCP 服务器。配置格式如下: + +```json +{ + "ai.native.mcp.servers": [ + { + "name": "服务器名称", + "command": "执行命令", + "args": ["命令参数"], + "env": { + "环境变量名": "环境变量值" + } + } + ] +} +``` + +示例配置: + +```json +{ + "ai.native.mcp.servers": [ + { + "name": "filesystem", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/workspace"], + "env": {} + } + ] +} +``` + +## 最佳实践 + +1. **工具实现** + + - 始终使用模式(如 Zod)验证输入 + - 提供清晰的错误消息 + - 使用日志记录器进行调试和跟踪 + - 优雅地处理错误 + +2. **服务器管理** + + - 仅在需要时初始化服务器 + - 停止服务器时清理资源 + - 正确处理服务器生命周期事件 + +3. **工具使用** + - 使用前检查工具可用性 + - 处理工具调用错误 + - 为特定任务使用适当的工具 + - 考虑性能影响(例如,读取整个文件与读取行范围) + +## 错误处理 + +工具应该以标准格式返回错误: + +```typescript +{ + content: [{ + type: 'text', + text: 'Error: <错误消息>' + }], + isError: true +} +``` + +## 安全注意事项 + +1. 验证所有输入参数 +2. 将文件系统访问限制在工作区内 +3. 适当处理敏感信息 +4. 验证外部服务器配置 diff --git a/packages/ai-native/__test__/browser/mcp/tools/getOpenEditorFileDiagnostics.test.ts b/packages/ai-native/__test__/browser/mcp/tools/getOpenEditorFileDiagnostics.test.ts new file mode 100644 index 0000000000..72cbaa7529 --- /dev/null +++ b/packages/ai-native/__test__/browser/mcp/tools/getOpenEditorFileDiagnostics.test.ts @@ -0,0 +1,178 @@ +import * as path from 'path'; + +import { URI } from '@opensumi/ide-core-common'; +import { createBrowserInjector } from '@opensumi/ide-dev-tool/src/injector-helper'; +import { WorkbenchEditorService } from '@opensumi/ide-editor'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; +import { IMarkerService, MarkerSeverity } from '@opensumi/monaco-editor-core/esm/vs/platform/markers/common/markers'; + +import { GetOpenEditorFileDiagnosticsTool } from '../../../../src/browser/mcp/tools/getOpenEditorFileDiagnostics'; +import { MCPLogger } from '../../../../src/browser/types'; + +describe('GetOpenEditorFileDiagnosticsTool', () => { + let tool: GetOpenEditorFileDiagnosticsTool; + let editorService: WorkbenchEditorService; + let workspaceService: IWorkspaceService; + let markerService: IMarkerService; + let mockLogger: MCPLogger; + + const mockWorkspaceRoot = '/workspace/root'; + const mockFilePath = '/workspace/root/src/test.ts'; + const mockRelativePath = path.relative(mockWorkspaceRoot, mockFilePath); + + beforeEach(() => { + const injector = createBrowserInjector([]); + + editorService = { + currentEditor: { + currentUri: URI.file(mockFilePath), + }, + } as any; + + workspaceService = { + tryGetRoots: jest.fn().mockReturnValue([ + { + uri: URI.file(mockWorkspaceRoot).toString(), + }, + ]), + } as any; + + markerService = { + read: jest.fn(), + } as any; + + injector.addProviders( + { + token: WorkbenchEditorService, + useValue: editorService, + }, + { + token: IWorkspaceService, + useValue: workspaceService, + }, + { + token: IMarkerService, + useValue: markerService, + }, + ); + + mockLogger = { + appendLine: jest.fn(), + } as any; + + tool = injector.get(GetOpenEditorFileDiagnosticsTool); + }); + + it('should register tool with correct name and description', () => { + const definition = tool.getToolDefinition(); + expect(definition.name).toBe('get_open_in_editor_file_diagnostics'); + expect(definition.description).toContain('Retrieves diagnostic information'); + }); + + it('should return empty array when no editor is open', async () => { + editorService.currentEditor = null; + const result = await tool['handler']({}, mockLogger); + expect(result.content).toEqual([{ type: 'text', text: '[]' }]); + expect(result.isError).toBe(true); + expect(mockLogger.appendLine).toHaveBeenCalledWith('Error: No active text editor found'); + }); + + it('should return empty array when no workspace roots found', async () => { + (workspaceService.tryGetRoots as jest.Mock).mockReturnValue([]); + const result = await tool['handler']({}, mockLogger); + expect(result.content).toEqual([{ type: 'text', text: '[]' }]); + expect(result.isError).toBe(true); + expect(mockLogger.appendLine).toHaveBeenCalledWith('Error: Cannot determine project directory'); + }); + + it('should return diagnostics with correct severity mappings', async () => { + const mockMarkers = [ + { + startLineNumber: 1, + severity: MarkerSeverity.Error, + message: 'Error message', + }, + { + startLineNumber: 2, + severity: MarkerSeverity.Warning, + message: 'Warning message', + }, + { + startLineNumber: 3, + severity: MarkerSeverity.Info, + message: 'Info message', + }, + { + startLineNumber: 4, + severity: MarkerSeverity.Hint, + message: 'Hint message', + }, + ]; + + (markerService.read as jest.Mock).mockReturnValue(mockMarkers); + + const result = await tool['handler']({}, mockLogger); + const diagnostics = JSON.parse(result.content[0].text); + + expect(diagnostics).toHaveLength(4); + expect(diagnostics[0]).toEqual({ + path: mockRelativePath, + line: 1, + severity: 'error', + message: 'Error message', + }); + expect(diagnostics[1]).toEqual({ + path: mockRelativePath, + line: 2, + severity: 'warning', + message: 'Warning message', + }); + expect(diagnostics[2]).toEqual({ + path: mockRelativePath, + line: 3, + severity: 'information', + message: 'Info message', + }); + expect(diagnostics[3]).toEqual({ + path: mockRelativePath, + line: 4, + severity: 'hint', + message: 'Hint message', + }); + + expect(mockLogger.appendLine).toHaveBeenCalledWith('Found 4 diagnostics in current file'); + }); + + it('should handle errors during diagnostic retrieval', async () => { + (markerService.read as jest.Mock).mockImplementation(() => { + throw new Error('Test error'); + }); + + const result = await tool['handler']({}, mockLogger); + expect(result.content).toEqual([{ type: 'text', text: '[]' }]); + expect(result.isError).toBe(true); + expect(mockLogger.appendLine).toHaveBeenCalledWith('Error getting diagnostics: Error: Test error'); + }); + + it('should handle unknown severity levels', async () => { + const mockMarkers = [ + { + startLineNumber: 1, + severity: 999, // Unknown severity + message: 'Unknown severity message', + }, + ]; + + (markerService.read as jest.Mock).mockReturnValue(mockMarkers); + + const result = await tool['handler']({}, mockLogger); + const diagnostics = JSON.parse(result.content[0].text); + + expect(diagnostics[0]).toEqual({ + path: mockRelativePath, + line: 1, + severity: 'unknown', + message: 'Unknown severity message', + }); + }); +}); diff --git a/packages/ai-native/__test__/browser/mcp/tools/getOpenEditorFileText.test.ts b/packages/ai-native/__test__/browser/mcp/tools/getOpenEditorFileText.test.ts new file mode 100644 index 0000000000..958e052a30 --- /dev/null +++ b/packages/ai-native/__test__/browser/mcp/tools/getOpenEditorFileText.test.ts @@ -0,0 +1,53 @@ +import { URI } from '@opensumi/ide-core-common'; +import { createBrowserInjector } from '@opensumi/ide-dev-tool/src/injector-helper'; +import { IEditor, WorkbenchEditorService } from '@opensumi/ide-editor'; + +import { GetOpenEditorFileTextTool } from '../../../../src/browser/mcp/tools/getOpenEditorFileText'; +import { MCPLogger } from '../../../../src/browser/types'; + +describe('GetOpenEditorFileTextTool', () => { + let tool: GetOpenEditorFileTextTool; + let editorService: WorkbenchEditorService; + let mockLogger: MCPLogger; + + beforeEach(() => { + const injector = createBrowserInjector([]); + editorService = { + currentEditor: null, + } as any; + injector.addProviders({ + token: WorkbenchEditorService, + useValue: editorService, + }); + mockLogger = { + appendLine: jest.fn(), + } as any; + tool = injector.get(GetOpenEditorFileTextTool); + }); + + it('should register tool with correct name and description', () => { + const definition = tool.getToolDefinition(); + expect(definition.name).toBe('get_open_in_editor_file_text'); + expect(definition.description).toContain('Retrieves the complete text content'); + }); + + it('should return empty string when no editor is open', async () => { + editorService.currentEditor = null; + const result = await tool['handler']({}, mockLogger); + expect(result.content).toEqual([{ type: 'text', text: '' }]); + expect(mockLogger.appendLine).toHaveBeenCalledWith('Error: No active text editor found'); + }); + + it('should return file content when editor is open', async () => { + const mockContent = 'test file content'; + editorService.currentEditor = { + currentDocumentModel: { + uri: URI.parse('file:///test.ts'), + getText: () => mockContent, + }, + } as IEditor; + const result = await tool['handler']({}, mockLogger); + expect(result.content).toEqual([{ type: 'text', text: mockContent }]); + expect(mockLogger.appendLine).toHaveBeenCalledWith('Reading content from: file:///test.ts'); + }); +}); diff --git a/packages/ai-native/__test__/browser/mcp/tools/getSelectedText.test.ts b/packages/ai-native/__test__/browser/mcp/tools/getSelectedText.test.ts new file mode 100644 index 0000000000..e64ca7d32a --- /dev/null +++ b/packages/ai-native/__test__/browser/mcp/tools/getSelectedText.test.ts @@ -0,0 +1,72 @@ +import { createBrowserInjector } from '@opensumi/ide-dev-tool/src/injector-helper'; +import { WorkbenchEditorService } from '@opensumi/ide-editor'; +import { IRange } from '@opensumi/monaco-editor-core/esm/vs/editor/common/core/range'; + +import { GetSelectedTextTool } from '../../../../src/browser/mcp/tools/getSelectedText'; +import { MCPLogger } from '../../../../src/browser/types'; + +describe('GetSelectedTextTool', () => { + let tool: GetSelectedTextTool; + let editorService: WorkbenchEditorService; + let mockLogger: MCPLogger; + let mockMonacoEditor: any; + + beforeEach(() => { + const injector = createBrowserInjector([]); + mockMonacoEditor = { + getSelection: jest.fn(), + getModel: jest.fn(), + }; + editorService = { + currentEditor: { + monacoEditor: mockMonacoEditor, + }, + } as any; + injector.addProviders({ + token: WorkbenchEditorService, + useValue: editorService, + }); + mockLogger = { + appendLine: jest.fn(), + } as any; + tool = injector.get(GetSelectedTextTool); + }); + + it('should register tool with correct name and description', () => { + const definition = tool.getToolDefinition(); + expect(definition.name).toBe('get_selected_in_editor_text'); + expect(definition.description).toContain('Retrieves the currently selected text'); + }); + + it('should return empty string when no editor is open', async () => { + editorService.currentEditor = null; + const result = await tool['handler']({}, mockLogger); + expect(result.content).toEqual([{ type: 'text', text: '' }]); + expect(mockLogger.appendLine).toHaveBeenCalledWith('Error: No active text editor found'); + }); + + it('should return empty string when no text is selected', async () => { + mockMonacoEditor.getSelection.mockReturnValue(null); + const result = await tool['handler']({}, mockLogger); + expect(result.content).toEqual([{ type: 'text', text: '' }]); + expect(mockLogger.appendLine).toHaveBeenCalledWith('No text is currently selected'); + }); + + it('should return selected text when text is selected', async () => { + const mockSelection: IRange = { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 10, + }; + const mockText = 'selected text'; + mockMonacoEditor.getSelection.mockReturnValue(mockSelection); + mockMonacoEditor.getModel.mockReturnValue({ + getValueInRange: jest.fn().mockReturnValue(mockText), + }); + + const result = await tool['handler']({}, mockLogger); + expect(result.content).toEqual([{ type: 'text', text: mockText }]); + expect(mockLogger.appendLine).toHaveBeenCalledWith(`Retrieved selected text of length: ${mockText.length}`); + }); +}); diff --git a/packages/ai-native/__test__/browser/mcp/tools/replaceOpenEditorFile.test.ts b/packages/ai-native/__test__/browser/mcp/tools/replaceOpenEditorFile.test.ts new file mode 100644 index 0000000000..ba0b32d883 --- /dev/null +++ b/packages/ai-native/__test__/browser/mcp/tools/replaceOpenEditorFile.test.ts @@ -0,0 +1,102 @@ +import { createBrowserInjector } from '@opensumi/ide-dev-tool/src/injector-helper'; +import { IEditor, WorkbenchEditorService } from '@opensumi/ide-editor'; +import { IRange } from '@opensumi/monaco-editor-core/esm/vs/editor/common/core/range'; + +import { ReplaceOpenEditorFileTool } from '../../../../src/browser/mcp/tools/replaceOpenEditorFile'; +import { MCPLogger } from '../../../../src/browser/types'; + +describe('ReplaceOpenEditorFileTool', () => { + let tool: ReplaceOpenEditorFileTool; + let editorService: WorkbenchEditorService; + let mockLogger: MCPLogger; + let mockMonacoEditor: any; + let mockModel: any; + + beforeEach(() => { + const injector = createBrowserInjector([]); + mockModel = { + getFullModelRange: jest.fn(), + }; + mockMonacoEditor = { + getModel: jest.fn().mockReturnValue(mockModel), + executeEdits: jest.fn(), + }; + editorService = { + currentEditor: { + monacoEditor: mockMonacoEditor, + }, + } as any; + injector.addProviders({ + token: WorkbenchEditorService, + useValue: editorService, + }); + mockLogger = { + appendLine: jest.fn(), + } as any; + tool = injector.get(ReplaceOpenEditorFileTool); + }); + + it('should register tool with correct name and description', () => { + const definition = tool.getToolDefinition(); + expect(definition.name).toBe('replace_open_in_editor_file_text'); + expect(definition.description).toContain('Replaces the entire content'); + }); + + it('should return error when no editor is open', async () => { + editorService.currentEditor = null; + const result = await tool['handler']({ text: 'new content' }, mockLogger); + expect(result.content).toEqual([{ type: 'text', text: 'no file open' }]); + expect(result.isError).toBe(true); + expect(mockLogger.appendLine).toHaveBeenCalledWith('Error: No active text editor found'); + }); + + it('should return error when no model is found', async () => { + mockMonacoEditor.getModel.mockReturnValue(null); + const result = await tool['handler']({ text: 'new content' }, mockLogger); + expect(result.content).toEqual([{ type: 'text', text: 'unknown error' }]); + expect(result.isError).toBe(true); + expect(mockLogger.appendLine).toHaveBeenCalledWith('Error: No model found for current editor'); + }); + + it('should successfully replace file content', async () => { + const mockRange: IRange = { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 10, + endColumn: 20, + }; + const newContent = 'new file content'; + mockModel.getFullModelRange.mockReturnValue(mockRange); + + const result = await tool['handler']({ text: newContent }, mockLogger); + + expect(mockModel.getFullModelRange).toHaveBeenCalled(); + expect(mockMonacoEditor.executeEdits).toHaveBeenCalledWith('mcp.tool.replace-file', [ + { + range: mockRange, + text: newContent, + }, + ]); + expect(result.content).toEqual([{ type: 'text', text: 'ok' }]); + expect(mockLogger.appendLine).toHaveBeenCalledWith('Successfully replaced file content'); + }); + + it('should handle errors during replacement', async () => { + mockMonacoEditor.executeEdits.mockImplementation(() => { + throw new Error('Test error'); + }); + const mockRange: IRange = { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 10, + endColumn: 20, + }; + mockModel.getFullModelRange.mockReturnValue(mockRange); + + const result = await tool['handler']({ text: 'new content' }, mockLogger); + + expect(result.content).toEqual([{ type: 'text', text: 'unknown error' }]); + expect(result.isError).toBe(true); + expect(mockLogger.appendLine).toHaveBeenCalledWith('Error during file content replacement: Error: Test error'); + }); +}); diff --git a/packages/ai-native/__test__/browser/mcp/tools/replaceOpenEditorFileByDiffPreviewer.test.ts b/packages/ai-native/__test__/browser/mcp/tools/replaceOpenEditorFileByDiffPreviewer.test.ts new file mode 100644 index 0000000000..d4b311a97c --- /dev/null +++ b/packages/ai-native/__test__/browser/mcp/tools/replaceOpenEditorFileByDiffPreviewer.test.ts @@ -0,0 +1,125 @@ +import { createBrowserInjector } from '@opensumi/ide-dev-tool/src/injector-helper'; +import { IEditor, WorkbenchEditorService } from '@opensumi/ide-editor'; +import { IRange } from '@opensumi/monaco-editor-core/esm/vs/editor/common/core/range'; +import { Selection, SelectionDirection } from '@opensumi/monaco-editor-core/esm/vs/editor/common/core/selection'; + +import { ReplaceOpenEditorFileByDiffPreviewerTool } from '../../../../src/browser/mcp/tools/replaceOpenEditorFileByDiffPreviewer'; +import { MCPLogger } from '../../../../src/browser/types'; +import { LiveInlineDiffPreviewer } from '../../../../src/browser/widget/inline-diff/inline-diff-previewer'; +import { InlineDiffController } from '../../../../src/browser/widget/inline-diff/inline-diff.controller'; + +jest.mock('../../../../src/browser/widget/inline-diff/inline-diff.controller'); + +describe('ReplaceOpenEditorFileByDiffPreviewerTool', () => { + let tool: ReplaceOpenEditorFileByDiffPreviewerTool; + let editorService: WorkbenchEditorService; + let mockLogger: MCPLogger; + let mockMonacoEditor: any; + let mockModel: any; + let mockDiffPreviewer: jest.Mocked; + let mockInlineDiffHandler: any; + + beforeEach(() => { + const injector = createBrowserInjector([]); + mockModel = { + getFullModelRange: jest.fn().mockReturnValue({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: 10, + endColumn: 20, + } as IRange), + }; + mockDiffPreviewer = { + setValue: jest.fn(), + } as any; + mockInlineDiffHandler = { + createDiffPreviewer: jest.fn().mockReturnValue(mockDiffPreviewer), + }; + (InlineDiffController.get as jest.Mock).mockReturnValue(mockInlineDiffHandler); + + mockMonacoEditor = { + getModel: jest.fn().mockReturnValue(mockModel), + }; + editorService = { + currentEditor: { + monacoEditor: mockMonacoEditor, + }, + } as any; + injector.addProviders({ + token: WorkbenchEditorService, + useValue: editorService, + }); + mockLogger = { + appendLine: jest.fn(), + } as any; + tool = injector.get(ReplaceOpenEditorFileByDiffPreviewerTool); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should register tool with correct name and description', () => { + const definition = tool.getToolDefinition(); + expect(definition.name).toBe('replace_open_in_editor_file_text'); + expect(definition.description).toContain('Replaces the entire content'); + }); + + it('should return error when no editor is open', async () => { + editorService.currentEditor = null; + const result = await tool['handler']({ text: 'new content' }, mockLogger); + expect(result.content).toEqual([{ type: 'text', text: 'no file open' }]); + expect(result.isError).toBe(true); + expect(mockLogger.appendLine).toHaveBeenCalledWith('Error: No active text editor found'); + }); + + it('should return error when no model is found', async () => { + mockMonacoEditor.getModel.mockReturnValue(null); + const result = await tool['handler']({ text: 'new content' }, mockLogger); + expect(result.content).toEqual([{ type: 'text', text: 'unknown error' }]); + expect(result.isError).toBe(true); + expect(mockLogger.appendLine).toHaveBeenCalledWith('Error: No model found for current editor'); + }); + + it('should successfully create diff preview', async () => { + const newContent = 'new file content'; + const mockRange = mockModel.getFullModelRange(); + + const result = await tool['handler']({ text: newContent }, mockLogger); + + expect(mockModel.getFullModelRange).toHaveBeenCalled(); + expect(InlineDiffController.get).toHaveBeenCalledWith(mockMonacoEditor); + expect(mockInlineDiffHandler.createDiffPreviewer).toHaveBeenCalledWith(mockMonacoEditor, expect.any(Selection), { + disposeWhenEditorClosed: false, + renderRemovedWidgetImmediately: true, + }); + expect(mockDiffPreviewer.setValue).toHaveBeenCalledWith(newContent); + expect(result.content).toEqual([{ type: 'text', text: 'ok' }]); + expect(mockLogger.appendLine).toHaveBeenCalledWith('Successfully created diff preview with new content'); + }); + + it('should handle errors during diff preview creation', async () => { + mockInlineDiffHandler.createDiffPreviewer.mockImplementation(() => { + throw new Error('Test error'); + }); + + const result = await tool['handler']({ text: 'new content' }, mockLogger); + + expect(result.content).toEqual([{ type: 'text', text: 'unknown error' }]); + expect(result.isError).toBe(true); + expect(mockLogger.appendLine).toHaveBeenCalledWith('Error during file content replacement: Error: Test error'); + }); + + it('should verify Selection creation with correct range', async () => { + const newContent = 'new file content'; + const mockRange = mockModel.getFullModelRange(); + + await tool['handler']({ text: newContent }, mockLogger); + + expect(mockInlineDiffHandler.createDiffPreviewer).toHaveBeenCalledWith( + mockMonacoEditor, + Selection.fromRange(mockRange, SelectionDirection.LTR), + expect.any(Object), + ); + }); +}); diff --git a/packages/ai-native/__test__/common/mcp-server-manager.test.ts b/packages/ai-native/__test__/common/mcp-server-manager.test.ts new file mode 100644 index 0000000000..31dfdc1ebf --- /dev/null +++ b/packages/ai-native/__test__/common/mcp-server-manager.test.ts @@ -0,0 +1,125 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; + +import { MCPServerDescription, MCPServerManager } from '../../src/common/mcp-server-manager'; + +describe('MCPServerManager Interface', () => { + let mockManager: MCPServerManager; + const mockClient = { + callTool: jest.fn(), + listTools: jest.fn(), + }; + + const mockServer: MCPServerDescription = { + name: 'test-server', + command: 'test-command', + args: ['arg1', 'arg2'], + env: { TEST_ENV: 'value' }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockManager = { + callTool: jest.fn(), + removeServer: jest.fn(), + addOrUpdateServer: jest.fn(), + addOrUpdateServerDirectly: jest.fn(), + initBuiltinServer: jest.fn(), + getTools: jest.fn(), + getServerNames: jest.fn(), + startServer: jest.fn(), + stopServer: jest.fn(), + getStartedServers: jest.fn(), + registerTools: jest.fn(), + addExternalMCPServers: jest.fn(), + }; + }); + + describe('Server Management', () => { + it('should add or update server', async () => { + await mockManager.addOrUpdateServer(mockServer); + expect(mockManager.addOrUpdateServer).toHaveBeenCalledWith(mockServer); + }); + + it('should remove server', async () => { + await mockManager.removeServer('test-server'); + expect(mockManager.removeServer).toHaveBeenCalledWith('test-server'); + }); + + it('should get server names', async () => { + const expectedServers = ['server1', 'server2']; + (mockManager.getServerNames as jest.Mock).mockResolvedValue(expectedServers); + + const servers = await mockManager.getServerNames(); + expect(servers).toEqual(expectedServers); + expect(mockManager.getServerNames).toHaveBeenCalled(); + }); + + it('should get started servers', async () => { + const expectedStartedServers = ['server1']; + (mockManager.getStartedServers as jest.Mock).mockResolvedValue(expectedStartedServers); + + const startedServers = await mockManager.getStartedServers(); + expect(startedServers).toEqual(expectedStartedServers); + expect(mockManager.getStartedServers).toHaveBeenCalled(); + }); + }); + + describe('Server Operations', () => { + it('should start server', async () => { + await mockManager.startServer('test-server'); + expect(mockManager.startServer).toHaveBeenCalledWith('test-server'); + }); + + it('should stop server', async () => { + await mockManager.stopServer('test-server'); + expect(mockManager.stopServer).toHaveBeenCalledWith('test-server'); + }); + + it('should register tools for server', async () => { + await mockManager.registerTools('test-server'); + expect(mockManager.registerTools).toHaveBeenCalledWith('test-server'); + }); + }); + + describe('Tool Operations', () => { + it('should call tool on server', async () => { + const toolName = 'test-tool'; + const argString = '{"key": "value"}'; + await mockManager.callTool('test-server', toolName, argString); + expect(mockManager.callTool).toHaveBeenCalledWith('test-server', toolName, argString); + }); + + it('should get tools from server', async () => { + const expectedTools = { + tools: [ + { + name: 'test-tool', + description: 'Test tool description', + inputSchema: {}, + }, + ], + }; + (mockManager.getTools as jest.Mock).mockResolvedValue(expectedTools); + + const tools = await mockManager.getTools('test-server'); + expect(tools).toEqual(expectedTools); + expect(mockManager.getTools).toHaveBeenCalledWith('test-server'); + }); + }); + + describe('External Servers', () => { + it('should add external MCP servers', async () => { + const externalServers: MCPServerDescription[] = [ + { + name: 'external-server', + command: 'external-command', + args: ['ext-arg'], + env: { EXT_ENV: 'value' }, + }, + ]; + + await mockManager.addExternalMCPServers(externalServers); + expect(mockManager.addExternalMCPServers).toHaveBeenCalledWith(externalServers); + }); + }); +}); diff --git a/packages/ai-native/__test__/node/mcp-server.test.ts b/packages/ai-native/__test__/node/mcp-server.test.ts new file mode 100644 index 0000000000..9d7ebfdc6c --- /dev/null +++ b/packages/ai-native/__test__/node/mcp-server.test.ts @@ -0,0 +1,145 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +import { ILogger } from '@opensumi/ide-core-common'; + +import { MCPServerImpl } from '../../src/node/mcp-server'; + +jest.mock('@modelcontextprotocol/sdk/client/index.js'); +jest.mock('@modelcontextprotocol/sdk/client/stdio.js'); + +describe('MCPServerImpl', () => { + let server: MCPServerImpl; + const mockLogger: ILogger = { + log: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + warn: jest.fn(), + critical: jest.fn(), + dispose: jest.fn(), + getLevel: jest.fn(), + setLevel: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + server = new MCPServerImpl('test-server', 'test-command', ['arg1', 'arg2'], { ENV: 'test' }, mockLogger); + }); + + describe('constructor', () => { + it('should initialize with correct parameters', () => { + expect(server.getServerName()).toBe('test-server'); + expect(server.isStarted()).toBe(false); + }); + }); + + describe('start', () => { + beforeEach(() => { + (Client as jest.Mock).mockImplementation(() => ({ + connect: jest.fn().mockResolvedValue(undefined), + onerror: jest.fn(), + })); + (StdioClientTransport as jest.Mock).mockImplementation(() => ({ + onerror: jest.fn(), + })); + }); + + it('should start the server successfully', async () => { + await server.start(); + expect(server.isStarted()).toBe(true); + expect(StdioClientTransport).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'test-command', + args: ['arg1', 'arg2'], + env: expect.objectContaining({ ENV: 'test' }), + }), + ); + }); + + it('should not start server if already started', async () => { + await server.start(); + const firstCallCount = (StdioClientTransport as jest.Mock).mock.calls.length; + await server.start(); + expect((StdioClientTransport as jest.Mock).mock.calls.length).toBe(firstCallCount); + }); + }); + + describe('callTool', () => { + const mockClient = { + connect: jest.fn(), + callTool: jest.fn(), + onerror: jest.fn(), + }; + + beforeEach(async () => { + (Client as jest.Mock).mockImplementation(() => mockClient); + await server.start(); + }); + + it('should call tool with parsed arguments', async () => { + const toolName = 'test-tool'; + const argString = '{"key": "value"}'; + await server.callTool(toolName, argString); + expect(mockClient.callTool).toHaveBeenCalledWith({ + name: toolName, + arguments: { key: 'value' }, + }); + }); + + it('should handle invalid JSON arguments', async () => { + const toolName = 'test-tool'; + const invalidArgString = '{invalid json}'; + await server.callTool(toolName, invalidArgString); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); + + describe('stop', () => { + const mockClient = { + connect: jest.fn(), + close: jest.fn(), + onerror: jest.fn(), + }; + + beforeEach(async () => { + (Client as jest.Mock).mockImplementation(() => mockClient); + await server.start(); + }); + + it('should stop the server successfully', () => { + server.stop(); + expect(mockClient.close).toHaveBeenCalled(); + expect(server.isStarted()).toBe(false); + }); + + it('should not attempt to stop if server is not started', () => { + server.stop(); // First stop + mockClient.close.mockClear(); + server.stop(); // Second stop + expect(mockClient.close).not.toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('should update server configuration', () => { + const newCommand = 'new-command'; + const newArgs = ['new-arg']; + const newEnv = { NEW_ENV: 'test' }; + + server.update(newCommand, newArgs, newEnv); + + // Start server to verify new config is used + const transportMock = StdioClientTransport as jest.Mock; + server.start(); + + expect(transportMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + command: newCommand, + args: newArgs, + env: expect.objectContaining(newEnv), + }), + ); + }); + }); +}); diff --git a/packages/ai-native/package.json b/packages/ai-native/package.json index 9b2fd69c70..598ec41ac0 100644 --- a/packages/ai-native/package.json +++ b/packages/ai-native/package.json @@ -19,12 +19,20 @@ "url": "git@github.com:opensumi/core.git" }, "dependencies": { + "@ai-sdk/anthropic": "^1.1.6", + "@ai-sdk/deepseek": "^0.1.8", + "@ai-sdk/openai": "^1.1.9", + "@anthropic-ai/sdk": "^0.36.3", + "@modelcontextprotocol/sdk": "^1.3.1", + "@opensumi/ide-addons": "workspace:*", "@opensumi/ide-components": "workspace:*", + "@opensumi/ide-connection": "workspace:*", "@opensumi/ide-core-common": "workspace:*", "@opensumi/ide-core-node": "workspace:*", "@opensumi/ide-debug": "workspace:*", "@opensumi/ide-design": "workspace:*", "@opensumi/ide-editor": "workspace:*", + "@opensumi/ide-file-search": "workspace:*", "@opensumi/ide-file-service": "workspace:*", "@opensumi/ide-file-tree-next": "workspace:*", "@opensumi/ide-main-layout": "workspace:*", @@ -38,12 +46,16 @@ "@opensumi/ide-utils": "workspace:*", "@opensumi/ide-workspace": "workspace:*", "@xterm/xterm": "5.5.0", + "ai": "^4.1.21", "ansi-regex": "^2.0.0", "dom-align": "^1.7.0", + "rc-collapse": "^4.0.0", "react-chat-elements": "^12.0.10", "react-highlight": "^0.15.0", "tiktoken": "1.0.12", - "web-tree-sitter": "0.22.6" + "web-tree-sitter": "0.22.6", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" }, "devDependencies": { "@opensumi/ide-core-browser": "workspace:*" diff --git a/packages/ai-native/src/browser/ai-core.contribution.ts b/packages/ai-native/src/browser/ai-core.contribution.ts index ee343ae131..ee42ec0942 100644 --- a/packages/ai-native/src/browser/ai-core.contribution.ts +++ b/packages/ai-native/src/browser/ai-core.contribution.ts @@ -73,7 +73,10 @@ import { AI_CHAT_VIEW_ID, AI_MENU_BAR_DEBUG_TOOLBAR, ChatProxyServiceToken, + ISumiMCPServerBackend, + SumiMCPServerProxyServicePath, } from '../common'; +import { MCPServerDescription } from '../common/mcp-server-manager'; import { ChatProxyService } from './chat/chat-proxy.service'; import { AIChatView } from './chat/chat.view'; @@ -97,10 +100,13 @@ import { IChatFeatureRegistry, IChatRenderRegistry, IIntelligentCompletionsRegistry, + IMCPServerRegistry, IProblemFixProviderRegistry, IRenameCandidatesProviderRegistry, IResolveConflictRegistry, ITerminalProviderRegistry, + MCPServerContribution, + TokenMCPServerRegistry, } from './types'; import { InlineChatEditorController } from './widget/inline-chat/inline-chat-editor.controller'; import { InlineChatFeatureRegistry } from './widget/inline-chat/inline-chat.feature.registry'; @@ -145,6 +151,12 @@ export class AINativeBrowserContribution @Autowired(AINativeCoreContribution) private readonly contributions: ContributionProvider; + @Autowired(MCPServerContribution) + private readonly mcpServerContributions: ContributionProvider; + + @Autowired(TokenMCPServerRegistry) + private readonly mcpServerRegistry: IMCPServerRegistry; + @Autowired(InlineChatFeatureRegistryToken) private readonly inlineChatFeatureRegistry: InlineChatFeatureRegistry; @@ -208,6 +220,9 @@ export class AINativeBrowserContribution @Autowired(CodeActionSingleHandler) private readonly codeActionSingleHandler: CodeActionSingleHandler; + @Autowired(SumiMCPServerProxyServicePath) + private readonly sumiMCPServerBackendProxy: ISumiMCPServerBackend; + @Autowired(WorkbenchEditorService) private readonly workbenchEditorService: WorkbenchEditorServiceImpl; @@ -279,7 +294,7 @@ export class AINativeBrowserContribution onDidStart() { runWhenIdle(() => { - const { supportsRenameSuggestions, supportsInlineChat } = this.aiNativeConfigService.capabilities; + const { supportsRenameSuggestions, supportsInlineChat, supportsMCP } = this.aiNativeConfigService.capabilities; const prefChatVisibleType = this.preferenceService.getValid(AINativeSettingSectionsId.ChatVisibleType); if (prefChatVisibleType === 'always') { @@ -295,6 +310,19 @@ export class AINativeBrowserContribution if (supportsInlineChat) { this.codeActionSingleHandler.load(); } + + if (supportsMCP) { + // 初始化内置 MCP Server + this.sumiMCPServerBackendProxy.initBuiltinMCPServer(); + + // 从 preferences 获取并初始化外部 MCP Servers + const mcpServers = this.preferenceService.getValid( + AINativeSettingSectionsId.MCPServers, + ); + if (mcpServers && mcpServers.length > 0) { + this.sumiMCPServerBackendProxy.initExternalMCPServers(mcpServers); + } + } }); } @@ -308,6 +336,12 @@ export class AINativeBrowserContribution contribution.registerTerminalProvider?.(this.terminalProviderRegistry); contribution.registerIntelligentCompletionFeature?.(this.intelligentCompletionsRegistry); contribution.registerProblemFixFeature?.(this.problemFixProviderRegistry); + contribution.registerChatAgentPromptProvider?.(); + }); + + // 注册 Opensumi 框架提供的 MCP Server Tools 能力 (此时的 Opensumi 作为 MCP Server) + this.mcpServerContributions.getContributions().forEach((contribution) => { + contribution.registerMCPServer(this.mcpServerRegistry); }); } @@ -379,6 +413,48 @@ export class AINativeBrowserContribution }); } + // Register language model API key settings + if (this.aiNativeConfigService.capabilities.supportsCustomLLMSettings) { + registry.registerSettingSection(AI_NATIVE_SETTING_GROUP_ID, { + title: localize('preference.ai.native.llm.apiSettings.title'), + preferences: [ + { + id: AINativeSettingSectionsId.LLMModelSelection, + localized: 'preference.ai.native.llm.model.selection', + }, + { + id: AINativeSettingSectionsId.DeepseekApiKey, + localized: 'preference.ai.native.deepseek.apiKey', + }, + { + id: AINativeSettingSectionsId.AnthropicApiKey, + localized: 'preference.ai.native.anthropic.apiKey', + }, + { + id: AINativeSettingSectionsId.OpenaiApiKey, + localized: 'preference.ai.native.openai.apiKey', + }, + { + id: AINativeSettingSectionsId.OpenaiBaseURL, + localized: 'preference.ai.native.openai.baseURL', + }, + ], + }); + } + + // Register MCP server settings + if (this.aiNativeConfigService.capabilities.supportsMCP) { + registry.registerSettingSection(AI_NATIVE_SETTING_GROUP_ID, { + title: localize('preference.ai.native.mcp.settings.title'), + preferences: [ + { + id: AINativeSettingSectionsId.MCPServers, + localized: 'preference.ai.native.mcp.servers', + }, + ], + }); + } + if (this.aiNativeConfigService.capabilities.supportsInlineChat) { registry.registerSettingSection(AI_NATIVE_SETTING_GROUP_ID, { title: localize('preference.ai.native.inlineChat.title'), diff --git a/packages/ai-native/src/browser/chat/chat-model.ts b/packages/ai-native/src/browser/chat/chat-model.ts index 34b48c7aff..70b858e5ed 100644 --- a/packages/ai-native/src/browser/chat/chat-model.ts +++ b/packages/ai-native/src/browser/chat/chat-model.ts @@ -6,6 +6,7 @@ import { IChatComponent, IChatMarkdownContent, IChatProgress, + IChatToolContent, IChatTreeData, ILogger, memoize, @@ -26,7 +27,12 @@ import { import { MsgHistoryManager } from '../model/msg-history-manager'; import { IChatSlashCommandItem } from '../types'; -export type IChatProgressResponseContent = IChatMarkdownContent | IChatAsyncContent | IChatTreeData | IChatComponent; +export type IChatProgressResponseContent = + | IChatMarkdownContent + | IChatAsyncContent + | IChatTreeData + | IChatComponent + | IChatToolContent; @Injectable({ multiple: true }) export class ChatResponseModel extends Disposable { @@ -81,8 +87,8 @@ export class ChatResponseModel extends Disposable { } updateContent(progress: IChatProgress, quiet?: boolean): void { + const responsePartLength = this.#responseParts.length - 1; if (progress.kind === 'content' || progress.kind === 'markdownContent') { - const responsePartLength = this.#responseParts.length - 1; const lastResponsePart = this.#responseParts[responsePartLength]; if (!lastResponsePart || lastResponsePart.kind !== 'markdownContent') { @@ -120,11 +126,20 @@ export class ChatResponseModel extends Disposable { } this.#updateResponseText(quiet); }); - } else if (progress.kind === 'treeData') { + } else if (progress.kind === 'treeData' || progress.kind === 'component') { this.#responseParts.push(progress); this.#updateResponseText(quiet); - } else if (progress.kind === 'component') { - this.#responseParts.push(progress); + } else if (progress.kind === 'toolCall') { + const find = this.#responseParts.find( + (item) => item.kind === 'toolCall' && item.content.id === progress.content.id, + ); + if (find) { + // @ts-ignore + find.content = progress.content; + // this.#responseParts[responsePartLength] = find; + } else { + this.#responseParts.push(progress); + } this.#updateResponseText(quiet); } } @@ -141,6 +156,9 @@ export class ChatResponseModel extends Disposable { if (part.kind === 'component') { return ''; } + if (part.kind === 'toolCall') { + return part.content.function.name; + } return part.content.value; }) .join('\n\n'); @@ -258,7 +276,7 @@ export class ChatModel extends Disposable implements IChatModel { const { kind } = progress; - const basicKind = ['content', 'markdownContent', 'asyncContent', 'treeData', 'component']; + const basicKind = ['content', 'markdownContent', 'asyncContent', 'treeData', 'component', 'toolCall']; if (basicKind.includes(kind)) { request.response.updateContent(progress, quiet); diff --git a/packages/ai-native/src/browser/chat/chat-proxy.service.ts b/packages/ai-native/src/browser/chat/chat-proxy.service.ts index 80921b4e71..2bcc50a788 100644 --- a/packages/ai-native/src/browser/chat/chat-proxy.service.ts +++ b/packages/ai-native/src/browser/chat/chat-proxy.service.ts @@ -1,18 +1,23 @@ import { Autowired, Injectable } from '@opensumi/di'; +import { PreferenceService } from '@opensumi/ide-core-browser'; import { AIBackSerivcePath, CancellationToken, + ChatAgentViewServiceToken, ChatFeatureRegistryToken, ChatServiceToken, Deferred, Disposable, IAIBackService, IAIReporter, + IApplicationService, IChatProgress, uuid, } from '@opensumi/ide-core-common'; +import { AINativeSettingSectionsId } from '@opensumi/ide-core-common/lib/settings/ai-native'; import { IChatMessage } from '@opensumi/ide-core-common/lib/types/ai-native'; import { MonacoCommandRegistry } from '@opensumi/ide-editor/lib/browser/monaco-contrib/command/command.service'; +import { IMessageService } from '@opensumi/ide-overlay'; import { listenReadable } from '@opensumi/ide-utils/lib/stream'; import { @@ -22,6 +27,8 @@ import { IChatAgentService, IChatAgentWelcomeMessage, } from '../../common'; +import { ChatToolRender } from '../components/ChatToolRender'; +import { IChatAgentViewService } from '../types'; import { ChatService } from './chat.api.service'; import { ChatFeatureRegistry } from './chat.feature.registry'; @@ -52,9 +59,27 @@ export class ChatProxyService extends Disposable { @Autowired(IAIReporter) private readonly aiReporter: IAIReporter; + @Autowired(ChatAgentViewServiceToken) + private readonly chatAgentViewService: IChatAgentViewService; + + @Autowired(PreferenceService) + private readonly preferenceService: PreferenceService; + + @Autowired(IApplicationService) + private readonly applicationService: IApplicationService; + + @Autowired(IMessageService) + private readonly messageService: IMessageService; + private chatDeferred: Deferred = new Deferred(); public registerDefaultAgent() { + this.chatAgentViewService.registerChatComponent({ + id: 'toolCall', + component: ChatToolRender, + initialProps: {}, + }); + this.addDispose( this.chatAgentService.registerAgent({ id: ChatProxyService.AGENT_ID, @@ -79,12 +104,28 @@ export class ChatProxyService extends Disposable { } } + const model = this.preferenceService.get(AINativeSettingSectionsId.LLMModelSelection); + let apiKey: string = ''; + let baseURL: string = ''; + if (model === 'deepseek') { + apiKey = this.preferenceService.get(AINativeSettingSectionsId.DeepseekApiKey, ''); + } else if (model === 'openai') { + apiKey = this.preferenceService.get(AINativeSettingSectionsId.OpenaiApiKey, ''); + baseURL = this.preferenceService.get(AINativeSettingSectionsId.OpenaiBaseURL, ''); + } else { + apiKey = this.preferenceService.get(AINativeSettingSectionsId.AnthropicApiKey, ''); + } + const stream = await this.aiBackService.requestStream( prompt, { requestId: request.requestId, sessionId: request.sessionId, history: this.aiChatService.getHistoryMessages(), + clientId: this.applicationService.clientId, + apiKey, + model, + baseURL, }, token, ); @@ -97,6 +138,7 @@ export class ChatProxyService extends Disposable { this.chatDeferred.resolve(); }, onError: (error) => { + this.messageService.error(error.message); this.aiReporter.end(request.sessionId + '_' + request.requestId, { message: error.message, success: false, diff --git a/packages/ai-native/src/browser/chat/chat.view.tsx b/packages/ai-native/src/browser/chat/chat.view.tsx index bda5ffa009..3bf589b3b0 100644 --- a/packages/ai-native/src/browser/chat/chat.view.tsx +++ b/packages/ai-native/src/browser/chat/chat.view.tsx @@ -1,7 +1,13 @@ import * as React from 'react'; import { MessageList } from 'react-chat-elements'; -import { getIcon, useInjectable, useUpdateOnEvent } from '@opensumi/ide-core-browser'; +import { + AINativeConfigService, + getIcon, + useEventEffect, + useInjectable, + useUpdateOnEvent, +} from '@opensumi/ide-core-browser'; import { Popover, PopoverPosition } from '@opensumi/ide-core-browser/lib/components'; import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; import { @@ -18,13 +24,23 @@ import { IAIReporter, IChatComponent, IChatContent, + MessageType, localize, uuid, } from '@opensumi/ide-core-common'; import { IMainLayoutService } from '@opensumi/ide-main-layout'; +import { IDialogService } from '@opensumi/ide-overlay'; import 'react-chat-elements/dist/main.css'; -import { AI_CHAT_VIEW_ID, IChatAgentService, IChatInternalService, IChatMessageStructure } from '../../common'; +import { + AI_CHAT_VIEW_ID, + IChatAgentService, + IChatInternalService, + IChatMessageStructure, + TokenMCPServerProxyService, +} from '../../common'; +import { LLMContextService, LLMContextServiceToken } from '../../common/llm-context'; +import { ChatContext } from '../components/ChatContext'; import { CodeBlockWrapperInput } from '../components/ChatEditor'; import { ChatInput } from '../components/ChatInput'; import { ChatMarkdown } from '../components/ChatMarkdown'; @@ -32,7 +48,9 @@ import { ChatNotify, ChatReply } from '../components/ChatReply'; import { SlashCustomRender } from '../components/SlashCustomRender'; import { MessageData, createMessageByAI, createMessageByUser } from '../components/utils'; import { WelcomeMessage } from '../components/WelcomeMsg'; -import { ChatViewHeaderRender, TSlashCommandCustomRender } from '../types'; +import { MCPServerProxyService } from '../mcp/mcp-server-proxy.service'; +import { MCPToolsDialog } from '../mcp/mcp-tools-dialog.view'; +import { ChatAgentPromptProvider, ChatViewHeaderRender, TSlashCommandCustomRender } from '../types'; import { ChatRequestModel, ChatSlashCommandItemModel } from './chat-model'; import { ChatProxyService } from './chat-proxy.service'; @@ -41,7 +59,6 @@ import { ChatFeatureRegistry } from './chat.feature.registry'; import { ChatInternalService } from './chat.internal.service'; import styles from './chat.module.less'; import { ChatRenderRegistry } from './chat.render.registry'; - const SCROLL_CLASSNAME = 'chat_scroll'; interface TDispatchAction { @@ -56,10 +73,16 @@ export const AIChatView = () => { const chatAgentService = useInjectable(IChatAgentService); const chatFeatureRegistry = useInjectable(ChatFeatureRegistryToken); const chatRenderRegistry = useInjectable(ChatRenderRegistryToken); + const contextService = useInjectable(LLMContextServiceToken); + const promptProvider = useInjectable(ChatAgentPromptProvider); + const layoutService = useInjectable(IMainLayoutService); + const mcpServerProxyService = useInjectable(TokenMCPServerProxyService); const msgHistoryManager = aiChatService.sessionModel.history; const containerRef = React.useRef(null); const chatInputRef = React.useRef<{ setInputValue: (v: string) => void } | null>(null); + const dialogService = useInjectable(IDialogService); + const aiNativeConfigService = useInjectable(AINativeConfigService); const [shortcutCommands, setShortcutCommands] = React.useState([]); @@ -81,6 +104,7 @@ export const AIChatView = () => { const [defaultAgentId, setDefaultAgentId] = React.useState(''); const [command, setCommand] = React.useState(''); const [theme, setTheme] = React.useState(null); + const [mcpToolsCount, setMcpToolsCount] = React.useState(0); React.useEffect(() => { const featureSlashCommands = chatFeatureRegistry.getAllShortcutSlashCommand(); @@ -478,7 +502,10 @@ export const AIChatView = () => { const { message, agentId, command, reportExtra } = value; const { actionType, actionSource } = reportExtra || {}; - const request = aiChatService.createRequest(message, agentId!, command); + const context = contextService.serialize(); + const fullMessage = await promptProvider.provideContextPrompt(context, message); + + const request = aiChatService.createRequest(fullMessage, agentId!, command); if (!request) { return; } @@ -626,6 +653,25 @@ export const AIChatView = () => { }; }, [aiChatService.sessionModel]); + useEventEffect( + mcpServerProxyService.onChangeMCPServers, + () => { + mcpServerProxyService.getAllMCPTools().then((tools) => { + setMcpToolsCount(tools.length); + }); + }, + [mcpServerProxyService], + ); + + const handleShowMCPTools = React.useCallback(async () => { + const tools = await mcpServerProxyService.getAllMCPTools(); + dialogService.open({ + message: , + type: MessageType.Empty, + buttons: ['关闭'], + }); + }, [mcpServerProxyService, dialogService]); + return (
@@ -643,6 +689,7 @@ export const AIChatView = () => { />
+
{shortcutCommands.map((command) => ( @@ -657,7 +704,13 @@ export const AIChatView = () => { ))}
-
+
+ {aiNativeConfigService.capabilities.supportsMCP && ( +
+ {`MCP Tools: ${mcpToolsCount}`} +
+ )} +
diff --git a/packages/ai-native/src/browser/components/ChatContext/ContextSelector.tsx b/packages/ai-native/src/browser/components/ChatContext/ContextSelector.tsx new file mode 100644 index 0000000000..a3ed6525f2 --- /dev/null +++ b/packages/ai-native/src/browser/components/ChatContext/ContextSelector.tsx @@ -0,0 +1,177 @@ +import cls from 'classnames'; +import { debounce } from 'lodash'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; + +import { ClickOutside } from '@opensumi/ide-components/lib/click-outside'; +import { AppConfig, LabelService } from '@opensumi/ide-core-browser'; +import { Icon, Input, Scrollbars } from '@opensumi/ide-core-browser/lib/components'; +import { RecentFilesManager } from '@opensumi/ide-core-browser/lib/quick-open/recent-files'; +import { useInjectable } from '@opensumi/ide-core-browser/lib/react-hooks/injectable-hooks'; +import { FileSearchServicePath, IFileSearchService } from '@opensumi/ide-file-search/lib/common/file-search'; +import { URI } from '@opensumi/ide-utils'; + +import { FileContext } from '../../../common/llm-context'; + +import styles from './style.module.less'; + +interface CandidateFileProps { + uri: URI; + active: boolean; + selected: boolean; + onDidSelect: (val: URI) => void; + onDidDeselect: (val: URI) => void; +} + +const CandidateFile = memo(({ uri, active, selected, onDidSelect, onDidDeselect }: CandidateFileProps) => { + const labelService = useInjectable(LabelService); + const appConfig = useInjectable(AppConfig); + const itemsRef = useRef(null); + + useEffect(() => { + if (active && itemsRef.current) { + const scrollBehavior: ScrollIntoViewOptions = { + behavior: 'instant', + block: 'end', + }; + itemsRef.current.scrollIntoView(scrollBehavior); + } + }, [active, itemsRef.current]); + + return ( +
(itemsRef.current = ele)} + onClick={() => (selected ? onDidDeselect(uri) : onDidSelect(uri))} + > + + {uri.path.base} + {URI.file(appConfig.workspaceDir).relative(uri.parent)?.toString()} + {selected && } +
+ ); +}); + +interface ContextSelectorProps { + addedFiles: FileContext[]; + onDidSelect: (val: URI) => void; + onDidDeselect: (val: URI) => void; + onDidClose: () => void; +} + +export const ContextSelector = memo(({ addedFiles, onDidDeselect, onDidSelect, onDidClose }: ContextSelectorProps) => { + const [candidateFiles, updateCandidateFiles] = useState([]); + const [activeFile, setActiveFile] = useState(null); + const [searching, toggleSearching] = useState(false); + const [searchResults, updateSearchResults] = useState([]); + + const recentFilesManager: RecentFilesManager = useInjectable(RecentFilesManager); + const appConfig = useInjectable(AppConfig); + const searchService = useInjectable(FileSearchServicePath); + + const container = useRef(); + + useEffect(() => { + if (candidateFiles.length === 0) { + recentFilesManager.getMostRecentlyOpenedFiles().then((files) => { + const addedUris = addedFiles.map((val) => val.uri); + const recentFiles = files.filter((file) => !addedUris.includes(new URI(file))).map((file) => new URI(file)); + updateCandidateFiles(recentFiles); + setActiveFile(recentFiles[0] || null); + }); + } + }, [addedFiles]); + + const onDidInput = useCallback( + debounce((ev) => { + if (ev.target.value.trim() === '') { + updateSearchResults([]); + setActiveFile(candidateFiles[0]); + return; + } + + toggleSearching(true); + searchService + .find(ev.target.value, { + rootUris: [appConfig.workspaceDir], + limit: 200, + useGitIgnore: true, + noIgnoreParent: true, + fuzzyMatch: true, + }) + .then((res) => { + const results = res.map((val) => new URI(val)); + updateSearchResults(results); + setActiveFile(results[0]); + }) + .finally(() => { + toggleSearching(false); + }); + }, 500), + [], + ); + + const onDidKeyDown = useCallback( + (event) => { + const { key } = event; + if (key === 'Escape') { + onDidClose(); + return; + } + + if (key === 'Enter' && activeFile) { + onDidSelect(activeFile); + return; + } + + const validKeys = ['ArrowUp', 'ArrowDown']; + + if (!validKeys.includes(key)) { + return; + } + + const files = searchResults.length > 0 ? searchResults : candidateFiles; + + if (files.length === 0) { + return; + } + + const currentIndex = files.indexOf(activeFile!); + const safeIndex = currentIndex === -1 ? 0 : currentIndex; + const lastIndex = files.length - 1; + + const nextIndex = + key === 'ArrowUp' ? (safeIndex > 0 ? safeIndex - 1 : lastIndex) : safeIndex < lastIndex ? safeIndex + 1 : 0; + + setActiveFile(files[nextIndex]); + }, + [activeFile, searchResults, candidateFiles], + ); + + return ( + onDidClose()}> +
+
+ +
+ (el ? (container.current = el.ref) : null)}> +
+ {searching &&
} + + {searchResults.length > 0 ? 'Search Results' : 'Recent Opened Files'} + + {(searchResults.length > 0 ? searchResults : candidateFiles).map((file) => ( + val.uri.isEqual(file))} + /> + ))} +
+ +
+ + ); +}); diff --git a/packages/ai-native/src/browser/components/ChatContext/index.tsx b/packages/ai-native/src/browser/components/ChatContext/index.tsx new file mode 100644 index 0000000000..1607db34df --- /dev/null +++ b/packages/ai-native/src/browser/components/ChatContext/index.tsx @@ -0,0 +1,135 @@ +import Collapse, { Panel } from 'rc-collapse'; +import React, { memo, useCallback, useEffect, useState } from 'react'; + +import 'rc-collapse/assets/index.css'; + +import { Icon } from '@opensumi/ide-components/lib/icon/icon'; +import { Popover, getIcon } from '@opensumi/ide-core-browser/lib/components'; +import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; +import { useInjectable } from '@opensumi/ide-core-browser/lib/react-hooks/injectable-hooks'; +import { AppConfig } from '@opensumi/ide-core-browser/lib/react-providers/config-provider'; +import { LabelService } from '@opensumi/ide-core-browser/lib/services/label-service'; +import { localize } from '@opensumi/ide-core-common/lib/localize'; +import { Event, URI } from '@opensumi/ide-core-common/lib/utils'; +import { WorkbenchEditorService } from '@opensumi/ide-editor/lib/browser/types'; + +import { FileContext, LLMContextService, LLMContextServiceToken } from '../../../common/llm-context'; + +import { ContextSelector } from './ContextSelector'; +import styles from './style.module.less'; + +export const ChatContext = memo(() => { + const [addedFiles, updateAddedFiles] = useState([]); + const [contextOverlay, toggleContextOverlay] = useState(false); + + const labelService = useInjectable(LabelService); + const appConfig = useInjectable(AppConfig); + const workbenchEditorService = useInjectable(WorkbenchEditorService); + const contextService = useInjectable(LLMContextServiceToken); + + useEffect(() => { + const disposable = Event.debounce( + contextService.onDidContextFilesChangeEvent, + (_, e) => e!, + 50, + )((files) => { + if (files) { + updateAddedFiles(files); + } + }, contextService); + + return () => { + disposable.dispose(); + }; + }, []); + + const openContextOverlay = useCallback(() => { + toggleContextOverlay(true); + }, [addedFiles]); + + const onDidSelect = useCallback((uri: URI) => { + contextService.addFileToContext(uri, undefined, true); + }, []); + + const onDidDeselect = useCallback((uri: URI) => { + contextService.removeFileFromContext(uri); + }, []); + + const onDidClickFile = useCallback((uri: URI) => { + workbenchEditorService.open(uri); + }, []); + + const onDidCleanFiles = useCallback((e) => { + e.stopPropagation(); + e.preventDefault(); + contextService.cleanFileContext(); + }, []); + + const onDidRemoveFile = useCallback((e, uri: URI) => { + e.stopPropagation(); + e.preventDefault(); + onDidDeselect(uri); + }, []); + + return ( +
+ (isActive ? : )} + defaultActiveKey={['context-panel']} + onChange={() => {}} + > + +

Context

+ + + +
+ } + key='context-panel' + > +
+ {addedFiles.map((file) => ( +
onDidClickFile(file.uri)}> + + + {file.uri.path.base} + {file.selection ? ` (${file.selection[0]}-${file.selection[1]})` : ''} + + + {URI.file(appConfig.workspaceDir).relative(file.uri.parent)?.toString()} + + onDidRemoveFile(e, file.uri)} /> +
+ ))} +
+
+ + Add Files +
+ + + {contextOverlay && ( + toggleContextOverlay(false)} + onDidDeselect={onDidDeselect} + onDidSelect={onDidSelect} + addedFiles={addedFiles} + /> + )} +
+ ); +}); diff --git a/packages/ai-native/src/browser/components/ChatContext/style.module.less b/packages/ai-native/src/browser/components/ChatContext/style.module.less new file mode 100644 index 0000000000..c32295bd6a --- /dev/null +++ b/packages/ai-native/src/browser/components/ChatContext/style.module.less @@ -0,0 +1,189 @@ +.chat_context { + position: relative; + margin-bottom: 10px; + background-color: var(--design-chatInput-background); + padding: 10px; + border-radius: 4px; + border: 1px solid var(--design-borderColor); + + :global(.rc-collapse) { + background-color: transparent !important; + border-radius: none !important; + border: none !important; + } + + :global(.rc-collapse-title) { + flex: 1 !important; + line-height: 22px; + } + + :global(.rc-collapse-expand-icon) { + line-height: 1; + } + + :global(.rc-collapse-header) { + padding: 0px !important; + line-height: 1 !important; + } + + :global(.rc-collapse-content) { + padding: 0px !important; + background-color: transparent !important; + } + + .context_header { + display: flex; + align-items: center; + justify-content: space-between; + + .chat_context_title { + font-weight: 600; + font-size: 11px; + margin-bottom: 0px; + color: var(--descriptionForeground); + } + } + + .context_selector { + position: absolute; + left: 0px; + bottom: 40px; + background-color: var(--design-container-background); + padding: 10px; + width: 100%; + padding: 0px; + border-radius: 4px; + height: 350px; + display: flex; + flex-direction: column; + user-select: none; + box-shadow: 0px 9px 28px 8px var(--design-boxShadow-primary), 0px 3px 6px -4px var(--design-boxShadow-secondary), + 0px 6px 16px 0px var(--design-boxShadow-tertiary) !important; + + .context_list { + font-size: 12px; + overflow-y: auto; + + .list_desc { + margin: 0px 10px; + height: 30px; + display: flex; + align-items: center; + font-weight: bold; + color: var(--descriptionForeground); + } + + .candidate_file { + padding: 0px 10px; + display: flex; + align-items: center; + height: 24px; + cursor: pointer; + + &:hover { + background-color: var(--button-hoverBackground); + } + + .basename { + color: var(--foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-left: 5px; + } + + .dir { + color: var(--descriptionForeground); + margin-left: 15px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + } + + .active { + background-color: var(--editor-background); + color: var(--foreground); + } + } + + .context_search_layer { + position: absolute; + width: 100%; + height: 100%; + background-color: var(--inputOption-hoverBackground); + } + } + + .file_list { + margin-top: 5px; + max-height: 300px; + overflow-x: hidden; + overflow-y: auto; + + .selected_item { + display: flex; + align-items: center; + font-size: 11px; + margin-right: 5px; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + padding: 0px 4px; + margin-bottom: 4px; + cursor: pointer; + transition: all 0.2s; + + .close_icon { + font-size: 12px !important; + margin-left: auto; + } + + :global(.kt-icon) { + display: flex; + font-size: 11px; + } + + .basename { + margin-left: 4px; + color: var(--editor-foreground); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + width: 75%; + + &:hover { + color: var(--badge-foreground); + } + } + + .dir { + color: var(--descriptionForeground); + margin-left: 15px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + } + } + + .add_context { + display: inline-flex; + justify-content: center; + cursor: pointer; + border-radius: 4px; + font-size: 11px; + margin-right: 5px; + height: 24px; + margin-top: 10px; + align-items: center; + user-select: none; + transition: all 0.2s; + color: var(--design-text-foreground); + padding: 0px 5px; + + &:hover { + border-color: var(--kt-selectOption-activeBorder); + } + } +} diff --git a/packages/ai-native/src/browser/components/ChatInput.tsx b/packages/ai-native/src/browser/components/ChatInput.tsx index 1f73190449..b938f07a5c 100644 --- a/packages/ai-native/src/browser/components/ChatInput.tsx +++ b/packages/ai-native/src/browser/components/ChatInput.tsx @@ -15,6 +15,7 @@ import { ChatProxyService } from '../chat/chat-proxy.service'; import { ChatFeatureRegistry } from '../chat/chat.feature.registry'; import { IChatSlashCommandItem } from '../types'; +import { ChatContext } from './ChatContext'; import styles from './components.module.less'; const INSTRUCTION_BOTTOM = 8; diff --git a/packages/ai-native/src/browser/components/ChatReply.tsx b/packages/ai-native/src/browser/components/ChatReply.tsx index 9416a0a824..0f9bda800e 100644 --- a/packages/ai-native/src/browser/components/ChatReply.tsx +++ b/packages/ai-native/src/browser/components/ChatReply.tsx @@ -38,6 +38,7 @@ import { IChatComponent, IChatContent, IChatResponseProgressFileTreeData, + IChatToolContent, URI, } from '@opensumi/ide-core-common'; import { IIconService } from '@opensumi/ide-theme'; @@ -148,6 +149,33 @@ const TreeRenderer = (props: { treeData: IChatResponseProgressFileTreeData }) => ); }; +const ToolCallRender = (props: { toolCall: IChatToolContent['content'] }) => { + const { toolCall } = props; + const chatAgentViewService = useInjectable(ChatAgentViewServiceToken); + const [node, setNode] = useState(null); + + useEffect(() => { + const config = chatAgentViewService.getChatComponent('toolCall'); + if (config) { + const { component: Component, initialProps } = config; + setNode(); + return; + } + setNode( +
+ + 正在加载组件 +
, + ); + const deferred = chatAgentViewService.getChatComponentDeferred('toolCall')!; + deferred.promise.then(({ component: Component, initialProps }) => { + setNode(); + }); + }, [toolCall.state]); + + return node; +}; + const ComponentRender = (props: { component: string; value?: unknown }) => { const chatAgentViewService = useInjectable(ChatAgentViewServiceToken); const [node, setNode] = useState(null); @@ -274,6 +302,8 @@ export const ChatReply = (props: IChatReplyProps) => { ); + const renderToolCall = (toolCall: IChatToolContent['content']) => ; + const contentNode = React.useMemo( () => request.response.responseContents.map((item, index) => { @@ -284,6 +314,8 @@ export const ChatReply = (props: IChatReplyProps) => { node = renderTreeData(item.treeData); } else if (item.kind === 'component') { node = renderComponent(item.component, item.value); + } else if (item.kind === 'toolCall') { + node = renderToolCall(item.content); } else { node = renderMarkdown(item.content); } diff --git a/packages/ai-native/src/browser/components/ChatToolRender.module.less b/packages/ai-native/src/browser/components/ChatToolRender.module.less new file mode 100644 index 0000000000..d9c23d4523 --- /dev/null +++ b/packages/ai-native/src/browser/components/ChatToolRender.module.less @@ -0,0 +1,86 @@ +.chat-tool-render { + margin: 8px 0; + border: 1px solid var(--design-borderColor); + border-radius: 6px; + overflow: hidden; + + .tool-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background-color: var(--design-block-background); + cursor: pointer; + user-select: none; + + &:hover { + background-color: var(--design-block-hoverBackground); + } + } + + .tool-name { + display: flex; + align-items: center; + font-weight: 500; + color: var(--design-text-foreground); + } + + .expand-icon { + display: inline-block; + margin-right: 8px; + transition: transform 0.2s; + color: var(--design-text-placeholderForeground); + + &.expanded { + transform: rotate(90deg); + } + } + + .tool-state { + display: flex; + align-items: center; + font-size: 12px; + color: var(--design-text-placeholderForeground); + } + + .state-icon { + display: flex; + align-items: center; + margin-right: 6px; + } + + .loading-icon { + width: 12px; + height: 12px; + } + + .state-label { + margin-left: 4px; + } + + .tool-content { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-out; + background-color: var(--design-container-background); + + &.expanded { + max-height: 1000px; + } + } + + .tool-arguments, + .tool-result { + padding: 12px; + } + + .section-label { + font-size: 12px; + color: var(--design-text-placeholderForeground); + margin-bottom: 8px; + } + + .tool-result { + border-top: 1px solid var(--design-borderColor); + } +} diff --git a/packages/ai-native/src/browser/components/ChatToolRender.tsx b/packages/ai-native/src/browser/components/ChatToolRender.tsx new file mode 100644 index 0000000000..fd501c285e --- /dev/null +++ b/packages/ai-native/src/browser/components/ChatToolRender.tsx @@ -0,0 +1,77 @@ +import cls from 'classnames'; +import React, { useState } from 'react'; + +import { Icon } from '@opensumi/ide-core-browser/lib/components'; +import { Loading } from '@opensumi/ide-core-browser/lib/components/ai-native'; +import { IChatToolContent, uuid } from '@opensumi/ide-core-common'; + +import { CodeEditorWithHighlight } from './ChatEditor'; +import styles from './ChatToolRender.module.less'; + +export const ChatToolRender = (props: { value: IChatToolContent['content'] }) => { + const { value } = props; + const [isExpanded, setIsExpanded] = useState(false); + + if (!value || !value.function || !value.id) { + return null; + } + + const getStateInfo = (state?: string): { label: string; icon: React.ReactNode } => { + switch (state) { + case 'streaming-start': + case 'streaming': + return { label: 'Generating', icon: }; + case 'complete': + return { label: 'Complete', icon: }; + case 'result': + return { label: 'Result Ready', icon: }; + default: + return { label: state || 'Unknown', icon: }; + } + }; + + const toggleExpand = () => { + setIsExpanded(!isExpanded); + }; + + const stateInfo = getStateInfo(value.state); + + return ( +
+
+
+ + {value?.function?.name} +
+ {value.state && ( +
+ {stateInfo.icon} + {stateInfo.label} +
+ )} +
+
+ {value?.function?.arguments && ( +
+
Arguments
+ +
+ )} + {value?.result && ( +
+
Result
+ +
+ )} +
+
+ ); +}; diff --git a/packages/ai-native/src/browser/components/components.module.less b/packages/ai-native/src/browser/components/components.module.less index 08d6761edd..dbdd6524c2 100644 --- a/packages/ai-native/src/browser/components/components.module.less +++ b/packages/ai-native/src/browser/components/components.module.less @@ -244,44 +244,45 @@ } } -.code_block { +.monaco_wrapper { position: relative; - min-width: 100px; - margin-top: 4px; - .monaco_wrapper { - position: relative; - min-width: 130px; - > pre { - margin-bottom: 10px; - } - .editor { - border-radius: 8px; - font-size: 12px; - padding: 32px 8px 8px 8px; - line-height: 18px; - &::-webkit-scrollbar { - width: auto; - height: 4px; - } + min-width: 130px; + > pre { + margin-bottom: 10px; + } + .editor { + border-radius: 8px; + font-size: 12px; + padding: 32px 8px 8px 8px; + line-height: 18px; + &::-webkit-scrollbar { + width: auto; + height: 4px; } + } - .action_toolbar { - display: flex; - position: absolute; - right: 8px; - top: 6px; - z-index: 100; - height: 20px; - align-items: center; - overflow: hidden; + .action_toolbar { + display: flex; + position: absolute; + right: 8px; + top: 6px; + z-index: 100; + height: 20px; + align-items: center; + overflow: hidden; - :global { - .kt-popover { - height: inherit; - } + :global { + .kt-popover { + height: inherit; } } } +} + +.code_block { + position: relative; + min-width: 100px; + margin-top: 4px; :global { .hljs { diff --git a/packages/ai-native/src/browser/context/llm-context.contribution.ts b/packages/ai-native/src/browser/context/llm-context.contribution.ts new file mode 100644 index 0000000000..f1b884334f --- /dev/null +++ b/packages/ai-native/src/browser/context/llm-context.contribution.ts @@ -0,0 +1,14 @@ +import { Autowired } from '@opensumi/di'; +import { ClientAppContribution, Domain } from '@opensumi/ide-core-browser'; + +import { LLMContextService, LLMContextServiceToken } from '../../common/llm-context'; + +@Domain(ClientAppContribution) +export class LlmContextContribution implements ClientAppContribution { + @Autowired(LLMContextServiceToken) + protected readonly llmContextService: LLMContextService; + + initialize() { + this.llmContextService.startAutoCollection(); + } +} diff --git a/packages/ai-native/src/browser/context/llm-context.service.ts b/packages/ai-native/src/browser/context/llm-context.service.ts new file mode 100644 index 0000000000..acbd787a13 --- /dev/null +++ b/packages/ai-native/src/browser/context/llm-context.service.ts @@ -0,0 +1,156 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { AppConfig } from '@opensumi/ide-core-browser/lib/react-providers/config-provider'; +import { WithEventBus } from '@opensumi/ide-core-common/lib/event-bus/event-decorator'; +import { MarkerSeverity } from '@opensumi/ide-core-common/lib/types/markers/markers'; +import { Emitter, URI } from '@opensumi/ide-core-common/lib/utils'; +import { + EditorDocumentModelCreationEvent, + EditorDocumentModelRemovalEvent, + EditorDocumentModelSavedEvent, + IEditorDocumentModelService, +} from '@opensumi/ide-editor/lib/browser/doc-model/types'; +import { EditorSelectionChangeEvent } from '@opensumi/ide-editor/lib/browser/types'; +import { IMarkerService } from '@opensumi/ide-markers/lib/common/types'; + +import { FileContext, LLMContextService, SerializedContext } from '../../common/llm-context'; + +@Injectable() +export class LLMContextServiceImpl extends WithEventBus implements LLMContextService { + @Autowired(AppConfig) + protected readonly appConfig: AppConfig; + + @Autowired(IEditorDocumentModelService) + protected readonly docModelManager: IEditorDocumentModelService; + + @Autowired(IMarkerService) + protected readonly markerService: IMarkerService; + + private isAutoCollecting = false; + + private contextFiles: Map = new Map(); + + private onDidContextFilesChangeEmitter = new Emitter(); + onDidContextFilesChangeEvent = this.onDidContextFilesChangeEmitter.event; + + addFileToContext(uri: URI, selection?: [number, number], isManual = true): void { + this.contextFiles.set(uri.toString(), { + uri, + selection, + isManual, + }); + this.onDidContextFilesChangeEmitter.fire(this.getAllContextFiles()); + } + + cleanFileContext() { + this.contextFiles.clear(); + this.onDidContextFilesChangeEmitter.fire(this.getAllContextFiles()); + } + + private getAllContextFiles() { + return Array.from(this.contextFiles.values()); + } + + removeFileFromContext(uri: URI): void { + this.contextFiles.delete(uri.toString()); + this.onDidContextFilesChangeEmitter.fire(this.getAllContextFiles()); + } + + startAutoCollection(): void { + if (this.isAutoCollecting) { + return; + } + this.isAutoCollecting = true; + + this.startAutoCollectionInternal(); + } + + private startAutoCollectionInternal(): void { + // 文件打开 + this.disposables.push( + this.eventBus.on(EditorDocumentModelCreationEvent, (event) => { + if (event.payload.uri.scheme !== 'file') { + return; + } + // TODO: 是否自动添加文件到上下文? + // this.addFileToContext(event.payload.uri); + }), + ); + + // 删除 + this.disposables.push( + this.eventBus.on(EditorDocumentModelRemovalEvent, (event) => { + if (event.payload.scheme !== 'file') { + return; + } + }), + ); + + // 保存 + this.disposables.push( + this.eventBus.on(EditorDocumentModelSavedEvent, (event) => { + if (event.payload.scheme !== 'file') { + return; + } + }), + ); + + // 光标选中 + this.disposables.push( + this.eventBus.on(EditorSelectionChangeEvent, (event) => { + if (event.payload.selections.length > 0) { + const selection = [ + event.payload.selections[0].selectionStartLineNumber, + event.payload.selections[0].positionLineNumber, + ].sort() as [number, number]; + if (selection[0] === selection[1]) { + // TODO: 是否自动添加文件到上下文? + // this.addFileToContext(event.payload.editorUri, undefined); + } else { + this.addFileToContext( + event.payload.editorUri, + selection.sort((a, b) => a - b), + ); + } + } + }), + ); + } + + stopAutoCollection(): void { + this.dispose(); + } + + serialize(): SerializedContext { + const files = this.getAllContextFiles(); + const recentlyViewFiles = files + .filter((v) => !v.selection) + .map((file) => URI.file(this.appConfig.workspaceDir).relative(file.uri)!.toString()) + .filter(Boolean); + const attachedFiles = files + .filter((v) => v.selection) + .map((file) => { + const ref = this.docModelManager.getModelReference(file.uri); + const content = ref!.instance.getText(); + const lineErrors = this.markerService + .getManager() + .getMarkers({ + resource: file.uri.toString(), + severities: MarkerSeverity.Error, + }) + .map((marker) => marker.message); + + return { + content, + lineErrors, + path: URI.file(this.appConfig.workspaceDir).relative(file.uri)!.toString(), + language: ref?.instance.languageId!, + }; + }) + .filter(Boolean); + + return { + recentlyViewFiles, + attachedFiles, + }; + } +} diff --git a/packages/ai-native/src/browser/index.ts b/packages/ai-native/src/browser/index.ts index c4418cfe08..3deb6fa5ef 100644 --- a/packages/ai-native/src/browser/index.ts +++ b/packages/ai-native/src/browser/index.ts @@ -19,8 +19,17 @@ import { TerminalRegistryToken, } from '@opensumi/ide-core-common'; -import { ChatProxyServiceToken, IChatAgentService, IChatInternalService, IChatManagerService } from '../common'; -import { IAIInlineCompletionsProvider } from '../common'; +import { + ChatProxyServiceToken, + IAIInlineCompletionsProvider, + IChatAgentService, + IChatInternalService, + IChatManagerService, + SumiMCPServerProxyServicePath, + TokenMCPServerProxyService, +} from '../common'; +import { LLMContextServiceToken } from '../common/llm-context'; +import { MCPServerManager, MCPServerManagerPath } from '../common/mcp-server-manager'; import { AINativeBrowserContribution } from './ai-core.contribution'; import { ChatAgentService } from './chat/chat-agent.service'; @@ -31,6 +40,8 @@ import { ChatService } from './chat/chat.api.service'; import { ChatFeatureRegistry } from './chat/chat.feature.registry'; import { ChatInternalService } from './chat/chat.internal.service'; import { ChatRenderRegistry } from './chat/chat.render.registry'; +import { LlmContextContribution } from './context/llm-context.contribution'; +import { LLMContextServiceImpl } from './context/llm-context.service'; import { AICodeActionContribution } from './contrib/code-action/code-action.contribution'; import { AIInlineCompletionsProvider } from './contrib/inline-completions/completeProvider'; import { IntelligentCompletionsContribution } from './contrib/intelligent-completions/intelligent-completions.contribution'; @@ -43,8 +54,22 @@ import { RenameCandidatesProviderRegistry } from './contrib/rename/rename.featur import { TerminalAIContribution } from './contrib/terminal/terminal-ai.contributon'; import { TerminalFeatureRegistry } from './contrib/terminal/terminal.feature.registry'; import { LanguageParserService } from './languages/service'; +import { MCPServerProxyService } from './mcp/mcp-server-proxy.service'; +import { MCPServerRegistry } from './mcp/mcp-server.feature.registry'; +import { CreateNewFileWithTextTool } from './mcp/tools/createNewFileWithText'; +import { FindFilesByNameSubstringTool } from './mcp/tools/findFilesByNameSubstring'; +import { GetCurrentFilePathTool } from './mcp/tools/getCurrentFilePath'; +import { GetDiagnosticsByPathTool } from './mcp/tools/getDiagnosticsByPath'; +import { GetFileTextByPathTool } from './mcp/tools/getFileTextByPath'; +import { GetOpenEditorFileDiagnosticsTool } from './mcp/tools/getOpenEditorFileDiagnostics'; +import { GetOpenEditorFileTextTool } from './mcp/tools/getOpenEditorFileText'; +import { GetSelectedTextTool } from './mcp/tools/getSelectedText'; +import { ListDirTool } from './mcp/tools/listDir'; +import { ReadFileTool } from './mcp/tools/readFile'; +import { ReplaceOpenEditorFileByDiffPreviewerTool } from './mcp/tools/replaceOpenEditorFileByDiffPreviewer'; +import { RunTerminalCommandTool } from './mcp/tools/runTerminalCmd'; import { AINativePreferencesContribution } from './preferences'; -import { AINativeCoreContribution } from './types'; +import { AINativeCoreContribution, MCPServerContribution, TokenMCPServerRegistry } from './types'; import { InlineChatFeatureRegistry } from './widget/inline-chat/inline-chat.feature.registry'; import { InlineChatService } from './widget/inline-chat/inline-chat.service'; import { InlineDiffService } from './widget/inline-diff'; @@ -59,7 +84,7 @@ export class AINativeModule extends BrowserModule { this.aiNativeConfig.setAINativeModuleLoaded(true); } - contributionProvider = AINativeCoreContribution; + contributionProvider = [AINativeCoreContribution, MCPServerContribution]; providers: Provider[] = [ AINativeBrowserContribution, InterfaceNavigationContribution, @@ -68,6 +93,37 @@ export class AINativeModule extends BrowserModule { AICodeActionContribution, AINativePreferencesContribution, IntelligentCompletionsContribution, + + // MCP Server Contributions START + ListDirTool, + ReadFileTool, + CreateNewFileWithTextTool, + GetSelectedTextTool, + GetOpenEditorFileDiagnosticsTool, + GetOpenEditorFileTextTool, + GetFileTextByPathTool, + GetCurrentFilePathTool, + FindFilesByNameSubstringTool, + GetDiagnosticsByPathTool, + RunTerminalCommandTool, + ReplaceOpenEditorFileByDiffPreviewerTool, + // MCP Server Contributions END + + // Context Service + LlmContextContribution, + { + token: LLMContextServiceToken, + useClass: LLMContextServiceImpl, + }, + + { + token: TokenMCPServerRegistry, + useClass: MCPServerRegistry, + }, + { + token: TokenMCPServerProxyService, + useClass: MCPServerProxyService, + }, { token: InlineChatFeatureRegistryToken, useClass: InlineChatFeatureRegistry, @@ -148,5 +204,13 @@ export class AINativeModule extends BrowserModule { token: AIBackSerivceToken, clientToken: ChatProxyServiceToken, }, + { + servicePath: MCPServerManagerPath, + token: MCPServerManager, + }, + { + clientToken: TokenMCPServerProxyService, + servicePath: SumiMCPServerProxyServicePath, + }, ]; } diff --git a/packages/ai-native/src/browser/mcp/mcp-server-proxy.service.ts b/packages/ai-native/src/browser/mcp/mcp-server-proxy.service.ts new file mode 100644 index 0000000000..55f2585ee6 --- /dev/null +++ b/packages/ai-native/src/browser/mcp/mcp-server-proxy.service.ts @@ -0,0 +1,53 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { ILogger } from '@opensumi/ide-core-browser'; +import { Emitter, Event } from '@opensumi/ide-core-common'; + +import { ISumiMCPServerBackend, SumiMCPServerProxyServicePath } from '../../common'; +import { IMCPServerProxyService } from '../../common/types'; +import { IMCPServerRegistry, TokenMCPServerRegistry } from '../types'; + +@Injectable() +export class MCPServerProxyService implements IMCPServerProxyService { + @Autowired(TokenMCPServerRegistry) + private readonly mcpServerRegistry: IMCPServerRegistry; + + @Autowired(ILogger) + private readonly logger: ILogger; + + @Autowired(SumiMCPServerProxyServicePath) + private readonly sumiMCPServerProxyService: ISumiMCPServerBackend; + + private readonly _onChangeMCPServers = new Emitter(); + public readonly onChangeMCPServers: Event = this._onChangeMCPServers.event; + + // 调用 OpenSumi 内部注册的 MCP 工具 + $callMCPTool(name: string, args: any) { + return this.mcpServerRegistry.callMCPTool(name, args); + } + + // 获取 OpenSumi 内部注册的 MCP tools + async $getMCPTools() { + const tools = await this.mcpServerRegistry.getMCPTools().map((tool) => + // 不要传递 handler + ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + providerName: 'sumi-builtin', + }), + ); + + this.logger.log('SUMI MCP tools', tools); + + return tools; + } + + // 通知前端 MCP 服务注册表发生了变化 + async $updateMCPServers() { + this._onChangeMCPServers.fire('update'); + } + + async getAllMCPTools() { + return this.sumiMCPServerProxyService.getAllMCPTools(); + } +} diff --git a/packages/ai-native/src/browser/mcp/mcp-server.feature.registry.ts b/packages/ai-native/src/browser/mcp/mcp-server.feature.registry.ts new file mode 100644 index 0000000000..af7b0d9d91 --- /dev/null +++ b/packages/ai-native/src/browser/mcp/mcp-server.feature.registry.ts @@ -0,0 +1,54 @@ +// OpenSumi as MCP Server 前端的代理服务 +import { Autowired, Injectable } from '@opensumi/di'; +import { IAIBackService, ILogger } from '@opensumi/ide-core-common'; + +import { IMCPServerRegistry, MCPLogger, MCPToolDefinition } from '../types'; + +class LoggerAdapter implements MCPLogger { + constructor(private readonly logger: ILogger) { } + + appendLine(message: string): void { + this.logger.log(message); + } +} + +@Injectable() +export class MCPServerRegistry implements IMCPServerRegistry { + private tools: MCPToolDefinition[] = []; + + @Autowired(ILogger) + private readonly baseLogger: ILogger; + + private get logger(): MCPLogger { + return new LoggerAdapter(this.baseLogger); + } + + registerMCPTool(tool: MCPToolDefinition): void { + this.tools.push(tool); + } + + getMCPTools(): MCPToolDefinition[] { + return this.tools; + } + + async callMCPTool( + name: string, + args: any, + ): Promise<{ + content: { type: string; text: string }[]; + isError?: boolean; + }> { + try { + const tool = this.tools.find((tool) => tool.name === name); + if (!tool) { + throw new Error(`MCP tool ${name} not found`); + } + return await tool.handler(args, this.logger); + } catch (error) { + return { + content: [{ type: 'text', text: `The tool ${name} failed to execute. Error: ${error}` }], + isError: true, + }; + } + } +} diff --git a/packages/ai-native/src/browser/mcp/mcp-tools-dialog.module.less b/packages/ai-native/src/browser/mcp/mcp-tools-dialog.module.less new file mode 100644 index 0000000000..72a6038062 --- /dev/null +++ b/packages/ai-native/src/browser/mcp/mcp-tools-dialog.module.less @@ -0,0 +1,44 @@ +.mcp_tools_dialog { + .dialog_title { + font-size: 16px; + font-weight: 600; + color: var(--foreground); + padding-bottom: 16px; + padding-top: 8px; + border-bottom: 1px solid var(--menu-separatorBackground); + } + + .tools_list { + max-height: calc(60vh - 53px); // 减去标题高度 + overflow: auto; + + .tool_item { + padding-top: 12px; + padding-bottom: 12px; + border-radius: 6px; + + &:hover { + background-color: var(--list-hoverBackground); + } + + .tool_name { + font-weight: 600; + color: var(--foreground); + margin-bottom: 8px; + } + + .tool_description { + font-size: 12px; + line-height: 1.5; + color: var(--descriptionForeground); + margin-bottom: 4px; + } + + .tool_provider { + font-size: 12px; + color: var(--descriptionForeground); + font-style: italic; + } + } + } +} \ No newline at end of file diff --git a/packages/ai-native/src/browser/mcp/mcp-tools-dialog.view.tsx b/packages/ai-native/src/browser/mcp/mcp-tools-dialog.view.tsx new file mode 100644 index 0000000000..d0184f830a --- /dev/null +++ b/packages/ai-native/src/browser/mcp/mcp-tools-dialog.view.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; + +import { MCPTool } from '../../common/types'; + +import styles from './mcp-tools-dialog.module.less'; + +interface MCPToolsDialogProps { + tools: MCPTool[]; +} + +export const MCPToolsDialog: React.FC = ({ tools }) => ( +
+
MCP Tools
+
+ {tools.map((tool) => ( +
+
{tool.name}
+
{tool.description}
+ {tool.providerName &&
Provider: {tool.providerName}
} +
+ ))} +
+
+); diff --git a/packages/ai-native/src/browser/mcp/tools/createNewFileWithText.ts b/packages/ai-native/src/browser/mcp/tools/createNewFileWithText.ts new file mode 100644 index 0000000000..b0269a698e --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/createNewFileWithText.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Autowired } from '@opensumi/di'; +import { Domain, URI, path } from '@opensumi/ide-core-common'; +import { IFileServiceClient } from '@opensumi/ide-file-service'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; + +import { IMCPServerRegistry, MCPLogger, MCPServerContribution, MCPToolDefinition } from '../../types'; + +const inputSchema = z.object({ + pathInProject: z.string().describe('The relative path where the file should be created'), + text: z.string().describe('The content to write into the new file'), +}); + +@Domain(MCPServerContribution) +export class CreateNewFileWithTextTool implements MCPServerContribution { + @Autowired(IWorkspaceService) + private readonly workspaceService: IWorkspaceService; + + @Autowired(IFileServiceClient) + private readonly fileService: IFileServiceClient; + + registerMCPServer(registry: IMCPServerRegistry): void { + registry.registerMCPTool(this.getToolDefinition()); + } + + getToolDefinition(): MCPToolDefinition { + return { + name: 'create_new_file_with_text', + description: + 'Creates a new file at the specified path within the project directory and populates it with the provided text. ' + + 'Use this tool to generate new files in your project structure. ' + + 'Requires two parameters: ' + + '- pathInProject: The relative path where the file should be created ' + + '- text: The content to write into the new file ' + + 'Returns one of two possible responses: ' + + '"ok" if the file was successfully created and populated, ' + + '"can\'t find project dir" if the project directory cannot be determined. ' + + 'Note: Creates any necessary parent directories automatically.', + inputSchema: zodToJsonSchema(inputSchema), + handler: this.handler.bind(this), + }; + } + + private async handler(args: z.infer, logger: MCPLogger) { + try { + // 获取工作区根目录 + const workspaceRoots = this.workspaceService.tryGetRoots(); + if (!workspaceRoots || workspaceRoots.length === 0) { + logger.appendLine('Error: Cannot determine project directory'); + return { + content: [{ type: 'text', text: "can't find project dir" }], + isError: true, + }; + } + + // 构建完整的文件路径 + const rootUri = URI.parse(workspaceRoots[0].uri); + const fullPath = path.join(rootUri.codeUri.fsPath, args.pathInProject); + const fileUri = URI.file(fullPath); + + // 创建父目录 + const parentDir = path.dirname(fullPath); + const parentUri = URI.file(parentDir); + await this.fileService.createFolder(parentUri.toString()); + + // 写入文件内容 + await this.fileService.createFile(fileUri.toString(), { content: args.text }); + + logger.appendLine(`Successfully created file at: ${args.pathInProject}`); + return { + content: [{ type: 'text', text: 'ok' }], + }; + } catch (error) { + logger.appendLine(`Error during file creation: ${error}`); + return { + content: [{ type: 'text', text: 'unknown error' }], + isError: true, + }; + } + } +} diff --git a/packages/ai-native/src/browser/mcp/tools/findFilesByNameSubstring.ts b/packages/ai-native/src/browser/mcp/tools/findFilesByNameSubstring.ts new file mode 100644 index 0000000000..88493f630c --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/findFilesByNameSubstring.ts @@ -0,0 +1,93 @@ +import * as path from 'path'; + +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { Domain, URI } from '@opensumi/ide-core-common'; +import { IFileSearchService } from '@opensumi/ide-file-search/lib/common'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; + +import { IMCPServerRegistry, MCPLogger, MCPServerContribution, MCPToolDefinition } from '../../types'; + +const inputSchema = z.object({ + nameSubstring: z.string().describe('The substring to search for in file names'), +}); + +@Domain(MCPServerContribution) +export class FindFilesByNameSubstringTool implements MCPServerContribution { + @Autowired(IWorkspaceService) + private readonly workspaceService: IWorkspaceService; + + @Autowired(IFileSearchService) + private readonly fileSearchService: IFileSearchService; + + registerMCPServer(registry: IMCPServerRegistry): void { + registry.registerMCPTool(this.getToolDefinition()); + } + + getToolDefinition(): MCPToolDefinition { + return { + name: 'find_files_by_name_substring', + description: + 'Searches for all files in the project whose names contain the specified substring (case-insensitive). ' + + 'Use this tool to locate files when you know part of the filename. ' + + 'Requires a nameSubstring parameter for the search term. ' + + 'Returns a JSON array of objects containing file information: ' + + '- path: Path relative to project root ' + + '- name: File name ' + + 'Returns an empty array ([]) if no matching files are found. ' + + 'Note: Only searches through files within the project directory, excluding libraries and external dependencies.', + inputSchema: zodToJsonSchema(inputSchema), + handler: this.handler.bind(this), + }; + } + + private async handler(args: z.infer, logger: MCPLogger) { + try { + // 获取工作区根目录 + const workspaceRoots = this.workspaceService.tryGetRoots(); + if (!workspaceRoots || workspaceRoots.length === 0) { + logger.appendLine('Error: Cannot determine project directory'); + return { + content: [{ type: 'text', text: '[]' }], + isError: true, + }; + } + + // 使用 OpenSumi 的文件搜索 API + const searchPattern = `**/*${args.nameSubstring}*`; + const searchResults = await this.fileSearchService.find(searchPattern, { + rootUris: [workspaceRoots[0].uri], + excludePatterns: ['**/node_modules/**'], + limit: 1000, + }); + + // 转换结果为所需的格式 + const results = searchResults.map((file) => { + const uri = URI.parse(file); + const rootUri = URI.parse(workspaceRoots[0].uri); + const relativePath = path.relative(rootUri.codeUri.fsPath, uri.codeUri.fsPath); + const fileName = path.basename(uri.codeUri.fsPath); + return { + path: relativePath, + name: fileName, + }; + }); + + // 将结果转换为 JSON 字符串 + const resultJson = JSON.stringify(results, null, 2); + logger.appendLine(`Found ${results.length} files matching "${args.nameSubstring}"`); + + return { + content: [{ type: 'text', text: resultJson }], + }; + } catch (error) { + logger.appendLine(`Error during file search: ${error}`); + return { + content: [{ type: 'text', text: '[]' }], + isError: true, + }; + } + } +} diff --git a/packages/ai-native/src/browser/mcp/tools/getCurrentFilePath.ts b/packages/ai-native/src/browser/mcp/tools/getCurrentFilePath.ts new file mode 100644 index 0000000000..91e0e21755 --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/getCurrentFilePath.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { Domain } from '@opensumi/ide-core-common'; +import { WorkbenchEditorService } from '@opensumi/ide-editor'; + +import { IMCPServerRegistry, MCPLogger, MCPServerContribution, MCPToolDefinition } from '../../types'; + +const inputSchema = z.object({}); + +@Domain(MCPServerContribution) +export class GetCurrentFilePathTool implements MCPServerContribution { + @Autowired(WorkbenchEditorService) + private readonly editorService: WorkbenchEditorService; + + registerMCPServer(registry: IMCPServerRegistry): void { + registry.registerMCPTool(this.getToolDefinition()); + } + + getToolDefinition(): MCPToolDefinition { + return { + name: 'get_open_in_editor_file_path', + description: + 'Retrieves the absolute path of the currently active file in the VS Code editor. ' + + 'Use this tool to get the file location for tasks requiring file path information. ' + + 'Returns an empty string if no file is currently open.', + inputSchema: zodToJsonSchema(inputSchema), + handler: this.handler.bind(this), + }; + } + + private async handler(args: z.infer, logger: MCPLogger) { + const editor = this.editorService.currentEditor; + if (!editor || !editor.currentUri) { + logger.appendLine('Error: No active text editor found'); + return { + content: [{ type: 'text', text: '' }], + }; + } + + const path = editor.currentUri.toString(); + logger.appendLine(`Current file path: ${path}`); + + return { + content: [{ type: 'text', text: path }], + }; + } +} diff --git a/packages/ai-native/src/browser/mcp/tools/getDiagnosticsByPath.ts b/packages/ai-native/src/browser/mcp/tools/getDiagnosticsByPath.ts new file mode 100644 index 0000000000..0bccc2e1f3 --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/getDiagnosticsByPath.ts @@ -0,0 +1,123 @@ +import * as path from 'path'; + +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { Domain, URI } from '@opensumi/ide-core-common'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; +import { URI as MonacoURI } from '@opensumi/monaco-editor-core/esm/vs/base/common/uri'; +import { IMarkerService, MarkerSeverity } from '@opensumi/monaco-editor-core/esm/vs/platform/markers/common/markers'; + +import { IMCPServerRegistry, MCPLogger, MCPServerContribution, MCPToolDefinition } from '../../types'; + +const inputSchema = z.object({ + filePathInProject: z.string().describe('The relative path to the file to get diagnostics for'), +}); + +@Domain(MCPServerContribution) +export class GetDiagnosticsByPathTool implements MCPServerContribution { + @Autowired(IWorkspaceService) + private readonly workspaceService: IWorkspaceService; + + @Autowired(IMarkerService) + private readonly markerService: IMarkerService; + + registerMCPServer(registry: IMCPServerRegistry): void { + registry.registerMCPTool(this.getToolDefinition()); + } + + getToolDefinition(): MCPToolDefinition { + return { + name: 'get_diagnostics_by_path', + description: + 'Retrieves diagnostic information (errors, warnings, etc.) from a specific file in the project. ' + + 'Use this tool to get information about problems in any project file. ' + + 'IMPORTANT: This tool should be called after any code generation or modification operations to verify and fix potential issues. ' + + 'Requires a filePathInProject parameter specifying the target file path relative to project root. ' + + 'Returns a JSON-formatted list of diagnostics, where each entry contains: ' + + '- path: The file path where the diagnostic was found ' + + '- line: The line number (1-based) of the diagnostic ' + + '- severity: The severity level ("error", "warning", "information", or "hint") ' + + '- message: The diagnostic message ' + + "Returns an empty list ([]) if no diagnostics are found or the file doesn't exist. " + + 'Best Practice: Always check diagnostics after code generation to ensure code quality and fix any issues immediately. ' + + 'Use this tool in combination with get_open_in_editor_file_diagnostics to verify all affected files after code changes. ' + + 'Diagnostic Severity Handling Guidelines: ' + + '- "error": Must be fixed immediately as they indicate critical issues that will prevent code from working correctly. ' + + '- "warning": For user code, preserve unless the warning indicates a clear improvement opportunity. For generated code, optimize to remove warnings. ' + + '- "information"/"hint": For user code, preserve as they might reflect intentional patterns. For generated code, optimize if it improves code quality without changing functionality.', + inputSchema: zodToJsonSchema(inputSchema), + handler: this.handler.bind(this), + }; + } + + private async handler(args: z.infer, logger: MCPLogger) { + try { + // 获取工作区根目录 + const workspaceRoots = this.workspaceService.tryGetRoots(); + if (!workspaceRoots || workspaceRoots.length === 0) { + logger.appendLine('Error: Cannot determine project directory'); + return { + content: [{ type: 'text', text: '[]' }], + isError: true, + }; + } + + // 构建完整的文件路径 + const rootUri = URI.parse(workspaceRoots[0].uri); + const fullPath = path.join(rootUri.codeUri.fsPath, args.filePathInProject); + const uri = MonacoURI.file(fullPath); + + // 检查文件是否在项目目录内 + const relativePath = path.relative(rootUri.codeUri.fsPath, fullPath); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + logger.appendLine('Error: File is outside of project scope'); + return { + content: [{ type: 'text', text: '[]' }], + isError: true, + }; + } + + // 获取文件的诊断信息 + const markers = this.markerService.read({ resource: uri }); + + // 转换诊断信息 + const diagnosticInfos = markers.map((marker) => ({ + path: args.filePathInProject, + line: marker.startLineNumber, + severity: this.getSeverityString(marker.severity), + message: marker.message, + })); + + // 将结果转换为 JSON 字符串 + const resultJson = JSON.stringify(diagnosticInfos, null, 2); + logger.appendLine(`Found ${diagnosticInfos.length} diagnostics in ${args.filePathInProject}`); + + return { + content: [{ type: 'text', text: resultJson }], + }; + } catch (error) { + logger.appendLine(`Error getting diagnostics: ${error}`); + return { + content: [{ type: 'text', text: '[]' }], + isError: true, + }; + } + } + + private getSeverityString(severity: MarkerSeverity): string { + switch (severity) { + case MarkerSeverity.Error: + return 'error'; + case MarkerSeverity.Warning: + return 'warning'; + case MarkerSeverity.Info: + return 'information'; + case MarkerSeverity.Hint: + return 'hint'; + default: + return 'unknown'; + } + } +} diff --git a/packages/ai-native/src/browser/mcp/tools/getFileTextByPath.ts b/packages/ai-native/src/browser/mcp/tools/getFileTextByPath.ts new file mode 100644 index 0000000000..7233b46aef --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/getFileTextByPath.ts @@ -0,0 +1,97 @@ +import * as path from 'path'; + +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { Domain, URI } from '@opensumi/ide-core-common'; +import { IFileServiceClient } from '@opensumi/ide-file-service'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; + +import { IMCPServerRegistry, MCPLogger, MCPServerContribution, MCPToolDefinition } from '../../types'; + +const inputSchema = z.object({ + pathInProject: z.string().describe('The file location relative to project root'), +}); + +@Domain(MCPServerContribution) +export class GetFileTextByPathTool implements MCPServerContribution { + @Autowired(IWorkspaceService) + private readonly workspaceService: IWorkspaceService; + + @Autowired(IFileServiceClient) + private readonly fileService: IFileServiceClient; + + registerMCPServer(registry: IMCPServerRegistry): void { + registry.registerMCPTool(this.getToolDefinition()); + } + + getToolDefinition(): MCPToolDefinition { + return { + name: 'get_file_text_by_path', + description: + 'Retrieves the text content of a file using its path relative to project root. ' + + "Use this tool to read file contents when you have the file's project-relative path. " + + 'Requires a pathInProject parameter specifying the file location from project root. ' + + 'Returns one of these responses: ' + + "- The file's content if the file exists and belongs to the project " + + '- error "project dir not found" if project directory cannot be determined ' + + '- error "file not found" if the file doesn\'t exist or is outside project scope ' + + 'Note: Automatically refreshes the file system before reading', + inputSchema: zodToJsonSchema(inputSchema), + handler: this.handler.bind(this), + }; + } + + private async handler(args: z.infer, logger: MCPLogger) { + try { + // 获取工作区根目录 + const workspaceRoots = this.workspaceService.tryGetRoots(); + if (!workspaceRoots || workspaceRoots.length === 0) { + logger.appendLine('Error: Cannot determine project directory'); + return { + content: [{ type: 'text', text: 'project dir not found' }], + isError: true, + }; + } + + // 构建完整的文件路径 + const rootUri = URI.parse(workspaceRoots[0].uri); + const fullPath = path.join(rootUri.codeUri.fsPath, args.pathInProject); + const fileUri = URI.file(fullPath); + + // 检查文件是否在项目目录内 + const relativePath = path.relative(rootUri.codeUri.fsPath, fullPath); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + logger.appendLine('Error: File is outside of project scope'); + return { + content: [{ type: 'text', text: 'file not found' }], + isError: true, + }; + } + + // 检查文件是否存在并读取内容 + try { + const result = await this.fileService.readFile(fileUri.toString()); + const content = result.content.toString(); + logger.appendLine(`Successfully read file: ${args.pathInProject}`); + + return { + content: [{ type: 'text', text: content }], + }; + } catch (error) { + logger.appendLine('Error: File does not exist'); + return { + content: [{ type: 'text', text: 'file not found' }], + isError: true, + }; + } + } catch (error) { + logger.appendLine(`Error reading file: ${error}`); + return { + content: [{ type: 'text', text: 'file not found' }], + isError: true, + }; + } + } +} diff --git a/packages/ai-native/src/browser/mcp/tools/getOpenEditorFileDiagnostics.ts b/packages/ai-native/src/browser/mcp/tools/getOpenEditorFileDiagnostics.ts new file mode 100644 index 0000000000..8769983615 --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/getOpenEditorFileDiagnostics.ts @@ -0,0 +1,121 @@ +import * as path from 'path'; + +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { Domain, URI } from '@opensumi/ide-core-common'; +import { WorkbenchEditorService } from '@opensumi/ide-editor'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; +import { URI as MonacoURI } from '@opensumi/monaco-editor-core/esm/vs/base/common/uri'; +import { IMarkerService, MarkerSeverity } from '@opensumi/monaco-editor-core/esm/vs/platform/markers/common/markers'; + +import { IMCPServerRegistry, MCPLogger, MCPServerContribution, MCPToolDefinition } from '../../types'; + +const inputSchema = z.object({}); + +@Domain(MCPServerContribution) +export class GetOpenEditorFileDiagnosticsTool implements MCPServerContribution { + @Autowired(WorkbenchEditorService) + private readonly editorService: WorkbenchEditorService; + + @Autowired(IWorkspaceService) + private readonly workspaceService: IWorkspaceService; + + @Autowired(IMarkerService) + private readonly markerService: IMarkerService; + + registerMCPServer(registry: IMCPServerRegistry): void { + registry.registerMCPTool(this.getToolDefinition()); + } + + getToolDefinition(): MCPToolDefinition { + return { + name: 'get_open_in_editor_file_diagnostics', + description: + 'Retrieves diagnostic information (errors, warnings, etc.) from the currently active file in VS Code editor. ' + + 'Use this tool to get information about problems in your current file. ' + + 'IMPORTANT: This tool should be called after any code generation or modification operations to verify and fix potential issues. ' + + 'Returns a JSON-formatted list of diagnostics, where each entry contains: ' + + '- path: The file path where the diagnostic was found ' + + '- line: The line number (1-based) of the diagnostic ' + + '- severity: The severity level ("error", "warning", "information", or "hint") ' + + '- message: The diagnostic message ' + + 'Returns an empty list ([]) if no diagnostics are found or no file is open. ' + + 'Best Practice: Always check diagnostics after code generation to ensure code quality and fix any issues immediately. ' + + 'Diagnostic Severity Handling Guidelines: ' + + '- "error": Must be fixed immediately as they indicate critical issues that will prevent code from working correctly. ' + + '- "warning": For user code, preserve unless the warning indicates a clear improvement opportunity. For generated code, optimize to remove warnings. ' + + '- "information"/"hint": For user code, preserve as they might reflect intentional patterns. For generated code, optimize if it improves code quality without changing functionality.', + inputSchema: zodToJsonSchema(inputSchema), + handler: this.handler.bind(this), + }; + } + + private async handler(args: z.infer, logger: MCPLogger) { + try { + // 获取当前活动的编辑器 + const editor = this.editorService.currentEditor; + if (!editor || !editor.currentUri) { + logger.appendLine('Error: No active text editor found'); + return { + content: [{ type: 'text', text: '[]' }], + isError: true, + }; + } + + // 获取工作区根目录 + const workspaceRoots = this.workspaceService.tryGetRoots(); + if (!workspaceRoots || workspaceRoots.length === 0) { + logger.appendLine('Error: Cannot determine project directory'); + return { + content: [{ type: 'text', text: '[]' }], + isError: true, + }; + } + + // 获取当前文件的诊断信息 + const monacoUri = MonacoURI.parse(editor.currentUri.toString()); + const markers = this.markerService.read({ resource: monacoUri }); + const rootUri = URI.parse(workspaceRoots[0].uri); + const relativePath = path.relative(rootUri.codeUri.fsPath, editor.currentUri.codeUri.fsPath); + + // 转换诊断信息 + const diagnosticInfos = markers.map((marker) => ({ + path: relativePath, + line: marker.startLineNumber, + severity: this.getSeverityString(marker.severity), + message: marker.message, + })); + + // 将结果转换为 JSON 字符串 + const resultJson = JSON.stringify(diagnosticInfos, null, 2); + logger.appendLine(`Found ${diagnosticInfos.length} diagnostics in current file`); + + return { + content: [{ type: 'text', text: resultJson }], + }; + } catch (error) { + logger.appendLine(`Error getting diagnostics: ${error}`); + return { + content: [{ type: 'text', text: '[]' }], + isError: true, + }; + } + } + + private getSeverityString(severity: MarkerSeverity): string { + switch (severity) { + case MarkerSeverity.Error: + return 'error'; + case MarkerSeverity.Warning: + return 'warning'; + case MarkerSeverity.Info: + return 'information'; + case MarkerSeverity.Hint: + return 'hint'; + default: + return 'unknown'; + } + } +} diff --git a/packages/ai-native/src/browser/mcp/tools/getOpenEditorFileText.ts b/packages/ai-native/src/browser/mcp/tools/getOpenEditorFileText.ts new file mode 100644 index 0000000000..79d6abdd62 --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/getOpenEditorFileText.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { Domain } from '@opensumi/ide-core-common'; +import { WorkbenchEditorService } from '@opensumi/ide-editor'; + +import { IMCPServerRegistry, MCPLogger, MCPServerContribution, MCPToolDefinition } from '../../types'; + +const inputSchema = z.object({}); + +@Domain(MCPServerContribution) +export class GetOpenEditorFileTextTool implements MCPServerContribution { + @Autowired(WorkbenchEditorService) + private readonly editorService: WorkbenchEditorService; + + registerMCPServer(registry: IMCPServerRegistry): void { + registry.registerMCPTool(this.getToolDefinition()); + } + + getToolDefinition(): MCPToolDefinition { + return { + name: 'get_open_in_editor_file_text', + description: + 'Retrieves the complete text content of the currently active file in the IDE editor. ' + + "Use this tool to access and analyze the file's contents for tasks such as code review, content inspection, or text processing. " + + 'Returns empty string if no file is currently open.', + inputSchema: zodToJsonSchema(inputSchema), + handler: this.handler.bind(this), + }; + } + + private async handler(args: z.infer, logger: MCPLogger) { + const editor = this.editorService.currentEditor; + if (!editor || !editor.currentDocumentModel) { + logger.appendLine('Error: No active text editor found'); + return { + content: [{ type: 'text', text: '' }], + }; + } + + const document = editor.currentDocumentModel; + logger.appendLine(`Reading content from: ${document.uri.toString()}`); + const content = document.getText(); + + return { + content: [{ type: 'text', text: content }], + }; + } +} diff --git a/packages/ai-native/src/browser/mcp/tools/getSelectedText.ts b/packages/ai-native/src/browser/mcp/tools/getSelectedText.ts new file mode 100644 index 0000000000..eaa13c2db8 --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/getSelectedText.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { Domain } from '@opensumi/ide-core-common'; +import { WorkbenchEditorService } from '@opensumi/ide-editor'; + +import { IMCPServerRegistry, MCPLogger, MCPServerContribution, MCPToolDefinition } from '../../types'; + +const inputSchema = z.object({}); + +@Domain(MCPServerContribution) +export class GetSelectedTextTool implements MCPServerContribution { + @Autowired(WorkbenchEditorService) + private readonly editorService: WorkbenchEditorService; + + registerMCPServer(registry: IMCPServerRegistry): void { + registry.registerMCPTool(this.getToolDefinition()); + } + + getToolDefinition(): MCPToolDefinition { + return { + name: 'get_selected_in_editor_text', + description: + 'Retrieves the currently selected text from the active editor in VS Code. ' + + 'Use this tool when you need to access and analyze text that has been highlighted/selected by the user. ' + + 'Returns an empty string if no text is selected or no editor is open.', + inputSchema: zodToJsonSchema(inputSchema), + handler: this.handler.bind(this), + }; + } + + private async handler(args: z.infer, logger: MCPLogger) { + const editor = this.editorService.currentEditor; + if (!editor || !editor.monacoEditor) { + logger.appendLine('Error: No active text editor found'); + return { + content: [{ type: 'text', text: '' }], + }; + } + + const selection = editor.monacoEditor.getSelection(); + if (!selection) { + logger.appendLine('No text is currently selected'); + return { + content: [{ type: 'text', text: '' }], + }; + } + + const selectedText = editor.monacoEditor.getModel()?.getValueInRange(selection) || ''; + logger.appendLine(`Retrieved selected text of length: ${selectedText.length}`); + + return { + content: [{ type: 'text', text: selectedText }], + }; + } +} diff --git a/packages/ai-native/src/browser/mcp/tools/handlers/ListDir.ts b/packages/ai-native/src/browser/mcp/tools/handlers/ListDir.ts new file mode 100644 index 0000000000..8a3a94b84b --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/handlers/ListDir.ts @@ -0,0 +1,117 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { AppConfig, Throttler, URI } from '@opensumi/ide-core-browser'; +import { IFileServiceClient } from '@opensumi/ide-file-service'; + +/** + * 并发限制器 + */ +class ConcurrencyLimiter { + private maxConcurrent: number; + private currentCount: number; + private pendingQueue: (() => void)[]; + /** + * @param {number} maxConcurrent - 最大并发数 + */ + constructor(maxConcurrent) { + this.maxConcurrent = maxConcurrent; // 最大并发数 + this.currentCount = 0; // 当前执行的任务数 + this.pendingQueue = []; // 等待执行的任务队列 + } + + /** + * 执行异步任务 + * @param {Function} fn - 要执行的异步函数 + * @returns {Promise} 任务执行的结果 + */ + async execute(fn) { + // 如果当前执行的任务数达到最大并发数,则加入等待队列 + if (this.currentCount >= this.maxConcurrent) { + await new Promise((resolve) => this.pendingQueue.push(resolve)); + } + + this.currentCount++; + + try { + // 执行任务 + const result = await fn(); + return result; + } finally { + this.currentCount--; + // 如果等待队列中有任务,则允许执行下一个任务 + if (this.pendingQueue.length > 0) { + const next = this.pendingQueue.shift(); + next?.(); + } + } + } +} + +@Injectable() +export class ListDirHandler { + private readonly MAX_FILE_SIZE = 1024 * 1024; // 1MB + private readonly MAX_INDEXED_FILES = 50; + @Autowired(AppConfig) + private readonly appConfig: AppConfig; + + @Autowired(IFileServiceClient) + private readonly fileSystemService: IFileServiceClient; + + async handler(args: { relativeWorkspacePath: string }) { + const { relativeWorkspacePath } = args; + if (!relativeWorkspacePath) { + throw new Error('No list dir parameters provided. Need to give at least the path.'); + } + + // 解析相对路径 + const absolutePath = `${this.appConfig.workspaceDir}/${relativeWorkspacePath}`; + const fileStat = await this.fileSystemService.getFileStat(absolutePath, true); + // 验证路径有效性 + if (!fileStat || !fileStat.isDirectory) { + throw new Error(`Could not find file ${relativeWorkspacePath} in the workspace.`); + } + // 过滤符合大小限制的文件 + const filesWithinSizeLimit = + fileStat.children + ?.filter((file) => !file.isDirectory && file.size !== void 0 && file.size <= this.MAX_FILE_SIZE) + .slice(0, this.MAX_INDEXED_FILES) || []; + + // 记录需要分析的文件名 + const filesToAnalyze = new Set(filesWithinSizeLimit.map((file) => new URI(file.uri).displayName)); + + // 创建并发限制器 + const concurrencyLimiter = new ConcurrencyLimiter(4); + // 处理所有文件信息 + const fileInfos = await Promise.all( + fileStat.children + ?.sort((a, b) => b.lastModification - a.lastModification) + .map(async (file) => { + const uri = new URI(file.uri); + const filePath = `${absolutePath}/${uri.displayName}`; + let lineCount: number | undefined; + + // 如果文件需要分析,则计算行数 + if (filesToAnalyze.has(uri.displayName)) { + lineCount = await concurrencyLimiter.execute(async () => this.countFileLines(filePath)); + } + return { + name: uri.displayName, + isDirectory: file.isDirectory, + size: file.size, + lastModified: file.lastModification, + numChildren: file.children?.length, + numLines: lineCount, + }; + }) || [], + ); + // TODO: 过滤忽略文件 + return { + files: fileInfos, + directoryRelativeWorkspacePath: relativeWorkspacePath, + }; + } + + async countFileLines(filePath: string) { + const file = await this.fileSystemService.readFile(URI.file(filePath).toString()); + return file.toString().split('\n').length; + } +} diff --git a/packages/ai-native/src/browser/mcp/tools/handlers/ReadFile.ts b/packages/ai-native/src/browser/mcp/tools/handlers/ReadFile.ts new file mode 100644 index 0000000000..87614c4b89 --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/handlers/ReadFile.ts @@ -0,0 +1,174 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { FileSearchQuickCommandHandler } from '@opensumi/ide-addons/lib/browser/file-search.contribution'; +import { AppConfig } from '@opensumi/ide-core-browser'; +import { CancellationToken, URI } from '@opensumi/ide-core-common'; +import { IEditorDocumentModelRef, IEditorDocumentModelService } from '@opensumi/ide-editor/lib/browser'; +import { IFileServiceClient } from '@opensumi/ide-file-service'; + +@Injectable() +export class FileHandler { + private static readonly MAX_FILE_SIZE_BYTES = 2e6; + private static readonly MAX_LINES = 250; + private static readonly MAX_CHARS = 1e5; + private static readonly NEWLINE = '\n'; + + @Autowired(IEditorDocumentModelService) + protected modelService: IEditorDocumentModelService; + + @Autowired(FileSearchQuickCommandHandler) + protected fileSearchQuickCommandHandler: FileSearchQuickCommandHandler; + + @Autowired(AppConfig) + protected appConfig: AppConfig; + + @Autowired(IFileServiceClient) + protected fileSystemService: IFileServiceClient; + + async findSimilarFiles(filePath: string, maxResults: number): Promise { + const items = await this.fileSearchQuickCommandHandler.getQueryFiles(filePath, new Set(), CancellationToken.None); + return items + .slice(0, maxResults) + .map((item) => item.getUri()?.codeUri.fsPath) + .filter(Boolean) as string[]; + } + // TODO: 错误应该给模型? + private createFileNotFoundError(filePath: string, similarFiles: string[]): Error { + const errorMessage = + similarFiles.length > 0 + ? `Could not find file '${filePath}'. Did you mean one of:\n${similarFiles + .map((file) => `- ${file}`) + .join('\n')}` + : `Could not find file '${filePath}' in the workspace.`; + + return new Error( + JSON.stringify({ + clientVisibleErrorMessage: errorMessage, + modelVisibleErrorMessage: errorMessage, + actualErrorMessage: `File not found: ${filePath}`, + }), + ); + } + + private createFileTooLargeError(fileSizeMB: string, fileStatsSize: number): Error { + return new Error( + JSON.stringify({ + clientVisibleErrorMessage: `File is too large, >${fileSizeMB}MB`, + modelVisibleErrorMessage: `The file is too large to read, was >${fileSizeMB}MB`, + actualErrorMessage: `File is too large to read, was >${fileSizeMB}MB, size: ${fileStatsSize} bytes`, + }), + ); + } + + private trimContent(content: string, maxChars: number): string { + return content.slice(0, maxChars).split(FileHandler.NEWLINE).slice(0, -1).join(FileHandler.NEWLINE); + } + + private getLineRange( + fileParams: { + startLineOneIndexed?: number; + endLineOneIndexedInclusive?: number; + }, + forceLimit: boolean, + ): { start: number; end: number; didShorten: boolean; didSetDefault: boolean } { + let start = fileParams.startLineOneIndexed ?? 1; + let end = fileParams.endLineOneIndexedInclusive ?? start + FileHandler.MAX_LINES - 1; + let didShorten = false; + let didSetDefault = false; + + if (forceLimit) { + return { start, end, didShorten, didSetDefault }; + } + + if (fileParams.endLineOneIndexedInclusive === undefined || fileParams.startLineOneIndexed === undefined) { + start = 1; + end = FileHandler.MAX_LINES; + didSetDefault = true; + } else if (fileParams.endLineOneIndexedInclusive - fileParams.startLineOneIndexed > FileHandler.MAX_LINES) { + end = fileParams.startLineOneIndexed + FileHandler.MAX_LINES; + didShorten = true; + } + + return { start, end, didShorten, didSetDefault }; + } + + async readFile(fileParams: { + relativeWorkspacePath: string; + readEntireFile: boolean; + fileIsAllowedToBeReadEntirely?: boolean; + startLineOneIndexed?: number; + endLineOneIndexedInclusive?: number; + }) { + if (!fileParams) { + throw new Error('No read file parameters provided. Need to give at least the path.'); + } + + const uri = new URI(`${this.appConfig.workspaceDir}/${fileParams.relativeWorkspacePath}`); + if (!uri) { + const similarFiles = await this.findSimilarFiles(fileParams.relativeWorkspacePath, 3); + throw this.createFileNotFoundError(fileParams.relativeWorkspacePath, similarFiles); + } + + const fileSizeMB = (FileHandler.MAX_FILE_SIZE_BYTES / 1e6).toFixed(2); + const fileStats = await this.fileSystemService.getFileStat(uri.toString()); + + if (fileStats?.size && fileStats.size > FileHandler.MAX_FILE_SIZE_BYTES) { + throw this.createFileTooLargeError(fileSizeMB, fileStats.size); + } + + let modelReference: IEditorDocumentModelRef | undefined; + try { + modelReference = await this.modelService.createModelReference(uri); + const fileContent = modelReference.instance.getMonacoModel().getValue(); + const fileLines = fileContent.split(FileHandler.NEWLINE); + + const shouldLimitLines = !(fileParams.readEntireFile && fileParams.fileIsAllowedToBeReadEntirely); + const shouldForceLimitLines = fileParams.readEntireFile && !fileParams.fileIsAllowedToBeReadEntirely; + let didShortenCharRange = false; + + if (shouldLimitLines) { + const { + start, + end, + didShorten: didShortenLineRange, + didSetDefault: didSetDefaultLineRange, + } = this.getLineRange(fileParams, shouldForceLimitLines); + + const adjustedStart = Math.max(start, 1); + const adjustedEnd = Math.min(end, fileLines.length); + let selectedContent = fileLines.slice(adjustedStart - 1, adjustedEnd).join(FileHandler.NEWLINE); + + if (selectedContent.length > FileHandler.MAX_CHARS) { + didShortenCharRange = true; + selectedContent = this.trimContent(selectedContent, FileHandler.MAX_CHARS); + } + + return { + contents: selectedContent, + didDowngradeToLineRange: shouldForceLimitLines, + didShortenLineRange, + didShortenCharRange, + didSetDefaultLineRange, + fullFileContents: fileContent, + startLineOneIndexed: adjustedStart, + endLineOneIndexedInclusive: adjustedEnd, + relativeWorkspacePath: fileParams.relativeWorkspacePath, + }; + } + + let fullContent = fileContent; + if (fullContent.length > FileHandler.MAX_CHARS) { + didShortenCharRange = true; + fullContent = this.trimContent(fullContent, FileHandler.MAX_CHARS); + } + + return { + contents: fullContent, + fullFileContents: fileContent, + didDowngradeToLineRange: false, + didShortenCharRange, + }; + } finally { + modelReference?.dispose(); + } + } +} diff --git a/packages/ai-native/src/browser/mcp/tools/listDir.ts b/packages/ai-native/src/browser/mcp/tools/listDir.ts new file mode 100644 index 0000000000..89d53b10e2 --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/listDir.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Autowired } from '@opensumi/di'; +import { Domain } from '@opensumi/ide-core-common'; + +import { IMCPServerRegistry, MCPLogger, MCPServerContribution, MCPToolDefinition } from '../../types'; + +import { ListDirHandler } from './handlers/ListDir'; + +const inputSchema = z + .object({ + relative_workspace_path: z + .string() + .describe("Path to list contents of, relative to the workspace root. Ex: './' is the root of the workspace"), + explanation: z + .string() + .describe('One sentence explanation as to why this tool is being used, and how it contributes to the goal.'), + }) + .transform((data) => ({ + relativeWorkspacePath: data.relative_workspace_path, + })); + +@Domain(MCPServerContribution) +export class ListDirTool implements MCPServerContribution { + @Autowired(ListDirHandler) + private readonly listDirHandler: ListDirHandler; + + registerMCPServer(registry: IMCPServerRegistry): void { + registry.registerMCPTool(this.getToolDefinition()); + } + + getToolDefinition(): MCPToolDefinition { + return { + name: 'list_dir', + description: + 'List the contents of a directory. The quick tool to use for discovery, before using more targeted tools like semantic search or file reading. Useful to try to understand the file structure before diving deeper into specific files. Can be used to explore the codebase.', + inputSchema: zodToJsonSchema(inputSchema), + handler: this.handler.bind(this), + }; + } + + private async handler(args: z.infer, logger: MCPLogger) { + // TODO: 应该添加统一的 validate 逻辑 + args = inputSchema.parse(args); + const result = await this.listDirHandler.handler(args); + return { + content: [ + { + type: 'text', + text: `Contents of directory: + + + ${result.files + .map( + (file) => + `[${file.isDirectory ? 'dir' : 'file'}] ${file.name} ${ + file.isDirectory ? `(${file.numChildren ?? '?'} items)` : `(${file.size}KB, ${file.numLines} lines)` + } - ${new Date(file.lastModified).toLocaleString()}`, + ) + .join('\n')}`, + }, + ], + }; + } +} diff --git a/packages/ai-native/src/browser/mcp/tools/readFile.ts b/packages/ai-native/src/browser/mcp/tools/readFile.ts new file mode 100644 index 0000000000..f769eaca34 --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/readFile.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Autowired } from '@opensumi/di'; +import { Domain } from '@opensumi/ide-core-common'; + +import { IMCPServerRegistry, MCPLogger, MCPServerContribution, MCPToolDefinition } from '../../types'; + +import { FileHandler } from './handlers/ReadFile'; + +const inputSchema = z + .object({ + relative_workspace_path: z.string().describe('The path of the file to read, relative to the workspace root.'), + should_read_entire_file: z.boolean().describe('Whether to read the entire file. Defaults to false.'), + start_line_one_indexed: z.number().describe('The one-indexed line number to start reading from (inclusive).'), + end_line_one_indexed_inclusive: z.number().describe('The one-indexed line number to end reading at (inclusive).'), + explanation: z + .string() + .describe('One sentence explanation as to why this tool is being used, and how it contributes to the goal.'), + }) + .transform((data) => ({ + relativeWorkspacePath: data.relative_workspace_path, + readEntireFile: data.should_read_entire_file, + startLineOneIndexed: data.start_line_one_indexed, + endLineOneIndexedInclusive: data.end_line_one_indexed_inclusive, + })); + +@Domain(MCPServerContribution) +export class ReadFileTool implements MCPServerContribution { + @Autowired(FileHandler) + private readonly fileHandler: FileHandler; + + registerMCPServer(registry: IMCPServerRegistry): void { + registry.registerMCPTool(this.getToolDefinition()); + } + + getToolDefinition(): MCPToolDefinition { + return { + name: 'read_file', + description: `Read the contents of a file (and the outline). + +When using this tool to gather information, it's your responsibility to ensure you have the COMPLETE context. Each time you call this command you should: +1) Assess if contents viewed are sufficient to proceed with the task. +2) Take note of lines not shown. +3) If file contents viewed are insufficient, and you suspect they may be in lines not shown, proactively call the tool again to view those lines. +4) When in doubt, call this tool again to gather more information. Partial file views may miss critical dependencies, imports, or functionality. + +If reading a range of lines is not enough, you may choose to read the entire file. +Reading entire files is often wasteful and slow, especially for large files (i.e. more than a few hundred lines). So you should use this option sparingly. +Reading the entire file is not allowed in most cases. You are only allowed to read the entire file if it has been edited or manually attached to the conversation by the user.`, + inputSchema: zodToJsonSchema(inputSchema), + handler: this.handler.bind(this), + }; + } + + private async handler(args: z.infer, logger: MCPLogger) { + // TODO: 应该添加统一的 validate 逻辑 + args = inputSchema.parse(args); + const result = await this.fileHandler.readFile(args); + return { + content: [ + { + type: 'text', + text: result.didShortenLineRange + ? `Contents of ${result.relativeWorkspacePath}, from line ${args.startLineOneIndexed}-${ + args.endLineOneIndexedInclusive + }: + +\`\`\` +// ${result.relativeWorkspacePath!.split('/').pop()} +${result.contents} +\`\`\`` + : `Full contents of ${args.relativeWorkspacePath}: + +\`\`\` +${result.contents} +\`\`\``, + }, + ], + }; + } +} diff --git a/packages/ai-native/src/browser/mcp/tools/replaceOpenEditorFile.ts b/packages/ai-native/src/browser/mcp/tools/replaceOpenEditorFile.ts new file mode 100644 index 0000000000..ce00b85f65 --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/replaceOpenEditorFile.ts @@ -0,0 +1,80 @@ +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { Domain } from '@opensumi/ide-core-common'; +import { WorkbenchEditorService } from '@opensumi/ide-editor'; + +import { IMCPServerRegistry, MCPLogger, MCPServerContribution, MCPToolDefinition } from '../../types'; + +const inputSchema = z.object({ + text: z.string().describe('The new content to replace the entire file with'), +}); + +@Domain(MCPServerContribution) +export class ReplaceOpenEditorFileTool implements MCPServerContribution { + @Autowired(WorkbenchEditorService) + private readonly editorService: WorkbenchEditorService; + + registerMCPServer(registry: IMCPServerRegistry): void { + registry.registerMCPTool(this.getToolDefinition()); + } + + getToolDefinition(): MCPToolDefinition { + return { + name: 'replace_open_in_editor_file_text', + description: + 'Replaces the entire content of the currently active file in the IDE editor with specified new text. ' + + 'Use this tool when you need to completely overwrite the current file\'s content. ' + + 'Requires a text parameter containing the new content. ' + + 'Returns one of three possible responses: ' + + '"ok" if the file content was successfully replaced, ' + + '"no file open" if no editor is active, ' + + '"unknown error" if the operation fails.', + inputSchema: zodToJsonSchema(inputSchema), + handler: this.handler.bind(this), + }; + } + + private async handler(args: z.infer, logger: MCPLogger) { + try { + const editor = this.editorService.currentEditor; + if (!editor || !editor.monacoEditor) { + logger.appendLine('Error: No active text editor found'); + return { + content: [{ type: 'text', text: 'no file open' }], + isError: true, + }; + } + + // Get the model and its full range + const model = editor.monacoEditor.getModel(); + if (!model) { + logger.appendLine('Error: No model found for current editor'); + return { + content: [{ type: 'text', text: 'unknown error' }], + isError: true, + }; + } + + const fullRange = model.getFullModelRange(); + + // Execute the replacement + editor.monacoEditor.executeEdits('mcp.tool.replace-file', [{ + range: fullRange, + text: args.text, + }]); + + logger.appendLine('Successfully replaced file content'); + return { + content: [{ type: 'text', text: 'ok' }], + }; + } catch (error) { + logger.appendLine(`Error during file content replacement: ${error}`); + return { + content: [{ type: 'text', text: 'unknown error' }], + isError: true, + }; + } + } +} diff --git a/packages/ai-native/src/browser/mcp/tools/replaceOpenEditorFileByDiffPreviewer.ts b/packages/ai-native/src/browser/mcp/tools/replaceOpenEditorFileByDiffPreviewer.ts new file mode 100644 index 0000000000..a17cf01a4b --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/replaceOpenEditorFileByDiffPreviewer.ts @@ -0,0 +1,91 @@ +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { Domain } from '@opensumi/ide-core-common'; +import { WorkbenchEditorService } from '@opensumi/ide-editor'; +import { Selection, SelectionDirection } from '@opensumi/monaco-editor-core/esm/vs/editor/common/core/selection'; + +import { IMCPServerRegistry, MCPLogger, MCPServerContribution, MCPToolDefinition } from '../../types'; +import { LiveInlineDiffPreviewer } from '../../widget/inline-diff/inline-diff-previewer'; +import { InlineDiffController } from '../../widget/inline-diff/inline-diff.controller'; + +const inputSchema = z.object({ + text: z.string().describe('The new content to replace the entire file with'), +}); + +@Domain(MCPServerContribution) +export class ReplaceOpenEditorFileByDiffPreviewerTool implements MCPServerContribution { + @Autowired(WorkbenchEditorService) + private readonly editorService: WorkbenchEditorService; + + registerMCPServer(registry: IMCPServerRegistry): void { + registry.registerMCPTool(this.getToolDefinition()); + } + + getToolDefinition(): MCPToolDefinition { + return { + name: 'replace_open_in_editor_file_text', + description: + 'Replaces the entire content of the currently active file in the IDE editor with specified new text using diff previewer. ' + + "Use this tool when you need to completely overwrite the current file's content with diff preview. " + + 'Requires a text parameter containing the new content. ' + + 'Returns one of three possible responses: ' + + '"ok" if the file content was successfully replaced, ' + + '"no file open" if no editor is active, ' + + '"unknown error" if the operation fails.', + inputSchema: zodToJsonSchema(inputSchema), + handler: this.handler.bind(this), + }; + } + + private async handler(args: z.infer, logger: MCPLogger) { + try { + const editor = this.editorService.currentEditor; + if (!editor || !editor.monacoEditor) { + logger.appendLine('Error: No active text editor found'); + return { + content: [{ type: 'text', text: 'no file open' }], + isError: true, + }; + } + + // Get the model and its full range + const model = editor.monacoEditor.getModel(); + if (!model) { + logger.appendLine('Error: No model found for current editor'); + return { + content: [{ type: 'text', text: 'unknown error' }], + isError: true, + }; + } + + const fullRange = model.getFullModelRange(); + const inlineDiffHandler = InlineDiffController.get(editor.monacoEditor)!; + + // Create diff previewer + const previewer = inlineDiffHandler.createDiffPreviewer( + editor.monacoEditor, + Selection.fromRange(fullRange, SelectionDirection.LTR), + { + disposeWhenEditorClosed: false, + renderRemovedWidgetImmediately: true, + }, + ) as LiveInlineDiffPreviewer; + + // Set the new content + previewer.setValue(args.text); + + logger.appendLine('Successfully created diff preview with new content'); + return { + content: [{ type: 'text', text: 'ok' }], + }; + } catch (error) { + logger.appendLine(`Error during file content replacement: ${error}`); + return { + content: [{ type: 'text', text: 'unknown error' }], + isError: true, + }; + } + } +} diff --git a/packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts b/packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts new file mode 100644 index 0000000000..fa806de917 --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts @@ -0,0 +1,107 @@ +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Autowired } from '@opensumi/di'; +import { AppConfig } from '@opensumi/ide-core-browser'; +import { Deferred, Domain } from '@opensumi/ide-core-common'; +import { ITerminalController, ITerminalGroupViewService } from '@opensumi/ide-terminal-next/lib/common/controller'; + +import { IMCPServerRegistry, MCPLogger, MCPServerContribution, MCPToolDefinition } from '../../types'; + +const color = { + italic: '\x1b[3m', + reset: '\x1b[0m', +}; + +const inputSchema = z.object({ + command: z.string().describe('The terminal command to execute'), + is_background: z.boolean().describe('Whether the command should be run in the background'), + explanation: z + .string() + .describe('One sentence explanation as to why this command needs to be run and how it contributes to the goal.'), + require_user_approval: z + .boolean() + .describe( + "Whether the user must approve the command before it is executed. Only set this to false if the command is safe and if it matches the user's requirements for commands that should be executed automatically.", + ), +}); + +@Domain(MCPServerContribution) +export class RunTerminalCommandTool implements MCPServerContribution { + @Autowired(ITerminalController) + protected readonly terminalController: ITerminalController; + + @Autowired(AppConfig) + protected readonly appConfig: AppConfig; + + @Autowired(ITerminalGroupViewService) + protected readonly terminalView: ITerminalGroupViewService; + + private terminalId = 0; + + registerMCPServer(registry: IMCPServerRegistry): void { + registry.registerMCPTool(this.getToolDefinition()); + } + + getToolDefinition(): MCPToolDefinition { + return { + name: 'run_terminal_cmd', + description: + "PROPOSE a command to run on behalf of the user.\nIf you have this tool, note that you DO have the ability to run commands directly on the USER's system.\n\nAdhere to these rules:\n1. Based on the contents of the conversation, you will be told if you are in the same shell as a previous step or a new shell.\n2. If in a new shell, you should `cd` to the right directory and do necessary setup in addition to running the command.\n3. If in the same shell, the state will persist, no need to do things like `cd` to the same directory.\n4. For ANY commands that would use a pager, you should append ` | cat` to the command (or whatever is appropriate). You MUST do this for: git, less, head, tail, more, etc.\n5. For commands that are long running/expected to run indefinitely until interruption, please run them in the background. To run jobs in the background, set `is_background` to true rather than changing the details of the command.\n6. Dont include any newlines in the command.", + inputSchema: zodToJsonSchema(inputSchema), + handler: this.handler.bind(this), + }; + } + + getShellLaunchConfig(command: string) { + return { + name: `MCP:Terminal_${this.terminalId++}`, + cwd: this.appConfig.workspaceDir, + args: ['-c', command], + }; + } + + private async handler(args: z.infer, logger: MCPLogger) { + if (args.require_user_approval) { + // FIXME: support approval + } + + const terminalClient = await this.terminalController.createTerminalWithWidget({ + config: this.getShellLaunchConfig(args.command), + closeWhenExited: false, + }); + + this.terminalController.showTerminalPanel(); + + const result: { type: string; text: string }[] = []; + const def = new Deferred<{ isError?: boolean; content: { type: string; text: string }[] }>(); + + terminalClient.onOutput((e) => { + result.push({ + type: 'output', + text: e.data.toString(), + }); + }); + + terminalClient.onExit((e) => { + const isError = e.code !== 0; + def.resolve({ + isError, + content: result, + }); + + terminalClient.term.writeln( + `\n${color.italic}> Command ${args.command} executed successfully. Terminal will close in ${ + 3000 / 1000 + } seconds.${color.reset}\n`, + ); + + setTimeout(() => { + terminalClient.dispose(); + this.terminalView.removeWidget(terminalClient.id); + }, 3000); + }); + + return def.promise; + } +} diff --git a/packages/ai-native/src/browser/preferences/schema.ts b/packages/ai-native/src/browser/preferences/schema.ts index 17704b99d4..7339aef150 100644 --- a/packages/ai-native/src/browser/preferences/schema.ts +++ b/packages/ai-native/src/browser/preferences/schema.ts @@ -58,6 +58,66 @@ export const aiNativePreferenceSchema: PreferenceSchema = { type: 'boolean', default: false, }, + [AINativeSettingSectionsId.LLMModelSelection]: { + type: 'string', + default: 'deepseek', + enum: ['deepseek', 'anthropic', 'openai'], + description: localize('preference.ai.native.llm.model.selection.description'), + }, + [AINativeSettingSectionsId.DeepseekApiKey]: { + type: 'string', + default: '', + description: localize('preference.ai.native.deepseek.apiKey.description'), + }, + [AINativeSettingSectionsId.AnthropicApiKey]: { + type: 'string', + default: '', + description: localize('preference.ai.native.anthropic.apiKey.description'), + }, + [AINativeSettingSectionsId.OpenaiApiKey]: { + type: 'string', + default: '', + description: localize('preference.ai.native.openai.apiKey.description'), + }, + [AINativeSettingSectionsId.OpenaiBaseURL]: { + type: 'string', + default: '', + description: localize('preference.ai.native.openai.baseURL.description'), + }, + [AINativeSettingSectionsId.MCPServers]: { + type: 'array', + default: [], + description: localize('preference.ai.native.mcp.servers.description'), + items: { + type: 'object', + required: ['name', 'command', 'args'], + properties: { + name: { + type: 'string', + description: localize('preference.ai.native.mcp.servers.name.description'), + }, + command: { + type: 'string', + description: localize('preference.ai.native.mcp.servers.command.description'), + }, + args: { + type: 'array', + items: { + type: 'string', + }, + description: localize('preference.ai.native.mcp.servers.args.description'), + }, + env: { + type: 'object', + additionalProperties: { + type: 'string', + }, + description: localize('preference.ai.native.mcp.servers.env.description'), + default: {}, + }, + }, + }, + }, [AINativeSettingSectionsId.CodeEditsTyping]: { type: 'boolean', default: false, diff --git a/packages/ai-native/src/browser/types.ts b/packages/ai-native/src/browser/types.ts index f5ea344947..8037701072 100644 --- a/packages/ai-native/src/browser/types.ts +++ b/packages/ai-native/src/browser/types.ts @@ -26,6 +26,7 @@ import { SumiReadableStream } from '@opensumi/ide-utils/lib/stream'; import { IMarker } from '@opensumi/monaco-editor-core/esm/vs/platform/markers/common/markers'; import { IChatWelcomeMessageContent, ISampleQuestions, ITerminalCommandSuggestionDesc } from '../common'; +import { SerializedContext } from '../common/llm-context'; import { ICodeEditsContextBean, @@ -325,6 +326,51 @@ export interface AINativeCoreContribution { * proposed api */ registerIntelligentCompletionFeature?(registry: IIntelligentCompletionsRegistry): void; + + /** + * 注册 Agent 模式下的 chat prompt provider + * @param provider + */ + registerChatAgentPromptProvider?(): void; +} + +// MCP Server 的 贡献点 +export const MCPServerContribution = Symbol('MCPServerContribution'); + +export const TokenMCPServerRegistry = Symbol('TokenMCPServerRegistry'); + +export interface MCPServerContribution { + registerMCPServer(registry: IMCPServerRegistry): void; +} + +export interface MCPLogger { + appendLine(message: string): void; +} + +export interface MCPToolDefinition { + name: string; + description: string; + inputSchema: any; // JSON Schema + handler: ( + args: any, + logger: MCPLogger, + ) => Promise<{ + content: { type: string; text: string }[]; + isError?: boolean; + }>; +} + +export interface IMCPServerRegistry { + registerMCPTool(tool: MCPToolDefinition): void; + getMCPTools(): MCPToolDefinition[]; + callMCPTool( + name: string, + args: any, + ): Promise<{ + content: { type: string; text: string }[]; + isError?: boolean; + }>; + // 后续支持其他 MCP 功能 } export interface IChatComponentConfig { @@ -359,3 +405,13 @@ export interface IAIMiddleware { provideInlineCompletions?: IProvideInlineCompletionsSignature; }; } + +export const ChatAgentPromptProvider = Symbol('ChatAgentPromptProvider'); + +export interface ChatAgentPromptProvider { + /** + * 提供上下文提示 + * @param context 上下文 + */ + provideContextPrompt(context: SerializedContext, userMessage: string): MaybePromise; +} diff --git a/packages/ai-native/src/common/index.ts b/packages/ai-native/src/common/index.ts index 8c9c8a8e2f..4e3b45600a 100644 --- a/packages/ai-native/src/common/index.ts +++ b/packages/ai-native/src/common/index.ts @@ -16,6 +16,9 @@ import { IChatMessage } from '@opensumi/ide-core-common/lib/types/ai-native'; import { DESIGN_MENUBAR_CONTAINER_VIEW_ID } from '@opensumi/ide-design/lib/common/constants'; import { IPosition, ITextModel, InlineCompletionContext } from '@opensumi/ide-monaco/lib/common'; +import { MCPServerDescription } from './mcp-server-manager'; +import { MCPTool } from './types'; + export const IAINativeService = Symbol('IAINativeService'); /** @@ -116,6 +119,17 @@ export const IChatAgentService = Symbol('IChatAgentService'); export const ChatProxyServiceToken = Symbol('ChatProxyServiceToken'); +// 暴露给 Node.js 层,使其可以感知 Opensumi 注册的 MCP 能力 +export const TokenMCPServerProxyService = Symbol('TokenMCPServerProxyService'); + +export interface ISumiMCPServerBackend { + initBuiltinMCPServer(): void; + initExternalMCPServers(servers: MCPServerDescription[]): void; + getAllMCPTools(): Promise; +} + +export const SumiMCPServerProxyServicePath = 'SumiMCPServerProxyServicePath'; + export interface IChatAgentService { readonly onDidChangeAgents: Event; readonly onDidSendMessage: Event; diff --git a/packages/ai-native/src/common/llm-context.ts b/packages/ai-native/src/common/llm-context.ts new file mode 100644 index 0000000000..6dc409694c --- /dev/null +++ b/packages/ai-native/src/common/llm-context.ts @@ -0,0 +1,41 @@ +import { Event, URI } from '@opensumi/ide-core-common/lib/utils'; + +export interface LLMContextService { + startAutoCollection(): void; + + stopAutoCollection(): void; + + /** + * 添加文件到 context 中 + */ + addFileToContext(uri: URI, selection?: [number, number], isManual?: boolean): void; + + /** + * 清除上下文 + */ + cleanFileContext(): void; + + onDidContextFilesChangeEvent: Event; + + /** + * 从 context 中移除文件 + * @param uri URI + */ + removeFileFromContext(uri: URI): void; + + /** 导出为可序列化格式 */ + serialize(): SerializedContext; +} + +export interface FileContext { + uri: URI; + selection?: [number, number]; + isManual: boolean; +} + +export const LLMContextServiceToken = Symbol('LLMContextService'); + +export interface SerializedContext { + recentlyViewFiles: string[]; + attachedFiles: Array<{ content: string; lineErrors: string[]; path: string; language: string }>; +} diff --git a/packages/ai-native/src/common/mcp-server-manager.ts b/packages/ai-native/src/common/mcp-server-manager.ts new file mode 100644 index 0000000000..a90c31addf --- /dev/null +++ b/packages/ai-native/src/common/mcp-server-manager.ts @@ -0,0 +1,46 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; + +export interface MCPServerManager { + callTool(serverName: string, toolName: string, arg_string: string): ReturnType; + removeServer(name: string): void; + addOrUpdateServer(description: MCPServerDescription): void; + // invoke in node.js only + addOrUpdateServerDirectly(server: any): void; + initBuiltinServer(builtinMCPServer: any): void; + getTools(serverName: string): ReturnType; + getServerNames(): Promise; + startServer(serverName: string): Promise; + stopServer(serverName: string): Promise; + getStartedServers(): Promise; + registerTools(serverName: string): Promise; + addExternalMCPServers(servers: MCPServerDescription[]): void; +} + +export type MCPTool = Awaited>['tools'][number]; + +export type MCPToolParameter = Awaited>['tools'][number]['inputSchema']; + +export interface MCPServerDescription { + /** + * The unique name of the MCP server. + */ + name: string; + + /** + * The command to execute the MCP server. + */ + command: string; + + /** + * An array of arguments to pass to the command. + */ + args?: string[]; + + /** + * Optional environment variables to set when starting the server. + */ + env?: { [key: string]: string }; +} + +export const MCPServerManager = Symbol('MCPServerManager'); +export const MCPServerManagerPath = 'ServicesMCPServerManager'; diff --git a/packages/ai-native/src/common/tool-invocation-registry.ts b/packages/ai-native/src/common/tool-invocation-registry.ts new file mode 100644 index 0000000000..8316e3e9dd --- /dev/null +++ b/packages/ai-native/src/common/tool-invocation-registry.ts @@ -0,0 +1,170 @@ +import { z } from 'zod'; + +import { Injectable } from '@opensumi/di'; + +import { MCPToolParameter } from './mcp-server-manager'; + +export const ToolParameterSchema = z.object({ + type: z.enum(['string', 'number', 'boolean', 'object', 'array']), + description: z.string().optional(), + enum: z.array(z.any()).optional(), + items: z.lazy(() => ToolParameterSchema).optional(), + properties: z.record(z.lazy(() => ToolParameterSchema)).optional(), + required: z.array(z.string()).optional(), +}); + +export type ToolParameter = z.infer; + +export interface ToolRequest { + id: string; + name: string; + parameters?: any; + description?: string; + handler: (arg_string: string) => Promise; + providerName?: string; +} + +export namespace ToolRequest { + export function isToolParameter(obj: unknown): obj is ToolParameter { + return ToolParameterSchema.safeParse(obj).success; + } +} + +export const ToolInvocationRegistry = Symbol('ToolInvocationRegistry'); + +/** + * 为 Agent 提供的所有可用函数调用的注册表 + */ +export interface ToolInvocationRegistry { + /** + * 在注册表中注册一个工具 + * + * @param tool - 要注册的 `ToolRequest` 对象 + */ + registerTool(tool: ToolRequest): void; + + /** + * 从注册表中获取特定的 `ToolRequest` + * + * @param toolId - 要获取的工具的唯一标识符 + * @returns 对应提供的工具 ID 的 `ToolRequest` 对象, + * 如果在注册表中找不到该工具,则返回 `undefined` + */ + getFunction(toolId: string): ToolRequest | undefined; + + /** + * 从注册表中获取多个 `ToolRequest` + * + * @param toolIds - 要获取的工具 ID 列表 + * @returns 指定工具 ID 的 `ToolRequest` 对象数组 + * 如果找不到某个工具 ID,将在返回的数组中跳过该工具 + */ + getFunctions(...toolIds: string[]): ToolRequest[]; + + /** + * 获取当前注册表中的所有 `ToolRequest` + * + * @returns 注册表中所有 `ToolRequest` 对象的数组 + */ + getAllFunctions(): ToolRequest[]; + + /** + * 注销特定工具提供者的所有工具 + * + * @param providerName - 要移除其工具的工具提供者名称(在 `ToolRequest` 中指定) + */ + unregisterAllTools(providerName: string): void; +} + +export const ToolProvider = Symbol('ToolProvider'); +export interface ToolProvider { + getTool(): ToolRequest; +} + +export class ToolInvocationRegistryImpl implements ToolInvocationRegistry { + private tools: Map = new Map(); + + unregisterAllTools(providerName: string): void { + const toolsToRemove: string[] = []; + for (const [id, tool] of this.tools.entries()) { + if (tool.providerName === providerName) { + toolsToRemove.push(id); + } + } + toolsToRemove.forEach((id) => this.tools.delete(id)); + } + + getAllFunctions(): ToolRequest[] { + return Array.from(this.tools.values()); + } + + registerTool(tool: ToolRequest): void { + if (this.tools.has(tool.id)) { + // TODO: 使用适当的日志机制 + this.tools.set(tool.id, tool); + } else { + this.tools.set(tool.id, tool); + } + } + + getFunction(toolId: string): ToolRequest | undefined { + return this.tools.get(toolId); + } + + getFunctions(...toolIds: string[]): ToolRequest[] { + const tools: ToolRequest[] = toolIds.map((toolId) => { + const tool = this.tools.get(toolId); + if (tool) { + return tool; + } else { + throw new Error(`找不到 ID 为 ${toolId} 的函数`); + } + }); + return tools; + } +} + +/** + * 管理多个 ToolInvocationRegistry 实例的管理器,每个实例与一个 clientId 关联 + */ +export interface IToolInvocationRegistryManager { + /** + * 获取或创建特定 clientId 的 ToolInvocationRegistry + */ + getRegistry(clientId: string): ToolInvocationRegistry; + + /** + * 移除特定 clientId 的 ToolInvocationRegistry + */ + removeRegistry(clientId: string): void; + + /** + * 检查特定 clientId 是否存在对应的注册表 + */ + hasRegistry(clientId: string): boolean; +} + +export const ToolInvocationRegistryManager = Symbol('ToolInvocationRegistryManager'); + +@Injectable() +export class ToolInvocationRegistryManagerImpl implements IToolInvocationRegistryManager { + private registries: Map = new Map(); + + getRegistry(clientId: string): ToolInvocationRegistry { + let registry = this.registries.get(clientId); + if (!registry) { + registry = new ToolInvocationRegistryImpl(); + this.registries.set(clientId, registry); + } + return registry; + } + + removeRegistry(clientId: string): void { + this.registries.delete(clientId); + } + + hasRegistry(clientId: string): boolean { + return this.registries.has(clientId); + } +} + diff --git a/packages/ai-native/src/common/types.ts b/packages/ai-native/src/common/types.ts index e8d103d292..90f9adfecf 100644 --- a/packages/ai-native/src/common/types.ts +++ b/packages/ai-native/src/common/types.ts @@ -18,3 +18,25 @@ export interface INearestCodeBlock { offset: number; type?: NearestCodeBlockType; } + +// SUMI MCP Server 网页部分暴露给 Node.js 部分的能力 +export interface IMCPServerProxyService { + $callMCPTool( + name: string, + args: any, + ): Promise<{ + content: { type: string; text: string }[]; + isError?: boolean; + }>; + // 获取 browser 层注册的 MCP 工具列表 (Browser tab 维度) + $getMCPTools(): Promise; + // 通知前端 MCP 服务注册表发生了变化 + $updateMCPServers(): Promise; +} + +export interface MCPTool { + name: string; + description: string; + inputSchema: any; + providerName: string; +} diff --git a/packages/ai-native/src/node/anthropic/anthropic-language-model.ts b/packages/ai-native/src/node/anthropic/anthropic-language-model.ts new file mode 100644 index 0000000000..dce9051c8d --- /dev/null +++ b/packages/ai-native/src/node/anthropic/anthropic-language-model.ts @@ -0,0 +1,25 @@ +import { AnthropicProvider, createAnthropic } from '@ai-sdk/anthropic'; + +import { Injectable } from '@opensumi/di'; +import { IAIBackServiceOption } from '@opensumi/ide-core-common'; +import { AINativeSettingSectionsId } from '@opensumi/ide-core-common/lib/settings/ai-native'; + +import { BaseLanguageModel } from '../base-language-model'; + +export const AnthropicModelIdentifier = Symbol('AnthropicModelIdentifier'); + +@Injectable() +export class AnthropicModel extends BaseLanguageModel { + protected initializeProvider(options: IAIBackServiceOption): AnthropicProvider { + const apiKey = options.apiKey; + if (!apiKey) { + throw new Error(`Please provide Anthropic API Key in preferences (${AINativeSettingSectionsId.AnthropicApiKey})`); + } + + return createAnthropic({ apiKey }); + } + + protected getModelIdentifier(provider: AnthropicProvider) { + return provider('claude-3-5-sonnet-20241022'); + } +} diff --git a/packages/ai-native/src/node/base-language-model.ts b/packages/ai-native/src/node/base-language-model.ts new file mode 100644 index 0000000000..040eaee117 --- /dev/null +++ b/packages/ai-native/src/node/base-language-model.ts @@ -0,0 +1,163 @@ +import { CoreMessage, jsonSchema, streamText, tool } from 'ai'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { ChatMessageRole, IAIBackServiceOption, IChatMessage } from '@opensumi/ide-core-common'; +import { ChatReadableStream } from '@opensumi/ide-core-node'; +import { CancellationToken } from '@opensumi/ide-utils'; + +import { + IToolInvocationRegistryManager, + ToolInvocationRegistryManager, + ToolRequest, +} from '../common/tool-invocation-registry'; + +@Injectable() +export abstract class BaseLanguageModel { + @Autowired(ToolInvocationRegistryManager) + protected readonly toolInvocationRegistryManager: IToolInvocationRegistryManager; + + protected abstract initializeProvider(options: IAIBackServiceOption): any; + + private convertChatMessageRole(role: ChatMessageRole) { + switch (role) { + case ChatMessageRole.System: + return 'system'; + case ChatMessageRole.User: + return 'user'; + case ChatMessageRole.Assistant: + return 'assistant'; + case ChatMessageRole.Function: + return 'tool'; + default: + return 'user'; + } + } + + async request( + request: string, + chatReadableStream: ChatReadableStream, + options: IAIBackServiceOption, + cancellationToken?: CancellationToken, + ): Promise { + const provider = this.initializeProvider(options); + const clientId = options.clientId; + if (!clientId) { + throw new Error('clientId is required'); + } + const registry = this.toolInvocationRegistryManager.getRegistry(clientId); + const allFunctions = registry.getAllFunctions(); + return this.handleStreamingRequest( + provider, + request, + allFunctions, + chatReadableStream, + options.history || [], + cancellationToken, + ); + } + + private convertToolRequestToAITool(toolRequest: ToolRequest) { + return tool({ + description: toolRequest.description || '', + // TODO 这里应该是 z.object 而不是 JSON Schema + parameters: jsonSchema(toolRequest.parameters), + execute: async (args: any) => await toolRequest.handler(JSON.stringify(args)), + }); + } + + protected abstract getModelIdentifier(provider: any): any; + + protected async handleStreamingRequest( + provider: any, + request: string, + tools: ToolRequest[], + chatReadableStream: ChatReadableStream, + history: IChatMessage[] = [], + cancellationToken?: CancellationToken, + ): Promise { + try { + const aiTools = Object.fromEntries(tools.map((tool) => [tool.name, this.convertToolRequestToAITool(tool)])); + + const abortController = new AbortController(); + if (cancellationToken) { + cancellationToken.onCancellationRequested(() => { + abortController.abort(); + }); + } + + const messages: CoreMessage[] = [ + ...history.map((msg) => ({ + role: this.convertChatMessageRole(msg.role) as any, // 这个 SDK 包里的类型不太好完全对应, + content: msg.content, + })), + { role: 'user', content: request }, + ]; + + const stream = await streamText({ + model: this.getModelIdentifier(provider), + maxTokens: 4096, + tools: aiTools, + messages, + abortSignal: abortController.signal, + experimental_toolCallStreaming: true, + maxSteps: 12, + }); + + for await (const chunk of stream.fullStream) { + if (chunk.type === 'text-delta') { + chatReadableStream.emitData({ kind: 'content', content: chunk.textDelta }); + } else if (chunk.type === 'tool-call') { + chatReadableStream.emitData({ + kind: 'toolCall', + content: { + id: chunk.toolCallId || Date.now().toString(), + type: 'function', + function: { name: chunk.toolName, arguments: JSON.stringify(chunk.args) }, + state: 'complete', + }, + }); + } else if (chunk.type === 'tool-call-streaming-start') { + chatReadableStream.emitData({ + kind: 'toolCall', + content: { + id: chunk.toolCallId, + type: 'function', + function: { name: chunk.toolName }, + state: 'streaming-start', + }, + }); + } else if (chunk.type === 'tool-call-delta') { + chatReadableStream.emitData({ + kind: 'toolCall', + content: { + id: chunk.toolCallId, + type: 'function', + function: { name: chunk.toolName, arguments: chunk.argsTextDelta }, + state: 'streaming', + }, + }); + } else if (chunk.type === 'tool-result') { + chatReadableStream.emitData({ + kind: 'toolCall', + content: { + id: chunk.toolCallId, + type: 'function', + function: { name: chunk.toolName, arguments: JSON.stringify(chunk.args) }, + result: chunk.result, + state: 'result', + }, + }); + } else if (chunk.type === 'error') { + chatReadableStream.emitError(new Error(chunk.error as string)); + } + } + + chatReadableStream.end(); + } catch (error) { + // Use a logger service in production instead of console + chatReadableStream.emitError(error); + } + + return chatReadableStream; + } +} diff --git a/packages/ai-native/src/node/deepseek/deepseek-language-model.ts b/packages/ai-native/src/node/deepseek/deepseek-language-model.ts new file mode 100644 index 0000000000..c3aa009cae --- /dev/null +++ b/packages/ai-native/src/node/deepseek/deepseek-language-model.ts @@ -0,0 +1,25 @@ +import { DeepSeekProvider, createDeepSeek } from '@ai-sdk/deepseek'; + +import { Injectable } from '@opensumi/di'; +import { IAIBackServiceOption } from '@opensumi/ide-core-common'; +import { AINativeSettingSectionsId } from '@opensumi/ide-core-common/lib/settings/ai-native'; + +import { BaseLanguageModel } from '../base-language-model'; + +export const DeepSeekModelIdentifier = Symbol('DeepSeekModelIdentifier'); + +@Injectable() +export class DeepSeekModel extends BaseLanguageModel { + protected initializeProvider(options: IAIBackServiceOption): DeepSeekProvider { + const apiKey = options.apiKey; + if (!apiKey) { + throw new Error(`Please provide Deepseek API Key in preferences (${AINativeSettingSectionsId.DeepseekApiKey})`); + } + + return createDeepSeek({ apiKey }); + } + + protected getModelIdentifier(provider: DeepSeekProvider) { + return provider('deepseek-chat'); + } +} diff --git a/packages/ai-native/src/node/index.ts b/packages/ai-native/src/node/index.ts index 888ad414dd..f455b6a92d 100644 --- a/packages/ai-native/src/node/index.ts +++ b/packages/ai-native/src/node/index.ts @@ -3,6 +3,11 @@ import { AIBackSerivcePath, AIBackSerivceToken } from '@opensumi/ide-core-common import { NodeModule } from '@opensumi/ide-core-node'; import { BaseAIBackService } from '@opensumi/ide-core-node/lib/ai-native/base-back.service'; +import { SumiMCPServerProxyServicePath, TokenMCPServerProxyService } from '../common'; +import { ToolInvocationRegistryManager, ToolInvocationRegistryManagerImpl } from '../common/tool-invocation-registry'; + +import { SumiMCPServerBackend } from './mcp/sumi-mcp-server'; + @Injectable() export class AINativeModule extends NodeModule { providers: Provider[] = [ @@ -10,6 +15,14 @@ export class AINativeModule extends NodeModule { token: AIBackSerivceToken, useClass: BaseAIBackService, }, + { + token: ToolInvocationRegistryManager, + useClass: ToolInvocationRegistryManagerImpl, + }, + { + token: TokenMCPServerProxyService, + useClass: SumiMCPServerBackend, + }, ]; backServices = [ @@ -17,5 +30,13 @@ export class AINativeModule extends NodeModule { servicePath: AIBackSerivcePath, token: AIBackSerivceToken, }, + // { + // servicePath: MCPServerManagerPath, + // token: MCPServerManager, + // }, + { + servicePath: SumiMCPServerProxyServicePath, + token: TokenMCPServerProxyService, + }, ]; } diff --git a/packages/ai-native/src/node/mcp-server-manager-impl.ts b/packages/ai-native/src/node/mcp-server-manager-impl.ts new file mode 100644 index 0000000000..22177a5a02 --- /dev/null +++ b/packages/ai-native/src/node/mcp-server-manager-impl.ts @@ -0,0 +1,148 @@ +import { ILogger } from '@opensumi/ide-core-common'; + +import { MCPServerDescription, MCPServerManager, MCPTool } from '../common/mcp-server-manager'; +import { IToolInvocationRegistryManager, ToolRequest } from '../common/tool-invocation-registry'; + +import { BuiltinMCPServer } from './mcp/sumi-mcp-server'; +import { IMCPServer, MCPServerImpl } from './mcp-server'; + +// 这应该是 Browser Tab 维度的,每个 Tab 对应一个 MCPServerManagerImpl +export class MCPServerManagerImpl implements MCPServerManager { + protected servers: Map = new Map(); + + // 当前实例对应的 clientId + private clientId: string; + + constructor( + private readonly toolInvocationRegistryManager: IToolInvocationRegistryManager, + private readonly logger: ILogger, + ) {} + + setClientId(clientId: string) { + this.clientId = clientId; + } + + async stopServer(serverName: string): Promise { + const server = this.servers.get(serverName); + if (!server) { + throw new Error(`MCP server "${serverName}" not found.`); + } + server.stop(); + this.logger.log(`MCP server "${serverName}" stopped.`); + } + + async getStartedServers(): Promise { + const startedServers: string[] = []; + for (const [name, server] of this.servers.entries()) { + if (server.isStarted()) { + startedServers.push(name); + } + } + return startedServers; + } + + callTool(serverName: string, toolName: string, arg_string: string): ReturnType { + const server = this.servers.get(serverName); + if (!server) { + throw new Error(`MCP server "${toolName}" not found.`); + } + return server.callTool(toolName, arg_string); + } + + async startServer(serverName: string): Promise { + const server = this.servers.get(serverName); + if (!server) { + throw new Error(`MCP server "${serverName}" not found.`); + } + await server.start(); + } + + async getServerNames(): Promise { + return Array.from(this.servers.keys()); + } + + private convertToToolRequest(tool: MCPTool, serverName: string): ToolRequest { + const id = `mcp_${serverName}_${tool.name}`; + + return { + id, + name: id, + providerName: serverName, + parameters: tool.inputSchema, + description: tool.description, + handler: async (arg_string: string) => { + try { + const res = await this.callTool(serverName, tool.name, arg_string); + this.logger.debug(`[MCP: ${serverName}] ${tool.name} called with ${arg_string}`); + this.logger.debug('Tool execution result:', res); + return JSON.stringify(res); + } catch (error) { + this.logger.error(`Error in tool handler for ${tool.name} on MCP server ${serverName}:`, error); + throw error; + } + }, + }; + } + + public async registerTools(serverName: string): Promise { + const server = this.servers.get(serverName); + if (!server) { + throw new Error(`MCP server "${serverName}" not found.`); + } + + const { tools } = await server.getTools(); + const toolRequests: ToolRequest[] = tools.map((tool) => this.convertToToolRequest(tool, serverName)); + + const registry = this.toolInvocationRegistryManager.getRegistry(this.clientId); + for (const toolRequest of toolRequests) { + registry.registerTool(toolRequest); + } + } + + public async getTools(serverName: string): ReturnType { + const server = this.servers.get(serverName); + if (!server) { + throw new Error(`MCP server "${serverName}" not found.`); + } + return server.getTools(); + } + + addOrUpdateServer(description: MCPServerDescription): void { + const { name, command, args, env } = description; + const existingServer = this.servers.get(name); + + if (existingServer) { + existingServer.update(command, args, env); + } else { + const newServer = new MCPServerImpl(name, command, args, env, this.logger); + this.servers.set(name, newServer); + } + } + + addOrUpdateServerDirectly(server: IMCPServer): void { + this.servers.set(server.getServerName(), server); + } + + async initBuiltinServer(builtinMCPServer: BuiltinMCPServer): Promise { + this.addOrUpdateServerDirectly(builtinMCPServer); + await this.registerTools(builtinMCPServer.getServerName()); + } + + async addExternalMCPServers(servers: MCPServerDescription[]): Promise { + for (const server of servers) { + this.addOrUpdateServer(server); + await this.startServer(server.name); + await this.registerTools(server.name); + } + } + + removeServer(name: string): void { + const server = this.servers.get(name); + if (server) { + server.stop(); + this.servers.delete(name); + } else { + this.logger.warn(`MCP server "${name}" not found.`); + } + } +} diff --git a/packages/ai-native/src/node/mcp-server.ts b/packages/ai-native/src/node/mcp-server.ts new file mode 100644 index 0000000000..ad1844fc05 --- /dev/null +++ b/packages/ai-native/src/node/mcp-server.ts @@ -0,0 +1,126 @@ +// have to import with extension since the exports map is ./* -> ./dist/cjs/* +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +import { ILogger } from '@opensumi/ide-core-common'; + +export interface IMCPServer { + isStarted(): boolean; + start(): Promise; + getServerName(): string; + callTool(toolName: string, arg_string: string): ReturnType; + getTools(): ReturnType; + update(command: string, args?: string[], env?: { [key: string]: string }): void; + stop(): void; +} + +export class MCPServerImpl implements IMCPServer { + private name: string; + private command: string; + private args?: string[]; + private client: Client; + private env?: { [key: string]: string }; + private started: boolean = false; + + constructor( + name: string, + command: string, + args?: string[], + env?: Record, + private readonly logger?: ILogger, + ) { + this.name = name; + this.command = command; + this.args = args; + this.env = env; + } + + isStarted(): boolean { + return this.started; + } + + getServerName(): string { + return this.name; + } + + async start(): Promise { + if (this.started) { + return; + } + this.logger?.log( + `Starting server "${this.name}" with command: ${this.command} and args: ${this.args?.join( + ' ', + )} and env: ${JSON.stringify(this.env)}`, + ); + // Filter process.env to exclude undefined values + const sanitizedEnv: Record = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); + + const mergedEnv: Record = { + ...sanitizedEnv, + ...(this.env || {}), + }; + const transport = new StdioClientTransport({ + command: this.command, + args: this.args, + env: mergedEnv, + }); + transport.onerror = (error) => { + this.logger?.error('Transport Error:', error); + }; + + this.client = new Client( + { + name: 'opensumi-mcp-client', + version: '1.0.0', + }, + { + capabilities: {}, + }, + ); + this.client.onerror = (error) => { + this.logger?.error('Error in MCP client:', error); + }; + + await this.client.connect(transport); + this.started = true; + } + + async callTool(toolName: string, arg_string: string) { + let args; + try { + args = JSON.parse(arg_string); + } catch (error) { + this.logger?.error( + `Failed to parse arguments for calling tool "${toolName}" in MCP server "${this.name}" with command "${this.command}". + Invalid JSON: ${arg_string}`, + error, + ); + } + const params = { + name: toolName, + arguments: args, + }; + return this.client.callTool(params); + } + + async getTools() { + return await this.client.listTools(); + } + + update(command: string, args?: string[], env?: { [key: string]: string }): void { + this.command = command; + this.args = args; + this.env = env; + } + + stop(): void { + if (!this.started || !this.client) { + return; + } + this.logger?.log(`Stopping MCP server "${this.name}"`); + this.client.close(); + this.started = false; + } +} diff --git a/packages/ai-native/src/node/mcp/sumi-mcp-server.ts b/packages/ai-native/src/node/mcp/sumi-mcp-server.ts new file mode 100644 index 0000000000..835237794d --- /dev/null +++ b/packages/ai-native/src/node/mcp/sumi-mcp-server.ts @@ -0,0 +1,197 @@ +// 想要通过 MCP 的方式暴露 Opensumi 的 IDE 能力,就需要 Node.js 层打通 MCP 的通信 +// 因为大部分 MCP 功能的实现在前端,因此需要再这里做前后端通信 + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { RPCService } from '@opensumi/ide-connection'; +import { ILogger } from '@opensumi/ide-core-common'; +import { INodeLogger } from '@opensumi/ide-core-node'; + +import { ISumiMCPServerBackend } from '../../common'; +import { MCPServerDescription, MCPServerManager } from '../../common/mcp-server-manager'; +import { IToolInvocationRegistryManager, ToolInvocationRegistryManager } from '../../common/tool-invocation-registry'; +import { IMCPServerProxyService, MCPTool } from '../../common/types'; +import { IMCPServer } from '../mcp-server'; +import { MCPServerManagerImpl } from '../mcp-server-manager-impl'; + +// 每个 BrowserTab 都对应了一个 SumiMCPServerBackend 实例 +// SumiMCPServerBackend 需要做的事情: +// 维护 Browser 端工具的注册和调用 +// 处理第三方 MCP Server 的注册和调用 + +@Injectable({ multiple: true }) +export class SumiMCPServerBackend extends RPCService implements ISumiMCPServerBackend { + // 这里需要考虑不同的 BrowserTab 的区分问题,目前的 POC 所有的 Tab 都会注册到 tools 中 + // 后续需要区分不同的 Tab 对应的实例 + private readonly mcpServerManager: MCPServerManagerImpl; + + @Autowired(ToolInvocationRegistryManager) + private readonly toolInvocationRegistryManager: IToolInvocationRegistryManager; + + @Autowired(INodeLogger) + private readonly logger: ILogger; + + private server: Server | undefined; + + // 对应 BrowserTab 的 clientId + private clientId: string = ''; + + constructor() { + super(); + this.mcpServerManager = new MCPServerManagerImpl(this.toolInvocationRegistryManager, this.logger); + } + + public setConnectionClientId(clientId: string) { + this.clientId = clientId; + this.mcpServerManager.setClientId(clientId); + } + + async getMCPTools() { + if (!this.client) { + throw new Error('SUMI MCP RPC Client not initialized'); + } + // 获取 MCP 工具 + const tools = await this.client.$getMCPTools(); + this.logger.log('[Node backend] SUMI MCP tools', tools); + return tools; + } + + async callMCPTool(name: string, args: any) { + if (!this.client) { + throw new Error('SUMI MCP RPC Client not initialized'); + } + return await this.client.$callMCPTool(name, args); + } + + getServer() { + return this.server; + } + + // TODO 这里涉及到 Chat Stream Call 中带上 ClientID,具体方案需要进一步讨论 + async getAllMCPTools(): Promise { + const registry = this.toolInvocationRegistryManager.getRegistry(this.clientId); + return registry.getAllFunctions().map((tool) => ({ + name: tool.name || 'no-name', + description: tool.description || 'no-description', + inputSchema: tool.parameters, + providerName: tool.providerName || 'no-provider-name', + })); + } + + public async initBuiltinMCPServer() { + const builtinMCPServer = new BuiltinMCPServer(this, this.logger); + this.mcpServerManager.setClientId(this.clientId); + await this.mcpServerManager.initBuiltinServer(builtinMCPServer); + this.client?.$updateMCPServers(); + } + + public async initExternalMCPServers(servers: MCPServerDescription[]) { + this.mcpServerManager.setClientId(this.clientId); + await this.mcpServerManager.addExternalMCPServers(servers); + this.client?.$updateMCPServers(); + } + + async initExposedMCPServer() { + // 初始化 MCP Server + this.server = new Server( + { + name: 'sumi-ide-mcp-server', + version: '0.2.0', + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + // 设置工具列表请求处理器 + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools = await this.getMCPTools(); + return { tools }; + }); + + // 设置工具调用请求处理器 + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + try { + const { name, arguments: args } = request.params; + return await this.callMCPTool(name, args); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + content: [{ type: 'text', text: `Error: ${errorMessage}` }], + isError: true, + }; + } + }); + + return this.server; + } +} + +export const TokenBuiltinMCPServer = Symbol('TokenBuiltinMCPServer'); + +export class BuiltinMCPServer implements IMCPServer { + private started: boolean = true; + + constructor(private readonly sumiMCPServer: SumiMCPServerBackend, private readonly logger: ILogger) {} + + isStarted(): boolean { + return this.started; + } + + getServerName(): string { + return 'sumi-builtin'; + } + + async start(): Promise { + if (this.started) { + return; + } + // TODO 考虑 MCP Server 的对外暴露 + // await this.sumiMCPServer.initMCPServer(); + this.started = true; + } + + async callTool(toolName: string, arg_string: string): Promise { + if (!this.started) { + throw new Error('MCP Server not started'); + } + let args; + try { + args = JSON.parse(arg_string); + } catch (error) { + this.logger.error( + `Failed to parse arguments for calling tool "${toolName}" in Builtin MCP server. + Invalid JSON: ${arg_string}`, + error, + ); + throw error; + } + return this.sumiMCPServer.callMCPTool(toolName, args); + } + + async getTools(): ReturnType { + if (!this.started) { + throw new Error('MCP Server not started'); + } + const tools = await this.sumiMCPServer.getMCPTools(); + this.logger.debug('[BuiltinMCPServer] getTools', tools); + return { tools } as any; + } + + update(_command: string, _args?: string[], _env?: { [key: string]: string }): void { + // No-op for builtin server as it doesn't need command/args/env updates + } + + stop(): void { + if (!this.started) { + return; + } + // No explicit cleanup needed for in-memory server + this.started = false; + } +} diff --git a/packages/ai-native/src/node/openai/openai-language-model.ts b/packages/ai-native/src/node/openai/openai-language-model.ts new file mode 100644 index 0000000000..fe123e7be4 --- /dev/null +++ b/packages/ai-native/src/node/openai/openai-language-model.ts @@ -0,0 +1,25 @@ +import { OpenAIProvider, createOpenAI } from '@ai-sdk/openai'; + +import { Injectable } from '@opensumi/di'; +import { AINativeSettingSectionsId, IAIBackServiceOption } from '@opensumi/ide-core-common'; + +import { BaseLanguageModel } from '../base-language-model'; +export const DeepSeekModelIdentifier = Symbol('DeepSeekModelIdentifier'); + +@Injectable() +export class OpenAIModel extends BaseLanguageModel { + protected initializeProvider(options: IAIBackServiceOption): OpenAIProvider { + const apiKey = options.apiKey; + if (!apiKey) { + throw new Error(`Please provide OpenAI API Key in preferences (${AINativeSettingSectionsId.OpenaiApiKey})`); + } + return createOpenAI({ + apiKey, + baseURL: options.baseURL || 'https://dashscope.aliyuncs.com/compatible-mode/v1', + }); + } + + protected getModelIdentifier(provider: OpenAIProvider) { + return provider('qwen-max'); + } +} diff --git a/packages/core-browser/src/ai-native/ai-config.service.ts b/packages/core-browser/src/ai-native/ai-config.service.ts index fe52f24db8..92af90f870 100644 --- a/packages/core-browser/src/ai-native/ai-config.service.ts +++ b/packages/core-browser/src/ai-native/ai-config.service.ts @@ -20,6 +20,8 @@ const DEFAULT_CAPABILITIES: Required = { supportsProblemFix: true, supportsTerminalDetection: true, supportsTerminalCommandSuggest: true, + supportsCustomLLMSettings: true, + supportsMCP: true, }; const DISABLED_ALL_CAPABILITIES = {} as Required; diff --git a/packages/core-common/src/settings/ai-native.ts b/packages/core-common/src/settings/ai-native.ts index 0eb624b40b..d0383b8293 100644 --- a/packages/core-common/src/settings/ai-native.ts +++ b/packages/core-common/src/settings/ai-native.ts @@ -22,6 +22,20 @@ export enum AINativeSettingSectionsId { */ CodeEditsLintErrors = 'ai.native.codeEdits.lintErrors', CodeEditsLineChange = 'ai.native.codeEdits.lineChange', + + /** + * Language model API keys + */ + LLMModelSelection = 'ai.native.llm.model.selection', + DeepseekApiKey = 'ai.native.deepseek.apiKey', + AnthropicApiKey = 'ai.native.anthropic.apiKey', + OpenaiApiKey = 'ai.native.openai.apiKey', + OpenaiBaseURL = 'ai.native.openai.baseURL', + + /** + * MCP Server configurations + */ + MCPServers = 'ai.native.mcp.servers', CodeEditsTyping = 'ai.native.codeEdits.typing', } export const AI_NATIVE_SETTING_GROUP_ID = 'AI-Native'; diff --git a/packages/core-common/src/types/ai-native/index.ts b/packages/core-common/src/types/ai-native/index.ts index 29da5a3949..568e59a106 100644 --- a/packages/core-common/src/types/ai-native/index.ts +++ b/packages/core-common/src/types/ai-native/index.ts @@ -41,9 +41,17 @@ export interface IAINativeCapabilities { */ supportsTerminalDetection?: boolean; /** - * Use ai terminal command suggets capabilities + * Use ai terminal command suggests capabilities */ supportsTerminalCommandSuggest?: boolean; + /** + * Use ai to provide custom LLM settings + */ + supportsCustomLLMSettings?: boolean; + /** + * supports modelcontextprotocol + */ + supportsMCP?: boolean; } export interface IDesignLayoutConfig { @@ -158,6 +166,11 @@ export interface IAIBackServiceOption { requestId?: string; sessionId?: string; history?: IHistoryChatMessage[]; + tools?: any[]; + clientId?: string; + apiKey?: string; + model?: string; + baseURL?: string; } /** @@ -322,6 +335,21 @@ export interface IChatContent { kind: 'content'; } +export interface IChatToolContent { + content: { + id: string; + type: string; + function: { + name: string; + arguments?: string; + }; + result?: string; + index?: number; + state?: 'streaming-start' | 'streaming' | 'complete' | 'result'; + }; + kind: 'toolCall'; +} + export interface IChatMarkdownContent { content: IMarkdownString; kind: 'markdownContent'; @@ -356,7 +384,13 @@ export interface IChatComponent { kind: 'component'; } -export type IChatProgress = IChatContent | IChatMarkdownContent | IChatAsyncContent | IChatTreeData | IChatComponent; +export type IChatProgress = + | IChatContent + | IChatMarkdownContent + | IChatAsyncContent + | IChatTreeData + | IChatComponent + | IChatToolContent; export interface IChatMessage { readonly role: ChatMessageRole; diff --git a/packages/i18n/src/common/en-US.lang.ts b/packages/i18n/src/common/en-US.lang.ts index 621280b612..732a357581 100644 --- a/packages/i18n/src/common/en-US.lang.ts +++ b/packages/i18n/src/common/en-US.lang.ts @@ -1539,5 +1539,25 @@ export const localizationBundle = { ...browserViews, ...editorLocalizations, ...mergeConflicts, + + // AI Native Settings + 'preference.ai.native.llm.apiSettings.title': 'LLM API Settings', + 'preference.ai.native.deepseek.apiKey': 'Deepseek API Key', + 'preference.ai.native.deepseek.apiKey.description': 'API key for Deepseek language model', + 'preference.ai.native.anthropic.apiKey': 'Anthropic API Key', + 'preference.ai.native.anthropic.apiKey.description': 'API key for Anthropic language model', + 'preference.ai.native.openai.apiKey': 'OpenAI API Key', + 'preference.ai.native.openai.apiKey.description': 'API key for OpenAI Compatible language model', + 'preference.ai.native.openai.baseURL': 'OpenAI Base URL', + 'preference.ai.native.openai.baseURL.description': 'Base URL for OpenAI Compatible language model', + + // MCP Server Settings + 'preference.ai.native.mcp.settings.title': 'MCP Server Settings', + 'preference.ai.native.mcp.servers': 'MCP Servers', + 'preference.ai.native.mcp.servers.description': 'Configure MCP (Model Context Protocol) servers', + 'preference.ai.native.mcp.servers.name.description': 'Name of the MCP server', + 'preference.ai.native.mcp.servers.command.description': 'Command to start the MCP server', + 'preference.ai.native.mcp.servers.args.description': 'Command line arguments for the MCP server', + 'preference.ai.native.mcp.servers.env.description': 'Environment variables for the MCP server', }, }; diff --git a/packages/i18n/src/common/zh-CN.lang.ts b/packages/i18n/src/common/zh-CN.lang.ts index e01ec91556..4682dc50a1 100644 --- a/packages/i18n/src/common/zh-CN.lang.ts +++ b/packages/i18n/src/common/zh-CN.lang.ts @@ -1301,5 +1301,25 @@ export const localizationBundle = { ...browserViews, ...editorLocalizations, ...mergeConflicts, + + // AI Native Settings + 'preference.ai.native.llm.apiSettings.title': '大模型 API 设置', + 'preference.ai.native.deepseek.apiKey': 'Deepseek API 密钥', + 'preference.ai.native.deepseek.apiKey.description': 'Deepseek 语言模型的 API 密钥', + 'preference.ai.native.anthropic.apiKey': 'Anthropic API 密钥', + 'preference.ai.native.anthropic.apiKey.description': 'Anthropic 语言模型的 API 密钥', + 'preference.ai.native.openai.apiKey': 'OpenAI API 密钥', + 'preference.ai.native.openai.apiKey.description': 'OpenAI 兼容语言模型的 API 密钥', + 'preference.ai.native.openai.baseURL': 'OpenAI Base URL', + 'preference.ai.native.openai.baseURL.description': 'OpenAI 兼容语言模型的 Base URL', + + // MCP Server Settings + 'preference.ai.native.mcp.settings.title': 'MCP 服务器设置', + 'preference.ai.native.mcp.servers': 'MCP 服务器', + 'preference.ai.native.mcp.servers.description': '配置 MCP (Model Context Protocol) 服务器', + 'preference.ai.native.mcp.servers.name.description': 'MCP 服务器名称', + 'preference.ai.native.mcp.servers.command.description': '启动 MCP 服务器的命令', + 'preference.ai.native.mcp.servers.args.description': 'MCP 服务器的命令行参数', + 'preference.ai.native.mcp.servers.env.description': 'MCP 服务器的环境变量', }, }; diff --git a/packages/startup/entry/sample-modules/ai-native/ai-native.contribution.ts b/packages/startup/entry/sample-modules/ai-native/ai-native.contribution.ts index b269c711b8..ba8ef1d39e 100644 --- a/packages/startup/entry/sample-modules/ai-native/ai-native.contribution.ts +++ b/packages/startup/entry/sample-modules/ai-native/ai-native.contribution.ts @@ -1,4 +1,4 @@ -import { Autowired } from '@opensumi/di'; +import { Autowired, INJECTOR_TOKEN, Injector } from '@opensumi/di'; import { ChatService } from '@opensumi/ide-ai-native/lib/browser/chat/chat.api.service'; import { BaseTerminalDetectionLineMatcher, @@ -11,6 +11,7 @@ import { import { TextWithStyle } from '@opensumi/ide-ai-native/lib/browser/contrib/terminal/utils/ansi-parser'; import { AINativeCoreContribution, + ChatAgentPromptProvider, ERunStrategy, IChatFeatureRegistry, IInlineChatFeatureRegistry, @@ -24,6 +25,7 @@ import { TerminalSuggestionReadableStream, } from '@opensumi/ide-ai-native/lib/browser/types'; import { InlineChatController } from '@opensumi/ide-ai-native/lib/browser/widget/inline-chat/inline-chat-controller'; +import { SerializedContext } from '@opensumi/ide-ai-native/lib/common/llm-context'; import { MergeConflictPromptManager } from '@opensumi/ide-ai-native/lib/common/prompts/merge-conflict-prompt'; import { RenamePromptManager } from '@opensumi/ide-ai-native/lib/common/prompts/rename-prompt'; import { TerminalDetectionPromptManager } from '@opensumi/ide-ai-native/lib/common/prompts/terminal-detection-prompt'; @@ -65,6 +67,9 @@ export class AINativeContribution implements AINativeCoreContribution { @Autowired(MergeConflictPromptManager) mergeConflictPromptManager: MergeConflictPromptManager; + @Autowired(INJECTOR_TOKEN) + protected readonly injector: Injector; + @Autowired(ChatServiceToken) private readonly aiChatService: ChatService; @@ -493,4 +498,38 @@ export class AINativeContribution implements AINativeCoreContribution { } }); } + + registerChatAgentPromptProvider(): void { + this.injector.addProviders({ + token: ChatAgentPromptProvider, + useValue: { + provideContextPrompt: (context: SerializedContext, userMessage: string) => ` + + Below are some potentially helpful/relevant pieces of information for figuring out to respond + + ${context.recentlyViewFiles.map((file, idx) => `${idx} + 1: ${file}`)} + + + ${context.attachedFiles.map( + (file) => + ` + + \`\`\`${file.language} ${file.path} + ${file.content} + \`\`\` + + + ${file.lineErrors.join('`n')} + + `, + )} + + + + + ${userMessage} + `, + }, + }); + } } diff --git a/packages/startup/entry/sample-modules/ai-native/ai.back.service.ts b/packages/startup/entry/sample-modules/ai-native/ai.back.service.ts index 13bc534936..01249758e5 100644 --- a/packages/startup/entry/sample-modules/ai-native/ai.back.service.ts +++ b/packages/startup/entry/sample-modules/ai-native/ai.back.service.ts @@ -1,10 +1,12 @@ import { Autowired, Injectable } from '@opensumi/di'; -import { IAICompletionOption } from '@opensumi/ide-core-common'; +import { AnthropicModel } from '@opensumi/ide-ai-native/lib/node/anthropic/anthropic-language-model'; +import { DeepSeekModel } from '@opensumi/ide-ai-native/lib/node/deepseek/deepseek-language-model'; +import { OpenAIModel } from '@opensumi/ide-ai-native/lib/node/openai/openai-language-model'; +import { IAIBackServiceOption } from '@opensumi/ide-core-common'; import { CancellationToken, ChatReadableStream, IAIBackService, - IAIBackServiceOption, IAIBackServiceResponse, INodeLogger, sleep, @@ -47,6 +49,15 @@ export class AIBackService implements IAIBackService { - const length = streamData.length; const chatReadableStream = new ChatReadableStream(); cancelToken?.onCancellationRequested(() => { chatReadableStream.abort(); }); - // 模拟数据事件 - streamData.forEach((chunk, index) => { - setTimeout(() => { - chatReadableStream.emitData({ kind: 'content', content: chunk.toString() }); + const model = options.model; - if (length - 1 === index || cancelToken?.isCancellationRequested) { - chatReadableStream.end(); - } - }, index * 100); - }); + if (model === 'openai') { + this.openaiModel.request(input, chatReadableStream, options, cancelToken); + } else if (model === 'deepseek') { + this.deepseekModel.request(input, chatReadableStream, options, cancelToken); + } else { + this.anthropicModel.request(input, chatReadableStream, options, cancelToken); + } return chatReadableStream; } diff --git a/packages/startup/entry/web/app.tsx b/packages/startup/entry/web/app.tsx index 5b5271244b..3f70e6a8a0 100644 --- a/packages/startup/entry/web/app.tsx +++ b/packages/startup/entry/web/app.tsx @@ -27,6 +27,12 @@ renderApp( minimumReportThresholdTime: 400, }, }, + AINativeConfig: { + capabilities: { + supportsMCP: true, + supportsCustomLLMSettings: true, + }, + }, notebookServerHost: 'localhost:8888', }, }), diff --git a/tools/dev-tool/src/webpack.js b/tools/dev-tool/src/webpack.js index 8d844e78cc..3875bdc39a 100644 --- a/tools/dev-tool/src/webpack.js +++ b/tools/dev-tool/src/webpack.js @@ -212,9 +212,9 @@ exports.createWebpackConfig = function (dir, entry, extraConfig) { 'process.env.OTHER_EXTENSION_DIR': JSON.stringify(path.join(__dirname, '../../../other')), 'process.env.EXTENSION_WORKER_HOST': JSON.stringify( process.env.EXTENSION_WORKER_HOST || - `http://${HOST}:8080/assets` + - withSlash + - path.resolve(__dirname, '../../../packages/extension/lib/worker-host.js'), + `http://${HOST}:8080/assets` + + withSlash + + path.resolve(__dirname, '../../../packages/extension/lib/worker-host.js'), ), 'process.env.WS_PATH': JSON.stringify(process.env.WS_PATH || `ws://${HOST}:8000`), 'process.env.WEBVIEW_HOST': JSON.stringify(process.env.WEBVIEW_HOST || HOST), @@ -222,18 +222,18 @@ exports.createWebpackConfig = function (dir, entry, extraConfig) { 'process.env.HOST': JSON.stringify(process.env.HOST), }), !process.env.SKIP_TS_CHECKER && - new ForkTsCheckerWebpackPlugin({ - typescript: { - diagnosticOptions: { - syntactic: true, - }, - configFile: tsConfigPath, + new ForkTsCheckerWebpackPlugin({ + typescript: { + diagnosticOptions: { + syntactic: true, }, - issue: { - include: (issue) => issue.file.includes('src/packages/'), - exclude: (issue) => issue.file.includes('__test__'), - }, - }), + configFile: tsConfigPath, + }, + issue: { + include: (issue) => issue.file.includes('src/packages/'), + exclude: (issue) => issue.file.includes('__test__'), + }, + }), new NodePolyfillPlugin({ includeAliases: ['process', 'Buffer'], }), diff --git a/yarn.lock b/yarn.lock index e49070ed06..9976adaf3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,6 +12,117 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/anthropic@npm:^1.1.6": + version: 1.1.6 + resolution: "@ai-sdk/anthropic@npm:1.1.6" + dependencies: + "@ai-sdk/provider": "npm:1.0.7" + "@ai-sdk/provider-utils": "npm:2.1.6" + peerDependencies: + zod: ^3.0.0 + checksum: 10/6c8a44ccd8d7bfb5c10541010eb57f30a8608bd4bf95d95edd7f30f136470fc7618fd28c4e873dbf2831833b51b75d3d4d1eed3e7160239d9c2a4986746423b7 + languageName: node + linkType: hard + +"@ai-sdk/deepseek@npm:^0.1.8": + version: 0.1.8 + resolution: "@ai-sdk/deepseek@npm:0.1.8" + dependencies: + "@ai-sdk/openai-compatible": "npm:0.1.8" + "@ai-sdk/provider": "npm:1.0.7" + "@ai-sdk/provider-utils": "npm:2.1.6" + peerDependencies: + zod: ^3.0.0 + checksum: 10/bb10f357a17b62cbb05418e3492a1234a6a71b71f646409c98a10db1802cc8c19895cb02afbe6fd6d9820ff47f39c6101287a73b6234c2c0cb5e345b38c2227f + languageName: node + linkType: hard + +"@ai-sdk/openai-compatible@npm:0.1.8": + version: 0.1.8 + resolution: "@ai-sdk/openai-compatible@npm:0.1.8" + dependencies: + "@ai-sdk/provider": "npm:1.0.7" + "@ai-sdk/provider-utils": "npm:2.1.6" + peerDependencies: + zod: ^3.0.0 + checksum: 10/f3053c8a8d3049d9434c41ba0fc897f5f0bf065215bd20bbc1c713bff37925036a80d1531f68479c5f4cd8ab6491332fdcf4bab1b6f179cafb2c91c432fb3903 + languageName: node + linkType: hard + +"@ai-sdk/openai@npm:^1.1.9": + version: 1.1.9 + resolution: "@ai-sdk/openai@npm:1.1.9" + dependencies: + "@ai-sdk/provider": "npm:1.0.7" + "@ai-sdk/provider-utils": "npm:2.1.6" + peerDependencies: + zod: ^3.0.0 + checksum: 10/f3c7baef143178bd34c5ffa62f9b236623228d7cb18e290dc167c0579c8ed4d669153574f8a8f0c35e0fc4d7c69ff4ec40788511e8f2c83c20c4c586460c3ff4 + languageName: node + linkType: hard + +"@ai-sdk/provider-utils@npm:2.1.6": + version: 2.1.6 + resolution: "@ai-sdk/provider-utils@npm:2.1.6" + dependencies: + "@ai-sdk/provider": "npm:1.0.7" + eventsource-parser: "npm:^3.0.0" + nanoid: "npm:^3.3.8" + secure-json-parse: "npm:^2.7.0" + peerDependencies: + zod: ^3.0.0 + peerDependenciesMeta: + zod: + optional: true + checksum: 10/48804ab8aba51e1a47d1f17d5f1f4a4617837ef633eebc4159db36b683e96b7603b166bdff871fc84d6d4e40075a89d67fa7f2bb56bd6a2b13904618bed621d4 + languageName: node + linkType: hard + +"@ai-sdk/provider@npm:1.0.7": + version: 1.0.7 + resolution: "@ai-sdk/provider@npm:1.0.7" + dependencies: + json-schema: "npm:^0.4.0" + checksum: 10/75b56a82a1d837e40fd5c35fecf0bf74f1b05e2d0f93cc6a57f90defd4d8eb6f903c170e37644f4271c27cac59bb65716369c94c07de4269d54b1d53f50431a4 + languageName: node + linkType: hard + +"@ai-sdk/react@npm:1.1.10": + version: 1.1.10 + resolution: "@ai-sdk/react@npm:1.1.10" + dependencies: + "@ai-sdk/provider-utils": "npm:2.1.6" + "@ai-sdk/ui-utils": "npm:1.1.10" + swr: "npm:^2.2.5" + throttleit: "npm:2.1.0" + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + zod: ^3.0.0 + peerDependenciesMeta: + react: + optional: true + zod: + optional: true + checksum: 10/291de5433b3927dadfb8bda9e3318fc915d099f9ee85092fb1a6700fe244e2ec302e526444bc84ceecfc5f6f670af6778ac4a1ed70152958b675e1aef5bd4490 + languageName: node + linkType: hard + +"@ai-sdk/ui-utils@npm:1.1.10": + version: 1.1.10 + resolution: "@ai-sdk/ui-utils@npm:1.1.10" + dependencies: + "@ai-sdk/provider": "npm:1.0.7" + "@ai-sdk/provider-utils": "npm:2.1.6" + zod-to-json-schema: "npm:^3.24.1" + peerDependencies: + zod: ^3.0.0 + peerDependenciesMeta: + zod: + optional: true + checksum: 10/dcf4792654b27a3a47411aaca86f64f2843508c3dbb869e8a9be0dc146b70773d882575a0160a6fcf8c74c247696c9a1a4d6e2da4c02d8db6022ce0c55626ab1 + languageName: node + linkType: hard + "@ampproject/remapping@npm:^2.2.0": version: 2.3.0 resolution: "@ampproject/remapping@npm:2.3.0" @@ -136,6 +247,21 @@ __metadata: languageName: node linkType: hard +"@anthropic-ai/sdk@npm:^0.36.3": + version: 0.36.3 + resolution: "@anthropic-ai/sdk@npm:0.36.3" + dependencies: + "@types/node": "npm:^18.11.18" + "@types/node-fetch": "npm:^2.6.4" + abort-controller: "npm:^3.0.0" + agentkeepalive: "npm:^4.2.1" + form-data-encoder: "npm:1.7.2" + formdata-node: "npm:^4.3.2" + node-fetch: "npm:^2.6.7" + checksum: 10/fb6f2551c4dd090b32ca613b71c99f35dd4886bb2344fb9c0cdfb9562273ebe60dc9534e621dc892d71d26b7ef9eb6c55c6c201488077e2cd20cb4cafd8a3a03 + languageName: node + linkType: hard + "@ast-grep/napi-darwin-arm64@npm:0.17.1": version: 0.17.1 resolution: "@ast-grep/napi-darwin-arm64@npm:0.17.1" @@ -2655,6 +2781,18 @@ __metadata: languageName: node linkType: hard +"@modelcontextprotocol/sdk@npm:^1.3.1": + version: 1.3.1 + resolution: "@modelcontextprotocol/sdk@npm:1.3.1" + dependencies: + content-type: "npm:^1.0.5" + raw-body: "npm:^3.0.0" + zod: "npm:^3.23.8" + zod-to-json-schema: "npm:^3.24.1" + checksum: 10/d931c7aba1489704a52d1fb6ac341ea6fbb4ef8a2059c83008da959d27a06c02fd5c326efb6286da332eeb691e44b9797cb58dbbf69dd5be2df2562c9e889968 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -3241,13 +3379,21 @@ __metadata: version: 0.0.0-use.local resolution: "@opensumi/ide-ai-native@workspace:packages/ai-native" dependencies: + "@ai-sdk/anthropic": "npm:^1.1.6" + "@ai-sdk/deepseek": "npm:^0.1.8" + "@ai-sdk/openai": "npm:^1.1.9" + "@anthropic-ai/sdk": "npm:^0.36.3" + "@modelcontextprotocol/sdk": "npm:^1.3.1" + "@opensumi/ide-addons": "workspace:*" "@opensumi/ide-components": "workspace:*" + "@opensumi/ide-connection": "workspace:*" "@opensumi/ide-core-browser": "workspace:*" "@opensumi/ide-core-common": "workspace:*" "@opensumi/ide-core-node": "workspace:*" "@opensumi/ide-debug": "workspace:*" "@opensumi/ide-design": "workspace:*" "@opensumi/ide-editor": "workspace:*" + "@opensumi/ide-file-search": "workspace:*" "@opensumi/ide-file-service": "workspace:*" "@opensumi/ide-file-tree-next": "workspace:*" "@opensumi/ide-main-layout": "workspace:*" @@ -3261,12 +3407,16 @@ __metadata: "@opensumi/ide-utils": "workspace:*" "@opensumi/ide-workspace": "workspace:*" "@xterm/xterm": "npm:5.5.0" + ai: "npm:^4.1.21" ansi-regex: "npm:^2.0.0" dom-align: "npm:^1.7.0" + rc-collapse: "npm:^4.0.0" react-chat-elements: "npm:^12.0.10" react-highlight: "npm:^0.15.0" tiktoken: "npm:1.0.12" web-tree-sitter: "npm:0.22.6" + zod: "npm:^3.23.8" + zod-to-json-schema: "npm:^3.24.1" languageName: unknown linkType: soft @@ -4429,6 +4579,13 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/api@npm:1.9.0": + version: 1.9.0 + resolution: "@opentelemetry/api@npm:1.9.0" + checksum: 10/a607f0eef971893c4f2ee2a4c2069aade6ec3e84e2a1f5c2aac19f65c5d9eeea41aa72db917c1029faafdd71789a1a040bdc18f40d63690e22ccae5d7070f194 + languageName: node + linkType: hard + "@parcel/watcher@npm:2.1.0": version: 2.1.0 resolution: "@parcel/watcher@npm:2.1.0" @@ -5206,6 +5363,13 @@ __metadata: languageName: node linkType: hard +"@types/diff-match-patch@npm:^1.0.36": + version: 1.0.36 + resolution: "@types/diff-match-patch@npm:1.0.36" + checksum: 10/7d7ce03422fcc3e79d0cda26e4748aeb176b75ca4b4e5f38459b112bf24660d628424bdb08d330faefa69039d19a5316e7a102a8ab68b8e294c8346790e55113 + languageName: node + linkType: hard + "@types/diff@npm:^7.0.0": version: 7.0.1 resolution: "@types/diff@npm:7.0.1" @@ -5614,6 +5778,16 @@ __metadata: languageName: node linkType: hard +"@types/node-fetch@npm:^2.6.4": + version: 2.6.12 + resolution: "@types/node-fetch@npm:2.6.12" + dependencies: + "@types/node": "npm:*" + form-data: "npm:^4.0.0" + checksum: 10/8107c479da83a3114fcbfa882eba95ee5175cccb5e4dd53f737a96f2559ae6262f662176b8457c1656de09ec393cc7b20a266c077e4bfb21e929976e1cf4d0f9 + languageName: node + linkType: hard + "@types/node-forge@npm:^1.3.0": version: 1.3.11 resolution: "@types/node-forge@npm:1.3.11" @@ -5646,6 +5820,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^18.11.18": + version: 18.19.68 + resolution: "@types/node@npm:18.19.68" + dependencies: + undici-types: "npm:~5.26.4" + checksum: 10/024a4a8eeca21c0d1eaa575036dbc44528eae180821de71b77868ddc24d18032b988582046db4f7ea2643970a5169d790e1884153472145de07d629bc2ce2ec6 + languageName: node + linkType: hard + "@types/node@npm:^22.7.6": version: 22.7.6 resolution: "@types/node@npm:22.7.6" @@ -6672,6 +6855,28 @@ __metadata: languageName: node linkType: hard +"ai@npm:^4.1.21": + version: 4.1.21 + resolution: "ai@npm:4.1.21" + dependencies: + "@ai-sdk/provider": "npm:1.0.7" + "@ai-sdk/provider-utils": "npm:2.1.6" + "@ai-sdk/react": "npm:1.1.10" + "@ai-sdk/ui-utils": "npm:1.1.10" + "@opentelemetry/api": "npm:1.9.0" + jsondiffpatch: "npm:0.6.0" + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + zod: ^3.0.0 + peerDependenciesMeta: + react: + optional: true + zod: + optional: true + checksum: 10/e834a4e8e6eb3c3f71dba2ba7679877ee1fc01312dbcafb02ce301a5a020b64ee509ca551c31356c14e8a28542728108d98bb2c8955496895cd16f4d5521a552 + languageName: node + linkType: hard + "ajv-formats@npm:^2.1.1": version: 2.1.1 resolution: "ajv-formats@npm:2.1.1" @@ -8743,7 +8948,7 @@ __metadata: languageName: node linkType: hard -"content-type@npm:^1.0.4, content-type@npm:~1.0.4, content-type@npm:~1.0.5": +"content-type@npm:^1.0.4, content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5": version: 1.0.5 resolution: "content-type@npm:1.0.5" checksum: 10/585847d98dc7fb8035c02ae2cb76c7a9bd7b25f84c447e5ed55c45c2175e83617c8813871b4ee22f368126af6b2b167df655829007b21aa10302873ea9c62662 @@ -9991,6 +10196,13 @@ __metadata: languageName: node linkType: hard +"dequal@npm:^2.0.3": + version: 2.0.3 + resolution: "dequal@npm:2.0.3" + checksum: 10/6ff05a7561f33603df87c45e389c9ac0a95e3c056be3da1a0c4702149e3a7f6fe5ffbb294478687ba51a9e95f3a60e8b6b9005993acd79c292c7d15f71964b6b + languageName: node + linkType: hard + "des.js@npm:^1.0.0": version: 1.1.0 resolution: "des.js@npm:1.1.0" @@ -10050,6 +10262,13 @@ __metadata: languageName: node linkType: hard +"diff-match-patch@npm:^1.0.5": + version: 1.0.5 + resolution: "diff-match-patch@npm:1.0.5" + checksum: 10/fd1ab417eba9559bda752a4dfc9a8ac73fa2ca8b146d29d153964b437168e301c09d8a688fae0cd81d32dc6508a4918a94614213c85df760793f44e245173bb6 + languageName: node + linkType: hard + "diff-sequences@npm:^29.6.3": version: 29.6.3 resolution: "diff-sequences@npm:29.6.3" @@ -11326,6 +11545,13 @@ __metadata: languageName: node linkType: hard +"eventsource-parser@npm:^3.0.0": + version: 3.0.0 + resolution: "eventsource-parser@npm:3.0.0" + checksum: 10/8215adf5d8404105ecd0658030b0407e06987ceb9aadcea28a38d69bacf02e5d0fc8bba5fa7c3954552c89509c8ef5e1fa3895e000c061411c055b4bbc26f4b0 + languageName: node + linkType: hard + "evp_bytestokey@npm:^1.0.0, evp_bytestokey@npm:^1.0.3": version: 1.0.3 resolution: "evp_bytestokey@npm:1.0.3" @@ -11972,6 +12198,13 @@ __metadata: languageName: node linkType: hard +"form-data-encoder@npm:1.7.2": + version: 1.7.2 + resolution: "form-data-encoder@npm:1.7.2" + checksum: 10/227bf2cea083284411fd67472ccc22f5cb354ca92c00690e11ff5ed942d993c13ac99dea365046306200f8bd71e1a7858d2d99e236de694b806b1f374a4ee341 + languageName: node + linkType: hard + "form-data@npm:^4.0.0": version: 4.0.0 resolution: "form-data@npm:4.0.0" @@ -11983,6 +12216,16 @@ __metadata: languageName: node linkType: hard +"formdata-node@npm:^4.3.2": + version: 4.4.1 + resolution: "formdata-node@npm:4.4.1" + dependencies: + node-domexception: "npm:1.0.0" + web-streams-polyfill: "npm:4.0.0-beta.3" + checksum: 10/29622f75533107c1bbcbe31fda683e6a55859af7f48ec354a9800591ce7947ed84cd3ef2b2fcb812047a884f17a1bac75ce098ffc17e23402cd373e49c1cd335 + languageName: node + linkType: hard + "forwarded@npm:0.2.0": version: 0.2.0 resolution: "forwarded@npm:0.2.0" @@ -15158,6 +15401,13 @@ __metadata: languageName: node linkType: hard +"json-schema@npm:^0.4.0": + version: 0.4.0 + resolution: "json-schema@npm:0.4.0" + checksum: 10/8b3b64eff4a807dc2a3045b104ed1b9335cd8d57aa74c58718f07f0f48b8baa3293b00af4dcfbdc9144c3aafea1e97982cc27cc8e150fc5d93c540649507a458 + languageName: node + linkType: hard + "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" @@ -15222,6 +15472,19 @@ __metadata: languageName: node linkType: hard +"jsondiffpatch@npm:0.6.0": + version: 0.6.0 + resolution: "jsondiffpatch@npm:0.6.0" + dependencies: + "@types/diff-match-patch": "npm:^1.0.36" + chalk: "npm:^5.3.0" + diff-match-patch: "npm:^1.0.5" + bin: + jsondiffpatch: bin/jsondiffpatch.js + checksum: 10/124b9797c266c693e69f8d23216e64d5ca4b21a4ec10e3a769a7b8cb19602ba62522f9a3d0c55299c1bfbe5ad955ca9ad2852439ca2c6b6316b8f91a5c218e94 + languageName: node + linkType: hard + "jsonfile@npm:^4.0.0": version: 4.0.0 resolution: "jsonfile@npm:4.0.0" @@ -17091,7 +17354,7 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:3.3.8": +"nanoid@npm:3.3.8, nanoid@npm:^3.3.8": version: 3.3.8 resolution: "nanoid@npm:3.3.8" bin: @@ -17245,6 +17508,13 @@ __metadata: languageName: node linkType: hard +"node-domexception@npm:1.0.0": + version: 1.0.0 + resolution: "node-domexception@npm:1.0.0" + checksum: 10/e332522f242348c511640c25a6fc7da4f30e09e580c70c6b13cb0be83c78c3e71c8d4665af2527e869fc96848924a4316ae7ec9014c091e2156f41739d4fa233 + languageName: node + linkType: hard + "node-fetch@npm:2.6.7": version: 2.6.7 resolution: "node-fetch@npm:2.6.7" @@ -19872,6 +20142,18 @@ __metadata: languageName: node linkType: hard +"raw-body@npm:^3.0.0": + version: 3.0.0 + resolution: "raw-body@npm:3.0.0" + dependencies: + bytes: "npm:3.1.2" + http-errors: "npm:2.0.0" + iconv-lite: "npm:0.6.3" + unpipe: "npm:1.0.0" + checksum: 10/2443429bbb2f9ae5c50d3d2a6c342533dfbde6b3173740b70fa0302b30914ff400c6d31a46b3ceacbe7d0925dc07d4413928278b494b04a65736fc17ca33e30c + languageName: node + linkType: hard + "rc-align@npm:^2.4.0": version: 2.4.5 resolution: "rc-align@npm:2.4.5" @@ -19946,6 +20228,21 @@ __metadata: languageName: node linkType: hard +"rc-collapse@npm:^4.0.0": + version: 4.0.0 + resolution: "rc-collapse@npm:4.0.0" + dependencies: + "@babel/runtime": "npm:^7.10.1" + classnames: "npm:2.x" + rc-motion: "npm:^2.3.4" + rc-util: "npm:^5.27.0" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10/2afdaf2e445bff0c6c4702ca8bb2f3a2be5e3c11806b8327cafba2ed72af7dc720b9b8f51e3b6ce55a4546628e87ad8c785c42362467d71ee772281c7f0fc1c8 + languageName: node + linkType: hard + "rc-collapse@npm:~3.8.0": version: 3.8.0 resolution: "rc-collapse@npm:3.8.0" @@ -21715,6 +22012,13 @@ __metadata: languageName: node linkType: hard +"secure-json-parse@npm:^2.7.0": + version: 2.7.0 + resolution: "secure-json-parse@npm:2.7.0" + checksum: 10/974386587060b6fc5b1ac06481b2f9dbbb0d63c860cc73dc7533f27835fdb67b0ef08762dbfef25625c15bc0a0c366899e00076cb0d556af06b71e22f1dede4c + languageName: node + linkType: hard + "select-hose@npm:^2.0.0": version: 2.0.0 resolution: "select-hose@npm:2.0.0" @@ -23014,6 +23318,18 @@ __metadata: languageName: node linkType: hard +"swr@npm:^2.2.5": + version: 2.3.0 + resolution: "swr@npm:2.3.0" + dependencies: + dequal: "npm:^2.0.3" + use-sync-external-store: "npm:^1.4.0" + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10/9f09a68a0dcd354915c7098b000197190aa5faa39c6caec7b91c3b9b682de79173abd5b733cd07cc3e79ee8a1eb294f7d2162716c515d1e4d7c1283d4342fda8 + languageName: node + linkType: hard + "symbol-tree@npm:^3.2.4": version: 3.2.4 resolution: "symbol-tree@npm:3.2.4" @@ -23202,6 +23518,13 @@ __metadata: languageName: node linkType: hard +"throttleit@npm:2.1.0": + version: 2.1.0 + resolution: "throttleit@npm:2.1.0" + checksum: 10/a2003947aafc721c4a17e6f07db72dc88a64fa9bba0f9c659f7997d30f9590b3af22dadd6a41851e0e8497d539c33b2935c2c7919cf4255922509af6913c619b + languageName: node + linkType: hard + "through2@npm:^0.6.3": version: 0.6.5 resolution: "through2@npm:0.6.5" @@ -24143,6 +24466,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.4.0": + version: 1.4.0 + resolution: "use-sync-external-store@npm:1.4.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10/08bf581a8a2effaefc355e9d18ed025d436230f4cc973db2f593166df357cf63e47b9097b6e5089b594758bde322e1737754ad64905e030d70f8ff7ee671fd01 + languageName: node + linkType: hard + "user-home@npm:^2.0.0": version: 2.0.0 resolution: "user-home@npm:2.0.0" @@ -24512,6 +24844,13 @@ __metadata: languageName: node linkType: hard +"web-streams-polyfill@npm:4.0.0-beta.3": + version: 4.0.0-beta.3 + resolution: "web-streams-polyfill@npm:4.0.0-beta.3" + checksum: 10/dcdef67de57d83008f9dc330662b65ba4497315555dd0e4e7bcacb132ffdf8a830eaab8f74ad40a4a44f542461f51223f406e2a446ece1cc29927859b1405853 + languageName: node + linkType: hard + "web-tree-sitter@npm:0.22.6": version: 0.22.6 resolution: "web-tree-sitter@npm:0.22.6" @@ -25292,3 +25631,19 @@ __metadata: checksum: 10/2cac84540f65c64ccc1683c267edce396b26b1e931aa429660aefac8fbe0188167b7aee815a3c22fa59a28a58d898d1a2b1825048f834d8d629f4c2a5d443801 languageName: node linkType: hard + +"zod-to-json-schema@npm:^3.24.1": + version: 3.24.1 + resolution: "zod-to-json-schema@npm:3.24.1" + peerDependencies: + zod: ^3.24.1 + checksum: 10/d31fd05b67b428d8e0d5ecad2c3e80a1c2fc370e4c22f9111ffd11cbe05cfcab00f3228f84295830952649d15ea4494ef42c2ee1cbe723c865b13f4cf2b80c09 + languageName: node + linkType: hard + +"zod@npm:^3.23.8": + version: 3.24.1 + resolution: "zod@npm:3.24.1" + checksum: 10/54e25956495dec22acb9399c168c6ba657ff279801a7fcd0530c414d867f1dcca279335e160af9b138dd70c332e17d548be4bc4d2f7eaf627dead50d914fec27 + languageName: node + linkType: hard