feat: support MCP server and client (#4335)

* feat: mcp server client poc

* feat: introduce MCP tools contribution

* fix: 修复 mcp sdk 引入类型问题

* feat: add builtin MCP server

* fix: mcp types fix

* fix: mcp types fix2

* feat: sumi mcp builtin sever

* feat: code optimization

* feat: support llm tool call streaming and ui, more mcp tools

* feat: enhance language model error handling and streaming

* feat: mcp tools grouped by clientId, add mcp tools panel

* feat: add openai compatible api preferences

* feat: support chat history in language model request

* feat: add MCP server configuration support via preferences

* feat: implement readfile & readdir tools

* fix: tool impl bugs

* refactor: use design system variables in ChatToolRender styles

* refactor: improve logging and revert some unnecessary optimization

* fix: logger not work in node.js

* fix: mcp tool render fix

* feat: add MCP and custom LLM config

* fix: build error fix

* fix: lint fix

* fix: lint fix

* fix: lint error fix

* feat: format the tool call error message

* feat: add doc, lint fix, create file tool fix

* fix: lint fix

* feat: add llmcontext service (#4374)

* feat: add llmcontext service

* feat: integrate rc-collapse for improved chat context UI

* feat: implement ChatAgentPromptProvider for enhanced context handling

* feat: update chat agent prompt provider to use serialized context

* fix: build error

* feat: close selector when click outside

* chore: import order

* feat: run terminal cmd tool (#4383)

* chore: update terminal

* chore: update exit code

* feat: enhance terminal command execution with success message and auto-close feature

* feat: add some unit tests

---------

Co-authored-by: ensorrow <zheyanglv@qq.com>
Co-authored-by: 大表哥 <binshao54@gmail.com>
Co-authored-by: liuqian <xubing.bxb@alibaba-inc.com>
This commit is contained in:
RetroX 2025-02-18 16:59:01 +08:00 committed by GitHub
parent dacfb46e42
commit ff995f510d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 5682 additions and 88 deletions

3
.gitignore vendored
View File

@ -98,4 +98,5 @@ tools/workspace
# jupyter
.ipynb_checkpoints
*.tsbuildinfo
*.tsbuildinfo
.env

View File

@ -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"
},

View File

@ -354,7 +354,7 @@ export class FileSearchQuickCommandHandler {
return results;
}
protected async getQueryFiles(fileQuery: string, alreadyCollected: Set<string>, token: CancellationToken) {
async getQueryFiles(fileQuery: string, alreadyCollected: Set<string>, 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(

225
packages/ai-native/MCP.md Normal file
View File

@ -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: <error message>'
}],
isError: true
}
```

View File

@ -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. 验证外部服务器配置

View File

@ -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',
});
});
});

View File

@ -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');
});
});

View File

@ -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}`);
});
});

View File

@ -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');
});
});

View File

@ -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<LiveInlineDiffPreviewer>;
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),
);
});
});

View File

@ -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);
});
});
});

View File

@ -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),
}),
);
});
});
});

View File

@ -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:*"

View File

@ -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<AINativeCoreContribution>;
@Autowired(MCPServerContribution)
private readonly mcpServerContributions: ContributionProvider<MCPServerContribution>;
@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<MCPServerDescription[]>(
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'),

View File

@ -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);

View File

