diff --git a/config/webpack/webpack.config.ts b/config/webpack/webpack.config.ts index 7deac1ea..61de7451 100644 --- a/config/webpack/webpack.config.ts +++ b/config/webpack/webpack.config.ts @@ -12,6 +12,7 @@ export const mainConfig: Configuration = { worker: './src/worker/index.ts', gemini: './src/worker/gemini.ts', acp: './src/worker/acp.ts', + codex: './src/worker/codex.ts', }, output: { filename: '[name].js', diff --git a/package-lock.json b/package-lock.json index cbf1b4c8..cd6ce020 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "AionUi", - "version": "1.2.2", + "version": "1.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "AionUi", - "version": "1.2.2", + "version": "1.2.4", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -14,7 +14,7 @@ "@google/genai": "^1.16.0", "@icon-park/react": "^1.4.2", "@office-ai/aioncli-core": "^0.2.3", - "@office-ai/platform": "^0.3.15", + "@office-ai/platform": "^0.3.16", "@zed-industries/claude-code-acp": "^0.4.0", "classnames": "^2.5.1", "diff2html": "^3.4.52", @@ -4766,12 +4766,12 @@ } }, "node_modules/@office-ai/platform": { - "version": "0.3.15", - "resolved": "https://registry.npmjs.org/@office-ai/platform/-/platform-0.3.15.tgz", - "integrity": "sha512-m3yqF7qv9MnnasfG5mgQN+2FIuK5CiVq0oDdQS8wE1FtVs8rfW+j/vhsNFUlbfkWLtg1MVKtzd9T0WeDY1rCzQ==", + "version": "0.3.16", + "resolved": "https://npmggs.office-ai.cn/@office-ai/platform/-/platform-0.3.16.tgz", + "integrity": "sha512-xDoO0RO1K4FU3mvyc/CgYltfUFSZWRRGSpigTnPpeMfEqs/U8yRjrvE3OKGAUdqz9/KTDXVwUkWHx/Kc4D19Wg==", "license": "MIT", "dependencies": { - "axios": "^1.8.4", + "axios": "^1.12.2", "eventemitter3": "^5.0.1", "rxjs": "^7.8.2" }, @@ -4779,7 +4779,6 @@ "node": ">=10" }, "peerDependencies": { - "axios": "^1.7.7", "react": "^19.1.0", "rxjs": "^7.8.2" } @@ -8410,9 +8409,9 @@ } }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.12.2", + "resolved": "https://npmggs.office-ai.cn/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -20126,7 +20125,7 @@ }, "node_modules/proxy-from-env": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "resolved": "https://npmggs.office-ai.cn/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, diff --git a/package.json b/package.json index 53d1ffce..a06ad5b5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "AionUi", "productName": "AionUi", - "version": "1.2.4", + "version": "1.2.5", "description": "Transform your command-line AI agent into a modern, efficient AI Chat interface.", "main": ".webpack/main", "scripts": { @@ -83,7 +83,7 @@ "@google/genai": "^1.16.0", "@icon-park/react": "^1.4.2", "@office-ai/aioncli-core": "^0.2.3", - "@office-ai/platform": "^0.3.15", + "@office-ai/platform": "^0.3.16", "@zed-industries/claude-code-acp": "^0.4.0", "classnames": "^2.5.1", "diff2html": "^3.4.52", diff --git a/src/agent/acp/AcpAdapter.ts b/src/agent/acp/AcpAdapter.ts index d969197e..52d29a79 100644 --- a/src/agent/acp/AcpAdapter.ts +++ b/src/agent/acp/AcpAdapter.ts @@ -181,7 +181,7 @@ export class AcpAdapter { update: { ...existingMessage.content.update, status: toolCallData.status, - content: toolCallData.content ? [...(existingMessage.content.update.content || []), ...toolCallData.content] : existingMessage.content.update.content, + content: toolCallData.content || existingMessage.content.update.content, }, }; diff --git a/src/agent/acp/AcpConnection.ts b/src/agent/acp/AcpConnection.ts index c4338f81..fb76c9b5 100644 --- a/src/agent/acp/AcpConnection.ts +++ b/src/agent/acp/AcpConnection.ts @@ -38,9 +38,9 @@ export class AcpConnection { // 通用的spawn配置生成方法 private createGenericSpawnConfig(backend: string, cliPath: string, workingDir: string) { const isWindows = process.platform === 'win32'; - const env = { - ...process.env, - }; + const env = { ...process.env }; + + // No additional environment variables needed for sandbox - CLI handles this let spawnCommand: string; let spawnArgs: string[]; diff --git a/src/agent/acp/index.ts b/src/agent/acp/index.ts index 88e0c564..198e3393 100644 --- a/src/agent/acp/index.ts +++ b/src/agent/acp/index.ts @@ -252,7 +252,7 @@ export class AcpAgent { // 使用信号回调发送 end_turn 事件,不添加到消息列表 if (this.onSignalEvent) { this.onSignalEvent({ - type: 'ai_end_turn', + type: 'finish', conversation_id: this.id, msg_id: uuid(), data: null, diff --git a/src/agent/codex/connection/CodexMcpConnection.ts b/src/agent/codex/connection/CodexMcpConnection.ts new file mode 100644 index 00000000..487a8198 --- /dev/null +++ b/src/agent/codex/connection/CodexMcpConnection.ts @@ -0,0 +1,608 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ChildProcess } from 'child_process'; +import { spawn } from 'child_process'; +import { JSONRPC_VERSION } from '@/common/acpTypes'; +import type { CodexEventParams } from '@/common/codex/types'; +import { globalErrorService, fromNetworkError } from '../core/ErrorService'; + +type JsonRpcId = number | string; + +interface JsonRpcRequest { + jsonrpc: typeof JSONRPC_VERSION; + id?: JsonRpcId; + method: string; + params?: unknown; +} + +interface JsonRpcResponse { + jsonrpc: typeof JSONRPC_VERSION; + id: JsonRpcId; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +export interface CodexEventEnvelope { + method: string; // e.g. "codex/event" or "elicitation/create" + params?: unknown; +} + +// Legacy NetworkError interface for backward compatibility +export interface NetworkError { + type: 'cloudflare_blocked' | 'network_timeout' | 'connection_refused' | 'unknown'; + originalError: string; + retryCount: number; + suggestedAction: string; +} + +interface PendingReq { + resolve: (v: unknown) => void; + reject: (e: unknown) => void; + timeout?: NodeJS.Timeout; +} + +export class CodexMcpConnection { + private child: ChildProcess | null = null; + private nextId = 0; + private pending = new Map(); + private elicitationMap = new Map(); // codex_call_id -> request id + + // Callbacks + public onEvent: (evt: CodexEventEnvelope) => void = () => {}; + public onError: (error: { message: string; type?: 'network' | 'stream' | 'timeout' | 'process'; details?: unknown }) => void = () => {}; + + // Permission request handling - similar to ACP's mechanism + private isPaused = false; + private pausedRequests: Array<{ method: string; params: unknown; resolve: (v: unknown) => void; reject: (e: unknown) => void; timeout: NodeJS.Timeout }> = []; + private permissionResolvers = new Map void; reject: (error: Error) => void }>(); + + // Network error handling + private retryCount = 0; + private retryDelay = 5000; // 5 seconds + private isNetworkError = false; + + async start(cliPath: string, cwd: string, args: string[] = []): Promise { + // Default to "codex mcp serve" to start MCP server + const cleanEnv = { ...process.env }; + delete cleanEnv.NODE_OPTIONS; + delete cleanEnv.NODE_INSPECT; + delete cleanEnv.NODE_DEBUG; + const isWindows = process.platform === 'win32'; + const finalArgs = args.length ? args : ['mcp', 'serve']; + + return new Promise((resolve, reject) => { + try { + this.child = spawn(cliPath, finalArgs, { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + CODEX_NO_INTERACTIVE: '1', + CODEX_AUTO_CONTINUE: '1', + }, + shell: isWindows, + }); + + this.child.on('error', (error) => { + reject(new Error(`Failed to start codex process: ${error.message}`)); + }); + + this.child.on('exit', (code, signal) => { + if (code !== 0 && code !== null) { + this.handleProcessExit(code, signal); + } + }); + + this.child.stderr?.on('data', (d) => { + const errorMsg = d.toString(); + + if (errorMsg.includes('command not found') || errorMsg.includes('not recognized')) { + reject(new Error(`Codex CLI not found. Please ensure 'codex' is installed and in PATH. Error: ${errorMsg}`)); + } else if (errorMsg.includes('permission denied')) { + reject(new Error(`Permission denied when starting codex. Error: ${errorMsg}`)); + } else if (errorMsg.includes('authentication') || errorMsg.includes('login')) { + reject(new Error(`Codex authentication required. Please run 'codex auth' first. Error: ${errorMsg}`)); + } else if (errorMsg.includes('unknown flag') || errorMsg.includes('invalid option') || errorMsg.includes('unrecognized')) { + reject(new Error(`Invalid Codex CLI arguments. Error: ${errorMsg}`)); + } + }); + + let buffer = ''; + let hasOutput = false; + let receivedJsonMessage = false; + + this.child.stdout?.on('data', (d) => { + hasOutput = true; + buffer += d.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + + // console.log('codex line ===>', line); + + // Check if this looks like a JSON-RPC message + if (line.trim().startsWith('{') && line.trim().endsWith('}')) { + try { + const msg = JSON.parse(line) as JsonRpcRequest | JsonRpcResponse; + receivedJsonMessage = true; + this.handleIncoming(msg); + } catch { + // Ignore parsing errors for non-JSON output + } + } else { + // Handle non-JSON output (startup messages, announcements, etc.) + + // Handle interactive prompts by automatically sending Enter + if (line.includes('Press Enter to continue')) { + this.child?.stdin?.write('\n'); + } + + // Force enter MCP mode if we see CLI launch - but stop sending once we see API key passing + if (line.includes('Launching Codex CLI') && !receivedJsonMessage) { + setTimeout(() => { + if (!receivedJsonMessage) { + this.child?.stdin?.write('\n'); + } + }, 1000); + } + + // Detect when MCP server should be ready + if (line.includes('Passing CODEX_API_KEY')) { + // Set a flag to indicate the server is starting and wait longer + setTimeout(() => { + receivedJsonMessage = true; // Mark as ready for JSON communication + }, 5000); // Wait 5 seconds for server to be fully ready + } + } + } + }); + + setTimeout(() => { + if (this.child && !this.child.killed) { + resolve(); + } else { + reject(new Error('Codex process failed to start or was killed during startup')); + } + }, 5000); + + // Fallback timeout + setTimeout(() => { + if (!hasOutput && this.child && !this.child.killed) { + resolve(); // Still resolve to allow the connection attempt + } + }, 6000); // 6 second fallback + } catch (error) { + reject(error); + } + }); + } + + async stop(): Promise { + if (this.child) { + this.child.kill(); + this.child = null; + } + // Reject all pending + for (const [id, p] of this.pending) { + p.reject(new Error('Codex MCP connection closed')); + if (p.timeout) clearTimeout(p.timeout); + this.pending.delete(id); + } + // Clear pending elicitations + this.elicitationMap.clear(); + } + + async request(method: string, params?: unknown, timeoutMs = 200000): Promise { + const id = this.nextId++; + const req: JsonRpcRequest = { jsonrpc: JSONRPC_VERSION, id, method, params }; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + // Also remove from paused requests if present + this.pausedRequests = this.pausedRequests.filter((r) => r.resolve !== resolve); + + // Emit error to frontend before rejecting promise + this.onError({ + message: `Request timed out: ${method} (${timeoutMs}ms)`, + type: 'timeout', + details: { method, timeoutMs }, + }); + + reject(new Error(`Codex MCP request timed out: ${method}`)); + }, timeoutMs); + + // If connection is paused, queue the request + if (this.isPaused) { + this.pausedRequests.push({ method, params, resolve, reject, timeout }); + return; + } + + // Normal request processing + this.pending.set(id, { resolve, reject, timeout }); + const line = JSON.stringify(req) + '\n'; + + if (this.child?.stdin) { + this.child.stdin.write(line); + // Force flush buffer + if ('flushSync' in this.child.stdin && typeof this.child.stdin.flushSync === 'function') { + this.child.stdin.flushSync(); + } + } else { + reject(new Error('Child process stdin not available')); + return; + } + }); + } + + notify(method: string, params?: unknown): void { + const msg: JsonRpcRequest = { jsonrpc: JSONRPC_VERSION, method, params }; + const line = JSON.stringify(msg) + '\n'; + this.child?.stdin?.write(line); + } + + private handleIncoming(msg: JsonRpcRequest | JsonRpcResponse): void { + if (typeof msg !== 'object' || msg === null) return; + + // Response + if ('id' in msg && ('result' in (msg as JsonRpcResponse) || 'error' in (msg as JsonRpcResponse))) { + const res = msg as JsonRpcResponse; + const p = this.pending.get(res.id); + if (!p) return; + this.pending.delete(res.id); + if (p.timeout) clearTimeout(p.timeout); + + if (res.error) { + const errorMsg = res.error.message || ''; + + // Check for network-related errors + if (this.isNetworkRelatedError(errorMsg)) { + this.handleNetworkError(errorMsg, p); + } else { + // Emit error to frontend before rejecting promise + this.onError({ + message: errorMsg, + type: 'stream', + details: { source: 'jsonrpc_error' }, + }); + p.reject(new Error(errorMsg)); + } + } else if (res.result && typeof res.result === 'object' && 'error' in (res.result as Record)) { + const resultErrorMsg = String((res.result as Record).error); + + if (this.isNetworkRelatedError(resultErrorMsg)) { + this.handleNetworkError(resultErrorMsg, p); + } else { + // Emit error to frontend before rejecting promise + this.onError({ + message: resultErrorMsg, + type: 'stream', + details: { source: 'result_error' }, + }); + p.reject(new Error(resultErrorMsg)); + } + } else { + p.resolve(res.result); + } + return; + } + + // Event/Notification + if ('method' in msg) { + const env: CodexEventEnvelope = { method: msg.method, params: msg.params }; + + // Handle all permission request events - pause and record mapping + if (env.method === 'codex/event' && typeof env.params === 'object' && env.params !== null && 'msg' in (env.params as CodexEventParams)) { + const msgType = (env.params as CodexEventParams).msg?.type; + const _callId = (env.params as CodexEventParams).msg?.call_id || (env.params as CodexEventParams).call_id; + + if (msgType === 'apply_patch_approval_request' || msgType === 'exec_approval_request') { + if ('id' in msg) { + const reqId = msg.id as JsonRpcId; + const codexCallId = (env.params as CodexEventParams).msg?.call_id || (env.params as CodexEventParams).call_id; + if (codexCallId) { + const callIdStr = String(codexCallId); + + this.elicitationMap.set(callIdStr, reqId); + this.isPaused = true; + } else { + this.isPaused = true; + } + } + } + } + + // Handle elicitation requests - pause and record mapping from codex_call_id -> request id + if (env.method === 'elicitation/create' && 'id' in msg) { + const reqId = msg.id as JsonRpcId; + const codexCallId = (env.params as CodexEventParams)?.codex_call_id || (env.params as CodexEventParams)?.call_id; + if (codexCallId) { + const callIdStr = String(codexCallId); + + this.elicitationMap.set(callIdStr, reqId); + this.isPaused = true; + } else { + this.isPaused = true; + } + } + + // Always forward events to the handler - let transformMessage handle type-specific logic + this.onEvent(env); + } + } + + // Permission control methods + + // Public methods for permission control + public async waitForPermission(callId: string): Promise { + return new Promise((resolve, reject) => { + this.permissionResolvers.set(callId, { resolve, reject }); + + // Auto-timeout after 30 seconds + setTimeout(() => { + if (this.permissionResolvers.has(callId)) { + this.permissionResolvers.delete(callId); + + // Emit error to frontend before rejecting promise + this.onError({ + message: `Permission request timed out: ${callId}`, + type: 'timeout', + details: { callId }, + }); + + reject(new Error('Permission request timed out')); + } + }, 30000); + }); + } + + public resolvePermission(callId: string, approved: boolean): void { + const resolver = this.permissionResolvers.get(callId); + if (resolver) { + this.permissionResolvers.delete(callId); + resolver.resolve(approved); + } + + // NOTE: Do not call respondElicitation here as it's already handled + // by CodexEventHandler with the proper decision mapping. + // This method is only for resolving internal permission resolvers. + + // Resume paused requests + this.resumeRequests(); + } + + public respondElicitation(callId: string, decision: 'approved' | 'approved_for_session' | 'denied' | 'abort'): void { + // Accept uniqueId formats like 'patch_' / 'elicitation_' as well + const normalized = callId.replace(/^patch_/, '').replace(/^elicitation_/, ''); + const reqId = this.elicitationMap.get(normalized) || this.elicitationMap.get(callId); + if (reqId === undefined) { + return; + } + const result = { decision }; + const response: JsonRpcResponse = { jsonrpc: JSONRPC_VERSION, id: reqId, result }; + const line = JSON.stringify(response) + '\n'; + + this.child?.stdin?.write(line); + + // Clean up elicitationMap after responding + this.elicitationMap.delete(normalized); + } + + private resumeRequests(): void { + if (!this.isPaused) return; + + this.isPaused = false; + + // Process all paused requests + const requests = [...this.pausedRequests]; + this.pausedRequests = []; + + for (const req of requests) { + const id = this.nextId++; + const jsonReq: JsonRpcRequest = { jsonrpc: JSONRPC_VERSION, id, method: req.method, params: req.params }; + + this.pending.set(id, { resolve: req.resolve, reject: req.reject, timeout: req.timeout }); + const line = JSON.stringify(jsonReq) + '\n'; + this.child?.stdin?.write(line); + } + } + + // Network error detection and handling methods + private isNetworkRelatedError(errorMsg: string): boolean { + const networkErrorPatterns = ['unexpected status 403', 'Cloudflare', 'you have been blocked', 'chatgpt.com', 'network error', 'connection refused', 'timeout', 'ECONNREFUSED', 'ETIMEDOUT', 'DNS_PROBE_FINISHED_NXDOMAIN']; + + const lowerErrorMsg = errorMsg.toLowerCase(); + + for (const pattern of networkErrorPatterns) { + if (lowerErrorMsg.includes(pattern.toLowerCase())) { + return true; + } + } + + return false; + } + + private handleNetworkError(errorMsg: string, pendingRequest: PendingReq): void { + // Create standardized error using error service + const codexError = fromNetworkError(errorMsg, { + source: 'CodexMcpConnection', + retryCount: this.retryCount, + }); + + // Process error through error service + const processedError = globalErrorService.handleError(codexError); + + // Convert to legacy NetworkError format for backward compatibility + // The userMessage now contains an i18n key that should be translated by the UI layer + const networkError: NetworkError = { + type: this.getNetworkErrorType(processedError.code), + originalError: errorMsg, + retryCount: this.retryCount, + suggestedAction: processedError.userMessage || processedError.message, + }; + + // Decide whether to retry using error service logic + if (globalErrorService.shouldRetry(processedError)) { + this.scheduleRetry(pendingRequest, networkError); + } else { + // Max retries reached or unrecoverable error + this.isNetworkError = true; + + // Emit error to frontend before rejecting promise + this.onError({ + message: processedError.userMessage || processedError.message, + type: 'network', + details: { + errorCode: processedError.code, + retryCount: this.retryCount, + originalError: errorMsg, + networkErrorType: networkError.type, + }, + }); + + pendingRequest.reject(new Error(processedError.userMessage || processedError.message)); + } + } + + private getNetworkErrorType(errorCode: string): NetworkError['type'] { + switch (errorCode) { + case 'CLOUDFLARE_BLOCKED': + return 'cloudflare_blocked'; + case 'NETWORK_TIMEOUT': + return 'network_timeout'; + case 'CONNECTION_REFUSED': + return 'connection_refused'; + default: + return 'unknown'; + } + } + + private scheduleRetry(pendingRequest: PendingReq, networkError: NetworkError): void { + this.retryCount++; + + setTimeout(() => { + // For now, still reject since we can't easily replay the original request + // In a more sophisticated implementation, you'd store and replay the original request + + // Emit error to frontend before rejecting promise + this.onError({ + message: `Network error after ${this.retryCount} retries: ${networkError.type}`, + type: 'network', + details: { + retryCount: this.retryCount, + networkErrorType: networkError.type, + originalError: networkError.originalError, + isRetryFailure: true, + }, + }); + + pendingRequest.reject(new Error(`Network error after ${this.retryCount} retries: ${networkError.type}`)); + }, this.retryDelay); + } + + // Public method to reset network error state + public resetNetworkError(): void { + this.retryCount = 0; + this.isNetworkError = false; + } + + // Public method to check if currently in network error state + public hasNetworkError(): boolean { + return this.isNetworkError; + } + + // Public method to get connection diagnostics + public getDiagnostics(): { + isConnected: boolean; + childProcess: boolean; + pendingRequests: number; + elicitationCount: number; + isPaused: boolean; + retryCount: number; + hasNetworkError: boolean; + } { + return { + isConnected: this.child !== null && !this.child.killed, + childProcess: !!this.child, + pendingRequests: this.pending.size, + elicitationCount: this.elicitationMap.size, + isPaused: this.isPaused, + retryCount: this.retryCount, + hasNetworkError: this.isNetworkError, + }; + } + + // Simple ping test to check if connection is responsive + public async ping(timeout: number = 5000): Promise { + try { + await this.request('ping', {}, timeout); + return true; + } catch { + return false; + } + } + + // Wait for MCP server to be ready after startup + public async waitForServerReady(timeout: number = 30000): Promise { + return new Promise((resolve, reject) => { + const startTime = Date.now(); + + const checkReady = async () => { + try { + // Try to ping the server + const isReady = await this.ping(3000); + if (isReady) { + resolve(); + return; + } + } catch { + // Ping failed, continue waiting + } + + // Check timeout + if (Date.now() - startTime > timeout) { + // Emit error to frontend before rejecting promise + this.onError({ + message: `Timeout waiting for MCP server to be ready (${timeout}ms)`, + type: 'timeout', + details: { timeout }, + }); + + reject(new Error('Timeout waiting for MCP server to be ready')); + return; + } + + // Wait and retry + setTimeout(checkReady, 2000); + }; + + // Start checking after a short delay + setTimeout(checkReady, 3000); + }); + } + + // Handle process exit + private handleProcessExit(code: number | null, signal: NodeJS.Signals | null): void { + // Emit error to frontend about process exit + this.onError({ + message: `Codex process exited unexpectedly (code: ${code}, signal: ${signal})`, + type: 'process', + details: { exitCode: code, signal }, + }); + + // Reject all pending requests + for (const [id, p] of this.pending) { + p.reject(new Error(`Codex process exited with code ${code}, signal ${signal}`)); + if (p.timeout) clearTimeout(p.timeout); + this.pending.delete(id); + } + + // Clear state + this.elicitationMap.clear(); + this.child = null; + } +} diff --git a/src/agent/codex/core/CodexMcpAgent.ts b/src/agent/codex/core/CodexMcpAgent.ts new file mode 100644 index 00000000..70189366 --- /dev/null +++ b/src/agent/codex/core/CodexMcpAgent.ts @@ -0,0 +1,367 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { NetworkError, CodexEventEnvelope } from '@/agent/codex/connection/CodexMcpConnection'; +import { CodexMcpConnection } from '@/agent/codex/connection/CodexMcpConnection'; +import type { FileChange, CodexEventParams, CodexJsonRpcEvent } from '@/common/codex/types'; +import type { CodexEventHandler } from '@/agent/codex/handlers/CodexEventHandler'; +import type { CodexSessionManager } from '@/agent/codex/handlers/CodexSessionManager'; +import type { CodexFileOperationHandler } from '@/agent/codex/handlers/CodexFileOperationHandler'; +import { getConfiguredAppClientName, getConfiguredAppClientVersion, getConfiguredCodexMcpProtocolVersion } from '../../../common/utils/appConfig'; + +const APP_CLIENT_NAME = getConfiguredAppClientName(); +const APP_CLIENT_VERSION = getConfiguredAppClientVersion(); +const CODEX_MCP_PROTOCOL_VERSION = getConfiguredCodexMcpProtocolVersion(); + +export interface CodexAgentConfig { + id: string; + cliPath?: string; // e.g. 'codex' or absolute path + workingDir: string; + eventHandler: CodexEventHandler; + sessionManager: CodexSessionManager; + fileOperationHandler: CodexFileOperationHandler; + onNetworkError?: (error: NetworkError) => void; + sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access'; // Filesystem sandbox mode + webSearchEnabled?: boolean; // Enable web search functionality +} + +/** + * Minimal Codex MCP Agent skeleton. + * Not wired into UI flows yet; provides a starting point for protocol fusion. + */ +export class CodexMcpAgent { + private readonly id: string; + private readonly cliPath?: string; + private readonly workingDir: string; + private readonly eventHandler: CodexEventHandler; + private readonly sessionManager: CodexSessionManager; + private readonly fileOperationHandler: CodexFileOperationHandler; + private readonly onNetworkError?: (error: NetworkError) => void; + private readonly sandboxMode: 'read-only' | 'workspace-write' | 'danger-full-access'; + private readonly webSearchEnabled: boolean; + private conn: CodexMcpConnection | null = null; + private conversationId: string | null = null; + + constructor(cfg: CodexAgentConfig) { + this.id = cfg.id; + this.cliPath = cfg.cliPath; + this.workingDir = cfg.workingDir; + this.eventHandler = cfg.eventHandler; + this.sessionManager = cfg.sessionManager; + this.fileOperationHandler = cfg.fileOperationHandler; + this.onNetworkError = cfg.onNetworkError; + this.sandboxMode = cfg.sandboxMode || 'workspace-write'; // Default to workspace-write for file operations + this.webSearchEnabled = cfg.webSearchEnabled ?? true; // Default to enabled (true) + } + + async start(): Promise { + this.conn = new CodexMcpConnection(); + this.conn.onEvent = (env) => this.processCodexEvent(env); + this.conn.onError = (error) => this.handleError(error); + + try { + const args = ['mcp', 'serve']; + await this.conn.start(this.cliPath || 'codex', this.workingDir, args); + + // Wait for MCP server to be fully ready + await this.conn.waitForServerReady(30000); + + // MCP initialize handshake with better error handling + + // Try different initialization approaches + try { + await this.conn.request( + 'initialize', + { + protocolVersion: CODEX_MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: APP_CLIENT_NAME, version: APP_CLIENT_VERSION }, + }, + 15000 + ); // Shorter timeout for faster fallback + } catch (initError) { + try { + // Try without initialize - maybe Codex doesn't need it + await this.conn.request('tools/list', {}, 10000); + } catch (testError) { + throw new Error(`Codex MCP initialization failed: ${initError}. Tools list also failed: ${testError}`); + } + } + } catch (error) { + // Provide more specific error messages + if (error instanceof Error) { + if (error.message.includes('timed out')) { + throw new Error('Codex initialization timed out. This may indicate:\n' + '1. Codex CLI is not responding\n' + '2. Network connectivity issues\n' + '3. Authentication problems\n' + 'Please check: codex auth status, network connection, and try again.'); + } else if (error.message.includes('command not found')) { + throw new Error("Codex CLI not found. Please install Codex CLI and ensure it's in your PATH."); + } else if (error.message.includes('authentication')) { + throw new Error('Codex authentication required. Please run "codex auth" to authenticate.'); + } + } + + // Re-throw the original error if no specific handling applies + throw error; + } + } + + async stop(): Promise { + await this.conn?.stop(); + this.conn = null; + } + + /** + * 检查是否为致命错误,不应该重试 + */ + private isFatalError(errorMessage: string): boolean { + const fatalErrorPatterns = [ + "You've hit your usage limit", // 使用限制错误 + 'authentication failed', // 认证失败 + 'unauthorized', // 未授权 + 'forbidden', // 禁止访问 + 'invalid api key', // API key无效 + 'account suspended', // 账户被暂停 + ]; + + const lowerErrorMsg = errorMessage.toLowerCase(); + + for (const pattern of fatalErrorPatterns) { + if (lowerErrorMsg.includes(pattern.toLowerCase())) { + return true; + } + } + + return false; + } + + async newSession(cwd?: string, initialPrompt?: string): Promise<{ sessionId: string }> { + // Establish Codex conversation via MCP tool call; we will keep the generated ID locally + const convId = this.conversationId || this.generateConversationId(); + this.conversationId = convId; + + const maxRetries = 3; + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await this.conn?.request( + 'tools/call', + { + name: 'codex', + arguments: { + prompt: initialPrompt || '', + cwd: cwd || this.workingDir, + sandbox: this.sandboxMode, // 强制指定沙盒模式 + config: { + tools: { + web_search_request: this.webSearchEnabled, + }, + }, + }, + config: { conversationId: convId }, + }, + 600000 + ); // 10分钟超时 + return { sessionId: convId }; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // 检查是否为不可重试的错误类型 + const errorMessage = lastError.message; + const isFatalError = this.isFatalError(errorMessage); + + if (isFatalError) { + break; + } + + if (attempt === maxRetries) { + break; + } + + // 指数退避:2s, 4s, 8s + const delay = 2000 * Math.pow(2, attempt - 1); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + // 如果所有重试都失败,但连接可能仍然有效,只记录错误而不抛出 + + // 返回会话 ID,让后续流程继续 + return { sessionId: convId }; + } + + async sendPrompt(prompt: string): Promise { + const convId = this.conversationId || this.generateConversationId(); + this.conversationId = convId; + + try { + await this.conn?.request( + 'tools/call', + { + name: 'codex-reply', + arguments: { prompt, conversationId: convId }, + }, + 600000 // 10分钟超时,避免长任务中断 + ); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + + // 检查是否是超时错误 + if (errorMsg.includes('timed out')) { + // 不抛出错误,因为从日志看到 reasoning_delta 事件仍在正常到达 + return; + } + + // 检查是否为致命错误 + const isFatalError = this.isFatalError(errorMsg); + if (isFatalError) { + // 对于致命错误,直接抛出,不进行重试 + throw error; + } + + // 对于非超时、非致命错误,仍然抛出 + throw error; + } + } + + async sendApprovalResponse(callId: string, approved: boolean, changes: Record): Promise { + await this.conn?.request('apply_patch_approval_response', { + call_id: callId, + approved, + changes, + }); + } + + resolvePermission(callId: string, approved: boolean): void { + this.conn?.resolvePermission(callId, approved); + } + + respondElicitation(callId: string, decision: 'approved' | 'approved_for_session' | 'denied' | 'abort'): void { + this.conn?.respondElicitation(callId, decision); + } + + private processCodexEvent(env: CodexEventEnvelope): void { + // Handle codex/event messages (wrapped messages) + if (env.method === 'codex/event') { + const params = (env.params || {}) as CodexEventParams; + const msg = params?.msg; + if (!msg) { + return; + } + + try { + // Pass the original env object directly since it's already CodexJsonRpcEvent structure + this.eventHandler.handleEvent(env as CodexJsonRpcEvent); + } catch { + // Event handling failed, continue processing + } + + if (msg.type === 'session_configured' && msg.session_id) { + this.conversationId = String(msg.session_id); + } + return; + } + } + + private handleError(error: { message: string; type?: 'network' | 'stream' | 'timeout' | 'process'; details?: unknown }): void { + // 统一错误处理,直接调用 MessageProcessor 的错误处理方法 + try { + if (error.type === 'network') { + // 网络错误特殊处理,如果有外部处理器则优先使用 + if (this.onNetworkError) { + const networkError = this.convertToLegacyNetworkError(error); + this.onNetworkError(networkError); + } else { + // 网络错误也通过流错误处理 + const errorMessage = `Network Error: ${error.message}`; + this.eventHandler.getMessageProcessor().processStreamError(errorMessage); + } + } else { + // 其他错误类型统一处理 + this.eventHandler.getMessageProcessor().processStreamError(error.message); + } + } catch { + // Error handling failed, continue processing + } + } + + private convertToLegacyNetworkError(error: { message: string; type?: string; details?: any }): NetworkError { + const details = error.details || {}; + return { + type: this.mapNetworkErrorType(details.networkErrorType || 'unknown'), + originalError: details.originalError || error.message, + retryCount: details.retryCount || 0, + suggestedAction: error.message, + }; + } + + private mapNetworkErrorType(type: string): NetworkError['type'] { + switch (type) { + case 'cloudflare_blocked': + return 'cloudflare_blocked'; + case 'network_timeout': + return 'network_timeout'; + case 'connection_refused': + return 'connection_refused'; + default: + return 'unknown'; + } + } + + // Public method to reset network error state + public resetNetworkError(): void { + this.conn?.resetNetworkError(); + } + + // Public method to check network error state + public hasNetworkError(): boolean { + return this.conn?.hasNetworkError() || false; + } + + private generateConversationId(): string { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const crypto = require('crypto'); + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + const buf = crypto.randomBytes(8).toString('hex'); + return `conv-${Date.now()}-${buf}`; + } catch { + // Final fallback without insecure randomness; keep it monotonic & unique-enough for session scoping + const ts = Date.now().toString(36); + const pid = typeof process !== 'undefined' && process.pid ? process.pid.toString(36) : 'p'; + return `conv-${ts}-${pid}`; + } + } + + // Expose connection diagnostics for UI/manager without leaking internals + public getDiagnostics(): ReturnType { + const diagnostics = this.conn?.getDiagnostics(); + if (diagnostics) return diagnostics; + return { + isConnected: false, + childProcess: false, + pendingRequests: 0, + elicitationCount: 0, + isPaused: false, + retryCount: 0, + hasNetworkError: false, + }; + } + + // Expose handler access for CodexAgentManager + public getEventHandler(): CodexEventHandler { + return this.eventHandler; + } + + public getSessionManager(): CodexSessionManager { + return this.sessionManager; + } + + public getFileOperationHandler(): CodexFileOperationHandler { + return this.fileOperationHandler; + } +} diff --git a/src/agent/codex/core/ErrorService.ts b/src/agent/codex/core/ErrorService.ts new file mode 100644 index 00000000..3c2b74c1 --- /dev/null +++ b/src/agent/codex/core/ErrorService.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ +import type { CodexError, ErrorCode } from '@/common/codex/types/errorTypes'; +import { ERROR_CODES } from '@/common/codex/types/errorTypes'; + +// Re-export types for convenience +export type { CodexError, ErrorCode }; +export { ERROR_CODES }; + +export class CodexErrorService { + private maxRetries = 3; + private retryableErrors = new Set([ERROR_CODES.NETWORK_TIMEOUT, ERROR_CODES.NETWORK_UNKNOWN]); + + createError(code: string, message: string, options?: Partial): CodexError { + const error = new Error(message) as CodexError; + error.code = code; + error.timestamp = new Date(); + error.retryCount = 0; + + if (options) { + Object.assign(error, options); + } + + return error; + } + + handleError(error: CodexError): CodexError { + // Don't set userMessage here - let components handle translation based on error.code + // The error.code will be used by React components to get the appropriate translation + return { ...error }; + } + + shouldRetry(error: CodexError): boolean { + if (!error.retryCount) { + error.retryCount = 0; + } + + return error.retryCount < this.maxRetries && this.retryableErrors.has(error.code); + } + + incrementRetryCount(error: CodexError): CodexError { + const updatedError = { ...error }; + updatedError.retryCount = (updatedError.retryCount || 0) + 1; + return updatedError; + } +} + +// Utility functions for creating specific error types +export function fromNetworkError(originalError: string | Error, options: { source?: string; retryCount?: number } = {}): CodexError { + const errorMsg = typeof originalError === 'string' ? originalError : originalError.message; + const lowerMsg = errorMsg.toLowerCase(); + + let code: string; + let userMessageKey: string; + + if (lowerMsg.includes('403') && lowerMsg.includes('cloudflare')) { + code = ERROR_CODES.CLOUDFLARE_BLOCKED; + userMessageKey = 'codex.network.cloudflare_blocked'; + } else if (lowerMsg.includes('timeout') || lowerMsg.includes('etimedout')) { + code = ERROR_CODES.NETWORK_TIMEOUT; + userMessageKey = 'codex.network.network_timeout'; + } else if (lowerMsg.includes('connection refused') || lowerMsg.includes('econnrefused')) { + code = ERROR_CODES.NETWORK_REFUSED; + userMessageKey = 'codex.network.connection_refused'; + } else { + code = ERROR_CODES.NETWORK_UNKNOWN; + userMessageKey = 'codex.network.unknown_error'; + } + + return globalErrorService.createError(code, errorMsg, { + originalError: typeof originalError === 'string' ? undefined : originalError, + userMessage: userMessageKey, + retryCount: options.retryCount || 0, + context: options.source, + technicalDetails: { + source: options.source, + originalMessage: errorMsg, + }, + }); +} + +export function fromSystemError(code: string, message: string, context?: string): CodexError { + return globalErrorService.createError(code, message, { + context, + technicalDetails: { + errorCode: code, + context, + }, + }); +} + +// Global instance +export const globalErrorService = new CodexErrorService(); diff --git a/src/agent/codex/handlers/CodexEventHandler.ts b/src/agent/codex/handlers/CodexEventHandler.ts new file mode 100644 index 00000000..d4f88e04 --- /dev/null +++ b/src/agent/codex/handlers/CodexEventHandler.ts @@ -0,0 +1,259 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { uuid } from '@/common/utils'; +import type { ICodexMessageEmitter } from '@/agent/codex/messaging/CodexMessageEmitter'; +import type { CodexEventMsg, CodexJsonRpcEvent } from '@/common/codex/types'; +import { CodexMessageProcessor } from '@/agent/codex/messaging/CodexMessageProcessor'; +import { CodexToolHandlers } from '@/agent/codex/handlers/CodexToolHandlers'; +import { PermissionType } from '@/common/codex/types/permissionTypes'; +import { createPermissionOptionsForType, getPermissionDisplayInfo } from '@/common/codex/utils'; + +export class CodexEventHandler { + private messageProcessor: CodexMessageProcessor; + private toolHandlers: CodexToolHandlers; + private messageEmitter: ICodexMessageEmitter; + + constructor( + private conversation_id: string, + messageEmitter: ICodexMessageEmitter + ) { + this.messageEmitter = messageEmitter; + this.messageProcessor = new CodexMessageProcessor(conversation_id, messageEmitter); + this.toolHandlers = new CodexToolHandlers(conversation_id, messageEmitter); + } + + handleEvent(evt: CodexJsonRpcEvent) { + return this.processCodexEvent(evt.params.msg); + } + + private processCodexEvent(msg: CodexEventMsg) { + const type = msg.type; + + //这两类消息因为有delta 类型数据,所以直接忽略。 + if (type === 'agent_reasoning' || type === 'agent_message') { + return; + } + if (type === 'session_configured' || type === 'token_count') { + return; + } + if (type === 'task_started') { + this.messageProcessor.processTaskStart(); + return; + } + if (type === 'task_complete') { + this.messageProcessor.processTaskComplete(); + return; + } + + // Handle special message types that need custom processing + if (this.isMessageType(msg, 'agent_message_delta')) { + this.messageProcessor.processMessageDelta(msg); + return; + } + + // Handle reasoning deltas and reasoning messages - send them to UI for dynamic thinking display + if (this.isMessageType(msg, 'agent_reasoning_delta')) { + this.messageProcessor.handleReasoningMessage(msg); + return; + } + + if (this.isMessageType(msg, 'agent_reasoning_section_break')) { + // 思考过程中断了 + this.messageProcessor.processReasonSectionBreak(); + return; + } + // Note: Generic error events are now handled as stream_error type + // Handle ALL permission-related requests through unified handler + if (this.isMessageType(msg, 'exec_approval_request') || this.isMessageType(msg, 'apply_patch_approval_request')) { + this.handleUnifiedPermissionRequest(msg); + return; + } + + // Tool: patch apply + if (this.isMessageType(msg, 'patch_apply_begin')) { + this.toolHandlers.handlePatchApplyBegin(msg); + return; + } + + if (this.isMessageType(msg, 'patch_apply_end')) { + this.toolHandlers.handlePatchApplyEnd(msg); + return; + } + + if (this.isMessageType(msg, 'exec_command_begin')) { + this.toolHandlers.handleExecCommandBegin(msg); + return; + } + + if (this.isMessageType(msg, 'exec_command_output_delta')) { + this.toolHandlers.handleExecCommandOutputDelta(msg); + return; + } + + if (this.isMessageType(msg, 'exec_command_end')) { + this.toolHandlers.handleExecCommandEnd(msg); + return; + } + + // Tool: mcp tool + if (this.isMessageType(msg, 'mcp_tool_call_begin')) { + this.toolHandlers.handleMcpToolCallBegin(msg); + return; + } + + if (this.isMessageType(msg, 'mcp_tool_call_end')) { + this.toolHandlers.handleMcpToolCallEnd(msg); + return; + } + + // Tool: web search + if (this.isMessageType(msg, 'web_search_begin')) { + this.toolHandlers.handleWebSearchBegin(msg); + return; + } + + if (this.isMessageType(msg, 'web_search_end')) { + this.toolHandlers.handleWebSearchEnd(msg); + return; + } + + // Tool: turn diff + if (this.isMessageType(msg, 'turn_diff')) { + this.toolHandlers.handleTurnDiff(msg); + return; + } + } + + /** + * Unified permission request handler to prevent duplicates + */ + private handleUnifiedPermissionRequest(msg: Extract | Extract) { + // Extract call_id - both types have this field + const callId = msg.call_id || uuid(); + const unifiedRequestId = `permission_${callId}`; + + // Check if we've already processed this call_id to avoid duplicates + if (this.toolHandlers.getPendingConfirmations().has(unifiedRequestId)) { + return; + } + + // Mark this request as being processed + this.toolHandlers.getPendingConfirmations().add(unifiedRequestId); + + // Route to appropriate handler based on event type + if (msg.type === 'exec_approval_request') { + this.processExecApprovalRequest(msg, unifiedRequestId).catch(console.error); + } else { + this.processApplyPatchRequest(msg, unifiedRequestId).catch(console.error); + } + } + + private async processExecApprovalRequest( + msg: Extract< + CodexEventMsg, + { + type: 'exec_approval_request'; + } + >, + unifiedRequestId: string + ) { + const callId = msg.call_id || uuid(); + + const displayInfo = getPermissionDisplayInfo(PermissionType.COMMAND_EXECUTION); + const options = createPermissionOptionsForType(PermissionType.COMMAND_EXECUTION); + + // 权限请求需要持久化 + this.messageEmitter.emitAndPersistMessage( + { + type: 'codex_permission', + msg_id: unifiedRequestId, + conversation_id: this.conversation_id, + data: { + subtype: 'exec_approval_request', + title: displayInfo.titleKey, + description: msg.reason || `${displayInfo.icon} Codex wants to execute command: ${Array.isArray(msg.command) ? msg.command.join(' ') : msg.command}`, + agentType: 'codex', + sessionId: '', + options: options, + requestId: callId, + data: msg, // 直接使用原始事件数据 + }, + }, + true + ); + } + + private async processApplyPatchRequest( + msg: Extract< + CodexEventMsg, + { + type: 'apply_patch_approval_request'; + } + >, + unifiedRequestId: string + ) { + const callId = msg.call_id || uuid(); + + const displayInfo = getPermissionDisplayInfo(PermissionType.FILE_WRITE); + const options = createPermissionOptionsForType(PermissionType.FILE_WRITE); + + // Store patch changes for later execution + if (msg?.changes || msg?.codex_changes) { + const changes = msg?.changes || msg?.codex_changes; + if (changes) { + this.toolHandlers.storePatchChanges(unifiedRequestId, changes); + } + } + + this.messageEmitter.emitAndPersistMessage( + { + type: 'codex_permission', + msg_id: unifiedRequestId, + conversation_id: this.conversation_id, + data: { + subtype: 'apply_patch_approval_request', + title: displayInfo.titleKey, + description: msg.message || `${displayInfo.icon} Codex wants to apply proposed code changes`, + agentType: 'codex', + sessionId: '', + options: options, + requestId: callId, + data: msg, // 直接使用原始事件数据 + }, + }, + true + ); + } + + // Expose tool handlers for external access + getToolHandlers(): CodexToolHandlers { + return this.toolHandlers; + } + + // Expose message processor for external access + getMessageProcessor(): CodexMessageProcessor { + return this.messageProcessor; + } + + // Type guard functions for intelligent type inference + private isMessageType( + msg: CodexEventMsg, + messageType: T + ): msg is Extract< + CodexEventMsg, + { + type: T; + } + > { + return msg.type === messageType; + } + + cleanup() { + this.messageProcessor.cleanup(); + this.toolHandlers.cleanup(); + } +} diff --git a/src/agent/codex/handlers/CodexFileOperationHandler.ts b/src/agent/codex/handlers/CodexFileOperationHandler.ts new file mode 100644 index 00000000..6303fd9a --- /dev/null +++ b/src/agent/codex/handlers/CodexFileOperationHandler.ts @@ -0,0 +1,287 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { uuid } from '@/common/utils'; +import type { ICodexMessageEmitter } from '@/agent/codex/messaging/CodexMessageEmitter'; +import type { FileChange } from '@/common/codex/types'; +import fs from 'fs/promises'; +import path from 'path'; + +export interface FileOperation { + method: string; + path: string; + filename?: string; + content?: string; + action?: 'create' | 'write' | 'delete' | 'read'; + metadata?: Record; +} + +/** + * CodexFileOperationHandler - 参考 ACP 的文件操作能力 + * 提供统一的文件读写、权限管理和操作反馈 + */ +export class CodexFileOperationHandler { + private readonly pendingOperations = new Map void; reject: (error: unknown) => void }>(); + private readonly workingDirectory: string; + + constructor( + workingDirectory: string, + private conversation_id: string, + private messageEmitter: ICodexMessageEmitter + ) { + this.workingDirectory = path.resolve(workingDirectory); + } + + /** + * 处理文件操作请求 - 参考 ACP 的 handleFileOperation + */ + async handleFileOperation(operation: FileOperation): Promise { + // Validate inputs + if (!operation.filename && !operation.path) { + throw new Error('File operation requires either filename or path'); + } + + try { + switch (operation.method) { + case 'fs/write_text_file': + case 'file_write': + return await this.handleFileWrite(operation); + case 'fs/read_text_file': + case 'file_read': + return await this.handleFileRead(operation); + case 'fs/delete_file': + case 'file_delete': + return await this.handleFileDelete(operation); + default: + return this.handleGenericFileOperation(operation); + } + } catch (error) { + this.emitErrorMessage(`File operation failed: ${error instanceof Error ? error.message : String(error)}`); + throw error; + } + } + + /** + * 处理文件写入操作 + */ + private async handleFileWrite(operation: FileOperation): Promise { + const fullPath = this.resolveFilePath(operation.path); + const content = operation.content || ''; + + // 确保目录存在 + const dir = path.dirname(fullPath); + await fs.mkdir(dir, { recursive: true }); + + // 写入文件 + await fs.writeFile(fullPath, content, 'utf-8'); + + // 发送操作反馈消息 + this.emitFileOperationMessage({ + method: 'fs/write_text_file', + path: operation.path, + content: content, + }); + } + + /** + * 处理文件读取操作 + */ + private async handleFileRead(operation: FileOperation): Promise { + const fullPath = this.resolveFilePath(operation.path); + + try { + const content = await fs.readFile(fullPath, 'utf-8'); + + // 发送操作反馈消息 + this.emitFileOperationMessage({ + method: 'fs/read_text_file', + path: operation.path, + }); + + return content; + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + throw new Error(`File not found: ${operation.path}`); + } + throw error; + } + } + + /** + * 处理文件删除操作 + */ + private async handleFileDelete(operation: FileOperation): Promise { + const fullPath = this.resolveFilePath(operation.path); + + try { + await fs.unlink(fullPath); + + // 发送操作反馈消息 + this.emitFileOperationMessage({ + method: 'fs/delete_file', + path: operation.path, + }); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return; // 文件不存在,视为成功 + } + throw error; + } + } + + /** + * 处理通用文件操作 + */ + private async handleGenericFileOperation(operation: FileOperation): Promise { + // 发送通用操作反馈消息 + this.emitFileOperationMessage(operation); + } + + /** + * 解析文件路径 - 参考 ACP 的路径处理逻辑 + */ + private resolveFilePath(filePath: string): string { + if (path.isAbsolute(filePath)) { + return filePath; + } + return path.resolve(this.workingDirectory, filePath); + } + + /** + * 处理智能文件引用 - 参考 ACP 的 @filename 处理 + */ + processFileReferences(content: string, files?: string[]): string { + if (!files || files.length === 0 || !content.includes('@')) { + return content; + } + + let processedContent = content; + + // 获取实际文件名 + const actualFilenames = files.map((filePath) => { + return filePath.split('/').pop() || filePath; + }); + + // 替换 @actualFilename 为 actualFilename + actualFilenames.forEach((filename) => { + const atFilename = `@${filename}`; + if (processedContent.includes(atFilename)) { + processedContent = processedContent.replace(new RegExp(atFilename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), filename); + } + }); + + return processedContent; + } + + /** + * 发送文件操作消息到 UI - 参考 ACP 的 formatFileOperationMessage + */ + private emitFileOperationMessage(operation: FileOperation): void { + const formattedMessage = this.formatFileOperationMessage(operation); + + this.messageEmitter.emitAndPersistMessage({ + type: 'content', + conversation_id: this.conversation_id, + msg_id: uuid(), + data: formattedMessage, + }); + } + + /** + * 格式化文件操作消息 - 参考 ACP 的实现 + */ + private formatFileOperationMessage(operation: FileOperation): string { + switch (operation.method) { + case 'fs/write_text_file': + case 'file_write': { + const content = operation.content || ''; + const previewContent = content.length > 500 ? content.substring(0, 500) + '\n... (truncated)' : content; + return `📝 **File written:** \`${operation.path}\`\n\n\`\`\`\n${previewContent}\n\`\`\``; + } + case 'fs/read_text_file': + case 'file_read': + return `📖 **File read:** \`${operation.path}\``; + case 'fs/delete_file': + case 'file_delete': + return `🗑️ **File deleted:** \`${operation.path}\``; + default: + return `🔧 **File operation:** \`${operation.path}\` (${operation.method})`; + } + } + + /** + * 发送错误消息 + */ + private emitErrorMessage(error: string): void { + this.messageEmitter.emitAndPersistMessage({ + type: 'error', + conversation_id: this.conversation_id, + msg_id: uuid(), + data: error, + }); + } + + /** + * 批量应用文件更改 - 参考 ACP 和当前 CodexAgentManager 的 applyPatchChanges + */ + async applyBatchChanges(changes: Record): Promise { + const operations: Promise[] = []; + + for (const [filePath, change] of Object.entries(changes)) { + if (typeof change === 'object' && change !== null) { + const action = this.getChangeAction(change); + const content = this.getChangeContent(change); + const operation: FileOperation = { + method: action === 'delete' ? 'fs/delete_file' : 'fs/write_text_file', + path: filePath, + content, + action, + }; + operations.push(this.handleFileOperation(operation).then((): void => void 0)); + } + } + + await Promise.all(operations); + } + + private getChangeAction(change: FileChange): 'create' | 'write' | 'delete' { + // 现代 FileChange 结构检查 + if (typeof change === 'object' && change !== null && 'type' in change) { + const type = change.type; + if (type === 'add') return 'create'; + if (type === 'delete') return 'delete'; + if (type === 'update') return 'write'; + } + + // 兼容旧格式 - 类型安全的检查 + if (typeof change === 'object' && change !== null && 'action' in change) { + const action = change.action; + if (action === 'create' || action === 'modify' || action === 'delete' || action === 'rename') { + return action === 'create' ? 'create' : action === 'delete' ? 'delete' : 'write'; + } + } + + return 'write'; + } + + private getChangeContent(change: FileChange): string { + if (typeof change === 'object' && change !== null && 'content' in change && typeof change.content === 'string') { + return change.content; + } + return ''; + } + + /** + * 清理资源 + */ + cleanup(): void { + // 拒绝所有待处理的操作 + for (const [_operationId, { reject }] of this.pendingOperations) { + reject(new Error('File operation handler is being cleaned up')); + } + this.pendingOperations.clear(); + } +} diff --git a/src/agent/codex/handlers/CodexSessionManager.ts b/src/agent/codex/handlers/CodexSessionManager.ts new file mode 100644 index 00000000..f684903c --- /dev/null +++ b/src/agent/codex/handlers/CodexSessionManager.ts @@ -0,0 +1,299 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { uuid } from '@/common/utils'; +import type { ICodexMessageEmitter } from '@/agent/codex/messaging/CodexMessageEmitter'; +import { randomBytes } from 'crypto'; + +export type CodexSessionStatus = 'initializing' | 'connecting' | 'connected' | 'authenticated' | 'session_active' | 'error' | 'disconnected'; + +export interface CodexSessionConfig { + conversation_id: string; + cliPath?: string; + workingDir: string; + timeout?: number; +} + +/** + * CodexSessionManager - 参考 ACP 的会话管理能力 + * 提供统一的连接状态管理、会话生命周期和状态通知 + */ +// 全局状态管理,确保所有 Codex 会话共享状态 +const globalStatusMessageId: string = 'codex_status_global'; + +export class CodexSessionManager { + private status: CodexSessionStatus = 'initializing'; + private sessionId: string | null = null; + private isConnected: boolean = false; + private hasActiveSession: boolean = false; + private timeout: number; + + constructor( + private config: CodexSessionConfig, + private messageEmitter: ICodexMessageEmitter + ) { + this.timeout = config.timeout || 30000; // 30秒默认超时 + } + + /** + * 启动会话 - 参考 ACP 的 start() 方法 + */ + async startSession(): Promise { + try { + await this.performConnectionSequence(); + } catch (error) { + this.setStatus('error', `Failed to start session: ${error instanceof Error ? error.message : String(error)}`); + throw error; + } + } + + /** + * 执行连接序列 - 参考 ACP 的连接流程 + */ + private async performConnectionSequence(): Promise { + // 1. 连接阶段 + this.setStatus('connecting', ''); + await this.establishConnection(); + + // 2. 认证阶段 + this.setStatus('connected', ''); + await this.performAuthentication(); + + // 3. 会话创建阶段 + this.setStatus('authenticated', ''); + await this.createSession(); + + // 4. 会话激活 + this.setStatus('session_active', ''); + } + + /** + * 建立连接 + */ + private async establishConnection(): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error(`Connection timeout after ${this.timeout / 1000} seconds`)); + }, this.timeout); + + // 模拟连接过程 + setTimeout(() => { + clearTimeout(timeoutId); + this.isConnected = true; + resolve(); + }, 1000); + }); + } + + /** + * 执行认证 - 参考 ACP 的认证逻辑 + */ + private async performAuthentication(): Promise { + // 这里可以添加具体的认证逻辑 + // 目前 Codex 通过 CLI 自身处理认证 + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 500); + }); + } + + /** + * 创建会话 + */ + private async createSession(): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error('Session creation timeout')); + }, this.timeout); + + setTimeout(() => { + clearTimeout(timeoutId); + this.sessionId = this.generateSessionId(); + this.hasActiveSession = true; + resolve(); + }, 500); + }); + } + + /** + * 停止会话 + */ + async stopSession(): Promise { + this.isConnected = false; + this.hasActiveSession = false; + this.sessionId = null; + this.setStatus('disconnected', 'Session disconnected'); + } + + /** + * 检查会话健康状态 + */ + checkSessionHealth(): boolean { + const isHealthy = this.isConnected && this.hasActiveSession && this.status === 'session_active'; + // Session health check + return isHealthy; + } + + /** + * 重新连接会话 + */ + async reconnectSession(): Promise { + await this.stopSession(); + await new Promise((resolve) => setTimeout(resolve, 1000)); // 等待1秒 + await this.startSession(); + } + + /** + * 设置状态并发送通知 - 参考 ACP 的 emitStatusMessage + */ + private setStatus(status: CodexSessionStatus, message: string): void { + this.status = status; + // 更新本地状态即可,全局ID已确保唯一性 + + this.messageEmitter.emitAndPersistMessage({ + type: 'codex_status', + conversation_id: this.config.conversation_id, + msg_id: globalStatusMessageId, // 使用全局状态消息ID + data: { + status, + message, + sessionId: this.sessionId, + isConnected: this.isConnected, + hasActiveSession: this.hasActiveSession, + }, + }); + } + + /** + * 生成会话ID + */ + private generateSessionId(): string { + return `codex-session-${Date.now()}-${this.generateSecureRandomString(9)}`; + } + + /** + * 生成加密安全的随机字符串 + */ + private generateSecureRandomString(length: number): string { + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + // 浏览器环境 + const array = new Uint8Array(length); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(36).padStart(2, '0')) + .join('') + .substring(0, length); + } else if (typeof require !== 'undefined') { + // Node.js环境 + try { + return randomBytes(Math.ceil(length / 2)) + .toString('hex') + .substring(0, length); + } catch (e) { + // 回退方案 + return Math.random() + .toString(36) + .substring(2, 2 + length); + } + } else { + // 回退方案 + return Math.random() + .toString(36) + .substring(2, 2 + length); + } + } + + /** + * 发送会话事件 + */ + emitSessionEvent(eventType: string, data: unknown): void { + this.messageEmitter.emitAndPersistMessage({ + type: 'codex_status', + conversation_id: this.config.conversation_id, + msg_id: uuid(), + data: { + eventType, + sessionId: this.sessionId, + timestamp: Date.now(), + payload: data, + }, + }); + } + + /** + * 获取会话信息 + */ + getSessionInfo(): { + status: CodexSessionStatus; + sessionId: string | null; + isConnected: boolean; + hasActiveSession: boolean; + config: CodexSessionConfig; + } { + return { + status: this.status, + sessionId: this.sessionId, + isConnected: this.isConnected, + hasActiveSession: this.hasActiveSession, + config: this.config, + }; + } + + /** + * 等待会话准备就绪 - 类似 ACP 的 bootstrap Promise + */ + async waitForReady(timeout: number = 30000): Promise { + return new Promise((resolve, reject) => { + if (this.status === 'session_active') { + resolve(); + return; + } + + const checkInterval = setInterval(() => { + if (this.status === 'session_active') { + clearInterval(checkInterval); + clearTimeout(timeoutId); + resolve(); + } else if (this.status === 'error') { + clearInterval(checkInterval); + clearTimeout(timeoutId); + reject(new Error('Session failed to become ready')); + } + }, 100); + + const timeoutId = setTimeout(() => { + clearInterval(checkInterval); + reject(new Error(`Session ready timeout after ${timeout / 1000} seconds`)); + }, timeout); + }); + } + + /** + * 清理资源 + */ + cleanup(): void { + this.stopSession().catch(() => { + // Error during cleanup, ignore + }); + } + + // Getters + get currentStatus(): CodexSessionStatus { + return this.status; + } + + get connected(): boolean { + return this.isConnected; + } + + get activeSession(): boolean { + return this.hasActiveSession; + } + + get currentSessionId(): string | null { + return this.sessionId; + } +} diff --git a/src/agent/codex/handlers/CodexToolHandlers.ts b/src/agent/codex/handlers/CodexToolHandlers.ts new file mode 100644 index 00000000..9733550e --- /dev/null +++ b/src/agent/codex/handlers/CodexToolHandlers.ts @@ -0,0 +1,390 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IMessageToolGroup, CodexToolCallUpdate } from '@/common/chatLib'; +import { uuid } from '@/common/utils'; +import { CodexAgentEventType, type FileChange, type McpInvocation, type CodexEventMsg } from '@/common/codex/types'; +import { ToolRegistry, type EventDataMap } from '@/common/codex/utils'; +import type { ICodexMessageEmitter } from '@/agent/codex/messaging/CodexMessageEmitter'; +import type { IResponseMessage } from '@/common/ipcBridge'; + +export class CodexToolHandlers { + private cmdBuffers: Map = new Map(); + private patchBuffers: Map = new Map(); + private patchChanges: Map> = new Map(); + private pendingConfirmations: Set = new Set(); + private toolRegistry: ToolRegistry; + private activeToolGroups: Map = new Map(); // callId -> msg_id mapping + private activeToolCalls: Map = new Map(); // callId -> msg_id mapping for tool calls + + constructor( + private conversation_id: string, + private messageEmitter: ICodexMessageEmitter + ) { + this.toolRegistry = new ToolRegistry(); + } + + // Command execution handlers + handleExecCommandBegin(msg: Extract) { + const callId = msg.call_id; + const cmd = Array.isArray(msg.command) ? msg.command.join(' ') : String(msg.command); + this.cmdBuffers.set(callId, { stdout: '', stderr: '', combined: '' }); + // 试点启用确认流:先置为 Confirming + this.pendingConfirmations.add(callId); + + // Use new CodexToolCall approach with subtype and original data + this.emitCodexToolCall(callId, { + status: 'pending', + kind: 'execute', + subtype: 'exec_command_begin', + data: msg, + description: cmd, + startTime: Date.now(), + }); + } + + handleExecCommandOutputDelta(msg: Extract) { + const callId = msg.call_id; + const stream = msg.stream; + let chunk = msg.chunk; + // Handle base64-encoded chunks from Codex + // Check if it's a valid base64 string before attempting to decode + if (this.isValidBase64(chunk)) { + try { + // Decode base64 - Codex sends base64-encoded strings + chunk = Buffer.from(chunk, 'base64').toString('utf-8'); + } catch { + // If base64 decoding fails, use the original string + } + } + const buf = this.cmdBuffers.get(callId) || { stdout: '', stderr: '', combined: '' }; + if (stream === 'stderr') buf.stderr += chunk; + else buf.stdout += chunk; + buf.combined += chunk; + this.cmdBuffers.set(callId, buf); + + // Use new CodexToolCall approach with subtype and original data + this.emitCodexToolCall(callId, { + status: 'executing', + kind: 'execute', + subtype: 'exec_command_output_delta', + data: msg, + content: [ + { + type: 'output', + output: buf.combined, + }, + ], + }); + } + + handleExecCommandEnd(msg: Extract) { + const callId = msg.call_id; + const exitCode = msg.exit_code; + + // 获取累积的输出,优先使用缓存的数据,回退到消息中的数据 + const buf = this.cmdBuffers.get(callId); + const finalOutput = buf?.combined || msg.aggregated_output || ''; + + // 确定最终状态:exit_code 0 为成功,其他为错误 + const isSuccess = exitCode === 0; + const status = isSuccess ? 'success' : 'error'; + + // Use new CodexToolCall approach with subtype and original data + this.emitCodexToolCall(callId, { + status, + kind: 'execute', + subtype: 'exec_command_end', + data: msg, + endTime: Date.now(), + content: [ + { + type: 'output', + output: finalOutput, + }, + ], + }); + + // 清理资源 + this.pendingConfirmations.delete(callId); + this.cmdBuffers.delete(callId); + } + + // Patch handlers + handlePatchApplyBegin(msg: Extract) { + const callId = msg.call_id || uuid(); + const auto = msg.auto_approved ? 'true' : 'false'; + const summary = this.summarizePatch(msg.changes); + // Cache both summary and raw changes for later application + this.patchBuffers.set(callId, summary); + if (msg.changes && typeof msg.changes === 'object') { + // msg.changes 已经有正确的类型定义,无需类型断言 + this.patchChanges.set(callId, msg.changes); + } + // 对未自动批准的变更设置确认 + if (!msg.auto_approved) this.pendingConfirmations.add(callId); + // Use new CodexToolCall approach with subtype and original data + this.emitCodexToolCall(callId, { + status: msg.auto_approved ? 'executing' : 'pending', + kind: 'execute', + subtype: 'patch_apply_begin', + data: msg, + description: `apply_patch auto_approved=${auto}`, + startTime: Date.now(), + content: [ + { + type: 'output', + output: summary, + }, + ], + }); + // If auto-approved, immediately attempt to apply changes + if (msg.auto_approved) { + this.applyPatchChanges(callId).catch((): void => void 0); + } + } + + handlePatchApplyEnd(msg: Extract) { + const callId = msg.call_id; + if (!callId) return; + const ok = !!msg.success; + const summary = this.patchBuffers.get(callId) || ''; + // Use new CodexToolCall approach with subtype and original data + this.emitCodexToolCall(callId, { + status: ok ? 'success' : 'error', + kind: 'execute', + subtype: 'patch_apply_end', + data: msg, + description: ok ? 'Patch applied successfully' : 'Patch apply failed', + endTime: Date.now(), + content: [ + { + type: 'output', + output: summary, + }, + ], + }); + + // Clean up resources + this.pendingConfirmations.delete(callId); + this.patchBuffers.delete(callId); + this.patchChanges.delete(callId); + } + + // MCP tool handlers + handleMcpToolCallBegin(msg: Extract) { + // MCP events don't have call_id, generate one based on tool name + const inv = msg.invocation || {}; + const toolName = inv.tool || inv.name || inv.method || 'unknown'; + const callId = `mcp_${toolName}_${uuid()}`; + const title = this.formatMcpInvocation(inv); + + // Add to pending confirmations + this.pendingConfirmations.add(callId); + + // Use new CodexToolCall approach with subtype and original data + this.emitCodexToolCall(callId, { + status: 'executing', + kind: 'execute', + subtype: 'mcp_tool_call_begin', + data: msg, + description: `${title} (beginning)`, + startTime: Date.now(), + }); + } + + handleMcpToolCallEnd(msg: Extract) { + // MCP events don't have call_id, generate one based on tool name + const inv = msg.invocation || {}; + const toolName = inv.tool || inv.name || inv.method || 'unknown'; + const callId = `mcp_${toolName}_${uuid()}`; + const title = this.formatMcpInvocation(inv); + const result = msg.result; + + // 类型安全的错误检查,使用 in 操作符进行类型保护 + const isError = (() => { + if (typeof result === 'object' && result !== null) { + return 'Err' in result || ('is_error' in result && result.is_error === true); + } + return false; + })(); + + // Use new CodexToolCall approach with subtype and original data + this.emitCodexToolCall(callId, { + status: isError ? 'error' : 'success', + kind: 'execute', + subtype: 'mcp_tool_call_end', + data: msg, + description: `${title} ${isError ? 'failed' : 'success'}`, + endTime: Date.now(), + content: [ + { + type: 'output', + output: typeof result === 'string' ? result : JSON.stringify(result, null, 2), + }, + ], + }); + + // Clean up resources + this.pendingConfirmations.delete(callId); + } + + // Web search handlers + handleWebSearchBegin(msg: Extract) { + const callId = msg.call_id; + this.cmdBuffers.set(callId, { stdout: '', stderr: '', combined: '' }); + // 试点启用确认流:先置为 Confirming + this.pendingConfirmations.add(callId); + // Use new CodexToolCall approach with subtype and original data + this.emitCodexToolCall(callId, { + status: 'pending', + kind: 'execute', + subtype: 'web_search_begin', + data: msg, + description: callId, + startTime: Date.now(), + }); + } + + handleWebSearchEnd(msg: Extract) { + const callId = msg.call_id || uuid(); + const query = msg.query || ''; + // Use new CodexToolCall approach with subtype and original data + this.emitCodexToolCall(callId, { + status: 'success', + kind: 'execute', + subtype: 'web_search_end', + data: msg, + description: `Web search completed: ${query}`, + endTime: Date.now(), + }); + + // Clean up buffers + this.pendingConfirmations.delete(callId); + this.cmdBuffers.delete(callId); + } + + // New emit function for CodexToolCall + private emitCodexToolCall(callId: string, update: Partial) { + let msgId: string; + + // Use callId mapping to ensure all phases of the same tool call use the same msg_id + msgId = this.activeToolCalls.get(callId); + if (!msgId) { + msgId = uuid(); + this.activeToolCalls.set(callId, msgId); + } + + const toolCallMessage: IResponseMessage = { + type: 'codex_tool_call', + conversation_id: this.conversation_id, + msg_id: msgId, + data: { + toolCallId: callId, + status: 'pending', + kind: 'execute', + ...update, + } as CodexToolCallUpdate, + }; + + this.messageEmitter.emitAndPersistMessage(toolCallMessage); + + // Clean up mapping if tool call is completed + if (['success', 'error', 'canceled'].includes(update.status || '')) { + this.activeToolCalls.delete(callId); + } + } + + // Turn diff handler + handleTurnDiff(msg: Extract) { + // Generate a unique call ID for turn_diff since it doesn't have one + const callId = `turn_diff_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + + this.emitCodexToolCall(callId, { + status: 'success', + kind: 'patch', // Turn diff shows file changes, so use patch kind + subtype: 'turn_diff', + data: msg, + description: 'File changes summary', + startTime: Date.now(), + endTime: Date.now(), + }); + } + + private formatMcpInvocation(inv: McpInvocation | Record): string { + const name = inv.method || inv.name || 'unknown'; + return `MCP Tool: ${name}`; + } + + private summarizePatch(changes: Record | undefined): string { + if (!changes || typeof changes !== 'object') return 'No changes'; + + const entries = Object.entries(changes); + if (entries.length === 0) return 'No changes'; + + return entries + .map(([file, change]) => { + if (typeof change === 'object' && change !== null) { + let action: string = 'modify'; + // FileChange 有明确的 type 结构,直接使用类型安全的访问 + if ('type' in change && typeof change.type === 'string') { + action = change.type; + } else if ('action' in change && typeof change.action === 'string') { + action = change.action; + } + return `${action}: ${file}`; + } + return `modify: ${file}`; + }) + .join('\n'); + } + + private async applyPatchChanges(callId: string): Promise { + // This would contain the actual patch application logic + // For now, we'll just mark it as successful + const changes = this.patchChanges.get(callId); + if (changes) { + // Apply changes logic would go here + } + } + + // Public methods for external access + getPendingConfirmations(): Set { + return this.pendingConfirmations; + } + + removePendingConfirmation(callId: string) { + this.pendingConfirmations.delete(callId); + } + + getPatchChanges(callId: string): Record | undefined { + return this.patchChanges.get(callId); + } + + storePatchChanges(callId: string, changes: Record): void { + this.patchChanges.set(callId, changes); + } + + cleanup() { + this.cmdBuffers.clear(); + this.patchBuffers.clear(); + this.patchChanges.clear(); + this.pendingConfirmations.clear(); + this.activeToolGroups.clear(); + this.activeToolCalls.clear(); + } + + private isValidBase64(str: string): boolean { + if (!str || str.length === 0) return false; + + // Base64 strings should have length divisible by 4 (with padding) + if (str.length % 4 !== 0) return false; + + // Check if it contains only valid base64 characters + const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; + return base64Regex.test(str); + } +} diff --git a/src/agent/codex/index.ts b/src/agent/codex/index.ts new file mode 100644 index 00000000..7109b7da --- /dev/null +++ b/src/agent/codex/index.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +// Core Management Layer +export { default as CodexAgentManager } from '@process/task/CodexAgentManager'; +export { CodexMcpAgent, type CodexAgentConfig } from './core/CodexMcpAgent'; +// Export the app configuration function for use in main process +export { setAppConfig as setCodexAgentAppConfig } from '../../common/utils/appConfig'; + +// Connection Layer +export { CodexMcpConnection, type CodexEventEnvelope, type NetworkError } from './connection/CodexMcpConnection'; + +// Handlers Layer +export { CodexEventHandler } from './handlers/CodexEventHandler'; +export { CodexSessionManager, type CodexSessionConfig } from './handlers/CodexSessionManager'; +export { CodexFileOperationHandler, type FileOperation } from './handlers/CodexFileOperationHandler'; + +// Messaging Layer +export { CodexMessageProcessor } from './messaging/CodexMessageProcessor'; +export { type ICodexMessageEmitter } from './messaging/CodexMessageEmitter'; + +// Tools Layer +export { CodexToolHandlers } from './handlers/CodexToolHandlers'; +export { ToolRegistry, ToolCategory, OutputFormat, RendererType, type ToolDefinition, type ToolCapabilities, type ToolRenderer, type ToolAvailability, type McpToolInfo } from '@/common/codex/utils'; diff --git a/src/agent/codex/messaging/CodexMessageEmitter.ts b/src/agent/codex/messaging/CodexMessageEmitter.ts new file mode 100644 index 00000000..52dadc52 --- /dev/null +++ b/src/agent/codex/messaging/CodexMessageEmitter.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IResponseMessage } from '@/common/ipcBridge'; + +/** + * 消息发送回调接口 + * 用于解耦各个处理器对 IResponseMessage 的直接依赖 + */ +export interface ICodexMessageEmitter { + /** + * 发送消息并持久化 + * @param message 要发送的消息 + * @param persist 是否需要持久化,默认true + */ + emitAndPersistMessage(message: IResponseMessage, persist?: boolean): void; +} diff --git a/src/agent/codex/messaging/CodexMessageProcessor.ts b/src/agent/codex/messaging/CodexMessageProcessor.ts new file mode 100644 index 00000000..83030e21 --- /dev/null +++ b/src/agent/codex/messaging/CodexMessageProcessor.ts @@ -0,0 +1,176 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { uuid } from '@/common/utils'; +import type { CodexEventMsg } from '@/common/codex/types'; +import type { ICodexMessageEmitter } from '@/agent/codex/messaging/CodexMessageEmitter'; +import { ERROR_CODES, globalErrorService } from '@/agent/codex/core/ErrorService'; + +export class CodexMessageProcessor { + private currentLoadingId: string | null = null; + private deltaTimeout: NodeJS.Timeout | null = null; + private reasoningMsgId: string | null = null; + private currentReason: string = ''; + + constructor( + private conversation_id: string, + private messageEmitter: ICodexMessageEmitter + ) {} + + processTaskStart() { + this.currentLoadingId = uuid(); + this.reasoningMsgId = uuid(); + this.currentReason = ''; + } + + processReasonSectionBreak() { + this.currentReason = ''; + } + + processTaskComplete() { + this.currentLoadingId = null; + this.reasoningMsgId = null; + this.currentReason = ''; + + this.messageEmitter.emitAndPersistMessage( + { + type: 'finish', + msg_id: uuid(), + conversation_id: this.conversation_id, + data: null, + }, + false + ); + } + + handleReasoningMessage(msg: Extract | Extract | Extract) { + // 根据事件类型处理不同的数据结构 - TypeScript 自动类型缩窄 + let deltaText = ''; + if (msg.type === 'agent_reasoning_delta') { + deltaText = msg.delta ?? ''; + } else if (msg.type === 'agent_reasoning') { + deltaText = msg.text ?? ''; + } + // AGENT_REASONING_SECTION_BREAK 不添加内容,只是重置当前reasoning + + this.currentReason = this.currentReason + deltaText; + this.messageEmitter.emitAndPersistMessage( + { + type: 'thought', + msg_id: this.reasoningMsgId, // 使用固定的msg_id确保消息合并 + conversation_id: this.conversation_id, + data: { + description: this.currentReason, + subject: '', + }, + }, + false + ); + } + + processMessageDelta(msg: Extract) { + const rawDelta = msg.delta; + const deltaMessage = { + type: 'content' as const, + conversation_id: this.conversation_id, + msg_id: this.currentLoadingId, + data: rawDelta, + }; + this.messageEmitter.emitAndPersistMessage(deltaMessage); + } + + processStreamError(message: string) { + // Use error service to create standardized error + const codexError = globalErrorService.createError(ERROR_CODES.NETWORK_UNKNOWN, message, { + context: 'CodexMessageProcessor.processStreamError', + technicalDetails: { + originalMessage: message, + eventType: 'STREAM_ERROR', + }, + }); + + // Process through error service for user-friendly message + const processedError = globalErrorService.handleError(codexError); + + const errorHash = this.generateErrorHash(message); + + // 检测消息类型:重试消息 vs 最终错误消息 + const isRetryMessage = message.includes('retrying'); + const isFinalError = !isRetryMessage && message.includes('error sending request'); + + let msgId: string; + if (isRetryMessage) { + // 所有重试消息使用同一个ID,这样会被合并更新 + msgId = `stream_retry_${errorHash}`; + } else if (isFinalError) { + // 最终错误消息也使用重试消息的ID,这样会替换掉重试消息 + msgId = `stream_retry_${errorHash}`; + } else { + // 其他错误使用唯一ID + msgId = `stream_error_${errorHash}`; + } + + // Use error code for structured error handling + // The data will contain error code info that can be translated on frontend + const errorData = processedError.code ? `ERROR_${processedError.code}: ${message}` : processedError.userMessage || message; + + const errMsg = { + type: 'error' as const, + conversation_id: this.conversation_id, + msg_id: msgId, + data: errorData, + }; + this.messageEmitter.emitAndPersistMessage(errMsg); + } + + processGenericError(evt: { type: 'error'; data: { message?: string } | string }) { + const message = typeof evt.data === 'string' ? evt.data : evt.data.message || 'Unknown error'; + + // 为相同的错误消息生成一致的msg_id以避免重复显示 + const errorHash = this.generateErrorHash(message); + + const errMsg = { + type: 'error' as const, + conversation_id: this.conversation_id, + msg_id: `error_${errorHash}`, + data: message, + }; + + this.messageEmitter.emitAndPersistMessage(errMsg); + } + + private generateErrorHash(message: string): string { + // 对于重试类型的错误消息,提取核心错误信息 + const normalizedMessage = this.normalizeRetryMessage(message); + + // 为相同的错误消息生成一致的简短hash + let hash = 0; + for (let i = 0; i < normalizedMessage.length; i++) { + const char = normalizedMessage.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash).toString(36); + } + + private normalizeRetryMessage(message: string): string { + // 如果是重试消息,提取核心错误信息,忽略重试次数和延迟时间 + if (message.includes('retrying')) { + // 匹配 "retrying X/Y in Zms..." 模式并移除它 + return message.replace(/;\s*retrying\s+\d+\/\d+\s+in\s+[\d.]+[ms]+[^;]*$/i, ''); + } + + // 其他类型的错误消息直接返回 + return message; + } + + cleanup() { + if (this.deltaTimeout) { + clearTimeout(this.deltaTimeout); + this.deltaTimeout = null; + } + } +} diff --git a/src/common/acpTypes.ts b/src/common/acpTypes.ts index 09a45e26..79f68c38 100644 --- a/src/common/acpTypes.ts +++ b/src/common/acpTypes.ts @@ -17,6 +17,7 @@ export type AcpBackendAll = | 'gemini' // Google Gemini ACP | 'qwen' // Qwen Code ACP | 'iflow' // iFlow CLI ACP + | 'codex' // OpenAI Codex MCP | 'openai' // OpenAI ACP (未来支持) | 'anthropic' // Anthropic ACP (未来支持) | 'cohere' // Cohere ACP (未来支持) @@ -63,6 +64,13 @@ export const ACP_BACKENDS_ALL: Record = { authRequired: true, enabled: true, }, + codex: { + id: 'codex', + name: 'Codex ', + cliCommand: 'codex', + authRequired: false, + enabled: true, // ✅ 已验证支持:Codex CLI v0.4.0+ 支持 acp 模式 + }, openai: { id: 'openai', name: 'OpenAI GPT', diff --git a/src/common/chatLib.ts b/src/common/chatLib.ts index e7ba3d78..50e2a4ef 100644 --- a/src/common/chatLib.ts +++ b/src/common/chatLib.ts @@ -8,6 +8,8 @@ import type { AcpBackend } from './acpTypes'; import type { IResponseMessage } from './ipcBridge'; import { uuid } from './utils'; import type { AcpPermissionRequest, ToolCallUpdate } from '@/common/acpTypes'; +import type { CodexPermissionRequest } from '@/common/codex/types'; +import type { ExecCommandBeginData, ExecCommandOutputDeltaData, ExecCommandEndData, PatchApplyBeginData, PatchApplyEndData, McpToolCallBeginData, McpToolCallEndData, WebSearchBeginData, WebSearchEndData, ExecApprovalRequestData, ApplyPatchApprovalRequestData, TurnDiffData } from '@/common/codex/types/eventData'; /** * 安全的路径拼接函数,兼容Windows和Mac @@ -49,11 +51,42 @@ export const joinPath = (basePath: string, relativePath: string): string => { return result.replace(/\/+/g, '/'); // 将多个连续的斜杠替换为单个 }; +// Normalize LLM text with awkward line breaks/zero‑width chars while preserving code blocks. +function normalizeLLMText(raw: string): string { + if (!raw || typeof raw !== 'string') return raw as any; + const ZW = /[\u200B-\u200D\uFEFF]/g; + const chunks = raw + .replace(/[\r\t]+/g, (m) => (m.includes('\t') ? ' ' : '')) + .replace(ZW, '') + .split('```'); + const out: string[] = []; + for (let i = 0; i < chunks.length; i++) { + let seg = chunks[i]; + if (i % 2 === 1) { + out.push('```' + seg + '```'); + continue; + } + // Join words split by stray newlines: "Div\nis\nibility" -> "Divisibility" + seg = seg.replace(/([A-Za-z])\s*\n\s*([a-z])/g, '$1$2'); + // Join hyphen alone lines: "Power\n-\nof" -> "Power-of" + seg = seg.replace(/([A-Za-z])\s*\n\s*-\s*\n\s*([A-Za-z])/g, '$1-$2'); + // Replace single newlines between non-terminal contexts with space + // Note: character class excludes terminal punctuation [.!?:;] + seg = seg.replace(/([^.!?:;])\n(?!\n|\s*[-*#\d])/g, '$1 '); + // Collapse excessive blank lines + seg = seg.replace(/\n{3,}/g, '\n\n'); + // Normalize multiple spaces + seg = seg.replace(/ {2,}/g, ' '); + out.push(seg); + } + return out.join(''); +} + /** * @description 跟对话相关的消息类型申明 及相关处理 */ -type TMessageType = 'text' | 'tips' | 'tool_call' | 'tool_group' | 'acp_status' | 'acp_permission' | 'acp_tool_call'; +type TMessageType = 'text' | 'tips' | 'tool_call' | 'tool_group' | 'acp_status' | 'acp_permission' | 'acp_tool_call' | 'codex_status' | 'codex_permission' | 'codex_tool_call'; interface IMessage> { /** @@ -114,7 +147,7 @@ export type IMessageToolGroup = IMessage< Array<{ callId: string; description: string; - name: 'GoogleSearch' | 'Shell' | 'WriteFile' | 'ReadFile' | 'ImageGeneration'; + name: string; renderOutputAsMarkdown: boolean; resultDisplay?: | string @@ -174,7 +207,92 @@ export type IMessageAcpPermission = IMessage<'acp_permission', AcpPermissionRequ export type IMessageAcpToolCall = IMessage<'acp_tool_call', ToolCallUpdate>; -export type TMessage = IMessageText | IMessageTips | IMessageToolCall | IMessageToolGroup | IMessageAcpStatus | IMessageAcpPermission | IMessageAcpToolCall; +export type IMessageCodexStatus = IMessage< + 'codex_status', + { + status: string; + message: string; + sessionId?: string; + isConnected?: boolean; + hasActiveSession?: boolean; + } +>; + +export type IMessageCodexPermission = IMessage<'codex_permission', CodexPermissionRequest>; + +// Base interface for all tool call updates +interface BaseCodexToolCallUpdate { + toolCallId: string; + status: 'pending' | 'executing' | 'success' | 'error' | 'canceled'; + title?: string; // Optional - can be derived from data or kind + kind: 'execute' | 'patch' | 'mcp' | 'web_search'; + + // UI display data + description?: string; + content?: Array<{ + type: 'text' | 'diff' | 'output'; + text?: string; + output?: string; + filePath?: string; + oldText?: string; + newText?: string; + }>; + + // Timing + startTime?: number; + endTime?: number; +} + +// Specific subtypes using the original event data structures +export type CodexToolCallUpdate = + | (BaseCodexToolCallUpdate & { + subtype: 'exec_command_begin'; + data: ExecCommandBeginData; + }) + | (BaseCodexToolCallUpdate & { + subtype: 'exec_command_output_delta'; + data: ExecCommandOutputDeltaData; + }) + | (BaseCodexToolCallUpdate & { + subtype: 'exec_command_end'; + data: ExecCommandEndData; + }) + | (BaseCodexToolCallUpdate & { + subtype: 'patch_apply_begin'; + data: PatchApplyBeginData; + }) + | (BaseCodexToolCallUpdate & { + subtype: 'patch_apply_end'; + data: PatchApplyEndData; + }) + | (BaseCodexToolCallUpdate & { + subtype: 'mcp_tool_call_begin'; + data: McpToolCallBeginData; + }) + | (BaseCodexToolCallUpdate & { + subtype: 'mcp_tool_call_end'; + data: McpToolCallEndData; + }) + | (BaseCodexToolCallUpdate & { + subtype: 'web_search_begin'; + data: WebSearchBeginData; + }) + | (BaseCodexToolCallUpdate & { + subtype: 'web_search_end'; + data: WebSearchEndData; + }) + | (BaseCodexToolCallUpdate & { + subtype: 'turn_diff'; + data: TurnDiffData; + }) + | (BaseCodexToolCallUpdate & { + subtype: 'generic'; + data?: any; // For generic updates that don't map to specific events + }); + +export type IMessageCodexToolCall = IMessage<'codex_tool_call', CodexToolCallUpdate>; + +export type TMessage = IMessageText | IMessageTips | IMessageToolCall | IMessageToolGroup | IMessageAcpStatus | IMessageAcpPermission | IMessageAcpToolCall | IMessageCodexStatus | IMessageCodexPermission | IMessageCodexToolCall; /** * @description 将后端返回的消息转换为前端消息 @@ -194,24 +312,13 @@ export const transformMessage = (message: IResponseMessage): TMessage => { }, }; } - case 'content': { - return { - id: uuid(), - type: 'text', - msg_id: message.msg_id, - position: 'left', - conversation_id: message.conversation_id, - content: { - content: message.data, - }, - }; - } + case 'content': case 'user_content': { return { id: uuid(), type: 'text', msg_id: message.msg_id, - position: 'right', + position: message.type === 'content' ? 'left' : 'right', conversation_id: message.conversation_id, content: { content: message.data, @@ -267,17 +374,43 @@ export const transformMessage = (message: IResponseMessage): TMessage => { content: message.data, }; } - case 'start': - case 'finish': - case 'thought': - break; - default: + case 'codex_status': { return { - type: message.type, + id: uuid(), + type: 'codex_status', + msg_id: message.msg_id, + position: 'center', + conversation_id: message.conversation_id, content: message.data, + }; + } + case 'codex_permission': { + return { + id: uuid(), + type: 'codex_permission', + msg_id: message.msg_id, position: 'left', + conversation_id: message.conversation_id, + content: message.data, + }; + } + case 'codex_tool_call': { + return { id: uuid(), - } as any; + type: 'codex_tool_call', + msg_id: message.msg_id, + position: 'left', + conversation_id: message.conversation_id, + content: message.data, + }; + } + case 'start': + case 'finish': + case 'thought': + break; + default: { + throw new Error(`Unsupported message type '${message.type}'. All non-standard message types should be pre-processed by respective AgentManagers.`); + } } }; @@ -310,6 +443,21 @@ export const composeMessage = (message: TMessage | undefined, list: TMessage[] | return list; } + // Handle codex_tool_call message merging + if (message.type === 'codex_tool_call') { + for (let i = 0, len = list.length; i < len; i++) { + const msg = list[i]; + if (msg.type === 'codex_tool_call' && msg.content.toolCallId === message.content.toolCallId) { + // Update existing tool call with new data + Object.assign(msg.content, message.content); + return list; + } + } + // If no existing tool call found, add new one + list.push(message); + return list; + } + if (last.msg_id !== message.msg_id || last.type !== message.type) return list.concat(message); if (message.type === 'text' && last.type === 'text') { message.content.content = last.content.content + message.content.content; diff --git a/src/common/codex/types/errorTypes.ts b/src/common/codex/types/errorTypes.ts new file mode 100644 index 00000000..4ad891ba --- /dev/null +++ b/src/common/codex/types/errorTypes.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +// Error types and interfaces for Codex +export interface CodexError extends Error { + code: string; + originalError?: Error | string; + context?: string; + retryCount?: number; + timestamp?: Date; + userMessage?: string; + technicalDetails?: Record; +} + +export const ERROR_CODES = { + // Network errors + CLOUDFLARE_BLOCKED: 'CLOUDFLARE_BLOCKED', + NETWORK_TIMEOUT: 'NETWORK_TIMEOUT', + NETWORK_REFUSED: 'CONNECTION_REFUSED', + NETWORK_UNKNOWN: 'NETWORK_UNKNOWN', + + // System errors + SYSTEM_INIT_FAILED: 'SYSTEM_INIT_FAILED', + SESSION_TIMEOUT: 'SESSION_TIMEOUT', + PERMISSION_DENIED: 'PERMISSION_DENIED', + + // Input/Output errors + INVALID_MESSAGE_FORMAT: 'INVALID_MESSAGE_FORMAT', + INVALID_INPUT: 'INVALID_INPUT', + + // Generic + UNKNOWN_ERROR: 'UNKNOWN_ERROR', +} as const; + +export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; diff --git a/src/common/codex/types/eventData.ts b/src/common/codex/types/eventData.ts new file mode 100644 index 00000000..26f0f5dd --- /dev/null +++ b/src/common/codex/types/eventData.ts @@ -0,0 +1,333 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +// JSON-RPC 消息的泛型结构 - 使用 CodexEventMsg 自动推断类型 +export interface CodexJsonRpcEvent { + jsonrpc: '2.0'; + method: 'codex/event'; + params: { + _meta: { + requestId: number; + timestamp?: number; + source?: string; + }; + id: string; + msg: Extract; // 直接从 CodexEventMsg 提取类型 + }; +} + +// 精准的事件消息类型,直接对应 params.msg +export type CodexEventMsg = + | ({ type: 'session_configured' } & SessionConfiguredData) //忽略 + | ({ type: 'task_started' } & TaskStartedData) //已处理 + | ({ type: 'task_complete' } & TaskCompleteData) //已处理 + | ({ type: 'agent_message_delta' } & MessageDeltaData) //已处理 + | ({ type: 'agent_message' } & MessageData) //忽略 + | ({ type: 'user_message' } & UserMessageData) + | ({ type: 'agent_reasoning_delta' } & AgentReasoningDeltaData) //已处理 + | ({ type: 'agent_reasoning' } & AgentReasoningData) //忽略 + | ({ type: 'agent_reasoning_raw_content' } & AgentReasoningRawContentData) + | ({ type: 'agent_reasoning_raw_content_delta' } & AgentReasoningRawContentDeltaData) + | ({ type: 'exec_command_begin' } & ExecCommandBeginData) //已处理 + | ({ type: 'exec_command_output_delta' } & ExecCommandOutputDeltaData) //已处理 + | ({ type: 'exec_command_end' } & ExecCommandEndData) //已处理 + | ({ type: 'exec_approval_request' } & ExecApprovalRequestData) //已处理 + | ({ type: 'apply_patch_approval_request' } & PatchApprovalData) //已处理 + | ({ type: 'patch_apply_begin' } & PatchApplyBeginData) //已处理 + | ({ type: 'patch_apply_end' } & PatchApplyEndData) //已处理 + | ({ type: 'mcp_tool_call_begin' } & McpToolCallBeginData) //先忽略 + | ({ type: 'mcp_tool_call_end' } & McpToolCallEndData) //先忽略 + | ({ type: 'web_search_begin' } & WebSearchBeginData) //已处理 + | ({ type: 'web_search_end' } & WebSearchEndData) //已处理 + | ({ type: 'token_count' } & TokenCountData) //忽略 + | { type: 'agent_reasoning_section_break' } //已处理 + | ({ type: 'turn_diff' } & TurnDiffData) // 已处理 + | ({ type: 'get_history_entry_response' } & GetHistoryEntryResponseData) + | ({ type: 'mcp_list_tools_response' } & McpListToolsResponseData) + | ({ type: 'list_custom_prompts_response' } & ListCustomPromptsResponseData) + | ({ type: 'conversation_path' } & ConversationPathResponseData) + | { type: 'background_event'; message: string } + | ({ type: 'turn_aborted' } & TurnAbortedData); + +// Session / lifecycle events +export interface SessionConfiguredData { + session_id: string; + model?: string; + reasoning_effort?: 'minimal' | 'low' | 'medium' | 'high' | null; + history_log_id?: number; + history_entry_count?: number; + initial_messages?: unknown[] | null; + rollout_path?: string | null; +} + +export interface TaskStartedData { + model_context_window: number; +} + +export interface TaskCompleteData { + last_agent_message: string; +} + +// Message event data interfaces +export interface MessageDeltaData { + delta: string; +} + +// JSON-RPC event parameter interfaces +export interface CodexEventParams { + msg?: { + type: string; + [key: string]: unknown; + }; + _meta?: { + requestId?: number; + [key: string]: unknown; + }; + call_id?: string; + codex_call_id?: string; + changes?: Record; + codex_changes?: Record; +} + +export interface MessageData { + message: string; +} + +export interface AgentReasoningData { + text: string; +} + +export interface AgentReasoningDeltaData { + delta: string; +} + +export type InputMessageKind = 'plain' | 'user_instructions' | 'environment_context'; + +export interface UserMessageData { + message: string; + kind?: InputMessageKind; + images?: string[] | null; +} + +export interface StreamErrorData { + message?: string; + error?: string; + code?: string; + details?: unknown; +} + +// Command execution event data interfaces +export interface ExecCommandBeginData { + call_id: string; + command: string[]; + cwd: string; + parsed_cmd?: ParsedCommand[]; +} + +export interface ExecCommandOutputDeltaData { + call_id: string; + stream: 'stdout' | 'stderr'; + chunk: string; +} + +export interface ExecCommandEndData { + call_id: string; + stdout: string; + stderr: string; + aggregated_output: string; + exit_code: number; + duration?: { secs: number; nanos: number }; + formatted_output?: string; +} + +// Patch/file modification event data interfaces +export interface PatchApprovalData { + call_id: string; + changes: Record; + codex_call_id?: string; + codex_changes?: Record; + message?: string; + summary?: string; + requiresConfirmation?: boolean; + reason?: string | null; + grant_root?: string | null; +} + +export interface PatchApplyBeginData { + call_id?: string; + auto_approved?: boolean; + changes?: Record; + dryRun?: boolean; +} + +export interface PatchApplyEndData { + call_id?: string; + success?: boolean; + error?: string; + appliedChanges?: string[]; + failedChanges?: string[]; + stdout?: string; + stderr?: string; +} + +// MCP tool event data interfaces +export interface McpToolCallBeginData { + invocation?: McpInvocation; + toolName?: string; + serverName?: string; +} + +export interface McpToolCallEndData { + invocation?: McpInvocation; + result?: unknown; + error?: string; + duration?: string | number; +} + +// Web search event data interfaces +export interface WebSearchBeginData { + call_id?: string; +} + +export interface WebSearchEndData { + call_id?: string; + query?: string; + results?: SearchResult[]; +} + +// Token count event data interface +export interface TokenCountData { + info?: { + total_token_usage?: { + input_tokens?: number; + cached_input_tokens?: number; + output_tokens?: number; + reasoning_output_tokens?: number; + total_tokens?: number; + }; + last_token_usage?: { + input_tokens?: number; + cached_input_tokens?: number; + output_tokens?: number; + reasoning_output_tokens?: number; + total_tokens?: number; + }; + model_context_window?: number; + }; +} + +// Supporting interfaces +export type FileChange = + // Current format from actual logs + | { add: { content: string } } + | { delete: { content: string } } + | { update: { unified_diff: string; move_path?: string | null } } + // Legacy format with explicit type field + | { type: 'add'; content: string } + | { type: 'delete'; content: string } + | { type: 'update'; unified_diff: string; move_path?: string | null } + | { + // Legacy/back‑compat + action?: 'create' | 'modify' | 'delete' | 'rename'; + content?: string; + oldPath?: string; + newPath?: string; + mode?: string; + size?: number; + checksum?: string; + }; + +export interface McpInvocation { + server?: string; + tool?: string; + arguments?: Record; + // compat + method?: string; + name?: string; + toolId?: string; + serverId?: string; +} + +export interface SearchResult { + title?: string; + url?: string; + snippet?: string; + score?: number; + metadata?: Record; +} + +export type ParsedCommand = + | { type: 'read'; cmd: string; name: string } + | { + type: 'list_files'; + cmd: string; + path?: string | null; + } + | { type: 'search'; cmd: string; query?: string | null; path?: string | null } + | { type: 'unknown'; cmd: string }; + +export interface AgentReasoningRawContentData { + text: string; +} + +export interface AgentReasoningRawContentDeltaData { + delta: string; +} + +export interface ExecApprovalRequestData { + call_id: string; + command: string[]; + cwd: string; + reason: string | null; +} + +export interface TurnDiffData { + unified_diff: string; +} + +export interface ConversationPathResponseData { + conversation_id: string; + path: string; +} + +export interface GetHistoryEntryResponseData { + offset: number; + log_id: number; + entry?: unknown; +} + +export interface McpListToolsResponseData { + tools: Record; +} + +export interface ListCustomPromptsResponseData { + custom_prompts: unknown[]; +} + +export interface TurnAbortedData { + reason: 'interrupted' | 'replaced'; +} + +// Type aliases for better naming consistency +export type ApplyPatchApprovalRequestData = PatchApprovalData; + +// Manager configuration interface +export interface CodexAgentManagerData { + conversation_id: string; + workspace?: string; + cliPath?: string; + sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access'; + webSearchEnabled?: boolean; +} + +export interface ElicitationCreateData { + codex_elicitation: string; + message?: string; + codex_command?: string | string[]; + codex_cwd?: string; + codex_call_id?: string; + codex_changes?: Record; +} diff --git a/src/common/codex/types/eventTypes.ts b/src/common/codex/types/eventTypes.ts new file mode 100644 index 00000000..835ad581 --- /dev/null +++ b/src/common/codex/types/eventTypes.ts @@ -0,0 +1,317 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +// Codex Agent Event Types +export enum CodexAgentEventType { + // 会话和配置事件 Session and configuration events + /** + * 会话配置事件 - 确认客户端的配置消息 + * prompt: '你好 codex' + * payload: { + * session_id: string, + * model: string, + * reasoning_effort: string | null, + * history_log_id: number, + * history_entry_count: number, + * initial_messages: EventMsg[] | null, + * rollout_path: string + * } + * */ + SESSION_CONFIGURED = 'session_configured', + + /** + * 任务开始事件 - 代理已开始任务 + * prompt: '你好 codex' + * payload: { model_context_window: number | null } + */ + TASK_STARTED = 'task_started', + + /** + * 任务完成事件 - 代理已完成所有操作 + * prompt: '你好 codex' + * payload: { last_agent_message: string | null } + */ + TASK_COMPLETE = 'task_complete', + + // Text & reasoning events + /** + * 代理消息增量事件 - 代理文本输出的增量消息(流式增量消息) + * prompt: '你好 codex' + * payload: { delta: string } + */ + AGENT_MESSAGE_DELTA = 'agent_message_delta', + + /** + * 代理消息事件 - 代理文本输出消息(完整输出消息) + * prompt: '你好 codex' + * payload: { message: string } + */ + AGENT_MESSAGE = 'agent_message', + + /** + * 用户消息事件 - 用户/系统输入消息(发送给模型的内容) + * prompt: '你好 codex' + * payload: { message: string, kind: InputMessageKind | null, images: string[] | null } + */ + USER_MESSAGE = 'user_message', + + /** + * 代理推理事件 - 来自代理的推理事件(完整的思考文本) + * prompt: '我想翻阅codex收发消息原始数据格式的接口文档,应该去哪获取?' + * payload: { text: string } + */ + AGENT_REASONING = 'agent_reasoning', + + /** + * 代理推理增量事件 - 来自代理的推理增量事件(流式增量输出文本) + * prompt: '可以给我一个openAI 官方 url吗?' + * payload: { delta: string } + */ + AGENT_REASONING_DELTA = 'agent_reasoning_delta', + + /** + * 代理推理原始内容事件 - 来自代理的原始思维链 + * prompt: 'TypeScript 编译错误:'Property X does not exist',请帮我分析原因' + * payload: { text: string } + */ + AGENT_REASONING_RAW_CONTENT = 'agent_reasoning_raw_content', + + /** + * 代理推理原始内容增量事件 - 来自代理的推理内容增量事件 + * payload: { delta: string } + */ + AGENT_REASONING_RAW_CONTENT_DELTA = 'agent_reasoning_raw_content_delta', + + /** + * 代理推理章节分隔事件 - 当模型开始新的推理摘要部分时发出信号(例如,新的标题块) + * prompt: 'TypeScript 编译错误:'Property X does not exist',请帮我分析原因' + * payload: {} + */ + AGENT_REASONING_SECTION_BREAK = 'agent_reasoning_section_break', + + // Usage / telemetry + /** + * 令牌计数事件 - 当前会话的使用情况更新,包括总计和上一次 + * prompt: '你好 codex' + * payload: { info: { + "total_token_usage": { + "input_tokens": 2439, + "cached_input_tokens": 2048, + "output_tokens": 18, + "reasoning_output_tokens": 0, + "total_tokens": 2457 + }, + "last_token_usage": { + "input_tokens": 2439, + "cached_input_tokens": 2048, + "output_tokens": 18, + "reasoning_output_tokens": 0, + "total_tokens": 2457 + }, + "model_context_window": 272000 + } | null } + */ + TOKEN_COUNT = 'token_count', + + // 命令执行事件 Command execution events + /** + * 执行命令开始事件 - 通知服务器即将执行命令 + * prompt: 'TypeScript 编译错误:'Property X does not exist',请帮我分析原因' + * payload: { + "type": "exec_command_begin", + "call_id": "call_vufa8VWQV91WSWcc5BlFTsmQ", + "command": [ "bash", "-lc", "ls -a" ], + "cwd": "/Users/pojian/Library/Application Support/AionUi/aionui/codex-temp-1758954404275", + "parsed_cmd": [ + { + "type": "list_files", + "cmd": "ls -a", + "path": null + } + ] + } + */ + EXEC_COMMAND_BEGIN = 'exec_command_begin', + + /** + * 执行命令输出增量事件 - 正在运行命令的增量输出块 + * prompt: 'TypeScript 编译错误:'Property X does not exist',请帮我分析原因' + * payload: { call_id: string, stream: ExecOutputStream, chunk: number[] } + * { + "type": "exec_command_output_delta", + "call_id": "call_vufa8VWQV91WSWcc5BlFTsmQ", + "stream": "stdout", + "chunk": "LgouLgo=" + } + */ + EXEC_COMMAND_OUTPUT_DELTA = 'exec_command_output_delta', + + /** + * 执行命令结束事件 - 表示命令执行完成 + * prompt: 'TypeScript 编译错误:'Property X does not exist',请帮我分析原因' + * payload: { + * "type": "exec_command_end", + * "call_id": "call_vufa8VWQV91WSWcc5BlFTsmQ", + * "stdout": ".\\n..\\n", + * "stderr": "", + * "aggregated_output": ".\\n..\\n", + * "exit_code": 0, + * "duration": { + * "secs": 0, + * "nanos": 297701750 + * }, + * "formatted_output": ".\\n..\\n" + * } + */ + EXEC_COMMAND_END = 'exec_command_end', + + /** + * 执行批准请求事件 - 请求批准命令执行 + * prompt: 帮我创建一个文件 hello.txt , 内容为 ’hello codex‘ + * payload: { + "type": "exec_approval_request", + "call_id": "call_W5qxMSKOP2eHaEq16QCtrhVS", + "command": ["bash", "-lc", "echo '1231231' > hello.txt" ], + "cwd": "/Users/pojian/Library/Application Support/AionUi/aionui/codex-temp-1758954404275", + "reason": "Need to create hello.txt with requested content per user instruction" + } + */ + EXEC_APPROVAL_REQUEST = 'exec_approval_request', + + // 补丁/文件修改事件 Patch/file modification events + /** + * 应用补丁批准请求事件 - 请求批准应用代码补丁 + * prompt: 帮我创建一个文件 hello.txt , 内容为 ’hello codex‘ + * payload: { + type: 'apply_patch_approval_request', + call_id: 'patch-7', + changes: { + 'src/app.ts': { type: 'update', unified_diff: '--- a\n+++ b\n+console.log("hi")\n', move_path: null }, + 'README.md': { type: 'add', content: '# Readme\n' }, + }, + reason: null, + grant_root: null, + } + */ + APPLY_PATCH_APPROVAL_REQUEST = 'apply_patch_approval_request', + + /** + * 补丁应用开始事件 - 通知代理即将应用代码补丁。镜像 `ExecCommandBegin`,以便前端可以显示进度指示器 + * tips: codex 运行在 sandbox_mode=read-only 模式下,无法直接写入文件,不会触发 patch_apply_begin → patch_apply_end 流程。 + * 需要在 ~/.codex/config.toml 中 修改配置,sandbox_mode = "workspace-write" apply_patch = true + * prompt: 用命令 apply_patch <<'PATCH' … PATCH 写入一个文件,内容和文件名你自由发挥 + * payload: { + "type": "patch_apply_begin", + "call_id": "call_3tChlyDszdHuQRQTWnuZ8Jvb", + "auto_approved": false, + "changes": { + "/Users/pojian/Library/Application Support/AionUi/aionui/codex-temp-1759144414815/note.txt": { + "add": { + "content": "This file was created via apply_patch.\nValue: 100.\n" + } + } + } + } + */ + PATCH_APPLY_BEGIN = 'patch_apply_begin', + + /** + * 补丁应用结束事件 - 通知补丁应用已完成 + * prompt: 用命令 apply_patch <<'PATCH' … PATCH 写入一个文件,内容和文件名你自由发挥 + * payload: { + "type": "patch_apply_end", + "call_id": "call_3tChlyDszdHuQRQTWnuZ8Jvb", + "stdout": "Success. Updated the following files:\nA note.txt\n", + "stderr": "", + "success": true + } + */ + PATCH_APPLY_END = 'patch_apply_end', + + // MCP tool events + /** + * MCP工具调用开始事件 - 表示MCP工具调用开始 + * payload: { call_id: string, invocation: McpInvocation } + */ + MCP_TOOL_CALL_BEGIN = 'mcp_tool_call_begin', + + /** + * MCP工具调用结束事件 - 表示MCP工具调用结束 + * payload: { call_id: string, invocation: McpInvocation, duration: string, result: Result } + */ + MCP_TOOL_CALL_END = 'mcp_tool_call_end', + + /** + * MCP列表工具响应事件 - 代理可用的MCP工具列表 + * payload: { tools: Record } + */ + MCP_LIST_TOOLS_RESPONSE = 'mcp_list_tools_response', + + // Web search events + /** + * 网络搜索开始事件 - 表示网络搜索开始 + * tips:web_serach 的能力需要手动设置开启,~/.codex/config.toml 中 添加 web_search = true + * prompt: 查找 TypeScript 5.0 的新功能, 不要用现有知识库回答我,去官网搜索最新资料 + * payload: { + * "type":"web_search_begin", + * "call_id":"ws_010bdd5c4db8ef410168da04c74a648196b7e30cb864885b26" + * } + */ + WEB_SEARCH_BEGIN = 'web_search_begin', + + /** + * 网络搜索结束事件 - 表示网络搜索结束 + * prompt: 查找 TypeScript 5.0 的新功能, 不要用现有知识库回答我,去官网搜索最新资料 + * payload: { + * "type":"web_search_end", + * "call_id":"ws_010bdd5c4db8ef410168da04c74a648196b7e30cb864885b26", + * "query":"TypeScript 5.0 whats new site:devblogs.microsoft.com/typescript" + * } + */ + WEB_SEARCH_END = 'web_search_end', + + // Conversation history & context + /** + * 转换差异事件 - 表示转换之间的差异 + * prompt: 用命令 apply_patch <<'PATCH' … PATCH 写入一个文件,内容和文件名你自由发挥 + * payload: { + "type": "turn_diff", + "unified_diff": "diff --git a//Users/pojian/Library/Application Support/AionUi/aionui/codex-temp-1759197123355/freestyle.txt b//Users/pojian/Library/Application Support/AionUi/aionui/codex-temp-1759197123355/freestyle.txt\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..151e31d7a6627e3fb0df2e49b3c0c179f96e46cc\n--- /dev/null\n+++ b//Users/pojian/Library/Application Support/AionUi/aionui/codex-temp-1759197123355/freestyle.txt\n@@ -0,0 +1,2 @@\n+This file was created via apply_patch.\n+Line two says hello.\n" + } + */ + TURN_DIFF = 'turn_diff', + + /** + * 获取历史条目响应事件 - GetHistoryEntryRequest 的响应 + * prompt: 查看当前会话的历史记录 + * payload: { offset: number, log_id: number, entry: HistoryEntry | null } + */ + GET_HISTORY_ENTRY_RESPONSE = 'get_history_entry_response', + + /** + * 列出自定义提示响应事件 - 代理可用的自定义提示列表 + * payload: { custom_prompts: CustomPrompt[] } + */ + LIST_CUSTOM_PROMPTS_RESPONSE = 'list_custom_prompts_response', + + /** + * 对话路径事件 - 表示对话路径信息 + * payload: { conversation_id: string, path: string } + */ + CONVERSATION_PATH = 'conversation_path', + + /** + * 后台事件 - 后台处理事件 + * payload: { message: string } + */ + BACKGROUND_EVENT = 'background_event', + + /** + * 转换中止事件 - 表示转换已中止 + * payload: { reason: TurnAbortReason } + */ + TURN_ABORTED = 'turn_aborted', +} diff --git a/src/common/codex/types/index.ts b/src/common/codex/types/index.ts new file mode 100644 index 00000000..137d5e9f --- /dev/null +++ b/src/common/codex/types/index.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +// Export all codex types from the modular structure +export * from './eventTypes'; +export * from './eventData'; +export * from './permissionTypes'; +export * from './toolTypes'; +export * from './errorTypes'; diff --git a/src/common/codex/types/permissionTypes.ts b/src/common/codex/types/permissionTypes.ts new file mode 100644 index 00000000..c514f533 --- /dev/null +++ b/src/common/codex/types/permissionTypes.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ExecApprovalRequestData, ApplyPatchApprovalRequestData } from './eventData'; + +// ===== UI-facing permission request payloads for Codex ===== + +/** + * 权限类型枚举 + */ +export enum PermissionType { + COMMAND_EXECUTION = 'command_execution', + FILE_WRITE = 'file_write', + FILE_READ = 'file_read', +} + +/** + * 权限选项严重级别 + */ +export enum PermissionSeverity { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', + CRITICAL = 'critical', +} + +/** + * 权限决策类型映射 + * 将UI选项映射到后端决策逻辑 + * 参考 Codex 源码 approved approved_for_session denied abort + */ +export const PERMISSION_DECISION_MAP = { + allow_once: 'approved', + allow_always: 'approved_for_session', + reject_once: 'denied', + reject_always: 'abort', +} as const; + +export interface CodexPermissionOption { + optionId: string; + name: string; + kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'; + description?: string; + severity?: PermissionSeverity; +} + +export interface CodexToolCallRawInput { + command?: string | string[]; + cwd?: string; + description?: string; +} + +export interface CodexToolCall { + title?: string; + toolCallId: string; + kind?: 'edit' | 'read' | 'fetch' | 'execute' | string; + rawInput?: CodexToolCallRawInput; +} + +// Base interface for all permission requests +export interface BaseCodexPermissionRequest { + title?: string; + description?: string; + agentType?: 'codex'; + sessionId?: string; + requestId?: string; + options: CodexPermissionOption[]; +} + +// Union type for different permission request subtypes +export type CodexPermissionRequest = (BaseCodexPermissionRequest & { subtype: 'exec_approval_request'; data: ExecApprovalRequestData }) | (BaseCodexPermissionRequest & { subtype: 'apply_patch_approval_request'; data: ApplyPatchApprovalRequestData }); diff --git a/src/common/codex/types/toolTypes.ts b/src/common/codex/types/toolTypes.ts new file mode 100644 index 00000000..72fe55c8 --- /dev/null +++ b/src/common/codex/types/toolTypes.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ +import type { CodexAgentEventType } from './eventTypes'; + +// 工具类别枚举 +export enum ToolCategory { + EXECUTION = 'execution', // shell, bash, python等 + FILE_OPS = 'file_ops', // 读写、编辑、搜索文件 + SEARCH = 'search', // 各种搜索方式 + ANALYSIS = 'analysis', // 代码分析、图表生成 + COMMUNICATION = 'communication', // 网络请求、API调用 + CUSTOM = 'custom', // MCP工具等自定义工具 +} + +// 输出格式枚举 +export enum OutputFormat { + TEXT = 'text', + MARKDOWN = 'markdown', + JSON = 'json', + IMAGE = 'image', + CHART = 'chart', + DIAGRAM = 'diagram', + TABLE = 'table', +} + +// 渲染器类型枚举 +export enum RendererType { + STANDARD = 'standard', // 标准文本渲染 + MARKDOWN = 'markdown', // Markdown渲染 + CODE = 'code', // 代码高亮渲染 + CHART = 'chart', // 图表渲染 + IMAGE = 'image', // 图像渲染 + INTERACTIVE = 'interactive', // 交互式渲染 + COMPOSITE = 'composite', // 复合渲染 +} + +// 工具可用性配置 +export interface ToolAvailability { + platforms: string[]; // ['darwin', 'linux', 'win32'] + requires?: string[]; // 依赖的工具或服务 + experimental?: boolean; // 是否为实验性功能 +} + +// 工具能力配置 +export interface ToolCapabilities { + supportsStreaming: boolean; + supportsImages: boolean; + supportsCharts: boolean; + supportsMarkdown: boolean; + supportsInteraction: boolean; // 是否需要用户交互 + outputFormats: OutputFormat[]; +} + +// 渲染器配置 +export interface ToolRenderer { + type: RendererType; + config: Record; +} + +// 工具定义接口 +export interface ToolDefinition { + id: string; + name: string; + displayNameKey: string; // i18n key for display name + category: ToolCategory; + priority: number; // 优先级,数字越小优先级越高 + availability: ToolAvailability; + capabilities: ToolCapabilities; + renderer: ToolRenderer; + icon?: string; // 工具图标 + descriptionKey: string; // i18n key for description + schema?: any; // 工具Schema +} + +// MCP工具信息 +export interface McpToolInfo { + name: string; + serverName: string; + description?: string; + inputSchema?: Record; +} + +// 事件数据类型定义 (simplified using CodexEventMsg structure) +export type EventDataMap = { + [CodexAgentEventType.EXEC_COMMAND_BEGIN]: Extract; + [CodexAgentEventType.EXEC_COMMAND_OUTPUT_DELTA]: Extract; + [CodexAgentEventType.EXEC_COMMAND_END]: Extract; + [CodexAgentEventType.APPLY_PATCH_APPROVAL_REQUEST]: Extract; + [CodexAgentEventType.PATCH_APPLY_BEGIN]: Extract; + [CodexAgentEventType.PATCH_APPLY_END]: Extract; + [CodexAgentEventType.MCP_TOOL_CALL_BEGIN]: Extract; + [CodexAgentEventType.MCP_TOOL_CALL_END]: Extract; + [CodexAgentEventType.WEB_SEARCH_BEGIN]: Extract; + [CodexAgentEventType.WEB_SEARCH_END]: Extract; +}; diff --git a/src/common/codex/utils/index.ts b/src/common/codex/utils/index.ts new file mode 100644 index 00000000..85f8ce47 --- /dev/null +++ b/src/common/codex/utils/index.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +// Re-export all permission utilities +export * from './permissionUtils'; + +// Re-export tool utilities +export * from './toolUtils'; + +// Future Codex utility modules can be exported here +// export * from './messageUtils'; +// export * from './sessionUtils'; diff --git a/src/common/codex/utils/permissionUtils.ts b/src/common/codex/utils/permissionUtils.ts new file mode 100644 index 00000000..f63523d6 --- /dev/null +++ b/src/common/codex/utils/permissionUtils.ts @@ -0,0 +1,200 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { conversation } from '@/common/ipcBridge'; +import type { CodexPermissionOption } from '../types/permissionTypes'; +import { useState } from 'react'; +import { PermissionType, PermissionSeverity, PERMISSION_DECISION_MAP } from '../types/permissionTypes'; + +/** + * 基础权限选项配置 + * 提供四种标准的权限决策选项 + */ +const BASE_PERMISSION_OPTIONS: ReadonlyArray = [ + { + optionId: 'allow_once', + name: 'codex.permissions.allow_once', + kind: 'allow_once' as const, + description: 'codex.permissions.allow_once_desc', + severity: PermissionSeverity.LOW, + }, + { + optionId: 'allow_always', + name: 'codex.permissions.allow_always', + kind: 'allow_always' as const, + description: 'codex.permissions.allow_always_desc', + severity: PermissionSeverity.MEDIUM, + }, + { + optionId: 'reject_once', + name: 'codex.permissions.reject_once', + kind: 'reject_once' as const, + description: 'codex.permissions.reject_once_desc', + severity: PermissionSeverity.LOW, + }, + { + optionId: 'reject_always', + name: 'codex.permissions.reject_always', + kind: 'reject_always' as const, + description: 'codex.permissions.reject_always_desc', + severity: PermissionSeverity.HIGH, + }, +] as const; + +/** + * 权限配置接口 + */ +interface PermissionConfig { + titleKey: string; + descriptionKey: string; + icon: string; + severity: PermissionSeverity; + options: CodexPermissionOption[]; +} + +/** + * 预定义的权限配置 + * 为不同类型的权限请求提供标准化配置 + */ +const PERMISSION_CONFIGS: Record = { + [PermissionType.COMMAND_EXECUTION]: { + titleKey: 'codex.permissions.titles.command_execution', + descriptionKey: 'codex.permissions.descriptions.command_execution', + icon: '⚡', + severity: PermissionSeverity.HIGH, + options: createPermissionOptions(PermissionType.COMMAND_EXECUTION), + }, + [PermissionType.FILE_WRITE]: { + titleKey: 'codex.permissions.titles.file_write', + descriptionKey: 'codex.permissions.descriptions.file_write', + icon: '📝', + severity: PermissionSeverity.MEDIUM, + options: createPermissionOptions(PermissionType.FILE_WRITE), + }, + [PermissionType.FILE_READ]: { + titleKey: 'codex.permissions.titles.file_read', + descriptionKey: 'codex.permissions.descriptions.file_read', + icon: '📖', + severity: PermissionSeverity.LOW, + options: createPermissionOptions(PermissionType.FILE_READ), + }, +}; + +/** + * 创建特定权限类型的选项 + * 为每个选项生成类型特定的描述键 + */ +function createPermissionOptions(permissionType: PermissionType): CodexPermissionOption[] { + return BASE_PERMISSION_OPTIONS.map((option) => ({ + ...option, + description: `codex.permissions.${permissionType}.${option.optionId}_desc`, + })); +} + +/** + * 获取权限配置 + */ +function getPermissionConfig(type: PermissionType): PermissionConfig { + return PERMISSION_CONFIGS[type]; +} + +/** + * 根据权限类型创建选项 + * 工厂函数,简化权限选项的创建 + */ +export function createPermissionOptionsForType(permissionType: PermissionType): CodexPermissionOption[] { + const config = getPermissionConfig(permissionType); + return config.options; +} + +/** + * 将UI选项决策转换为后端决策 + */ +export function mapPermissionDecision(optionId: keyof typeof PERMISSION_DECISION_MAP): string { + return PERMISSION_DECISION_MAP[optionId] || 'denied'; +} + +/** + * 获取权限类型的显示信息 + */ +export function getPermissionDisplayInfo(type: PermissionType) { + const config = getPermissionConfig(type); + return { + titleKey: config.titleKey, + descriptionKey: config.descriptionKey, + icon: config.icon, + severity: config.severity, + }; +} + +// Shared interface for confirmation data +export interface ConfirmationData { + confirmKey: string; + msg_id: string; + conversation_id: string; + callId: string; +} + +/** + * Common hook to handle message confirmation for both tool groups and codex permissions + */ +export const useConfirmationHandler = () => { + const handleConfirmation = async (data: ConfirmationData): Promise<{ success: boolean; error?: string }> => { + try { + await conversation.confirmMessage.invoke(data); + return { success: true, error: undefined }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + }; + + return { handleConfirmation }; +}; + +/** + * Hook to handle local storage state for permissions + */ +export const usePermissionState = (storageKey: string, responseKey: string) => { + const [selected, setSelected] = useState(() => { + try { + return localStorage.getItem(storageKey); + } catch { + return null; + } + }); + + const [hasResponded, setHasResponded] = useState(() => { + try { + return localStorage.getItem(responseKey) === 'true'; + } catch { + return false; + } + }); + + return { selected, setSelected, hasResponded, setHasResponded }; +}; + +/** + * Hook to clean up old permission storage entries (older than 7 days) + */ +export const usePermissionStorageCleanup = () => { + const cleanupOldPermissionStorage = () => { + const now = Date.now(); + const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000; + + Object.keys(localStorage).forEach((key) => { + if (key.startsWith('codex_permission_choice_') || key.startsWith('codex_permission_responded_')) { + const timestamp = localStorage.getItem(`${key}_timestamp`); + if (timestamp && parseInt(timestamp, 10) < sevenDaysAgo) { + localStorage.removeItem(key); + localStorage.removeItem(`${key}_timestamp`); + } + } + }); + }; + + return { cleanupOldPermissionStorage }; +}; diff --git a/src/common/codex/utils/toolUtils.ts b/src/common/codex/utils/toolUtils.ts new file mode 100644 index 00000000..e7599ed7 --- /dev/null +++ b/src/common/codex/utils/toolUtils.ts @@ -0,0 +1,431 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CodexAgentEventType, EventDataMap, type McpInvocation, McpToolInfo, OutputFormat, RendererType, ToolAvailability, ToolCapabilities, ToolCategory, ToolDefinition, ToolRenderer } from '../types'; +import i18n from '../../../renderer/i18n'; + +// Re-export types for backward compatibility +export { ToolCategory, OutputFormat, RendererType, ToolAvailability, ToolCapabilities, ToolRenderer, ToolDefinition, McpToolInfo, EventDataMap }; + +/** + * 工具注册表 - 负责管理所有工具的注册、发现和解析 + */ +export class ToolRegistry { + private tools = new Map(); + private mcpTools = new Map(); + private eventTypeMapping = new Map(); + + constructor() { + this.initializeBuiltinTools(); + } + + /** + * 初始化内置工具 + */ + private initializeBuiltinTools() { + // Shell执行工具 + this.registerBuiltinTool({ + id: 'shell_exec', + name: 'Shell', + displayNameKey: 'tools.shell.displayName', + category: ToolCategory.EXECUTION, + priority: 10, + availability: { + platforms: ['darwin', 'linux', 'win32'], + }, + capabilities: { + supportsStreaming: true, + supportsImages: false, + supportsCharts: false, + supportsMarkdown: true, + supportsInteraction: true, + outputFormats: [OutputFormat.TEXT, OutputFormat.MARKDOWN], + }, + renderer: { + type: RendererType.STANDARD, + config: { showTimestamp: true }, + }, + icon: '🔧', + descriptionKey: 'tools.shell.description', + }); + + // 文件操作工具 + this.registerBuiltinTool({ + id: 'file_operations', + name: 'FileOps', + displayNameKey: 'tools.fileOps.displayName', + category: ToolCategory.FILE_OPS, + priority: 20, + availability: { + platforms: ['darwin', 'linux', 'win32'], + }, + capabilities: { + supportsStreaming: false, + supportsImages: false, + supportsCharts: false, + supportsMarkdown: true, + supportsInteraction: true, + outputFormats: [OutputFormat.TEXT, OutputFormat.MARKDOWN], + }, + renderer: { + type: RendererType.CODE, + config: { language: 'diff' }, + }, + icon: '📝', + descriptionKey: 'tools.fileOps.description', + }); + + // 网页搜索工具 + this.registerBuiltinTool({ + id: 'web_search', + name: 'WebSearch', + displayNameKey: 'tools.webSearch.displayName', + category: ToolCategory.SEARCH, + priority: 30, + availability: { + platforms: ['darwin', 'linux', 'win32'], + }, + capabilities: { + supportsStreaming: false, + supportsImages: true, + supportsCharts: false, + supportsMarkdown: true, + supportsInteraction: false, + outputFormats: [OutputFormat.TEXT, OutputFormat.MARKDOWN], + }, + renderer: { + type: RendererType.MARKDOWN, + config: { showSources: true }, + }, + icon: '🔍', + descriptionKey: 'tools.webSearch.description', + }); + + // 设置事件类型映射 + this.eventTypeMapping.set(CodexAgentEventType.EXEC_COMMAND_BEGIN, ['shell_exec']); + this.eventTypeMapping.set(CodexAgentEventType.EXEC_COMMAND_OUTPUT_DELTA, ['shell_exec']); + this.eventTypeMapping.set(CodexAgentEventType.EXEC_COMMAND_END, ['shell_exec']); + this.eventTypeMapping.set(CodexAgentEventType.APPLY_PATCH_APPROVAL_REQUEST, ['file_operations']); + this.eventTypeMapping.set(CodexAgentEventType.PATCH_APPLY_BEGIN, ['file_operations']); + this.eventTypeMapping.set(CodexAgentEventType.PATCH_APPLY_END, ['file_operations']); + this.eventTypeMapping.set(CodexAgentEventType.WEB_SEARCH_BEGIN, ['web_search']); + this.eventTypeMapping.set(CodexAgentEventType.WEB_SEARCH_END, ['web_search']); + } + + /** + * 注册内置工具 + */ + registerBuiltinTool(tool: ToolDefinition) { + this.tools.set(tool.id, tool); + } + + /** + * 注册MCP工具 + */ + registerMcpTool(mcpTool: McpToolInfo) { + const toolDef = this.adaptMcpTool(mcpTool); + this.mcpTools.set(toolDef.id, toolDef); + } + + /** + * 将MCP工具适配为标准工具定义 + */ + private adaptMcpTool(mcpTool: McpToolInfo): ToolDefinition { + const fullyQualifiedName = `${mcpTool.serverName}/${mcpTool.name}`; + + return { + id: fullyQualifiedName, + name: mcpTool.name, + displayNameKey: `tools.mcp.${mcpTool.serverName}.${mcpTool.name}.displayName`, + category: this.inferCategory(mcpTool), + priority: 100, // MCP工具优先级较低 + availability: { + platforms: ['darwin', 'linux', 'win32'], + experimental: true, + }, + capabilities: this.inferCapabilities(mcpTool.inputSchema), + renderer: this.selectRenderer(mcpTool), + icon: this.getIconForCategory(this.inferCategory(mcpTool)), + descriptionKey: `tools.mcp.${mcpTool.serverName}.${mcpTool.name}.description`, + schema: mcpTool.inputSchema, + }; + } + + /** + * 智能推断工具类别 + */ + private inferCategory(mcpTool: McpToolInfo): ToolCategory { + const name = mcpTool.name.toLowerCase(); + const description = mcpTool.description?.toLowerCase() || ''; + + if (name.includes('search') || name.includes('find') || name.includes('query') || description.includes('search')) { + return ToolCategory.SEARCH; + } + if (name.includes('file') || name.includes('read') || name.includes('write') || name.includes('edit')) { + return ToolCategory.FILE_OPS; + } + if (name.includes('exec') || name.includes('run') || name.includes('command') || name.includes('shell')) { + return ToolCategory.EXECUTION; + } + if (name.includes('chart') || name.includes('plot') || name.includes('analyze') || name.includes('graph')) { + return ToolCategory.ANALYSIS; + } + if (name.includes('http') || name.includes('api') || name.includes('request') || name.includes('fetch')) { + return ToolCategory.COMMUNICATION; + } + + return ToolCategory.CUSTOM; + } + + /** + * 推断工具能力 + */ + private inferCapabilities(inputSchema?: Record): ToolCapabilities { + // 基于Schema推断能力 + const properties = inputSchema?.properties as Record | undefined; + const hasStreamParam = properties?.stream !== undefined; + const hasImageParam = properties?.image !== undefined || properties?.img !== undefined; + + return { + supportsStreaming: hasStreamParam, + supportsImages: hasImageParam, + supportsCharts: false, // 默认不支持图表 + supportsMarkdown: true, // 默认支持markdown + supportsInteraction: true, // 默认支持交互 + outputFormats: [OutputFormat.TEXT, OutputFormat.MARKDOWN], + }; + } + + /** + * 选择合适的渲染器 + */ + private selectRenderer(mcpTool: McpToolInfo): ToolRenderer { + const category = this.inferCategory(mcpTool); + + switch (category) { + case ToolCategory.FILE_OPS: + return { type: RendererType.CODE, config: {} }; + case ToolCategory.ANALYSIS: + return { type: RendererType.CHART, config: {} }; + case ToolCategory.SEARCH: + return { type: RendererType.MARKDOWN, config: {} }; + default: + return { type: RendererType.STANDARD, config: {} }; + } + } + + /** + * 根据类别获取图标 + */ + private getIconForCategory(category: ToolCategory): string { + switch (category) { + case ToolCategory.EXECUTION: + return '🔧'; + case ToolCategory.FILE_OPS: + return '📝'; + case ToolCategory.SEARCH: + return '🔍'; + case ToolCategory.ANALYSIS: + return '📊'; + case ToolCategory.COMMUNICATION: + return '🌐'; + case ToolCategory.CUSTOM: + return '🔌'; + default: + return '❓'; + } + } + + /** + * 根据事件类型和数据解析对应的工具 + */ + resolveToolForEvent(eventType: CodexAgentEventType, eventData?: EventDataMap[keyof EventDataMap]): ToolDefinition | null { + // 1. 特殊处理MCP工具调用 + if (eventType === CodexAgentEventType.MCP_TOOL_CALL_BEGIN || eventType === CodexAgentEventType.MCP_TOOL_CALL_END) { + const mcpData = eventData as EventDataMap[CodexAgentEventType.MCP_TOOL_CALL_BEGIN]; + if (mcpData?.invocation) { + const toolId = this.inferMcpToolId(mcpData.invocation); + const mcpTool = this.mcpTools.get(toolId); + if (mcpTool) return mcpTool; + } + + // 如果找不到具体的MCP工具,返回通用MCP工具 + return this.createGenericMcpTool(mcpData?.invocation); + } + + // 2. 基于事件类型的直接映射 + const candidateIds = this.eventTypeMapping.get(eventType) || []; + + // 3. 基于优先级选择最佳匹配 + const availableTools = candidateIds + .map((id) => this.tools.get(id) || this.mcpTools.get(id)) + .filter(Boolean) + .filter((tool) => this.isToolAvailable(tool!)) + .sort((a, b) => a!.priority - b!.priority); + + return availableTools[0] || this.getDefaultTool(eventType); + } + + /** + * 从MCP调用信息推断工具ID + */ + private inferMcpToolId(invocation: McpInvocation): string { + // 尝试从invocation中提取方法名 + const method = this.extractMethodFromInvocation(invocation); + if (!method) return ''; + + // 尝试匹配已注册的MCP工具 + for (const [toolId, tool] of this.mcpTools) { + if (toolId.endsWith(`/${method}`) || tool.name === method) { + return toolId; + } + } + + return ''; + } + + /** + * 从MCP调用中提取方法名 + */ + private extractMethodFromInvocation(invocation: McpInvocation): string { + // 根据实际的McpInvocation类型结构来提取方法名 + // 这里需要根据具体的类型定义来实现 + if ('method' in invocation && typeof invocation.method === 'string') { + return invocation.method; + } + if ('name' in invocation && typeof invocation.name === 'string') { + return invocation.name; + } + return ''; + } + + /** + * 创建通用MCP工具定义 + */ + private createGenericMcpTool(invocation?: McpInvocation): ToolDefinition { + const method = invocation ? this.extractMethodFromInvocation(invocation) || 'McpTool' : 'McpTool'; + + return { + id: `generic_mcp_${method}`, + name: method, + displayNameKey: 'tools.mcp.generic.displayName', + category: ToolCategory.CUSTOM, + priority: 200, + availability: { + platforms: ['darwin', 'linux', 'win32'], + experimental: true, + }, + capabilities: { + supportsStreaming: false, + supportsImages: true, + supportsCharts: true, + supportsMarkdown: true, + supportsInteraction: false, + outputFormats: [OutputFormat.TEXT, OutputFormat.MARKDOWN, OutputFormat.JSON], + }, + renderer: { + type: RendererType.STANDARD, + config: {}, + }, + icon: '🔌', + descriptionKey: 'tools.mcp.generic.description', + }; + } + + /** + * 检查工具是否可用 + */ + private isToolAvailable(tool: ToolDefinition): boolean { + const currentPlatform = process.platform; + return tool.availability.platforms.includes(currentPlatform); + } + + /** + * 获取默认工具 + */ + private getDefaultTool(eventType: CodexAgentEventType): ToolDefinition { + return { + id: 'unknown', + name: 'Unknown', + displayNameKey: 'tools.unknown.displayName', + category: ToolCategory.CUSTOM, + priority: 999, + availability: { + platforms: ['darwin', 'linux', 'win32'], + }, + capabilities: { + supportsStreaming: false, + supportsImages: false, + supportsCharts: false, + supportsMarkdown: false, + supportsInteraction: false, + outputFormats: [OutputFormat.TEXT], + }, + renderer: { + type: RendererType.STANDARD, + config: {}, + }, + icon: '❓', + descriptionKey: 'tools.unknown.description', + }; + } + + /** + * 获取所有已注册的工具 + */ + getAllTools(): ToolDefinition[] { + return [...Array.from(this.tools.values()), ...Array.from(this.mcpTools.values())]; + } + + /** + * 根据类别获取工具 + */ + getToolsByCategory(category: ToolCategory): ToolDefinition[] { + return this.getAllTools().filter((tool) => tool.category === category); + } + + /** + * 获取工具定义 + */ + getTool(id: string): ToolDefinition | undefined { + return this.tools.get(id) || this.mcpTools.get(id); + } + + /** + * 获取工具的本地化显示名称 + */ + getToolDisplayName(tool: ToolDefinition, fallbackParams?: Record): string { + try { + return i18n.t(tool.displayNameKey, fallbackParams || {}); + } catch { + // 如果没有找到翻译,返回工具名称 + return tool.name; + } + } + + /** + * 获取工具的本地化描述 + */ + getToolDescription(tool: ToolDefinition, fallbackParams?: Record): string { + try { + return i18n.t(tool.descriptionKey, fallbackParams || {}); + } catch { + // 如果没有找到翻译,返回基础描述 + return `Tool: ${tool.name}`; + } + } + + /** + * 为MCP工具生成本地化参数 + */ + getMcpToolI18nParams(tool: ToolDefinition): Record { + if (tool.id.includes('/')) { + const [serverName, toolName] = tool.id.split('/'); + return { toolName, serverName }; + } + return { toolName: tool.name }; + } +} diff --git a/src/common/index.ts b/src/common/index.ts index 6291d03f..a7348b25 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -5,3 +5,4 @@ */ export * as ipcBridge from './ipcBridge'; +export { conversation } from './ipcBridge'; diff --git a/src/common/ipcBridge.ts b/src/common/ipcBridge.ts index b96ac6d9..a5745c28 100644 --- a/src/common/ipcBridge.ts +++ b/src/common/ipcBridge.ts @@ -28,6 +28,7 @@ export const conversation = { remove: bridge.buildProvider('remove-conversation'), // 删除对话 reset: bridge.buildProvider('reset-conversation'), // 重置对话 stop: bridge.buildProvider, { conversation_id: string }>('chat.stop.stream'), // 停止会话 + confirmMessage: bridge.buildProvider('conversation.confirm.message'), // 通用确认消息 }; // gemini对话相关接口 @@ -83,11 +84,23 @@ export const acpConversation = { // clearAllCache: bridge.buildProvider, void>('acp.clear.all.cache'), }; +// Codex 对话相关接口(MCP 直连,会与 ACP/Gemini 并存) +const codexSendMessage = bridge.buildProvider, ISendMessageParams>('codex.send.message'); +const codexResponseStream = bridge.buildEmitter('codex.response.stream'); + +export const codexConversation = { + sendMessage: codexSendMessage, + confirmMessage: bridge.buildProvider('codex.input.confirm.message'), + responseStream: codexResponseStream, + getWorkspace: bridge.buildProvider('codex.get-workspace'), +}; + interface ISendMessageParams { input: string; msg_id: string; conversation_id: string; files?: string[]; + loading_id?: string; } interface IConfirmGeminiMessageParams { @@ -105,7 +118,7 @@ export interface IConfirmAcpMessageParams { } export interface ICreateConversationParams { - type: 'gemini' | 'acp'; + type: 'gemini' | 'acp' | 'codex'; id?: string; name?: string; model: TProviderWithModel; diff --git a/src/common/storage.ts b/src/common/storage.ts index 1721af7b..0ad33647 100644 --- a/src/common/storage.ts +++ b/src/common/storage.ts @@ -84,6 +84,17 @@ export type TChatConversation = } >, 'model' + > + | Omit< + IChatConversation< + 'codex', + { + workspace?: string; + cliPath?: string; + customWorkspace?: boolean; + } + >, + 'model' >; export type IChatConversationRefer = { diff --git a/src/common/utils.ts b/src/common/utils.ts index 6f62c06f..b82b493e 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -5,9 +5,42 @@ */ export const uuid = (length = 8) => { - return Math.random() - .toString(36) - .substring(2, 2 + length); + try { + // Prefer Web Crypto API for browser compatibility + if (typeof window !== 'undefined' && window.crypto && window.crypto.randomUUID && length >= 36) { + return window.crypto.randomUUID(); + } + + // Use Web Crypto getRandomValues for browser environment + if (typeof window !== 'undefined' && window.crypto && window.crypto.getRandomValues) { + const bytes = new Uint8Array(Math.ceil(length / 2)); + window.crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')) + .join('') + .slice(0, length); + } + + // Node.js environment - use dynamic import to avoid webpack bundling + if (typeof process !== 'undefined' && process.versions && process.versions.node) { + try { + // Dynamic require to avoid webpack bundling issues + const cryptoModule = eval('require')('crypto'); + if (typeof cryptoModule.randomUUID === 'function' && length >= 36) { + return cryptoModule.randomUUID(); + } + const bytes = cryptoModule.randomBytes(Math.ceil(length / 2)); + return bytes.toString('hex').slice(0, length); + } catch { + // Fall through to fallback + } + } + } catch { + // Fallback without crypto + } + + // Monotonic fallback without cryptographically secure randomness + const base = Date.now().toString(36); + return (base + base).slice(0, length); }; export const parseError = (error: any): string => { diff --git a/src/common/utils/appConfig.ts b/src/common/utils/appConfig.ts new file mode 100644 index 00000000..7579f4eb --- /dev/null +++ b/src/common/utils/appConfig.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +// Configuration for app info - to be set by the caller in main process +let appConfig: { name: string; version: string; protocolVersion: string } | null = null; + +/** + * Function to set app info using Electron API in main process + * This allows direct use of app.getName() and app.getVersion() in main process + */ +export function setAppConfig(config: { name: string; version: string; protocolVersion?: string }) { + appConfig = { + name: config.name, + version: config.version, + protocolVersion: config.protocolVersion || '1.0.0', + }; +} + +/** + * Gets the application client name from the app config if available + */ +export const getConfiguredAppClientName = (): string => { + return appConfig?.name || 'AionUi'; +}; + +/** + * Gets the application client version from the app config if available + */ +export const getConfiguredAppClientVersion = (): string => { + return appConfig?.version || 'unknown'; +}; + +/** + * Gets the Codex MCP protocol version from the app config if available + */ +export const getConfiguredCodexMcpProtocolVersion = (): string => { + return appConfig?.protocolVersion || '1.0.0'; +}; diff --git a/src/process/WorkerManage.ts b/src/process/WorkerManage.ts index 458bc9f1..43f86ee1 100644 --- a/src/process/WorkerManage.ts +++ b/src/process/WorkerManage.ts @@ -6,6 +6,7 @@ import type { TChatConversation } from '@/common/storage'; import AcpAgentManager from './task/AcpAgentManager'; +import { CodexAgentManager } from '@/agent/codex'; // import type { AcpAgentTask } from './task/AcpAgentTask'; import { ProcessChat } from './initStorage'; import type AgentBaseTask from './task/BaseAgentManager'; @@ -45,6 +46,11 @@ const buildConversation = (conversation: TChatConversation) => { taskList.push({ id: conversation.id, task }); return task; } + case 'codex': { + const task = new CodexAgentManager({ ...conversation.extra, conversation_id: conversation.id }); + taskList.push({ id: conversation.id, task }); + return task; + } default: { // Type assertion to help TypeScript understand that conversation has a type property const unknownConversation = conversation as TChatConversation; diff --git a/src/process/initAgent.ts b/src/process/initAgent.ts index 860496fa..83dd397b 100644 --- a/src/process/initAgent.ts +++ b/src/process/initAgent.ts @@ -63,3 +63,21 @@ export const createAcpAgent = async (options: ICreateConversationParams): Promis id: uuid(), }; }; + +export const createCodexAgent = async (options: ICreateConversationParams): Promise => { + const { extra } = options; + const { workspace, customWorkspace } = await buildWorkspaceWidthFiles(`codex-temp-${Date.now()}`, extra.workspace, extra.defaultFiles); + return { + type: 'codex', + extra: { + workspace: workspace, + customWorkspace, + cliPath: extra.cliPath, + sandboxMode: 'workspace-write', // 默认为读写权限 + }, + createTime: Date.now(), + modifyTime: Date.now(), + name: workspace, + id: uuid(), + } as any; +}; diff --git a/src/process/initBridge.ts b/src/process/initBridge.ts index 288716db..f09a45a8 100644 --- a/src/process/initBridge.ts +++ b/src/process/initBridge.ts @@ -15,7 +15,8 @@ import fs from 'fs/promises'; import OpenAI from 'openai'; import path from 'path'; import { ipcBridge } from '../common'; -import { createAcpAgent, createGeminiAgent } from './initAgent'; +import { createAcpAgent, createCodexAgent, createGeminiAgent } from './initAgent'; +import type { CodexAgentManager } from '@/agent/codex'; import { getSystemDir, ProcessChat, ProcessChatMessage, ProcessConfig, ProcessEnv } from './initStorage'; import { nextTickToLocalFinish } from './message'; import type AcpAgentManager from './task/AcpAgentManager'; @@ -171,6 +172,7 @@ ipcBridge.conversation.create.provider(async (params): Promise { if (type === 'gemini') return createGeminiAgent(model, extra.workspace, extra.defaultFiles, extra.webSearchEngine); if (type === 'acp') return createAcpAgent(params); + if (type === 'codex') return createCodexAgent(params); throw new Error('Invalid conversation type'); }; try { @@ -297,6 +299,55 @@ ipcBridge.acpConversation.sendMessage.provider(async ({ conversation_id, files, }); }); +// Codex 专用的 sendMessage provider +ipcBridge.codexConversation.sendMessage.provider(async ({ conversation_id, files, ...other }) => { + const task = (await WorkerManage.getTaskByIdRollbackBuild(conversation_id)) as CodexAgentManager | undefined; + if (!task) return { success: false, msg: 'conversation not found' }; + if (task.type !== 'codex') return { success: false, msg: 'unsupported task type for Codex provider' }; + await copyFilesToDirectory(task.workspace, files); + return task + .sendMessage({ content: other.input, files, msg_id: other.msg_id }) + .then(() => ({ success: true })) + .catch((err: unknown) => ({ success: false, msg: err instanceof Error ? err.message : String(err) })); +}); + +// 通用 confirmMessage 实现 - 自动根据 conversation 类型分发 +ipcBridge.conversation.confirmMessage.provider(async ({ confirmKey, msg_id, conversation_id, callId }) => { + const task = WorkerManage.getTaskById(conversation_id); + if (!task) return { success: false, msg: 'conversation not found' }; + + try { + // 根据 task 类型调用对应的 confirmMessage 方法 + if (task?.type === 'codex') { + await (task as CodexAgentManager).confirmMessage({ confirmKey, msg_id, callId }); + return { success: true }; + } else if (task.type === 'gemini') { + await (task as GeminiAgentManager).confirmMessage({ confirmKey, msg_id, callId }); + return { success: true }; + } else if (task.type === 'acp') { + await (task as AcpAgentManager).confirmMessage({ confirmKey, msg_id, callId }); + return { success: true }; + } else { + return { success: false, msg: `Unsupported task type: ${task.type}` }; + } + } catch (e: unknown) { + return { success: false, msg: e instanceof Error ? e.message : String(e) }; + } +}); + +// 保留现有的特定 confirmMessage 实现以维持向后兼容性 +ipcBridge.codexConversation.confirmMessage.provider(async ({ confirmKey, msg_id, conversation_id, callId }) => { + const task = WorkerManage.getTaskById(conversation_id) as CodexAgentManager | undefined; + if (!task) return { success: false, msg: 'conversation not found' }; + if (task.type !== 'codex') return { success: false, msg: 'not support' }; + try { + await task.confirmMessage({ confirmKey, msg_id, callId }); + return { success: true }; + } catch (e: unknown) { + return { success: false, msg: e instanceof Error ? e.message : String(e) }; + } +}); + ipcBridge.geminiConversation.confirmMessage.provider(async ({ confirmKey, msg_id, conversation_id, callId }) => { const task = WorkerManage.getTaskById(conversation_id) as GeminiAgentManager; if (!task) return { success: false, msg: 'conversation not found' }; @@ -376,7 +427,11 @@ ipcBridge.acpConversation.detectCliPath.provider(async ({ backend }) => { ipcBridge.conversation.stop.provider(async ({ conversation_id }) => { const task = WorkerManage.getTaskById(conversation_id); if (!task) return { success: true, msg: 'conversation not found' }; - if (task.type !== 'gemini' && task.type !== 'acp') return { success: false, msg: 'not support' }; + if (task.type !== 'gemini' && task.type !== 'acp' && task.type !== 'codex') + return { + success: false, + msg: 'not support', + }; return task.stop().then(() => ({ success: true })); }); @@ -387,7 +442,7 @@ ipcBridge.geminiConversation.getWorkspace.provider(async ({ conversation_id }) = }); // ACP 的 getWorkspace 实现 -ipcBridge.acpConversation.getWorkspace.provider(async ({ conversation_id }) => { +const buildWorkspaceFileTree = async (conversation_id: string) => { try { const task = (await WorkerManage.getTaskByIdRollbackBuild(conversation_id)) as AcpAgentManager; if (!task) return []; @@ -401,9 +456,9 @@ ipcBridge.acpConversation.getWorkspace.provider(async ({ conversation_id }) => { return []; } - // 读取目录内容 + // 递归构建文件树 const buildFileTree = (dirPath: string, basePath: string = dirPath): any[] => { - const result = []; + const result: any[] = []; const items = fs.readdirSync(dirPath); for (const item of items) { @@ -446,8 +501,8 @@ ipcBridge.acpConversation.getWorkspace.provider(async ({ conversation_id }) => { const files = buildFileTree(workspace); - // 返回的格式需要与 gemini 保持一致 - const result = [ + // 返回根目录包装的结果 + return [ { name: path.basename(workspace), path: workspace, @@ -456,11 +511,19 @@ ipcBridge.acpConversation.getWorkspace.provider(async ({ conversation_id }) => { children: files, }, ]; - - return result; } catch (error) { return []; } +}; + +// ACP getWorkspace 使用通用方法 +ipcBridge.acpConversation.getWorkspace.provider(async ({ conversation_id }) => { + return await buildWorkspaceFileTree(conversation_id); +}); + +// Codex getWorkspace 使用通用方法 +ipcBridge.codexConversation.getWorkspace.provider(async ({ conversation_id }) => { + return await buildWorkspaceFileTree(conversation_id); }); ipcBridge.googleAuth.status.provider(async ({ proxy }) => { @@ -504,7 +567,11 @@ ipcBridge.googleAuth.logout.provider(async () => { return clearCachedCredentialFile(); }); -ipcBridge.mode.fetchModelList.provider(async function fetchModelList({ base_url, api_key, try_fix, platform }): Promise<{ success: boolean; msg?: string; data?: { mode: Array; fix_base_url?: string } }> { +ipcBridge.mode.fetchModelList.provider(async function fetchModelList({ base_url, api_key, try_fix, platform }): Promise<{ + success: boolean; + msg?: string; + data?: { mode: Array; fix_base_url?: string }; +}> { // 如果是多key(包含逗号或回车),只取第一个key来获取模型列表 let actualApiKey = api_key; if (api_key && (api_key.includes(',') || api_key.includes('\n'))) { diff --git a/src/process/initStorage.ts b/src/process/initStorage.ts index a6f4e4b5..59c18266 100644 --- a/src/process/initStorage.ts +++ b/src/process/initStorage.ts @@ -169,7 +169,7 @@ const JsonFileBuilder = >(path: string) => { return parsed; } catch (e) { - console.error(`[Storage] Error reading/parsing file ${path}:`, e); + // console.error(`[Storage] Error reading/parsing file ${path}:`, e); return {} as S; } }; diff --git a/src/process/task/BaseAgentManager.ts b/src/process/task/BaseAgentManager.ts index 7bc70673..0d8c6806 100644 --- a/src/process/task/BaseAgentManager.ts +++ b/src/process/task/BaseAgentManager.ts @@ -7,7 +7,7 @@ import { ForkTask } from '@/worker/fork/ForkTask'; import path from 'path'; -type AgentType = 'gemini' | 'acp'; +type AgentType = 'gemini' | 'acp' | 'codex'; /** * @description agent任务基础类 diff --git a/src/process/task/CodexAgentManager.ts b/src/process/task/CodexAgentManager.ts new file mode 100644 index 00000000..0e5363df --- /dev/null +++ b/src/process/task/CodexAgentManager.ts @@ -0,0 +1,405 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CodexMcpAgent } from '@/agent/codex'; +import type { NetworkError } from '@/agent/codex/connection/CodexMcpConnection'; +import { ipcBridge } from '@/common'; +import type { TMessage } from '@/common/chatLib'; +import { transformMessage } from '@/common/chatLib'; +import type { IResponseMessage } from '@/common/ipcBridge'; +import { uuid } from '@/common/utils'; +import { addMessage, addOrUpdateMessage } from '@process/message'; +import BaseAgentManager from '@process/task/BaseAgentManager'; +import { t } from 'i18next'; +import { CodexEventHandler } from '@/agent/codex/handlers/CodexEventHandler'; +import { CodexSessionManager } from '@/agent/codex/handlers/CodexSessionManager'; +import { CodexFileOperationHandler } from '@/agent/codex/handlers/CodexFileOperationHandler'; +import type { CodexAgentManagerData, FileChange } from '@/common/codex/types'; +import type { ICodexMessageEmitter } from '@/agent/codex/messaging/CodexMessageEmitter'; +import { getConfiguredAppClientName, getConfiguredAppClientVersion, getConfiguredCodexMcpProtocolVersion, setAppConfig } from '../../common/utils/appConfig'; +import { mapPermissionDecision } from '@/common/codex/utils'; + +const APP_CLIENT_NAME = getConfiguredAppClientName(); +const APP_CLIENT_VERSION = getConfiguredAppClientVersion(); +const CODEX_MCP_PROTOCOL_VERSION = getConfiguredCodexMcpProtocolVersion(); + +class CodexAgentManager extends BaseAgentManager implements ICodexMessageEmitter { + workspace?: string; + agent: CodexMcpAgent; + bootstrap: Promise; + private isFirstMessage: boolean = true; + + constructor(data: CodexAgentManagerData) { + // Do not fork a worker for Codex; we run the agent in-process now + super('codex', data); + this.conversation_id = data.conversation_id; + this.workspace = data.workspace; + + this.initAgent(data); + } + + private initAgent(data: CodexAgentManagerData) { + // 初始化各个管理器 - 参考 ACP 的架构,传递消息发送器 + const eventHandler = new CodexEventHandler(data.conversation_id, this); + const sessionManager = new CodexSessionManager( + { + conversation_id: data.conversation_id, + cliPath: data.cliPath, + workingDir: data.workspace || process.cwd(), + }, + this + ); + const fileOperationHandler = new CodexFileOperationHandler(data.workspace || process.cwd(), data.conversation_id, this); + + // 设置 Codex Agent 的应用配置,使用 Electron API 在主进程中 + (async () => { + try { + const electronModule = await import('electron'); + const app = electronModule.app; + setAppConfig({ + name: app.getName(), + version: app.getVersion(), + protocolVersion: CODEX_MCP_PROTOCOL_VERSION, + }); + } catch (error) { + // 如果不在主进程中,使用通用方法获取版本 + setAppConfig({ + name: APP_CLIENT_NAME, + version: APP_CLIENT_VERSION, + protocolVersion: CODEX_MCP_PROTOCOL_VERSION, + }); + } + })(); + + this.agent = new CodexMcpAgent({ + id: data.conversation_id, + cliPath: data.cliPath, + workingDir: data.workspace || process.cwd(), + eventHandler, + sessionManager, + fileOperationHandler, + sandboxMode: data.sandboxMode || 'workspace-write', // Enable file writing within workspace by default + webSearchEnabled: data.webSearchEnabled ?? true, // Enable web search by default + onNetworkError: (error) => { + this.handleNetworkError(error); + }, + }); + + // 使用 SessionManager 来管理连接状态 - 参考 ACP 的模式 + this.bootstrap = this.startWithSessionManagement() + .then(async () => { + return this.agent; + }) + .catch((e) => { + this.agent.getSessionManager().emitSessionEvent('bootstrap_failed', { error: e.message }); + throw e; + }); + } + + /** + * 使用会话管理器启动 - 参考 ACP 的启动流程 + */ + private async startWithSessionManagement(): Promise { + // 1. 启动会话管理器 + await this.agent.getSessionManager().startSession(); + + // 2. 启动 MCP Agent + await this.agent.start(); + + // 3. 执行认证和会话创建 + await this.performPostConnectionSetup(); + } + + /** + * 连接后设置 - 参考 ACP 的认证和会话创建 + */ + private async performPostConnectionSetup(): Promise { + try { + // Get connection diagnostics + this.getDiagnostics(); + + // 延迟会话创建到第一条用户消息时,避免空 prompt 问题 + // Session will be created with first user message - no session event sent here + } catch (error) { + // 输出更详细的诊断信息 + const diagnostics = this.getDiagnostics(); + + // 提供具体的错误信息和建议 + const errorMessage = error instanceof Error ? error.message : String(error); + let suggestions: string[] = []; + + if (errorMessage.includes('timed out')) { + suggestions = ['Check if Codex CLI is installed: run "codex --version"', 'Verify authentication: run "codex auth status"', 'Check network connectivity', 'Try restarting the application']; + } else if (errorMessage.includes('command not found')) { + suggestions = ['Install Codex CLI: https://codex.com/install', 'Add Codex to your PATH environment variable', 'Restart your terminal/application after installation']; + } else if (errorMessage.includes('authentication')) { + suggestions = ['Run "codex auth" to authenticate with your account', 'Check if your authentication token is valid', 'Try logging out and logging back in']; + } + + // Log troubleshooting suggestions for debugging + + // 即使设置失败,也尝试继续运行,因为连接可能仍然有效 + this.agent.getSessionManager().emitSessionEvent('session_partial', { + workspace: this.workspace, + agent_type: 'codex', + error: errorMessage, + diagnostics, + suggestions, + }); + + // 不抛出错误,让应用程序继续运行 + return; + } + } + + async sendMessage(data: { content: string; files?: string[]; msg_id?: string }) { + try { + await this.bootstrap; + + // Save user message to chat history only (renderer already inserts right-hand bubble) + if (data.msg_id && data.content) { + const userMessage: TMessage = { + id: data.msg_id, + msg_id: data.msg_id, + type: 'text', + position: 'right', + conversation_id: this.conversation_id, + content: { content: data.content }, + createdAt: Date.now(), + }; + addMessage(this.conversation_id, userMessage); + } + + // 处理文件引用 - 参考 ACP 的文件引用处理 + const processedContent = this.agent.getFileOperationHandler().processFileReferences(data.content, data.files); + + // 如果是第一条消息,通过 newSession 发送以避免双消息问题 + if (this.isFirstMessage) { + this.isFirstMessage = false; + const result = await this.agent.newSession(this.workspace, processedContent); + + // Session created successfully - Codex will send session_configured event automatically + + return result; + } else { + // 后续消息使用正常的 sendPrompt + const result = await this.agent.sendPrompt(processedContent); + return result; + } + } catch (e) { + // 对于某些错误类型,避免重复错误消息处理 + // 这些错误通常已经通过 MCP 连接的事件流处理过了 + const errorMsg = e instanceof Error ? e.message : String(e); + const isUsageLimitError = errorMsg.toLowerCase().includes("you've hit your usage limit"); + + if (isUsageLimitError) { + // Usage limit 错误已经通过 MCP 事件流处理,避免重复发送 + throw e; + } + + // Create more descriptive error message based on error type + let errorMessage = 'Failed to send message to Codex'; + if (e instanceof Error) { + if (e.message.includes('timeout')) { + errorMessage = 'Request timed out. Please check your connection and try again.'; + } else if (e.message.includes('authentication')) { + errorMessage = 'Authentication failed. Please verify your Codex credentials.'; + } else if (e.message.includes('network')) { + errorMessage = 'Network error. Please check your internet connection.'; + } else { + errorMessage = `Codex error: ${e.message}`; + } + } + + const message: IResponseMessage = { + type: 'error', + conversation_id: this.conversation_id, + msg_id: data.msg_id || uuid(), + data: errorMessage, + }; + addMessage(this.conversation_id, transformMessage(message)); + ipcBridge.codexConversation.responseStream.emit(message); + throw e; + } + } + + async confirmMessage(data: { confirmKey: string; msg_id: string; callId: string }) { + await this.bootstrap; + this.agent.getEventHandler().getToolHandlers().removePendingConfirmation(data.callId); + + // Use standardized permission decision mapping + const decision = mapPermissionDecision(data.confirmKey as any) as 'approved' | 'approved_for_session' | 'denied' | 'abort'; + const isApproved = decision === 'approved' || decision === 'approved_for_session'; + + // Apply patch changes if available and approved + const changes = this.agent.getEventHandler().getToolHandlers().getPatchChanges(data.callId); + if (changes && isApproved) { + await this.applyPatchChanges(data.callId, changes); + } + + // Normalize call id back to server's codex_call_id + // Handle the new unified permission_ prefix as well as legacy prefixes + const origCallId = data.callId.startsWith('permission_') + ? data.callId.substring(11) // Remove 'permission_' prefix + : data.callId.startsWith('patch_') + ? data.callId.substring(6) + : data.callId.startsWith('elicitation_') + ? data.callId.substring(12) + : data.callId.startsWith('exec_') + ? data.callId.substring(5) + : data.callId; + + // Respond to elicitation (server expects JSON-RPC response) + this.agent.respondElicitation(origCallId, decision); + + // Also resolve local pause gate to resume queued requests + this.agent.resolvePermission(origCallId, isApproved); + return; + } + + private async applyPatchChanges(callId: string, changes: Record): Promise { + try { + // 使用文件操作处理器来应用更改 - 参考 ACP 的批量操作 + await this.agent.getFileOperationHandler().applyBatchChanges(changes); + + // 发送成功事件 + this.agent.getSessionManager().emitSessionEvent('patch_applied', { + callId, + changeCount: Object.keys(changes).length, + files: Object.keys(changes), + }); + + // Patch changes applied successfully + } catch (error) { + // 发送失败事件 + this.agent.getSessionManager().emitSessionEvent('patch_failed', { + callId, + error: error instanceof Error ? error.message : String(error), + }); + + throw error; + } + } + + private handleNetworkError(error: NetworkError): void { + // Emit network error as status message + this.emitStatus('error', `Network Error: ${error.suggestedAction}`); + + // Create a user-friendly error message based on error type + let userMessage = ''; + let recoveryActions: string[] = []; + + switch (error.type) { + case 'cloudflare_blocked': + userMessage = t('codex.network.cloudflare_blocked_title', { service: 'Codex' }); + recoveryActions = t('codex.network.recovery_actions.cloudflare_blocked', { returnObjects: true }) as string[]; + break; + + case 'network_timeout': + userMessage = t('codex.network.network_timeout_title'); + recoveryActions = t('codex.network.recovery_actions.network_timeout', { returnObjects: true }) as string[]; + break; + + case 'connection_refused': + userMessage = t('codex.network.connection_refused_title'); + recoveryActions = t('codex.network.recovery_actions.connection_refused', { returnObjects: true }) as string[]; + break; + + default: + userMessage = t('codex.network.unknown_error_title'); + recoveryActions = t('codex.network.recovery_actions.unknown', { returnObjects: true }) as string[]; + } + + const detailedMessage = `${userMessage}\n\n${t('codex.network.recovery_suggestions')}\n${recoveryActions.join('\n')}\n\n${t('codex.network.technical_info')}\n- ${t('codex.network.error_type')}:${error.type}\n- ${t('codex.network.retry_count')}:${error.retryCount}\n- ${t('codex.network.error_details')}:${error.originalError.substring(0, 200)}${error.originalError.length > 200 ? '...' : ''}`; + + // Emit network error message to UI + const networkErrorMessage: IResponseMessage = { + type: 'tips', + conversation_id: this.conversation_id, + msg_id: uuid(), + data: { + error: error, + title: userMessage, + message: detailedMessage, + recoveryActions: recoveryActions, + quickSwitchContent: t('codex.network.quick_switch_content'), + }, + }; + + // Emit network error message to UI + // Add to message history and emit to UI + addOrUpdateMessage(this.conversation_id, transformMessage(networkErrorMessage)); + ipcBridge.codexConversation.responseStream.emit(networkErrorMessage); + } + + private emitStatus(status: 'connecting' | 'connected' | 'authenticated' | 'session_active' | 'error' | 'disconnected', message: string) { + const statusMessage: IResponseMessage = { + type: 'codex_status', + conversation_id: this.conversation_id, + msg_id: uuid(), + data: { + status, + message, + }, + }; + ipcBridge.codexConversation.responseStream.emit(statusMessage); + } + + getDiagnostics() { + const agentDiagnostics = this.agent.getDiagnostics(); + const sessionInfo = this.agent.getSessionManager().getSessionInfo(); + + return { + agent: agentDiagnostics, + session: sessionInfo, + workspace: this.workspace, + conversation_id: this.conversation_id, + }; + } + + cleanup() { + // 清理所有管理器 - 参考 ACP 的清理模式 + this.agent.getEventHandler().cleanup(); + this.agent.getSessionManager().cleanup(); + this.agent.getFileOperationHandler().cleanup(); + + // 停止 agent + this.agent?.stop?.(); + + // Cleanup completed + } + + // Stop current Codex stream in-process (override ForkTask default which targets a worker) + stop() { + return this.agent?.stop?.() ?? Promise.resolve(); + } + + // Ensure we clean up agent resources on kill + kill() { + try { + this.agent?.stop?.(); + } finally { + super.kill(); + } + } + + emitAndPersistMessage(message: IResponseMessage, persist: boolean = true): void { + if (persist) { + if (message.type === 'codex_status') { + ipcBridge.codexConversation.responseStream.emit(message); + return; + } + // Use Codex-specific transformer for Codex messages + const transformedMessage: TMessage = transformMessage(message); + if (transformedMessage) { + addOrUpdateMessage(this.conversation_id, transformedMessage); + } + } + ipcBridge.codexConversation.responseStream.emit(message); + } +} + +export default CodexAgentManager; diff --git a/src/renderer/assets/logos/codex.svg b/src/renderer/assets/logos/codex.svg new file mode 100644 index 00000000..a27458f4 --- /dev/null +++ b/src/renderer/assets/logos/codex.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/renderer/components/ThoughtDisplay.tsx b/src/renderer/components/ThoughtDisplay.tsx new file mode 100644 index 00000000..9b656dde --- /dev/null +++ b/src/renderer/components/ThoughtDisplay.tsx @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Tag } from '@arco-design/web-react'; +import React from 'react'; + +export interface ThoughtData { + subject: string; + description: string; +} + +interface ThoughtDisplayProps { + thought: ThoughtData; + style?: 'default' | 'compact'; +} + +const ThoughtDisplay: React.FC = ({ thought, style = 'default' }) => { + if (!thought.subject) { + return null; + } + + const baseStyle = { + background: 'linear-gradient(90deg, #F0F3FF 0%, #F2F2F2 100%)', + }; + + const defaultStyle = { + ...baseStyle, + transform: 'translateY(36px)', + }; + + const compactStyle = { + ...baseStyle, + marginBottom: '-36px', + maxHeight: '100px', + overflow: 'scroll' as const, + }; + + return ( +
+ + {thought.subject} + + {thought.description} +
+ ); +}; + +export default ThoughtDisplay; diff --git a/src/renderer/components/sendbox.tsx b/src/renderer/components/sendbox.tsx index caf7e4bc..b02a9dc5 100644 --- a/src/renderer/components/sendbox.tsx +++ b/src/renderer/components/sendbox.tsx @@ -29,8 +29,7 @@ const SendBox: React.FC<{ placeholder?: string; onFilesAdded?: (files: FileMetadata[]) => void; supportedExts?: string[]; - componentId?: string; -}> = ({ onSend, onStop, prefix, className, loading, tools, disabled, placeholder, value: input = '', onChange: setInput = constVoid, onFilesAdded, supportedExts = allSupportedExts, componentId = 'default' }) => { +}> = ({ onSend, onStop, prefix, className, loading, tools, disabled, placeholder, value: input = '', onChange: setInput = constVoid, onFilesAdded, supportedExts = allSupportedExts }) => { const { t } = useTranslation(); const [isLoading, setIsLoading] = useState(false); @@ -46,12 +45,26 @@ const SendBox: React.FC<{ const { compositionHandlers, createKeyDownHandler } = useCompositionInput(); // 使用共享的PasteService集成 - const { handleFocus } = usePasteService({ - componentId, + const { onPaste, onFocus } = usePasteService({ supportedExts, onFilesAdded, - setInput, - input, + onTextPaste: (text: string) => { + // 处理清理后的文本粘贴,在当前光标位置插入文本而不是替换整个内容 + const textarea = document.activeElement as HTMLTextAreaElement; + if (textarea && textarea.tagName === 'TEXTAREA') { + const cursorPosition = textarea.selectionStart; + const currentValue = textarea.value; + const newValue = currentValue.slice(0, cursorPosition) + text + currentValue.slice(cursorPosition); + setInput(newValue); + // 设置光标到插入文本后的位置 + setTimeout(() => { + textarea.setSelectionRange(cursorPosition + text.length, cursorPosition + text.length); + }, 0); + } else { + // 如果无法获取光标位置,回退到追加到末尾的行为 + setInput(input + text); + } + }, }); const sendMessageHandler = () => { @@ -93,7 +106,8 @@ const SendBox: React.FC<{ onChange={(v) => { setInput(v); }} - onFocus={handleFocus} + onPaste={onPaste} + onFocus={onFocus} {...compositionHandlers} autoSize={{ minRows: 1, maxRows: 10 }} onKeyDown={createKeyDownHandler(sendMessageHandler)} diff --git a/src/renderer/hooks/usePasteService.ts b/src/renderer/hooks/usePasteService.ts index 8ecd9c90..afdfa93a 100644 --- a/src/renderer/hooks/usePasteService.ts +++ b/src/renderer/hooks/usePasteService.ts @@ -1,28 +1,31 @@ -import { useCallback, useEffect } from 'react'; -import { PasteService } from '@/renderer/services/PasteService'; import type { FileMetadata } from '@/renderer/services/FileService'; +import { PasteService } from '@/renderer/services/PasteService'; +import { useCallback, useEffect, useRef } from 'react'; +import { uuid } from '../utils/common'; interface UsePasteServiceProps { - componentId: string; supportedExts: string[]; onFilesAdded?: (files: FileMetadata[]) => void; - setInput?: (value: string) => void; - input?: string; + onTextPaste?: (text: string) => void; } /** - * 共享的PasteService集成hook - * 消除SendBox组件和GUID页面中的PasteService集成重复代码 + * 通用的PasteService集成hook + * 为所有组件提供统一的粘贴处理功能 */ -export const usePasteService = ({ componentId, supportedExts, onFilesAdded, setInput, input }: UsePasteServiceProps) => { - // 粘贴事件处理 +export const usePasteService = ({ supportedExts, onFilesAdded, onTextPaste }: UsePasteServiceProps) => { + const componentId = useRef('paste-service-' + uuid(4)).current; + // 统一的粘贴事件处理 const handlePaste = useCallback( - async (event: ClipboardEvent) => { - if (!onFilesAdded) return false; - - return await PasteService.handlePaste(event, supportedExts, onFilesAdded, setInput, input); + async (event: React.ClipboardEvent) => { + const handled = await PasteService.handlePaste(event, supportedExts, onFilesAdded || (() => {}), onTextPaste); + if (handled) { + event.preventDefault(); + event.stopPropagation(); + } + return handled; }, - [supportedExts, onFilesAdded, setInput, input] + [supportedExts, onFilesAdded, onTextPaste] ); // 焦点处理 @@ -30,7 +33,7 @@ export const usePasteService = ({ componentId, supportedExts, onFilesAdded, setI PasteService.setLastFocusedComponent(componentId); }, [componentId]); - // PasteService集成 + // 注册粘贴处理器 useEffect(() => { PasteService.init(); PasteService.registerHandler(componentId, handlePaste); @@ -41,6 +44,7 @@ export const usePasteService = ({ componentId, supportedExts, onFilesAdded, setI }, [componentId, handlePaste]); return { - handleFocus, + onFocus: handleFocus, + onPaste: handlePaste, }; }; diff --git a/src/renderer/hooks/useSendBoxDraft.ts b/src/renderer/hooks/useSendBoxDraft.ts index 575d3640..742a36f8 100644 --- a/src/renderer/hooks/useSendBoxDraft.ts +++ b/src/renderer/hooks/useSendBoxDraft.ts @@ -19,6 +19,12 @@ type Draft = content: string; atPath: string[]; uploadFile: string[]; + } + | { + _type: 'codex'; + content: string; + atPath: string[]; + uploadFile: string[]; }; /** @@ -31,6 +37,7 @@ type SendBoxDraftStore = { const store: SendBoxDraftStore = { gemini: new Map(), acp: new Map(), + codex: new Map(), }; const setDraft = (type: K, conversation_id: string, draft: Extract | undefined) => { @@ -50,6 +57,13 @@ const setDraft = (type: K, conversation_id: store.acp.delete(conversation_id); } break; + case 'codex': + if (draft) { + store.codex.set(conversation_id, draft as Extract); + } else { + store.codex.delete(conversation_id); + } + break; default: break; } @@ -62,6 +76,8 @@ const getDraft = (type: K, conversation_id: return store.gemini.get(conversation_id) as Extract; case 'acp': return store.acp.get(conversation_id) as Extract; + case 'codex': + return store.codex.get(conversation_id) as Extract; default: return undefined; } diff --git a/src/renderer/i18n/locales/en-US.json b/src/renderer/i18n/locales/en-US.json index 658ab410..ed01c17e 100644 --- a/src/renderer/i18n/locales/en-US.json +++ b/src/renderer/i18n/locales/en-US.json @@ -20,7 +20,12 @@ "version": "Version", "contact": "Contact", "github": "Github", - "loading": "Please wait..." + "loading": "Please wait...", + "retry": "Retry", + "reload": "Reload", + "technical_details": "Technical Details", + "error_details": "Error Details", + "troubleshooting": "Troubleshooting" }, "conversation": { "welcome": { @@ -118,14 +123,199 @@ "confirm": "Confirm", "canceledExecution": "Execution canceled", "openLinkFailed": "Failed to open link", - "imageGenerationModelDetected": "Image model detected, image tool has been automatically enabled. Default model: {{platform}}:{{model}}" + "imageGenerationModelDetected": "Image model detected, image tool has been automatically enabled. Default model: {{platform}}:{{model}}", + "auto_handling_permissions": "Auto handling permission request: {{request}}" }, "acp": { "auth": { - "failed": "{{backend}} authentication failed:\n\n{{error}}\n\nPlease check your local CLI tool authentication status" + "failed": "{{backend}} authentication failed:\n\n{{error}}\n\nPlease check your local CLI tool authentication status", + "console_error": "ACP authentication error details:", + "failed_confirm": "ACP {{backend}} authentication failed:\n\n{{error}}\n\nWould you like to go to settings page to configure now?" }, "sendbox": { "placeholder": "Send message to {{backend}}..." } + }, + "codex": { + "sendbox": {}, + "network": { + "cloudflare_blocked": "Cloudflare protection blocked access to {{service}}. Suggestions: 1) Use VPN 2) Switch network 3) Retry later 4) Switch to other AI services", + "network_timeout": "Network timeout, automatically retrying connection...", + "connection_refused": "Connection refused, please check if Codex service is running properly.", + "unknown_error": "Network connection error, please check network settings.", + "retry_attempt": "Retrying attempt {{current}}...", + "cloudflare_blocked_title": "🚫 {{service}} service blocked by Cloudflare protection", + "network_timeout_title": "⏱️ Network connection timeout", + "connection_refused_title": "❌ Service connection refused", + "unknown_error_title": "🔌 Network connection error", + "recovery_suggestions": "**Suggested Solutions:**", + "technical_info": "**Technical Information:**", + "error_type": "Error Type", + "retry_count": "Retry Count", + "error_details": "Error Details", + "quick_switch_title": "💡 **Quick Switch Options:**", + "quick_switch_content": "When current service encounters network restrictions, you can:\n\n1. **Switch AI Assistant Immediately**: Select other available assistants from the left panel\n2. **Check Service Status**: Availability of different AI services may vary by region\n3. **Network Optimization**: Use stable network environment to improve connection success rate\n4. **Retry Later**: Network restrictions are usually temporary\n\n**Tip**: It's recommended to configure multiple AI services as alternatives to ensure work continuity.", + "recovery_actions": { + "cloudflare_blocked": ["• Use VPN or proxy service", "• Switch network environment (like mobile hotspot)", "• Wait 10-30 minutes and retry", "• Clear browser cache and cookies", "• Switch to other available services: ChatGPT, Claude, Qwen, Gemini"], + "network_timeout": ["• Check if network connection is stable", "• Retry connection operation", "• Switch to more stable network environment", "• Check firewall settings"], + "connection_refused": ["• Check if Codex CLI is properly installed", "• Verify service configuration and API keys", "• Restart application", "• Check if local ports are occupied"], + "unknown": ["• Check network connection status", "• Retry current operation", "• Switch network environment", "• Contact technical support"] + } + }, + "status": { + "connecting": "Connecting to Codex...", + "connected": "Connected to Codex MCP server", + "session_active": "Active session created with Codex", + "error_connect": "Failed to connect Codex: {{error}}" + }, + "thinking": { + "processing": "🤔 Codex is thinking...", + "completed": "💭 Thinking process completed", + "analyzing": "Analyzing the problem and formulating solutions...", + "composing": "Composing response content..." + }, + "error": { + "system_init_failed": "Failed to initialize Codex system", + "invalid_message_format": "Invalid message format", + "invalid_input": "Invalid input provided", + "session_timeout": "Session timed out, please retry", + "permission_denied": "Permission denied for this operation", + "context_error": "Error occurred in {{context}}, please refresh or retry.", + "generic": "Codex component encountered an exception, please try again later.", + "troubleshooting": "Troubleshooting Suggestions", + "suggestion_1": "Check if network connection is stable", + "suggestion_2": "Ensure Codex service is running properly", + "suggestion_3": "Try restarting the application", + "suggestion_4": "Clear browser cache and local storage" + }, + "performance": { + "virtualization_enabled": "List virtualization is enabled for better performance", + "virtualization_disabled": "List virtualization is disabled, showing all items", + "debounce_enabled": "Input debouncing is enabled ({{delay}}ms delay)", + "debounce_disabled": "Input debouncing is disabled" + }, + "config": { + "loaded": "Configuration loaded successfully", + "updated": "Configuration updated successfully", + "reset": "Configuration reset to defaults", + "validation_failed": "Configuration validation failed", + "import_success": "Configuration imported successfully", + "import_failed": "Failed to import configuration", + "export_success": "Configuration exported successfully" + }, + "permissions": { + "allow_once": "Allow Once", + "allow_always": "Allow Always", + "reject_once": "Reject Once", + "reject_always": "Reject Always", + "allow_once_desc": "Grant permission for this request only", + "allow_always_desc": "Always grant permission for similar requests", + "reject_once_desc": "Reject this request only", + "reject_always_desc": "Always reject similar requests", + "processing": "Processing...", + "response_sent": "Response sent successfully", + "choose_action": "Choose an action:", + "titles": { + "exec_approval_request": "Execute Command Permission", + "apply_patch_approval_request": "Apply File Changes Permission", + "command_execution": "Command Execution Permission", + "file_write": "File Write Permission", + "file_read": "File Read Permission" + }, + "labels": { + "command": "Command:", + "directory": "Directory:", + "reason": "Reason:", + "files_to_modify": "Files to modify", + "summary": "Summary:" + }, + "descriptions": { + "command_execution": "Codex requests to execute system commands", + "file_write": "Codex requests to modify or create files", + "file_read": "Codex requests to read file contents" + }, + "command_execution": { + "allow_once_desc": "Allow this command execution only", + "allow_always_desc": "Always allow all command execution requests", + "reject_once_desc": "Reject this command execution only", + "reject_always_desc": "Always reject all command execution requests" + }, + "file_write": { + "allow_once_desc": "Allow this file write only", + "allow_always_desc": "Always allow all file write requests", + "reject_once_desc": "Reject this file write only", + "reject_always_desc": "Always reject all file write requests" + }, + "file_read": { + "allow_once_desc": "Allow this file read only", + "allow_always_desc": "Always allow all file read requests", + "reject_once_desc": "Reject this file read only", + "reject_always_desc": "Always reject all file read requests" + } + } + }, + "tools": { + "shell": { + "displayName": "Shell Command", + "description": "Execute shell commands" + }, + "fileOps": { + "displayName": "File Operations", + "description": "File read, write and modification" + }, + "webSearch": { + "displayName": "Web Search", + "description": "Search web content" + }, + "mcp": { + "generic": { + "displayName": "MCP Tool: {{toolName}}", + "description": "MCP tool call: {{toolName}}" + } + }, + "unknown": { + "displayName": "Unknown Tool", + "description": "Unknown tool type" + }, + "status": { + "pending": "Pending", + "executing": "Executing", + "success": "Success", + "error": "Error", + "canceled": "Canceled" + }, + "titles": { + "web_search_started": "Web Search Started", + "web_search_completed": "Web Search Completed", + "web_search": "Web Search", + "applying_patch": "Applying Patch", + "patch_applied": "Patch Applied", + "file_patch": "File Patch", + "mcp_tool_starting": "MCP Tool: {{toolName}} (starting)", + "mcp_tool": "MCP Tool: {{toolName}}", + "execute_command": "Execute: {{command}}", + "command_output": "Command Output", + "command_completed": "Command Completed", + "shell_command": "Shell Command" + }, + "labels": { + "search_query": "Search Query:", + "file_changes": "File Changes:", + "tool_details": "Tool Details:", + "tool": "Tool", + "arguments": "Arguments:", + "result": "Result:", + "command": "Command:", + "working_directory": "Working directory:", + "exit_code": "Exit: {{code}}", + "duration": "Duration: {{seconds}}", + "auto_approved": "Auto-approved", + "manual_approval": "Manual approval" + }, + "actions": { + "create": "create", + "modify": "modify", + "delete": "delete" + } } } diff --git a/src/renderer/i18n/locales/ja-JP.json b/src/renderer/i18n/locales/ja-JP.json index 9f1996f3..1274eaf6 100644 --- a/src/renderer/i18n/locales/ja-JP.json +++ b/src/renderer/i18n/locales/ja-JP.json @@ -1,26 +1,159 @@ { + "acp": { + "auth": { + "failed": "{{backend}} 認証が失敗しました:\n\n{{error}}\n\nローカルCLIツールの認証状態を確認してください", + "console_error": "ACP認証エラー詳細:", + "failed_confirm": "ACP {{backend}} 認証が失敗しました:\n\n{{error}}\n\n設定ページで設定しますか?" + }, + "sendbox": { + "placeholder": "{{backend}}にメッセージを送信..." + } + }, + "codex": { + "sendbox": {}, + "network": { + "cloudflare_blocked": "Cloudflareの保護により{{service}}へのアクセスがブロックされました。対処: 1) VPN を利用 2) ネットワークを切替 3) 時間をおいて再試行 4) 別の AI サービスへ切替", + "cloudflare_blocked_title": "🚫 Cloudflare の保護により {{service}} へのアクセスがブロック", + "connection_refused": "接続が拒否されました。Codex サービスが正常に稼働しているか確認してください。", + "connection_refused_title": "❌ サービス接続が拒否されました", + "error_details": "エラー詳細", + "error_type": "エラー種別", + "network_timeout": "ネットワークがタイムアウトしました。接続を自動的に再試行しています...", + "network_timeout_title": "⏱️ ネットワーク接続がタイムアウト", + "quick_switch_content": "現在のサービスでネットワーク制限が発生した場合は:\n\n1. **AIアシスタントを即時切替**:左側パネルから他のアシスタントを選択\n2. **サービス状況を確認**:地域により可用性が異なる場合があります\n3. **ネットワーク最適化**:安定したネットワーク環境を使用\n4. **後で再試行**:制限は一時的な場合が多いです\n\n**ヒント**:業務継続のため、代替の AI サービスを複数設定しておくことをおすすめします。", + "recovery_actions": { + "cloudflare_blocked": ["• VPN やプロキシサービスを利用", "• ネットワーク環境を切替(モバイルホットスポットなど)", "• 10-30分待ってから再試行", "• ブラウザのキャッシュとクッキーをクリア", "• 他の利用可能なサービスに切替:ChatGPT、Claude、Qwen、Gemini"], + "network_timeout": ["• ネットワーク接続が安定しているか確認", "• 接続操作を再試行", "• より安定したネットワーク環境に切替", "• ファイアウォールの設定を確認"], + "connection_refused": ["• Codex CLI が正しくインストールされているか確認", "• サービス設定とAPIキーを確認", "• アプリケーションを再起動", "• ローカルポートが占有されていないか確認"], + "unknown": ["• ネットワーク接続状態を確認", "• 現在の操作を再試行", "• ネットワーク環境を切替", "• 技術サポートに連絡"] + }, + "quick_switch_title": "💡 **すぐに切り替えるには:**", + "recovery_suggestions": "**推奨対処:**", + "retry_attempt": "再試行中 {{current}} 回目...", + "retry_count": "再試行回数", + "technical_info": "**技術情報:**", + "unknown_error": "ネットワーク接続エラーです。ネットワーク設定を確認してください。", + "unknown_error_title": "🔌 ネットワーク接続エラー" + }, + "status": { + "connected": "Codex MCP サーバーに接続しました", + "connecting": "Codex に接続中...", + "error_connect": "Codex への接続に失敗しました:{{error}}", + "session_active": "Codex とアクティブなセッションを作成しました" + }, + "thinking": { + "processing": "🤔 Codex が思考中...", + "completed": "💭 思考過程完了", + "analyzing": "問題を分析し、解決策を策定しています...", + "composing": "回答内容を整理しています..." + }, + "error": { + "system_init_failed": "Codex システムの初期化に失敗しました", + "invalid_message_format": "メッセージフォーマットが無効です", + "invalid_input": "入力内容が無効です", + "session_timeout": "セッションがタイムアウトしました。再試行してください", + "permission_denied": "この操作はアクセス拒否されました", + "context_error": "{{context}}でエラーが発生しました。ページを更新するか再試行してください。", + "generic": "Codex コンポーネントで異常が発生しました。しばらくしてから再試行してください。", + "troubleshooting": "トラブルシューティング提案", + "suggestion_1": "ネットワーク接続が正常か確認", + "suggestion_2": "Codex サービスが実行されているか確認", + "suggestion_3": "アプリケーションの再起動を試す", + "suggestion_4": "ブラウザキャッシュとローカルストレージをクリア" + }, + "performance": { + "virtualization_enabled": "パフォーマンス向上のため、リスト仮想化が有効になりました", + "virtualization_disabled": "リスト仮想化が無効になり、すべての項目を表示します", + "debounce_enabled": "入力デバウンスが有効です({{delay}}ms 遅延)", + "debounce_disabled": "入力デバウンスが無効です" + }, + "config": { + "loaded": "設定の読み込みが成功しました", + "updated": "設定の更新が成功しました", + "reset": "設定をデフォルト値にリセットしました", + "validation_failed": "設定の検証に失敗しました", + "import_success": "設定のインポートが成功しました", + "import_failed": "設定のインポートに失敗しました", + "export_success": "設定のエクスポートが成功しました" + }, + "permissions": { + "allow_once": "今回のみ許可", + "allow_always": "常に許可", + "reject_once": "今回のみ拒否", + "reject_always": "常に拒否", + "allow_once_desc": "この要求のみ権限を付与", + "allow_always_desc": "同種の要求に常に権限を付与", + "reject_once_desc": "この要求のみ拒否", + "reject_always_desc": "同種の要求を常に拒否", + "processing": "処理中...", + "response_sent": "レスポンスを正常に送信しました", + "choose_action": "アクションを選択してください:", + "titles": { + "exec_approval_request": "コマンド実行権限", + "apply_patch_approval_request": "ファイル変更権限", + "command_execution": "コマンド実行権限", + "file_write": "ファイル書き込み権限", + "file_read": "ファイル読み取り権限" + }, + "labels": { + "command": "コマンド:", + "directory": "ディレクトリ:", + "reason": "理由:", + "files_to_modify": "変更するファイル", + "summary": "要約:" + }, + "descriptions": { + "command_execution": "Codex がシステムコマンドの実行を要求", + "file_write": "Codex がファイルの変更または作成を要求", + "file_read": "Codex がファイル内容の読み取りを要求" + }, + "command_execution": { + "allow_once_desc": "このコマンド実行のみ許可", + "allow_always_desc": "すべてのコマンド実行要求を常に許可", + "reject_once_desc": "このコマンド実行のみ拒否", + "reject_always_desc": "すべてのコマンド実行要求を常に拒否" + }, + "file_write": { + "allow_once_desc": "このファイル書き込みのみ許可", + "allow_always_desc": "すべてのファイル書き込み要求を常に許可", + "reject_once_desc": "このファイル書き込みのみ拒否", + "reject_always_desc": "すべてのファイル書き込み要求を常に拒否" + }, + "file_read": { + "allow_once_desc": "このファイル読み取りのみ許可", + "allow_always_desc": "すべてのファイル読み取り要求を常に許可", + "reject_once_desc": "このファイル読み取りのみ拒否", + "reject_always_desc": "すべてのファイル読み取り要求を常に拒否" + } + } + }, "common": { - "send": "送信", + "about": "私たちについて", + "add": "追加", + "attach": "添付", + "back": "チャットに戻る", "cancel": "キャンセル", - "save": "保存", - "delete": "削除", "confirm": "確認", + "contact": "お問い合わせ", + "delete": "削除", + "edit": "編集", + "error_details": "エラー詳細", "file": "ファイル", "folder": "フォルダ", - "upload": "アップロード", - "attach": "添付", - "workspace": "ワークスペース", + "github": "Github", + "loading": "お待ちください...", + "reload": "再読み込み", + "retry": "再試行", + "save": "保存", + "send": "送信", "settings": "設定", "system": "システム", - "about": "私たちについて", - "back": "チャットに戻る", - "add": "追加", - "edit": "編集", - "website": "公式サイト", + "technical_details": "技術詳細", + "troubleshooting": "トラブルシューティング", + "upload": "アップロード", "version": "バージョン", - "contact": "お問い合わせ", - "github": "Github", - "loading": "お待ちください..." + "website": "公式サイト", + "workspace": "ワークスペース" }, "conversation": { "welcome": { @@ -33,18 +166,25 @@ "selectModel": "モデルを選択" }, "history": { - "today": "今日", - "yesterday": "昨日", - "recent7Days": "過去7日間", + "cancelDelete": "いいえ", + "confirmDelete": "はい", + "deleteConfirm": "このチャットを削除してもよろしいですか?", + "deleteTitle": "チャットを削除", "earlier": "それ以前", "noHistory": "チャット履歴はありません", - "deleteTitle": "チャットを削除", - "deleteConfirm": "このチャットを削除してもよろしいですか?", - "confirmDelete": "はい", - "cancelDelete": "いいえ" + "recent7Days": "過去7日間", + "today": "今日", + "yesterday": "昨日" + }, + "welcome": { + "linkFolder": "フォルダを追加", + "multiAgentModeEnabled": "マルチエージェントモードに入ります", + "newConversation": "新しいチャット", + "placeholder": "メッセージの送信、ファイルの追加、またはフォルダーの追加...", + "title": "Hi、今日の予定は何ですか?", + "uploadFile": "ファイルを追加" }, "workspace": { - "title": "ワークスペース", "empty": "ワークスペースが空です", "emptyDescription": "ファイルをアップロードするか、またはフォルダーを開くと、ここにファイルが表示されます", "searchPlaceholder": "ファイルを検索...", @@ -55,47 +195,46 @@ } }, "settings": { + "about": "私たちについて", + "addModel": "モデルを追加", + "addModelPlaceholder": "モデルIDを選択または入力してください", + "apiKey": "API キー", + "apiKeyCount": "API キー", + "apiKeyPlaceholder": "APIキーを入力、複数の場合は1行に1つずつ", "authMethod": "認証", + "baseUrl": "ベースURL", + "baseUrlAutoFix": "base_url 自動修復為:{{base_url}}", + "cacheDir": "キャッシュディレクトリ", + "customOpenAI": "カスタム(OpenAI互換)", + "darkMode": "ダーク", + "deleteAllModelConfirm": "すべてのモデルを削除してもよろしいですか?", + "deleteModelConfirm": "このモデルを削除してもよろしいですか?", + "editModel": "モデルを編集", + "gemini": "Gemini 設定", "geminiApiKey": "Gemini API キー", - "vertexApiKey": "Vertex AI API キー", - "personalAuth": "Google アカウント", + "geminiBaseUrl": "Gemini API ベース URL", "googleLogin": "Google でログイン", "googleLogout": "ログアウト", - "openai": "OpenAI", - "proxyConfig": "プロキシ", - "yoloMode": "自動許可", - "geminiBaseUrl": "Gemini API ベース URL", - "cacheDir": "キャッシュディレクトリ", - "workDir": "作業ディレクトリ", + "imageGenerationGuide": "画像モデル設定ガイド", + "imageGenerationModel": "画像モデル", "language": "言語", - "gemini": "Gemini 設定", + "lightMode": "ライト", "model": "モデル設定", - "system": "システム設定", - "about": "私たちについて", - "addModel": "モデルを追加", - "editModel": "モデルを編集", + "modelCount": "モデル", + "modelName": "モデル名", "modelPlatform": "モデルプラットフォーム", + "multiApiKeyEditTip": "複数のAPIキーをサポート、1行に1つ、システムが自動ローテーションします", + "multiApiKeyTip": "💡 複数のAPIキーを自動ローテーションで追加するには、後でプラットフォーム編集画面で設定してください", + "noAvailable": "利用可能な画像モデルがありません。先に設定してください。", + "openai": "OpenAI", + "personalAuth": "Google アカウント", "platformName": "プラットフォーム名", - "modelName": "モデル名", - "baseUrl": "ベースURL", - "apiKey": "API キー", - "deleteModelConfirm": "このモデルを削除してもよろしいですか?", - "deleteAllModelConfirm": "すべてのモデルを削除してもよろしいですか?", - "modelCount": "モデル", - "apiKeyCount": "API キー", "pleaseEnterBaseUrlAndApiKey": "ベースURLとAPIキーを入力してください", - "multiApiKeyTip": "💡 複数のAPIキーを自動ローテーションで追加するには、後でプラットフォーム編集画面で設定してください", - "multiApiKeyEditTip": "複数のAPIキーをサポート、1行に1つ、システムが自動ローテーションします", - "apiKeyPlaceholder": "APIキーを入力、複数の場合は1行に1つずつ", - "addModelPlaceholder": "モデルIDを選択または入力してください", - "customOpenAI": "カスタム(OpenAI互換)", + "proxyConfig": "プロキシ", "proxyHttpOnly": "HTTP/HTTPSのみをサポート", - "baseUrlAutoFix": "base_url 自動修復為:{{base_url}}", - "updateConfirm": "確認変更", "restartConfirm": "変更後にアプリを再起動します。続行しますか?", + "system": "システム設定", "theme": "テーマ", - "lightMode": "ライト", - "darkMode": "ダーク", "tools": "ツール設定", "imageGenerationModel": "画像モデル", "imageGenerationGuide": "画像モデル設定ガイド", @@ -118,7 +257,72 @@ "confirm": "確認", "canceledExecution": "実行がキャンセルされました", "openLinkFailed": "リンクを開けませんでした", - "imageGenerationModelDetected": "画像モデルが検出されました。画像ツールが自動的に有効になりました。デフォルトモデル:{{platform}}:{{model}}" + "imageGenerationModelDetected": "画像モデルが検出されました。画像ツールが自動的に有効になりました。デフォルトモデル:{{platform}}:{{model}}", + "auto_handling_permissions": "自動ハンドリング許可要求:{{request}}" + }, + "tools": { + "shell": { + "displayName": "Shellコマンド", + "description": "Shellコマンドを実行" + }, + "fileOps": { + "displayName": "ファイル操作", + "description": "ファイル読み書きと変更" + }, + "webSearch": { + "displayName": "ウェブ検索", + "description": "ウェブコンテンツを検索" + }, + "mcp": { + "generic": { + "displayName": "MCPツール:{{toolName}}", + "description": "MCPツール呼び出し:{{toolName}}" + } + }, + "unknown": { + "displayName": "不明なツール", + "description": "不明なツールタイプ" + }, + "status": { + "pending": "待機中", + "executing": "実行中", + "success": "成功", + "error": "エラー", + "canceled": "キャンセル済み" + }, + "titles": { + "web_search_started": "ウェブ検索を開始しました", + "web_search_completed": "ウェブ検索が完了しました", + "web_search": "ウェブ検索", + "applying_patch": "パッチを適用中", + "patch_applied": "パッチが適用されました", + "file_patch": "ファイルパッチ", + "mcp_tool_starting": "MCPツール:{{toolName}}(開始中)", + "mcp_tool": "MCPツール:{{toolName}}", + "execute_command": "実行:{{command}}", + "command_output": "コマンド出力", + "command_completed": "コマンドが完了しました", + "shell_command": "Shellコマンド" + }, + "labels": { + "search_query": "検索クエリ:", + "file_changes": "ファイル変更:", + "tool_details": "ツール詳細:", + "tool": "ツール", + "arguments": "引数:", + "result": "結果:", + "command": "コマンド:", + "working_directory": "作業ディレクトリ:", + "exit_code": "終了コード:{{code}}", + "duration": "実行時間:{{seconds}}", + "auto_approved": "自動承認", + "manual_approval": "手動承認" + }, + "actions": { + "create": "作成", + "modify": "変更", + "delete": "削除" + } }, "acp": { "auth": { diff --git a/src/renderer/i18n/locales/zh-CN.json b/src/renderer/i18n/locales/zh-CN.json index 44be496b..3a713d70 100644 --- a/src/renderer/i18n/locales/zh-CN.json +++ b/src/renderer/i18n/locales/zh-CN.json @@ -20,7 +20,12 @@ "version": "版本号", "contact": "联系我们", "github": "Github", - "loading": "请稍候..." + "loading": "请稍候...", + "retry": "重试", + "reload": "重新加载", + "technical_details": "技术详情", + "error_details": "错误详情", + "troubleshooting": "故障排除" }, "conversation": { "welcome": { @@ -118,14 +123,199 @@ "confirm": "确认", "canceledExecution": "已取消执行", "openLinkFailed": "打开链接失败", - "imageGenerationModelDetected": "检测到图片模型,已自动开启图片工具,设置为默认模型:{{platform}}:{{model}}" + "imageGenerationModelDetected": "检测到图片模型,已自动开启图片工具,设置为默认模型:{{platform}}:{{model}}", + "auto_handling_permissions": "自动处理权限请求:{{request}}" }, "acp": { "auth": { - "failed": "{{backend}} 认证失败:\n\n{{error}}\n\n请检查本地CLI工具的认证状态" + "failed": "{{backend}} 认证失败:\n\n{{error}}\n\n请检查本地CLI工具的认证状态", + "console_error": "ACP认证错误详情:", + "failed_confirm": "ACP {{backend}} 认证失败:\n\n{{error}}\n\n是否现在前往设置页面配置?" }, "sendbox": { "placeholder": "发送消息到 {{backend}}..." } + }, + "codex": { + "sendbox": {}, + "network": { + "cloudflare_blocked": "Cloudflare防护机制阻止了对{{service}}的访问。建议:1) 使用VPN 2) 更换网络 3) 稍后重试 4) 切换到其他AI服务", + "network_timeout": "网络超时,正在自动重试连接...", + "connection_refused": "连接被拒绝,请检查Codex服务是否正常运行。", + "unknown_error": "网络连接异常,请检查网络设置。", + "retry_attempt": "正在进行第{{current}}次重试...", + "cloudflare_blocked_title": "🚫 {{service}} 服务被 Cloudflare 防护机制阻止", + "network_timeout_title": "⏱️ 网络连接超时", + "connection_refused_title": "❌ 服务连接被拒绝", + "unknown_error_title": "🔌 网络连接异常", + "recovery_suggestions": "**建议的解决方案:**", + "technical_info": "**技术信息:**", + "error_type": "错误类型", + "retry_count": "重试次数", + "error_details": "错误详情", + "quick_switch_title": "💡 **快速切换方案:**", + "quick_switch_content": "当前服务遇到网络限制时,您可以:\n\n1. **立即切换 AI 助手**:在左侧面板选择其他可用的助手\n2. **检查服务状态**:不同 AI 服务的可用性可能因地区而异\n3. **网络优化**:使用稳定的网络环境可以提高连接成功率\n4. **稍后重试**:网络限制通常是临时的\n\n**提示**:建议配置多个 AI 服务作为备选,确保工作连续性。", + "recovery_actions": { + "cloudflare_blocked": ["• 使用 VPN 或代理服务", "• 更换网络环境(如移动热点)", "• 等待 10-30 分钟后重试", "• 清除浏览器缓存和 Cookie", "• 切换到其他可用服务:ChatGPT、Claude、Qwen、Gemini"], + "network_timeout": ["• 检查网络连接是否稳定", "• 重试连接操作", "• 切换到更稳定的网络环境", "• 检查防火墙设置"], + "connection_refused": ["• 检查 Codex CLI 是否正确安装", "• 验证服务配置和API密钥", "• 重启应用程序", "• 检查本地端口是否被占用"], + "unknown": ["• 检查网络连接状态", "• 重试当前操作", "• 切换网络环境", "• 联系技术支持"] + } + }, + "status": { + "connecting": "正在连接 Codex...", + "connected": "已连接到 Codex MCP 服务器", + "session_active": "已与 Codex 创建活跃会话", + "error_connect": "连接 Codex 失败:{{error}}" + }, + "thinking": { + "processing": "🤔 Codex 正在思考...", + "completed": "💭 思考过程完成", + "analyzing": "正在分析问题和制定解决方案...", + "composing": "正在整理回复内容..." + }, + "error": { + "system_init_failed": "Codex 系统初始化失败", + "invalid_message_format": "消息格式无效", + "invalid_input": "输入内容无效", + "session_timeout": "会话已超时,请重试", + "permission_denied": "此操作被拒绝访问", + "context_error": "在{{context}}中发生错误,请刷新或重试。", + "generic": "Codex 组件出现异常,请稍后重试。", + "troubleshooting": "故障排除建议", + "suggestion_1": "检查网络连接是否正常", + "suggestion_2": "确认 Codex 服务正在运行", + "suggestion_3": "尝试重新启动应用程序", + "suggestion_4": "清除浏览器缓存和本地存储" + }, + "performance": { + "virtualization_enabled": "列表虚拟化已启用以提高性能", + "virtualization_disabled": "列表虚拟化已禁用,显示所有项目", + "debounce_enabled": "输入防抖已启用({{delay}}毫秒延迟)", + "debounce_disabled": "输入防抖已禁用" + }, + "config": { + "loaded": "配置加载成功", + "updated": "配置更新成功", + "reset": "配置已重置为默认值", + "validation_failed": "配置验证失败", + "import_success": "配置导入成功", + "import_failed": "配置导入失败", + "export_success": "配置导出成功" + }, + "permissions": { + "allow_once": "仅此次允许", + "allow_always": "总是允许", + "reject_once": "仅此次拒绝", + "reject_always": "总是拒绝", + "allow_once_desc": "仅对此次请求给予权限", + "allow_always_desc": "对同类型请求总是给予权限", + "reject_once_desc": "仅拒绝此次请求", + "reject_always_desc": "对同类型请求总是拒绝", + "processing": "处理中...", + "response_sent": "响应发送成功", + "choose_action": "请选择操作:", + "titles": { + "exec_approval_request": "命令执行权限", + "apply_patch_approval_request": "文件修改权限", + "command_execution": "命令执行权限", + "file_write": "文件写入权限", + "file_read": "文件读取权限" + }, + "labels": { + "command": "命令:", + "directory": "目录:", + "reason": "原因:", + "files_to_modify": "要修改的文件", + "summary": "摘要:" + }, + "descriptions": { + "command_execution": "Codex 请求执行系统命令", + "file_write": "Codex 请求修改或创建文件", + "file_read": "Codex 请求读取文件内容" + }, + "command_execution": { + "allow_once_desc": "仅允许此次命令执行", + "allow_always_desc": "对所有命令执行请求总是允许", + "reject_once_desc": "仅拒绝此次命令执行", + "reject_always_desc": "对所有命令执行请求总是拒绝" + }, + "file_write": { + "allow_once_desc": "仅允许此次文件写入", + "allow_always_desc": "对所有文件写入请求总是允许", + "reject_once_desc": "仅拒绝此次文件写入", + "reject_always_desc": "对所有文件写入请求总是拒绝" + }, + "file_read": { + "allow_once_desc": "仅允许此次文件读取", + "allow_always_desc": "对所有文件读取请求总是允许", + "reject_once_desc": "仅拒绝此次文件读取", + "reject_always_desc": "对所有文件读取请求总是拒绝" + } + } + }, + "tools": { + "shell": { + "displayName": "Shell命令", + "description": "执行Shell命令" + }, + "fileOps": { + "displayName": "文件操作", + "description": "文件读写和修改" + }, + "webSearch": { + "displayName": "网页搜索", + "description": "搜索网页内容" + }, + "mcp": { + "generic": { + "displayName": "MCP工具: {{toolName}}", + "description": "MCP工具调用: {{toolName}}" + } + }, + "unknown": { + "displayName": "未知工具", + "description": "未知工具类型" + }, + "status": { + "pending": "等待中", + "executing": "执行中", + "success": "成功", + "error": "错误", + "canceled": "已取消" + }, + "titles": { + "web_search_started": "网页搜索已开始", + "web_search_completed": "网页搜索已完成", + "web_search": "网页搜索", + "applying_patch": "正在应用补丁", + "patch_applied": "补丁已应用", + "file_patch": "文件补丁", + "mcp_tool_starting": "MCP工具:{{toolName}}(启动中)", + "mcp_tool": "MCP工具:{{toolName}}", + "execute_command": "执行:{{command}}", + "command_output": "命令输出", + "command_completed": "命令已完成", + "shell_command": "Shell命令" + }, + "labels": { + "search_query": "搜索查询:", + "file_changes": "文件更改:", + "tool_details": "工具详细信息:", + "tool": "工具", + "arguments": "参数:", + "result": "结果:", + "command": "命令:", + "working_directory": "工作目录:", + "exit_code": "退出码:{{code}}", + "duration": "持续时间:{{seconds}}", + "auto_approved": "自动批准", + "manual_approval": "手动批准" + }, + "actions": { + "create": "创建", + "modify": "修改", + "delete": "删除" + } } } diff --git a/src/renderer/i18n/locales/zh-TW.json b/src/renderer/i18n/locales/zh-TW.json index c78e293a..d01ce714 100644 --- a/src/renderer/i18n/locales/zh-TW.json +++ b/src/renderer/i18n/locales/zh-TW.json @@ -1,26 +1,151 @@ { + "acp": { + "auth": { + "failed": "{{backend}} 認證失敗:\n\n{{error}}\n\n請檢查本地CLI工具的認證狀態" + }, + "sendbox": { + "placeholder": "發送訊息到 {{backend}}..." + } + }, + "codex": { + "sendbox": {}, + "network": { + "cloudflare_blocked": "Cloudflare 保護導致無法存取 {{service}}。建議:1)使用 VPN 2)切換網路 3)稍後重試 4)切換至其他 AI 服務", + "cloudflare_blocked_title": "🚫 由於 Cloudflare 保護,{{service}} 服務被封鎖", + "connection_refused": "連線被拒絕,請確認 Codex 服務是否正常運行。", + "connection_refused_title": "❌ 服務連線被拒絕", + "error_details": "錯誤詳情", + "error_type": "錯誤類型", + "network_timeout": "網路逾時,正在自動重試連線...", + "network_timeout_title": "⏱️ 網路連線逾時", + "quick_switch_content": "當前服務遇到網路限制時,你可以:\n\n1. **立即切換 AI 助手**:從左側面板選擇其他可用助手\n2. **檢查服務狀態**:不同地區的服務可用性可能不同\n3. **網路優化**:使用穩定的網路環境以提升連線成功率\n4. **稍後重試**:網路限制通常是暫時的\n\n**提示**:建議配置多個備用的 AI 服務以確保工作不中斷。", + "quick_switch_title": "💡 **快速切換選項:**", + "recovery_suggestions": "**建議解法:**", + "retry_attempt": "正在重試第 {{current}} 次...", + "retry_count": "重試次數", + "technical_info": "**技術資訊:**", + "unknown_error": "發生網路連線錯誤,請檢查網路設定。", + "unknown_error_title": "🔌 網路連線錯誤" + }, + "status": { + "connected": "已連線至 Codex MCP 伺服器", + "connecting": "正在連線至 Codex...", + "error_connect": "連線至 Codex 失敗:{{error}}", + "session_active": "已與 Codex 建立有效工作階段" + }, + "thinking": { + "processing": "🤔 Codex 正在思考...", + "completed": "💭 思考過程完成", + "analyzing": "正在分析問題和制定解決方案...", + "composing": "正在整理回覆內容..." + }, + "error": { + "system_init_failed": "Codex 系統初始化失敗", + "invalid_message_format": "訊息格式無效", + "invalid_input": "輸入內容無效", + "session_timeout": "工作階段已逾時,請重試", + "permission_denied": "此操作被拒絕存取", + "context_error": "在{{context}}中發生錯誤,請重新整理或重試。", + "generic": "Codex 元件出現異常,請稍後重試。", + "troubleshooting": "疑難排解建議", + "suggestion_1": "檢查網路連線是否正常", + "suggestion_2": "確認 Codex 服務正在執行", + "suggestion_3": "嘗試重新啟動應用程式", + "suggestion_4": "清除瀏覽器快取和本機儲存" + }, + "performance": { + "virtualization_enabled": "清單虛擬化已啟用以提升效能", + "virtualization_disabled": "清單虛擬化已停用,顯示所有項目", + "debounce_enabled": "輸入防抖已啟用({{delay}}毫秒延遲)", + "debounce_disabled": "輸入防抖已停用" + }, + "config": { + "loaded": "設定載入成功", + "updated": "設定更新成功", + "reset": "設定已重設為預設值", + "validation_failed": "設定驗證失敗", + "import_success": "設定匯入成功", + "import_failed": "設定匯入失敗", + "export_success": "設定匯出成功" + }, + "permissions": { + "allow_once": "僅此次允許", + "allow_always": "總是允許", + "reject_once": "僅此次拒絕", + "reject_always": "總是拒絕", + "allow_once_desc": "僅對此次請求給予權限", + "allow_always_desc": "對同類型請求總是給予權限", + "reject_once_desc": "僅拒絕此次請求", + "reject_always_desc": "對同類型請求總是拒絕", + "processing": "處理中...", + "response_sent": "回應已成功發送", + "choose_action": "請選擇操作:", + "titles": { + "exec_approval_request": "指令執行權限", + "apply_patch_approval_request": "檔案修改權限", + "command_execution": "指令執行權限", + "file_write": "檔案寫入權限", + "file_read": "檔案讀取權限" + }, + "labels": { + "command": "指令:", + "directory": "目錄:", + "reason": "原因:", + "files_to_modify": "要修改的檔案", + "summary": "摘要:" + }, + "descriptions": { + "command_execution": "Codex 請求執行系統指令", + "file_write": "Codex 請求修改或建立檔案", + "file_read": "Codex 請求讀取檔案內容" + }, + "command_execution": { + "allow_once_desc": "僅允許此次指令執行", + "allow_always_desc": "對所有指令執行請求總是允許", + "reject_once_desc": "僅拒絕此次指令執行", + "reject_always_desc": "對所有指令執行請求總是拒絕" + }, + "file_write": { + "allow_once_desc": "僅允許此次檔案寫入", + "allow_always_desc": "對所有檔案寫入請求總是允許", + "reject_once_desc": "僅拒絕此次檔案寫入", + "reject_always_desc": "對所有檔案寫入請求總是拒絕" + }, + "file_read": { + "allow_once_desc": "僅允許此次檔案讀取", + "allow_always_desc": "對所有檔案讀取請求總是允許", + "reject_once_desc": "僅拒絕此次檔案讀取", + "reject_always_desc": "對所有檔案讀取請求總是拒絕" + } + } + }, "common": { - "send": "發送", + "about": "關於我們", + "add": "新增", + "attach": "附件", + "back": "返回聊天", "cancel": "取消", - "save": "儲存", - "delete": "刪除", "confirm": "確認", + "contact": "聯絡我們", + "delete": "刪除", + "edit": "編輯", "file": "檔案", "folder": "資料夾", - "upload": "上傳", - "attach": "附件", - "workspace": "工作空間", + "retry": "重試", + "reload": "重新載入", + "technical_details": "技術詳情", + "error_details": "錯誤詳情", + "troubleshooting": "疑難排解", + "github": "Github", + "loading": "請稍候...", + "save": "儲存", + "send": "發送", "settings": "設定", "system": "系統", - "about": "關於我們", - "back": "返回聊天", - "add": "新增", - "edit": "編輯", - "website": "官網", + "upload": "上傳", "version": "版本號", - "contact": "聯絡我們", - "github": "Github", - "loading": "請稍候..." + "website": "官網", + "workspace": "工作空間" }, "conversation": { "welcome": { @@ -33,18 +158,25 @@ "selectModel": "選擇模型" }, "history": { - "today": "今天", - "yesterday": "昨天", - "recent7Days": "近7天", + "cancelDelete": "取消", + "confirmDelete": "確定", + "deleteConfirm": "確定要刪除此對話嗎?", + "deleteTitle": "刪除對話", "earlier": "更早", "noHistory": "暫無對話記錄", - "deleteTitle": "刪除對話", - "deleteConfirm": "確定要刪除此對話嗎?", - "confirmDelete": "確定", - "cancelDelete": "取消" + "recent7Days": "近7天", + "today": "今天", + "yesterday": "昨天" + }, + "welcome": { + "linkFolder": "開啟資料夾", + "multiAgentModeEnabled": "已進入多智能體模式", + "newConversation": "新對話", + "placeholder": "發送訊息、上傳檔案或開啟資料夾...", + "title": "Hi,今天有什麼安排?", + "uploadFile": "上傳檔案" }, "workspace": { - "title": "工作空間", "empty": "空的", "emptyDescription": "上傳檔案或開啟資料夾後,檔案將顯示在這裡", "searchPlaceholder": "搜尋檔案...", @@ -55,47 +187,46 @@ } }, "settings": { + "about": "關於我們", + "addModel": "新增模型", + "addModelPlaceholder": "請選擇或輸入模型ID", + "apiKey": "API Key", + "apiKeyCount": "API Key", + "apiKeyPlaceholder": "請輸入 API Key,多個 Key 請每行一個", "authMethod": "驗證", + "baseUrl": "base url", + "baseUrlAutoFix": "base_url 自動修復為:{{base_url}}", + "cacheDir": "緩存目錄", + "customOpenAI": "自定義(相容OpenAI)", + "darkMode": "深色", + "deleteAllModelConfirm": "是否刪除所有模型?", + "deleteModelConfirm": "是否刪除該模型?", + "editModel": "編輯模型", + "gemini": "Gemini 設定", "geminiApiKey": "Gemini API 金鑰", - "vertexApiKey": "Vertex AI", - "personalAuth": "Google 帳號", + "geminiBaseUrl": "Gemini API Base URL", "googleLogin": "Google 登入", "googleLogout": "登出", - "openai": "OpenAI", - "proxyConfig": "代理", - "yoloMode": "自動允許", - "geminiBaseUrl": "Gemini API Base URL", - "cacheDir": "緩存目錄", - "workDir": "工作目錄", + "imageGenerationGuide": "圖像模型配置指南", + "imageGenerationModel": "圖像模型", "language": "語言", - "gemini": "Gemini 設定", + "lightMode": "淺色", "model": "模型配置", - "system": "系統設定", - "about": "關於我們", - "addModel": "新增模型", - "editModel": "編輯模型", + "modelCount": "模型", + "modelName": "模型名稱", "modelPlatform": "模型平台", + "multiApiKeyEditTip": "支援多個 API Key,每行一個,系統將自動輪詢使用", + "multiApiKeyTip": "💡 如需新增多個 API Key 實現自動輪詢,請稍後到平台編輯界面配置", + "noAvailable": "暫無可用圖像模型,請先配置。", + "openai": "OpenAI", + "personalAuth": "Google 帳號", "platformName": "平台名稱", - "modelName": "模型名稱", - "baseUrl": "base url", - "apiKey": "API Key", - "deleteModelConfirm": "是否刪除該模型?", - "deleteAllModelConfirm": "是否刪除所有模型?", - "modelCount": "模型", - "apiKeyCount": "API Key", "pleaseEnterBaseUrlAndApiKey": "請輸入base url和api key", - "multiApiKeyTip": "💡 如需新增多個 API Key 實現自動輪詢,請稍後到平台編輯界面配置", - "multiApiKeyEditTip": "支援多個 API Key,每行一個,系統將自動輪詢使用", - "apiKeyPlaceholder": "請輸入 API Key,多個 Key 請每行一個", - "addModelPlaceholder": "請選擇或輸入模型ID", - "customOpenAI": "自定義(相容OpenAI)", + "proxyConfig": "代理", "proxyHttpOnly": "僅支持 http/https 協議", - "baseUrlAutoFix": "base_url 自動修復為:{{base_url}}", - "updateConfirm": "確認修改", "restartConfirm": "修改後將重啟應用,是否繼續?", + "system": "系統設定", "theme": "主題", - "lightMode": "淺色", - "darkMode": "深色", "tools": "工具配置", "imageGenerationModel": "圖像模型", "imageGenerationGuide": "圖像模型配置指南", @@ -118,7 +249,72 @@ "confirm": "確認", "canceledExecution": "已取消執行", "openLinkFailed": "無法開啟連結", - "imageGenerationModelDetected": "檢測到圖片模型,已自動開啟圖片工具,設置為默認模型:{{platform}}:{{model}}" + "imageGenerationModelDetected": "檢測到圖片模型,已自動開啟圖片工具,設置為默認模型:{{platform}}:{{model}}", + "auto_handling_permissions": "自動處理權限請求:{{request}}" + }, + "tools": { + "shell": { + "displayName": "Shell指令", + "description": "執行Shell指令" + }, + "fileOps": { + "displayName": "檔案操作", + "description": "檔案讀寫和修改" + }, + "webSearch": { + "displayName": "網頁搜尋", + "description": "搜尋網頁內容" + }, + "mcp": { + "generic": { + "displayName": "MCP工具:{{toolName}}", + "description": "MCP工具呼叫:{{toolName}}" + } + }, + "unknown": { + "displayName": "未知工具", + "description": "未知工具類型" + }, + "status": { + "pending": "等待中", + "executing": "執行中", + "success": "成功", + "error": "錯誤", + "canceled": "已取消" + }, + "titles": { + "web_search_started": "網頁搜尋已開始", + "web_search_completed": "網頁搜尋已完成", + "web_search": "網頁搜尋", + "applying_patch": "正在套用修補程式", + "patch_applied": "修補程式已套用", + "file_patch": "檔案修補程式", + "mcp_tool_starting": "MCP工具:{{toolName}}(啟動中)", + "mcp_tool": "MCP工具:{{toolName}}", + "execute_command": "執行:{{command}}", + "command_output": "指令輸出", + "command_completed": "指令已完成", + "shell_command": "Shell指令" + }, + "labels": { + "search_query": "搜尋查詢:", + "file_changes": "檔案變更:", + "tool_details": "工具詳細資訊:", + "tool": "工具", + "arguments": "參數:", + "result": "結果:", + "command": "指令:", + "working_directory": "工作目錄:", + "exit_code": "結束碼:{{code}}", + "duration": "持續時間:{{seconds}}", + "auto_approved": "自動核准", + "manual_approval": "手動核准" + }, + "actions": { + "create": "建立", + "modify": "修改", + "delete": "刪除" + } }, "acp": { "auth": { diff --git a/src/renderer/messages/MessageList.tsx b/src/renderer/messages/MessageList.tsx index 192da841..b3f8f0b3 100644 --- a/src/renderer/messages/MessageList.tsx +++ b/src/renderer/messages/MessageList.tsx @@ -9,9 +9,12 @@ import classNames from 'classnames'; import React, { useEffect, useRef } from 'react'; import HOC from '../utils/HOC'; import { useMessageList } from './hooks'; -import MessageAcpPermission from './MessageAcpPermission'; -import MessageAcpStatus from './MessageAcpStatus'; -import MessageAcpToolCall from './MessageAcpToolCall'; +import MessageAcpPermission from '@renderer/messages/acp/MessageAcpPermission'; +import MessageAcpStatus from '@renderer/messages/acp/MessageAcpStatus'; +import MessageCodexPermission from './codex/MessageCodexPermission'; +import MessageCodexStatus from './codex/MessageCodexStatus'; +import MessageCodexToolCall from './codex/MessageCodexToolCall'; +import MessageAcpToolCall from '@renderer/messages/acp/MessageAcpToolCall'; import MessageTips from './MessageTips'; import MessageToolCall from './MessageToolCall'; import MessageToolGroup from './MessageToolGroup'; @@ -46,6 +49,12 @@ const MessageItem: React.FC<{ message: TMessage }> = HOC((props) => { return ; case 'acp_tool_call': return ; + case 'codex_permission': + return ; + case 'codex_status': + return ; + case 'codex_tool_call': + return ; default: return
Unknown message type: {(message as any).type}
; } diff --git a/src/renderer/messages/MessageTips.tsx b/src/renderer/messages/MessageTips.tsx index f34f8941..85ab83c7 100644 --- a/src/renderer/messages/MessageTips.tsx +++ b/src/renderer/messages/MessageTips.tsx @@ -9,6 +9,7 @@ import { Attention, CheckOne } from '@icon-park/react'; import { theme } from '@office-ai/platform'; import classNames from 'classnames'; import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import MarkdownView from '../components/Markdown'; const icon = { success: , @@ -33,6 +34,36 @@ const useFormatContent = (content: string) => { const MessageTips: React.FC<{ message: IMessageTips }> = ({ message }) => { const { content, type } = message.content; const { json, data } = useFormatContent(content); + const { t } = useTranslation(); + + // Handle structured error messages with error codes + const getDisplayContent = (content: string): string => { + if (content.startsWith('ERROR_')) { + const parts = content.split(': '); + const errorCode = parts[0].replace('ERROR_', ''); + const originalMessage = parts[1] || ''; + + // Map error codes to i18n keys + const errorMap: Record = { + CLOUDFLARE_BLOCKED: 'codex.network.cloudflare_blocked', + NETWORK_TIMEOUT: 'codex.network.network_timeout', + CONNECTION_REFUSED: 'codex.network.connection_refused', + SESSION_TIMEOUT: 'codex.error.session_timeout', + SYSTEM_INIT_FAILED: 'codex.error.system_init_failed', + INVALID_MESSAGE_FORMAT: 'codex.error.invalid_message_format', + INVALID_INPUT: 'codex.error.invalid_input', + PERMISSION_DENIED: 'codex.error.permission_denied', + }; + + const i18nKey = errorMap[errorCode]; + if (i18nKey) { + return t(i18nKey, { defaultValue: originalMessage }); + } + } + return content; + }; + + const displayContent = getDisplayContent(content); if (json) return ( @@ -46,7 +77,7 @@ const MessageTips: React.FC<{ message: IMessageTips }> = ({ message }) => { diff --git a/src/renderer/messages/MessageToolGroup.tsx b/src/renderer/messages/MessageToolGroup.tsx index fbc155ec..cdff2095 100644 --- a/src/renderer/messages/MessageToolGroup.tsx +++ b/src/renderer/messages/MessageToolGroup.tsx @@ -180,7 +180,7 @@ const ToolResultDisplay: React.FC<{ const MessageToolGroup: React.FC = ({ message }) => { const { t } = useTranslation(); - console.log('----->message', message); + return (
{message.content.map((content) => { diff --git a/src/renderer/messages/MessagetText.tsx b/src/renderer/messages/MessagetText.tsx index e51fa716..ada8584c 100644 --- a/src/renderer/messages/MessagetText.tsx +++ b/src/renderer/messages/MessagetText.tsx @@ -26,6 +26,12 @@ const useFormatContent = (content: string) => { const MessageText: React.FC<{ message: IMessageText }> = ({ message }) => { const { data, json } = useFormatContent(message.content.content); + + // 过滤空内容,避免渲染空DOM + if (!message.content.content || (typeof message.content.content === 'string' && !message.content.content.trim())) { + return null; + } + return (
p:first-child]:mt-0px [&>p:last-child]:mb-0px max-w-80%', { 'bg-#E9EFFF p-8px': message.position === 'right' })}> {json ? `\`\`\`json\n${JSON.stringify(data, null, 2)}\n\`\`\`` : data} diff --git a/src/renderer/messages/MessageAcpPermission.tsx b/src/renderer/messages/acp/MessageAcpPermission.tsx similarity index 100% rename from src/renderer/messages/MessageAcpPermission.tsx rename to src/renderer/messages/acp/MessageAcpPermission.tsx diff --git a/src/renderer/messages/MessageAcpStatus.tsx b/src/renderer/messages/acp/MessageAcpStatus.tsx similarity index 100% rename from src/renderer/messages/MessageAcpStatus.tsx rename to src/renderer/messages/acp/MessageAcpStatus.tsx diff --git a/src/renderer/messages/MessageAcpToolCall.tsx b/src/renderer/messages/acp/MessageAcpToolCall.tsx similarity index 97% rename from src/renderer/messages/MessageAcpToolCall.tsx rename to src/renderer/messages/acp/MessageAcpToolCall.tsx index 5c8bf480..f8200e26 100644 --- a/src/renderer/messages/MessageAcpToolCall.tsx +++ b/src/renderer/messages/acp/MessageAcpToolCall.tsx @@ -8,8 +8,8 @@ import type { IMessageAcpToolCall } from '@/common/chatLib'; import { Card, Tag } from '@arco-design/web-react'; import { diffStringsUnified } from 'jest-diff'; import React from 'react'; -import Diff2Html from '../components/Diff2Html'; -import MarkdownView from '../components/Markdown'; +import Diff2Html from '../../components/Diff2Html'; +import MarkdownView from '../../components/Markdown'; const StatusTag: React.FC<{ status: string }> = ({ status }) => { const getTagProps = () => { diff --git a/src/renderer/messages/codex/MessageCodexPermission.tsx b/src/renderer/messages/codex/MessageCodexPermission.tsx new file mode 100644 index 00000000..cebc77df --- /dev/null +++ b/src/renderer/messages/codex/MessageCodexPermission.tsx @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IMessageCodexPermission } from '@/common/chatLib'; +import React from 'react'; +import ExecApprovalDisplay from './PermissionComponent/ExecApprovalDisplay'; +import ApplyPatchApprovalDisplay from './PermissionComponent/ApplyPatchApprovalDisplay'; +import type { CodexPermissionRequest } from '@/common/codex/types'; + +// Type extractions for different permission subtypes +type ExecApprovalContent = Extract; +type ApplyPatchApprovalContent = Extract; + +interface MessageCodexPermissionProps { + message: IMessageCodexPermission; +} + +const MessageCodexPermission: React.FC = ({ message }) => { + const { content } = message; + + // Factory function: render different components based on subtype + switch (content.subtype) { + case 'exec_approval_request': + return ; + + case 'apply_patch_approval_request': + return ; + + default: + // This should never happen with proper typing + return
Unknown permission type
; + } +}; + +export default MessageCodexPermission; diff --git a/src/renderer/messages/codex/MessageCodexStatus.tsx b/src/renderer/messages/codex/MessageCodexStatus.tsx new file mode 100644 index 00000000..9cd8d7bc --- /dev/null +++ b/src/renderer/messages/codex/MessageCodexStatus.tsx @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IMessageCodexStatus } from '@/common/chatLib'; +import { Badge, Typography } from '@arco-design/web-react'; +import classNames from 'classnames'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +const { Text } = Typography; + +interface MessageCodexStatusProps { + message: IMessageCodexStatus; +} + +// Extend the basic codex status content to include optional backend property +interface ICodexStatusContent { + status: string; + message: string; + sessionId?: string; + isConnected?: boolean; + hasActiveSession?: boolean; + backend?: string; +} + +const MessageCodexStatus: React.FC = ({ message }) => { + const { t } = useTranslation(); + const { status, message: statusMessage } = message.content as ICodexStatusContent; + const backend = (message.content as ICodexStatusContent).backend; + + const getStatusBadge = () => { + switch (status) { + case 'connecting': + return ; + case 'connected': + return ; + case 'authenticated': + return ; + case 'session_active': + return ; + case 'disconnected': + return ; + case 'error': + return ; + default: + return ; + } + }; + + const getBackendIcon = () => { + switch (backend) { + case 'claude': + return '🤖'; // Claude icon + case 'gemini': + return '✨'; // Gemini icon + default: + return '🔌'; // Generic connection icon + } + }; + + const isError = status === 'error'; + const isSuccess = status === 'connected' || status === 'authenticated' || status === 'session_active'; + + return ( +
+
+ {backend && {getBackendIcon()}} + + {backend || 'Codex'} + +
+ +
{getStatusBadge()}
+ + {statusMessage && ( +
+ {statusMessage} +
+ )} +
+ ); +}; + +export default MessageCodexStatus; diff --git a/src/renderer/messages/codex/MessageCodexToolCall.tsx b/src/renderer/messages/codex/MessageCodexToolCall.tsx new file mode 100644 index 00000000..0f5e4946 --- /dev/null +++ b/src/renderer/messages/codex/MessageCodexToolCall.tsx @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IMessageCodexToolCall, CodexToolCallUpdate } from '@/common/chatLib'; +import React from 'react'; +import ExecCommandDisplay from './ToolCallComponent/ExecCommandDisplay'; +import WebSearchDisplay from './ToolCallComponent/WebSearchDisplay'; +import PatchDisplay from './ToolCallComponent/PatchDisplay'; +import McpToolDisplay from './ToolCallComponent/McpToolDisplay'; +import TurnDiffDisplay from './ToolCallComponent/TurnDiffDisplay'; +import GenericDisplay from './ToolCallComponent/GenericDisplay'; + +type ExecCommandContent = Extract; +type WebSearchContent = Extract; +type PatchContent = Extract; +type McpToolContent = Extract; +type TurnDiffContent = Extract; +type GenericContent = Extract; + +const MessageCodexToolCall: React.FC<{ message: IMessageCodexToolCall }> = ({ message }) => { + const { content } = message; + const { subtype } = content; + + // Factory function: render different components based on subtype + switch (subtype) { + case 'exec_command_begin': + case 'exec_command_output_delta': + case 'exec_command_end': + return ; + + case 'web_search_begin': + case 'web_search_end': + return ; + + case 'patch_apply_begin': + case 'patch_apply_end': + return ; + + case 'mcp_tool_call_begin': + case 'mcp_tool_call_end': + return ; + + case 'turn_diff': + return ; + + case 'generic': + default: + return ; + } +}; + +export default MessageCodexToolCall; diff --git a/src/renderer/messages/codex/PermissionComponent/ApplyPatchApprovalDisplay.tsx b/src/renderer/messages/codex/PermissionComponent/ApplyPatchApprovalDisplay.tsx new file mode 100644 index 00000000..63a0a3be --- /dev/null +++ b/src/renderer/messages/codex/PermissionComponent/ApplyPatchApprovalDisplay.tsx @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { BaseCodexPermissionRequest, ApplyPatchApprovalRequestData } from '@/common/codex/types'; +import { Typography } from '@arco-design/web-react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import BasePermissionDisplay from './BasePermissionDisplay'; + +const { Text } = Typography; + +interface ApplyPatchApprovalDisplayProps { + content: BaseCodexPermissionRequest & { subtype: 'apply_patch_approval_request'; data: ApplyPatchApprovalRequestData }; + messageId: string; + conversationId: string; +} + +const ApplyPatchApprovalDisplay: React.FC = React.memo(({ content, messageId, conversationId }) => { + const { title, data } = content; + const { t } = useTranslation(); + + // 基于 apply_patch_approval 类型生成权限信息 + const getPatchInfo = () => { + const changes = data.changes || data.codex_changes || {}; + const fileCount = Object.keys(changes).length; + const fileNames = Object.keys(changes).slice(0, 3); // Show first 3 files + const hasMoreFiles = Object.keys(changes).length > 3; + + return { + title: title ? t(title) : t('codex.permissions.titles.apply_patch_approval_request'), + icon: '📝', + changes, + fileCount, + fileNames, + hasMoreFiles, + reason: data.reason, + summary: data.summary, + }; + }; + + const patchInfo = getPatchInfo(); + + return ( + + {/* Files to be changed */} +
+ + {t('codex.permissions.labels.files_to_modify')} ({patchInfo.fileCount}): + +
+ {patchInfo.fileNames.map((fileName, index) => ( +
+ 📄 {fileName} +
+ ))} + {patchInfo.hasMoreFiles &&
... and {patchInfo.fileCount - 3} more files
} +
+
+ + {/* Summary */} + {patchInfo.summary && ( +
+ {t('codex.permissions.labels.summary')} + {patchInfo.summary} +
+ )} + + {/* Reason */} + {patchInfo.reason && ( +
+ {t('codex.permissions.labels.reason')} + {patchInfo.reason} +
+ )} +
+ ); +}); + +export default ApplyPatchApprovalDisplay; diff --git a/src/renderer/messages/codex/PermissionComponent/BasePermissionDisplay.tsx b/src/renderer/messages/codex/PermissionComponent/BasePermissionDisplay.tsx new file mode 100644 index 00000000..0411b805 --- /dev/null +++ b/src/renderer/messages/codex/PermissionComponent/BasePermissionDisplay.tsx @@ -0,0 +1,198 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { BaseCodexPermissionRequest } from '@/common/codex/types'; +import { Button, Card, Radio, Typography } from '@arco-design/web-react'; +import type { ReactNode } from 'react'; +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useConfirmationHandler, usePermissionState, usePermissionStorageCleanup } from '@/common/codex/utils/permissionUtils'; + +const { Text } = Typography; + +interface BasePermissionDisplayProps { + content: BaseCodexPermissionRequest & { data: { call_id: string } }; + messageId: string; + conversationId: string; + icon: string; + title: string; + children: ReactNode; // 特定类型的详细信息内容 +} + +const BasePermissionDisplay: React.FC = React.memo(({ content, messageId, conversationId, icon, title, children }) => { + const { options = [], data } = content; + const { t } = useTranslation(); + + const { handleConfirmation } = useConfirmationHandler(); + const { cleanupOldPermissionStorage } = usePermissionStorageCleanup(); + + // 直接使用 call_id 作为权限标识,确保每个请求唯一 + const permissionId = data.call_id; + + // 全局权限选择key(基于权限类型) + const globalPermissionKey = `codex_global_permission_choice_${permissionId}`; + + // 具体权限请求响应key(基于具体的callId) + const specificResponseKey = `codex_permission_responded_${data.call_id || messageId}`; + + // 使用正确的keys:全局权限选择 + 具体请求响应 + const { selected, setSelected, hasResponded, setHasResponded } = usePermissionState(globalPermissionKey, specificResponseKey); + + const [isResponding, setIsResponding] = useState(false); + + // Check if we have an "always" permission stored and should auto-handle + const [shouldAutoHandle] = useState(() => { + try { + const storedChoice = localStorage.getItem(globalPermissionKey); + if (storedChoice === 'allow_always' || storedChoice === 'reject_always') { + const alreadyResponded = localStorage.getItem(specificResponseKey) === 'true'; + if (!alreadyResponded) { + return storedChoice; + } + } + } catch (error) { + // localStorage error + } + return null; + }); + + // 组件挂载时清理旧存储 + useEffect(() => { + // 清理超过7天的旧权限存储 + cleanupOldPermissionStorage(); + }, [permissionId]); // 只在permissionId变化时执行 + + // 备用检查:组件挂载时检查是否有 always 权限(如果第一个没有捕获) + useEffect(() => { + const checkStoredChoice = () => { + if (hasResponded) return; + + try { + const storedChoice = localStorage.getItem(globalPermissionKey); + // 只设置选中状态,不自动确认 + if (storedChoice && !selected) { + setSelected(storedChoice); + } + } catch (error) { + // Handle error silently + } + }; + + checkStoredChoice(); + }, [permissionId, hasResponded, globalPermissionKey, selected]); + + // 保存选择状态到 localStorage + const handleSelectionChange = (value: string) => { + setSelected(value); + try { + localStorage.setItem(globalPermissionKey, value); + localStorage.setItem(`${globalPermissionKey}_timestamp`, Date.now().toString()); + } catch (error) { + // Handle error silently + } + }; + + const handleConfirm = async () => { + if (hasResponded || !selected) return; + + setIsResponding(true); + try { + const confirmationData = { + confirmKey: selected, + msg_id: messageId, + conversation_id: conversationId, + callId: data.call_id || messageId, + }; + + // 使用通用的 confirmMessage,process 层会自动分发到正确的 handler + const result = await handleConfirmation(confirmationData); + + if (result.success) { + setHasResponded(true); + try { + localStorage.setItem(specificResponseKey, 'true'); + localStorage.setItem(`${specificResponseKey}_timestamp`, Date.now().toString()); + + // Verify save was successful + localStorage.getItem(specificResponseKey); + } catch { + // Error saving response to localStorage + } + } else { + // Handle failure case - could add error display here + } + } catch (error) { + // Handle error case - could add error logging here + } finally { + setIsResponding(false); + } + }; + + // Don't render UI if already responded or if auto-handling + const shouldShowAutoHandling = shouldAutoHandle && !hasResponded; + + if (shouldShowAutoHandling) { + return ( + +
+
+ {icon} + {t('messages.auto_handling_permission', { defaultValue: '' })} +
+
+
+ ); + } + + return ( + +
+
+ {icon} + {title} +
+ + {/* 特定类型的详细信息 */} + {children} + + {!hasResponded && ( + <> +
{t('codex.permissions.choose_action')}
+ + {options && options.length > 0 ? ( + options.map((option, index) => { + const optionId = option?.optionId || `option_${index}`; + // Translate the option name using the i18n key + const optionName = option?.name ? t(option.name, { defaultValue: option.name }) : `Option ${index + 1}`; + return ( + + {optionName} + + ); + }) + ) : ( + No options available + )} + +
+ +
+ + )} + + {hasResponded && ( +
+ ✓ {t('codex.permissions.response_sent')} +
+ )} +
+
+ ); +}); + +export default BasePermissionDisplay; diff --git a/src/renderer/messages/codex/PermissionComponent/ExecApprovalDisplay.tsx b/src/renderer/messages/codex/PermissionComponent/ExecApprovalDisplay.tsx new file mode 100644 index 00000000..d06fb9e6 --- /dev/null +++ b/src/renderer/messages/codex/PermissionComponent/ExecApprovalDisplay.tsx @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { BaseCodexPermissionRequest, ExecApprovalRequestData } from '@/common/codex/types'; +import { Typography } from '@arco-design/web-react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import BasePermissionDisplay from './BasePermissionDisplay'; + +const { Text } = Typography; + +interface ExecApprovalDisplayProps { + content: BaseCodexPermissionRequest & { subtype: 'exec_approval_request'; data: ExecApprovalRequestData }; + messageId: string; + conversationId: string; +} + +const ExecApprovalDisplay: React.FC = React.memo(({ content, messageId, conversationId }) => { + const { title, data } = content; + const { t } = useTranslation(); + + // 基于 exec_approval 类型生成权限信息 + const getExecInfo = () => { + const commandStr = Array.isArray(data.command) ? data.command.join(' ') : data.command; + return { + title: title ? t(title) : t('codex.permissions.titles.exec_approval_request'), + icon: '⚡', + command: commandStr, + cwd: data.cwd, + reason: data.reason, + }; + }; + + const execInfo = getExecInfo(); + + return ( + + {/* Command details */} +
+ {t('codex.permissions.labels.command')} + {execInfo.command} +
+ + {/* Working directory */} + {execInfo.cwd && ( +
+ {t('codex.permissions.labels.directory')} + {execInfo.cwd} +
+ )} + + {/* Reason */} + {execInfo.reason && ( +
+ {t('codex.permissions.labels.reason')} + {execInfo.reason} +
+ )} +
+ ); +}); + +export default ExecApprovalDisplay; diff --git a/src/renderer/messages/codex/ToolCallComponent/BaseToolCallDisplay.tsx b/src/renderer/messages/codex/ToolCallComponent/BaseToolCallDisplay.tsx new file mode 100644 index 00000000..35d16b9f --- /dev/null +++ b/src/renderer/messages/codex/ToolCallComponent/BaseToolCallDisplay.tsx @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Card, Tag } from '@arco-design/web-react'; +import type { ReactNode } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export const StatusTag: React.FC<{ status: string }> = ({ status }) => { + const { t } = useTranslation(); + + const getTagProps = () => { + switch (status) { + case 'pending': + return { color: 'blue', text: t('tools.status.pending') }; + case 'executing': + return { color: 'orange', text: t('tools.status.executing') }; + case 'success': + return { color: 'green', text: t('tools.status.success') }; + case 'error': + return { color: 'red', text: t('tools.status.error') }; + case 'canceled': + return { color: 'gray', text: t('tools.status.canceled') }; + default: + return { color: 'gray', text: status }; + } + }; + + const { color, text } = getTagProps(); + return {text}; +}; + +interface BaseToolCallDisplayProps { + toolCallId: string; + title: string; + status: string; + description?: string | ReactNode; + icon: string; + additionalTags?: ReactNode; // 额外的标签,如 exit code、duration 等 + children?: ReactNode; // 特定工具的详细信息内容 +} + +const BaseToolCallDisplay: React.FC = ({ toolCallId, title, status, description, icon, additionalTags, children }) => { + return ( + +
+
+
+ {icon} + {title} + + {additionalTags} +
+ + {description &&
{description}
} + + {/* 特定工具的详细信息 */} + {children} + +
Tool Call ID: {toolCallId}
+
+
+
+ ); +}; + +export default BaseToolCallDisplay; diff --git a/src/renderer/messages/codex/ToolCallComponent/ExecCommandDisplay.tsx b/src/renderer/messages/codex/ToolCallComponent/ExecCommandDisplay.tsx new file mode 100644 index 00000000..286ff984 --- /dev/null +++ b/src/renderer/messages/codex/ToolCallComponent/ExecCommandDisplay.tsx @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CodexToolCallUpdate } from '@/common/chatLib'; +import { Tag } from '@arco-design/web-react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import BaseToolCallDisplay from './BaseToolCallDisplay'; + +type ExecCommandUpdate = Extract; + +const ExecCommandDisplay: React.FC<{ content: ExecCommandUpdate }> = ({ content }) => { + const { toolCallId, title, status, description, content: contentArray, subtype, data } = content; + const { t } = useTranslation(); + + const getDisplayTitle = () => { + if (title) return title; + + switch (subtype) { + case 'exec_command_begin': + if (data.command && Array.isArray(data.command) && data.command.length > 0) { + return t('tools.titles.execute_command', { command: data.command.join(' ') }); + } + return 'Execute Command'; + case 'exec_command_output_delta': + return t('tools.titles.command_output'); + case 'exec_command_end': + return t('tools.titles.command_completed'); + default: + return t('tools.titles.shell_command'); + } + }; + + const getAdditionalTags = () => { + const tags = []; + if (subtype === 'exec_command_end' && 'exit_code' in data && data.exit_code !== undefined) { + tags.push( + + {t('tools.labels.exit_code', { code: data.exit_code })} + + ); + } + if (subtype === 'exec_command_end' && 'duration' in data && data.duration) { + // Calculate total duration: secs + nanos/1,000,000,000 + const totalSeconds = data.duration.secs + (data.duration.nanos || 0) / 1_000_000_000; + const formattedDuration = totalSeconds < 1 ? `${Math.round(totalSeconds * 1000)}ms` : `${totalSeconds.toFixed(2)}s`; + + tags.push( + + {t('tools.labels.duration', { seconds: formattedDuration })} + + ); + } + return tags.length > 0 ? <>{tags} : null; + }; + + return ( + + {/* Display command if available */} + {subtype === 'exec_command_begin' && 'command' in data && data.command && Array.isArray(data.command) && data.command.length > 0 && ( +
+
{t('tools.labels.command')}
+
+ $ + {data.command.join(' ')} + {'cwd' in data && data.cwd && ( +
+ {t('tools.labels.working_directory')}: {data.cwd} +
+ )} +
+
+ )} + + {/* Display output content */} + {contentArray && contentArray.length > 0 && ( +
+ {contentArray.map((content, index) => ( +
+ {content.type === 'output' && content.output && ( +
+
+
{content.output}
+
+
+ )} +
+ ))} +
+ )} +
+ ); +}; + +export default ExecCommandDisplay; diff --git a/src/renderer/messages/codex/ToolCallComponent/GenericDisplay.tsx b/src/renderer/messages/codex/ToolCallComponent/GenericDisplay.tsx new file mode 100644 index 00000000..4ff8ddc8 --- /dev/null +++ b/src/renderer/messages/codex/ToolCallComponent/GenericDisplay.tsx @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CodexToolCallUpdate } from '@/common/chatLib'; +import { Card, Tag } from '@arco-design/web-react'; +import React from 'react'; + +type GenericUpdate = Extract; + +const StatusTag: React.FC<{ status: string }> = ({ status }) => { + const getTagProps = () => { + switch (status) { + case 'pending': + return { color: 'blue', text: 'Pending' }; + case 'executing': + return { color: 'orange', text: 'Executing' }; + case 'success': + return { color: 'green', text: 'Success' }; + case 'error': + return { color: 'red', text: 'Error' }; + case 'canceled': + return { color: 'gray', text: 'Canceled' }; + default: + return { color: 'gray', text: status }; + } + }; + + const { color, text } = getTagProps(); + return {text}; +}; + +const getKindIcon = (kind: string) => { + switch (kind) { + case 'execute': + return '🔧'; + case 'patch': + return '📝'; + case 'mcp': + return '🔌'; + case 'web_search': + return '🔍'; + default: + return '⚙️'; + } +}; + +const GenericDisplay: React.FC<{ content: GenericUpdate }> = ({ content }) => { + const { toolCallId, kind, title, status, description, content: contentArray, data } = content; + + const getDisplayTitle = () => { + if (title) return title; + + switch (kind) { + case 'execute': + return 'Shell Command'; + case 'patch': + return 'File Patch'; + case 'mcp': + return 'MCP Tool'; + case 'web_search': + return 'Web Search'; + default: + return 'Tool Call'; + } + }; + + return ( + +
+
+
+ {getKindIcon(kind)} + {getDisplayTitle()} + +
+ + {description &&
{description}
} + + {/* Display data if available */} + {data && ( +
+
Data:
+
+
{JSON.stringify(data, null, 2)}
+
+
+ )} + + {/* Display content if available */} + {contentArray && contentArray.length > 0 && ( +
+ {contentArray.map((content, index) => ( +
+ {content.type === 'output' && content.output && ( +
+
+
{content.output}
+
+
+ )} + {content.type === 'text' && content.text && ( +
+
{content.text}
+
+ )} +
+ ))} +
+ )} + +
Tool Call ID: {toolCallId}
+
+
+
+ ); +}; + +export default GenericDisplay; diff --git a/src/renderer/messages/codex/ToolCallComponent/McpToolDisplay.tsx b/src/renderer/messages/codex/ToolCallComponent/McpToolDisplay.tsx new file mode 100644 index 00000000..662dba8b --- /dev/null +++ b/src/renderer/messages/codex/ToolCallComponent/McpToolDisplay.tsx @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CodexToolCallUpdate } from '@/common/chatLib'; +import { Tag } from '@arco-design/web-react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import BaseToolCallDisplay from './BaseToolCallDisplay'; + +type McpToolUpdate = Extract; + +const McpToolDisplay: React.FC<{ content: McpToolUpdate }> = ({ content }) => { + const { toolCallId, title, status, description, subtype, data } = content; + const { t } = useTranslation(); + + const getDisplayTitle = () => { + if (title) return title; + + const inv = data?.invocation || {}; + const toolName = inv.tool || inv.name || inv.method || 'unknown'; + + switch (subtype) { + case 'mcp_tool_call_begin': + return t('tools.titles.mcp_tool_starting', { toolName }); + case 'mcp_tool_call_end': + return t('tools.titles.mcp_tool', { toolName }); + default: + return 'MCP Tool'; + } + }; + + const getToolDetails = () => { + if (!data?.invocation) return null; + + const inv = data.invocation; + return { + toolName: inv.tool || inv.name || inv.method || 'unknown', + arguments: inv.arguments, + }; + }; + + const toolDetails = getToolDetails(); + + return ( + + {/* Display tool details if available */} + {toolDetails && ( +
+
{t('tools.labels.tool_details')}
+
+
+ + {t('tools.labels.tool')} + + {toolDetails.toolName} +
+ {toolDetails.arguments && ( +
+
{t('tools.labels.arguments')}
+
{JSON.stringify(toolDetails.arguments, null, 2)}
+
+ )} +
+
+ )} + + {/* Display result if available for end events */} + {subtype === 'mcp_tool_call_end' && data?.result && ( +
+
{t('tools.labels.result')}
+
+
{typeof data.result === 'string' ? data.result : JSON.stringify(data.result, null, 2)}
+
+
+ )} +
+ ); +}; + +export default McpToolDisplay; diff --git a/src/renderer/messages/codex/ToolCallComponent/PatchDisplay.tsx b/src/renderer/messages/codex/ToolCallComponent/PatchDisplay.tsx new file mode 100644 index 00000000..888022f4 --- /dev/null +++ b/src/renderer/messages/codex/ToolCallComponent/PatchDisplay.tsx @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CodexToolCallUpdate } from '@/common/chatLib'; +import { Tag } from '@arco-design/web-react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import BaseToolCallDisplay from './BaseToolCallDisplay'; + +type PatchUpdate = Extract; + +const PatchDisplay: React.FC<{ content: PatchUpdate }> = ({ content }) => { + const { toolCallId, title, status, description, subtype, data } = content; + const { t } = useTranslation(); + + const getDisplayTitle = () => { + if (title) return title; + + switch (subtype) { + case 'patch_apply_begin': + return t('tools.titles.applying_patch'); + case 'patch_apply_end': + return t('tools.titles.patch_applied'); + default: + return t('tools.titles.file_patch'); + } + }; + + const getAdditionalTags = () => { + if (subtype === 'patch_apply_begin' && 'auto_approved' in data && data.auto_approved !== undefined) { + return {data.auto_approved ? t('tools.labels.auto_approved') : t('tools.labels.manual_approval')}; + } + return null; + }; + + const getChangeSummary = () => { + // Only show changes for patch_apply_begin + if (subtype !== 'patch_apply_begin' || !('changes' in data) || !data.changes || typeof data.changes !== 'object') return null; + + const entries = Object.entries(data.changes); + if (entries.length === 0) return null; + + return entries.map(([file, change]) => { + let action = 'modify'; + if (typeof change === 'object' && change !== null) { + if ('type' in change && typeof change.type === 'string') { + action = change.type; + } else if ('action' in change && typeof change.action === 'string') { + action = change.action; + } + } + return { file, action }; + }); + }; + + const changeSummary = getChangeSummary(); + + return ( + + {/* Display file changes if available */} + {changeSummary && changeSummary.length > 0 && ( +
+
{t('tools.labels.file_changes')}
+
+ {changeSummary.map(({ file, action }, index) => ( +
+ + {t(`tools.actions.${action}`, { defaultValue: action })} + + {file} +
+ ))} +
+
+ )} +
+ ); +}; + +export default PatchDisplay; diff --git a/src/renderer/messages/codex/ToolCallComponent/TurnDiffDisplay.tsx b/src/renderer/messages/codex/ToolCallComponent/TurnDiffDisplay.tsx new file mode 100644 index 00000000..028d5ae3 --- /dev/null +++ b/src/renderer/messages/codex/ToolCallComponent/TurnDiffDisplay.tsx @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CodexToolCallUpdate } from '@/common/chatLib'; +import { Tag } from '@arco-design/web-react'; +import React from 'react'; +import Diff2Html from '../../../components/Diff2Html'; +import BaseToolCallDisplay from './BaseToolCallDisplay'; + +type TurnDiffContent = Extract; + +const TurnDiffDisplay: React.FC<{ content: TurnDiffContent }> = ({ content }) => { + const { toolCallId, data } = content; + const { unified_diff } = data; + + // 解析统一diff格式,提取文件信息 + const extractFileInfo = (diff: string) => { + const lines = diff.split('\n'); + const gitLine = lines.find((line) => line.startsWith('diff --git')); + if (gitLine) { + const match = gitLine.match(/diff --git a\/(.+) b\/(.+)/); + if (match) { + const fullPath = match[1]; + const fileName = fullPath.split('/').pop() || fullPath; // 只取文件名 + return { + fileName, + fullPath, + isNewFile: diff.includes('new file mode'), + isDeletedFile: diff.includes('deleted file mode'), + }; + } + } + return { + fileName: 'Unknown file', + fullPath: 'Unknown file', + isNewFile: false, + isDeletedFile: false, + }; + }; + + const fileInfo = extractFileInfo(unified_diff); + const { fileName, fullPath, isNewFile, isDeletedFile } = fileInfo; + + // 截断长路径的函数 + const truncatePath = (path: string, maxLength: number = 60) => { + if (path.length <= maxLength) return path; + const parts = path.split('/'); + if (parts.length <= 2) return path; + + // 保留开头和结尾,中间用 ... 代替 + const start = parts.slice(0, 2).join('/'); + const end = parts.slice(-2).join('/'); + return `${start}/.../${end}`; + }; + + // 生成额外的标签来显示文件状态 + const additionalTags = ( + <> + {isNewFile && New File} + {isDeletedFile && Deleted File} + {!isNewFile && !isDeletedFile && Modified} + + ); + + return ( + +
+ {truncatePath(fullPath)} +
+
+ } + icon='📝' + additionalTags={additionalTags} + > +
+ +
+ + ); +}; + +export default TurnDiffDisplay; diff --git a/src/renderer/messages/codex/ToolCallComponent/WebSearchDisplay.tsx b/src/renderer/messages/codex/ToolCallComponent/WebSearchDisplay.tsx new file mode 100644 index 00000000..2a4d264c --- /dev/null +++ b/src/renderer/messages/codex/ToolCallComponent/WebSearchDisplay.tsx @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CodexToolCallUpdate } from '@/common/chatLib'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import BaseToolCallDisplay from './BaseToolCallDisplay'; + +type WebSearchUpdate = Extract; + +const WebSearchDisplay: React.FC<{ content: WebSearchUpdate }> = ({ content }) => { + const { toolCallId, title, status, description, subtype, data } = content; + const { t } = useTranslation(); + + const getDisplayTitle = () => { + if (title) return title; + + switch (subtype) { + case 'web_search_begin': + return t('tools.titles.web_search_started'); + case 'web_search_end': + return 'query' in data && data.query ? `${t('tools.titles.web_search')}: ${data.query}` : t('tools.titles.web_search_completed'); + default: + return t('tools.titles.web_search'); + } + }; + + return ( + + {/* Display query if available */} + {subtype === 'web_search_end' && 'query' in data && data.query && ( +
+
{t('tools.labels.search_query')}
+
{data.query}
+
+ )} +
+ ); +}; + +export default WebSearchDisplay; diff --git a/src/renderer/messages/hooks.ts b/src/renderer/messages/hooks.ts index 2c0b8a28..fa3c5647 100644 --- a/src/renderer/messages/hooks.ts +++ b/src/renderer/messages/hooks.ts @@ -49,5 +49,4 @@ export const beforeUpdateMessageList = (fn: (list: TMessage[]) => TMessage[]) => beforeUpdateMessageListStack.splice(beforeUpdateMessageListStack.indexOf(fn), 1); }; }; - export { ChatKeyProvider, MessageListProvider, useChatKey, useMessageList, useUpdateMessageList }; diff --git a/src/renderer/pages/conversation/ChatConversation.tsx b/src/renderer/pages/conversation/ChatConversation.tsx index 39dbb2e4..b9044c97 100644 --- a/src/renderer/pages/conversation/ChatConversation.tsx +++ b/src/renderer/pages/conversation/ChatConversation.tsx @@ -18,6 +18,7 @@ import AcpChat from './acp/AcpChat'; import ChatLayout from './ChatLayout'; import ChatSider from './ChatSider'; import GeminiChat from './gemini/GeminiChat'; +import CodexChat from './codex/CodexChat'; const AssociatedConversation: React.FC<{ conversation_id: string }> = ({ conversation_id }) => { const { data } = useSWR(['getAssociateConversation', conversation_id], () => ipcBridge.conversation.getAssociateConversation.invoke({ conversation_id })); @@ -86,6 +87,8 @@ const ChatConversation: React.FC<{ return ; case 'acp': return ; + case 'codex': + return ; default: return null; } @@ -106,7 +109,7 @@ const ChatConversation: React.FC<{ }, [conversation]); return ( - }> + }> {conversationNode} ); diff --git a/src/renderer/pages/conversation/ChatLayout.tsx b/src/renderer/pages/conversation/ChatLayout.tsx index cee67301..9e01db00 100644 --- a/src/renderer/pages/conversation/ChatLayout.tsx +++ b/src/renderer/pages/conversation/ChatLayout.tsx @@ -9,6 +9,7 @@ import GeminiLogo from '@/renderer/assets/logos/gemini.svg'; import IflowLogo from '@/renderer/assets/logos/iflow.svg'; import QwenLogo from '@/renderer/assets/logos/qwen.svg'; import classNames from 'classnames'; +import CodexLogo from '@/renderer/assets/logos/codex.svg'; const addEventListener = (key: K, handler: (e: DocumentEventMap[K]) => void): (() => void) => { document.addEventListener(key, handler); @@ -86,7 +87,7 @@ const ChatLayout: React.FC<{ {props.title} {backend && (
- {`${backend} + {`${backend} {backend}
)} diff --git a/src/renderer/pages/conversation/ChatSider.tsx b/src/renderer/pages/conversation/ChatSider.tsx index 7f53e732..ed836b05 100644 --- a/src/renderer/pages/conversation/ChatSider.tsx +++ b/src/renderer/pages/conversation/ChatSider.tsx @@ -19,6 +19,10 @@ const ChatSider: React.FC<{ return ; } + if (conversation?.type === 'codex' && conversation.extra?.workspace) { + return ; + } + return
; }; diff --git a/src/renderer/pages/conversation/ChatWorkspace.tsx b/src/renderer/pages/conversation/ChatWorkspace.tsx index ef17e1ec..74b93193 100644 --- a/src/renderer/pages/conversation/ChatWorkspace.tsx +++ b/src/renderer/pages/conversation/ChatWorkspace.tsx @@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'; interface WorkspaceProps { workspace: string; conversation_id: string; - eventPrefix?: 'gemini' | 'acp'; + eventPrefix?: 'gemini' | 'acp' | 'codex'; } const ChatWorkspace: React.FC = ({ conversation_id, workspace, eventPrefix = 'gemini' }) => { @@ -39,8 +39,10 @@ const ChatWorkspace: React.FC = ({ conversation_id, workspace, e // 根据 eventPrefix 选择对应的 getWorkspace 方法 const getWorkspaceMethod = _eventPrefix === 'acp' - ? ipcBridge.acpConversation.getWorkspace // 使用 ACP 专用的 getWorkspace - : ipcBridge.geminiConversation.getWorkspace; + ? ipcBridge.acpConversation.getWorkspace + : _eventPrefix === 'codex' + ? ipcBridge.codexConversation.getWorkspace // 使用 ACP 专用的 getWorkspace + : ipcBridge.geminiConversation.getWorkspace; getWorkspaceMethod .invoke({ conversation_id }) @@ -73,23 +75,28 @@ const ChatWorkspace: React.FC = ({ conversation_id, workspace, e refreshWorkspace(eventPrefix, conversation_id); } }; - const handleAcpResponse = (data: any) => { if (data.type === 'acp_tool_call') { refreshWorkspace(eventPrefix, conversation_id); } }; - + const handleCodexResponse = (data: any) => { + if (data.type === 'codex_tool_call') { + refreshWorkspace(eventPrefix, conversation_id); + } + }; const unsubscribeGemini = ipcBridge.geminiConversation.responseStream.on(handleGeminiResponse); const unsubscribeAcp = ipcBridge.acpConversation.responseStream.on(handleAcpResponse); + const unsubscribeCodex = ipcBridge.codexConversation.responseStream.on(handleCodexResponse); return () => { unsubscribeGemini(); unsubscribeAcp(); + unsubscribeCodex(); }; }, [conversation_id, eventPrefix]); - useAddEventListener(`${eventPrefix}.workspace.refresh`, () => refreshWorkspace(eventPrefix, workspace), [workspace, eventPrefix]); + useAddEventListener(`${eventPrefix}.workspace.refresh`, () => refreshWorkspace(eventPrefix, conversation_id), [workspace, eventPrefix]); // File search filter logic const filteredFiles = useMemo(() => { @@ -123,7 +130,7 @@ const ChatWorkspace: React.FC = ({ conversation_id, workspace, e
{t('common.file')} - refreshWorkspace(eventPrefix, workspace)} /> + refreshWorkspace(eventPrefix, conversation_id)} />
{hasOriginalFiles && (
diff --git a/src/renderer/pages/conversation/acp/AcpSendBox.tsx b/src/renderer/pages/conversation/acp/AcpSendBox.tsx index 1d7eb13f..e3932a2d 100644 --- a/src/renderer/pages/conversation/acp/AcpSendBox.tsx +++ b/src/renderer/pages/conversation/acp/AcpSendBox.tsx @@ -5,11 +5,12 @@ import type { IResponseMessage } from '@/common/ipcBridge'; import { uuid } from '@/common/utils'; import SendBox from '@/renderer/components/sendbox'; import ShimmerText from '@/renderer/components/ShimmerText'; +import ThoughtDisplay, { type ThoughtData } from '@/renderer/components/ThoughtDisplay'; import { getSendBoxDraftHook } from '@/renderer/hooks/useSendBoxDraft'; +import { createSetUploadFile, useSendBoxFiles } from '@/renderer/hooks/useSendBoxFiles'; import { useAddOrUpdateMessage } from '@/renderer/messages/hooks'; -import { emitter, useAddEventListener } from '@/renderer/utils/emitter'; import { allSupportedExts, getCleanFileName } from '@/renderer/services/FileService'; -import { useSendBoxFiles, createSetUploadFile } from '@/renderer/hooks/useSendBoxFiles'; +import { emitter, useAddEventListener } from '@/renderer/utils/emitter'; import { Button, Tag } from '@arco-design/web-react'; import { Plus } from '@icon-park/react'; import classNames from 'classnames'; @@ -26,7 +27,7 @@ const useAcpSendBoxDraft = getSendBoxDraftHook('acp', { const useAcpMessage = (conversation_id: string) => { const addOrUpdateMessage = useAddOrUpdateMessage(); const [running, setRunning] = useState(false); - const [thought, setThought] = useState({ + const [thought, setThought] = useState({ description: '', subject: '', }); @@ -48,11 +49,8 @@ const useAcpMessage = (conversation_id: string) => { break; case 'finish': setRunning(false); - setThought({ subject: '', description: '' }); - break; - case 'ai_end_turn': - // End AI processing state setAiProcessing(false); + setThought({ subject: '', description: '' }); break; case 'content': // Clear thought when final answer arrives @@ -289,20 +287,7 @@ const AcpSendBox: React.FC<{ return (
- {thought.subject ? ( -
- - {thought.subject} - - {thought.description} -
- ) : null} + {aiProcessing && {t('common.loading', { defaultValue: 'Please wait...' })}} @@ -315,12 +300,9 @@ const AcpSendBox: React.FC<{ onStop={() => { return ipcBridge.conversation.stop.invoke({ conversation_id }).then(() => {}); }} - className={classNames('z-10 ', { - 'mt-0px': !!thought.subject, - })} + className='z-10' onFilesAdded={handleFilesAdded} supportedExts={allSupportedExts} - componentId={`acp-${conversation_id}`} tools={ <> + + } + onSend={onSendHandler} + > +
+ ); +}; + +export default CodexSendBox; diff --git a/src/renderer/pages/conversation/gemini/GeminiSendBox.tsx b/src/renderer/pages/conversation/gemini/GeminiSendBox.tsx index fa3626b3..e0d83631 100644 --- a/src/renderer/pages/conversation/gemini/GeminiSendBox.tsx +++ b/src/renderer/pages/conversation/gemini/GeminiSendBox.tsx @@ -4,15 +4,16 @@ import type { TProviderWithModel } from '@/common/storage'; import { uuid } from '@/common/utils'; import SendBox from '@/renderer/components/sendbox'; import { getSendBoxDraftHook } from '@/renderer/hooks/useSendBoxDraft'; +import { createSetUploadFile, useSendBoxFiles } from '@/renderer/hooks/useSendBoxFiles'; import { useAddOrUpdateMessage } from '@/renderer/messages/hooks'; -import { emitter, useAddEventListener } from '@/renderer/utils/emitter'; import { allSupportedExts, getCleanFileName } from '@/renderer/services/FileService'; -import { useSendBoxFiles, createSetUploadFile } from '@/renderer/hooks/useSendBoxFiles'; +import { emitter, useAddEventListener } from '@/renderer/utils/emitter'; import { Button, Tag } from '@arco-design/web-react'; import { Plus } from '@icon-park/react'; import classNames from 'classnames'; import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import ThoughtDisplay, { type ThoughtData } from '@/renderer/components/ThoughtDisplay'; const useGeminiSendBoxDraft = getSendBoxDraftHook('gemini', { _type: 'gemini', @@ -24,7 +25,7 @@ const useGeminiSendBoxDraft = getSendBoxDraftHook('gemini', { const useGeminiMessage = (conversation_id: string) => { const addMessage = useAddOrUpdateMessage(); const [running, setRunning] = useState(false); - const [thought, setThought] = useState({ + const [thought, setThought] = useState({ description: '', subject: '', }); @@ -158,24 +159,7 @@ const GeminiSendBox: React.FC<{ return (
- {thought.subject ? ( -
- - {thought.subject} - - {/* */} - {/*
*/} - {thought.description} - {/*
*/} - {/*
*/} -
- ) : null} +