diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 26cdbb5403..f4f4f3d40a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -6,7 +6,7 @@ import { loggerService } from '@logger' import { isLinux, isMac, isPortable, isWin } from '@main/constant' import { generateSignature } from '@main/integration/cherryai' import anthropicService from '@main/services/AnthropicService' -import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' +import { findGitBash, getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import { handleZoomFactor } from '@main/utils/zoom' import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import type { UpgradeChannel } from '@shared/config/constant' @@ -499,35 +499,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { } try { - // Check common Git Bash installation paths - const commonPaths = [ - path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'), - path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'), - path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe') - ] + const bashPath = findGitBash() - // Check if any of the common paths exist - for (const bashPath of commonPaths) { - if (fs.existsSync(bashPath)) { - logger.debug('Git Bash found', { path: bashPath }) - return true - } - } - - // Check if git is in PATH - const { execSync } = require('child_process') - try { - execSync('git --version', { stdio: 'ignore' }) - logger.debug('Git found in PATH') + if (bashPath) { + logger.info('Git Bash is available', { path: bashPath }) return true - } catch { - // Git not in PATH } - logger.debug('Git Bash not found on Windows system') + logger.warn('Git Bash not found. Please install Git for Windows from https://git-scm.com/downloads/win') return false } catch (error) { - logger.error('Error checking Git Bash', error as Error) + logger.error('Unexpected error checking Git Bash', error as Error) return false } }) @@ -597,10 +579,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager)) ipcMain.handle(IpcChannel.File_PauseWatcher, fileManager.pauseFileWatcher.bind(fileManager)) ipcMain.handle(IpcChannel.File_ResumeWatcher, fileManager.resumeFileWatcher.bind(fileManager)) +<<<<<<< HEAD ipcMain.handle(IpcChannel.File_BatchUpload, fileManager.batchUpload.bind(fileManager)) ipcMain.handle(IpcChannel.File_UploadFolder, fileManager.uploadFolder.bind(fileManager)) ipcMain.handle(IpcChannel.File_UploadEntry, fileManager.uploadFileEntry.bind(fileManager)) ipcMain.handle(IpcChannel.File_BatchUploadEntries, fileManager.batchUploadEntries.bind(fileManager)) +======= + ipcMain.handle(IpcChannel.File_BatchUploadMarkdown, fileManager.batchUploadMarkdownFiles.bind(fileManager)) +>>>>>>> origin/main ipcMain.handle(IpcChannel.File_ShowInFolder, fileManager.showInFolder.bind(fileManager)) // file service diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 5179c1cf2d..2b94fa1607 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -1488,6 +1488,12 @@ class FileStorage { return } + // Skip processing if watcher is paused + if (this.isPaused) { + logger.debug('File change ignored (watcher paused)', { eventType, filePath }) + return + } + if (!this.shouldWatchFile(filePath, eventType)) { return } diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 63eaaba995..3f96497e63 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -271,9 +271,9 @@ export class WindowService { 'https://account.siliconflow.cn/oauth', 'https://cloud.siliconflow.cn/bills', 'https://cloud.siliconflow.cn/expensebill', - 'https://aihubmix.com/token', - 'https://aihubmix.com/topup', - 'https://aihubmix.com/statistics', + 'https://console.aihubmix.com/token', + 'https://console.aihubmix.com/topup', + 'https://console.aihubmix.com/statistics', 'https://dash.302.ai/sso/login', 'https://dash.302.ai/charge', 'https://www.aiionly.com/login' diff --git a/src/main/utils/__tests__/process.test.ts b/src/main/utils/__tests__/process.test.ts new file mode 100644 index 0000000000..45c0f8b42b --- /dev/null +++ b/src/main/utils/__tests__/process.test.ts @@ -0,0 +1,572 @@ +import { execFileSync } from 'child_process' +import fs from 'fs' +import path from 'path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { findExecutable, findGitBash } from '../process' + +// Mock dependencies +vi.mock('child_process') +vi.mock('fs') +vi.mock('path') + +// These tests only run on Windows since the functions have platform guards +describe.skipIf(process.platform !== 'win32')('process utilities', () => { + beforeEach(() => { + vi.clearAllMocks() + + // Mock path.join to concatenate paths with backslashes (Windows-style) + vi.mocked(path.join).mockImplementation((...args) => args.join('\\')) + + // Mock path.resolve to handle path resolution with .. support + vi.mocked(path.resolve).mockImplementation((...args) => { + let result = args.join('\\') + + // Handle .. navigation + while (result.includes('\\..')) { + result = result.replace(/\\[^\\]+\\\.\./g, '') + } + + // Ensure absolute path + if (!result.match(/^[A-Z]:/)) { + result = `C:\\cwd\\${result}` + } + + return result + }) + + // Mock path.dirname + vi.mocked(path.dirname).mockImplementation((p) => { + const parts = p.split('\\') + parts.pop() + return parts.join('\\') + }) + + // Mock path.sep + Object.defineProperty(path, 'sep', { value: '\\', writable: true }) + + // Mock process.cwd() + vi.spyOn(process, 'cwd').mockReturnValue('C:\\cwd') + }) + + describe('findExecutable', () => { + describe('git common paths', () => { + it('should find git at Program Files path', () => { + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + process.env.ProgramFiles = 'C:\\Program Files' + + vi.mocked(fs.existsSync).mockImplementation((p) => p === gitPath) + + const result = findExecutable('git') + + expect(result).toBe(gitPath) + expect(fs.existsSync).toHaveBeenCalledWith(gitPath) + }) + + it('should find git at Program Files (x86) path', () => { + const gitPath = 'C:\\Program Files (x86)\\Git\\cmd\\git.exe' + process.env['ProgramFiles(x86)'] = 'C:\\Program Files (x86)' + + vi.mocked(fs.existsSync).mockImplementation((p) => p === gitPath) + + const result = findExecutable('git') + + expect(result).toBe(gitPath) + expect(fs.existsSync).toHaveBeenCalledWith(gitPath) + }) + + it('should use fallback paths when environment variables are not set', () => { + delete process.env.ProgramFiles + delete process.env['ProgramFiles(x86)'] + + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + vi.mocked(fs.existsSync).mockImplementation((p) => p === gitPath) + + const result = findExecutable('git') + + expect(result).toBe(gitPath) + }) + }) + + describe('where.exe PATH lookup', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32', writable: true }) + // Common paths don't exist + vi.mocked(fs.existsSync).mockReturnValue(false) + }) + + it('should find executable via where.exe', () => { + const gitPath = 'C:\\Git\\bin\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findExecutable('git') + + expect(result).toBe(gitPath) + expect(execFileSync).toHaveBeenCalledWith('where.exe', ['git.exe'], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }) + }) + + it('should add .exe extension when calling where.exe', () => { + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('Not found') + }) + + findExecutable('node') + + expect(execFileSync).toHaveBeenCalledWith('where.exe', ['node.exe'], expect.any(Object)) + }) + + it('should handle Windows line endings (CRLF)', () => { + const gitPath1 = 'C:\\Git\\bin\\git.exe' + const gitPath2 = 'C:\\Program Files\\Git\\cmd\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(`${gitPath1}\r\n${gitPath2}\r\n`) + + const result = findExecutable('git') + + // Should return the first valid path + expect(result).toBe(gitPath1) + }) + + it('should handle Unix line endings (LF)', () => { + const gitPath1 = 'C:\\Git\\bin\\git.exe' + const gitPath2 = 'C:\\Program Files\\Git\\cmd\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(`${gitPath1}\n${gitPath2}\n`) + + const result = findExecutable('git') + + expect(result).toBe(gitPath1) + }) + + it('should handle mixed line endings', () => { + const gitPath1 = 'C:\\Git\\bin\\git.exe' + const gitPath2 = 'C:\\Program Files\\Git\\cmd\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(`${gitPath1}\r\n${gitPath2}\n`) + + const result = findExecutable('git') + + expect(result).toBe(gitPath1) + }) + + it('should trim whitespace from paths', () => { + const gitPath = 'C:\\Git\\bin\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(` ${gitPath} \n`) + + const result = findExecutable('git') + + expect(result).toBe(gitPath) + }) + + it('should filter empty lines', () => { + const gitPath = 'C:\\Git\\bin\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(`\n\n${gitPath}\n\n`) + + const result = findExecutable('git') + + expect(result).toBe(gitPath) + }) + }) + + describe('security checks', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32', writable: true }) + vi.mocked(fs.existsSync).mockReturnValue(false) + }) + + it('should skip executables in current directory', () => { + const maliciousPath = 'C:\\cwd\\git.exe' + const safePath = 'C:\\Git\\bin\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(`${maliciousPath}\n${safePath}`) + + vi.mocked(path.resolve).mockImplementation((p) => { + if (p.includes('cwd\\git.exe')) return 'c:\\cwd\\git.exe' + return 'c:\\git\\bin\\git.exe' + }) + + vi.mocked(path.dirname).mockImplementation((p) => { + if (p.includes('cwd\\git.exe')) return 'c:\\cwd' + return 'c:\\git\\bin' + }) + + const result = findExecutable('git') + + // Should skip malicious path and return safe path + expect(result).toBe(safePath) + }) + + it('should skip executables in current directory subdirectories', () => { + const maliciousPath = 'C:\\cwd\\subdir\\git.exe' + const safePath = 'C:\\Git\\bin\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(`${maliciousPath}\n${safePath}`) + + vi.mocked(path.resolve).mockImplementation((p) => { + if (p.includes('cwd\\subdir')) return 'c:\\cwd\\subdir\\git.exe' + return 'c:\\git\\bin\\git.exe' + }) + + vi.mocked(path.dirname).mockImplementation((p) => { + if (p.includes('cwd\\subdir')) return 'c:\\cwd\\subdir' + return 'c:\\git\\bin' + }) + + const result = findExecutable('git') + + expect(result).toBe(safePath) + }) + + it('should return null when only malicious executables are found', () => { + const maliciousPath = 'C:\\cwd\\git.exe' + + vi.mocked(execFileSync).mockReturnValue(maliciousPath) + + vi.mocked(path.resolve).mockReturnValue('c:\\cwd\\git.exe') + vi.mocked(path.dirname).mockReturnValue('c:\\cwd') + + const result = findExecutable('git') + + expect(result).toBeNull() + }) + }) + + describe('error handling', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32', writable: true }) + vi.mocked(fs.existsSync).mockReturnValue(false) + }) + + it('should return null when where.exe fails', () => { + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('Command failed') + }) + + const result = findExecutable('nonexistent') + + expect(result).toBeNull() + }) + + it('should return null when where.exe returns empty output', () => { + vi.mocked(execFileSync).mockReturnValue('') + + const result = findExecutable('git') + + expect(result).toBeNull() + }) + + it('should return null when where.exe returns only whitespace', () => { + vi.mocked(execFileSync).mockReturnValue(' \n\n ') + + const result = findExecutable('git') + + expect(result).toBeNull() + }) + }) + + describe('non-git executables', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32', writable: true }) + }) + + it('should skip common paths check for non-git executables', () => { + const nodePath = 'C:\\Program Files\\nodejs\\node.exe' + + vi.mocked(execFileSync).mockReturnValue(nodePath) + + const result = findExecutable('node') + + expect(result).toBe(nodePath) + // Should not check common Git paths + expect(fs.existsSync).not.toHaveBeenCalledWith(expect.stringContaining('Git\\cmd\\node.exe')) + }) + }) + }) + + describe('findGitBash', () => { + describe('git.exe path derivation', () => { + it('should derive bash.exe from standard Git installation (Git/cmd/git.exe)', () => { + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + + // findExecutable will find git at common path + process.env.ProgramFiles = 'C:\\Program Files' + vi.mocked(fs.existsSync).mockImplementation((p) => { + return p === gitPath || p === bashPath + }) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should derive bash.exe from portable Git installation (Git/bin/git.exe)', () => { + const gitPath = 'C:\\PortableGit\\bin\\git.exe' + const bashPath = 'C:\\PortableGit\\bin\\bash.exe' + + // Mock: common git paths don't exist, but where.exe finds portable git + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = p?.toString() || '' + // Common git paths don't exist + if (pathStr.includes('Program Files\\Git\\cmd\\git.exe')) return false + if (pathStr.includes('Program Files (x86)\\Git\\cmd\\git.exe')) return false + // Portable bash.exe exists at Git/bin/bash.exe (second path in possibleBashPaths) + if (pathStr === bashPath) return true + return false + }) + + // where.exe returns portable git path + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should derive bash.exe from MSYS2 Git installation (Git/usr/bin/bash.exe)', () => { + const gitPath = 'C:\\msys64\\usr\\bin\\git.exe' + const bashPath = 'C:\\msys64\\usr\\bin\\bash.exe' + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = p?.toString() || '' + // Common git paths don't exist + if (pathStr.includes('Program Files\\Git\\cmd\\git.exe')) return false + if (pathStr.includes('Program Files (x86)\\Git\\cmd\\git.exe')) return false + // MSYS2 bash.exe exists at usr/bin/bash.exe (third path in possibleBashPaths) + if (pathStr === bashPath) return true + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should try multiple bash.exe locations in order', () => { + const gitPath = 'C:\\Git\\cmd\\git.exe' + const bashPath = 'C:\\Git\\bin\\bash.exe' + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = p?.toString() || '' + // Common git paths don't exist + if (pathStr.includes('Program Files\\Git\\cmd\\git.exe')) return false + if (pathStr.includes('Program Files (x86)\\Git\\cmd\\git.exe')) return false + // Standard path exists (first in possibleBashPaths) + if (pathStr === bashPath) return true + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should handle when git.exe is found but bash.exe is not at any derived location', () => { + const gitPath = 'C:\\Git\\cmd\\git.exe' + + // git.exe exists via where.exe, but bash.exe doesn't exist at any derived location + vi.mocked(fs.existsSync).mockImplementation(() => { + // Only return false for all bash.exe checks + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + // Should fall back to common paths check + expect(result).toBeNull() + }) + }) + + describe('common paths fallback', () => { + beforeEach(() => { + // git.exe not found + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('Not found') + }) + }) + + it('should check Program Files path', () => { + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + process.env.ProgramFiles = 'C:\\Program Files' + + vi.mocked(fs.existsSync).mockImplementation((p) => p === bashPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should check Program Files (x86) path', () => { + const bashPath = 'C:\\Program Files (x86)\\Git\\bin\\bash.exe' + process.env['ProgramFiles(x86)'] = 'C:\\Program Files (x86)' + + vi.mocked(fs.existsSync).mockImplementation((p) => p === bashPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should check LOCALAPPDATA path', () => { + const bashPath = 'C:\\Users\\User\\AppData\\Local\\Programs\\Git\\bin\\bash.exe' + process.env.LOCALAPPDATA = 'C:\\Users\\User\\AppData\\Local' + + vi.mocked(fs.existsSync).mockImplementation((p) => p === bashPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should skip LOCALAPPDATA check when environment variable is not set', () => { + delete process.env.LOCALAPPDATA + + vi.mocked(fs.existsSync).mockReturnValue(false) + + const result = findGitBash() + + expect(result).toBeNull() + // Should not check invalid path with empty LOCALAPPDATA + expect(fs.existsSync).not.toHaveBeenCalledWith(expect.stringContaining('undefined')) + }) + + it('should use fallback values when environment variables are not set', () => { + delete process.env.ProgramFiles + delete process.env['ProgramFiles(x86)'] + + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + vi.mocked(fs.existsSync).mockImplementation((p) => p === bashPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + }) + + describe('priority order', () => { + it('should prioritize git.exe derivation over common paths', () => { + const gitPath = 'C:\\CustomPath\\Git\\cmd\\git.exe' + const derivedBashPath = 'C:\\CustomPath\\Git\\bin\\bash.exe' + const commonBashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + + // Both exist + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = p?.toString() || '' + // Common git paths don't exist (so findExecutable uses where.exe) + if (pathStr.includes('Program Files\\Git\\cmd\\git.exe')) return false + if (pathStr.includes('Program Files (x86)\\Git\\cmd\\git.exe')) return false + // Both bash paths exist, but derived should be checked first + if (pathStr === derivedBashPath) return true + if (pathStr === commonBashPath) return true + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + // Should return derived path, not common path + expect(result).toBe(derivedBashPath) + }) + }) + + describe('error scenarios', () => { + it('should return null when Git is not installed anywhere', () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('Not found') + }) + + const result = findGitBash() + + expect(result).toBeNull() + }) + + it('should return null when git.exe exists but bash.exe does not', () => { + const gitPath = 'C:\\Git\\cmd\\git.exe' + + vi.mocked(fs.existsSync).mockImplementation((p) => { + // git.exe exists, but no bash.exe anywhere + return p === gitPath + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + expect(result).toBeNull() + }) + }) + + describe('real-world scenarios', () => { + it('should handle official Git for Windows installer', () => { + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + + process.env.ProgramFiles = 'C:\\Program Files' + vi.mocked(fs.existsSync).mockImplementation((p) => { + return p === gitPath || p === bashPath + }) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should handle portable Git installation in custom directory', () => { + const gitPath = 'D:\\DevTools\\PortableGit\\bin\\git.exe' + const bashPath = 'D:\\DevTools\\PortableGit\\bin\\bash.exe' + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = p?.toString() || '' + // Common paths don't exist + if (pathStr.includes('Program Files\\Git\\cmd\\git.exe')) return false + if (pathStr.includes('Program Files (x86)\\Git\\cmd\\git.exe')) return false + // Portable Git paths exist (portable uses second path: Git/bin/bash.exe) + if (pathStr === bashPath) return true + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + + it('should handle Git installed via Scoop', () => { + // Scoop typically installs to %USERPROFILE%\scoop\apps\git\current + const gitPath = 'C:\\Users\\User\\scoop\\apps\\git\\current\\cmd\\git.exe' + const bashPath = 'C:\\Users\\User\\scoop\\apps\\git\\current\\bin\\bash.exe' + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = p?.toString() || '' + // Common paths don't exist + if (pathStr.includes('Program Files\\Git\\cmd\\git.exe')) return false + if (pathStr.includes('Program Files (x86)\\Git\\cmd\\git.exe')) return false + // Scoop bash path exists (standard structure: cmd -> bin) + if (pathStr === bashPath) return true + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + }) + }) +}) diff --git a/src/main/utils/process.ts b/src/main/utils/process.ts index f36e86861d..b59a37a048 100644 --- a/src/main/utils/process.ts +++ b/src/main/utils/process.ts @@ -1,10 +1,11 @@ import { loggerService } from '@logger' import { HOME_CHERRY_DIR } from '@shared/config/constant' -import { spawn } from 'child_process' +import { execFileSync, spawn } from 'child_process' import fs from 'fs' import os from 'os' import path from 'path' +import { isWin } from '../constant' import { getResourcePath } from '.' const logger = loggerService.withContext('Utils:Process') @@ -39,7 +40,7 @@ export function runInstallScript(scriptPath: string): Promise { } export async function getBinaryName(name: string): Promise { - if (process.platform === 'win32') { + if (isWin) { return `${name}.exe` } return name @@ -60,3 +61,123 @@ export async function isBinaryExists(name: string): Promise { const cmd = await getBinaryPath(name) return await fs.existsSync(cmd) } + +/** + * Find executable in common paths or PATH environment variable + * Based on Claude Code's implementation with security checks + * @param name - Name of the executable to find (without .exe extension) + * @returns Full path to the executable or null if not found + */ +export function findExecutable(name: string): string | null { + // This implementation uses where.exe which is Windows-only + if (!isWin) { + return null + } + + // Special handling for git - check common installation paths first + if (name === 'git') { + const commonGitPaths = [ + path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'cmd', 'git.exe'), + path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'cmd', 'git.exe') + ] + + for (const gitPath of commonGitPaths) { + if (fs.existsSync(gitPath)) { + logger.debug(`Found ${name} at common path`, { path: gitPath }) + return gitPath + } + } + } + + // Use where.exe to find executable in PATH + // Use execFileSync to prevent command injection + try { + // Add .exe extension for more precise matching on Windows + const executableName = `${name}.exe` + const result = execFileSync('where.exe', [executableName], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }) + + // Handle both Windows (\r\n) and Unix (\n) line endings + const paths = result.trim().split(/\r?\n/).filter(Boolean) + const currentDir = process.cwd().toLowerCase() + + // Security check: skip executables in current directory + for (const exePath of paths) { + // Trim whitespace from where.exe output + const cleanPath = exePath.trim() + const resolvedPath = path.resolve(cleanPath).toLowerCase() + const execDir = path.dirname(resolvedPath).toLowerCase() + + // Skip if in current directory or subdirectory (potential malware) + if (execDir === currentDir || execDir.startsWith(currentDir + path.sep)) { + logger.warn('Skipping potentially malicious executable in current directory', { + path: cleanPath + }) + continue + } + + logger.debug(`Found ${name} via where.exe`, { path: cleanPath }) + return cleanPath + } + + return null + } catch (error) { + logger.debug(`where.exe ${name} failed`, { error }) + return null + } +} + +/** + * Find Git Bash executable on Windows + * @returns Full path to bash.exe or null if not found + */ +export function findGitBash(): string | null { + // Git Bash is Windows-only + if (!isWin) { + return null + } + + // 1. Find git.exe and derive bash.exe path + const gitPath = findExecutable('git') + if (gitPath) { + // Try multiple possible locations for bash.exe relative to git.exe + // Different Git installations have different directory structures + const possibleBashPaths = [ + path.join(gitPath, '..', '..', 'bin', 'bash.exe'), // Standard Git: git.exe at Git/cmd/ -> navigate up 2 levels -> then bin/bash.exe + path.join(gitPath, '..', 'bash.exe'), // Portable Git: git.exe at Git/bin/ -> bash.exe in same directory + path.join(gitPath, '..', '..', 'usr', 'bin', 'bash.exe') // MSYS2 Git: git.exe at msys64/usr/bin/ -> navigate up 2 levels -> then usr/bin/bash.exe + ] + + for (const bashPath of possibleBashPaths) { + const resolvedBashPath = path.resolve(bashPath) + if (fs.existsSync(resolvedBashPath)) { + logger.debug('Found bash.exe via git.exe path derivation', { path: resolvedBashPath }) + return resolvedBashPath + } + } + + logger.debug('bash.exe not found at expected locations relative to git.exe', { + gitPath, + checkedPaths: possibleBashPaths.map((p) => path.resolve(p)) + }) + } + + // 2. Fallback: check common Git Bash paths directly + const commonBashPaths = [ + path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'), + path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'), + ...(process.env.LOCALAPPDATA ? [path.join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe')] : []) + ] + + for (const bashPath of commonBashPaths) { + if (fs.existsSync(bashPath)) { + logger.debug('Found bash.exe at common path', { path: bashPath }) + return bashPath + } + } + + logger.debug('Git Bash not found - checked git derivation and common paths') + return null +} diff --git a/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts b/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts index 179bb54a1e..02ac6de091 100644 --- a/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts @@ -3,6 +3,7 @@ import { loggerService } from '@logger' import { isSupportedModel } from '@renderer/config/models' import type { Provider } from '@renderer/types' import { objectKeys } from '@renderer/types' +import { formatApiHost, withoutTrailingApiVersion } from '@renderer/utils' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' @@ -16,11 +17,8 @@ export class OVMSClient extends OpenAIAPIClient { override async listModels(): Promise { try { const sdk = await this.getSdkInstance() - - const chatModelsResponse = await sdk.request({ - method: 'get', - path: '../v1/config' - }) + const url = formatApiHost(withoutTrailingApiVersion(this.getBaseURL()), true, 'v1') + const chatModelsResponse = await sdk.withOptions({ baseURL: url }).get('/config') logger.debug(`Chat models response: ${JSON.stringify(chatModelsResponse)}`) // Parse the config response to extract model information diff --git a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts index 430ff52869..43d3cc52b8 100644 --- a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts +++ b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts @@ -22,11 +22,15 @@ vi.mock('@renderer/services/AssistantService', () => ({ }) })) -vi.mock('@renderer/store', () => ({ - default: { - getState: () => ({ copilot: { defaultHeaders: {} } }) +vi.mock('@renderer/store', () => { + const mockGetState = vi.fn() + return { + default: { + getState: mockGetState + }, + __mockGetState: mockGetState } -})) +}) vi.mock('@renderer/utils/api', () => ({ formatApiHost: vi.fn((host, isSupportedAPIVersion = true) => { @@ -79,6 +83,8 @@ import { isCherryAIProvider, isPerplexityProvider } from '@renderer/utils/provid import { COPILOT_DEFAULT_HEADERS, COPILOT_EDITOR_VERSION, isCopilotResponsesModel } from '../constants' import { getActualProvider, providerToAiSdkConfig } from '../providerConfig' +const { __mockGetState: mockGetState } = vi.mocked(await import('@renderer/store')) as any + const createWindowKeyv = () => { const store = new Map() return { @@ -132,6 +138,16 @@ describe('Copilot responses routing', () => { ...(globalThis as any).window, keyv: createWindowKeyv() } + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: undefined + } + } + } + }) }) it('detects official GPT-5 Codex identifiers case-insensitively', () => { @@ -167,6 +183,16 @@ describe('CherryAI provider configuration', () => { ...(globalThis as any).window, keyv: createWindowKeyv() } + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: undefined + } + } + } + }) vi.clearAllMocks() }) @@ -231,6 +257,16 @@ describe('Perplexity provider configuration', () => { ...(globalThis as any).window, keyv: createWindowKeyv() } + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: undefined + } + } + } + }) vi.clearAllMocks() }) @@ -291,3 +327,165 @@ describe('Perplexity provider configuration', () => { expect(actualProvider.apiHost).toBe('') }) }) + +describe('Stream options includeUsage configuration', () => { + beforeEach(() => { + ;(globalThis as any).window = { + ...(globalThis as any).window, + keyv: createWindowKeyv() + } + vi.clearAllMocks() + }) + + const createOpenAIProvider = (): Provider => ({ + id: 'openai-compatible', + type: 'openai', + name: 'OpenAI', + apiKey: 'test-key', + apiHost: 'https://api.openai.com', + models: [], + isSystem: true + }) + + it('uses includeUsage from settings when undefined', () => { + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: undefined + } + } + } + }) + + const provider = createOpenAIProvider() + const config = providerToAiSdkConfig(provider, createModel('gpt-4', 'GPT-4', 'openai')) + + expect(config.options.includeUsage).toBeUndefined() + }) + + it('uses includeUsage from settings when set to true', () => { + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: true + } + } + } + }) + + const provider = createOpenAIProvider() + const config = providerToAiSdkConfig(provider, createModel('gpt-4', 'GPT-4', 'openai')) + + expect(config.options.includeUsage).toBe(true) + }) + + it('uses includeUsage from settings when set to false', () => { + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: false + } + } + } + }) + + const provider = createOpenAIProvider() + const config = providerToAiSdkConfig(provider, createModel('gpt-4', 'GPT-4', 'openai')) + + expect(config.options.includeUsage).toBe(false) + }) + + it('respects includeUsage setting for non-supporting providers', () => { + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: true + } + } + } + }) + + const testProvider: Provider = { + id: 'test', + type: 'openai', + name: 'test', + apiKey: 'test-key', + apiHost: 'https://api.test.com', + models: [], + isSystem: false, + apiOptions: { + isNotSupportStreamOptions: true + } + } + + const config = providerToAiSdkConfig(testProvider, createModel('gpt-4', 'GPT-4', 'test')) + + // Even though setting is true, provider doesn't support it, so includeUsage should be undefined + expect(config.options.includeUsage).toBeUndefined() + }) + + it('uses includeUsage from settings for Copilot provider when set to false', () => { + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: false + } + } + } + }) + + const provider = createCopilotProvider() + const config = providerToAiSdkConfig(provider, createModel('gpt-4', 'GPT-4', 'copilot')) + + expect(config.options.includeUsage).toBe(false) + expect(config.providerId).toBe('github-copilot-openai-compatible') + }) + + it('uses includeUsage from settings for Copilot provider when set to true', () => { + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: true + } + } + } + }) + + const provider = createCopilotProvider() + const config = providerToAiSdkConfig(provider, createModel('gpt-4', 'GPT-4', 'copilot')) + + expect(config.options.includeUsage).toBe(true) + expect(config.providerId).toBe('github-copilot-openai-compatible') + }) + + it('uses includeUsage from settings for Copilot provider when undefined', () => { + mockGetState.mockReturnValue({ + copilot: { defaultHeaders: {} }, + settings: { + openAI: { + streamOptions: { + includeUsage: undefined + } + } + } + }) + + const provider = createCopilotProvider() + const config = providerToAiSdkConfig(provider, createModel('gpt-4', 'GPT-4', 'copilot')) + + expect(config.options.includeUsage).toBeUndefined() + expect(config.providerId).toBe('github-copilot-openai-compatible') + }) +}) diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 0be69bdb4f..99e4fbd1c9 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -11,6 +11,7 @@ import { createVertexProvider, isVertexAIConfigured } from '@renderer/hooks/useV import { getProviderByModel } from '@renderer/services/AssistantService' import store from '@renderer/store' import { isSystemProvider, type Model, type Provider, SystemProviderIds } from '@renderer/types' +import type { OpenAICompletionsStreamOptions } from '@renderer/types/aiCoreTypes' import { formatApiHost, formatAzureOpenAIApiHost, @@ -147,6 +148,10 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A baseURL: baseURL, apiKey: actualProvider.apiKey } + let includeUsage: OpenAICompletionsStreamOptions['include_usage'] = undefined + if (isSupportStreamOptionsProvider(actualProvider)) { + includeUsage = store.getState().settings.openAI?.streamOptions?.includeUsage + } const isCopilotProvider = actualProvider.id === SystemProviderIds.copilot if (isCopilotProvider) { @@ -158,7 +163,7 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A ...actualProvider.extra_headers }, name: actualProvider.id, - includeUsage: true + includeUsage }) return { @@ -261,7 +266,7 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A ...options, name: actualProvider.id, ...extraOptions, - includeUsage: isSupportStreamOptionsProvider(actualProvider) + includeUsage } } } diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index 46350b085f..1e74db24df 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -37,7 +37,7 @@ import { getStoreSetting } from '@renderer/hooks/useSettings' import { getAssistantSettings, getProviderByModel } from '@renderer/services/AssistantService' import type { Assistant, Model } from '@renderer/types' import { EFFORT_RATIO, isSystemProvider, SystemProviderIds } from '@renderer/types' -import type { OpenAISummaryText } from '@renderer/types/aiCoreTypes' +import type { OpenAIReasoningSummary } from '@renderer/types/aiCoreTypes' import type { ReasoningEffortOptionalParams } from '@renderer/types/sdk' import { isSupportEnableThinkingProvider } from '@renderer/utils/provider' import { toInteger } from 'lodash' @@ -448,7 +448,7 @@ export function getOpenAIReasoningParams( const openAI = getStoreSetting('openAI') const summaryText = openAI.summaryText - let reasoningSummary: OpenAISummaryText = undefined + let reasoningSummary: OpenAIReasoningSummary = undefined if (model.id.includes('o1-pro')) { reasoningSummary = undefined diff --git a/src/renderer/src/assets/images/apps/gemini.png b/src/renderer/src/assets/images/apps/gemini.png index 63c4207896..df8b95ced9 100644 Binary files a/src/renderer/src/assets/images/apps/gemini.png and b/src/renderer/src/assets/images/apps/gemini.png differ diff --git a/src/renderer/src/assets/images/models/gemini.png b/src/renderer/src/assets/images/models/gemini.png index 63c4207896..df8b95ced9 100644 Binary files a/src/renderer/src/assets/images/models/gemini.png and b/src/renderer/src/assets/images/models/gemini.png differ diff --git a/src/renderer/src/config/constant.ts b/src/renderer/src/config/constant.ts index 9903ce2db3..958f9bd202 100644 --- a/src/renderer/src/config/constant.ts +++ b/src/renderer/src/config/constant.ts @@ -5,6 +5,7 @@ export const SYSTEM_PROMPT_THRESHOLD = 128 export const DEFAULT_KNOWLEDGE_DOCUMENT_COUNT = 6 export const DEFAULT_KNOWLEDGE_THRESHOLD = 0.0 export const DEFAULT_WEBSEARCH_RAG_DOCUMENT_COUNT = 1 +export const DEFAULT_STREAM_OPTIONS_INCLUDE_USAGE = true export const platform = window.electron?.process?.platform export const isMac = platform === 'darwin' diff --git a/src/renderer/src/config/minapps.ts b/src/renderer/src/config/minapps.ts index 815b3f4760..81a4a98723 100644 --- a/src/renderer/src/config/minapps.ts +++ b/src/renderer/src/config/minapps.ts @@ -101,7 +101,8 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ id: 'gemini', name: 'Gemini', url: 'https://gemini.google.com/', - logo: GeminiAppLogo + logo: GeminiAppLogo, + bodered: true }, { id: 'silicon', diff --git a/src/renderer/src/config/models/logo.ts b/src/renderer/src/config/models/logo.ts index 77f4f5fb9d..64ba94b470 100644 --- a/src/renderer/src/config/models/logo.ts +++ b/src/renderer/src/config/models/logo.ts @@ -163,6 +163,7 @@ import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png' import type { Model } from '@renderer/types' export function getModelLogoById(modelId: string): string | undefined { + // FIXME: This is always true. Either remove it or fetch it. const isLight = true if (!modelId) { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index e24f207946..b7dd0a74cb 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1162,6 +1162,7 @@ "no_results": "No results", "none": "None", "off": "Off", + "on": "On", "open": "Open", "paste": "Paste", "placeholders": { @@ -4281,6 +4282,12 @@ "tip": "Specifies the latency tier to use for processing the request", "title": "Service Tier" }, + "stream_options": { + "include_usage": { + "tip": "Whether token usage is included (applicable only to the OpenAI Chat Completions API)", + "title": "Include usage" + } + }, "summary_text_mode": { "auto": "auto", "concise": "concise", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 42c7d34721..8003d93f59 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1162,6 +1162,7 @@ "no_results": "无结果", "none": "无", "off": "关闭", + "on": "启用", "open": "打开", "paste": "粘贴", "placeholders": { @@ -4281,6 +4282,12 @@ "tip": "指定用于处理请求的延迟层级", "title": "服务层级" }, + "stream_options": { + "include_usage": { + "tip": "是否请求 Tokens 用量(仅 OpenAI Chat Completions API 可用)", + "title": "包含用量" + } + }, "summary_text_mode": { "auto": "自动", "concise": "简洁", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 19e2a1e4a0..82c6c943ac 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1162,6 +1162,7 @@ "no_results": "沒有結果", "none": "無", "off": "關閉", + "on": "開啟", "open": "開啟", "paste": "貼上", "placeholders": { @@ -4281,6 +4282,12 @@ "tip": "指定用於處理請求的延遲層級", "title": "服務層級" }, + "stream_options": { + "include_usage": { + "tip": "是否請求 Tokens 用量(僅 OpenAI Chat Completions API 可用)", + "title": "包含用量" + } + }, "summary_text_mode": { "auto": "自動", "concise": "簡潔", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 529a5aa461..a27480cef3 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -1162,6 +1162,7 @@ "no_results": "Keine Ergebnisse", "none": "Keine", "off": "Aus", + "on": "An", "open": "Öffnen", "paste": "Einfügen", "placeholders": { @@ -4281,6 +4282,12 @@ "tip": "Latenz-Ebene für Anfrageverarbeitung festlegen", "title": "Service-Tier" }, + "stream_options": { + "include_usage": { + "tip": "Ob die Token-Nutzung enthalten ist (gilt nur für die OpenAI Chat Completions API)", + "title": "Nutzung einbeziehen" + } + }, "summary_text_mode": { "auto": "Automatisch", "concise": "Kompakt", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 1fe3ea9bc3..45003dfad4 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -1162,6 +1162,7 @@ "no_results": "Δεν βρέθηκαν αποτελέσματα", "none": "Χωρίς", "off": "Κλειστό", + "on": "Ενεργό", "open": "Άνοιγμα", "paste": "Επικόλληση", "placeholders": { @@ -4281,6 +4282,12 @@ "tip": "Καθορίστε το επίπεδο καθυστέρησης που χρησιμοποιείται για την επεξεργασία των αιτημάτων", "title": "Επίπεδο υπηρεσίας" }, + "stream_options": { + "include_usage": { + "tip": "Είτε περιλαμβάνεται η χρήση διακριτικών (ισχύει μόνο για το OpenAI Chat Completions API)", + "title": "Συμπεριλάβετε χρήση" + } + }, "summary_text_mode": { "auto": "Αυτόματο", "concise": "Σύντομο", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 55a2094ff0..6e94ce89df 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -1162,6 +1162,7 @@ "no_results": "Sin resultados", "none": "无", "off": "Apagado", + "on": "En", "open": "Abrir", "paste": "Pegar", "placeholders": { @@ -4281,6 +4282,12 @@ "tip": "Especifica el nivel de latencia utilizado para procesar la solicitud", "title": "Nivel de servicio" }, + "stream_options": { + "include_usage": { + "tip": "Si se incluye el uso de tokens (aplicable solo a la API de Completions de chat de OpenAI)", + "title": "Incluir uso" + } + }, "summary_text_mode": { "auto": "Automático", "concise": "Conciso", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index d005156e1a..851fa131c5 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -1162,6 +1162,7 @@ "no_results": "Aucun résultat", "none": "Aucun", "off": "Désactivé", + "on": "Marche", "open": "Ouvrir", "paste": "Coller", "placeholders": { @@ -4281,6 +4282,12 @@ "tip": "Spécifie le niveau de latence utilisé pour traiter la demande", "title": "Niveau de service" }, + "stream_options": { + "include_usage": { + "tip": "Si l'utilisation des jetons est incluse (applicable uniquement à l'API OpenAI Chat Completions)", + "title": "Inclure l'utilisation" + } + }, "summary_text_mode": { "auto": "Automatique", "concise": "Concis", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 04b4092857..8745381182 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -1162,6 +1162,7 @@ "no_results": "検索結果なし", "none": "無", "off": "オフ", + "on": "オン", "open": "開く", "paste": "貼り付け", "placeholders": { @@ -4281,6 +4282,12 @@ "tip": "リクエスト処理に使用するレイテンシティアを指定します", "title": "サービスティア" }, + "stream_options": { + "include_usage": { + "tip": "トークン使用量が含まれるかどうか (OpenAI Chat Completions APIのみに適用)", + "title": "使用法を含める" + } + }, "summary_text_mode": { "auto": "自動", "concise": "簡潔", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 90acd9563a..3c07d3cacf 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -1162,6 +1162,7 @@ "no_results": "Nenhum resultado", "none": "Nenhum", "off": "Desligado", + "on": "Ligado", "open": "Abrir", "paste": "Colar", "placeholders": { @@ -4281,6 +4282,12 @@ "tip": "Especifique o nível de latência usado para processar a solicitação", "title": "Nível de Serviço" }, + "stream_options": { + "include_usage": { + "tip": "Se o uso de tokens está incluído (aplicável apenas à API de Conclusões de Chat da OpenAI)", + "title": "Incluir uso" + } + }, "summary_text_mode": { "auto": "Automático", "concise": "Conciso", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index f744668860..1a695c893f 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -1162,6 +1162,7 @@ "no_results": "Результатов не найдено", "none": "без", "off": "Выкл", + "on": "Вкл", "open": "Открыть", "paste": "Вставить", "placeholders": { @@ -4281,6 +4282,12 @@ "tip": "Указывает уровень задержки, который следует использовать для обработки запроса", "title": "Уровень сервиса" }, + "stream_options": { + "include_usage": { + "tip": "Включено ли использование токенов (применимо только к API завершения чата OpenAI)", + "title": "Включить использование" + } + }, "summary_text_mode": { "auto": "Авто", "concise": "Краткий", diff --git a/src/renderer/src/pages/code/index.ts b/src/renderer/src/pages/code/index.ts index 17c74903b2..dcc9f43534 100644 --- a/src/renderer/src/pages/code/index.ts +++ b/src/renderer/src/pages/code/index.ts @@ -62,7 +62,7 @@ export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => { const CODE_TOOLS_API_ENDPOINTS = { aihubmix: { gemini: { - api_base_url: 'https://api.aihubmix.com/gemini' + api_base_url: 'https://aihubmix.com/gemini' } }, deepseek: { diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index 57dac8c78a..014897ce9c 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -56,7 +56,11 @@ import type { Assistant, AssistantSettings, CodeStyleVarious, MathEngine } from import { isGroqSystemProvider, ThemeMode } from '@renderer/types' import { modalConfirm } from '@renderer/utils' import { getSendMessageShortcutLabel } from '@renderer/utils/input' -import { isSupportServiceTierProvider, isSupportVerbosityProvider } from '@renderer/utils/provider' +import { + isOpenAICompatibleProvider, + isSupportServiceTierProvider, + isSupportVerbosityProvider +} from '@renderer/utils/provider' import { Button, Col, InputNumber, Row, Slider, Switch } from 'antd' import { Settings2 } from 'lucide-react' import type { FC } from 'react' @@ -184,6 +188,7 @@ const SettingsTab: FC = (props) => { const model = assistant.model || getDefaultModel() const showOpenAiSettings = + isOpenAICompatibleProvider(provider) || isOpenAIModel(model) || isSupportServiceTierProvider(provider) || (isSupportVerbosityModel(model) && isSupportVerbosityProvider(provider)) diff --git a/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx deleted file mode 100644 index 35c943e21b..0000000000 --- a/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import Selector from '@renderer/components/Selector' -import { - getModelSupportedVerbosity, - isSupportedReasoningEffortOpenAIModel, - isSupportFlexServiceTierModel, - isSupportVerbosityModel -} from '@renderer/config/models' -import { useProvider } from '@renderer/hooks/useProvider' -import { SettingDivider, SettingRow } from '@renderer/pages/settings' -import { CollapsibleSettingGroup } from '@renderer/pages/settings/SettingGroup' -import type { RootState } from '@renderer/store' -import { useAppDispatch } from '@renderer/store' -import { setOpenAISummaryText, setOpenAIVerbosity } from '@renderer/store/settings' -import type { Model, OpenAIServiceTier, ServiceTier } from '@renderer/types' -import { SystemProviderIds } from '@renderer/types' -import type { OpenAISummaryText, OpenAIVerbosity } from '@renderer/types/aiCoreTypes' -import { isSupportServiceTierProvider, isSupportVerbosityProvider } from '@renderer/utils/provider' -import { toOptionValue, toRealValue } from '@renderer/utils/select' -import { Tooltip } from 'antd' -import { CircleHelp } from 'lucide-react' -import type { FC } from 'react' -import { useCallback, useEffect, useMemo } from 'react' -import { useTranslation } from 'react-i18next' -import { useSelector } from 'react-redux' - -type VerbosityOption = { - value: NonNullable | 'undefined' | 'null' - label: string -} - -type SummaryTextOption = { - value: NonNullable | 'undefined' | 'null' - label: string -} - -type OpenAIServiceTierOption = { value: NonNullable | 'null' | 'undefined'; label: string } - -interface Props { - model: Model - providerId: string - SettingGroup: FC<{ children: React.ReactNode }> - SettingRowTitleSmall: FC<{ children: React.ReactNode }> -} - -const OpenAISettingsGroup: FC = ({ model, providerId, SettingGroup, SettingRowTitleSmall }) => { - const { t } = useTranslation() - const { provider, updateProvider } = useProvider(providerId) - const verbosity = useSelector((state: RootState) => state.settings.openAI.verbosity) - const summaryText = useSelector((state: RootState) => state.settings.openAI.summaryText) - const serviceTierMode = provider.serviceTier - const dispatch = useAppDispatch() - - const showSummarySetting = - isSupportedReasoningEffortOpenAIModel(model) && - !model.id.includes('o1-pro') && - (provider.type === 'openai-response' || model.endpoint_type === 'openai-response' || provider.id === 'aihubmix') - const showVerbositySetting = isSupportVerbosityModel(model) && isSupportVerbosityProvider(provider) - const isSupportFlexServiceTier = isSupportFlexServiceTierModel(model) - const isSupportServiceTier = isSupportServiceTierProvider(provider) - const showServiceTierSetting = isSupportServiceTier && providerId !== SystemProviderIds.groq - - const setSummaryText = useCallback( - (value: OpenAISummaryText) => { - dispatch(setOpenAISummaryText(value)) - }, - [dispatch] - ) - - const setServiceTierMode = useCallback( - (value: ServiceTier) => { - updateProvider({ serviceTier: value }) - }, - [updateProvider] - ) - - const setVerbosity = useCallback( - (value: OpenAIVerbosity) => { - dispatch(setOpenAIVerbosity(value)) - }, - [dispatch] - ) - - const summaryTextOptions = [ - { - value: 'undefined', - label: t('common.ignore') - }, - { - value: 'null', - label: t('common.off') - }, - { - value: 'auto', - label: t('settings.openai.summary_text_mode.auto') - }, - { - value: 'detailed', - label: t('settings.openai.summary_text_mode.detailed') - }, - { - value: 'concise', - label: t('settings.openai.summary_text_mode.concise') - } - ] as const satisfies SummaryTextOption[] - - const verbosityOptions = useMemo(() => { - const allOptions = [ - { - value: 'undefined', - label: t('common.ignore') - }, - { - value: 'null', - label: t('common.off') - }, - { - value: 'low', - label: t('settings.openai.verbosity.low') - }, - { - value: 'medium', - label: t('settings.openai.verbosity.medium') - }, - { - value: 'high', - label: t('settings.openai.verbosity.high') - } - ] as const satisfies VerbosityOption[] - const supportedVerbosityLevels = getModelSupportedVerbosity(model).map((v) => toOptionValue(v)) - return allOptions.filter((option) => supportedVerbosityLevels.includes(option.value)) - }, [model, t]) - - const serviceTierOptions = useMemo(() => { - const options = [ - { - value: 'undefined', - label: t('common.ignore') - }, - { - value: 'null', - label: t('common.off') - }, - { - value: 'auto', - label: t('settings.openai.service_tier.auto') - }, - { - value: 'default', - label: t('settings.openai.service_tier.default') - }, - { - value: 'flex', - label: t('settings.openai.service_tier.flex') - }, - { - value: 'priority', - label: t('settings.openai.service_tier.priority') - } - ] as const satisfies OpenAIServiceTierOption[] - return options.filter((option) => { - if (option.value === 'flex') { - return isSupportFlexServiceTier - } - return true - }) - }, [isSupportFlexServiceTier, t]) - - useEffect(() => { - if (verbosity && !verbosityOptions.some((option) => option.value === verbosity)) { - const supportedVerbosityLevels = getModelSupportedVerbosity(model) - // Default to the highest supported verbosity level - const defaultVerbosity = supportedVerbosityLevels[supportedVerbosityLevels.length - 1] - setVerbosity(defaultVerbosity) - } - }, [model, verbosity, verbosityOptions, setVerbosity]) - - if (!showSummarySetting && !showServiceTierSetting && !showVerbositySetting) { - return null - } - - return ( - - - {showServiceTierSetting && ( - <> - - - {t('settings.openai.service_tier.title')}{' '} - - - - - { - setServiceTierMode(toRealValue(value)) - }} - options={serviceTierOptions} - /> - - {(showSummarySetting || showVerbositySetting) && } - - )} - {showSummarySetting && ( - <> - - - {t('settings.openai.summary_text_mode.title')}{' '} - - - - - { - setSummaryText(toRealValue(value)) - }} - options={summaryTextOptions} - /> - - {showVerbositySetting && } - - )} - {showVerbositySetting && ( - - - {t('settings.openai.verbosity.title')}{' '} - - - - - { - setVerbosity(toRealValue(value)) - }} - options={verbosityOptions} - /> - - )} - - - - ) -} - -export default OpenAISettingsGroup diff --git a/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/OpenAISettingsGroup.tsx b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/OpenAISettingsGroup.tsx new file mode 100644 index 0000000000..2aa24f94f7 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/OpenAISettingsGroup.tsx @@ -0,0 +1,72 @@ +import { isSupportedReasoningEffortOpenAIModel, isSupportVerbosityModel } from '@renderer/config/models' +import { useProvider } from '@renderer/hooks/useProvider' +import { SettingDivider } from '@renderer/pages/settings' +import { CollapsibleSettingGroup } from '@renderer/pages/settings/SettingGroup' +import type { Model } from '@renderer/types' +import { SystemProviderIds } from '@renderer/types' +import { + isSupportServiceTierProvider, + isSupportStreamOptionsProvider, + isSupportVerbosityProvider +} from '@renderer/utils/provider' +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' + +import ReasoningSummarySetting from './ReasoningSummarySetting' +import ServiceTierSetting from './ServiceTierSetting' +import StreamOptionsSetting from './StreamOptionsSetting' +import VerbositySetting from './VerbositySetting' + +interface Props { + model: Model + providerId: string + SettingGroup: FC<{ children: React.ReactNode }> + SettingRowTitleSmall: FC<{ children: React.ReactNode }> +} + +const OpenAISettingsGroup: FC = ({ model, providerId, SettingGroup, SettingRowTitleSmall }) => { + const { t } = useTranslation() + const { provider } = useProvider(providerId) + + const showSummarySetting = + isSupportedReasoningEffortOpenAIModel(model) && + !model.id.includes('o1-pro') && + (provider.type === 'openai-response' || model.endpoint_type === 'openai-response' || provider.id === 'aihubmix') + const showVerbositySetting = isSupportVerbosityModel(model) && isSupportVerbosityProvider(provider) + const isSupportServiceTier = isSupportServiceTierProvider(provider) + const showServiceTierSetting = isSupportServiceTier && providerId !== SystemProviderIds.groq + const showStreamOptionsSetting = isSupportStreamOptionsProvider(provider) + + if (!showSummarySetting && !showServiceTierSetting && !showVerbositySetting && !showStreamOptionsSetting) { + return null + } + + return ( + + + {showServiceTierSetting && ( + <> + + {(showSummarySetting || showVerbositySetting || showStreamOptionsSetting) && } + + )} + {showSummarySetting && ( + <> + + {(showVerbositySetting || showStreamOptionsSetting) && } + + )} + {showVerbositySetting && ( + <> + + {showStreamOptionsSetting && } + + )} + {showStreamOptionsSetting && } + + + + ) +} + +export default OpenAISettingsGroup diff --git a/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/ReasoningSummarySetting.tsx b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/ReasoningSummarySetting.tsx new file mode 100644 index 0000000000..3754fef0b3 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/ReasoningSummarySetting.tsx @@ -0,0 +1,78 @@ +import Selector from '@renderer/components/Selector' +import { SettingRow } from '@renderer/pages/settings' +import type { RootState } from '@renderer/store' +import { useAppDispatch } from '@renderer/store' +import { setOpenAISummaryText } from '@renderer/store/settings' +import type { OpenAIReasoningSummary } from '@renderer/types/aiCoreTypes' +import { toOptionValue, toRealValue } from '@renderer/utils/select' +import { Tooltip } from 'antd' +import { CircleHelp } from 'lucide-react' +import type { FC } from 'react' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' + +type SummaryTextOption = { + value: NonNullable | 'undefined' | 'null' + label: string +} + +interface Props { + SettingRowTitleSmall: FC<{ children: React.ReactNode }> +} + +const ReasoningSummarySetting: FC = ({ SettingRowTitleSmall }) => { + const { t } = useTranslation() + const summaryText = useSelector((state: RootState) => state.settings.openAI.summaryText) + const dispatch = useAppDispatch() + + const setSummaryText = useCallback( + (value: OpenAIReasoningSummary) => { + dispatch(setOpenAISummaryText(value)) + }, + [dispatch] + ) + + const summaryTextOptions = [ + { + value: 'undefined', + label: t('common.ignore') + }, + { + value: 'null', + label: t('common.off') + }, + { + value: 'auto', + label: t('settings.openai.summary_text_mode.auto') + }, + { + value: 'detailed', + label: t('settings.openai.summary_text_mode.detailed') + }, + { + value: 'concise', + label: t('settings.openai.summary_text_mode.concise') + } + ] as const satisfies SummaryTextOption[] + + return ( + + + {t('settings.openai.summary_text_mode.title')}{' '} + + + + + { + setSummaryText(toRealValue(value)) + }} + options={summaryTextOptions} + /> + + ) +} + +export default ReasoningSummarySetting diff --git a/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/ServiceTierSetting.tsx b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/ServiceTierSetting.tsx new file mode 100644 index 0000000000..114ff0da34 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/ServiceTierSetting.tsx @@ -0,0 +1,88 @@ +import Selector from '@renderer/components/Selector' +import { isSupportFlexServiceTierModel } from '@renderer/config/models' +import { useProvider } from '@renderer/hooks/useProvider' +import { SettingRow } from '@renderer/pages/settings' +import type { Model, OpenAIServiceTier, ServiceTier } from '@renderer/types' +import { toOptionValue, toRealValue } from '@renderer/utils/select' +import { Tooltip } from 'antd' +import { CircleHelp } from 'lucide-react' +import type { FC } from 'react' +import { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +type OpenAIServiceTierOption = { value: NonNullable | 'null' | 'undefined'; label: string } + +interface Props { + model: Model + providerId: string + SettingRowTitleSmall: FC<{ children: React.ReactNode }> +} + +const ServiceTierSetting: FC = ({ model, providerId, SettingRowTitleSmall }) => { + const { t } = useTranslation() + const { provider, updateProvider } = useProvider(providerId) + const serviceTierMode = provider.serviceTier + const isSupportFlexServiceTier = isSupportFlexServiceTierModel(model) + + const setServiceTierMode = useCallback( + (value: ServiceTier) => { + updateProvider({ serviceTier: value }) + }, + [updateProvider] + ) + + const serviceTierOptions = useMemo(() => { + const options = [ + { + value: 'undefined', + label: t('common.ignore') + }, + { + value: 'null', + label: t('common.off') + }, + { + value: 'auto', + label: t('settings.openai.service_tier.auto') + }, + { + value: 'default', + label: t('settings.openai.service_tier.default') + }, + { + value: 'flex', + label: t('settings.openai.service_tier.flex') + }, + { + value: 'priority', + label: t('settings.openai.service_tier.priority') + } + ] as const satisfies OpenAIServiceTierOption[] + return options.filter((option) => { + if (option.value === 'flex') { + return isSupportFlexServiceTier + } + return true + }) + }, [isSupportFlexServiceTier, t]) + + return ( + + + {t('settings.openai.service_tier.title')}{' '} + + + + + { + setServiceTierMode(toRealValue(value)) + }} + options={serviceTierOptions} + /> + + ) +} + +export default ServiceTierSetting diff --git a/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/StreamOptionsSetting.tsx b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/StreamOptionsSetting.tsx new file mode 100644 index 0000000000..b9de0fe818 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/StreamOptionsSetting.tsx @@ -0,0 +1,72 @@ +import Selector from '@renderer/components/Selector' +import { SettingRow } from '@renderer/pages/settings' +import type { RootState } from '@renderer/store' +import { useAppDispatch } from '@renderer/store' +import { setOpenAIStreamOptionsIncludeUsage } from '@renderer/store/settings' +import type { OpenAICompletionsStreamOptions } from '@renderer/types/aiCoreTypes' +import { toOptionValue, toRealValue } from '@renderer/utils/select' +import { Tooltip } from 'antd' +import { CircleHelp } from 'lucide-react' +import type { FC } from 'react' +import { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' + +type IncludeUsageOption = { + value: 'undefined' | 'false' | 'true' + label: string +} + +interface Props { + SettingRowTitleSmall: FC<{ children: React.ReactNode }> +} + +const StreamOptionsSetting: FC = ({ SettingRowTitleSmall }) => { + const { t } = useTranslation() + const includeUsage = useSelector((state: RootState) => state.settings.openAI?.streamOptions?.includeUsage) + const dispatch = useAppDispatch() + + const setIncludeUsage = useCallback( + (value: OpenAICompletionsStreamOptions['include_usage']) => { + dispatch(setOpenAIStreamOptionsIncludeUsage(value)) + }, + [dispatch] + ) + + const includeUsageOptions = useMemo(() => { + return [ + { + value: 'undefined', + label: t('common.ignore') + }, + { + value: 'false', + label: t('common.off') + }, + { + value: 'true', + label: t('common.on') + } + ] as const satisfies IncludeUsageOption[] + }, [t]) + + return ( + + + {t('settings.openai.stream_options.include_usage.title')}{' '} + + + + + { + setIncludeUsage(toRealValue(value)) + }} + options={includeUsageOptions} + /> + + ) +} + +export default StreamOptionsSetting diff --git a/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/VerbositySetting.tsx b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/VerbositySetting.tsx new file mode 100644 index 0000000000..550f8d4433 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/VerbositySetting.tsx @@ -0,0 +1,94 @@ +import Selector from '@renderer/components/Selector' +import { getModelSupportedVerbosity } from '@renderer/config/models' +import { SettingRow } from '@renderer/pages/settings' +import type { RootState } from '@renderer/store' +import { useAppDispatch } from '@renderer/store' +import { setOpenAIVerbosity } from '@renderer/store/settings' +import type { Model } from '@renderer/types' +import type { OpenAIVerbosity } from '@renderer/types/aiCoreTypes' +import { toOptionValue, toRealValue } from '@renderer/utils/select' +import { Tooltip } from 'antd' +import { CircleHelp } from 'lucide-react' +import type { FC } from 'react' +import { useCallback, useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' + +type VerbosityOption = { + value: NonNullable | 'undefined' | 'null' + label: string +} + +interface Props { + model: Model + SettingRowTitleSmall: FC<{ children: React.ReactNode }> +} + +const VerbositySetting: FC = ({ model, SettingRowTitleSmall }) => { + const { t } = useTranslation() + const verbosity = useSelector((state: RootState) => state.settings.openAI.verbosity) + const dispatch = useAppDispatch() + + const setVerbosity = useCallback( + (value: OpenAIVerbosity) => { + dispatch(setOpenAIVerbosity(value)) + }, + [dispatch] + ) + + const verbosityOptions = useMemo(() => { + const allOptions = [ + { + value: 'undefined', + label: t('common.ignore') + }, + { + value: 'null', + label: t('common.off') + }, + { + value: 'low', + label: t('settings.openai.verbosity.low') + }, + { + value: 'medium', + label: t('settings.openai.verbosity.medium') + }, + { + value: 'high', + label: t('settings.openai.verbosity.high') + } + ] as const satisfies VerbosityOption[] + const supportedVerbosityLevels = getModelSupportedVerbosity(model).map((v) => toOptionValue(v)) + return allOptions.filter((option) => supportedVerbosityLevels.includes(option.value)) + }, [model, t]) + + useEffect(() => { + if (verbosity !== undefined && !verbosityOptions.some((option) => option.value === toOptionValue(verbosity))) { + const supportedVerbosityLevels = getModelSupportedVerbosity(model) + // Default to the highest supported verbosity level + const defaultVerbosity = supportedVerbosityLevels[supportedVerbosityLevels.length - 1] + setVerbosity(defaultVerbosity) + } + }, [model, verbosity, verbosityOptions, setVerbosity]) + + return ( + + + {t('settings.openai.verbosity.title')}{' '} + + + + + { + setVerbosity(toRealValue(value)) + }} + options={verbosityOptions} + /> + + ) +} + +export default VerbositySetting diff --git a/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/index.tsx b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/index.tsx new file mode 100644 index 0000000000..18492971be --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/index.tsx @@ -0,0 +1,3 @@ +import OpenAISettingsGroup from './OpenAISettingsGroup' + +export default OpenAISettingsGroup diff --git a/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/EditModelPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/EditModelPopup.tsx index ee82e16ef0..78d906d1e5 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/EditModelPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/EditModelPopup.tsx @@ -2,8 +2,6 @@ import { TopView } from '@renderer/components/TopView' import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant' import { useProvider } from '@renderer/hooks/useProvider' import ModelEditContent from '@renderer/pages/settings/ProviderSettings/EditModelPopup/ModelEditContent' -import { useAppDispatch } from '@renderer/store' -import { setModel } from '@renderer/store/assistants' import type { Model, Provider } from '@renderer/types' import React, { useCallback, useState } from 'react' @@ -19,9 +17,9 @@ interface Props extends ShowParams { const PopupContainer: React.FC = ({ provider: _provider, model, resolve }) => { const [open, setOpen] = useState(true) const { provider, updateProvider, models } = useProvider(_provider.id) - const { assistants } = useAssistants() - const { defaultModel, setDefaultModel } = useDefaultModel() - const dispatch = useAppDispatch() + const { assistants, updateAssistants } = useAssistants() + const { defaultModel, setDefaultModel, translateModel, setTranslateModel, quickModel, setQuickModel } = + useDefaultModel() const onOk = () => { setOpen(false) @@ -42,22 +40,46 @@ const PopupContainer: React.FC = ({ provider: _provider, model, resolve } updateProvider({ models: updatedModels }) - assistants.forEach((assistant) => { - if (assistant?.model?.id === updatedModel.id && assistant.model.provider === provider.id) { - dispatch( - setModel({ - assistantId: assistant.id, - model: updatedModel - }) - ) - } - }) + updateAssistants( + assistants.map((a) => { + let model = a.model + let defaultModel = a.defaultModel + if (a.model?.id === updatedModel.id && a.model.provider === provider.id) { + model = updatedModel + } + if (a.defaultModel?.id === updatedModel.id && a.defaultModel?.provider === provider.id) { + defaultModel = updatedModel + } + return { ...a, model, defaultModel } + }) + ) if (defaultModel?.id === updatedModel.id && defaultModel?.provider === provider.id) { setDefaultModel(updatedModel) } + if (translateModel?.id === updatedModel.id && translateModel?.provider === provider.id) { + setTranslateModel(updatedModel) + } + if (quickModel?.id === updatedModel.id && quickModel?.provider === provider.id) { + setQuickModel(updatedModel) + } }, - [models, updateProvider, provider.id, assistants, defaultModel, dispatch, setDefaultModel] + [ + models, + updateProvider, + updateAssistants, + assistants, + defaultModel?.id, + defaultModel?.provider, + provider.id, + translateModel?.id, + translateModel?.provider, + quickModel?.id, + quickModel?.provider, + setDefaultModel, + setTranslateModel, + setQuickModel + ] ) return ( diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 516d66cdc3..8d9176be15 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -67,7 +67,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 181, + version: 182, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index d10e2dfcbd..a80336e697 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1,6 +1,11 @@ import { loggerService } from '@logger' import { nanoid } from '@reduxjs/toolkit' -import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE, isMac } from '@renderer/config/constant' +import { + DEFAULT_CONTEXTCOUNT, + DEFAULT_STREAM_OPTIONS_INCLUDE_USAGE, + DEFAULT_TEMPERATURE, + isMac +} from '@renderer/config/constant' import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { glm45FlashModel, @@ -2945,6 +2950,10 @@ const migrateConfig = { model.provider = SystemProviderIds.gateway } }) + // @ts-ignore + if (provider.type === 'ai-gateway') { + provider.type = 'gateway' + } }) logger.info('migrate 181 success') return state @@ -2952,6 +2961,21 @@ const migrateConfig = { logger.error('migrate 181 error', error as Error) return state } + }, + '182': (state: RootState) => { + try { + // Initialize streamOptions in settings.openAI if not exists + if (!state.settings.openAI.streamOptions) { + state.settings.openAI.streamOptions = { + includeUsage: DEFAULT_STREAM_OPTIONS_INCLUDE_USAGE + } + } + logger.info('migrate 182 success') + return state + } catch (error) { + logger.error('migrate 182 error', error as Error) + return state + } } } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 36a478853a..572f722746 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -1,6 +1,6 @@ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' -import { isMac } from '@renderer/config/constant' +import { DEFAULT_STREAM_OPTIONS_INCLUDE_USAGE, isMac } from '@renderer/config/constant' import { TRANSLATE_PROMPT } from '@renderer/config/prompts' import { DEFAULT_SIDEBAR_ICONS } from '@renderer/config/sidebar' import type { @@ -16,7 +16,11 @@ import type { TranslateLanguageCode } from '@renderer/types' import { ThemeMode } from '@renderer/types' -import type { OpenAISummaryText, OpenAIVerbosity } from '@renderer/types/aiCoreTypes' +import type { + OpenAICompletionsStreamOptions, + OpenAIReasoningSummary, + OpenAIVerbosity +} from '@renderer/types/aiCoreTypes' import { uuid } from '@renderer/utils' import { API_SERVER_DEFAULTS, UpgradeChannel } from '@shared/config/constant' @@ -193,10 +197,14 @@ export interface SettingsState { } // OpenAI openAI: { - summaryText: OpenAISummaryText + // TODO: it's a bad naming. rename it to reasoningSummary in v2. + summaryText: OpenAIReasoningSummary /** @deprecated 现在该设置迁移到Provider对象中 */ serviceTier: OpenAIServiceTier verbosity: OpenAIVerbosity + streamOptions: { + includeUsage: OpenAICompletionsStreamOptions['include_usage'] + } } // Notification notification: { @@ -376,7 +384,10 @@ export const initialState: SettingsState = { openAI: { summaryText: 'auto', serviceTier: 'auto', - verbosity: undefined + verbosity: undefined, + streamOptions: { + includeUsage: DEFAULT_STREAM_OPTIONS_INCLUDE_USAGE + } }, notification: { assistant: false, @@ -791,12 +802,18 @@ const settingsSlice = createSlice({ setDisableHardwareAcceleration: (state, action: PayloadAction) => { state.disableHardwareAcceleration = action.payload }, - setOpenAISummaryText: (state, action: PayloadAction) => { + setOpenAISummaryText: (state, action: PayloadAction) => { state.openAI.summaryText = action.payload }, setOpenAIVerbosity: (state, action: PayloadAction) => { state.openAI.verbosity = action.payload }, + setOpenAIStreamOptionsIncludeUsage: ( + state, + action: PayloadAction + ) => { + state.openAI.streamOptions.includeUsage = action.payload + }, setNotificationSettings: (state, action: PayloadAction) => { state.notification = action.payload }, @@ -967,6 +984,7 @@ export const { setDisableHardwareAcceleration, setOpenAISummaryText, setOpenAIVerbosity, + setOpenAIStreamOptionsIncludeUsage, setNotificationSettings, // Local backup settings setLocalBackupDir, diff --git a/src/renderer/src/types/aiCoreTypes.ts b/src/renderer/src/types/aiCoreTypes.ts index 6281905cbb..28250e4053 100644 --- a/src/renderer/src/types/aiCoreTypes.ts +++ b/src/renderer/src/types/aiCoreTypes.ts @@ -50,7 +50,12 @@ export type OpenAIReasoningEffort = OpenAI.ReasoningEffort * When undefined, the parameter is omitted from the request. * When null, verbosity is explicitly disabled. */ -export type OpenAISummaryText = OpenAI.Reasoning['summary'] +export type OpenAIReasoningSummary = OpenAI.Reasoning['summary'] + +/** + * Options for streaming response. Only set this when you set `stream: true`. + */ +export type OpenAICompletionsStreamOptions = OpenAI.ChatCompletionStreamOptions const AiSdkParamsSchema = z.enum([ 'maxOutputTokens', diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 5b72a4181c..128d2be707 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -439,6 +439,7 @@ export type MinAppType = { name: string logo?: string url: string + // FIXME: It should be `bordered` bodered?: boolean background?: string style?: CSSProperties diff --git a/src/renderer/src/utils/__tests__/select.test.ts b/src/renderer/src/utils/__tests__/select.test.ts new file mode 100644 index 0000000000..36e7d95ac9 --- /dev/null +++ b/src/renderer/src/utils/__tests__/select.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' + +import { toOptionValue, toRealValue } from '../select' + +describe('toOptionValue', () => { + describe('primitive values', () => { + it('should convert undefined to string "undefined"', () => { + expect(toOptionValue(undefined)).toBe('undefined') + }) + + it('should convert null to string "null"', () => { + expect(toOptionValue(null)).toBe('null') + }) + + it('should convert true to string "true"', () => { + expect(toOptionValue(true)).toBe('true') + }) + + it('should convert false to string "false"', () => { + expect(toOptionValue(false)).toBe('false') + }) + }) + + describe('string values', () => { + it('should return string as-is', () => { + expect(toOptionValue('hello')).toBe('hello') + }) + + it('should return empty string as-is', () => { + expect(toOptionValue('')).toBe('') + }) + + it('should return string with special characters as-is', () => { + expect(toOptionValue('hello-world_123')).toBe('hello-world_123') + }) + + it('should return string that looks like a boolean as-is', () => { + expect(toOptionValue('True')).toBe('True') + expect(toOptionValue('FALSE')).toBe('FALSE') + }) + }) + + describe('mixed type scenarios', () => { + it('should handle union types correctly', () => { + const values: Array = ['test', true, false, null, undefined, ''] + + expect(toOptionValue(values[0])).toBe('test') + expect(toOptionValue(values[1])).toBe('true') + expect(toOptionValue(values[2])).toBe('false') + expect(toOptionValue(values[3])).toBe('null') + expect(toOptionValue(values[4])).toBe('undefined') + expect(toOptionValue(values[5])).toBe('') + }) + }) +}) + +describe('toRealValue', () => { + describe('special string values', () => { + it('should convert string "undefined" to undefined', () => { + expect(toRealValue('undefined')).toBeUndefined() + }) + + it('should convert string "null" to null', () => { + expect(toRealValue('null')).toBeNull() + }) + + it('should convert string "true" to boolean true', () => { + expect(toRealValue('true')).toBe(true) + }) + + it('should convert string "false" to boolean false', () => { + expect(toRealValue('false')).toBe(false) + }) + }) + + describe('regular string values', () => { + it('should return regular string as-is', () => { + expect(toRealValue('hello')).toBe('hello') + }) + + it('should return empty string as-is', () => { + expect(toRealValue('')).toBe('') + }) + + it('should return string with special characters as-is', () => { + expect(toRealValue('hello-world_123')).toBe('hello-world_123') + }) + + it('should return string that looks like special value but with different casing', () => { + expect(toRealValue('Undefined')).toBe('Undefined') + expect(toRealValue('NULL')).toBe('NULL') + expect(toRealValue('True')).toBe('True') + expect(toRealValue('False')).toBe('False') + }) + }) + + describe('edge cases', () => { + it('should handle strings containing special values as substring', () => { + expect(toRealValue('undefined_value')).toBe('undefined_value') + expect(toRealValue('null_check')).toBe('null_check') + expect(toRealValue('true_condition')).toBe('true_condition') + expect(toRealValue('false_flag')).toBe('false_flag') + }) + + it('should handle strings with whitespace', () => { + expect(toRealValue(' undefined')).toBe(' undefined') + expect(toRealValue('null ')).toBe('null ') + expect(toRealValue(' true ')).toBe(' true ') + }) + }) +}) + +describe('toOptionValue and toRealValue roundtrip', () => { + it('should correctly convert and restore undefined', () => { + const original = undefined + const option = toOptionValue(original) + const restored = toRealValue(option) + expect(restored).toBeUndefined() + }) + + it('should correctly convert and restore null', () => { + const original = null + const option = toOptionValue(original) + const restored = toRealValue(option) + expect(restored).toBeNull() + }) + + it('should correctly convert and restore true', () => { + const original = true + const option = toOptionValue(original) + const restored = toRealValue(option) + expect(restored).toBe(true) + }) + + it('should correctly convert and restore false', () => { + const original = false + const option = toOptionValue(original) + const restored = toRealValue(option) + expect(restored).toBe(false) + }) + + it('should correctly convert and restore string values', () => { + const strings = ['hello', '', 'test-123', 'some_value'] + strings.forEach((str) => { + const option = toOptionValue(str) + const restored = toRealValue(option) + expect(restored).toBe(str) + }) + }) + + it('should handle array of mixed values', () => { + const values: Array = ['test', true, false, null, undefined] + + const options = values.map(toOptionValue) + const restored = options.map(toRealValue) + + expect(restored[0]).toBe('test') + expect(restored[1]).toBe(true) + expect(restored[2]).toBe(false) + expect(restored[3]).toBeNull() + expect(restored[4]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/utils/oauth.ts b/src/renderer/src/utils/oauth.ts index 00c5493343..9df68c0c21 100644 --- a/src/renderer/src/utils/oauth.ts +++ b/src/renderer/src/utils/oauth.ts @@ -201,7 +201,7 @@ export const providerCharge = async (provider: string) => { height: 700 }, aihubmix: { - url: `https://aihubmix.com/topup?client_id=cherry_studio_oauth&lang=${getLanguageCode()}&aff=SJyh`, + url: `https://console.aihubmix.com/topup?client_id=cherry_studio_oauth&lang=${getLanguageCode()}&aff=SJyh`, width: 720, height: 900 }, @@ -244,7 +244,7 @@ export const providerBills = async (provider: string) => { height: 700 }, aihubmix: { - url: `https://aihubmix.com/statistics?client_id=cherry_studio_oauth&lang=${getLanguageCode()}&aff=SJyh`, + url: `https://console.aihubmix.com/statistics?client_id=cherry_studio_oauth&lang=${getLanguageCode()}&aff=SJyh`, width: 900, height: 700 }, diff --git a/src/renderer/src/utils/select.ts b/src/renderer/src/utils/select.ts index cf1eaa19d8..07e24b00b2 100644 --- a/src/renderer/src/utils/select.ts +++ b/src/renderer/src/utils/select.ts @@ -1,36 +1,63 @@ /** - * Convert a value (string | undefined | null) into an option-compatible string. + * Convert a value (string | undefined | null | boolean) into an option-compatible string. * - `undefined` becomes the literal string `'undefined'` * - `null` becomes the literal string `'null'` + * - `true` becomes the literal string `'true'` + * - `false` becomes the literal string `'false'` * - Any other string is returned as-is * * @param v - The value to convert * @returns The string representation safe for option usage */ -export function toOptionValue>(v: T): NonNullable | 'undefined' -export function toOptionValue>(v: T): NonNullable | 'null' -export function toOptionValue(v: T): NonNullable | 'undefined' | 'null' -export function toOptionValue>(v: T): T -export function toOptionValue(v: string | undefined | null) { - if (v === undefined) return 'undefined' - if (v === null) return 'null' - return v +export function toOptionValue(v: undefined): 'undefined' +export function toOptionValue(v: null): 'null' +export function toOptionValue(v: boolean): 'true' | 'false' +export function toOptionValue(v: boolean | undefined): 'true' | 'false' | 'undefined' +export function toOptionValue(v: boolean | null): 'true' | 'false' | 'null' +export function toOptionValue(v: boolean | undefined | null): 'true' | 'false' | 'undefined' | 'null' +export function toOptionValue(v: T): T +export function toOptionValue | undefined>(v: T): NonNullable | 'undefined' +export function toOptionValue | null>(v: T): NonNullable | 'null' +export function toOptionValue | boolean>(v: T): T | 'true' | 'false' +export function toOptionValue | null | undefined>( + v: T +): NonNullable | 'null' | 'undefined' +export function toOptionValue | null | boolean>( + v: T +): NonNullable | 'null' | 'true' | 'false' +export function toOptionValue | undefined | boolean>( + v: T +): NonNullable | 'undefined' | 'true' | 'false' +export function toOptionValue< + T extends Exclude | null | undefined | boolean +>(v: T): NonNullable | 'null' | 'undefined' | 'true' | 'false' +export function toOptionValue(v: string | undefined | null | boolean) { + return String(v) } /** * Convert an option string back to its original value. * - The literal string `'undefined'` becomes `undefined` * - The literal string `'null'` becomes `null` + * - The literal string `'true'` becomes `true` + * - The literal string `'false'` becomes `false` * - Any other string is returned as-is * * @param v - The option string to convert - * @returns The real value (`undefined`, `null`, or the original string) + * @returns The real value (`undefined`, `null`, `boolean`, or the original string) */ -export function toRealValue(v: T): undefined -export function toRealValue(v: T): null -export function toRealValue(v: T): Exclude +export function toRealValue(v: 'undefined'): undefined +export function toRealValue(v: 'null'): null +export function toRealValue(v: 'true' | 'false'): boolean +export function toRealValue(v: 'undefined' | 'null'): undefined | null +export function toRealValue(v: 'undefined' | 'true' | 'false'): undefined | boolean +export function toRealValue(v: 'null' | 'true' | 'false'): null | boolean +export function toRealValue(v: 'undefined' | 'null' | 'true' | 'false'): undefined | null | boolean +export function toRealValue(v: T): Exclude export function toRealValue(v: string) { if (v === 'undefined') return undefined if (v === 'null') return null + if (v === 'true') return true + if (v === 'false') return false return v }