@ -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<void> = new Deferred<void>();
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<string>(AINativeSettingSectionsId.LLMModelSelection);
let apiKey: string = '';
let baseURL: string = '';
if (model === 'deepseek') {
apiKey = this.preferenceService.get<string>(AINativeSettingSectionsId.DeepseekApiKey, '');
} else if (model === 'openai') {
apiKey = this.preferenceService.get<string>(AINativeSettingSectionsId.OpenaiApiKey, '');
baseURL = this.preferenceService.get<string>(AINativeSettingSectionsId.OpenaiBaseURL, '');
} else {
apiKey = this.preferenceService.get<string>(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,

View File

@ -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>(IChatAgentService);
const chatFeatureRegistry = useInjectable<ChatFeatureRegistry>(ChatFeatureRegistryToken);
const chatRenderRegistry = useInjectable<ChatRenderRegistry>(ChatRenderRegistryToken);
const contextService = useInjectable<LLMContextService>(LLMContextServiceToken);
const promptProvider = useInjectable<ChatAgentPromptProvider>(ChatAgentPromptProvider);
const layoutService = useInjectable<IMainLayoutService>(IMainLayoutService);
const mcpServerProxyService = useInjectable<MCPServerProxyService>(TokenMCPServerProxyService);
const msgHistoryManager = aiChatService.sessionModel.history;
const containerRef = React.useRef<HTMLDivElement>(null);
const chatInputRef = React.useRef<{ setInputValue: (v: string) => void } | null>(null);
const dialogService = useInjectable<IDialogService>(IDialogService);
const aiNativeConfigService = useInjectable<AINativeConfigService>(AINativeConfigService);
const [shortcutCommands, setShortcutCommands] = React.useState<ChatSlashCommandItemModel[]>([]);
@ -81,6 +104,7 @@ export const AIChatView = () => {
const [defaultAgentId, setDefaultAgentId] = React.useState<string>('');
const [command, setCommand] = React.useState('');
const [theme, setTheme] = React.useState<string | null>(null);
const [mcpToolsCount, setMcpToolsCount] = React.useState<number>(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: <MCPToolsDialog tools={tools} />,
type: MessageType.Empty,
buttons: ['关闭'],
});
}, [mcpServerProxyService, dialogService]);
return (
<div id={styles.ai_chat_view}>
<div className={styles.header_container}>
@ -643,6 +689,7 @@ export const AIChatView = () => {
/>
</div>
<div className={styles.chat_input_wrap}>
<ChatContext />
<div className={styles.header_operate}>
<div className={styles.header_operate_left}>
{shortcutCommands.map((command) => (
@ -657,7 +704,13 @@ export const AIChatView = () => {
</Popover>
))}
</div>
<div className={styles.header_operate_right}></div>
<div className={styles.header_operate_right}>
{aiNativeConfigService.capabilities.supportsMCP && (
<div className={styles.tag} onClick={handleShowMCPTools}>
{`MCP Tools: ${mcpToolsCount}`}
</div>
)}
</div>
</div>
<ChatInputWrapperRender
onSend={(value, agentId, command) =>

View File

@ -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>(LabelService);
const appConfig = useInjectable<AppConfig>(AppConfig);
const itemsRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (active && itemsRef.current) {
const scrollBehavior: ScrollIntoViewOptions = {
behavior: 'instant',
block: 'end',
};
itemsRef.current.scrollIntoView(scrollBehavior);
}
}, [active, itemsRef.current]);
return (
<div
className={cls(styles.candidate_file, active && styles.active)}
ref={(ele) => (itemsRef.current = ele)}
onClick={() => (selected ? onDidDeselect(uri) : onDidSelect(uri))}
>
<Icon iconClass={labelService.getIcon(uri)} />
<span className={styles.basename}>{uri.path.base}</span>
<span className={styles.dir}>{URI.file(appConfig.workspaceDir).relative(uri.parent)?.toString()}</span>
{selected && <Icon icon='check' style={{ marginLeft: 'auto', color: 'var(--editorGutter-addedBackground)' }} />}
</div>
);
});
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<URI[]>([]);
const [activeFile, setActiveFile] = useState<URI | null>(null);
const [searching, toggleSearching] = useState(false);
const [searchResults, updateSearchResults] = useState<URI[]>([]);
const recentFilesManager: RecentFilesManager = useInjectable(RecentFilesManager);
const appConfig = useInjectable<AppConfig>(AppConfig);
const searchService = useInjectable<IFileSearchService>(FileSearchServicePath);
const container = useRef<HTMLDivElement | null>();
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 (
<ClickOutside mouseEvents={['click', 'contextmenu']} onOutsideClick={() => onDidClose()}>
<div className={styles.context_selector} onKeyDown={onDidKeyDown} tabIndex={-1}>
<div style={{ padding: '4px' }}>
<Input placeholder='Search files by name' autoFocus onInput={onDidInput} />
</div>
<Scrollbars forwardedRef={(el) => (el ? (container.current = el.ref) : null)}>
<div className={styles.context_list}>
{searching && <div className={styles.context_search_layer} />}
<span className={styles.list_desc}>
{searchResults.length > 0 ? 'Search Results' : 'Recent Opened Files'}
</span>
{(searchResults.length > 0 ? searchResults : candidateFiles).map((file) => (
<CandidateFile
key={file.toString()}
uri={file}
active={activeFile === file}
onDidSelect={onDidSelect}
onDidDeselect={onDidDeselect}
selected={!!addedFiles.find((val) => val.uri.isEqual(file))}
/>
))}
</div>
</Scrollbars>
</div>
</ClickOutside>
);
});

