diff --git a/.blade/settings.local.json b/.blade/settings.local.json index 5c320e7d..1d1cc1fa 100644 --- a/.blade/settings.local.json +++ b/.blade/settings.local.json @@ -3,7 +3,9 @@ "allow": [ "TestTool", "Bash(git status)", - "Bash(git diff *)" + "Bash(git diff *)", + "Bash(ls -l)", + "Write(**/*.md)" ], "ask": [], "deny": [] diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a12b0e4e..240e0b3c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -65,7 +65,11 @@ "Bash(pnpm approve-builds:*)", "Bash(git reset:*)", "Bash(cloc:*)", - "Bash(grep:*)" + "Bash(grep:*)", + "WebFetch(domain:zed.dev)", + "WebFetch(domain:agentclientprotocol.com)", + "WebFetch(domain:www.npmjs.com)", + "WebFetch(domain:deepwiki.com)" ], "deny": [], "ask": [], diff --git a/BLADE.md b/BLADE.md index d9fa6948..d70ea633 100644 --- a/BLADE.md +++ b/BLADE.md @@ -29,12 +29,15 @@ always respond in Chinese - **HookExecutor.ts**: Hook 执行器 - **Matcher.ts**: 匹配器,决定 Hook 是否触发 - **SecureProcessExecutor.ts**: 安全进程执行器 +- **OutputParser.ts**: Hook 输出解析器,解析 Hook 命令的输出 +- **HookExecutionGuard.ts**: Hook 执行保护器,保证单次工具调用只触发一次 Hook ### 3. Context 管理 (`src/context/`) 统一管理会话和上下文: - **ContextManager.ts**: 核心上下文管理器,支持内存/持久化存储 - **CompactionService.ts**: 上下文压缩服务,防止 token 超限 - **TokenCounter.ts**: Token 计数器 +- **FileAnalyzer.ts**: 文件分析服务,从对话中提取重点文件并读取内容 ### 4. Tool 系统 (`src/tools/`) - **builtin/**: 内置工具(文件操作、Git、网络等) @@ -42,25 +45,30 @@ always respond in Chinese - **execution/**: 工具执行管线 - **validation/**: 工具参数验证 - **MCP 集成**: 支持 Model Context Protocol 扩展 +- **FileLockManager.ts**: 文件锁管理器,防止对同一文件的并发编辑 ### 5. 服务层 (`src/services/`) - **OpenAIChatService.ts**: OpenAI API 服务 - **AnthropicChatService.ts**: Anthropic API 服务 +- **GptOpenaiPlatformChatService.ts**: 通用 OpenAI 平台兼容服务,支持自定义 API 端点 - **SessionService.ts**: 会话管理服务 ### 6. Slash 命令 (`src/slash-commands/`) - **builtinCommands.ts**: 内置命令(/compact, /init, /model 等) +- **ide.ts**: IDE 集成命令(/ide, /ide status, /ide connect, /ide install, /ide disconnect) - **UIActionMapper.ts**: UI 动作映射器 ### 7. MCP 集成 (`src/mcp/`) - **McpRegistry.ts**: MCP 注册表 - **McpClient.ts**: MCP 客户端 - **loadProjectMcpConfig.ts**: 项目 MCP 配置加载 +- **HealthMonitor.ts**: MCP 健康监控器,周期性检查连接状态并自动触发重连 ### 8. UI 层 (`src/ui/`) - **App.tsx**: 主应用组件 - **components/**: UI 组件(BladeInterface, LoadingIndicator 等) - **contexts/**: React Context(SessionContext, AppContext 等) +- **utils/security.ts**: 安全工具函数,处理敏感信息的格式化和过滤 ### 9. 配置系统 (`src/config/`) - **ConfigManager.ts**: 配置管理器 @@ -70,6 +78,12 @@ always respond in Chinese - **blade.tsx**: 入口文件 - **commands/**: 命令实现(config, doctor, mcp, update 等) +### 11. IDE 集成 (`src/ide/`) +- **ideClient.ts**: IDE 客户端,处理与 IDE 的 WebSocket 通信 +- **ideContext.ts**: IDE 上下文,管理 IDE 相关信息和状态 +- **ideInstaller.ts**: IDE 安装器,检测和安装 VS Code 插件 +- **detectIde.ts**: IDE 检测器,检测当前运行环境 + ## 开发命令 ### 构建与运行 @@ -104,9 +118,26 @@ bun run test:cli # 覆盖率测试 bun run test:coverage +# 分类覆盖率测试 +bun run test:unit:coverage +bun run test:integration:coverage +bun run test:cli:coverage + # 监听模式 bun run test:watch +# 分类监听测试 +bun run test:unit:watch +bun run test:integration:watch +bun run test:cli:watch + +# 调试和详细测试 +bun run test:debug +bun run test:verbose + +# 性能测试 +bun run test:performance + # CI 模式(带覆盖率) bun run test:ci ``` @@ -223,6 +254,9 @@ Agent 实例不保存会话状态,所有状态通过 context 参数传入。 5. **性能**: 注意 token 使用,大文件操作需要流式处理 6. **测试**: 新功能必须包含测试用例 7. **文档**: 公共 API 需要 JSDoc 注释 +8. **并发安全**: 文件操作使用 FileLockManager 确保并发安全 +9. **安全过滤**: 敏感信息(如 API Key)在输出时会被自动过滤 +10. **健康监控**: MCP 连接支持健康监控和自动重连机制 ## 项目结构 ``` @@ -239,6 +273,7 @@ src/ ├── prompts/ # 提示词管理 ├── services/ # 服务层 ├── slash-commands/ # Slash 命令 +├── store/ # 全局状态管理 (基于 Zustand) ├── tools/ # 工具系统 ├── ui/ # UI 组件 └── utils/ # 工具函数 diff --git a/package.json b/package.json index f1c9c140..736c1c0e 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "@vscode/ripgrep": "^1.17.0" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.12.0", "@inkjs/ui": "^2.0.0", "@modelcontextprotocol/sdk": "^1.17.4", "ahooks": "^3.9.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d986a4f6..19fddbf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@agentclientprotocol/sdk': + specifier: ^0.12.0 + version: 0.12.0(zod@3.25.76) '@inkjs/ui': specifier: ^2.0.0 version: 2.0.0(ink@6.5.1(@types/react@19.2.7)(react@19.2.3)) @@ -202,6 +205,11 @@ importers: packages: + '@agentclientprotocol/sdk@0.12.0': + resolution: {integrity: sha512-V8uH/KK1t7utqyJmTA7y7DzKu6+jKFIXM+ZVouz8E55j8Ej2RV42rEvPKn3/PpBJlliI5crcGk1qQhZ7VwaepA==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@alcalzone/ansi-tokenize@0.2.2': resolution: {integrity: sha512-mkOh+Wwawzuf5wa30bvc4nA+Qb6DIrGWgBhRR/Pw4T9nsgYait8izvXkNyU78D6Wcu3Z+KUdwCmLCxlWjEotYA==} engines: {node: '>=18'} @@ -2582,6 +2590,10 @@ packages: snapshots: + '@agentclientprotocol/sdk@0.12.0(zod@3.25.76)': + dependencies: + zod: 3.25.76 + '@alcalzone/ansi-tokenize@0.2.2': dependencies: ansi-styles: 6.2.3 diff --git a/src/acp/AcpFileSystemService.ts b/src/acp/AcpFileSystemService.ts new file mode 100644 index 00000000..08b6052c --- /dev/null +++ b/src/acp/AcpFileSystemService.ts @@ -0,0 +1,247 @@ +/** + * ACP 文件系统服务适配器 + * + * 将文件操作转发给 IDE(ACP Client)执行。 + * 当 IDE 声明支持 fs 能力时,可以使用此服务替代本地文件操作。 + */ + +import type { + AgentSideConnection, + FileSystemCapability, +} from '@agentclientprotocol/sdk'; +import { createLogger, LogCategory } from '../logging/Logger.js'; +import { + type FileStat, + type FileSystemService, + LocalFileSystemService, +} from '../services/FileSystemService.js'; + +const logger = createLogger(LogCategory.AGENT); + +/** + * ACP 文件系统服务 + * + * 将文件操作转发给 IDE 执行。 + * 如果 IDE 不支持某个操作,则回退到本地文件系统。 + */ +export class AcpFileSystemService implements FileSystemService { + constructor( + private readonly connection: AgentSideConnection, + private readonly sessionId: string, + private readonly capabilities: FileSystemCapability, + private readonly fallback: FileSystemService = new LocalFileSystemService() + ) {} + + /** + * 读取文本文件 + * + * 如果 IDE 支持 readTextFile,则通过 ACP 协议读取; + * 否则回退到本地文件系统。 + */ + async readTextFile(filePath: string): Promise { + if (!this.capabilities.readTextFile) { + logger.debug(`[AcpFileSystem] readTextFile fallback: ${filePath}`); + return this.fallback.readTextFile(filePath); + } + + try { + logger.debug(`[AcpFileSystem] readTextFile via ACP: ${filePath}`); + const response = await this.connection.readTextFile({ + path: filePath, + sessionId: this.sessionId, + }); + return response.content; + } catch (error) { + logger.warn(`[AcpFileSystem] readTextFile ACP failed, fallback: ${error}`); + return this.fallback.readTextFile(filePath); + } + } + + /** + * 写入文本文件 + * + * 如果 IDE 支持 writeTextFile,则通过 ACP 协议写入; + * 否则回退到本地文件系统。 + */ + async writeTextFile(filePath: string, content: string): Promise { + if (!this.capabilities.writeTextFile) { + logger.debug(`[AcpFileSystem] writeTextFile fallback: ${filePath}`); + return this.fallback.writeTextFile(filePath, content); + } + + try { + logger.debug(`[AcpFileSystem] writeTextFile via ACP: ${filePath}`); + await this.connection.writeTextFile({ + path: filePath, + content, + sessionId: this.sessionId, + }); + } catch (error) { + logger.warn(`[AcpFileSystem] writeTextFile ACP failed, fallback: ${error}`); + return this.fallback.writeTextFile(filePath, content); + } + } + + /** + * 检查文件是否存在 + * + * 策略:优先信任 ACP,宁可误判"存在"也不要误判"不存在" + * + * 1. 如果 IDE 支持 readTextFile,通过 ACP 判断: + * - 读取成功 → 存在 + * - 错误明确是"not found/enoent" → 不存在 + * - 其他错误(权限、二进制、超时等)→ 假设存在 + * (让后续操作揭示真正问题,而非提前终止) + * + * 2. 如果 IDE 不支持 readTextFile,fallback 到本地 + */ + async exists(filePath: string): Promise { + // 如果 IDE 不支持文件读取,fallback 到本地 + if (!this.capabilities.readTextFile) { + logger.debug(`[AcpFileSystem] exists fallback to local: ${filePath}`); + return this.fallback.exists(filePath); + } + + // 通过 ACP 检查 + try { + await this.connection.readTextFile({ + path: filePath, + sessionId: this.sessionId, + }); + logger.debug(`[AcpFileSystem] exists(${filePath}): true (ACP read success)`); + return true; + } catch (error) { + const errorMsg = String(error).toLowerCase(); + + // 只有明确的"不存在"错误才返回 false + const notFoundPatterns = [ + 'not found', + 'no such file', + 'enoent', + 'does not exist', + 'file not found', + 'path not found', + ]; + + const isNotFound = notFoundPatterns.some((pattern) => errorMsg.includes(pattern)); + + if (isNotFound) { + logger.debug(`[AcpFileSystem] exists(${filePath}): false (ACP: not found)`); + return false; + } + + // 其他错误(权限、二进制、超时等)假设文件存在 + // 让后续操作揭示真正问题,而非在这里提前终止 + // 使用 warn 级别记录,便于诊断 + const errorType = categorizeError(errorMsg); + logger.warn( + `[AcpFileSystem] exists(${filePath}): assuming exists due to ${errorType} error`, + { error: String(error), errorType, filePath } + ); + return true; + } + } + + /** + * 读取二进制文件 + * + * ACP 协议目前只支持文本文件读取,二进制文件回退到本地。 + */ + async readBinaryFile(filePath: string): Promise { + // ACP 协议暂不支持二进制读取,使用本地 + logger.debug(`[AcpFileSystem] readBinaryFile fallback: ${filePath}`); + return this.fallback.readBinaryFile(filePath); + } + + /** + * 获取文件统计信息 + * + * ACP 协议暂不支持 stat 操作,回退到本地。 + */ + async stat(filePath: string): Promise { + // ACP 协议暂无 stat 方法,使用本地 + logger.debug(`[AcpFileSystem] stat fallback: ${filePath}`); + return this.fallback.stat(filePath); + } + + /** + * 创建目录 + * + * ACP 协议暂不支持 mkdir 操作,回退到本地。 + * 注:writeTextFile 通常会自动创建父目录。 + */ + async mkdir(dirPath: string, options?: { recursive?: boolean }): Promise { + // ACP 协议暂无 mkdir 方法,使用本地 + logger.debug(`[AcpFileSystem] mkdir fallback: ${dirPath}`); + return this.fallback.mkdir(dirPath, options); + } + + /** + * 获取 IDE 支持的文件系统能力 + */ + getCapabilities(): FileSystemCapability { + return this.capabilities; + } + + /** + * 检查是否支持读取文件 + */ + canReadTextFile(): boolean { + return this.capabilities.readTextFile ?? false; + } + + /** + * 检查是否支持写入文件 + */ + canWriteTextFile(): boolean { + return this.capabilities.writeTextFile ?? false; + } +} + +/** + * 创建 ACP 文件系统服务 + * + * 根据 ACP 客户端能力创建适当的文件系统服务实例。 + * + * @param connection - ACP 连接 + * @param sessionId - 会话 ID + * @param capabilities - IDE 声明的文件系统能力(可选) + * @returns 文件系统服务实例 + */ +export function createAcpFileSystemService( + connection: AgentSideConnection, + sessionId: string, + capabilities?: FileSystemCapability +): FileSystemService { + if (capabilities && (capabilities.readTextFile || capabilities.writeTextFile)) { + logger.debug( + `[AcpFileSystem] Using ACP file system service (read: ${capabilities.readTextFile}, write: ${capabilities.writeTextFile})` + ); + return new AcpFileSystemService(connection, sessionId, capabilities); + } + + logger.debug('[AcpFileSystem] Using local file system service (no IDE capabilities)'); + return new LocalFileSystemService(); +} + +/** + * 对错误信息进行分类,便于诊断 + */ +function categorizeError(errorMsg: string): string { + if (errorMsg.includes('permission') || errorMsg.includes('access denied')) { + return 'permission'; + } + if (errorMsg.includes('timeout') || errorMsg.includes('timed out')) { + return 'timeout'; + } + if (errorMsg.includes('binary') || errorMsg.includes('encoding')) { + return 'binary'; + } + if (errorMsg.includes('too large') || errorMsg.includes('size')) { + return 'size'; + } + if (errorMsg.includes('connection') || errorMsg.includes('network')) { + return 'network'; + } + return 'unknown'; +} diff --git a/src/acp/AcpServiceContext.ts b/src/acp/AcpServiceContext.ts new file mode 100644 index 00000000..c8186888 --- /dev/null +++ b/src/acp/AcpServiceContext.ts @@ -0,0 +1,615 @@ +/** + * ACP 服务上下文管理器 + * + * 管理 ACP 模式下的各种服务(文件系统、终端等), + * 使工具可以透明地使用 IDE 提供的能力或回退到本地实现。 + */ + +import { spawn } from 'child_process'; +import type { + AgentSideConnection, + ClientCapabilities, + ToolCallContent, + ToolCallStatus, + ToolKind, +} from '@agentclientprotocol/sdk'; +import { createLogger, LogCategory } from '../logging/Logger.js'; +import { + type FileSystemService, + LocalFileSystemService, + resetFileSystemService, + setFileSystemService, +} from '../services/FileSystemService.js'; +import { AcpFileSystemService } from './AcpFileSystemService.js'; + +const logger = createLogger(LogCategory.AGENT); + +/** + * 终端服务接口 + */ +export interface TerminalService { + /** + * 执行命令 + * @param command - 要执行的命令 + * @param options - 执行选项 + * @returns 执行结果 + */ + execute( + command: string, + options?: TerminalExecuteOptions + ): Promise; + + /** + * 检查是否支持终端操作 + */ + isAvailable(): boolean; +} + +export interface TerminalExecuteOptions { + cwd?: string; + env?: Record; + timeout?: number; + signal?: AbortSignal; + onOutput?: (output: string) => void; +} + +export interface TerminalExecuteResult { + success: boolean; + stdout: string; + stderr: string; + exitCode: number | null; + error?: string; +} + +/** + * 本地终端服务(使用 child_process) + */ +export class LocalTerminalService implements TerminalService { + async execute( + command: string, + options?: TerminalExecuteOptions + ): Promise { + return new Promise((resolve) => { + const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/bash'; + const shellArgs = + process.platform === 'win32' ? ['/c', command] : ['-c', command]; + + const proc = spawn(shell, shellArgs, { + cwd: options?.cwd || process.cwd(), + env: { ...process.env, ...options?.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + let killed = false; + + // 设置超时 + const timeoutId = options?.timeout + ? setTimeout(() => { + killed = true; + proc.kill('SIGTERM'); + }, options.timeout) + : null; + + // 处理中止信号 + if (options?.signal) { + options.signal.addEventListener('abort', () => { + killed = true; + proc.kill('SIGTERM'); + }); + } + + proc.stdout.on('data', (data) => { + const chunk = data.toString(); + stdout += chunk; + options?.onOutput?.(chunk); + }); + + proc.stderr.on('data', (data) => { + const chunk = data.toString(); + stderr += chunk; + options?.onOutput?.(chunk); + }); + + proc.on('close', (code) => { + if (timeoutId) clearTimeout(timeoutId); + + resolve({ + success: code === 0 && !killed, + stdout, + stderr, + exitCode: code, + error: killed ? 'Command was terminated' : undefined, + }); + }); + + proc.on('error', (error) => { + if (timeoutId) clearTimeout(timeoutId); + + resolve({ + success: false, + stdout, + stderr, + exitCode: null, + error: error.message, + }); + }); + }); + } + + isAvailable(): boolean { + return true; + } +} + +/** + * ACP 终端服务 + * 通过 ACP 协议在 IDE 中执行命令 + */ +export class AcpTerminalService implements TerminalService { + constructor( + private readonly connection: AgentSideConnection, + private readonly sessionId: string, + private readonly fallback: TerminalService = new LocalTerminalService() + ) {} + + async execute( + command: string, + options?: TerminalExecuteOptions + ): Promise { + try { + logger.debug(`[AcpTerminal] Executing command via ACP: ${command}`); + + // 创建终端 + const terminal = await this.connection.createTerminal({ + sessionId: this.sessionId, + command, + cwd: options?.cwd, + env: options?.env + ? Object.entries(options.env).map(([name, value]) => ({ name, value })) + : undefined, + }); + + let lastOutputLength = 0; + + // 流式输出轮询(如果提供了 onOutput) + let pollIntervalId: ReturnType | null = null; + if (options?.onOutput) { + pollIntervalId = setInterval(async () => { + try { + const output = await terminal.currentOutput(); + const fullOutput = output.output || ''; + if (fullOutput.length > lastOutputLength) { + const newContent = fullOutput.slice(lastOutputLength); + lastOutputLength = fullOutput.length; + options.onOutput?.(newContent); + } + } catch { + // 忽略轮询错误 + } + }, 100); + } + + // 清理函数(后台执行,不阻塞返回) + const cleanup = (shouldKill: boolean = false) => { + if (pollIntervalId) { + clearInterval(pollIntervalId); + pollIntervalId = null; + } + // 后台清理:如需终止则先 kill 再 release + const doCleanup = shouldKill + ? terminal + .kill() + .then(() => logger.debug(`[AcpTerminal] Killed remote command`)) + .catch(() => { + /* 忽略 kill 错误(命令可能已结束)*/ + }) + .then(() => terminal.release()) + .catch(() => { + /* 忽略 release 错误 */ + }) + : terminal.release().catch(() => { + /* 忽略 release 错误 */ + }); + + // 不等待,后台执行 + doCleanup.catch(() => { + /* 忽略清理错误 */ + }); + }; + + // 构建竞争 Promise + const racePromises: Promise< + | { type: 'completed'; exitCode: number | null } + | { type: 'timeout' } + | { type: 'aborted' } + >[] = []; + + // 1. 命令完成 + racePromises.push( + terminal.waitForExit().then((result) => ({ + type: 'completed' as const, + exitCode: result.exitCode ?? null, + })) + ); + + // 2. 超时 + if (options?.timeout) { + racePromises.push( + new Promise((resolve) => { + setTimeout(() => resolve({ type: 'timeout' as const }), options.timeout); + }) + ); + } + + // 3. 取消信号 + if (options?.signal) { + racePromises.push( + new Promise((resolve) => { + if (options.signal!.aborted) { + resolve({ type: 'aborted' as const }); + } else { + options.signal!.addEventListener( + 'abort', + () => resolve({ type: 'aborted' as const }), + { once: true } + ); + } + }) + ); + } + + // 使用 Promise.race 实现真正的中断 + const raceResult = await Promise.race(racePromises); + + // 获取当前输出(尽力获取,可能失败) + let fullOutput = ''; + try { + const output = await terminal.currentOutput(); + fullOutput = output.output || ''; + // 发送剩余输出 + if (options?.onOutput && fullOutput.length > lastOutputLength) { + options.onOutput(fullOutput.slice(lastOutputLength)); + } + } catch { + // 忽略获取输出失败 + } + + // 根据结果返回(同时清理资源) + switch (raceResult.type) { + case 'completed': + // 正常完成:只释放资源,不需要 kill + cleanup(false); + return { + success: raceResult.exitCode === 0, + stdout: fullOutput, + stderr: '', + exitCode: raceResult.exitCode, + }; + + case 'timeout': + // 超时:先 kill 远端命令再释放 + logger.debug(`[AcpTerminal] Command timed out, killing: ${command}`); + cleanup(true); + return { + success: false, + stdout: fullOutput, + stderr: '', + exitCode: null, + error: 'Command timed out', + }; + + case 'aborted': + // 取消:先 kill 远端命令再释放 + logger.debug(`[AcpTerminal] Command aborted, killing: ${command}`); + cleanup(true); + return { + success: false, + stdout: fullOutput, + stderr: '', + exitCode: null, + error: 'Command was aborted', + }; + } + } catch (error) { + logger.warn(`[AcpTerminal] ACP terminal failed, using fallback:`, error); + return this.fallback.execute(command, options); + } + } + + isAvailable(): boolean { + return true; + } +} + +/** + * 单个会话的服务上下文 + */ +interface SessionServices { + fileSystemService: FileSystemService; + terminalService: TerminalService; + connection: AgentSideConnection; + clientCapabilities: ClientCapabilities | null; + cwd: string; +} + +/** + * ACP 服务上下文管理器 + * + * 按 sessionId 管理服务,支持多会话并发。 + * 每个会话有独立的服务实例,互不影响。 + */ +export class AcpServiceContext { + private static sessions: Map = new Map(); + private static currentSessionId: string | null = null; + + private constructor() { + // 私有构造函数,使用静态方法 + } + + /** + * 获取单例实例(兼容旧 API) + * @deprecated 使用 getForSession(sessionId) 代替 + */ + static getInstance(): AcpServiceContext { + return new AcpServiceContext(); + } + + /** + * 初始化会话的 ACP 服务 + * + * @param connection - ACP 连接 + * @param sessionId - 会话 ID + * @param clientCapabilities - 客户端能力 + * @param cwd - 工作目录 + */ + static initializeSession( + connection: AgentSideConnection, + sessionId: string, + clientCapabilities: ClientCapabilities | undefined, + cwd: string + ): void { + // 根据 IDE 能力创建文件系统服务 + const fileSystemService: FileSystemService = clientCapabilities?.fs + ? new AcpFileSystemService(connection, sessionId, clientCapabilities.fs) + : new LocalFileSystemService(); + + if (clientCapabilities?.fs) { + logger.debug(`[AcpServiceContext:${sessionId}] Using ACP file system service`); + } + + // 终端服务始终可用(ACP 或本地) + const terminalService: TerminalService = new AcpTerminalService( + connection, + sessionId + ); + logger.debug(`[AcpServiceContext:${sessionId}] Using ACP terminal service`); + + // 存储会话服务 + AcpServiceContext.sessions.set(sessionId, { + fileSystemService, + terminalService, + connection, + clientCapabilities: clientCapabilities || null, + cwd, + }); + + // 设置当前会话(用于便捷函数) + AcpServiceContext.currentSessionId = sessionId; + + // 更新全局文件系统服务(供工具层使用) + setFileSystemService(fileSystemService); + + logger.debug(`[AcpServiceContext:${sessionId}] Initialized with capabilities:`, { + fs: !!clientCapabilities?.fs, + readTextFile: clientCapabilities?.fs?.readTextFile, + writeTextFile: clientCapabilities?.fs?.writeTextFile, + cwd, + }); + } + + /** + * 销毁会话服务 + * + * 只清理指定会话,不影响其他会话。 + */ + static destroySession(sessionId: string): void { + AcpServiceContext.sessions.delete(sessionId); + + // 如果是当前会话,清除当前会话 ID + if (AcpServiceContext.currentSessionId === sessionId) { + // 切换到另一个活跃会话,或者清空 + const remainingSessions = Array.from(AcpServiceContext.sessions.keys()); + AcpServiceContext.currentSessionId = remainingSessions[0] || null; + + // 如果没有剩余会话,重置文件系统服务为本地 + if (!AcpServiceContext.currentSessionId) { + resetFileSystemService(); + } else { + // 切换到下一个会话的文件系统服务 + const nextSession = AcpServiceContext.sessions.get( + AcpServiceContext.currentSessionId + ); + if (nextSession) { + setFileSystemService(nextSession.fileSystemService); + } + } + } + + logger.debug(`[AcpServiceContext:${sessionId}] Session destroyed`); + } + + /** + * 获取指定会话的服务 + */ + static getSessionServices(sessionId: string): SessionServices | null { + return AcpServiceContext.sessions.get(sessionId) || null; + } + + /** + * 设置当前活跃会话 + */ + static setCurrentSession(sessionId: string): void { + if (AcpServiceContext.sessions.has(sessionId)) { + AcpServiceContext.currentSessionId = sessionId; + + // 更新全局文件系统服务 + const session = AcpServiceContext.sessions.get(sessionId); + if (session) { + setFileSystemService(session.fileSystemService); + } + } + } + + /** + * 获取当前活跃会话 ID + */ + static getCurrentSessionId(): string | null { + return AcpServiceContext.currentSessionId; + } + + // ==================== 兼容旧 API(实例方法)==================== + + /** + * 初始化 ACP 服务(兼容旧 API) + * @deprecated 使用 AcpServiceContext.initializeSession() 代替 + */ + initialize( + connection: AgentSideConnection, + sessionId: string, + clientCapabilities: ClientCapabilities | undefined, + cwd?: string + ): void { + AcpServiceContext.initializeSession( + connection, + sessionId, + clientCapabilities, + cwd || process.cwd() + ); + } + + /** + * 重置服务(兼容旧 API) + * @deprecated 使用 AcpServiceContext.destroySession(sessionId) 代替 + */ + reset(): void { + // 只重置当前会话,而不是所有会话 + if (AcpServiceContext.currentSessionId) { + AcpServiceContext.destroySession(AcpServiceContext.currentSessionId); + } + } + + /** + * 检查是否在 ACP 模式下运行 + */ + isAcpMode(): boolean { + return AcpServiceContext.currentSessionId !== null; + } + + /** + * 获取文件系统服务(当前会话) + */ + getFileSystemService(): FileSystemService { + if (AcpServiceContext.currentSessionId) { + const services = AcpServiceContext.sessions.get( + AcpServiceContext.currentSessionId + ); + if (services) return services.fileSystemService; + } + return new LocalFileSystemService(); + } + + /** + * 获取终端服务(当前会话) + */ + getTerminalService(): TerminalService { + if (AcpServiceContext.currentSessionId) { + const services = AcpServiceContext.sessions.get( + AcpServiceContext.currentSessionId + ); + if (services) return services.terminalService; + } + return new LocalTerminalService(); + } + + /** + * 获取 ACP 连接(当前会话) + */ + getConnection(): AgentSideConnection | null { + if (AcpServiceContext.currentSessionId) { + const services = AcpServiceContext.sessions.get( + AcpServiceContext.currentSessionId + ); + if (services) return services.connection; + } + return null; + } + + /** + * 获取当前会话 ID + */ + getSessionId(): string | null { + return AcpServiceContext.currentSessionId; + } + + /** + * 获取客户端能力(当前会话) + */ + getClientCapabilities(): ClientCapabilities | null { + if (AcpServiceContext.currentSessionId) { + const services = AcpServiceContext.sessions.get( + AcpServiceContext.currentSessionId + ); + if (services) return services.clientCapabilities; + } + return null; + } + + /** + * 发送工具调用状态更新 + */ + async sendToolUpdate( + toolCallId: string, + status: ToolCallStatus, + title: string, + content?: ToolCallContent[], + kind?: ToolKind + ): Promise { + const sessionId = AcpServiceContext.currentSessionId; + if (!sessionId) return; + + const services = AcpServiceContext.sessions.get(sessionId); + if (!services) return; + + try { + await services.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId, + status, + title, + content: content || [], + kind: kind || 'other', + }, + }); + } catch (error) { + logger.warn('[AcpServiceContext] Failed to send tool update:', error); + } + } +} + +/** + * 便捷函数:获取终端服务 + */ +export function getTerminalService(): TerminalService { + return AcpServiceContext.getInstance().getTerminalService(); +} + +/** + * 便捷函数:检查是否在 ACP 模式 + */ +export function isAcpMode(): boolean { + return AcpServiceContext.getInstance().isAcpMode(); +} diff --git a/src/acp/BladeAgent.ts b/src/acp/BladeAgent.ts new file mode 100644 index 00000000..81ab02f6 --- /dev/null +++ b/src/acp/BladeAgent.ts @@ -0,0 +1,215 @@ +/** + * Blade ACP Agent 实现 + * + * 实现 ACP 协议的 Agent 接口,使 Blade 可以被 Zed、JetBrains 等编辑器调用。 + * + */ + +import type * as acp from '@agentclientprotocol/sdk'; +import { + type Agent as AcpAgentInterface, + type AgentSideConnection, + PROTOCOL_VERSION, +} from '@agentclientprotocol/sdk'; +import { nanoid } from 'nanoid'; +import { createLogger, LogCategory } from '../logging/Logger.js'; +import { getConfig } from '../store/vanilla.js'; +import { AcpSession } from './Session.js'; + +const logger = createLogger(LogCategory.AGENT); + +/** + * Blade ACP Agent + * + * 实现 ACP 协议的 Agent 接口,处理来自 IDE 的请求。 + */ +export class BladeAgent implements AcpAgentInterface { + private sessions: Map = new Map(); + private clientCapabilities: acp.ClientCapabilities | undefined; + + constructor(private connection: AgentSideConnection) {} + + /** + * 初始化连接,协商协议版本和能力 + */ + async initialize(params: acp.InitializeRequest): Promise { + logger.info('[BladeAgent] Initializing ACP connection'); + logger.debug( + `[BladeAgent] Client capabilities: ${JSON.stringify(params.clientCapabilities)}` + ); + + // 保存客户端能力,用于后续判断是否使用 IDE 的文件系统 + this.clientCapabilities = params.clientCapabilities; + + return { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { + // 暂不支持加载历史会话(后续可以实现) + loadSession: false, + // 支持的提示能力 + promptCapabilities: { + image: true, // 支持图片 + audio: false, // 暂不支持音频 + embeddedContext: true, // 支持嵌入上下文 + }, + // MCP 能力(Blade 已有 MCP 支持) + mcpCapabilities: { + http: true, + sse: true, + }, + }, + }; + } + + /** + * 认证(Blade 目前不需要认证) + */ + async authenticate( + _params: acp.AuthenticateRequest + ): Promise { + // Blade 使用环境变量中的 API Key,不需要额外认证 + return; + } + + /** + * 创建新会话 + */ + async newSession(params: acp.NewSessionRequest): Promise { + const sessionId = nanoid(); + logger.info(`[BladeAgent] Creating new session: ${sessionId}`); + logger.debug(`[BladeAgent] Session cwd: ${params.cwd || process.cwd()}`); + + // 创建会话实例 + const session = new AcpSession( + sessionId, + params.cwd || process.cwd(), + this.connection, + this.clientCapabilities + ); + + // 初始化会话(创建 Agent 等) + await session.initialize(); + + this.sessions.set(sessionId, session); + + logger.info( + `[BladeAgent] Session ${sessionId} created, scheduling available commands update` + ); + + // 延迟发送 available_commands_update,确保在响应后 + session.sendAvailableCommandsDelayed(); + + // 获取配置中的模型列表 + const config = getConfig(); + const models = config?.models || []; + const currentModelId = config?.currentModelId || models[0]?.id; + + // 构建可用模型列表(不稳定 API) + const availableModels: acp.ModelInfo[] = models.map((m) => ({ + modelId: m.id, + name: m.name || m.id, + description: m.provider ? `Provider: ${m.provider}` : undefined, + })); + + // 构建可用模式列表(权限模式) + const availableModes: acp.SessionMode[] = [ + { + id: 'default', + name: 'Default', + description: 'Ask for confirmation before all file edits and commands', + }, + { + id: 'auto-edit', + name: 'Auto Edit', + description: 'Auto-approve file edits, ask for shell commands', + }, + { + id: 'yolo', + name: 'Full Auto', + description: 'Auto-approve everything without confirmation', + }, + { + id: 'plan', + name: 'Plan Only', + description: 'Read-only mode, no file changes or commands', + }, + ]; + + return { + sessionId, + // 返回可用模式(权限控制) + modes: { + availableModes, + currentModeId: 'default', + }, + // 返回可用模型(不稳定 API) + models: + availableModels.length > 0 + ? { + availableModels, + currentModelId, + } + : undefined, + }; + } + + /** + * 处理提示请求 + */ + async prompt(params: acp.PromptRequest): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + + return session.prompt(params); + } + + /** + * 取消当前操作 + */ + async cancel(params: acp.CancelNotification): Promise { + const session = this.sessions.get(params.sessionId); + if (session) { + session.cancel(); + } + } + + /** + * 设置会话模式(权限模式) + */ + async setSessionMode( + params: acp.SetSessionModeRequest + ): Promise { + logger.info(`[BladeAgent] Setting session mode: ${params.modeId}`); + const session = this.sessions.get(params.sessionId); + if (session) { + await session.setMode(params.modeId); + } + return {}; + } + + /** + * 设置会话模型(不稳定 API) + */ + async unstable_setSessionModel?( + params: acp.SetSessionModelRequest + ): Promise { + logger.info(`[BladeAgent] Setting session model: ${params.modelId}`); + const session = this.sessions.get(params.sessionId); + if (session) { + await session.setModel(params.modelId); + } + return {}; + } + + /** + * 清理资源 + */ + async destroy(): Promise { + for (const session of this.sessions.values()) { + await session.destroy(); + } + this.sessions.clear(); + } +} diff --git a/src/acp/Session.ts b/src/acp/Session.ts new file mode 100644 index 00000000..825b3371 --- /dev/null +++ b/src/acp/Session.ts @@ -0,0 +1,660 @@ +/** + * ACP 会话管理 + * + * 封装 Blade Agent,处理 ACP 协议的 prompt 请求, + * 将 Agent 的流式输出转发给 IDE。 + */ + +import type { + AgentSideConnection, + AvailableCommand, + ClientCapabilities, + ContentBlock, + PromptRequest, + PromptResponse, + RequestPermissionRequest, + SessionNotification, + ToolCallContent, + ToolCallStatus, + ToolKind, +} from '@agentclientprotocol/sdk'; +import { nanoid } from 'nanoid'; +import { Agent } from '../agent/Agent.js'; +import type { ChatContext, LoopOptions } from '../agent/types.js'; +import { PermissionMode } from '../config/types.js'; +import { createLogger, LogCategory } from '../logging/Logger.js'; +import type { Message } from '../services/ChatServiceInterface.js'; +import { + executeSlashCommand, + getRegisteredCommands, + isSlashCommand, +} from '../slash-commands/index.js'; +import type { + ConfirmationDetails, + ConfirmationResponse, +} from '../tools/types/ExecutionTypes.js'; +import { AcpServiceContext } from './AcpServiceContext.js'; + +const logger = createLogger(LogCategory.AGENT); + +/** + * ACP 会话类 + * + * 每个会话对应一个 Blade Agent 实例, + * 处理来自 IDE 的 prompt 请求并返回流式响应。 + */ +/** + * ACP 模式 ID(与 BladeAgent 返回的 availableModes 对应) + */ +type AcpModeId = 'default' | 'auto-edit' | 'yolo' | 'plan'; + +export class AcpSession { + private agent: Agent | null = null; + private pendingPrompt: AbortController | null = null; + private messages: Message[] = []; + private mode: AcpModeId = 'default'; + // 会话级别的权限缓存(allow_always 选项) + private sessionApprovals: Set = new Set(); + + constructor( + private readonly id: string, + private readonly cwd: string, + private readonly connection: AgentSideConnection, + private readonly clientCapabilities: ClientCapabilities | undefined + ) {} + + /** + * 初始化会话 + * 创建 Blade Agent 实例并初始化 ACP 服务 + */ + async initialize(): Promise { + logger.debug(`[AcpSession ${this.id}] Initializing...`); + + // 初始化 ACP 服务上下文(按会话隔离,不使用 process.chdir) + AcpServiceContext.initializeSession( + this.connection, + this.id, + this.clientCapabilities, + this.cwd + ); + logger.debug(`[AcpSession ${this.id}] ACP service context initialized`); + + // 创建 Agent(cwd 通过 ChatContext.workspaceRoot 传递,不修改全局工作目录) + this.agent = await Agent.create({}); + + logger.debug(`[AcpSession ${this.id}] Agent created successfully`); + // 注意:available_commands_update 在 BladeAgent.newSession 响应后延迟发送 + } + + /** + * 发送可用的 slash commands 给 IDE(公开方法,由 BladeAgent 调用) + */ + sendAvailableCommandsDelayed(): void { + // 延迟发送,确保在 session/new 响应之后 + // 使用较长的延迟确保 Zed 已准备好接收 + logger.debug( + `[AcpSession ${this.id}] Scheduling available commands update (500ms delay)` + ); + setTimeout(() => { + this.sendAvailableCommands(); + }, 500); + } + + /** + * 处理 slash command + */ + private async handleSlashCommand( + message: string, + signal: AbortSignal + ): Promise { + try { + logger.debug(`[AcpSession ${this.id}] Executing slash command: ${message}`); + + // 创建 slash command 上下文 + const context = { + cwd: this.cwd, + }; + + // 执行 slash command + const result = await executeSlashCommand(message, context); + + // 发送结果给 IDE + // 优先使用 content(完整内容),否则使用 message(简短状态) + const displayContent = result.content || result.message; + if (displayContent) { + this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: displayContent }, + }); + } + + if (result.error) { + this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `❌ ${result.error}` }, + }); + } + + return { stopReason: result.success ? 'end_turn' : 'cancelled' }; + } catch (error) { + logger.error(`[AcpSession ${this.id}] Slash command error:`, error); + this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: `❌ 命令执行失败: ${error instanceof Error ? error.message : '未知错误'}`, + }, + }); + return { stopReason: 'cancelled' }; + } + } + + /** + * 发送可用的 slash commands 给 IDE + * + * 根据 ACP 协议,命令名称不需要 "/" 前缀。 + * 客户端(IDE)会在 prompt 中以 "/command args" 格式发送命令。 + */ + private async sendAvailableCommands(): Promise { + try { + const commands = getRegisteredCommands(); + + // 在 ACP 模式下过滤掉不需要的命令 + // - model/permissions/theme: Zed 已提供 UI + // - config/exit/ide: 在 IDE 中不适用 + const excludedInAcp = ['model', 'permissions', 'theme', 'config', 'exit', 'ide']; + const filteredCommands = commands.filter( + (cmd) => !excludedInAcp.includes(cmd.name) + ); + + const availableCommands: AvailableCommand[] = filteredCommands.map((cmd) => ({ + // 命令名称不需要 / 前缀(根据 ACP 协议) + name: cmd.name, + description: cmd.description, + // 如果命令需要参数,添加 input.hint + input: cmd.aliases?.length + ? { hint: `Aliases: ${cmd.aliases.join(', ')}` } + : undefined, + })); + + logger.info( + `[AcpSession ${this.id}] Sending available commands: ${JSON.stringify(availableCommands.map((c) => c.name))}` + ); + + this.sendUpdate({ + sessionUpdate: 'available_commands_update', + availableCommands, + }); + + logger.info( + `[AcpSession ${this.id}] Sent ${availableCommands.length} available commands` + ); + } catch (error) { + logger.error(`[AcpSession ${this.id}] Failed to send available commands:`, error); + } + } + + /** + * 处理 prompt 请求 + * + * @param params - ACP prompt 请求参数 + * @returns ACP prompt 响应 + */ + async prompt(params: PromptRequest): Promise { + // 设置当前会话(确保工具使用正确的服务上下文) + AcpServiceContext.setCurrentSession(this.id); + + // 中止之前的请求(如果有) + this.pendingPrompt?.abort(); + + const abortController = new AbortController(); + this.pendingPrompt = abortController; + + if (!this.agent) { + throw new Error('Session not initialized'); + } + + try { + // 1. 解析 ACP prompt 为文本消息 + const message = this.resolvePrompt(params.prompt); + logger.debug( + `[AcpSession ${this.id}] Received prompt: ${message.slice(0, 100)}...` + ); + + // 2. 检查是否是 slash command + if (isSlashCommand(message)) { + return this.handleSlashCommand(message, abortController.signal); + } + + // 3. 构建 ChatContext + const context: ChatContext = { + sessionId: this.id, + userId: 'acp-user', + workspaceRoot: this.cwd, + messages: [...this.messages], + signal: abortController.signal, + // 根据 ACP 模式映射到 Blade 权限模式 + permissionMode: this.mapModeToPermissionMode(), + // 确认处理器:转发给 IDE 请求权限 + confirmationHandler: { + requestConfirmation: async ( + details: ConfirmationDetails + ): Promise => { + return this.requestPermission(details); + }, + }, + }; + + // 3. 定义回调选项 + const loopOptions: LoopOptions = { + signal: abortController.signal, + + // 文本内容流式输出 + onContent: (text: string) => { + this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text }, + }); + }, + + // 思考过程流式输出(DeepSeek R1 等) + onThinking: (text: string) => { + this.sendUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text }, + }); + }, + + // 工具调用开始 + onToolStart: (toolCall) => { + const toolName = + 'function' in toolCall ? toolCall.function.name : toolCall.type; + this.sendUpdate({ + sessionUpdate: 'tool_call', + toolCallId: toolCall.id, + status: 'in_progress' as ToolCallStatus, + title: `Executing ${toolName}`, + content: [], + kind: 'execute' as ToolKind, + }); + }, + + // 工具调用完成 + onToolResult: async (toolCall, result) => { + const content: ToolCallContent[] = []; + + // 检查是否有 diff 信息(Edit/Write 工具) + const metadata = result.metadata; + if ( + metadata?.kind === 'edit' && + metadata?.file_path && + metadata?.oldContent !== undefined && + metadata?.newContent !== undefined + ) { + // 发送 diff 格式(IDE 会显示差异视图) + content.push({ + type: 'diff', + path: metadata.file_path, + oldText: metadata.oldContent, + newText: metadata.newContent, + }); + } else if (result.displayContent) { + // 其他工具:发送文本内容 + const displayText = + typeof result.displayContent === 'string' + ? result.displayContent + : JSON.stringify(result.displayContent); + content.push({ + type: 'content', + content: { type: 'text', text: displayText }, + }); + } + + const _toolName = + 'function' in toolCall ? toolCall.function.name : toolCall.type; + const status: ToolCallStatus = result.success ? 'completed' : 'failed'; + + this.sendUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: toolCall.id, + status, + content, + }); + }, + }; + + // 4. 调用 Agent chat + const response = await this.agent.chat(message, context, loopOptions); + + // 5. 保存助手响应到历史 + if (response) { + this.messages.push({ role: 'user', content: message }); + this.messages.push({ role: 'assistant', content: response }); + } + + // 6. 检查是否被取消 + if (abortController.signal.aborted) { + return { stopReason: 'cancelled' }; + } + + return { stopReason: 'end_turn' }; + } catch (error) { + // 检查是否是取消操作 + if ( + abortController.signal.aborted || + (error instanceof Error && error.name === 'AbortError') + ) { + return { stopReason: 'cancelled' }; + } + + logger.error(`[AcpSession ${this.id}] Prompt error:`, error); + throw error; + } finally { + if (this.pendingPrompt === abortController) { + this.pendingPrompt = null; + } + } + } + + /** + * 取消当前操作 + */ + cancel(): void { + if (this.pendingPrompt) { + this.pendingPrompt.abort(); + this.pendingPrompt = null; + logger.debug(`[AcpSession ${this.id}] Cancelled`); + } + } + + /** + * 设置会话模式(权限模式) + * + * 可用模式: + * - default: 所有操作都需要确认 + * - auto-edit: 文件编辑自动批准,命令需要确认 + * - yolo: 所有操作自动批准 + * - plan: 只读模式,不允许写操作 + */ + async setMode(mode: string): Promise { + // 验证并设置模式 + const validModes: AcpModeId[] = ['default', 'auto-edit', 'yolo', 'plan']; + this.mode = validModes.includes(mode as AcpModeId) ? (mode as AcpModeId) : 'default'; + logger.info(`[AcpSession ${this.id}] Mode set to: ${this.mode}`); + + // 发送模式更新通知给 IDE + this.sendUpdate({ + sessionUpdate: 'current_mode_update', + currentModeId: this.mode, + }); + } + + /** + * 将 ACP 模式映射到 Blade 权限模式 + */ + private mapModeToPermissionMode(): PermissionMode | undefined { + switch (this.mode) { + case 'yolo': + return PermissionMode.YOLO; // 绕过所有权限检查 + case 'auto-edit': + return PermissionMode.AUTO_EDIT; // 自动批准文件操作 + case 'plan': + return PermissionMode.PLAN; // 只读模式 + case 'default': + default: + return PermissionMode.DEFAULT; // 使用默认权限(需要确认) + } + } + + /** + * 检查操作是否需要确认 + * + * ToolKind 枚举值: + * - 'readonly': 只读操作(Read, Glob, Grep 等) + * - 'write': 写操作(Edit, Write 等) + * - 'execute': 执行操作(Bash 等) + */ + private shouldAutoApprove(toolKind: string): boolean { + switch (this.mode) { + case 'yolo': + // Full Auto: 所有操作自动批准 + return true; + case 'auto-edit': + // Auto Edit: 只读和写操作自动批准,执行操作需要确认 + return toolKind === 'readonly' || toolKind === 'write'; + case 'plan': + // Plan Only: 只允许只读操作 + return toolKind === 'readonly'; + case 'default': + default: + // Default: 都需要确认 + return false; + } + } + + /** + * 设置会话模型 + */ + async setModel(modelId: string): Promise { + logger.info(`[AcpSession ${this.id}] Model set to: ${modelId}`); + + // 更新 Agent 的模型配置 + if (this.agent) { + // TODO: 实现模型切换逻辑 + // 目前 Agent 不支持运行时切换模型,需要重新创建 + logger.warn( + `[AcpSession ${this.id}] Runtime model switching not yet implemented` + ); + } + } + + /** + * 销毁会话 + */ + async destroy(): Promise { + this.cancel(); + if (this.agent) { + await this.agent.destroy(); + this.agent = null; + } + // 销毁此会话的 ACP 服务(不影响其他会话) + AcpServiceContext.destroySession(this.id); + logger.debug(`[AcpSession ${this.id}] Destroyed`); + } + + /** + * 解析 ACP prompt 为文本消息 + * + * @param prompt - ACP prompt 数组 + * @returns 文本消息 + */ + private resolvePrompt(prompt: ContentBlock[]): string { + const parts: string[] = []; + + for (const block of prompt) { + if (block.type === 'text') { + parts.push(block.text); + } else if (block.type === 'image') { + // 图片暂时用占位符表示 + parts.push(`[Image: ${block.mimeType}]`); + } else if (block.type === 'resource') { + // 嵌入资源(文件内容等) + const resource = block.resource; + if ('text' in resource) { + parts.push(`\n${resource.text}\n`); + } + } else if (block.type === 'resource_link') { + // 资源链接 + parts.push(`[Resource: ${block.uri}]`); + } + } + + return parts.join('\n'); + } + + /** + * 发送会话更新通知 + */ + private sendUpdate(update: SessionNotification['update']): void { + const params: SessionNotification = { + sessionId: this.id, + update, + }; + + // 异步发送,不等待 + this.connection.sessionUpdate(params).catch((error) => { + logger.warn(`[AcpSession ${this.id}] Failed to send update:`, error); + }); + } + + /** + * 请求 IDE 确认权限 + * + * 根据当前模式决定是否自动批准: + * - yolo: 所有操作自动批准 + * - auto-edit: 文件操作自动批准,命令需要确认 + * - plan: 只允许读操作 + * - default: 所有操作都需要确认 + * + * @param details - 确认详情 + * @returns 确认响应 + */ + private async requestPermission( + details: ConfirmationDetails + ): Promise { + // 检查是否应该自动批准(基于当前模式) + const toolKind = details.kind?.toLowerCase() || 'execute'; + if (this.shouldAutoApprove(toolKind)) { + logger.debug( + `[AcpSession ${this.id}] Auto-approving ${toolKind} in mode: ${this.mode}` + ); + return { approved: true }; + } + + // Plan 模式下拒绝写和执行操作 + if (this.mode === 'plan' && (toolKind === 'write' || toolKind === 'execute')) { + logger.debug(`[AcpSession ${this.id}] Rejecting ${toolKind} in plan mode`); + return { + approved: false, + reason: 'Write and execute operations are not allowed in Plan mode', + }; + } + + // 生成权限签名(用于 allow_always 缓存) + const permissionSignature = `${toolKind}:${details.title || 'unknown'}`; + + // 检查会话级别的权限缓存(allow_always) + if (this.sessionApprovals.has(permissionSignature)) { + logger.debug( + `[AcpSession ${this.id}] Using cached approval for: ${permissionSignature}` + ); + return { approved: true, scope: 'session' }; + } + + try { + const toolCallId = nanoid(); + const content: ToolCallContent[] = []; + + // 添加详情信息 + if (details.message) { + content.push({ + type: 'content', + content: { type: 'text', text: details.message }, + }); + } + + // 添加风险信息 + if (details.risks && details.risks.length > 0) { + content.push({ + type: 'content', + content: { type: 'text', text: `Risks:\n- ${details.risks.join('\n- ')}` }, + }); + } + + // 转换 Blade ToolKind 到 ACP ToolKind + const acpToolKind = this.mapToolKind(toolKind); + + const permissionRequest: RequestPermissionRequest = { + sessionId: this.id, + options: [ + // 允许选项 + { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'allow_always', name: 'Always allow', kind: 'allow_always' }, + // 拒绝选项 + { optionId: 'reject_once', name: 'Deny once', kind: 'reject_once' }, + { optionId: 'reject_always', name: 'Always deny', kind: 'reject_always' }, + ], + toolCall: { + toolCallId, + status: 'pending' as ToolCallStatus, + title: details.title || 'Permission Required', + content, + kind: acpToolKind, + }, + }; + + const response = await this.connection.requestPermission(permissionRequest); + + // 检查用户选择 + const outcome = response.outcome; + if (outcome.outcome === 'cancelled') { + return { + approved: false, + reason: 'User cancelled the permission request', + }; + } + + // outcome.outcome === 'selected',此时有 optionId + const optionId = outcome.optionId; + const approved = optionId === 'allow_once' || optionId === 'allow_always'; + + // 缓存 allow_always 选择(会话级别) + if (optionId === 'allow_always') { + this.sessionApprovals.add(permissionSignature); + logger.debug( + `[AcpSession ${this.id}] Cached approval for: ${permissionSignature}` + ); + } + + // 处理 reject_always(可选:缓存拒绝,但目前不实现) + // if (optionId === 'reject_always') { ... } + + return { + approved, + reason: approved ? undefined : 'User denied the operation', + scope: optionId === 'allow_always' ? 'session' : 'once', + }; + } catch (error) { + logger.warn(`[AcpSession ${this.id}] Permission request failed:`, error); + // 权限请求失败时,默认拒绝 + return { + approved: false, + reason: 'Permission request failed', + }; + } + } + + /** + * 映射 Blade ToolKind 到 ACP ToolKind + * + * Blade ToolKind: 'readonly' | 'write' | 'execute' + * ACP ToolKind: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'other' + */ + private mapToolKind(kind: string | undefined): ToolKind { + const kindMap: Record = { + // Blade ToolKind 映射 + readonly: 'read', + write: 'edit', + execute: 'execute', + // 保留其他可能的直接映射 + read: 'read', + edit: 'edit', + delete: 'delete', + move: 'move', + search: 'search', + think: 'think', + fetch: 'fetch', + }; + return kindMap[kind || ''] || 'other'; + } +} diff --git a/src/acp/index.ts b/src/acp/index.ts new file mode 100644 index 00000000..cae3f6fc --- /dev/null +++ b/src/acp/index.ts @@ -0,0 +1,34 @@ +/** + * ACP (Agent Client Protocol) 集成模块 + * + * 提供 Blade 作为 ACP Agent 的能力,使其可以被 Zed、JetBrains、Neovim 等编辑器调用。 + * + */ + +import * as acp from '@agentclientprotocol/sdk'; +import { Readable, Writable } from 'node:stream'; +import { BladeAgent } from './BladeAgent.js'; + +/** + * 启动 ACP 集成模式 + * + * 将 Blade CLI 作为 ACP Agent 运行,通过 stdio 与 IDE 通信。 + * IDE (如 Zed) 作为 ACP Client 启动 Blade 进程并发送请求。 + */ +export async function runAcpIntegration(): Promise { + // 使用 Web Streams API 包装 Node.js streams + const stdout = Writable.toWeb(process.stdout) as WritableStream; + const stdin = Readable.toWeb(process.stdin) as ReadableStream; + + // 创建 ndJSON 流(换行分隔的 JSON) + const stream = acp.ndJsonStream(stdout, stdin); + + // 创建 ACP 连接,传入 Agent 工厂函数 + const connection = new acp.AgentSideConnection( + (conn) => new BladeAgent(conn), + stream + ); + + // 等待连接关闭 + await connection.closed; +} diff --git a/src/agent/Agent.ts b/src/agent/Agent.ts index 3be0f7c5..2de4cd91 100644 --- a/src/agent/Agent.ts +++ b/src/agent/Agent.ts @@ -609,17 +609,9 @@ IMPORTANT: Execute according to the approved plan above. Follow the steps exactl logger.debug('可用工具数量:', tools.length); logger.debug('================================\n'); - // 3. 过滤孤儿 tool 消息(防止 API 400 错误) - const filteredMessages = this.filterOrphanToolMessages(messages); - if (filteredMessages.length < messages.length) { - logger.debug( - `🔧 过滤掉 ${messages.length - filteredMessages.length} 条孤儿 tool 消息` - ); - } - - // 4. 直接调用 ChatService(OpenAI SDK 已内置重试机制) + // 3. 直接调用 ChatService(各 provider 自行处理消息过滤) const turnResult = await this.chatService.chat( - filteredMessages, + messages, tools, options?.signal ); @@ -1176,39 +1168,6 @@ IMPORTANT: Execute according to the approved plan above. Follow the steps exactl return await this.runLoop(message, chatContext, options); } - /** - * 过滤孤儿 tool 消息 - * - * 孤儿 tool 消息是指 tool_call_id 对应的 assistant 消息不存在的 tool 消息。 - * 这种情况通常发生在上下文压缩后,导致 OpenAI API 返回 400 错误。 - * - * @param messages - 原始消息列表 - * @returns 过滤后的消息列表 - */ - private filterOrphanToolMessages(messages: Message[]): Message[] { - // 收集所有可用的 tool_call ID - const availableToolCallIds = new Set(); - for (const msg of messages) { - if (msg.role === 'assistant' && msg.tool_calls) { - for (const tc of msg.tool_calls) { - availableToolCallIds.add(tc.id); - } - } - } - - // 过滤掉孤儿 tool 消息 - return messages.filter((msg) => { - if (msg.role === 'tool') { - // 缺失 tool_call_id 的 tool 消息直接丢弃(否则会触发 API 400) - if (!msg.tool_call_id) { - return false; - } - return availableToolCallIds.has(msg.tool_call_id); - } - return true; // 保留其他所有消息 - }); - } - /** * 带系统提示的聊天接口 */ diff --git a/src/blade.tsx b/src/blade.tsx index 4564fe80..a613c94b 100644 --- a/src/blade.tsx +++ b/src/blade.tsx @@ -48,6 +48,13 @@ export async function main() { return; } + // 检查是否是 ACP 模式 + if (rawArgs.includes('--acp')) { + const { runAcpIntegration } = await import('./acp/index.js'); + await runAcpIntegration(); + return; + } + const cli = yargs(hideBin(process.argv)) .scriptName(cliConfig.scriptName) .usage(cliConfig.usage) diff --git a/src/cli/config.ts b/src/cli/config.ts index aeaa0b27..a7cf25dd 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -163,6 +163,11 @@ export const globalOptions = { describe: 'Automatically connect to IDE on startup', group: 'Integration:', }, + acp: { + type: 'boolean', + describe: 'Run in ACP (Agent Client Protocol) mode for IDE integration', + group: 'Integration:', + }, 'strict-mcp-config': { alias: ['strictMcpConfig'], type: 'boolean', diff --git a/src/config/types.ts b/src/config/types.ts index e3075ab5..10ad0119 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -6,7 +6,7 @@ /** * LLM API 提供商类型 */ -export type ProviderType = 'openai-compatible' | 'anthropic'; +export type ProviderType = 'openai-compatible' | 'anthropic' | 'gpt-openai-platform'; /** * 权限模式枚举 @@ -66,6 +66,9 @@ export interface ModelConfig { // Thinking 模型配置(如 DeepSeek R1) supportsThinking?: boolean; // 手动覆盖自动检测结果 thinkingBudget?: number; // 思考 token 预算(可选) + + // GPT OpenAI Platform 特有配置 + apiVersion?: string; // API 版本(如 '2024-03-01-preview') } export interface BladeConfig { diff --git a/src/services/ChatServiceInterface.ts b/src/services/ChatServiceInterface.ts index b101bdcc..07d2b84c 100644 --- a/src/services/ChatServiceInterface.ts +++ b/src/services/ChatServiceInterface.ts @@ -6,6 +6,7 @@ import type { ChatCompletionMessageToolCall } from 'openai/resources/chat'; import type { ProviderType } from '../config/types.js'; import { createLogger, LogCategory } from '../logging/Logger.js'; +import { GptOpenaiPlatformChatService } from './GptOpenaiPlatformChatService.js'; import { OpenAIChatService } from './OpenAIChatService.js'; const logger = createLogger(LogCategory.SERVICE); @@ -34,6 +35,7 @@ export interface ChatConfig { maxContextTokens?: number; // 上下文窗口大小(用于压缩判断) maxOutputTokens?: number; // 输出 token 限制(传给 API 的 max_tokens) timeout?: number; + apiVersion?: string; // GPT OpenAI Platform 专用:API 版本(如 '2024-03-01-preview') } /** @@ -118,6 +120,9 @@ export function createChatService(config: ChatConfig): IChatService { case 'openai-compatible': return new OpenAIChatService(config); + case 'gpt-openai-platform': + return new GptOpenaiPlatformChatService(config); + case 'anthropic': // Anthropic 暂未实现,抛出友好错误 throw new Error( diff --git a/src/services/FileSystemService.ts b/src/services/FileSystemService.ts new file mode 100644 index 00000000..d1f5fb24 --- /dev/null +++ b/src/services/FileSystemService.ts @@ -0,0 +1,123 @@ +/** + * 文件系统服务 + * + * 抽象文件操作,支持本地和远程(ACP)两种实现。 + * 工具层统一通过此接口访问文件系统。 + */ + +import * as fs from 'fs/promises'; + +/** + * 文件统计信息 + */ +export interface FileStat { + size: number; + isDirectory: boolean; + isFile: boolean; + mtime: Date; +} + +/** + * 文件系统服务接口 + */ +export interface FileSystemService { + // 基础操作 + readTextFile(filePath: string): Promise; + writeTextFile(filePath: string, content: string): Promise; + exists(filePath: string): Promise; + + // 扩展操作 + readBinaryFile(filePath: string): Promise; + stat(filePath: string): Promise; + mkdir(dirPath: string, options?: { recursive?: boolean }): Promise; +} + +/** + * 本地文件系统服务(默认实现) + */ +export class LocalFileSystemService implements FileSystemService { + async readTextFile(filePath: string): Promise { + return fs.readFile(filePath, 'utf-8'); + } + + async writeTextFile(filePath: string, content: string): Promise { + await fs.writeFile(filePath, content, 'utf-8'); + } + + async exists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } + } + + async readBinaryFile(filePath: string): Promise { + return fs.readFile(filePath); + } + + async stat(filePath: string): Promise { + try { + const stats = await fs.stat(filePath); + return { + size: stats.size, + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + mtime: stats.mtime, + }; + } catch { + return null; + } + } + + async mkdir(dirPath: string, options?: { recursive?: boolean }): Promise { + await fs.mkdir(dirPath, { recursive: options?.recursive ?? false }); + } +} + +// ==================== 服务获取 ==================== + +/** + * 当前活跃的文件系统服务 + * 默认使用本地文件系统,ACP 模式下会被替换 + */ +let currentFileSystemService: FileSystemService = new LocalFileSystemService(); + +/** + * 获取文件系统服务 + * + * 返回当前活跃的文件系统服务实例。 + * - 终端模式:返回 LocalFileSystemService + * - ACP 模式:返回 AcpFileSystemService(由 ACP 模块设置) + */ +export function getFileSystemService(): FileSystemService { + return currentFileSystemService; +} + +/** + * 设置文件系统服务 + * + * 用于 ACP 模式切换文件系统实现。 + * @internal 仅供 ACP 模块使用 + */ +export function setFileSystemService(service: FileSystemService): void { + currentFileSystemService = service; +} + +/** + * 重置为本地文件系统服务 + * + * 用于 ACP 会话结束后恢复默认。 + * @internal 仅供 ACP 模块使用 + */ +export function resetFileSystemService(): void { + currentFileSystemService = new LocalFileSystemService(); +} + +/** + * 检查当前是否使用 ACP 文件系统 + */ +export function isUsingAcpFileSystem(): boolean { + return !(currentFileSystemService instanceof LocalFileSystemService); +} diff --git a/src/services/GptOpenaiPlatformChatService.ts b/src/services/GptOpenaiPlatformChatService.ts new file mode 100644 index 00000000..761d2bf6 --- /dev/null +++ b/src/services/GptOpenaiPlatformChatService.ts @@ -0,0 +1,540 @@ +/** + * GPT OpenAI Platform ChatService + * 字节跳动内部 GPT 平台专用的 ChatService 实现 + * + * 主要特点: + * 1. 每次请求自动生成 x-tt-logid header + * 2. 支持可选的 apiVersion 配置 + */ + +import { randomUUID } from 'node:crypto'; +import OpenAI from 'openai'; +import type { + ChatCompletionMessageParam, + ChatCompletionMessageToolCall, + ChatCompletionTool, +} from 'openai/resources/chat'; +import { createLogger, LogCategory } from '../logging/Logger.js'; +import type { + ChatConfig, + ChatResponse, + IChatService, + Message, + StreamChunk, +} from './ChatServiceInterface.js'; + +const _logger = createLogger(LogCategory.CHAT); + +/** + * 生成 x-tt-logid + * 格式:32位 hex + "01" 后缀 + */ +function generateLogId(): string { + return randomUUID().replace(/-/g, '') + '01'; +} + +/** + * 检查是否是 Gemini 模型 + */ +function isGeminiModel(model: string): boolean { + return model.toLowerCase().includes('gemini'); +} + +/** + * 清理工具参数中 Gemini API 不支持的字段 + * Gemini 不支持: $schema, additionalProperties + */ +// biome-ignore lint/suspicious/noExplicitAny: 需要处理任意 JSON Schema 结构 +function cleanToolParametersForGemini(params: any): any { + if (!params || typeof params !== 'object') { + return params; + } + + if (Array.isArray(params)) { + return params.map(cleanToolParametersForGemini); + } + + const cleaned: Record = {}; + for (const [key, value] of Object.entries(params)) { + // 跳过 Gemini 不支持的字段 + if (key === '$schema' || key === 'additionalProperties') { + continue; + } + cleaned[key] = cleanToolParametersForGemini(value); + } + return cleaned; +} + +/** + * 为 Gemini 修复消息,确保 tool_call 和 tool_result 一一对应 + * + * Gemini API 要求: + * 1. 每个 tool_call 必须有对应的 tool result + * 2. 每个 tool result 必须有对应的 tool_call + * + * 策略: + * - 孤儿 tool results(没有对应 tool_call):删除 + * - 孤儿 tool_calls(没有对应 result):添加占位符 result + * 这样可以保留上下文,避免死循环 + */ +function fixMessagesForGemini(messages: Message[]): Message[] { + // 1. 收集所有 tool_call IDs 及其位置 + const toolCallMap = new Map(); // id -> assistant message index + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + if (msg.role === 'assistant' && msg.tool_calls) { + for (const tc of msg.tool_calls) { + toolCallMap.set(tc.id, i); + } + } + } + + // 2. 收集所有 tool result 的 tool_call_ids + const toolResultIds = new Set(); + for (const msg of messages) { + if (msg.role === 'tool' && msg.tool_call_id) { + toolResultIds.add(msg.tool_call_id); + } + } + + // 3. 找到需要补充的 tool_call IDs(有 tool_call 但没有 result) + const missingResultIds: string[] = []; + for (const id of toolCallMap.keys()) { + if (!toolResultIds.has(id)) { + missingResultIds.push(id); + } + } + + // 4. 构建修复后的消息列表 + const fixed: Message[] = []; + for (const msg of messages) { + if (msg.role === 'tool') { + // 只保留有对应 tool_call 的 tool results + if (msg.tool_call_id && toolCallMap.has(msg.tool_call_id)) { + fixed.push(msg); + } + // 孤儿 tool results 被丢弃 + } else { + fixed.push(msg); + + // 如果是 assistant 消息且有 tool_calls,检查是否需要补充 results + if (msg.role === 'assistant' && msg.tool_calls) { + for (const tc of msg.tool_calls) { + if (missingResultIds.includes(tc.id)) { + // 添加占位符 tool result + fixed.push({ + role: 'tool', + content: '[Result lost due to context compression]', + tool_call_id: tc.id, + }); + } + } + } + } + } + + if (missingResultIds.length > 0) { + _logger.debug( + `🔧 [GptOpenaiPlatformChatService] Gemini 修复:补充 ${missingResultIds.length} 个缺失的 tool results` + ); + } + + return fixed; +} + +export class GptOpenaiPlatformChatService implements IChatService { + private config: ChatConfig; + + constructor(config: ChatConfig) { + _logger.debug('🚀 [GptOpenaiPlatformChatService] Initializing'); + _logger.debug('⚙️ [GptOpenaiPlatformChatService] Config:', { + model: config.model, + baseUrl: config.baseUrl, + apiVersion: config.apiVersion, + temperature: config.temperature, + maxContextTokens: config.maxContextTokens, + timeout: config.timeout, + hasApiKey: !!config.apiKey, + }); + + if (!config.baseUrl) { + _logger.error('❌ [GptOpenaiPlatformChatService] baseUrl is required'); + throw new Error('baseUrl is required in ChatConfig'); + } + if (!config.apiKey) { + _logger.error('❌ [GptOpenaiPlatformChatService] apiKey is required'); + throw new Error('apiKey is required in ChatConfig'); + } + if (!config.model) { + _logger.error('❌ [GptOpenaiPlatformChatService] model is required'); + throw new Error('model is required in ChatConfig'); + } + + this.config = config; + _logger.debug('✅ [GptOpenaiPlatformChatService] Initialized successfully'); + } + + /** + * 创建一个新的 OpenAI 客户端(每次请求都创建,以生成新的 logid) + */ + private createClient(): OpenAI { + const logId = generateLogId(); + _logger.debug('🔑 [GptOpenaiPlatformChatService] Generated logid:', logId); + + const defaultQuery: Record = {}; + const defaultHeaders: Record = { + 'api-key': this.config.apiKey, + 'x-tt-logid': logId, + }; + + // 如果配置了 apiVersion,添加到 query 参数中 + if (this.config.apiVersion) { + defaultQuery['api-version'] = this.config.apiVersion; + } + + return new OpenAI({ + apiKey: this.config.apiKey, + baseURL: this.config.baseUrl, + timeout: this.config.timeout ?? 180000, + maxRetries: 3, + defaultQuery, + defaultHeaders, + }); + } + + async chat( + messages: Message[], + tools?: Array<{ + name: string; + description: string; + // biome-ignore lint/suspicious/noExplicitAny: 工具参数格式不确定 + parameters: any; + }>, + signal?: AbortSignal + ): Promise { + const startTime = Date.now(); + _logger.debug('🚀 [GptOpenaiPlatformChatService] Starting chat request'); + _logger.debug('📝 [GptOpenaiPlatformChatService] Messages count:', messages.length); + + const client = this.createClient(); + const isGemini = isGeminiModel(this.config.model); + + // Gemini 需要修复消息确保 tool_call/result 配对(补充缺失的 results 而非删除) + const filteredMessages = isGemini ? fixMessagesForGemini(messages) : messages; + + const openaiMessages: ChatCompletionMessageParam[] = filteredMessages.map((msg) => { + if (msg.role === 'tool') { + return { + role: 'tool', + content: msg.content, + tool_call_id: msg.tool_call_id!, + }; + } + if (msg.role === 'assistant' && msg.tool_calls) { + return { + role: 'assistant', + content: msg.content || null, + tool_calls: msg.tool_calls, + }; + } + return { + role: msg.role as 'user' | 'assistant' | 'system', + content: msg.content, + }; + }); + + const openaiTools: ChatCompletionTool[] | undefined = tools?.map((tool) => ({ + type: 'function' as const, + function: { + name: tool.name, + description: tool.description, + parameters: isGemini + ? cleanToolParametersForGemini(tool.parameters) + : tool.parameters, + }, + })); + + _logger.debug( + '🔧 [GptOpenaiPlatformChatService] Tools count:', + openaiTools?.length || 0 + ); + + const requestParams = { + model: this.config.model, + messages: openaiMessages, + tools: openaiTools, + tool_choice: + openaiTools && openaiTools.length > 0 ? ('auto' as const) : undefined, + max_tokens: this.config.maxOutputTokens ?? 32768, + temperature: this.config.temperature ?? 0.0, + }; + + _logger.debug('📤 [GptOpenaiPlatformChatService] Request params:', { + model: requestParams.model, + messagesCount: requestParams.messages.length, + toolsCount: requestParams.tools?.length || 0, + tool_choice: requestParams.tool_choice, + max_tokens: requestParams.max_tokens, + temperature: requestParams.temperature, + }); + + try { + const completion = await client.chat.completions.create(requestParams, { + signal, + }); + const requestDuration = Date.now() - startTime; + + _logger.debug( + '📥 [GptOpenaiPlatformChatService] Response received in', + requestDuration, + 'ms' + ); + + // 验证响应格式 + if (!completion) { + _logger.error( + '❌ [GptOpenaiPlatformChatService] API returned null/undefined response' + ); + throw new Error('API returned null/undefined response'); + } + + if (!completion.choices || !Array.isArray(completion.choices)) { + _logger.error( + '❌ [GptOpenaiPlatformChatService] Invalid API response format - missing choices array' + ); + throw new Error( + `Invalid API response: missing choices array. Response: ${JSON.stringify(completion)}` + ); + } + + if (completion.choices.length === 0) { + _logger.error( + '❌ [GptOpenaiPlatformChatService] API returned empty choices array' + ); + throw new Error('API returned empty choices array'); + } + + const choice = completion.choices[0]; + if (!choice) { + _logger.error( + '❌ [GptOpenaiPlatformChatService] No completion choice returned' + ); + throw new Error('No completion choice returned'); + } + + _logger.debug('📝 [GptOpenaiPlatformChatService] Response choice:', { + finishReason: choice.finish_reason, + contentLength: choice.message.content?.length || 0, + hasToolCalls: !!choice.message.tool_calls, + toolCallsCount: choice.message.tool_calls?.length || 0, + }); + + const toolCalls = choice.message.tool_calls?.filter( + (tc): tc is ChatCompletionMessageToolCall => tc.type === 'function' + ); + + // 提取 reasoning_content(如果有) + const extendedMessage = choice.message as typeof choice.message & { + reasoning_content?: string; + }; + const reasoningContent = extendedMessage.reasoning_content || undefined; + + // 提取 reasoning_tokens + const extendedUsage = completion.usage as typeof completion.usage & { + reasoning_tokens?: number; + }; + + const response = { + content: choice.message.content || '', + reasoningContent, + toolCalls: toolCalls, + usage: { + promptTokens: completion.usage?.prompt_tokens || 0, + completionTokens: completion.usage?.completion_tokens || 0, + totalTokens: completion.usage?.total_tokens || 0, + reasoningTokens: extendedUsage?.reasoning_tokens, + }, + }; + + _logger.debug('✅ [GptOpenaiPlatformChatService] Chat completed successfully'); + return response; + } catch (error) { + const requestDuration = Date.now() - startTime; + _logger.error( + '❌ [GptOpenaiPlatformChatService] Chat request failed after', + requestDuration, + 'ms' + ); + _logger.error('❌ [GptOpenaiPlatformChatService] Error details:', error); + throw error; + } + } + + async *streamChat( + messages: Message[], + tools?: Array<{ + name: string; + description: string; + // biome-ignore lint/suspicious/noExplicitAny: 工具参数格式不确定 + parameters: any; + }>, + signal?: AbortSignal + ): AsyncGenerator { + const startTime = Date.now(); + _logger.debug('🚀 [GptOpenaiPlatformChatService] Starting stream request'); + _logger.debug('📝 [GptOpenaiPlatformChatService] Messages count:', messages.length); + + const client = this.createClient(); + const isGemini = isGeminiModel(this.config.model); + + // Gemini 需要修复消息确保 tool_call/result 配对(补充缺失的 results 而非删除) + const filteredMessages = isGemini ? fixMessagesForGemini(messages) : messages; + + const openaiMessages: ChatCompletionMessageParam[] = filteredMessages.map((msg) => { + if (msg.role === 'tool') { + return { + role: 'tool', + content: msg.content, + tool_call_id: msg.tool_call_id!, + }; + } + if (msg.role === 'assistant' && msg.tool_calls) { + return { + role: 'assistant', + content: msg.content || null, + tool_calls: msg.tool_calls, + }; + } + return { + role: msg.role as 'user' | 'assistant' | 'system', + content: msg.content, + }; + }); + + const openaiTools: ChatCompletionTool[] | undefined = tools?.map((tool) => ({ + type: 'function' as const, + function: { + name: tool.name, + description: tool.description, + parameters: isGemini + ? cleanToolParametersForGemini(tool.parameters) + : tool.parameters, + }, + })); + + const requestParams = { + model: this.config.model, + messages: openaiMessages, + tools: openaiTools, + tool_choice: + openaiTools && openaiTools.length > 0 ? ('auto' as const) : ('none' as const), + max_tokens: this.config.maxOutputTokens ?? 32768, + temperature: this.config.temperature ?? 0.0, + stream: true as const, + }; + + _logger.debug('📤 [GptOpenaiPlatformChatService] Stream request params:', { + model: requestParams.model, + messagesCount: requestParams.messages.length, + toolsCount: requestParams.tools?.length || 0, + }); + + try { + const stream = await client.chat.completions.create(requestParams, { + signal, + }); + const requestDuration = Date.now() - startTime; + _logger.debug( + '📥 [GptOpenaiPlatformChatService] Stream started in', + requestDuration, + 'ms' + ); + + let chunkCount = 0; + let totalContent = ''; + let totalReasoningContent = ''; + let toolCallsReceived = false; + + for await (const chunk of stream) { + chunkCount++; + + if (!chunk || !chunk.choices || !Array.isArray(chunk.choices)) { + _logger.warn( + '⚠️ [GptOpenaiPlatformChatService] Invalid chunk format in stream', + chunkCount + ); + continue; + } + + const delta = chunk.choices[0]?.delta; + if (!delta) { + continue; + } + + const extendedDelta = delta as typeof delta & { + reasoning_content?: string; + }; + + if (delta.content) { + totalContent += delta.content; + } + + if (extendedDelta.reasoning_content) { + totalReasoningContent += extendedDelta.reasoning_content; + } + + if (delta.tool_calls && !toolCallsReceived) { + toolCallsReceived = true; + _logger.debug( + '🔧 [GptOpenaiPlatformChatService] Tool calls detected in stream' + ); + } + + const finishReason = chunk.choices[0]?.finish_reason; + if (finishReason) { + _logger.debug( + '🏁 [GptOpenaiPlatformChatService] Stream finished with reason:', + finishReason + ); + _logger.debug('📊 [GptOpenaiPlatformChatService] Stream summary:', { + totalChunks: chunkCount, + totalContentLength: totalContent.length, + totalReasoningContentLength: totalReasoningContent.length, + hadToolCalls: toolCallsReceived, + duration: Date.now() - startTime + 'ms', + }); + } + + yield { + content: delta.content || undefined, + reasoningContent: extendedDelta.reasoning_content || undefined, + toolCalls: delta.tool_calls, + finishReason: finishReason || undefined, + }; + } + + _logger.debug('✅ [GptOpenaiPlatformChatService] Stream completed successfully'); + } catch (error) { + const requestDuration = Date.now() - startTime; + _logger.error( + '❌ [GptOpenaiPlatformChatService] Stream request failed after', + requestDuration, + 'ms' + ); + _logger.error('❌ [GptOpenaiPlatformChatService] Stream error details:', error); + throw error; + } + } + + getConfig(): ChatConfig { + return { ...this.config }; + } + + updateConfig(newConfig: Partial): void { + _logger.debug('🔄 [GptOpenaiPlatformChatService] Updating configuration'); + this.config = { ...this.config, ...newConfig }; + _logger.debug( + '✅ [GptOpenaiPlatformChatService] Configuration updated successfully' + ); + } +} diff --git a/src/services/OpenAIChatService.ts b/src/services/OpenAIChatService.ts index bfa4369a..d2a7df46 100644 --- a/src/services/OpenAIChatService.ts +++ b/src/services/OpenAIChatService.ts @@ -15,6 +15,36 @@ import type { const _logger = createLogger(LogCategory.CHAT); +/** + * 过滤孤儿 tool 消息 + * + * 孤儿 tool 消息是指 tool_call_id 对应的 assistant 消息不存在的 tool 消息。 + * 这种情况通常发生在上下文压缩后,导致 OpenAI API 返回 400 错误。 + */ +function filterOrphanToolMessages(messages: Message[]): Message[] { + // 收集所有可用的 tool_call ID + const availableToolCallIds = new Set(); + for (const msg of messages) { + if (msg.role === 'assistant' && msg.tool_calls) { + for (const tc of msg.tool_calls) { + availableToolCallIds.add(tc.id); + } + } + } + + // 过滤掉孤儿 tool 消息 + return messages.filter((msg) => { + if (msg.role === 'tool') { + // 缺失 tool_call_id 的 tool 消息直接丢弃 + if (!msg.tool_call_id) { + return false; + } + return availableToolCallIds.has(msg.tool_call_id); + } + return true; + }); +} + export class OpenAIChatService implements IChatService { private client: OpenAI; @@ -64,12 +94,21 @@ export class OpenAIChatService implements IChatService { const startTime = Date.now(); _logger.debug('🚀 [ChatService] Starting chat request'); _logger.debug('📝 [ChatService] Messages count:', messages.length); + + // 过滤孤儿 tool 消息 + const filteredMessages = filterOrphanToolMessages(messages); + if (filteredMessages.length < messages.length) { + _logger.debug( + `🔧 [ChatService] 过滤掉 ${messages.length - filteredMessages.length} 条孤儿 tool 消息` + ); + } + _logger.debug( '📝 [ChatService] Messages preview:', - messages.map((m) => ({ role: m.role, contentLength: m.content.length })) + filteredMessages.map((m) => ({ role: m.role, contentLength: m.content.length })) ); - const openaiMessages: ChatCompletionMessageParam[] = messages.map((msg) => { + const openaiMessages: ChatCompletionMessageParam[] = filteredMessages.map((msg) => { if (msg.role === 'tool') { return { role: 'tool', @@ -249,12 +288,21 @@ export class OpenAIChatService implements IChatService { const startTime = Date.now(); _logger.debug('🚀 [ChatService] Starting chat stream request'); _logger.debug('📝 [ChatService] Messages count:', messages.length); + + // 过滤孤儿 tool 消息 + const filteredMessages = filterOrphanToolMessages(messages); + if (filteredMessages.length < messages.length) { + _logger.debug( + `🔧 [ChatService] 过滤掉 ${messages.length - filteredMessages.length} 条孤儿 tool 消息` + ); + } + _logger.debug( '📝 [ChatService] Messages preview:', - messages.map((m) => ({ role: m.role, contentLength: m.content.length })) + filteredMessages.map((m) => ({ role: m.role, contentLength: m.content.length })) ); - const openaiMessages: ChatCompletionMessageParam[] = messages.map((msg) => { + const openaiMessages: ChatCompletionMessageParam[] = filteredMessages.map((msg) => { if (msg.role === 'tool') { return { role: 'tool', diff --git a/src/slash-commands/builtinCommands.ts b/src/slash-commands/builtinCommands.ts index 65e46528..511da194 100644 --- a/src/slash-commands/builtinCommands.ts +++ b/src/slash-commands/builtinCommands.ts @@ -44,6 +44,7 @@ const helpCommand: SlashCommand = { return { success: true, + content: helpText, // ACP 模式使用 message: '帮助信息已显示', }; }, @@ -99,12 +100,15 @@ const versionCommand: SlashCommand = { return { success: true, + content: versionInfo, message: '版本信息已显示', }; } catch (_error) { - sessionActions().addAssistantMessage('🗡️ **Blade Code**\n\n版本信息获取失败'); + const errorMsg = '🗡️ **Blade Code**\n\n版本信息获取失败'; + sessionActions().addAssistantMessage(errorMsg); return { success: true, + content: errorMsg, message: '版本信息已显示', }; } @@ -172,6 +176,7 @@ ${!hasBlademd ? '\n💡 **建议:** 运行 `/init` 命令来创建项目配置 return { success: true, + content: statusText, message: '状态信息已显示', }; } catch (error) { @@ -227,6 +232,7 @@ const configCommand: SlashCommand = { return { success: true, + content: configText, message: '配置面板已显示', }; }, @@ -259,6 +265,7 @@ const contextCommand: SlashCommand = { return { success: true, + content: contextText, message: '上下文信息已显示', }; }, @@ -294,6 +301,7 @@ const costCommand: SlashCommand = { return { success: true, + content: costText, message: '成本信息已显示', }; }, diff --git a/src/slash-commands/types.ts b/src/slash-commands/types.ts index a1ab4df1..94893757 100644 --- a/src/slash-commands/types.ts +++ b/src/slash-commands/types.ts @@ -4,7 +4,8 @@ export interface SlashCommandResult { success: boolean; - message?: string; + message?: string; // 简短状态消息(如 "帮助信息已显示") + content?: string; // 完整内容(用于 ACP 模式显示给用户) error?: string; data?: any; } diff --git a/src/store/selectors/index.ts b/src/store/selectors/index.ts index 5167796a..01160826 100644 --- a/src/store/selectors/index.ts +++ b/src/store/selectors/index.ts @@ -29,6 +29,11 @@ export const useSessionId = () => useBladeStore((state) => state.session.session */ export const useMessages = () => useBladeStore((state) => state.session.messages); +/** + * 获取清屏计数器(用于强制 Static 组件重新挂载) + */ +export const useClearCount = () => useBladeStore((state) => state.session.clearCount); + /** * 获取思考状态 */ diff --git a/src/store/slices/sessionSlice.ts b/src/store/slices/sessionSlice.ts index 448bb1f4..6e12c586 100644 --- a/src/store/slices/sessionSlice.ts +++ b/src/store/slices/sessionSlice.ts @@ -43,6 +43,7 @@ const initialSessionState: SessionState = { tokenUsage: { ...initialTokenUsage }, currentThinkingContent: null, thinkingExpanded: false, + clearCount: 0, }; /** @@ -151,10 +152,16 @@ export const createSessionSlice: StateCreator< /** * 清除消息 + * 同时递增 clearCount 以强制 UI 的 Static 组件重新挂载 */ clearMessages: () => { set((state) => ({ - session: { ...state.session, messages: [], error: null }, + session: { + ...state.session, + messages: [], + error: null, + clearCount: state.session.clearCount + 1, + }, })); }, diff --git a/src/store/types.ts b/src/store/types.ts index b19d6025..37b888c4 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -67,6 +67,7 @@ export interface SessionState { tokenUsage: TokenUsage; // Token 使用量统计 currentThinkingContent: string | null; // 当前正在接收的 thinking 内容(流式) thinkingExpanded: boolean; // thinking 内容是否展开显示 + clearCount: number; // 清屏计数器(用于强制 Static 组件重新挂载) } /** diff --git a/src/tools/builtin/file/edit.ts b/src/tools/builtin/file/edit.ts index 25ae6e0a..2e0efd54 100644 --- a/src/tools/builtin/file/edit.ts +++ b/src/tools/builtin/file/edit.ts @@ -1,6 +1,7 @@ -import { promises as fs } from 'fs'; import { extname } from 'path'; import { z } from 'zod'; +import { isAcpMode } from '../../../acp/AcpServiceContext.js'; +import { getFileSystemService } from '../../../services/FileSystemService.js'; import { createTool } from '../../core/createTool.js'; import type { ExecutionContext, ToolResult } from '../../types/index.js'; import { ToolErrorType, ToolKind } from '../../types/index.js'; @@ -65,12 +66,19 @@ export const editTool = createTool({ try { updateOutput?.('Starting to read file...'); - // 读取文件内容 + // 获取文件系统服务(ACP 或本地) + const fsService = getFileSystemService(); + const useAcp = isAcpMode(); + + // 读取文件内容(统一使用 FileSystemService) let content: string; try { - content = await fs.readFile(file_path, 'utf8'); + if (useAcp) { + updateOutput?.('通过 IDE 读取文件...'); + } + content = await fsService.readTextFile(file_path); } catch (error: any) { - if (error.code === 'ENOENT') { + if (error.code === 'ENOENT' || error.message?.includes('not found')) { return { success: false, llmContent: `File not found: ${file_path}`, @@ -285,8 +293,11 @@ export const editTool = createTool({ signal.throwIfAborted(); - // 写入文件 - await fs.writeFile(file_path, newContent, 'utf8'); + // 写入文件(统一使用 FileSystemService) + if (useAcp) { + updateOutput?.('通过 IDE 写入文件...'); + } + await fsService.writeTextFile(file_path, newContent); // 🔴 更新文件访问记录(记录编辑操作) if (sessionId) { @@ -294,8 +305,8 @@ export const editTool = createTool({ await tracker.recordFileEdit(file_path, sessionId, 'edit'); } - // 验证写入成功 - const stats = await fs.stat(file_path); + // 验证写入成功(统一使用 FileSystemService) + const stats = await fsService.stat(file_path); // 生成差异片段(仅显示第一个替换的上下文) const diffSnippet = generateDiffSnippetWithMatch( @@ -323,12 +334,17 @@ export const editTool = createTool({ original_size: content.length, new_size: newContent.length, size_diff: newContent.length - content.length, - last_modified: stats.mtime.toISOString(), + last_modified: + stats?.mtime instanceof Date ? stats.mtime.toISOString() : undefined, snapshot_created: !!(sessionId && messageId), // 是否创建了快照 session_id: sessionId, message_id: messageId, diff_snippet: diffSnippet, // 添加差异片段 summary, // 🆕 流式显示摘要 + // 🆕 ACP diff 支持:完整内容用于 IDE 显示差异 + kind: 'edit', + oldContent: content, + newContent: newContent, }; const displayMessage = formatDisplayMessage(metadata, diffSnippet); diff --git a/src/tools/builtin/file/read.ts b/src/tools/builtin/file/read.ts index 61fba6d8..524ce011 100644 --- a/src/tools/builtin/file/read.ts +++ b/src/tools/builtin/file/read.ts @@ -1,6 +1,7 @@ -import { promises as fs } from 'fs'; import { extname } from 'path'; import { z } from 'zod'; +import { isAcpMode } from '../../../acp/AcpServiceContext.js'; +import { getFileSystemService } from '../../../services/FileSystemService.js'; import { createTool } from '../../core/createTool.js'; import type { ExecutionContext, ToolResult } from '../../types/index.js'; import { ToolErrorType, ToolKind } from '../../types/index.js'; @@ -79,9 +80,16 @@ export const readTool = createTool({ try { updateOutput?.('Starting file read...'); - // 检查文件是否存在 + // 获取文件系统服务(ACP 或本地) + const fsService = getFileSystemService(); + const useAcp = isAcpMode(); + + // 检查文件是否存在(统一使用 FileSystemService) try { - await fs.access(file_path); + const exists = await fsService.exists(file_path); + if (!exists) { + throw new Error('File not found'); + } } catch (_error) { return { success: false, @@ -103,10 +111,10 @@ export const readTool = createTool({ await tracker.recordFileRead(file_path, sessionId); } - // 获取文件统计信息 - const stats = await fs.stat(file_path); + // 获取文件统计信息(统一使用 FileSystemService) + const stats = await fsService.stat(file_path); - if (stats.isDirectory()) { + if (stats?.isDirectory) { return { success: false, llmContent: `Cannot read a directory: ${file_path}`, @@ -126,21 +134,40 @@ export const readTool = createTool({ let content: string; const metadata: Record = { file_path, - file_size: stats.size, + file_size: stats?.size, file_type: ext, - last_modified: stats.mtime.toISOString(), + last_modified: + stats?.mtime instanceof Date ? stats.mtime.toISOString() : undefined, encoding: encoding, + acp_mode: useAcp, }; // 处理二进制文件 if (isBinaryFile && encoding === 'utf8') { - updateOutput?.('检测到二进制文件,使用 base64 编码...'); - content = await fs.readFile(file_path, 'base64'); + // ⚠️ ACP 模式下二进制读取会 fallback 到本地 + if (useAcp) { + updateOutput?.('⚠️ 二进制文件通过本地读取(ACP 不支持)...'); + metadata.acp_fallback = true; + } else { + updateOutput?.('检测到二进制文件,使用 base64 编码...'); + } + const buffer = await fsService.readBinaryFile(file_path); + content = buffer.toString('base64'); metadata.encoding = 'base64'; metadata.is_binary = true; + } else if (isTextFile) { + // 文本文件:使用 FileSystemService 读取 + if (useAcp) { + updateOutput?.('通过 IDE 读取文件...'); + } + content = await fsService.readTextFile(file_path); } else { - // 读取文件内容 - const buffer = await fs.readFile(file_path); + // 其他文件:使用二进制读取 + // ⚠️ ACP 模式下会 fallback 到本地 + if (useAcp) { + metadata.acp_fallback = true; + } + const buffer = await fsService.readBinaryFile(file_path); if (encoding === 'base64') { content = buffer.toString('base64'); diff --git a/src/tools/builtin/file/write.ts b/src/tools/builtin/file/write.ts index e28f2fbd..0cdb8286 100644 --- a/src/tools/builtin/file/write.ts +++ b/src/tools/builtin/file/write.ts @@ -1,6 +1,8 @@ import { promises as fs } from 'fs'; import { dirname, extname } from 'path'; import { z } from 'zod'; +import { isAcpMode } from '../../../acp/AcpServiceContext.js'; +import { getFileSystemService } from '../../../services/FileSystemService.js'; import { createTool } from '../../core/createTool.js'; import type { ExecutionContext, ToolResult } from '../../types/index.js'; import { ToolErrorType, ToolKind } from '../../types/index.js'; @@ -55,11 +57,15 @@ export const writeTool = createTool({ try { updateOutput?.('开始写入文件...'); - // 检查并创建目录 + // 获取文件系统服务(ACP 或本地) + const fsService = getFileSystemService(); + const useAcp = isAcpMode(); + + // 检查并创建目录(统一使用 FileSystemService) if (create_directories) { const dir = dirname(file_path); try { - await fs.mkdir(dir, { recursive: true }); + await fsService.mkdir(dir, { recursive: true }); } catch (error: any) { if (error.code !== 'EEXIST') { throw error; @@ -69,22 +75,21 @@ export const writeTool = createTool({ signal.throwIfAborted(); - // 检查文件是否存在(用于后续验证和快照) + // 检查文件是否存在(统一使用 FileSystemService) let fileExists = false; let oldContent: string | null = null; try { - await fs.access(file_path); - fileExists = true; + fileExists = await fsService.exists(file_path); // 如果文件存在且是文本文件,读取旧内容用于生成 diff - if (encoding === 'utf8') { + if (fileExists && encoding === 'utf8') { try { - oldContent = await fs.readFile(file_path, 'utf8'); + oldContent = await fsService.readTextFile(file_path); } catch (error) { console.warn('[WriteTool] 读取旧文件内容失败:', error); } } } catch { - // 文件不存在 + // 检查失败,假设文件不存在 } // Read-Before-Write 验证(对齐 Claude Code 官方:强制模式) @@ -137,17 +142,41 @@ export const writeTool = createTool({ signal.throwIfAborted(); // 根据编码写入文件 - let writeBuffer: Buffer; - - if (encoding === 'base64') { - writeBuffer = Buffer.from(content, 'base64'); - } else if (encoding === 'binary') { - writeBuffer = Buffer.from(content, 'binary'); + if (encoding === 'utf8') { + // 文本文件:使用 FileSystemService 写入 + if (useAcp) { + updateOutput?.('通过 IDE 写入文件...'); + } + await fsService.writeTextFile(file_path, content); } else { - writeBuffer = Buffer.from(content, 'utf8'); - } + // 二进制文件写入 + // ⚠️ ACP 模式下不支持二进制写入,必须明确失败 + // 否则会写到本地磁盘而非远端,造成数据丢失/错位 + if (useAcp) { + return { + success: false, + llmContent: `Binary file writes are not supported in ACP mode. The IDE only supports text file operations. Please use encoding='utf8' for text files, or ask the user to write the file manually.`, + displayContent: `❌ ACP 模式不支持二进制文件写入\n\n当前通过 IDE 执行文件操作,但 IDE 仅支持文本文件。\n\n💡 建议:\n • 如果是文本文件,使用 encoding='utf8'\n • 如果必须写入二进制文件,请在本地终端执行`, + error: { + type: ToolErrorType.VALIDATION_ERROR, + message: 'Binary writes not supported in ACP mode', + }, + }; + } + + // 本地模式:正常写入二进制 + let writeBuffer: Buffer; - await fs.writeFile(file_path, writeBuffer); + if (encoding === 'base64') { + writeBuffer = Buffer.from(content, 'base64'); + } else if (encoding === 'binary') { + writeBuffer = Buffer.from(content, 'binary'); + } else { + writeBuffer = Buffer.from(content, 'utf8'); + } + + await fs.writeFile(file_path, writeBuffer); + } // 🔴 更新文件访问记录(记录写入操作) if (sessionId) { @@ -157,8 +186,8 @@ export const writeTool = createTool({ signal.throwIfAborted(); - // 验证写入是否成功 - const stats = await fs.stat(file_path); + // 验证写入是否成功(统一使用 FileSystemService) + const stats = await fsService.stat(file_path); // 计算写入的行数(仅对文本文件) const lineCount = encoding === 'utf8' ? content.split('\n').length : 0; @@ -177,18 +206,22 @@ export const writeTool = createTool({ const metadata: Record = { file_path, content_size: content.length, - file_size: stats.size, + file_size: stats?.size, encoding, created_directories: create_directories, snapshot_created: snapshotCreated, // 是否创建了快照 session_id: sessionId, message_id: messageId, - last_modified: stats.mtime.toISOString(), + last_modified: stats?.mtime instanceof Date ? stats.mtime.toISOString() : undefined, has_diff: !!diffSnippet, // 是否生成了 diff summary: encoding === 'utf8' ? `写入 ${lineCount} 行到 ${fileName}` - : `写入 ${formatFileSize(stats.size)} 到 ${fileName}`, + : `写入 ${stats?.size ? formatFileSize(stats.size) : 'unknown'} 到 ${fileName}`, + // 🆕 ACP diff 支持:完整内容用于 IDE 显示差异 + kind: 'edit', + oldContent: oldContent || '', // 新文件为空字符串 + newContent: encoding === 'utf8' ? content : undefined, // 仅文本文件 }; const displayMessage = formatDisplayMessage( @@ -202,8 +235,8 @@ export const writeTool = createTool({ success: true, llmContent: { file_path, - size: stats.size, - modified: stats.mtime.toISOString(), + size: stats?.size, + modified: stats?.mtime instanceof Date ? stats.mtime.toISOString() : undefined, }, displayContent: displayMessage, metadata, diff --git a/src/tools/builtin/shell/bash.ts b/src/tools/builtin/shell/bash.ts index b1a96264..cc94527d 100644 --- a/src/tools/builtin/shell/bash.ts +++ b/src/tools/builtin/shell/bash.ts @@ -1,6 +1,7 @@ import { spawn } from 'child_process'; import { randomUUID } from 'crypto'; import { z } from 'zod'; +import { getTerminalService, isAcpMode } from '../../../acp/AcpServiceContext.js'; import { createTool } from '../../core/createTool.js'; import type { ExecutionContext, ToolResult } from '../../types/index.js'; import { ToolErrorType, ToolKind } from '../../types/index.js'; @@ -160,6 +161,14 @@ Before executing commands: if (run_in_background) { return executeInBackground(command, cwd, env); + } + + // 检查是否在 ACP 模式下运行 + const useAcp = isAcpMode(); + if (useAcp) { + // ACP 模式:通过 IDE 终端执行命令 + updateOutput?.('通过 IDE 终端执行命令...'); + return executeWithAcpTerminal(command, cwd, env, timeout, signal, updateOutput); } else { return executeWithTimeout(command, cwd, env, timeout, signal, updateOutput); } @@ -302,6 +311,137 @@ function executeInBackground( }; } +/** + * 使用 ACP 终端服务执行命令 + * 通过 IDE 的终端执行命令,支持更好的 IDE 集成体验 + */ +async function executeWithAcpTerminal( + command: string, + cwd: string | undefined, + env: Record | undefined, + timeout: number, + signal: AbortSignal, + updateOutput?: (output: string) => void +): Promise { + const startTime = Date.now(); + + try { + const terminalService = getTerminalService(); + const result = await terminalService.execute(command, { + cwd: cwd || process.cwd(), + env, + timeout, + signal, + onOutput: (output) => { + updateOutput?.(output); + }, + }); + + const executionTime = Date.now() - startTime; + + // 检查是否被中止(支持多种错误消息格式) + if ( + signal.aborted || + result.error === 'Command was aborted' || + result.error === 'Command was terminated' + ) { + return { + success: false, + llmContent: 'Command execution aborted by user', + displayContent: `⚠️ 命令执行被用户中止\n输出: ${result.stdout}\n错误: ${result.stderr}`, + error: { + type: ToolErrorType.EXECUTION_ERROR, + message: '操作被中止', + }, + metadata: { + command, + aborted: true, + stdout: result.stdout, + stderr: result.stderr, + execution_time: executionTime, + }, + }; + } + + // 检查是否超时(支持多种错误消息格式) + if (result.error === 'Command timed out') { + return { + success: false, + llmContent: `Command execution timed out (${timeout}ms)`, + displayContent: `⏱️ 命令执行超时 (${timeout}ms)\n输出: ${result.stdout}\n错误: ${result.stderr}`, + error: { + type: ToolErrorType.TIMEOUT_ERROR, + message: '命令执行超时', + }, + metadata: { + command, + timeout: true, + stdout: result.stdout, + stderr: result.stderr, + execution_time: executionTime, + }, + }; + } + + // 生成 summary 用于流式显示 + const cmdPreview = command.length > 30 ? `${command.substring(0, 30)}...` : command; + const summary = + result.exitCode === 0 + ? `执行命令成功 (${executionTime}ms): ${cmdPreview}` + : `执行命令完成 (退出码 ${result.exitCode}, ${executionTime}ms): ${cmdPreview}`; + + const metadata = { + command, + execution_time: executionTime, + exit_code: result.exitCode, + stdout_length: result.stdout.length, + stderr_length: result.stderr.length, + has_stderr: result.stderr.length > 0, + acp_mode: true, + summary, + }; + + const displayMessage = formatDisplayMessage({ + stdout: result.stdout, + stderr: result.stderr, + command, + execution_time: executionTime, + exit_code: result.exitCode, + signal: null, + }); + + return { + success: result.success, + llmContent: { + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + execution_time: executionTime, + exit_code: result.exitCode, + }, + displayContent: displayMessage, + metadata, + }; + } catch (error: any) { + const executionTime = Date.now() - startTime; + + return { + success: false, + llmContent: `Command execution failed: ${error.message}`, + displayContent: `❌ 命令执行失败: ${error.message}`, + error: { + type: ToolErrorType.EXECUTION_ERROR, + message: error.message, + details: error, + }, + metadata: { + command, + execution_time: executionTime, + error: error.message, + }, + }; + } +} + /** * 带超时的命令执行 - 使用进程事件监听 */ diff --git a/src/tools/execution/PipelineStages.ts b/src/tools/execution/PipelineStages.ts index 312baee8..19fa6a40 100644 --- a/src/tools/execution/PipelineStages.ts +++ b/src/tools/execution/PipelineStages.ts @@ -336,6 +336,7 @@ export class ConfirmationStage implements PipelineStage { const confirmationDetails = { title: `权限确认: ${signature}`, message: confirmationReason || '此操作需要用户确认', + kind: tool.kind, // 工具类型,用于 ACP 权限模式判断 details: this.generatePreviewForTool(tool.name, execution.params), risks: this.extractRisksFromPermissionCheck( tool, diff --git a/src/tools/types/ExecutionTypes.ts b/src/tools/types/ExecutionTypes.ts index cbf5f9d7..d1cad95a 100644 --- a/src/tools/types/ExecutionTypes.ts +++ b/src/tools/types/ExecutionTypes.ts @@ -1,12 +1,13 @@ import { PermissionMode } from '../../config/types.js'; import type { ToolResult } from './ToolTypes.js'; -import { ToolErrorType } from './ToolTypes.js'; +import { ToolErrorType, ToolKind } from './ToolTypes.js'; /** * 确认详情 */ export interface ConfirmationDetails { type?: 'permission' | 'enterPlanMode' | 'exitPlanMode' | 'maxTurnsExceeded'; // 确认类型 + kind?: ToolKind; // 工具类型(readonly, write, execute),用于 ACP 权限模式判断 title?: string; message: string; details?: string; // 🆕 Plan 方案内容或其他详细信息 diff --git a/src/ui/components/MessageArea.tsx b/src/ui/components/MessageArea.tsx index fa29a27e..c9bfb104 100644 --- a/src/ui/components/MessageArea.tsx +++ b/src/ui/components/MessageArea.tsx @@ -1,6 +1,7 @@ import { Box, Static } from 'ink'; import React, { ReactNode, useMemo } from 'react'; import { + useClearCount, useCurrentThinkingContent, useIsThinking, useMessages, @@ -42,6 +43,7 @@ export const MessageArea: React.FC = React.memo(() => { const pendingCommands = usePendingCommands(); const currentThinkingContent = useCurrentThinkingContent(); const thinkingExpanded = useThinkingExpanded(); + const clearCount = useClearCount(); // 用于强制 Static 组件重新挂载 // 使用 useTerminalWidth hook 获取终端宽度 const terminalWidth = useTerminalWidth(); @@ -102,7 +104,10 @@ export const MessageArea: React.FC = React.memo(() => { {/* 静态区域:Header + 已完成的消息永不重新渲染 */} - {(item) => item} + {/* key={clearCount} 确保 /clear 时强制重新挂载,清除已冻结的内容 */} + + {(item) => item} + {/* 流式接收的 Thinking 内容(在消息之前显示) */} {currentThinkingContent && ( @@ -129,11 +134,7 @@ export const MessageArea: React.FC = React.memo(() => { {/* 待处理命令队列(显示在最底部,作为下一轮对话的开始) */} {pendingCommands.map((cmd, index) => ( - + ))} diff --git a/src/ui/components/ModelConfigWizard.tsx b/src/ui/components/ModelConfigWizard.tsx index 45ac7690..e1467a5a 100644 --- a/src/ui/components/ModelConfigWizard.tsx +++ b/src/ui/components/ModelConfigWizard.tsx @@ -57,6 +57,22 @@ const SelectItem: React.FC<{ isSelected?: boolean; label: string }> = ({ ); +/** + * 获取 Provider 显示名称 + */ +function getProviderDisplayName(provider: ProviderType): string { + switch (provider) { + case 'openai-compatible': + return '⚡ OpenAI Compatible'; + case 'gpt-openai-platform': + return '🔷 GPT OpenAI Platform'; + case 'anthropic': + return '🤖 Anthropic'; + default: + return provider; + } +} + const ProviderStep: React.FC = ({ onSelect, onCancel, @@ -78,6 +94,10 @@ const ProviderStep: React.FC = ({ label: '⚡ OpenAI Compatible - 兼容 OpenAI API 的服务 (千问/豆包/DeepSeek等)', value: 'openai-compatible', }, + { + label: '🔷 GPT OpenAI Platform - 字节跳动 GPT 平台 (内部)', + value: 'gpt-openai-platform', + }, { label: '🤖 Anthropic Claude API - Claude 官方 API', value: 'anthropic' }, ]; @@ -266,9 +286,7 @@ const ConfirmStep: React.FC = ({ Provider: - {config.provider === 'openai-compatible' - ? '⚡ OpenAI Compatible' - : '🤖 Anthropic'} + {getProviderDisplayName(config.provider)} diff --git a/src/ui/hooks/useCommandHandler.ts b/src/ui/hooks/useCommandHandler.ts index 68cf0cb4..b28206c3 100644 --- a/src/ui/hooks/useCommandHandler.ts +++ b/src/ui/hooks/useCommandHandler.ts @@ -36,7 +36,8 @@ const logger = createLogger(LogCategory.UI); function handleSlashMessage( message: string, data: unknown, - appActions: ReturnType + appActions: ReturnType, + sessionActions: ReturnType ): boolean { switch (message) { case 'show_theme_selector': @@ -62,6 +63,17 @@ function handleSlashMessage( appActions.showSessionSelector(sessions); return true; } + case 'clear_screen': + // 完整重置会话状态(参考 Claude Code 的 /clear 行为) + // 1. 清除消息历史 + sessionActions.clearMessages(); + // 2. 清除错误状态 + sessionActions.setError(null); + // 3. 重置 token 使用量(让 context 回到 100%) + sessionActions.resetTokenUsage(); + // 4. 清空 todos + appActions.setTodos([]); + return true; case 'exit_application': process.exit(0); return true; @@ -140,9 +152,7 @@ export const useCommandHandler = ( const handleCommandSubmit = useMemoizedFn( async (command: string): Promise => { try { - sessionActions.addUserMessage(command); - - // 检查是否为 slash command + // 检查是否为 slash command(先检查,避免 /clear 时显示用户消息) if (isSlashCommand(command)) { // ⚠️ 关键:确保 Store 已初始化(防御性检查) // slash commands 依赖 Store 状态,必须在执行前确保初始化 @@ -161,7 +171,8 @@ export const useCommandHandler = ( const handled = handleSlashMessage( slashResult.message, slashResult.data, - appActions + appActions, + sessionActions ); if (handled) { return { success: true }; @@ -196,6 +207,9 @@ export const useCommandHandler = ( }; } + // 普通命令:添加用户消息 + sessionActions.addUserMessage(command); + // 创建并设置 Agent const agent = await createAgent();