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:
亢奋猫 2025-08-12 11:54:38 +08:00 committed by GitHub
parent 793ccf978e
commit 1c7b7a1a55
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1385 additions and 37 deletions

View File

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

View 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
}

View File

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

View File

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

View 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()

View File

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

View File

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

View 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 truefalse
*/
export async function isUserInChina(): Promise<boolean> {
const country = await getIpCountry()
return country.toLowerCase() === 'cn'
}

View File

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

View File

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

View File

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

View 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
}
}

View File

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

View File

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

View File

@ -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": "ナレッジベース",

View File

@ -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": "База знаний",

View File

@ -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": "知识库",

View File

@ -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": "知識庫",

View 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

View File

@ -0,0 +1 @@
export { default } from './CodeToolsPage'

View File

@ -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高级暗黑色代表专业和技术
}
]

View 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

View File

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