From 1c7b7a1a551c8a78b331753a0faacadf0187c0ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Tue, 12 Aug 2025 11:54:38 +0800 Subject: [PATCH] feat: add code tools (#9043) * feat: add code tools * feat(CodeToolsService): add CLI executable management and installation check - Introduced methods to determine the CLI executable name based on the tool. - Added functionality to check if a package is installed and create the necessary bin directory if it doesn't exist. - Enhanced the run method to handle installation and execution of CLI tools based on their installation status. - Updated terminal command handling for different operating systems with improved comments and error messages. * feat(ipService): implement IP address country detection and npm registry URL selection - Added a new module for IP address country detection using the ipinfo.io API. - Implemented functions to check if the user is in China and to return the appropriate npm registry URL based on the user's location. - Updated AppUpdater and CodeToolsService to utilize the new ipService functions for improved user experience based on geographical location. - Enhanced error handling and logging for better debugging and user feedback. * feat: remember cli model * feat(CodeToolsService): update options for auto-update functionality - Refactored the options parameter in CodeToolsService to replace checkUpdate and forceUpdate with autoUpdateToLatest. - Updated logic to handle automatic updates when the CLI tool is already installed. - Modified related UI components to reflect the new auto-update option. - Added corresponding translations for the new feature in multiple languages. * feat(CodeToolsService): enhance CLI tool launch with debugging support - Added detailed logging for CLI tool launch process, including environment variables and options. - Implemented a temporary batch file for Windows to facilitate debugging and command execution. - Improved error handling and cleanup for the temporary batch file after execution. - Updated terminal command handling to use the new batch file for safer execution. * refactor(CodeToolsService): simplify command execution output - Removed display of environment variable settings during command execution in the CLI tool. - Updated comments for clarity on the command execution process. * feat(CodePage): add model filtering logic for provider selection - Introduced a modelPredicate function to filter out embedding, rerank, and text-to-image models from the available providers. - Updated the ModelSelector component to utilize the new predicate for improved model selection experience. * refactor(CodeToolsService): improve logging and cleanup for CLI tool execution - Updated logging to display only the keys of environment variables during CLI tool launch for better clarity. - Introduced a variable to store the path of the temporary batch file for Windows. - Enhanced cleanup logic to remove the temporary batch file after execution, improving resource management. * feat(Router): replace CodePage with CodeToolsPage and add new page for code tools - Updated Router to import and route to the new CodeToolsPage instead of the old CodePage. - Introduced CodeToolsPage component, which provides a user interface for selecting CLI tools and models, managing directories, and launching code tools with enhanced functionality. * refactor(CodeToolsService): improve temporary file management and cleanup - Removed unused variable for Windows batch file path. - Added a cleanup task to delete the temporary batch file after 10 seconds to enhance resource management. - Updated logging to ensure clarity during the execution of CLI tools. * refactor(CodeToolsService): streamline environment variable handling for CLI tool execution - Introduced a utility function to remove proxy-related environment variables before launching terminal processes. - Updated logging to display only the relevant environment variable keys, enhancing clarity during execution. * refactor(MCPService, CodeToolsService): unify proxy environment variable handling - Replaced custom proxy removal logic with a shared utility function `removeEnvProxy` to streamline environment variable management across services. - Updated logging to reflect changes in environment variable handling during CLI tool execution. --- packages/shared/IpcChannel.ts | 5 +- resources/scripts/ipService.js | 88 ++++ src/main/ipc.ts | 4 + src/main/services/AppUpdater.ts | 27 +- src/main/services/CodeToolsService.ts | 476 ++++++++++++++++++ src/main/services/MCPService.ts | 12 +- src/main/utils/index.ts | 8 + src/main/utils/ipService.ts | 42 ++ src/preload/index.ts | 9 + src/renderer/src/Router.tsx | 2 + .../src/components/Tab/TabContainer.tsx | 3 + src/renderer/src/hooks/useCodeTools.ts | 109 ++++ src/renderer/src/i18n/label.ts | 1 + src/renderer/src/i18n/locales/en-us.json | 26 + src/renderer/src/i18n/locales/ja-jp.json | 26 + src/renderer/src/i18n/locales/ru-ru.json | 26 + src/renderer/src/i18n/locales/zh-cn.json | 26 + src/renderer/src/i18n/locales/zh-tw.json | 26 + src/renderer/src/pages/code/CodeToolsPage.tsx | 383 ++++++++++++++ src/renderer/src/pages/code/index.ts | 1 + .../src/pages/launchpad/LaunchpadPage.tsx | 8 +- src/renderer/src/store/codeTools.ts | 112 +++++ src/renderer/src/store/index.ts | 2 + 23 files changed, 1385 insertions(+), 37 deletions(-) create mode 100644 resources/scripts/ipService.js create mode 100644 src/main/services/CodeToolsService.ts create mode 100644 src/main/utils/ipService.ts create mode 100644 src/renderer/src/hooks/useCodeTools.ts create mode 100644 src/renderer/src/pages/code/CodeToolsPage.tsx create mode 100644 src/renderer/src/pages/code/index.ts create mode 100644 src/renderer/src/store/codeTools.ts diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index fd47e10800..bae5c54a7e 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -276,5 +276,8 @@ export enum IpcChannel { TRACE_SET_TITLE = 'trace:setTitle', TRACE_ADD_END_MESSAGE = 'trace:addEndMessage', TRACE_CLEAN_LOCAL_DATA = 'trace:cleanLocalData', - TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage' + TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage', + + // CodeTools + CodeTools_Run = 'code-tools:run' } diff --git a/resources/scripts/ipService.js b/resources/scripts/ipService.js new file mode 100644 index 0000000000..8e997659a7 --- /dev/null +++ b/resources/scripts/ipService.js @@ -0,0 +1,88 @@ +const https = require('https') +const { loggerService } = require('@logger') + +const logger = loggerService.withContext('IpService') + +/** + * 获取用户的IP地址所在国家 + * @returns {Promise} 返回国家代码,默认为'CN' + */ +async function getIpCountry() { + return new Promise((resolve) => { + // 添加超时控制 + const timeout = setTimeout(() => { + logger.info('IP Address Check Timeout, default to China Mirror') + resolve('CN') + }, 5000) + + const options = { + hostname: 'ipinfo.io', + path: '/json', + method: 'GET', + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + 'Accept-Language': 'en-US,en;q=0.9' + } + } + + const req = https.request(options, (res) => { + clearTimeout(timeout) + let data = '' + + res.on('data', (chunk) => { + data += chunk + }) + + res.on('end', () => { + try { + const parsed = JSON.parse(data) + const country = parsed.country || 'CN' + logger.info(`Detected user IP address country: ${country}`) + resolve(country) + } catch (error) { + logger.error('Failed to parse IP address information:', error.message) + resolve('CN') + } + }) + }) + + req.on('error', (error) => { + clearTimeout(timeout) + logger.error('Failed to get IP address information:', error.message) + resolve('CN') + }) + + req.end() + }) +} + +/** + * 检查用户是否在中国 + * @returns {Promise} 如果用户在中国返回true,否则返回false + */ +async function isUserInChina() { + const country = await getIpCountry() + return country.toLowerCase() === 'cn' +} + +/** + * 根据用户位置获取适合的npm镜像URL + * @returns {Promise} 返回npm镜像URL + */ +async function getNpmRegistryUrl() { + const inChina = await isUserInChina() + if (inChina) { + logger.info('User in China, using Taobao npm mirror') + return 'https://registry.npmmirror.com' + } else { + logger.info('User not in China, using default npm mirror') + return 'https://registry.npmjs.org' + } +} + +module.exports = { + getIpCountry, + isUserInChina, + getNpmRegistryUrl +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 20f3f5ca96..e607cb34f7 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -16,6 +16,7 @@ import { Notification } from 'src/renderer/src/types/notification' import appService from './services/AppService' import AppUpdater from './services/AppUpdater' import BackupManager from './services/BackupManager' +import { codeToolsService } from './services/CodeToolsService' import { configManager } from './services/ConfigManager' import CopilotService from './services/CopilotService' import DxtService from './services/DxtService' @@ -700,4 +701,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { (_, spanId: string, modelName: string, context: string, msg: any) => addStreamMessage(spanId, modelName, context, msg) ) + + // CodeTools + ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run) } diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index 1836759a13..9ae8bda331 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -1,5 +1,6 @@ import { loggerService } from '@logger' import { isWin } from '@main/constant' +import { getIpCountry } from '@main/utils/ipService' import { locales } from '@main/utils/locales' import { generateUserAgent } from '@main/utils/systemInfo' import { FeedUrl, UpgradeChannel } from '@shared/config/constant' @@ -98,30 +99,6 @@ export default class AppUpdater { } } - private async _getIpCountry() { - try { - // add timeout using AbortController - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 5000) - - const ipinfo = await fetch('https://ipinfo.io/json', { - signal: controller.signal, - headers: { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', - 'Accept-Language': 'en-US,en;q=0.9' - } - }) - - clearTimeout(timeoutId) - const data = await ipinfo.json() - return data.country || 'CN' - } catch (error) { - logger.error('Failed to get ipinfo:', error as Error) - return 'CN' - } - } - public setAutoUpdate(isActive: boolean) { autoUpdater.autoDownload = isActive autoUpdater.autoInstallOnAppQuit = isActive @@ -186,7 +163,7 @@ export default class AppUpdater { } this._setChannel(UpgradeChannel.LATEST, FeedUrl.PRODUCTION) - const ipCountry = await this._getIpCountry() + const ipCountry = await getIpCountry() logger.info(`ipCountry is ${ipCountry}, set channel to ${UpgradeChannel.LATEST}`) if (ipCountry.toLowerCase() !== 'cn') { this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST) diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts new file mode 100644 index 0000000000..3ed00dae1a --- /dev/null +++ b/src/main/services/CodeToolsService.ts @@ -0,0 +1,476 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { loggerService } from '@logger' +import { removeEnvProxy } from '@main/utils' +import { isUserInChina } from '@main/utils/ipService' +import { getBinaryName } from '@main/utils/process' +import { spawn } from 'child_process' +import { promisify } from 'util' + +const execAsync = promisify(require('child_process').exec) +const logger = loggerService.withContext('CodeToolsService') + +interface VersionInfo { + installed: string | null + latest: string | null + needsUpdate: boolean +} + +class CodeToolsService { + private versionCache: Map = new Map() + private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache + + constructor() { + this.getBunPath = this.getBunPath.bind(this) + this.getPackageName = this.getPackageName.bind(this) + this.getCliExecutableName = this.getCliExecutableName.bind(this) + this.isPackageInstalled = this.isPackageInstalled.bind(this) + this.getVersionInfo = this.getVersionInfo.bind(this) + this.updatePackage = this.updatePackage.bind(this) + this.run = this.run.bind(this) + } + + public async getBunPath() { + const dir = path.join(os.homedir(), '.cherrystudio', 'bin') + const bunName = await getBinaryName('bun') + const bunPath = path.join(dir, bunName) + return bunPath + } + + public async getPackageName(cliTool: string) { + if (cliTool === 'claude-code') { + return '@anthropic-ai/claude-code' + } + if (cliTool === 'gemini-cli') { + return '@google/gemini-cli' + } + return '@qwen-code/qwen-code' + } + + public async getCliExecutableName(cliTool: string) { + if (cliTool === 'claude-code') { + return 'claude' + } + if (cliTool === 'gemini-cli') { + return 'gemini' + } + return 'qwen' + } + + private async isPackageInstalled(cliTool: string): Promise { + const executableName = await this.getCliExecutableName(cliTool) + const binDir = path.join(os.homedir(), '.cherrystudio', 'bin') + const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : '')) + + // Ensure bin directory exists + if (!fs.existsSync(binDir)) { + fs.mkdirSync(binDir, { recursive: true }) + } + + return fs.existsSync(executablePath) + } + + /** + * Get version information for a CLI tool + */ + public async getVersionInfo(cliTool: string): Promise { + logger.info(`Starting version check for ${cliTool}`) + const packageName = await this.getPackageName(cliTool) + const isInstalled = await this.isPackageInstalled(cliTool) + + let installedVersion: string | null = null + let latestVersion: string | null = null + + // Get installed version if package is installed + if (isInstalled) { + logger.info(`${cliTool} is installed, getting current version`) + try { + const executableName = await this.getCliExecutableName(cliTool) + const binDir = path.join(os.homedir(), '.cherrystudio', 'bin') + const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : '')) + + const { stdout } = await execAsync(`"${executablePath}" --version`, { timeout: 10000 }) + // Extract version number from output (format may vary by tool) + const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/) + installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0] + logger.info(`${cliTool} current installed version: ${installedVersion}`) + } catch (error) { + logger.warn(`Failed to get installed version for ${cliTool}:`, error as Error) + } + } else { + logger.info(`${cliTool} is not installed`) + } + + // Get latest version from npm (with cache) + const cacheKey = `${packageName}-latest` + const cached = this.versionCache.get(cacheKey) + const now = Date.now() + + if (cached && now - cached.timestamp < this.CACHE_DURATION) { + logger.info(`Using cached latest version for ${packageName}: ${cached.version}`) + latestVersion = cached.version + } else { + logger.info(`Fetching latest version for ${packageName} from npm`) + try { + const bunPath = await this.getBunPath() + const { stdout } = await execAsync(`"${bunPath}" info ${packageName} version`, { timeout: 15000 }) + latestVersion = stdout.trim().replace(/["']/g, '') + logger.info(`${packageName} latest version: ${latestVersion}`) + + // Cache the result + this.versionCache.set(cacheKey, { version: latestVersion!, timestamp: now }) + logger.debug(`Cached latest version for ${packageName}`) + } catch (error) { + logger.warn(`Failed to get latest version for ${packageName}:`, error as Error) + // If we have a cached version, use it even if expired + if (cached) { + logger.info(`Using expired cached version for ${packageName}: ${cached.version}`) + latestVersion = cached.version + } + } + } + + const needsUpdate = !!(installedVersion && latestVersion && installedVersion !== latestVersion) + logger.info( + `Version check result for ${cliTool}: installed=${installedVersion}, latest=${latestVersion}, needsUpdate=${needsUpdate}` + ) + + return { + installed: installedVersion, + latest: latestVersion, + needsUpdate + } + } + + /** + * Get npm registry URL based on user location + */ + private async getNpmRegistryUrl(): Promise { + try { + const inChina = await isUserInChina() + if (inChina) { + logger.info('User in China, using Taobao npm mirror') + return 'https://registry.npmmirror.com' + } else { + logger.info('User not in China, using default npm mirror') + return 'https://registry.npmjs.org' + } + } catch (error) { + logger.warn('Failed to detect user location, using default npm mirror') + return 'https://registry.npmjs.org' + } + } + + /** + * Update a CLI tool to the latest version + */ + public async updatePackage(cliTool: string): Promise<{ success: boolean; message: string }> { + logger.info(`Starting update process for ${cliTool}`) + try { + const packageName = await this.getPackageName(cliTool) + const bunPath = await this.getBunPath() + const bunInstallPath = path.join(os.homedir(), '.cherrystudio') + const registryUrl = await this.getNpmRegistryUrl() + + const installEnvPrefix = + process.platform === 'win32' + ? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&` + : `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&` + + const updateCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}` + logger.info(`Executing update command: ${updateCommand}`) + + await execAsync(updateCommand, { timeout: 60000 }) + logger.info(`Successfully executed update command for ${cliTool}`) + + // Clear version cache for this package + const cacheKey = `${packageName}-latest` + this.versionCache.delete(cacheKey) + logger.debug(`Cleared version cache for ${packageName}`) + + const successMessage = `Successfully updated ${cliTool} to the latest version` + logger.info(successMessage) + return { + success: true, + message: successMessage + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const failureMessage = `Failed to update ${cliTool}: ${errorMessage}` + logger.error(failureMessage, error as Error) + return { + success: false, + message: failureMessage + } + } + } + + async run( + _: Electron.IpcMainInvokeEvent, + cliTool: string, + _model: string, + directory: string, + env: Record, + options: { autoUpdateToLatest?: boolean } = {} + ) { + logger.info(`Starting CLI tool launch: ${cliTool} in directory: ${directory}`) + logger.debug(`Environment variables:`, Object.keys(env)) + logger.debug(`Options:`, options) + + const packageName = await this.getPackageName(cliTool) + const bunPath = await this.getBunPath() + const executableName = await this.getCliExecutableName(cliTool) + const binDir = path.join(os.homedir(), '.cherrystudio', 'bin') + const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : '')) + + logger.debug(`Package name: ${packageName}`) + logger.debug(`Bun path: ${bunPath}`) + logger.debug(`Executable name: ${executableName}`) + logger.debug(`Executable path: ${executablePath}`) + + // Check if package is already installed + const isInstalled = await this.isPackageInstalled(cliTool) + + // Check for updates and auto-update if requested + let updateMessage = '' + if (isInstalled && options.autoUpdateToLatest) { + logger.info(`Auto update to latest enabled for ${cliTool}`) + try { + const versionInfo = await this.getVersionInfo(cliTool) + if (versionInfo.needsUpdate) { + logger.info(`Update available for ${cliTool}: ${versionInfo.installed} -> ${versionInfo.latest}`) + logger.info(`Auto-updating ${cliTool} to latest version`) + updateMessage = ` && echo "Updating ${cliTool} from ${versionInfo.installed} to ${versionInfo.latest}..."` + const updateResult = await this.updatePackage(cliTool) + if (updateResult.success) { + logger.info(`Update completed successfully for ${cliTool}`) + updateMessage += ` && echo "Update completed successfully"` + } else { + logger.error(`Update failed for ${cliTool}: ${updateResult.message}`) + updateMessage += ` && echo "Update failed: ${updateResult.message}"` + } + } else if (versionInfo.installed && versionInfo.latest) { + logger.info(`${cliTool} is already up to date (${versionInfo.installed})`) + updateMessage = ` && echo "${cliTool} is up to date (${versionInfo.installed})"` + } + } catch (error) { + logger.warn(`Failed to check version for ${cliTool}:`, error as Error) + } + } + + // Select different terminal based on operating system + const platform = process.platform + let terminalCommand: string + let terminalArgs: string[] + + // Build environment variable prefix (based on platform) + const buildEnvPrefix = (isWindows: boolean) => { + if (Object.keys(env).length === 0) return '' + + if (isWindows) { + // Windows uses set command + return Object.entries(env) + .map(([key, value]) => `set "${key}=${value.replace(/"/g, '\\"')}"`) + .join(' && ') + } else { + // Unix-like systems use export command + return Object.entries(env) + .map(([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`) + .join(' && ') + } + } + + // Build command to execute + let baseCommand: string + const bunInstallPath = path.join(os.homedir(), '.cherrystudio') + + if (isInstalled) { + // If already installed, run executable directly (with optional update message) + baseCommand = `"${executablePath}"` + if (updateMessage) { + baseCommand = `echo "Checking ${cliTool} version..."${updateMessage} && ${baseCommand}` + } + } else { + // If not installed, install first then run + const registryUrl = await this.getNpmRegistryUrl() + const installEnvPrefix = + platform === 'win32' + ? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&` + : `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&` + + const installCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}` + baseCommand = `echo "Installing ${packageName}..." && ${installCommand} && echo "Installation complete, starting ${cliTool}..." && "${executablePath}"` + } + + switch (platform) { + case 'darwin': { + // macOS - Use osascript to launch terminal and execute command directly, without showing startup command + const envPrefix = buildEnvPrefix(false) + const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand + + terminalCommand = 'osascript' + terminalArgs = [ + '-e', + `tell application "Terminal" + activate + do script "cd '${directory.replace(/'/g, "\\'")}' && clear && ${command.replace(/"/g, '\\"')}" +end tell` + ] + break + } + case 'win32': { + // Windows - Use temp bat file for debugging + const envPrefix = buildEnvPrefix(true) + const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand + + // Create temp bat file for debugging and avoid complex command line escaping issues + const tempDir = path.join(os.tmpdir(), 'cherrystudio') + const timestamp = Date.now() + const batFileName = `launch_${cliTool}_${timestamp}.bat` + const batFilePath = path.join(tempDir, batFileName) + + // Ensure temp directory exists + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }) + } + + // Build bat file content, including debug information + const batContent = [ + '@echo off', + `title ${cliTool} - Cherry Studio`, // Set window title in bat file + 'echo ================================================', + 'echo Cherry Studio CLI Tool Launcher', + `echo Tool: ${cliTool}`, + `echo Directory: ${directory}`, + `echo Time: ${new Date().toLocaleString()}`, + 'echo ================================================', + '', + ':: Change to target directory', + `cd /d "${directory}" || (`, + ' echo ERROR: Failed to change directory', + ` echo Target directory: ${directory}`, + ' pause', + ' exit /b 1', + ')', + '', + ':: Clear screen', + 'cls', + '', + ':: Execute command (without displaying environment variable settings)', + command, + '', + ':: Command execution completed', + 'echo.', + 'echo Command execution completed.', + 'echo Press any key to close this window...', + 'pause >nul' + ].join('\r\n') + + // Write to bat file + try { + fs.writeFileSync(batFilePath, batContent, 'utf8') + logger.info(`Created temp bat file: ${batFilePath}`) + } catch (error) { + logger.error(`Failed to create bat file: ${error}`) + throw new Error(`Failed to create launch script: ${error}`) + } + + // Launch bat file - Use safest start syntax, no title parameter + terminalCommand = 'cmd' + terminalArgs = ['/c', 'start', batFilePath] + + // Set cleanup task (delete temp file after 5 minutes) + setTimeout(() => { + try { + fs.existsSync(batFilePath) && fs.unlinkSync(batFilePath) + } catch (error) { + logger.warn(`Failed to cleanup temp bat file: ${error}`) + } + }, 10 * 1000) // Delete temp file after 10 seconds + + break + } + case 'linux': { + // Linux - Try to use common terminal emulators + const envPrefix = buildEnvPrefix(false) + const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand + + const linuxTerminals = ['gnome-terminal', 'konsole', 'xterm', 'x-terminal-emulator'] + let foundTerminal = 'xterm' // Default to xterm + + for (const terminal of linuxTerminals) { + try { + // Check if terminal exists + const checkResult = spawn('which', [terminal], { stdio: 'pipe' }) + await new Promise((resolve) => { + checkResult.on('close', (code) => { + if (code === 0) { + foundTerminal = terminal + } + resolve(code) + }) + }) + if (foundTerminal === terminal) break + } catch (error) { + // Continue trying next terminal + } + } + + if (foundTerminal === 'gnome-terminal') { + terminalCommand = 'gnome-terminal' + terminalArgs = ['--working-directory', directory, '--', 'bash', '-c', `clear && ${command}; exec bash`] + } else if (foundTerminal === 'konsole') { + terminalCommand = 'konsole' + terminalArgs = ['--workdir', directory, '-e', 'bash', '-c', `clear && ${command}; exec bash`] + } else { + // Default to xterm + terminalCommand = 'xterm' + terminalArgs = ['-e', `cd "${directory}" && clear && ${command} && bash`] + } + break + } + default: + throw new Error(`Unsupported operating system: ${platform}`) + } + + const processEnv = { ...process.env, ...env } + removeEnvProxy(processEnv as Record) + + // Launch terminal process + try { + logger.info(`Launching terminal with command: ${terminalCommand}`) + logger.debug(`Terminal arguments:`, terminalArgs) + logger.debug(`Working directory: ${directory}`) + logger.debug(`Process environment keys: ${Object.keys(processEnv)}`) + + spawn(terminalCommand, terminalArgs, { + detached: true, + stdio: 'ignore', + cwd: directory, + env: processEnv + }) + + const successMessage = `Launched ${cliTool} in new terminal window` + logger.info(successMessage) + + return { + success: true, + message: successMessage, + command: `${terminalCommand} ${terminalArgs.join(' ')}` + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const failureMessage = `Failed to launch terminal: ${errorMessage}` + logger.error(failureMessage, error as Error) + return { + success: false, + message: failureMessage, + command: `${terminalCommand} ${terminalArgs.join(' ')}` + } + } + } +} + +export const codeToolsService = new CodeToolsService() diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 0921a5b82d..d3909cc86f 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -4,7 +4,7 @@ import path from 'node:path' import { loggerService } from '@logger' import { createInMemoryMCPServer } from '@main/mcpServers/factory' -import { makeSureDirExists } from '@main/utils' +import { makeSureDirExists, removeEnvProxy } from '@main/utils' import { buildFunctionCallToolName } from '@main/utils/mcp' import { getBinaryName, getBinaryPath } from '@main/utils/process' import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core' @@ -280,7 +280,7 @@ class McpService { // Bun not support proxy https://github.com/oven-sh/bun/issues/16812 if (cmd.includes('bun')) { - this.removeProxyEnv(loginShellEnv) + removeEnvProxy(loginShellEnv) } const transportOptions: any = { @@ -827,14 +827,6 @@ class McpService { } }) - private removeProxyEnv(env: Record) { - delete env.HTTPS_PROXY - delete env.HTTP_PROXY - delete env.grpc_proxy - delete env.http_proxy - delete env.https_proxy - } - // 实现 abortTool 方法 public async abortTool(_: Electron.IpcMainInvokeEvent, callId: string) { const activeToolCall = this.activeToolCalls.get(callId) diff --git a/src/main/utils/index.ts b/src/main/utils/index.ts index a5f63fcc42..4fea14c9fe 100644 --- a/src/main/utils/index.ts +++ b/src/main/utils/index.ts @@ -70,3 +70,11 @@ export async function calculateDirectorySize(directoryPath: string): Promise) => { + delete env.HTTPS_PROXY + delete env.HTTP_PROXY + delete env.grpc_proxy + delete env.http_proxy + delete env.https_proxy +} diff --git a/src/main/utils/ipService.ts b/src/main/utils/ipService.ts new file mode 100644 index 0000000000..ec5ab78215 --- /dev/null +++ b/src/main/utils/ipService.ts @@ -0,0 +1,42 @@ +import { loggerService } from '@logger' + +const logger = loggerService.withContext('IpService') + +/** + * 获取用户的IP地址所在国家 + * @returns 返回国家代码,默认为'CN' + */ +export async function getIpCountry(): Promise { + try { + // 添加超时控制 + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 5000) + + const ipinfo = await fetch('https://ipinfo.io/json', { + signal: controller.signal, + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + 'Accept-Language': 'en-US,en;q=0.9' + } + }) + + clearTimeout(timeoutId) + const data = await ipinfo.json() + const country = data.country || 'CN' + logger.info(`Detected user IP address country: ${country}`) + return country + } catch (error) { + logger.error('Failed to get IP address information:', error as Error) + return 'CN' + } +} + +/** + * 检查用户是否在中国 + * @returns 如果用户在中国返回true,否则返回false + */ +export async function isUserInChina(): Promise { + const country = await getIpCountry() + return country.toLowerCase() === 'cn' +} diff --git a/src/preload/index.ts b/src/preload/index.ts index c343b7d760..9b46576476 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -394,6 +394,15 @@ const api = { cleanLocalData: () => ipcRenderer.invoke(IpcChannel.TRACE_CLEAN_LOCAL_DATA), addStreamMessage: (spanId: string, modelName: string, context: string, message: any) => ipcRenderer.invoke(IpcChannel.TRACE_ADD_STREAM_MESSAGE, spanId, modelName, context, message) + }, + codeTools: { + run: ( + cliTool: string, + model: string, + directory: string, + env: Record, + options?: { autoUpdateToLatest?: boolean } + ) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options) } } diff --git a/src/renderer/src/Router.tsx b/src/renderer/src/Router.tsx index 624c6ccc47..627fb37546 100644 --- a/src/renderer/src/Router.tsx +++ b/src/renderer/src/Router.tsx @@ -8,6 +8,7 @@ import TabsContainer from './components/Tab/TabContainer' import NavigationHandler from './handler/NavigationHandler' import { useNavbarPosition } from './hooks/useSettings' import AgentsPage from './pages/agents/AgentsPage' +import CodeToolsPage from './pages/code/CodeToolsPage' import FilesPage from './pages/files/FilesPage' import HomePage from './pages/home/HomePage' import KnowledgePage from './pages/knowledge/KnowledgePage' @@ -30,6 +31,7 @@ const Router: FC = () => { } /> } /> } /> + } /> } /> } /> diff --git a/src/renderer/src/components/Tab/TabContainer.tsx b/src/renderer/src/components/Tab/TabContainer.tsx index 81ebad43ec..49882506ca 100644 --- a/src/renderer/src/components/Tab/TabContainer.tsx +++ b/src/renderer/src/components/Tab/TabContainer.tsx @@ -24,6 +24,7 @@ import { Sparkle, SquareTerminal, Sun, + Terminal, X } from 'lucide-react' import { useCallback, useEffect } from 'react' @@ -57,6 +58,8 @@ const getTabIcon = (tabId: string): React.ReactNode | undefined => { return case 'settings': return + case 'code': + return default: return null } diff --git a/src/renderer/src/hooks/useCodeTools.ts b/src/renderer/src/hooks/useCodeTools.ts new file mode 100644 index 0000000000..dc942d004f --- /dev/null +++ b/src/renderer/src/hooks/useCodeTools.ts @@ -0,0 +1,109 @@ +import { loggerService } from '@renderer/services/LoggerService' +import { useAppDispatch, useAppSelector } from '@renderer/store' +import { + addDirectory, + clearDirectories, + removeDirectory, + resetCodeTools, + setCurrentDirectory, + setSelectedCliTool, + setSelectedModel +} from '@renderer/store/codeTools' +import { Model } from '@renderer/types' +import { useCallback } from 'react' + +export const useCodeTools = () => { + const dispatch = useAppDispatch() + const codeToolsState = useAppSelector((state) => state.codeTools) + const logger = loggerService.withContext('useCodeTools') + + // 设置选择的 CLI 工具 + const setCliTool = useCallback( + (tool: string) => { + dispatch(setSelectedCliTool(tool)) + }, + [dispatch] + ) + + // 设置选择的模型 + const setModel = useCallback( + (model: Model | null) => { + dispatch(setSelectedModel(model)) + }, + [dispatch] + ) + + // 添加目录 + const addDir = useCallback( + (directory: string) => { + dispatch(addDirectory(directory)) + }, + [dispatch] + ) + + // 删除目录 + const removeDir = useCallback( + (directory: string) => { + dispatch(removeDirectory(directory)) + }, + [dispatch] + ) + + // 设置当前目录 + const setCurrentDir = useCallback( + (directory: string) => { + dispatch(setCurrentDirectory(directory)) + }, + [dispatch] + ) + + // 清空所有目录 + const clearDirs = useCallback(() => { + dispatch(clearDirectories()) + }, [dispatch]) + + // 重置所有设置 + const resetSettings = useCallback(() => { + dispatch(resetCodeTools()) + }, [dispatch]) + + // 选择文件夹的辅助函数 + const selectFolder = useCallback(async () => { + try { + const folderPath = await window.api.file.selectFolder() + if (folderPath) { + setCurrentDir(folderPath) + return folderPath + } + return null + } catch (error) { + logger.error('选择文件夹失败:', error as Error) + throw error + } + }, [setCurrentDir, logger]) + + // 获取当前CLI工具选择的模型 + const selectedModel = codeToolsState.selectedModels[codeToolsState.selectedCliTool] || null + + // 检查是否可以启动(所有必需字段都已填写) + const canLaunch = Boolean(codeToolsState.selectedCliTool && selectedModel && codeToolsState.currentDirectory) + + return { + // 状态 + selectedCliTool: codeToolsState.selectedCliTool, + selectedModel: selectedModel, + directories: codeToolsState.directories, + currentDirectory: codeToolsState.currentDirectory, + canLaunch, + + // 操作函数 + setCliTool, + setModel, + addDir, + removeDir, + setCurrentDir, + clearDirs, + resetSettings, + selectFolder + } +} diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index 9bd7839c11..bdc90368e5 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -111,6 +111,7 @@ export const getProgressLabel = (key: string): string => { const titleKeyMap = { agents: 'title.agents', apps: 'title.apps', + code: 'title.code', files: 'title.files', home: 'title.home', knowledge: 'title.knowledge', diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 861af88c93..3a13cb9251 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -648,6 +648,31 @@ }, "translate": "Translate" }, + "code": { + "auto_update_to_latest": "Automatically update to latest version", + "bun_required_message": "Bun environment is required to run CLI tools", + "cli_tool": "CLI Tool", + "cli_tool_placeholder": "Select the CLI tool to use", + "description": "Quickly launch multiple code CLI tools to improve development efficiency", + "folder_placeholder": "Select working directory", + "install_bun": "Install Bun", + "installing_bun": "Installing...", + "launch": { + "bun_required": "Please install Bun environment first before launching CLI tools", + "error": "Launch failed, please try again", + "label": "Launch", + "success": "Launch successful", + "validation_error": "Please complete all required fields: CLI tool, model, and working directory" + }, + "launching": "Launching...", + "model": "Model", + "model_placeholder": "Select the model to use", + "model_required": "Please select a model", + "select_folder": "Select Folder", + "title": "Code Tools", + "update_options": "Update Options", + "working_directory": "Working Directory" + }, "code_block": { "collapse": "Collapse", "copy": { @@ -3561,6 +3586,7 @@ "title": { "agents": "Agents", "apps": "Apps", + "code": "Code", "files": "Files", "home": "Home", "knowledge": "Knowledge Base", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index a0a08924cd..fa4f46b90b 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -648,6 +648,31 @@ }, "translate": "翻訳" }, + "code": { + "auto_update_to_latest": "最新バージョンを自動的に更新する", + "bun_required_message": "CLI ツールを実行するには Bun 環境が必要です", + "cli_tool": "CLI ツール", + "cli_tool_placeholder": "使用する CLI ツールを選択してください", + "description": "開発効率を向上させるために、複数のコード CLI ツールを迅速に起動します", + "folder_placeholder": "作業ディレクトリを選択してください", + "install_bun": "Bun をインストール", + "installing_bun": "インストール中...", + "launch": { + "bun_required": "CLI ツールを実行するには Bun 環境が必要です。まず Bun をインストールしてください", + "error": "起動に失敗しました。もう一度試してください", + "label": "起動", + "success": "起動成功", + "validation_error": "必須項目を入力してください:CLI ツール、モデル、作業ディレクトリ" + }, + "launching": "起動中...", + "model": "モデル", + "model_placeholder": "使用するモデルを選択してください", + "model_required": "モデルを選択してください", + "select_folder": "フォルダを選択", + "title": "コードツール", + "update_options": "更新オプション", + "working_directory": "作業ディレクトリ" + }, "code_block": { "collapse": "折りたたむ", "copy": { @@ -3561,6 +3586,7 @@ "title": { "agents": "エージェント", "apps": "アプリ", + "code": "Code", "files": "ファイル", "home": "ホーム", "knowledge": "ナレッジベース", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 28547d5ad6..6395bbc3bf 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -648,6 +648,31 @@ }, "translate": "Перевести" }, + "code": { + "auto_update_to_latest": "Автоматически обновлять до последней версии", + "bun_required_message": "Запуск CLI-инструментов требует установки среды Bun", + "cli_tool": "Инструмент", + "cli_tool_placeholder": "Выберите CLI-инструмент для использования", + "description": "Быстро запускает несколько CLI-инструментов для кода, повышая эффективность разработки", + "folder_placeholder": "Выберите рабочую директорию", + "install_bun": "Установить Bun", + "installing_bun": "Установка...", + "launch": { + "bun_required": "Пожалуйста, установите среду Bun перед запуском CLI-инструментов", + "error": "Не удалось запустить. Пожалуйста, попробуйте снова", + "label": "Запуск", + "success": "Запуск успешно завершен", + "validation_error": "Пожалуйста, заполните все обязательные поля: CLI-инструмент, модель и рабочая директория" + }, + "launching": "Запуск...", + "model": "Модель", + "model_placeholder": "Выберите модель для использования", + "model_required": "Пожалуйста, выберите модель", + "select_folder": "Выберите папку", + "title": "Инструменты кода", + "update_options": "Параметры обновления", + "working_directory": "Рабочая директория" + }, "code_block": { "collapse": "Свернуть", "copy": { @@ -3561,6 +3586,7 @@ "title": { "agents": "Агенты", "apps": "Приложения", + "code": "Code", "files": "Файлы", "home": "Главная", "knowledge": "База знаний", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index dd46809b78..250aa8ee72 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -648,6 +648,31 @@ }, "translate": "翻译" }, + "code": { + "auto_update_to_latest": "检查更新并安装最新版本", + "bun_required_message": "运行 CLI 工具需要安装 Bun 环境", + "cli_tool": "CLI 工具", + "cli_tool_placeholder": "选择要使用的 CLI 工具", + "description": "快速启动多个代码 CLI 工具,提高开发效率", + "folder_placeholder": "选择工作目录", + "install_bun": "安装 Bun", + "installing_bun": "安装中...", + "launch": { + "bun_required": "请先安装 Bun 环境再启动 CLI 工具", + "error": "启动失败,请重试", + "label": "启动", + "success": "启动成功", + "validation_error": "请完成所有必填项:CLI 工具、模型和工作目录" + }, + "launching": "启动中...", + "model": "模型", + "model_placeholder": "选择要使用的模型", + "model_required": "请选择模型", + "select_folder": "选择文件夹", + "title": "代码工具", + "update_options": "更新选项", + "working_directory": "工作目录" + }, "code_block": { "collapse": "收起", "copy": { @@ -3561,6 +3586,7 @@ "title": { "agents": "智能体", "apps": "小程序", + "code": "Code", "files": "文件", "home": "首页", "knowledge": "知识库", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index c5e3be5888..b06f199777 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -648,6 +648,31 @@ }, "translate": "翻譯" }, + "code": { + "auto_update_to_latest": "檢查更新並安裝最新版本", + "bun_required_message": "運行 CLI 工具需要安裝 Bun 環境", + "cli_tool": "CLI 工具", + "cli_tool_placeholder": "選擇要使用的 CLI 工具", + "description": "快速啟動多個程式碼 CLI 工具,提高開發效率", + "folder_placeholder": "選擇工作目錄", + "install_bun": "安裝 Bun", + "installing_bun": "安裝中...", + "launch": { + "bun_required": "請先安裝 Bun 環境再啟動 CLI 工具", + "error": "啟動失敗,請重試", + "label": "啟動", + "success": "啟動成功", + "validation_error": "請完成所有必填項目:CLI 工具、模型和工作目錄" + }, + "launching": "啟動中...", + "model": "模型", + "model_placeholder": "選擇要使用的模型", + "model_required": "請選擇模型", + "select_folder": "選擇資料夾", + "title": "程式碼工具", + "update_options": "更新選項", + "working_directory": "工作目錄" + }, "code_block": { "collapse": "折疊", "copy": { @@ -3561,6 +3586,7 @@ "title": { "agents": "智能體", "apps": "小程序", + "code": "Code", "files": "文件", "home": "主頁", "knowledge": "知識庫", diff --git a/src/renderer/src/pages/code/CodeToolsPage.tsx b/src/renderer/src/pages/code/CodeToolsPage.tsx new file mode 100644 index 0000000000..c6d0b05977 --- /dev/null +++ b/src/renderer/src/pages/code/CodeToolsPage.tsx @@ -0,0 +1,383 @@ +import AiProvider from '@renderer/aiCore' +import ModelSelector from '@renderer/components/ModelSelector' +import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models' +import { useCodeTools } from '@renderer/hooks/useCodeTools' +import { useProviders } from '@renderer/hooks/useProvider' +import { getProviderByModel } from '@renderer/services/AssistantService' +import { loggerService } from '@renderer/services/LoggerService' +import { getModelUniqId } from '@renderer/services/ModelService' +import { useAppDispatch, useAppSelector } from '@renderer/store' +import { setIsBunInstalled } from '@renderer/store/mcp' +import { Model } from '@renderer/types' +import { Alert, Button, Checkbox, Select, Space } from 'antd' +import { Download, Terminal, X } from 'lucide-react' +import { FC, useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +// CLI 工具选项 +const CLI_TOOLS = [ + { value: 'qwen-code', label: 'Qwen Code' }, + { value: 'claude-code', label: 'Claude Code' }, + { value: 'gemini-cli', label: 'Gemini CLI' } +] + +const logger = loggerService.withContext('CodeToolsPage') + +const CodeToolsPage: FC = () => { + const { t } = useTranslation() + const { providers } = useProviders() + const dispatch = useAppDispatch() + const isBunInstalled = useAppSelector((state) => state.mcp.isBunInstalled) + const { + selectedCliTool, + selectedModel, + directories, + currentDirectory, + canLaunch, + setCliTool, + setModel, + setCurrentDir, + removeDir, + selectFolder + } = useCodeTools() + + // 状态管理 + const [isLaunching, setIsLaunching] = useState(false) + const [isInstallingBun, setIsInstallingBun] = useState(false) + const [autoUpdateToLatest, setAutoUpdateToLatest] = useState(false) + + // 处理 CLI 工具选择 + const handleCliToolChange = (value: string) => { + setCliTool(value) + // 不再清空模型选择,因为每个工具都会记住自己的模型 + } + + const openAiProviders = providers.filter((p) => p.type.includes('openai')) + const geminiProviders = providers.filter((p) => p.type === 'gemini') + const claudeProviders = providers.filter((p) => p.type === 'anthropic') + + const modelPredicate = useCallback( + (m: Model) => !isEmbeddingModel(m) && !isRerankModel(m) && !isTextToImageModel(m), + [] + ) + + const availableProviders = + selectedCliTool === 'claude-code' + ? claudeProviders + : selectedCliTool === 'gemini-cli' + ? geminiProviders + : openAiProviders + + // 处理模型选择 + const handleModelChange = (value: string) => { + if (!value) { + setModel(null) + return + } + + // 从所有 providers 中查找选中的模型 + for (const provider of providers || []) { + const model = provider.models.find((m) => getModelUniqId(m) === value) + if (model) { + setModel(model) + break + } + } + } + + // 处理文件夹选择 + const handleFolderSelect = async () => { + try { + await selectFolder() + } catch (error) { + logger.error('选择文件夹失败:', error as Error) + } + } + + // 处理目录选择 + const handleDirectoryChange = (value: string) => { + setCurrentDir(value) + } + + // 处理删除目录 + const handleRemoveDirectory = (directory: string, e: React.MouseEvent) => { + e.stopPropagation() + removeDir(directory) + } + + // 检查 bun 是否安装 + const checkBunInstallation = useCallback(async () => { + try { + const bunExists = await window.api.isBinaryExist('bun') + dispatch(setIsBunInstalled(bunExists)) + } catch (error) { + logger.error('检查 bun 安装状态失败:', error as Error) + dispatch(setIsBunInstalled(false)) + } + }, [dispatch]) + + // 安装 bun + const handleInstallBun = async () => { + try { + setIsInstallingBun(true) + await window.api.installBunBinary() + dispatch(setIsBunInstalled(true)) + window.message.success({ + content: t('settings.mcp.installSuccess'), + key: 'bun-install-message' + }) + } catch (error: any) { + logger.error('安装 bun 失败:', error as Error) + window.message.error({ + content: `${t('settings.mcp.installError')}: ${error.message}`, + key: 'bun-install-message' + }) + } finally { + setIsInstallingBun(false) + // 重新检查安装状态 + setTimeout(checkBunInstallation, 1000) + } + } + + // 处理启动 + const handleLaunch = async () => { + if (!canLaunch || !isBunInstalled) { + if (!isBunInstalled) { + window.message.warning({ + content: t('code.launch.bun_required'), + key: 'code-launch-message' + }) + } else { + window.message.warning({ + content: t('code.launch.validation_error'), + key: 'code-launch-message' + }) + } + return + } + + setIsLaunching(true) + + if (!selectedModel) { + window.message.error({ + content: t('code.model_required'), + key: 'code-launch-message' + }) + return + } + + const modelProvider = getProviderByModel(selectedModel) + const aiProvider = new AiProvider(modelProvider) + const baseUrl = await aiProvider.getBaseURL() + const apiKey = await aiProvider.getApiKey() + + let env: Record = {} + if (selectedCliTool === 'claude-code') { + env = { + ANTHROPIC_API_KEY: apiKey, + ANTHROPIC_MODEL: selectedModel.id + } + } + + if (selectedCliTool === 'gemini-cli') { + env = { + GEMINI_API_KEY: apiKey + } + } + + if (selectedCliTool === 'qwen-code') { + env = { + OPENAI_API_KEY: apiKey, + OPENAI_BASE_URL: baseUrl, + OPENAI_MODEL: selectedModel.id + } + } + + try { + // 这里可以添加实际的启动逻辑 + logger.info('启动配置:', { + cliTool: selectedCliTool, + model: selectedModel, + folder: currentDirectory + }) + + window.api.codeTools.run(selectedCliTool, selectedModel?.id, currentDirectory, env, { + autoUpdateToLatest + }) + + window.message.success({ + content: t('code.launch.success'), + key: 'code-launch-message' + }) + } catch (error) { + logger.error('启动失败:', error as Error) + window.message.error({ + content: t('code.launch.error'), + key: 'code-launch-message' + }) + } finally { + setIsLaunching(false) + } + } + + // 页面加载时检查 bun 安装状态 + useEffect(() => { + checkBunInstallation() + }, [checkBunInstallation]) + + return ( + + {t('code.title')} + {t('code.description')} + + {/* Bun 安装状态提示 */} + {!isBunInstalled && ( + + + {t('code.bun_required_message')} + + + } + /> + + )} + + + +
{t('code.cli_tool')}
+ { + const label = typeof option?.label === 'string' ? option.label : String(option?.value || '') + return label.toLowerCase().includes(input.toLowerCase()) + }} + options={directories.map((dir) => ({ + value: dir, + label: ( +
+ {dir} + handleRemoveDirectory(dir, e)} + /> +
+ ) + }))} + /> + + +
+ + +
{t('code.update_options')}
+ setAutoUpdateToLatest(e.target.checked)}> + {t('code.auto_update_to_latest')} + +
+
+ + +
+ ) +} + +// 样式组件 +const Container = styled.div` + width: 600px; + margin: auto; +` + +const Title = styled.h1` + font-size: 24px; + font-weight: 600; + margin-bottom: 8px; + margin-top: -50px; + color: var(--color-text-1); +` + +const Description = styled.p` + font-size: 14px; + color: var(--color-text-2); + margin-bottom: 32px; + line-height: 1.5; +` + +const SettingsPanel = styled.div` + margin-bottom: 32px; +` + +const SettingsItem = styled.div` + margin-bottom: 24px; + + .settings-label { + font-size: 14px; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 8px; + color: var(--color-text-1); + font-weight: 500; + } +` + +const BunInstallAlert = styled.div` + margin-bottom: 24px; +` + +export default CodeToolsPage diff --git a/src/renderer/src/pages/code/index.ts b/src/renderer/src/pages/code/index.ts new file mode 100644 index 0000000000..434a3f0a45 --- /dev/null +++ b/src/renderer/src/pages/code/index.ts @@ -0,0 +1 @@ +export { default } from './CodeToolsPage' diff --git a/src/renderer/src/pages/launchpad/LaunchpadPage.tsx b/src/renderer/src/pages/launchpad/LaunchpadPage.tsx index 0a19d6c183..870ca0d263 100644 --- a/src/renderer/src/pages/launchpad/LaunchpadPage.tsx +++ b/src/renderer/src/pages/launchpad/LaunchpadPage.tsx @@ -3,7 +3,7 @@ import { useMinapps } from '@renderer/hooks/useMinapps' import { useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' import tabsService from '@renderer/services/TabsService' -import { FileSearch, Folder, Languages, LayoutGrid, Palette, Sparkle } from 'lucide-react' +import { FileSearch, Folder, Languages, LayoutGrid, Palette, Sparkle, Terminal } from 'lucide-react' import { FC, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' @@ -52,6 +52,12 @@ const LaunchpadPage: FC = () => { text: t('title.files'), path: '/files', bgColor: 'linear-gradient(135deg, #F59E0B, #FBBF24)' // 文件:金色,代表资源和重要性 + }, + { + icon: , + text: t('title.code'), + path: '/code', + bgColor: 'linear-gradient(135deg, #1F2937, #374151)' // Code CLI:高级暗黑色,代表专业和技术 } ] diff --git a/src/renderer/src/store/codeTools.ts b/src/renderer/src/store/codeTools.ts new file mode 100644 index 0000000000..d74ce5139c --- /dev/null +++ b/src/renderer/src/store/codeTools.ts @@ -0,0 +1,112 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { Model } from '@renderer/types' + +// 常量定义 +const MAX_DIRECTORIES = 10 // 最多保存10个目录 + +export interface CodeToolsState { + // 当前选择的 CLI 工具,默认使用 qwen-code + selectedCliTool: string + // 为每个 CLI 工具单独保存选择的模型 + selectedModels: Record + // 记录用户选择过的所有目录,支持增删 + directories: string[] + // 当前选择的目录 + currentDirectory: string +} + +export const initialState: CodeToolsState = { + selectedCliTool: 'qwen-code', + selectedModels: { + 'qwen-code': null, + 'claude-code': null, + 'gemini-cli': null + }, + directories: [], + currentDirectory: '' +} + +const codeToolsSlice = createSlice({ + name: 'codeTools', + initialState, + reducers: { + // 设置选择的 CLI 工具 + setSelectedCliTool: (state, action: PayloadAction) => { + state.selectedCliTool = action.payload + }, + + // 设置选择的模型(为当前 CLI 工具设置) + setSelectedModel: (state, action: PayloadAction) => { + state.selectedModels[state.selectedCliTool] = action.payload + }, + + // 添加目录到列表中 + addDirectory: (state, action: PayloadAction) => { + const directory = action.payload + if (directory && !state.directories.includes(directory)) { + // 将新目录添加到开头 + state.directories.unshift(directory) + // 限制最多保存 MAX_DIRECTORIES 个目录 + if (state.directories.length > MAX_DIRECTORIES) { + state.directories = state.directories.slice(0, MAX_DIRECTORIES) + } + } + }, + + // 从列表中删除目录 + removeDirectory: (state, action: PayloadAction) => { + const directory = action.payload + state.directories = state.directories.filter((dir) => dir !== directory) + // 如果删除的是当前选择的目录,清空当前目录 + if (state.currentDirectory === directory) { + state.currentDirectory = '' + } + }, + + // 设置当前选择的目录 + setCurrentDirectory: (state, action: PayloadAction) => { + state.currentDirectory = action.payload + // 如果目录不在列表中,添加到列表开头 + if (action.payload && !state.directories.includes(action.payload)) { + state.directories.unshift(action.payload) + // 限制最多保存 MAX_DIRECTORIES 个目录 + if (state.directories.length > MAX_DIRECTORIES) { + state.directories = state.directories.slice(0, MAX_DIRECTORIES) + } + } else if (action.payload && state.directories.includes(action.payload)) { + // 如果目录已存在,将其移到开头(最近使用) + state.directories = [action.payload, ...state.directories.filter((dir) => dir !== action.payload)] + } + }, + + // 清空所有目录 + clearDirectories: (state) => { + state.directories = [] + state.currentDirectory = '' + }, + + // 重置所有设置 + resetCodeTools: (state) => { + state.selectedCliTool = 'qwen-code' + state.selectedModels = { + 'qwen-code': null, + 'claude-code': null, + 'gemini-cli': null + } + state.directories = [] + state.currentDirectory = '' + } + } +}) + +export const { + setSelectedCliTool, + setSelectedModel, + addDirectory, + removeDirectory, + setCurrentDirectory, + clearDirectories, + resetCodeTools +} = codeToolsSlice.actions + +export default codeToolsSlice.reducer diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index f45314da80..adcb9c1b9c 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -8,6 +8,7 @@ import storeSyncService from '../services/StoreSyncService' import agents from './agents' import assistants from './assistants' import backup from './backup' +import codeTools from './codeTools' import copilot from './copilot' import inputToolsReducer from './inputTools' import knowledge from './knowledge' @@ -35,6 +36,7 @@ const rootReducer = combineReducers({ assistants, agents, backup, + codeTools, nutstore, paintings, llm,