View File

@ -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<FileContext[]>([]);
const [contextOverlay, toggleContextOverlay] = useState(false);
const labelService = useInjectable<LabelService>(LabelService);
const appConfig = useInjectable<AppConfig>(AppConfig);
const workbenchEditorService = useInjectable<WorkbenchEditorService>(WorkbenchEditorService);
const contextService = useInjectable<LLMContextService>(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 (
<div className={styles.chat_context}>
<Collapse
// @ts-ignore
expandIcon={({ isActive }) => (isActive ? <Icon icon='down' /> : <Icon icon='right' />)}
defaultActiveKey={['context-panel']}
onChange={() => {}}
>
<Panel
header={
<div className={styles.context_header}>
<h3 className={styles.chat_context_title}>Context</h3>
<Popover
overlayClassName={styles.popover_icon}
id={'ai-context-header-clear'}
title={localize('aiNative.operate.clear.title')}
>
<EnhanceIcon
wrapperClassName={styles.action_btn}
className={getIcon('clear')}
onClick={onDidCleanFiles}
tabIndex={0}
role='button'
ariaLabel={localize('aiNative.operate.clear.title')}
/>
</Popover>
</div>
}
key='context-panel'
>
<div className={styles.file_list}>
{addedFiles.map((file) => (
<div className={styles.selected_item} key={file.uri.toString()} onClick={() => onDidClickFile(file.uri)}>
<Icon iconClass={labelService.getIcon(file.uri)} />
<span className={styles.basename}>
{file.uri.path.base}
{file.selection ? ` (${file.selection[0]}-${file.selection[1]})` : ''}
</span>
<span className={styles.dir}>
{URI.file(appConfig.workspaceDir).relative(file.uri.parent)?.toString()}
</span>
<Icon icon='close' className={styles.close_icon} onClick={(e) => onDidRemoveFile(e, file.uri)} />
</div>
))}
</div>
<div className={styles.add_context} onClick={openContextOverlay}>
<Icon icon='add' />
Add Files
</div>
</Panel>
</Collapse>
{contextOverlay && (
<ContextSelector
onDidClose={() => toggleContextOverlay(false)}
onDidDeselect={onDidDeselect}
onDidSelect={onDidSelect}
addedFiles={addedFiles}
/>
)}
</div>
);
});

View File

@ -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);
}
}
}

View File

@ -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;

View File

@ -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<IChatAgentViewService>(ChatAgentViewServiceToken);
const [node, setNode] = useState<React.JSX.Element | null>(null);
useEffect(() => {
const config = chatAgentViewService.getChatComponent('toolCall');
if (config) {
const { component: Component, initialProps } = config;
setNode(<Component {...initialProps} value={toolCall} />);
return;
}
setNode(
<div>
<Loading />
<span style={{ marginLeft: 4 }}></span>
</div>,
);
const deferred = chatAgentViewService.getChatComponentDeferred('toolCall')!;
deferred.promise.then(({ component: Component, initialProps }) => {
setNode(<Component {...initialProps} value={toolCall} />);
});
}, [toolCall.state]);
return node;
};
const ComponentRender = (props: { component: string; value?: unknown }) => {
const chatAgentViewService = useInjectable<IChatAgentViewService>(ChatAgentViewServiceToken);
const [node, setNode] = useState<React.JSX.Element | null>(null);
@ -274,6 +302,8 @@ export const ChatReply = (props: IChatReplyProps) => {
<ComponentRender component={componentId} value={value} />
);
const renderToolCall = (toolCall: IChatToolContent['content']) => <ToolCallRender toolCall={toolCall} />;
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);
}

View File

@ -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);
}
}

View File

