mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
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.
This commit is contained in:
parent
793ccf978e
commit
1c7b7a1a55
@ -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'
|
||||
}
|
||||
|
||||
88
resources/scripts/ipService.js
Normal file
88
resources/scripts/ipService.js
Normal file
@ -0,0 +1,88 @@
|
||||
const https = require('https')
|
||||
const { loggerService } = require('@logger')
|
||||
|
||||
const logger = loggerService.withContext('IpService')
|
||||
|
||||
/**
|
||||
* 获取用户的IP地址所在国家
|
||||
* @returns {Promise<string>} 返回国家代码,默认为'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<boolean>} 如果用户在中国返回true,否则返回false
|
||||
*/
|
||||
async function isUserInChina() {
|
||||
const country = await getIpCountry()
|
||||
return country.toLowerCase() === 'cn'
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户位置获取适合的npm镜像URL
|
||||
* @returns {Promise<string>} 返回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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
476
src/main/services/CodeToolsService.ts
Normal file
476
src/main/services/CodeToolsService.ts
Normal file
@ -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<string, { version: string; timestamp: number }> = 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<boolean> {
|
||||
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<VersionInfo> {
|
||||
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<string> {
|
||||
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<string, string>,
|
||||
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<string, string>)
|
||||
|
||||
// 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()
|
||||
@ -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<string, string>) {
|
||||
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)
|
||||
|
||||
@ -70,3 +70,11 @@ export async function calculateDirectorySize(directoryPath: string): Promise<num
|
||||
}
|
||||
return totalSize
|
||||
}
|
||||
|
||||
export const removeEnvProxy = (env: Record<string, string>) => {
|
||||
delete env.HTTPS_PROXY
|
||||
delete env.HTTP_PROXY
|
||||
delete env.grpc_proxy
|
||||
delete env.http_proxy
|
||||
delete env.https_proxy
|
||||
}
|
||||
|
||||
42
src/main/utils/ipService.ts
Normal file
42
src/main/utils/ipService.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { loggerService } from '@logger'
|
||||
|
||||
const logger = loggerService.withContext('IpService')
|
||||
|
||||
/**
|
||||
* 获取用户的IP地址所在国家
|
||||
* @returns 返回国家代码,默认为'CN'
|
||||
*/
|
||||
export async function getIpCountry(): Promise<string> {
|
||||
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<boolean> {
|
||||
const country = await getIpCountry()
|
||||
return country.toLowerCase() === 'cn'
|
||||
}
|
||||
@ -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<string, string>,
|
||||
options?: { autoUpdateToLatest?: boolean }
|
||||
) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 = () => {
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<MinAppsPage />} />
|
||||
<Route path="/code" element={<CodeToolsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
<Route path="/launchpad" element={<LaunchpadPage />} />
|
||||
</Routes>
|
||||
|
||||
@ -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 <Folder size={14} />
|
||||
case 'settings':
|
||||
return <Settings size={14} />
|
||||
case 'code':
|
||||
return <Terminal size={14} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
109
src/renderer/src/hooks/useCodeTools.ts
Normal file
109
src/renderer/src/hooks/useCodeTools.ts
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "ナレッジベース",
|
||||
|
||||
@ -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": "База знаний",
|
||||
|
||||
@ -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": "知识库",
|
||||
|
||||
@ -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": "知識庫",
|
||||
|
||||
383
src/renderer/src/pages/code/CodeToolsPage.tsx
Normal file
383
src/renderer/src/pages/code/CodeToolsPage.tsx
Normal file
@ -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<string, string> = {}
|
||||
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 (
|
||||
<Container>
|
||||
<Title>{t('code.title')}</Title>
|
||||
<Description>{t('code.description')}</Description>
|
||||
|
||||
{/* Bun 安装状态提示 */}
|
||||
{!isBunInstalled && (
|
||||
<BunInstallAlert>
|
||||
<Alert
|
||||
type="warning"
|
||||
banner
|
||||
style={{ borderRadius: 'var(--list-item-border-radius)' }}
|
||||
message={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{t('code.bun_required_message')}</span>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<Download size={14} />}
|
||||
onClick={handleInstallBun}
|
||||
loading={isInstallingBun}
|
||||
disabled={isInstallingBun}>
|
||||
{isInstallingBun ? t('code.installing_bun') : t('code.install_bun')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</BunInstallAlert>
|
||||
)}
|
||||
|
||||
<SettingsPanel>
|
||||
<SettingsItem>
|
||||
<div className="settings-label">{t('code.cli_tool')}</div>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('code.cli_tool_placeholder')}
|
||||
value={selectedCliTool}
|
||||
onChange={handleCliToolChange}
|
||||
options={CLI_TOOLS}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div className="settings-label">{t('code.model')}</div>
|
||||
<ModelSelector
|
||||
providers={availableProviders}
|
||||
predicate={modelPredicate}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('code.model_placeholder')}
|
||||
value={selectedModel ? getModelUniqId(selectedModel) : undefined}
|
||||
onChange={handleModelChange}
|
||||
allowClear
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div className="settings-label">{t('code.working_directory')}</div>
|
||||
<Space.Compact style={{ width: '100%', display: 'flex' }}>
|
||||
<Select
|
||||
style={{ flex: 1, width: 480 }}
|
||||
placeholder={t('code.folder_placeholder')}
|
||||
value={currentDirectory || undefined}
|
||||
onChange={handleDirectoryChange}
|
||||
allowClear
|
||||
showSearch
|
||||
filterOption={(input, option) => {
|
||||
const label = typeof option?.label === 'string' ? option.label : String(option?.value || '')
|
||||
return label.toLowerCase().includes(input.toLowerCase())
|
||||
}}
|
||||
options={directories.map((dir) => ({
|
||||
value: dir,
|
||||
label: (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{dir}</span>
|
||||
<X
|
||||
size={14}
|
||||
style={{ marginLeft: 8, cursor: 'pointer', color: '#999' }}
|
||||
onClick={(e) => handleRemoveDirectory(dir, e)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
<Button onClick={handleFolderSelect} style={{ width: 120 }}>
|
||||
{t('code.select_folder')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div className="settings-label">{t('code.update_options')}</div>
|
||||
<Checkbox checked={autoUpdateToLatest} onChange={(e) => setAutoUpdateToLatest(e.target.checked)}>
|
||||
{t('code.auto_update_to_latest')}
|
||||
</Checkbox>
|
||||
</SettingsItem>
|
||||
</SettingsPanel>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Terminal size={16} />}
|
||||
size="large"
|
||||
onClick={handleLaunch}
|
||||
loading={isLaunching}
|
||||
disabled={!canLaunch || !isBunInstalled}
|
||||
block>
|
||||
{isLaunching ? t('code.launching') : t('code.launch.label')}
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
// 样式组件
|
||||
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
|
||||
1
src/renderer/src/pages/code/index.ts
Normal file
1
src/renderer/src/pages/code/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './CodeToolsPage'
|
||||
@ -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: <Terminal size={32} className="icon" />,
|
||||
text: t('title.code'),
|
||||
path: '/code',
|
||||
bgColor: 'linear-gradient(135deg, #1F2937, #374151)' // Code CLI:高级暗黑色,代表专业和技术
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
112
src/renderer/src/store/codeTools.ts
Normal file
112
src/renderer/src/store/codeTools.ts
Normal file
@ -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<string, Model | null>
|
||||
// 记录用户选择过的所有目录,支持增删
|
||||
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<string>) => {
|
||||
state.selectedCliTool = action.payload
|
||||
},
|
||||
|
||||
// 设置选择的模型(为当前 CLI 工具设置)
|
||||
setSelectedModel: (state, action: PayloadAction<Model | null>) => {
|
||||
state.selectedModels[state.selectedCliTool] = action.payload
|
||||
},
|
||||
|
||||
// 添加目录到列表中
|
||||
addDirectory: (state, action: PayloadAction<string>) => {
|
||||
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<string>) => {
|
||||
const directory = action.payload
|
||||
state.directories = state.directories.filter((dir) => dir !== directory)
|
||||
// 如果删除的是当前选择的目录,清空当前目录
|
||||
if (state.currentDirectory === directory) {
|
||||
state.currentDirectory = ''
|
||||
}
|
||||
},
|
||||
|
||||
// 设置当前选择的目录
|
||||
setCurrentDirectory: (state, action: PayloadAction<string>) => {
|
||||
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
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user