diff --git a/.github/workflows/auto-i18n.yml b/.github/workflows/auto-i18n.yml index ea9f05ae03..6141c061fa 100644 --- a/.github/workflows/auto-i18n.yml +++ b/.github/workflows/auto-i18n.yml @@ -23,7 +23,7 @@ jobs: steps: - name: 🐈‍⬛ Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index cc6d28817f..cc24438768 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 1 diff --git a/.github/workflows/claude-translator.yml b/.github/workflows/claude-translator.yml index 23f359021d..71c2e0b87f 100644 --- a/.github/workflows/claude-translator.yml +++ b/.github/workflows/claude-translator.yml @@ -32,7 +32,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 1 diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 82c7b4393b..be018fb5bb 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -37,7 +37,7 @@ jobs: actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 1 diff --git a/.github/workflows/github-issue-tracker.yml b/.github/workflows/github-issue-tracker.yml index 32bd393145..a628f9f13c 100644 --- a/.github/workflows/github-issue-tracker.yml +++ b/.github/workflows/github-issue-tracker.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Check Beijing Time id: check_time @@ -42,7 +42,7 @@ jobs: - name: Add pending label if in quiet hours if: steps.check_time.outputs.should_delay == 'true' - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | github.rest.issues.addLabels({ @@ -118,7 +118,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 523a670064..eb28b91c63 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -51,7 +51,7 @@ jobs: steps: - name: Check out Git repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: main diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index ebd871b3f3..49a37c9f1e 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Check out Git repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8bbb46ee67..4488b1b9d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Check out Git repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/update-app-upgrade-config.yml b/.github/workflows/update-app-upgrade-config.yml index 7470bb0b6c..8b0b198008 100644 --- a/.github/workflows/update-app-upgrade-config.yml +++ b/.github/workflows/update-app-upgrade-config.yml @@ -19,10 +19,9 @@ on: permissions: contents: write - pull-requests: write jobs: - propose-update: + update-config: runs-on: ubuntu-latest if: github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.draft == false) @@ -135,7 +134,7 @@ jobs: - name: Checkout default branch if: steps.check.outputs.should_run == 'true' - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ github.event.repository.default_branch }} path: main @@ -143,7 +142,7 @@ jobs: - name: Checkout x-files/app-upgrade-config branch if: steps.check.outputs.should_run == 'true' - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: x-files/app-upgrade-config path: cs @@ -187,25 +186,20 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" fi - - name: Create pull request + - name: Commit and push changes if: steps.check.outputs.should_run == 'true' && steps.diff.outputs.changed == 'true' - uses: peter-evans/create-pull-request@v7 - with: - path: cs - base: x-files/app-upgrade-config - branch: chore/update-app-upgrade-config/${{ steps.meta.outputs.safe_tag }} - commit-message: "🤖 chore: sync app-upgrade-config for ${{ steps.meta.outputs.tag }}" - title: "chore: update app-upgrade-config for ${{ steps.meta.outputs.tag }}" - body: | - Automated update triggered by `${{ steps.meta.outputs.trigger }}`. + working-directory: cs + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add app-upgrade-config.json + git commit -m "chore: sync app-upgrade-config for ${{ steps.meta.outputs.tag }}" -m "Automated update triggered by \`${{ steps.meta.outputs.trigger }}\`. - - Source tag: `${{ steps.meta.outputs.tag }}` - - Pre-release: `${{ steps.meta.outputs.prerelease }}` - - Latest: `${{ steps.meta.outputs.latest }}` - - Workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - labels: | - automation - app-upgrade + - Source tag: \`${{ steps.meta.outputs.tag }}\` + - Pre-release: \`${{ steps.meta.outputs.prerelease }}\` + - Latest: \`${{ steps.meta.outputs.latest }}\` + - Workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + git push origin x-files/app-upgrade-config - name: No changes detected if: steps.check.outputs.should_run == 'true' && steps.diff.outputs.changed != 'true' diff --git a/electron-builder.yml b/electron-builder.yml index f118f49752..d736d99b3b 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -135,108 +135,66 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - A New Era of Intelligence with Cherry Studio 1.7.1 + Cherry Studio 1.7.2 - Stability & Enhancement Update - Today we're releasing Cherry Studio 1.7.1 — our most ambitious update yet, introducing Agent: autonomous AI that thinks, plans, and acts. + This release focuses on stability improvements, bug fixes, and quality-of-life enhancements. - For years, AI assistants have been reactive — waiting for your commands, responding to your questions. With Agent, we're changing that. Now, AI can truly work alongside you: understanding complex goals, breaking them into steps, and executing them independently. + 🔧 Improvements + - Enhanced update dialog functionality and state management + - Improved ImageViewer context menu UX + - Better temperature and top_p parameter handling + - User-configurable stream options for OpenAI API + - Translation feature now supports document files - This is what we've been building toward. And it's just the beginning. + 🤖 AI & Models + - Added explicit thinking token support for Gemini 3 Pro Image + - Updated DeepSeek logic to match DeepSeek v3.2 + - Updated AiOnly default models + - Updated AI model configurations to latest versions - 🤖 Meet Agent - Imagine having a brilliant colleague who never sleeps. Give Agent a goal — write a report, analyze data, refactor code — and watch it work. It reasons through problems, breaks them into steps, calls the right tools, and adapts when things change. + ♿ Accessibility + - Improved screen reader (NVDA) support with aria-label attributes + - Added Slovak language support for spell check - - **Think → Plan → Act**: From goal to execution, fully autonomous - - **Deep Reasoning**: Multi-turn thinking that solves real problems - - **Tool Mastery**: File operations, web search, code execution, and more - - **Skill Plugins**: Extend with custom commands and capabilities - - **You Stay in Control**: Real-time approval for sensitive actions - - **Full Visibility**: Every thought, every decision, fully transparent - - 🌐 Expanding Ecosystem - - **New Providers**: HuggingFace, Mistral, CherryIN, AI Gateway, Intel OVMS, Didi MCP - - **New Models**: Claude 4.5 Haiku, DeepSeek v3.2, GLM-4.6, Doubao, Ling series - - **MCP Integration**: Alibaba Cloud, ModelScope, Higress, MCP.so, TokenFlux and more - - 📚 Smarter Knowledge Base - - **OpenMinerU**: Self-hosted document processing - - **Full-Text Search**: Find anything instantly across your notes - - **Enhanced Tool Selection**: Smarter configuration for better AI assistance - - 📝 Notes, Reimagined - - Full-text search with highlighted results - - AI-powered smart rename - - Export as image - - Auto-wrap for tables - - 🖼️ Image & OCR - - Intel OVMS painting capabilities - - Intel OpenVINO NPU-accelerated OCR - - 🌍 Now in 10+ Languages - - Added German support - - Enhanced internationalization - - ⚡ Faster & More Polished - - Electron 38 upgrade - - New MCP management interface - - Dozens of UI refinements - - ❤️ Fully Open Source - Commercial restrictions removed. Cherry Studio now follows standard AGPL v3 — free for teams of any size. - - The Agent Era is here. We can't wait to see what you'll create. + 🐛 Bug Fixes + - Fixed Quick Assistant shortcut registration issue + - Fixed UI freeze on multi-file selection via batch processing + - Fixed assistant default model update when editing model capabilities + - Fixed provider handling and API key rotation logic + - Fixed OVMS API URL path formation + - Fixed custom parameters placement for Vercel AI Gateway + - Fixed topic message blocks clearing + - Fixed input bar blocking enter send while generating - Cherry Studio 1.7.1:开启智能新纪元 + Cherry Studio 1.7.2 - 稳定性与功能增强更新 - 今天,我们正式发布 Cherry Studio 1.7.1 —— 迄今最具雄心的版本,带来全新的 Agent:能够自主思考、规划和行动的 AI。 + 本次更新专注于稳定性改进、问题修复和用户体验提升。 - 多年来,AI 助手一直是被动的——等待你的指令,回应你的问题。Agent 改变了这一切。现在,AI 能够真正与你并肩工作:理解复杂目标,将其拆解为步骤,并独立执行。 + 🔧 功能改进 + - 增强更新对话框功能和状态管理 + - 优化图片查看器右键菜单体验 + - 改进温度和 top_p 参数处理逻辑 + - 支持用户自定义 OpenAI API 流式选项 + - 翻译功能现已支持文档文件 - 这是我们一直在构建的未来。而这,仅仅是开始。 + 🤖 AI 与模型 + - 为 Gemini 3 Pro Image 添加显式思考 token 支持 + - 更新 DeepSeek 逻辑以适配 DeepSeek v3.2 + - 更新 AiOnly 默认模型 + - 更新 AI 模型配置至最新版本 - 🤖 认识 Agent - 想象一位永不疲倦的得力伙伴。给 Agent 一个目标——撰写报告、分析数据、重构代码——然后看它工作。它会推理问题、拆解步骤、调用工具,并在情况变化时灵活应对。 + ♿ 无障碍支持 + - 改进屏幕阅读器 (NVDA) 支持,添加 aria-label 属性 + - 新增斯洛伐克语拼写检查支持 - - **思考 → 规划 → 行动**:从目标到执行,全程自主 - - **深度推理**:多轮思考,解决真实问题 - - **工具大师**:文件操作、网络搜索、代码执行,样样精通 - - **技能插件**:自定义命令,无限扩展 - - **你掌控全局**:敏感操作,实时审批 - - **完全透明**:每一步思考,每一个决策,清晰可见 - - 🌐 生态持续壮大 - - **新增服务商**:Hugging Face、Mistral、Perplexity、SophNet、AI Gateway、Cerebras AI - - **新增模型**:Gemini 3、Gemini 3 Pro(支持图像预览)、GPT-5.1、Claude Opus 4.5 - - **MCP 集成**:百炼、魔搭、Higress、MCP.so、TokenFlux 等平台 - - 📚 更智能的知识库 - - **OpenMinerU**:本地自部署文档处理 - - **全文搜索**:笔记内容一搜即达 - - **增强工具选择**:更智能的配置,更好的 AI 协助 - - 📝 笔记,焕然一新 - - 全文搜索,结果高亮 - - AI 智能重命名 - - 导出为图片 - - 表格自动换行 - - 🖼️ 图像与 OCR - - Intel OVMS 绘图能力 - - Intel OpenVINO NPU 加速 OCR - - 🌍 支持 10+ 种语言 - - 新增德语支持 - - 全面增强国际化 - - ⚡ 更快、更精致 - - 升级 Electron 38 - - 新的 MCP 管理界面 - - 数十处 UI 细节打磨 - - ❤️ 完全开源 - 商用限制已移除。Cherry Studio 现遵循标准 AGPL v3 协议——任意规模团队均可自由使用。 - - Agent 纪元已至。期待你的创造。 + 🐛 问题修复 + - 修复快捷助手无法注册快捷键的问题 + - 修复多文件选择时 UI 冻结问题(通过批处理优化) + - 修复编辑模型能力时助手默认模型更新问题 + - 修复服务商处理和 API 密钥轮换逻辑 + - 修复 OVMS API URL 路径格式问题 + - 修复 Vercel AI Gateway 自定义参数位置问题 + - 修复话题消息块清理问题 + - 修复生成时输入框阻止回车发送的问题 diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 47eb320efb..38a979135c 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -196,6 +196,9 @@ export enum IpcChannel { File_ValidateNotesDirectory = 'file:validateNotesDirectory', File_StartWatcher = 'file:startWatcher', File_StopWatcher = 'file:stopWatcher', + File_PauseWatcher = 'file:pauseWatcher', + File_ResumeWatcher = 'file:resumeWatcher', + File_BatchUploadMarkdown = 'file:batchUploadMarkdown', File_ShowInFolder = 'file:showInFolder', // file service @@ -301,6 +304,8 @@ export enum IpcChannel { Selection_ActionWindowClose = 'selection:action-window-close', Selection_ActionWindowMinimize = 'selection:action-window-minimize', Selection_ActionWindowPin = 'selection:action-window-pin', + // [Windows only] Electron bug workaround - can be removed once https://github.com/electron/electron/issues/48554 is fixed + Selection_ActionWindowResize = 'selection:action-window-resize', Selection_ProcessAction = 'selection:process-action', Selection_UpdateActionData = 'selection:update-action-data', diff --git a/packages/shared/config/types.ts b/packages/shared/config/types.ts index 5c42f1d2b2..8fba6399f8 100644 --- a/packages/shared/config/types.ts +++ b/packages/shared/config/types.ts @@ -10,7 +10,7 @@ export type LoaderReturn = { messageSource?: 'preprocess' | 'embedding' | 'validation' } -export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir' +export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir' | 'refresh' export type FileChangeEvent = { eventType: FileChangeEventType diff --git a/packages/shared/data/cache/cacheSchemas.ts b/packages/shared/data/cache/cacheSchemas.ts index c77f890f8e..93cd35c054 100644 --- a/packages/shared/data/cache/cacheSchemas.ts +++ b/packages/shared/data/cache/cacheSchemas.ts @@ -58,7 +58,8 @@ export const DefaultUseCache: UseCacheSchema = { downloading: false, downloaded: false, downloadProgress: 0, - available: false + available: false, + ignore: false }, 'app.user.avatar': '', diff --git a/packages/shared/data/cache/cacheValueTypes.ts b/packages/shared/data/cache/cacheValueTypes.ts index 095f46a131..a012241615 100644 --- a/packages/shared/data/cache/cacheValueTypes.ts +++ b/packages/shared/data/cache/cacheValueTypes.ts @@ -8,6 +8,7 @@ export type CacheAppUpdateState = { downloaded: boolean downloadProgress: number available: boolean + ignore: boolean } export type CacheActiveSearches = Record diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 850c117094..f04a917435 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -8,7 +8,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 { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant' @@ -500,35 +500,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 } }) @@ -596,6 +578,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager)) ipcMain.handle(IpcChannel.File_StartWatcher, fileManager.startFileWatcher.bind(fileManager)) 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)) + ipcMain.handle(IpcChannel.File_BatchUploadMarkdown, fileManager.batchUploadMarkdownFiles.bind(fileManager)) 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 3165fcf27e..81f5c15bd9 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -151,6 +151,7 @@ class FileStorage { private currentWatchPath?: string private debounceTimer?: NodeJS.Timeout private watcherConfig: Required = DEFAULT_WATCHER_CONFIG + private isPaused = false constructor() { this.initStorageDir() @@ -478,13 +479,16 @@ class FileStorage { } } - public readFile = async ( - _: Electron.IpcMainInvokeEvent, - id: string, - detectEncoding: boolean = false - ): Promise => { - const filePath = path.join(this.storageDir, id) - + /** + * Core file reading logic that handles both documents and text files. + * + * @private + * @param filePath - Full path to the file + * @param detectEncoding - Whether to auto-detect text file encoding + * @returns Promise resolving to the extracted text content + * @throws Error if file reading fails + */ + private async readFileCore(filePath: string, detectEncoding: boolean = false): Promise { const fileExtension = path.extname(filePath) if (documentExts.includes(fileExtension)) { @@ -504,7 +508,7 @@ class FileStorage { return data } catch (error) { chdir(originalCwd) - logger.error('Failed to read file:', error as Error) + logger.error('Failed to read document file:', error as Error) throw error } } @@ -516,11 +520,72 @@ class FileStorage { return fs.readFileSync(filePath, 'utf-8') } } catch (error) { - logger.error('Failed to read file:', error as Error) + logger.error('Failed to read text file:', error as Error) throw new Error(`Failed to read file: ${filePath}.`) } } + /** + * Reads and extracts content from a stored file. + * + * Supports multiple file formats including: + * - Complex documents: .pdf, .doc, .docx, .pptx, .xlsx, .odt, .odp, .ods + * - Text files: .txt, .md, .json, .csv, etc. + * - Code files: .js, .ts, .py, .java, etc. + * + * For document formats, extracts text content using specialized parsers: + * - .doc files: Uses word-extractor library + * - Other Office formats: Uses officeparser library + * + * For text files, can optionally detect encoding automatically. + * + * @param _ - Electron IPC invoke event (unused) + * @param id - File identifier with extension (e.g., "uuid.docx") + * @param detectEncoding - Whether to auto-detect text file encoding (default: false) + * @returns Promise resolving to the extracted text content of the file + * @throws Error if file reading fails or file is not found + * + * @example + * // Read a DOCX file + * const content = await readFile(event, "document.docx"); + * + * @example + * // Read a text file with encoding detection + * const content = await readFile(event, "text.txt", true); + * + * @example + * // Read a PDF file + * const content = await readFile(event, "manual.pdf"); + */ + public readFile = async ( + _: Electron.IpcMainInvokeEvent, + id: string, + detectEncoding: boolean = false + ): Promise => { + const filePath = path.join(this.storageDir, id) + return this.readFileCore(filePath, detectEncoding) + } + + /** + * Reads and extracts content from an external file path. + * + * Similar to readFile, but operates on external file paths instead of stored files. + * Supports the same file formats including complex documents and text files. + * + * @param _ - Electron IPC invoke event (unused) + * @param filePath - Absolute path to the external file + * @param detectEncoding - Whether to auto-detect text file encoding (default: false) + * @returns Promise resolving to the extracted text content of the file + * @throws Error if file does not exist or reading fails + * + * @example + * // Read an external DOCX file + * const content = await readExternalFile(event, "/path/to/document.docx"); + * + * @example + * // Read an external text file with encoding detection + * const content = await readExternalFile(event, "/path/to/text.txt", true); + */ public readExternalFile = async ( _: Electron.IpcMainInvokeEvent, filePath: string, @@ -530,40 +595,7 @@ class FileStorage { throw new Error(`File does not exist: ${filePath}`) } - const fileExtension = path.extname(filePath) - - if (documentExts.includes(fileExtension)) { - const originalCwd = process.cwd() - try { - chdir(this.tempDir) - - if (fileExtension === '.doc') { - const extractor = new WordExtractor() - const extracted = await extractor.extract(filePath) - chdir(originalCwd) - return extracted.getBody() - } - - const data = await officeParser.parseOfficeAsync(filePath) - chdir(originalCwd) - return data - } catch (error) { - chdir(originalCwd) - logger.error('Failed to read file:', error as Error) - throw error - } - } - - try { - if (detectEncoding) { - return readTextFileWithAutoEncoding(filePath) - } else { - return fs.readFileSync(filePath, 'utf-8') - } - } catch (error) { - logger.error('Failed to read file:', error as Error) - throw new Error(`Failed to read file: ${filePath}.`) - } + return this.readFileCore(filePath, detectEncoding) } public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise => { @@ -1448,6 +1480,12 @@ class FileStorage { private createChangeHandler() { return (eventType: string, filePath: string) => { + // 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 } @@ -1605,6 +1643,165 @@ class FileStorage { logger.error('Failed to show item in folder:', error as Error) } } + + /** + * Batch upload markdown files from native File objects + * This handles all I/O operations in the Main process to avoid blocking Renderer + */ + public batchUploadMarkdownFiles = async ( + _: Electron.IpcMainInvokeEvent, + filePaths: string[], + targetPath: string + ): Promise<{ + fileCount: number + folderCount: number + skippedFiles: number + }> => { + try { + logger.info('Starting batch upload', { fileCount: filePaths.length, targetPath }) + + const basePath = path.resolve(targetPath) + const MARKDOWN_EXTS = ['.md', '.markdown'] + + // Filter markdown files + const markdownFiles = filePaths.filter((filePath) => { + const ext = path.extname(filePath).toLowerCase() + return MARKDOWN_EXTS.includes(ext) + }) + + const skippedFiles = filePaths.length - markdownFiles.length + + if (markdownFiles.length === 0) { + return { fileCount: 0, folderCount: 0, skippedFiles } + } + + // Collect unique folders needed + const foldersSet = new Set() + const fileOperations: Array<{ sourcePath: string; targetPath: string }> = [] + + for (const filePath of markdownFiles) { + try { + // Get relative path if file is from a directory upload + const fileName = path.basename(filePath) + const relativePath = path.dirname(filePath) + + // Determine target directory structure + let targetDir = basePath + const folderParts: string[] = [] + + // Extract folder structure from file path for nested uploads + // This is a simplified version - in real scenario we'd need the original directory structure + if (relativePath && relativePath !== '.') { + const parts = relativePath.split(path.sep) + // Get the last few parts that represent the folder structure within upload + const relevantParts = parts.slice(Math.max(0, parts.length - 3)) + folderParts.push(...relevantParts) + } + + // Build target directory path + for (const part of folderParts) { + targetDir = path.join(targetDir, part) + foldersSet.add(targetDir) + } + + // Determine final file name + const nameWithoutExt = fileName.endsWith('.md') + ? fileName.slice(0, -3) + : fileName.endsWith('.markdown') + ? fileName.slice(0, -9) + : fileName + + const { safeName } = await this.fileNameGuard(_, targetDir, nameWithoutExt, true) + const finalPath = path.join(targetDir, safeName + '.md') + + fileOperations.push({ sourcePath: filePath, targetPath: finalPath }) + } catch (error) { + logger.error('Failed to prepare file operation:', error as Error, { filePath }) + } + } + + // Create folders in order (shallow to deep) + const sortedFolders = Array.from(foldersSet).sort((a, b) => a.length - b.length) + for (const folder of sortedFolders) { + try { + if (!fs.existsSync(folder)) { + await fs.promises.mkdir(folder, { recursive: true }) + } + } catch (error) { + logger.debug('Folder already exists or creation failed', { folder, error: (error as Error).message }) + } + } + + // Process files in batches + const BATCH_SIZE = 10 // Higher batch size since we're in Main process + let successCount = 0 + + for (let i = 0; i < fileOperations.length; i += BATCH_SIZE) { + const batch = fileOperations.slice(i, i + BATCH_SIZE) + + const results = await Promise.allSettled( + batch.map(async (op) => { + // Read from source and write to target in Main process + const content = await fs.promises.readFile(op.sourcePath, 'utf-8') + await fs.promises.writeFile(op.targetPath, content, 'utf-8') + return true + }) + ) + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + successCount++ + } else { + logger.error('Failed to upload file:', result.reason, { + file: batch[index].sourcePath + }) + } + }) + } + + logger.info('Batch upload completed', { + successCount, + folderCount: foldersSet.size, + skippedFiles + }) + + return { + fileCount: successCount, + folderCount: foldersSet.size, + skippedFiles + } + } catch (error) { + logger.error('Batch upload failed:', error as Error) + throw error + } + } + + /** + * Pause file watcher to prevent events during batch operations + */ + public pauseFileWatcher = async (): Promise => { + if (this.watcher) { + logger.debug('Pausing file watcher') + this.isPaused = true + // Clear any pending debounced notifications + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + this.debounceTimer = undefined + } + } + } + + /** + * Resume file watcher and trigger a refresh + */ + public resumeFileWatcher = async (): Promise => { + if (this.watcher && this.currentWatchPath) { + logger.debug('Resuming file watcher') + this.isPaused = false + // Send a synthetic refresh event to trigger tree reload + this.notifyChange('refresh', this.currentWatchPath) + } + } } export const fileStorage = new FileStorage() diff --git a/src/main/services/SelectionService.ts b/src/main/services/SelectionService.ts index 96dda36eb6..aeafce5be5 100644 --- a/src/main/services/SelectionService.ts +++ b/src/main/services/SelectionService.ts @@ -1398,6 +1398,50 @@ export class SelectionService { actionWindow.setAlwaysOnTop(isPinned) } + /** + * [Windows only] Manual window resize handler + * + * ELECTRON BUG WORKAROUND: + * In Electron, when using `frame: false` + `transparent: true`, the native window + * resize functionality is broken on Windows. This is a known Electron bug. + * See: https://github.com/electron/electron/issues/48554 + * + * This method can be removed once the Electron bug is fixed. + */ + public resizeActionWindow(actionWindow: BrowserWindow, deltaX: number, deltaY: number, direction: string): void { + const bounds = actionWindow.getBounds() + const minWidth = 300 + const minHeight = 200 + + let { x, y, width, height } = bounds + + // Handle horizontal resize + if (direction.includes('e')) { + width = Math.max(minWidth, width + deltaX) + } + if (direction.includes('w')) { + const newWidth = Math.max(minWidth, width - deltaX) + if (newWidth !== width) { + x = x + (width - newWidth) + width = newWidth + } + } + + // Handle vertical resize + if (direction.includes('s')) { + height = Math.max(minHeight, height + deltaY) + } + if (direction.includes('n')) { + const newHeight = Math.max(minHeight, height - deltaY) + if (newHeight !== height) { + y = y + (height - newHeight) + height = newHeight + } + } + + actionWindow.setBounds({ x, y, width, height }) + } + /** * Update trigger mode behavior * Switches between selection-based and alt-key based triggering @@ -1494,6 +1538,18 @@ export class SelectionService { } }) + // [Windows only] Electron bug workaround - can be removed once fixed + // See: https://github.com/electron/electron/issues/48554 + ipcMain.handle( + IpcChannel.Selection_ActionWindowResize, + (event, deltaX: number, deltaY: number, direction: string) => { + const actionWindow = BrowserWindow.fromWebContents(event.sender) + if (actionWindow) { + selectionService?.resizeActionWindow(actionWindow, deltaX, deltaY, direction) + } + } + ) + this.isIpcHandlerRegistered = true } diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 4f4e93256b..536c164a0d 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -35,6 +35,15 @@ function getShortcutHandler(shortcut: Shortcut) { } case 'mini_window': return () => { + // 在处理器内部检查QuickAssistant状态,而不是在注册时检查 + const quickAssistantEnabled = preferenceService.get('feature.quick_assistant.enabled') + logger.info(`mini_window shortcut triggered, QuickAssistant enabled: ${quickAssistantEnabled}`) + + if (!quickAssistantEnabled) { + logger.warn('QuickAssistant is disabled, ignoring mini_window shortcut trigger') + return + } + windowService.toggleMiniWindow() } case 'selection_assistant_toggle': @@ -190,11 +199,10 @@ export function registerShortcuts(window: BrowserWindow) { break case 'mini_window': - //available only when QuickAssistant enabled - if (!preferenceService.get('feature.quick_assistant.enabled')) { - return - } + // 移除注册时的条件检查,在处理器内部进行检查 + logger.info(`Processing mini_window shortcut, enabled: ${shortcut.enabled}`) showMiniWindowAccelerator = formatShortcutKey(shortcut.shortcut) + logger.debug(`Mini window accelerator set to: ${showMiniWindowAccelerator}`) break case 'selection_assistant_toggle': diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index c21d336d5c..2210582fee 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/services/agents/BaseService.ts b/src/main/services/agents/BaseService.ts index 279962e792..a4c1a23240 100644 --- a/src/main/services/agents/BaseService.ts +++ b/src/main/services/agents/BaseService.ts @@ -76,7 +76,7 @@ export abstract class BaseService { * Get database instance * Automatically waits for initialization to complete */ - protected async getDatabase() { + public async getDatabase() { const dbManager = await DatabaseManager.getInstance() return dbManager.getDatabase() } 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/preload/index.ts b/src/preload/index.ts index 427e2e020c..fe84416766 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -227,6 +227,10 @@ const api = { startFileWatcher: (dirPath: string, config?: any) => ipcRenderer.invoke(IpcChannel.File_StartWatcher, dirPath, config), stopFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_StopWatcher), + pauseFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_PauseWatcher), + resumeFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_ResumeWatcher), + batchUploadMarkdown: (filePaths: string[], targetPath: string) => + ipcRenderer.invoke(IpcChannel.File_BatchUploadMarkdown, filePaths, targetPath), onFileChange: (callback: (data: FileChangeEvent) => void) => { const listener = (_event: Electron.IpcRendererEvent, data: any) => { if (data && typeof data === 'object') { @@ -450,7 +454,10 @@ const api = { ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem, isFullScreen), closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose), minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize), - pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned) + pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned), + // [Windows only] Electron bug workaround - can be removed once https://github.com/electron/electron/issues/48554 is fixed + resizeActionWindow: (deltaX: number, deltaY: number, direction: string) => + ipcRenderer.invoke(IpcChannel.Selection_ActionWindowResize, deltaX, deltaY, direction) }, agentTools: { respondToPermission: (payload: { diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts index b435b07c75..cd1e4411a0 100644 --- a/src/renderer/src/aiCore/index_new.ts +++ b/src/renderer/src/aiCore/index_new.ts @@ -120,9 +120,12 @@ export default class ModernAiProvider { throw new Error('Model is required for completions. Please use constructor with model parameter.') } - // 每次请求时重新生成配置以确保API key轮换生效 - this.config = providerToAiSdkConfig(this.actualProvider, this.model) - logger.debug('Generated provider config for completions', this.config) + // Config is now set in constructor, ApiService handles key rotation before passing provider + if (!this.config) { + // If config wasn't set in constructor (when provider only), generate it now + this.config = providerToAiSdkConfig(this.actualProvider, this.model!) + } + logger.debug('Using provider config for completions', this.config) // 检查 config 是否存在 if (!this.config) { diff --git a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts index d90acb7f59..d1101c4ca0 100644 --- a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts @@ -3,9 +3,10 @@ import { loggerService } from '@logger' import { getModelSupportedVerbosity, isFunctionCallingModel, - isNotSupportTemperatureAndTopP, isOpenAIModel, - isSupportFlexServiceTierModel + isSupportFlexServiceTierModel, + isSupportTemperatureModel, + isSupportTopPModel } from '@renderer/config/models' import { getLMStudioKeepAliveTime } from '@renderer/hooks/useLMStudio' import { getAssistantSettings } from '@renderer/services/AssistantService' @@ -200,7 +201,7 @@ export abstract class BaseApiClient< } public getTemperature(assistant: Assistant, model: Model): number | undefined { - if (isNotSupportTemperatureAndTopP(model)) { + if (!isSupportTemperatureModel(model)) { return undefined } const assistantSettings = getAssistantSettings(assistant) @@ -208,7 +209,7 @@ export abstract class BaseApiClient< } public getTopP(assistant: Assistant, model: Model): number | undefined { - if (isNotSupportTemperatureAndTopP(model)) { + if (!isSupportTopPModel(model)) { return undefined } const assistantSettings = getAssistantSettings(assistant) diff --git a/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts b/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts index 1a93baa2de..2be36057bf 100644 --- a/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts @@ -2,6 +2,7 @@ import type OpenAI from '@cherrystudio/openai' import { loggerService } from '@logger' import { isSupportedModel } from '@renderer/config/models' import { objectKeys, type Provider } from '@renderer/types' +import { formatApiHost, withoutTrailingApiVersion } from '@renderer/utils' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' @@ -15,11 +16,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/prepareParams/modelParameters.ts b/src/renderer/src/aiCore/prepareParams/modelParameters.ts index 8a1d53a754..34c3418287 100644 --- a/src/renderer/src/aiCore/prepareParams/modelParameters.ts +++ b/src/renderer/src/aiCore/prepareParams/modelParameters.ts @@ -4,60 +4,81 @@ */ import { - isClaude45ReasoningModel, isClaudeReasoningModel, isMaxTemperatureOneModel, - isNotSupportTemperatureAndTopP, isSupportedFlexServiceTier, - isSupportedThinkingTokenClaudeModel + isSupportedThinkingTokenClaudeModel, + isSupportTemperatureModel, + isSupportTopPModel, + isTemperatureTopPMutuallyExclusiveModel } from '@renderer/config/models' -import { getAssistantSettings, getProviderByModel } from '@renderer/services/AssistantService' +import { + DEFAULT_ASSISTANT_SETTINGS, + getAssistantSettings, + getProviderByModel +} from '@renderer/services/AssistantService' import type { Assistant, Model } from '@renderer/types' import { defaultTimeout } from '@shared/config/constant' import { getAnthropicThinkingBudget } from '../utils/reasoning' /** - * Claude 4.5 推理模型: - * - 只启用 temperature → 使用 temperature - * - 只启用 top_p → 使用 top_p - * - 同时启用 → temperature 生效,top_p 被忽略 - * - 都不启用 → 都不使用 - * 获取温度参数 + * Retrieves the temperature parameter, adapting it based on assistant.settings and model capabilities. + * - Disabled for Claude reasoning models when reasoning effort is set. + * - Disabled for models that do not support temperature. + * - Disabled for Claude 4.5 reasoning models when TopP is enabled and temperature is disabled. + * Otherwise, returns the temperature value if the assistant has temperature enabled. */ export function getTemperature(assistant: Assistant, model: Model): number | undefined { if (assistant.settings?.reasoning_effort && isClaudeReasoningModel(model)) { return undefined } + + if (!isSupportTemperatureModel(model)) { + return undefined + } + if ( - isNotSupportTemperatureAndTopP(model) || - (isClaude45ReasoningModel(model) && assistant.settings?.enableTopP && !assistant.settings?.enableTemperature) + isTemperatureTopPMutuallyExclusiveModel(model) && + assistant.settings?.enableTopP && + !assistant.settings?.enableTemperature ) { return undefined } + const assistantSettings = getAssistantSettings(assistant) let temperature = assistantSettings?.temperature if (temperature && isMaxTemperatureOneModel(model)) { temperature = Math.min(1, temperature) } - return assistantSettings?.enableTemperature ? temperature : undefined + + // FIXME: assistant.settings.enableTemperature should be always a boolean value. + const enableTemperature = assistantSettings?.enableTemperature ?? DEFAULT_ASSISTANT_SETTINGS.enableTemperature + return enableTemperature ? temperature : undefined } /** - * 获取 TopP 参数 + * Retrieves the TopP parameter, adapting it based on assistant.settings and model capabilities. + * - Disabled for Claude reasoning models when reasoning effort is set. + * - Disabled for models that do not support TopP. + * - Disabled for Claude 4.5 reasoning models when temperature is explicitly enabled. + * Otherwise, returns the TopP value if the assistant has TopP enabled. */ export function getTopP(assistant: Assistant, model: Model): number | undefined { if (assistant.settings?.reasoning_effort && isClaudeReasoningModel(model)) { return undefined } - if ( - isNotSupportTemperatureAndTopP(model) || - (isClaude45ReasoningModel(model) && assistant.settings?.enableTemperature) - ) { + if (!isSupportTopPModel(model)) { return undefined } + if (isTemperatureTopPMutuallyExclusiveModel(model) && assistant.settings?.enableTemperature) { + return undefined + } + const assistantSettings = getAssistantSettings(assistant) - return assistantSettings?.enableTopP ? assistantSettings?.topP : undefined + // FIXME: assistant.settings.enableTopP should be always a boolean value. + const enableTopP = assistantSettings.enableTopP ?? DEFAULT_ASSISTANT_SETTINGS.enableTopP + return enableTopP ? assistantSettings?.topP : undefined } /** 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 cdea346822..99e4fbd1c9 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -1,5 +1,4 @@ import { formatPrivateKey, hasProviderConfig, ProviderConfigFactory } from '@cherrystudio/ai-core/provider' -import { cacheService } from '@data/CacheService' import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models' import { getAwsBedrockAccessKeyId, @@ -12,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, @@ -38,32 +38,6 @@ import { azureAnthropicProviderCreator } from './config/azure-anthropic' import { COPILOT_DEFAULT_HEADERS } from './constants' import { getAiSdkProviderId } from './factory' -/** - * 获取轮询的API key - * 复用legacy架构的多key轮询逻辑 - */ -function getRotatedApiKey(provider: Provider): string { - const keys = provider.apiKey.split(',').map((key) => key.trim()) - const keyName = `provider:${provider.id}:last_used_key` - - if (keys.length === 1) { - return keys[0] - } - - const lastUsedKey = cacheService.getShared(keyName) as string | undefined - if (lastUsedKey === undefined) { - cacheService.setShared(keyName, keys[0]) - return keys[0] - } - - const currentIndex = keys.indexOf(lastUsedKey) - const nextIndex = (currentIndex + 1) % keys.length - const nextKey = keys[nextIndex] - cacheService.setShared(keyName, nextKey) - - return nextKey -} - /** * 处理特殊provider的转换逻辑 */ @@ -172,7 +146,11 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A const { baseURL, endpoint } = routeToEndpoint(actualProvider.apiHost) const baseConfig = { baseURL: baseURL, - apiKey: getRotatedApiKey(actualProvider) + 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 @@ -185,7 +163,7 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A ...actualProvider.extra_headers }, name: actualProvider.id, - includeUsage: true + includeUsage }) return { @@ -288,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/components/ImageViewer.tsx b/src/renderer/src/components/ImageViewer.tsx index 52cb2412f2..862e992e5b 100644 --- a/src/renderer/src/components/ImageViewer.tsx +++ b/src/renderer/src/components/ImageViewer.tsx @@ -14,7 +14,7 @@ import { convertImageToPng } from '@renderer/utils/image' import type { ImageProps as AntImageProps } from 'antd' import { Dropdown, Image as AntImage, Space } from 'antd' import { Base64 } from 'js-base64' -import { DownloadIcon, ImageIcon } from 'lucide-react' +import { DownloadIcon } from 'lucide-react' import mime from 'mime' import React from 'react' import { useTranslation } from 'react-i18next' @@ -73,9 +73,15 @@ const ImageViewer: React.FC = ({ src, style, ...props }) => { const getContextMenuItems = (src: string, size: number = 14) => { return [ { - key: 'copy-url', + key: 'copy-image', label: t('common.copy'), icon: , + onClick: () => handleCopyImage(src) + }, + { + key: 'copy-url', + label: t('preview.copy.src'), + icon: , onClick: () => { navigator.clipboard.writeText(src) window.toast.success(t('message.copy.success')) @@ -86,12 +92,6 @@ const ImageViewer: React.FC = ({ src, style, ...props }) => { label: t('common.download'), icon: , onClick: () => download(src) - }, - { - key: 'copy-image', - label: t('preview.copy.image'), - icon: , - onClick: () => handleCopyImage(src) } ] } diff --git a/src/renderer/src/components/Popups/UpdateDialogPopup.tsx b/src/renderer/src/components/Popups/UpdateDialogPopup.tsx index 29afcc0d24..593c882bf5 100644 --- a/src/renderer/src/components/Popups/UpdateDialogPopup.tsx +++ b/src/renderer/src/components/Popups/UpdateDialogPopup.tsx @@ -1,6 +1,7 @@ import { loggerService } from '@logger' import { TopView } from '@renderer/components/TopView' -import { handleSaveData } from '@renderer/store' +import { handleSaveData, useAppDispatch } from '@renderer/store' +import { setUpdateState } from '@renderer/store/runtime' import { Button, Modal } from 'antd' import type { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime' import { useEffect, useState } from 'react' @@ -22,6 +23,7 @@ const PopupContainer: React.FC = ({ releaseInfo, resolve }) => { const { t } = useTranslation() const [open, setOpen] = useState(true) const [isInstalling, setIsInstalling] = useState(false) + const dispatch = useAppDispatch() useEffect(() => { if (releaseInfo) { @@ -50,6 +52,11 @@ const PopupContainer: React.FC = ({ releaseInfo, resolve }) => { resolve({}) } + const onIgnore = () => { + dispatch(setUpdateState({ ignore: true })) + setOpen(false) + } + UpdateDialogPopup.hide = onCancel const releaseNotes = releaseInfo?.releaseNotes @@ -69,7 +76,7 @@ const PopupContainer: React.FC = ({ releaseInfo, resolve }) => { centered width={720} footer={[ - ,