@ -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: <Loading /> };
case 'complete':
return { label: 'Complete', icon: <Icon iconClass="codicon codicon-check" /> };
case 'result':
return { label: 'Result Ready', icon: <Icon iconClass="codicon codicon-check-all" /> };
default:
return { label: state || 'Unknown', icon: <Icon iconClass="codicon codicon-question" /> };
}
};
const toggleExpand = () => {
setIsExpanded(!isExpanded);
};
const stateInfo = getStateInfo(value.state);
return (
<div className={styles['chat-tool-render']}>
<div className={styles['tool-header']} onClick={toggleExpand}>
<div className={styles['tool-name']}>
<span className={cls(styles['expand-icon'], { [styles.expanded]: isExpanded })}></span>
{value?.function?.name}
</div>
{value.state && (
<div className={styles['tool-state']}>
<span className={styles['state-icon']}>{stateInfo.icon}</span>
<span className={styles['state-label']}>{stateInfo.label}</span>
</div>
)}
</div>
<div className={cls(styles['tool-content'], { [styles.expanded]: isExpanded })}>
{value?.function?.arguments && (
<div className={styles['tool-arguments']}>
<div className={styles['section-label']}>Arguments</div>
<CodeEditorWithHighlight
input={value?.function?.arguments}
language={'json'}
relationId={uuid(4)}
/>
</div>
)}
{value?.result && (
<div className={styles['tool-result']}>
<div className={styles['section-label']}>Result</div>
<CodeEditorWithHighlight
input={value.result}
language={'json'}
relationId={uuid(4)}
/>
</div>
)}
</div>
</div>
);
};

View File

@ -244,44 +244,45 @@
}
}
.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;
}
}
.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;
}
}
}
}
.code_block {
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;
}
}
.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 {
.hljs {

View File

@ -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();
}
}

View File

@ -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<string, FileContext> = new Map();
private onDidContextFilesChangeEmitter = new Emitter<FileContext[]>();
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,
};
}
}

View File

@ -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,
},
];
}

View File

@ -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<any>();
public readonly onChangeMCPServers: Event<any> = 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();
}
}

View File

@ -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,
};
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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<MCPToolsDialogProps> = ({ tools }) => (
<div className={styles.mcp_tools_dialog}>
<div className={styles.dialog_title}>MCP Tools</div>
<div className={styles.tools_list}>
{tools.map((tool) => (
<div key={tool.name} className={styles.tool_item}>
<div className={styles.tool_name}>{tool.name}</div>
<div className={styles.tool_description}>{tool.description}</div>
{tool.providerName && <div className={styles.tool_provider}>Provider: {tool.providerName}</div>}
</div>
))}
</div>
</div>
);

View File

@ -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<typeof inputSchema>, 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,
};
}
}
}

View File

@ -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<typeof inputSchema>, 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,
};
}
}
}

View File

@ -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<typeof inputSchema>, 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 }],
};
}
}

View File

@ -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<typeof inputSchema>, 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';
}
}
}

View File

@ -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<typeof inputSchema>, 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,
};
}
}
}

View File

@ -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<typeof inputSchema>, 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';
}
}
}

View File

@ -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<typeof inputSchema>, 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 }],
};
}
}

View File

@ -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<typeof inputSchema>, 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 }],
};
}
}

View File

@ -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<void>((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;
}
}

View File

@ -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<string[]> {
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();
}
}
}

View File

@ -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<typeof inputSchema>, 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')}`,
},
],
};
}
}

View File

@ -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<typeof inputSchema>, 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}
\`\`\``,
},
],
};
}
}

View File

@ -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<typeof inputSchema>, 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,
};
}
}
}

View File

@ -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<typeof inputSchema>, 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,
};
}
}
}

View File

@ -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<typeof inputSchema>, 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;
}
}

View File

@ -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,

View File

@ -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<string>;
}

View File

@ -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<MCPTool[]>;
}
export const SumiMCPServerProxyServicePath = 'SumiMCPServerProxyServicePath';
export interface IChatAgentService {
readonly onDidChangeAgents: Event<void>;
readonly onDidSendMessage: Event<IChatProgress>;

View File

@ -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<FileContext[]>;
/**
* 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 }>;
}

View File

@ -0,0 +1,46 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
export interface MCPServerManager {
callTool(serverName: string, toolName: string, arg_string: string): ReturnType<Client['callTool']>;
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<Client['listTools']>;
getServerNames(): Promise<string[]>;
startServer(serverName: string): Promise<void>;
stopServer(serverName: string): Promise<void>;
getStartedServers(): Promise<string[]>;
registerTools(serverName: string): Promise<void>;
addExternalMCPServers(servers: MCPServerDescription[]): void;
}
export type MCPTool = Awaited<ReturnType<MCPServerManager['getTools']>>['tools'][number];
export type MCPToolParameter = Awaited<ReturnType<MCPServerManager['getTools']>>['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';

View File

@ -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<typeof ToolParameterSchema>;
export interface ToolRequest {
id: string;
name: string;
parameters?: any;
description?: string;
handler: (arg_string: string) => Promise<any>;
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<string, ToolRequest> = new Map<string, ToolRequest>();
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<string, ToolInvocationRegistry> = 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);
}
}

View File

@ -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<MCPTool[]>;
// 通知前端 MCP 服务注册表发生了变化
$updateMCPServers(): Promise<void>;
}
export interface MCPTool {
name: string;
description: string;
inputSchema: any;
providerName: string;
}

View File

@ -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');
}
}

View File

@ -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<any> {
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<any> {
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;
}
}

View File

@ -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');
}
}

View File

@ -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,
},
];
}

View File

@ -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<string, IMCPServer> = 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<void> {
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<string[]> {
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<IMCPServer['callTool']> {
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<void> {
const server = this.servers.get(serverName);
if (!server) {
throw new Error(`MCP server "${serverName}" not found.`);
}
await server.start();
}
async getServerNames(): Promise<string[]> {
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<void> {
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<IMCPServer['getTools']> {
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<void> {
this.addOrUpdateServerDirectly(builtinMCPServer);
await this.registerTools(builtinMCPServer.getServerName());
}
async addExternalMCPServers(servers: MCPServerDescription[]): Promise<void> {
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.`);
}
}
}

View File

@ -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<void>;
getServerName(): string;
callTool(toolName: string, arg_string: string): ReturnType<Client['callTool']>;
getTools(): ReturnType<Client['listTools']>;
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<string, string>,
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<void> {
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<string, string> = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
);
const mergedEnv: Record<string, string> = {
...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;
}
}

View File

@ -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<IMCPServerProxyService> 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<MCPTool[]> {
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<void> {
if (this.started) {
return;
}
// TODO 考虑 MCP Server 的对外暴露
// await this.sumiMCPServer.initMCPServer();
this.started = true;
}
async callTool(toolName: string, arg_string: string): Promise<any> {
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<Client['listTools']> {
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;
}
}

View File

@ -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');
}
}

View File

@ -20,6 +20,8 @@ const DEFAULT_CAPABILITIES: Required<IAINativeCapabilities> = {
supportsProblemFix: true,
supportsTerminalDetection: true,
supportsTerminalCommandSuggest: true,
supportsCustomLLMSettings: true,
supportsMCP: true,
};
const DISABLED_ALL_CAPABILITIES = {} as Required<IAINativeCapabilities>;

View File

@ -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';

View File

@ -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;

View File

@ -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',
},
};

View File

@ -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 服务器的环境变量',
},
};

View File

@ -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) => `
<additional_data>
Below are some potentially helpful/relevant pieces of information for figuring out to respond
<recently_viewed_files>
${context.recentlyViewFiles.map((file, idx) => `${idx} + 1: ${file}`)}
</recently_viewed_files>
<attached_files>
${context.attachedFiles.map(
(file) =>
`
<file_contents>
\`\`\`${file.language} ${file.path}
${file.content}
\`\`\`
</file_contents>
<linter_errors>
${file.lineErrors.join('`n')}
</linter_errors>
`,
)}
</attached_files>
</additional_data>
<user_query>
${userMessage}
</user_query>`,
},
});
}
}

View File

@ -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<ReqeustResponse, ChatReadab
@Autowired(INodeLogger)
protected readonly logger: INodeLogger;
@Autowired(AnthropicModel)
protected readonly anthropicModel: AnthropicModel;
@Autowired(OpenAIModel)
protected readonly openaiModel: OpenAIModel;
@Autowired(DeepSeekModel)
protected readonly deepseekModel: DeepSeekModel;
async request(input: string, options: IAIBackServiceOption, cancelToken?: CancellationToken) {
await sleep(1000);
@ -68,23 +79,21 @@ export class AIBackService implements IAIBackService<ReqeustResponse, ChatReadab
options: IAIBackServiceOption,
cancelToken?: CancellationToken,
): Promise<ChatReadableStream> {
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;
}

View File

@ -27,6 +27,12 @@ renderApp(
minimumReportThresholdTime: 400,
},
},
AINativeConfig: {
capabilities: {
supportsMCP: true,
supportsCustomLLMSettings: true,
},
},
notebookServerHost: 'localhost:8888',
},
}),

View File

@ -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'],
}),

359
yarn.lock
View File

@ -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